Add frontend/modules/loyalty with types, API client, design system, feature pages and thin App Router routes under app/loyalty/. BFF proxy at app/api/loyalty/. Include loyalty frontend docs. Co-authored-by: Cursor <cursoragent@cursor.com>
768 lines
26 KiB
TypeScript
768 lines
26 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState, type ReactNode } from "react";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { toast } from "sonner";
|
||
import { Eye, Pencil, Plus, Trash2 } from "lucide-react";
|
||
import {
|
||
PageHeader,
|
||
Button,
|
||
Dialog,
|
||
Input,
|
||
Textarea,
|
||
DataTable,
|
||
EmptyState,
|
||
LoadingState,
|
||
ErrorState,
|
||
ConfirmDialog,
|
||
TableToolbar,
|
||
Select,
|
||
Pagination,
|
||
Tabs,
|
||
JalaliDateText,
|
||
} from "@/components/ds";
|
||
import {
|
||
DetailDrawer,
|
||
Timeline,
|
||
PermissionDeniedState,
|
||
type TimelineItem,
|
||
} from "@/src/shared/ui";
|
||
import { useDebouncedValue, useSavedFilters } from "@/src/shared/hooks";
|
||
import { useTenantId } from "@/hooks/useTenantId";
|
||
import { BeautyBusinessApiError } from "@/modules/beauty/services/beauty-business-api";
|
||
import { useBeautyPermissions } from "@/modules/beauty/hooks/useBeautyPermissions";
|
||
import { BeautyStatusChip } from "@/modules/beauty/design-system";
|
||
import {
|
||
OrganizationSelect,
|
||
BranchSelect,
|
||
StaffSelect,
|
||
CustomerSelect,
|
||
ServiceSelect,
|
||
CategorySelect,
|
||
RoomSelect,
|
||
} from "@/modules/beauty/components/BeautyComboboxes";
|
||
import { lifecycleStatusOptions } from "@/modules/beauty/constants/labels";
|
||
|
||
export type FieldType =
|
||
| "text"
|
||
| "textarea"
|
||
| "number"
|
||
| "datetime-local"
|
||
| "select"
|
||
| "organization"
|
||
| "branch"
|
||
| "staff"
|
||
| "customer"
|
||
| "service"
|
||
| "category"
|
||
| "room";
|
||
|
||
export type FieldDef = {
|
||
name: string;
|
||
label: string;
|
||
type?: FieldType;
|
||
required?: boolean;
|
||
placeholder?: string;
|
||
options?: { value: string; label: string }[];
|
||
table?: boolean | ((mode: "create" | "edit") => boolean);
|
||
form?: boolean | ((mode: "create" | "edit") => boolean);
|
||
render?: (row: Record<string, unknown>) => ReactNode;
|
||
};
|
||
|
||
export type FilterDef = {
|
||
id: string;
|
||
label: string;
|
||
options: { value: string; label: string }[];
|
||
};
|
||
|
||
export type WorkflowActionDef = {
|
||
id: string;
|
||
label: string;
|
||
variant?: "default" | "outline" | "danger";
|
||
confirmTitle?: string;
|
||
confirmDescription?: string;
|
||
visible?: (row: Record<string, unknown>) => boolean;
|
||
run: (row: Record<string, unknown>, tenantId: string) => Promise<unknown>;
|
||
};
|
||
|
||
export type ListParams = {
|
||
search?: string;
|
||
status?: string;
|
||
page?: number;
|
||
pageSize?: number;
|
||
[key: string]: string | number | undefined;
|
||
};
|
||
|
||
export type ResourceConfig = {
|
||
storageKey: string;
|
||
title: string;
|
||
description?: string;
|
||
viewPermission?: string;
|
||
managePermission?: string;
|
||
queryKey: string[];
|
||
listFn: (tenantId: string, params: ListParams) => Promise<unknown[] | { items: unknown[]; total: number }>;
|
||
createFn?: (tenantId: string, body: Record<string, unknown>) => Promise<unknown>;
|
||
updateFn?: (tenantId: string, id: string, body: Record<string, unknown>) => Promise<unknown>;
|
||
deleteFn?: (tenantId: string, id: string) => Promise<unknown>;
|
||
fields: FieldDef[];
|
||
filters?: FilterDef[];
|
||
workflowActions?: WorkflowActionDef[];
|
||
auditEntityType?: string;
|
||
auditFn?: (
|
||
tenantId: string,
|
||
entityType: string,
|
||
entityId: string
|
||
) => Promise<{ id: string; action: string; message: string | null; created_at: string }[]>;
|
||
defaultStatusFilter?: string;
|
||
pageSize?: number;
|
||
createLabel?: string;
|
||
mapCreateBody?: (values: Record<string, string>) => Record<string, unknown>;
|
||
mapUpdateBody?: (values: Record<string, string>, row: Record<string, unknown>) => Record<string, unknown>;
|
||
listParams?: (filters: Record<string, string>) => ListParams;
|
||
cardTitleKey?: string;
|
||
};
|
||
|
||
function fieldVisible(f: FieldDef, mode: "create" | "edit", kind: "table" | "form") {
|
||
const flag = kind === "table" ? f.table : f.form;
|
||
if (flag === undefined) return kind === "table" ? f.name !== "version" : true;
|
||
if (typeof flag === "function") return flag(mode);
|
||
return flag;
|
||
}
|
||
|
||
function emptyValues(fields: FieldDef[]) {
|
||
return Object.fromEntries(fields.map((f) => [f.name, ""]));
|
||
}
|
||
|
||
function rowToValues(row: Record<string, unknown>, fields: FieldDef[]) {
|
||
const out: Record<string, string> = {};
|
||
for (const f of fields) {
|
||
const v = row[f.name];
|
||
if (f.type === "datetime-local" && v) {
|
||
const d = new Date(String(v));
|
||
out[f.name] = Number.isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 16);
|
||
} else {
|
||
out[f.name] = v === null || v === undefined ? "" : String(v);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function FieldInput({
|
||
field,
|
||
value,
|
||
onChange,
|
||
organizationId,
|
||
}: {
|
||
field: FieldDef;
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
organizationId?: string;
|
||
}) {
|
||
const type = field.type ?? "text";
|
||
if (type === "organization")
|
||
return <OrganizationSelect value={value} onChange={onChange} required={field.required} label={field.label} />;
|
||
if (type === "branch")
|
||
return (
|
||
<BranchSelect
|
||
value={value}
|
||
onChange={onChange}
|
||
required={field.required}
|
||
label={field.label}
|
||
organizationId={organizationId}
|
||
/>
|
||
);
|
||
if (type === "staff")
|
||
return <StaffSelect value={value} onChange={onChange} required={field.required} label={field.label} />;
|
||
if (type === "customer")
|
||
return <CustomerSelect value={value} onChange={onChange} required={field.required} label={field.label} />;
|
||
if (type === "service")
|
||
return <ServiceSelect value={value} onChange={onChange} required={field.required} label={field.label} />;
|
||
if (type === "category")
|
||
return <CategorySelect value={value} onChange={onChange} required={field.required} label={field.label} />;
|
||
if (type === "room")
|
||
return (
|
||
<RoomSelect
|
||
value={value}
|
||
onChange={onChange}
|
||
required={field.required}
|
||
label={field.label}
|
||
organizationId={organizationId}
|
||
/>
|
||
);
|
||
if (type === "textarea")
|
||
return (
|
||
<label className="block text-sm">
|
||
<span className="mb-1 block text-[var(--muted)]">
|
||
{field.label}
|
||
{field.required ? " *" : ""}
|
||
</span>
|
||
<Textarea
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={field.placeholder}
|
||
rows={3}
|
||
required={field.required}
|
||
/>
|
||
</label>
|
||
);
|
||
if (type === "select")
|
||
return (
|
||
<label className="block text-sm">
|
||
<span className="mb-1 block text-[var(--muted)]">
|
||
{field.label}
|
||
{field.required ? " *" : ""}
|
||
</span>
|
||
<Select value={value} onChange={(e) => onChange(e.target.value)} required={field.required}>
|
||
<option value="">انتخاب…</option>
|
||
{(field.options ?? []).map((o) => (
|
||
<option key={o.value} value={o.value}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</label>
|
||
);
|
||
return (
|
||
<label className="block text-sm">
|
||
<span className="mb-1 block text-[var(--muted)]">
|
||
{field.label}
|
||
{field.required ? " *" : ""}
|
||
</span>
|
||
<Input
|
||
type={type === "number" ? "number" : type === "datetime-local" ? "datetime-local" : "text"}
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={field.placeholder}
|
||
required={field.required}
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function renderCell(f: FieldDef, r: Record<string, unknown>) {
|
||
if (f.render) return f.render(r);
|
||
if (f.name === "status") return <BeautyStatusChip status={String(r.status ?? "")} domain="lifecycle" />;
|
||
if (f.type === "datetime-local" && r[f.name])
|
||
return <JalaliDateText value={String(r[f.name])} />;
|
||
return String(r[f.name] ?? "—");
|
||
}
|
||
|
||
export function BeautyResourceWorkspace({ config, readOnly }: { config: ResourceConfig; readOnly?: boolean }) {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const { can, canManage } = useBeautyPermissions();
|
||
const pageSize = config.pageSize ?? 20;
|
||
|
||
const [search, setSearch] = useState("");
|
||
const debouncedSearch = useDebouncedValue(search);
|
||
const [statusFilter, setStatusFilter] = useState(config.defaultStatusFilter ?? "");
|
||
const [extraFilters, setExtraFilters] = useState<Record<string, string>>({});
|
||
const [page, setPage] = useState(1);
|
||
const [viewMode, setViewMode] = useState<"table" | "cards">("table");
|
||
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [editRow, setEditRow] = useState<Record<string, unknown> | null>(null);
|
||
const [detailRow, setDetailRow] = useState<Record<string, unknown> | null>(null);
|
||
const [deleteRow, setDeleteRow] = useState<Record<string, unknown> | null>(null);
|
||
const [confirmAction, setConfirmAction] = useState<{
|
||
action: WorkflowActionDef;
|
||
row: Record<string, unknown>;
|
||
} | null>(null);
|
||
const [formValues, setFormValues] = useState<Record<string, string>>(() => emptyValues(config.fields));
|
||
const [saveFilterName, setSaveFilterName] = useState("");
|
||
|
||
const { saved, saveFilter, removeFilter } = useSavedFilters(config.storageKey);
|
||
|
||
const listParams = useMemo(() => {
|
||
const base: ListParams = {
|
||
search: debouncedSearch || undefined,
|
||
status: statusFilter || undefined,
|
||
page,
|
||
pageSize,
|
||
...extraFilters,
|
||
};
|
||
return config.listParams
|
||
? config.listParams({ ...extraFilters, status: statusFilter, search: debouncedSearch })
|
||
: base;
|
||
}, [config, debouncedSearch, statusFilter, page, pageSize, extraFilters]);
|
||
|
||
const listQ = useQuery({
|
||
queryKey: [...config.queryKey, tenantId, listParams],
|
||
queryFn: () => config.listFn(tenantId!, listParams),
|
||
enabled: !!tenantId && can(config.viewPermission),
|
||
});
|
||
|
||
const auditQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "audit", config.auditEntityType, detailRow?.id],
|
||
queryFn: () => config.auditFn!(tenantId!, config.auditEntityType!, String(detailRow!.id)),
|
||
enabled: !!tenantId && !!detailRow && !!config.auditFn && !!config.auditEntityType,
|
||
});
|
||
|
||
const rows = useMemo(() => {
|
||
const data = listQ.data;
|
||
if (!data) return [];
|
||
const items = Array.isArray(data) ? data : data.items;
|
||
let list = items as Record<string, unknown>[];
|
||
if (debouncedSearch && Array.isArray(data)) {
|
||
const q = debouncedSearch.toLowerCase();
|
||
list = list.filter((r) =>
|
||
config.fields.some((f) => String(r[f.name] ?? "").toLowerCase().includes(q))
|
||
);
|
||
}
|
||
if (statusFilter && Array.isArray(data)) {
|
||
list = list.filter((r) => String(r.status ?? "") === statusFilter);
|
||
}
|
||
return list;
|
||
}, [listQ.data, debouncedSearch, statusFilter, config.fields]);
|
||
|
||
const total = Array.isArray(listQ.data) ? rows.length : (listQ.data?.total ?? rows.length);
|
||
|
||
const invalidate = async () => {
|
||
await qc.invalidateQueries({ queryKey: config.queryKey });
|
||
await qc.invalidateQueries({ queryKey: ["beauty", tenantId, "lookups"] });
|
||
};
|
||
|
||
const createM = useMutation({
|
||
mutationFn: (body: Record<string, unknown>) => config.createFn!(tenantId!, body),
|
||
onSuccess: async () => {
|
||
toast.success("ثبت شد");
|
||
setCreateOpen(false);
|
||
setFormValues(emptyValues(config.fields));
|
||
await invalidate();
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const updateM = useMutation({
|
||
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) =>
|
||
config.updateFn!(tenantId!, id, body),
|
||
onSuccess: async () => {
|
||
toast.success("ذخیره شد");
|
||
setEditRow(null);
|
||
await invalidate();
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const deleteM = useMutation({
|
||
mutationFn: (id: string) => config.deleteFn!(tenantId!, id),
|
||
onSuccess: async () => {
|
||
toast.success("حذف شد");
|
||
setDeleteRow(null);
|
||
await invalidate();
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const workflowM = useMutation({
|
||
mutationFn: ({ action, row }: { action: WorkflowActionDef; row: Record<string, unknown> }) =>
|
||
action.run(row, tenantId!),
|
||
onSuccess: async () => {
|
||
toast.success("انجام شد");
|
||
setConfirmAction(null);
|
||
await invalidate();
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const manageAllowed =
|
||
!readOnly && canManage(config.viewPermission, config.managePermission) && !!config.createFn;
|
||
|
||
if (!tenantId) return <LoadingState />;
|
||
if (!can(config.viewPermission)) return <PermissionDeniedState />;
|
||
if (listQ.isLoading) return <LoadingState label={`در حال بارگذاری ${config.title}…`} />;
|
||
if (listQ.error) {
|
||
const err = listQ.error;
|
||
if (err instanceof BeautyBusinessApiError && err.status === 403) {
|
||
return <PermissionDeniedState message={err.message} onRetry={() => listQ.refetch()} />;
|
||
}
|
||
return (
|
||
<ErrorState
|
||
message={err instanceof Error ? err.message : "خطا"}
|
||
onRetry={() => listQ.refetch()}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const tableColumns = [
|
||
...config.fields
|
||
.filter((f) => fieldVisible(f, "edit", "table"))
|
||
.map((f) => ({
|
||
key: f.name,
|
||
header: f.label,
|
||
render: (r: Record<string, unknown>) => renderCell(f, r),
|
||
})),
|
||
{
|
||
key: "_actions",
|
||
header: "عملیات",
|
||
render: (r: Record<string, unknown>) => (
|
||
<div className="flex flex-wrap gap-1">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setDetailRow(r)}
|
||
aria-label="جزئیات"
|
||
>
|
||
<Eye className="h-4 w-4" />
|
||
</Button>
|
||
{manageAllowed && config.updateFn ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
setEditRow(r);
|
||
setFormValues(rowToValues(r, config.fields));
|
||
}}
|
||
aria-label="ویرایش"
|
||
>
|
||
<Pencil className="h-4 w-4" />
|
||
</Button>
|
||
) : null}
|
||
{manageAllowed && config.deleteFn ? (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setDeleteRow(r)}
|
||
aria-label="حذف"
|
||
>
|
||
<Trash2 className="h-4 w-4 text-red-600" />
|
||
</Button>
|
||
) : null}
|
||
{config.workflowActions?.map((a) =>
|
||
a.visible && !a.visible(r) ? null : (
|
||
<Button
|
||
key={a.id}
|
||
type="button"
|
||
variant={a.variant ?? "outline"}
|
||
size="sm"
|
||
onClick={() => setConfirmAction({ action: a, row: r })}
|
||
>
|
||
{a.label}
|
||
</Button>
|
||
)
|
||
)}
|
||
</div>
|
||
),
|
||
},
|
||
];
|
||
|
||
const timelineItems: TimelineItem[] = (auditQ.data ?? []).map((log) => ({
|
||
id: log.id,
|
||
title: log.action,
|
||
subtitle: log.message ?? undefined,
|
||
timestamp: log.created_at,
|
||
}));
|
||
|
||
const submitCreate = () => {
|
||
const body = config.mapCreateBody ? config.mapCreateBody(formValues) : { ...formValues };
|
||
for (const f of config.fields) {
|
||
if (f.required && !formValues[f.name]?.trim()) {
|
||
toast.error(`${f.label} الزامی است`);
|
||
return;
|
||
}
|
||
if (f.type === "number" && formValues[f.name]) body[f.name] = Number(formValues[f.name]);
|
||
if (f.type === "datetime-local" && formValues[f.name]) {
|
||
body[f.name] = new Date(formValues[f.name]).toISOString();
|
||
}
|
||
if (!formValues[f.name]?.trim() && !f.required) delete body[f.name];
|
||
}
|
||
createM.mutate(body);
|
||
};
|
||
|
||
const submitEdit = () => {
|
||
if (!editRow) return;
|
||
const body = config.mapUpdateBody
|
||
? config.mapUpdateBody(formValues, editRow)
|
||
: { ...formValues, version: editRow.version };
|
||
updateM.mutate({ id: String(editRow.id), body });
|
||
};
|
||
|
||
const cardKey = config.cardTitleKey ?? "name";
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<PageHeader
|
||
title={config.title}
|
||
description={config.description}
|
||
actions={
|
||
manageAllowed ? (
|
||
<Button
|
||
type="button"
|
||
onClick={() => {
|
||
setFormValues(emptyValues(config.fields));
|
||
setCreateOpen(true);
|
||
}}
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
{config.createLabel ?? "ثبت جدید"}
|
||
</Button>
|
||
) : null
|
||
}
|
||
/>
|
||
|
||
<Tabs
|
||
items={[
|
||
{ id: "table", label: "جدول" },
|
||
{ id: "cards", label: "کارت" },
|
||
]}
|
||
value={viewMode}
|
||
onChange={(id) => setViewMode(id as "table" | "cards")}
|
||
/>
|
||
|
||
<TableToolbar
|
||
search={search}
|
||
onSearchChange={(v) => {
|
||
setSearch(v);
|
||
setPage(1);
|
||
}}
|
||
searchPlaceholder="جستجو در لیست…"
|
||
actions={
|
||
<>
|
||
{config.filters?.map((f) => (
|
||
<Select
|
||
key={f.id}
|
||
value={extraFilters[f.id] ?? ""}
|
||
onChange={(e) => {
|
||
setExtraFilters((prev) => ({ ...prev, [f.id]: e.target.value }));
|
||
setPage(1);
|
||
}}
|
||
className="h-10 min-w-[140px]"
|
||
>
|
||
<option value="">{f.label}</option>
|
||
{f.options.map((o) => (
|
||
<option key={o.value} value={o.value}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
))}
|
||
<Select
|
||
value={statusFilter}
|
||
onChange={(e) => {
|
||
setStatusFilter(e.target.value);
|
||
setPage(1);
|
||
}}
|
||
className="h-10 min-w-[120px]"
|
||
>
|
||
<option value="">همه وضعیتها</option>
|
||
{lifecycleStatusOptions.map((o) => (
|
||
<option key={o.value} value={o.value}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
{saved.length > 0 ? (
|
||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||
<span className="text-[var(--muted)]">فیلترهای ذخیرهشده:</span>
|
||
{saved.map((f) => (
|
||
<button
|
||
key={f.id}
|
||
type="button"
|
||
className="rounded-full border border-[var(--border)] px-2 py-1 hover:bg-[var(--surface-muted)]"
|
||
onClick={() => {
|
||
setStatusFilter(f.values.status ?? "");
|
||
setExtraFilters(f.values);
|
||
setSearch(f.values.search ?? "");
|
||
}}
|
||
>
|
||
{f.name}
|
||
<span
|
||
className="ms-1 text-red-500"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
removeFilter(f.id);
|
||
}}
|
||
>
|
||
×
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="flex flex-wrap items-end gap-2">
|
||
<Input
|
||
value={saveFilterName}
|
||
onChange={(e) => setSaveFilterName(e.target.value)}
|
||
placeholder="نام فیلتر ذخیرهشده…"
|
||
className="max-w-xs"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={!saveFilterName.trim()}
|
||
onClick={() => {
|
||
saveFilter(saveFilterName.trim(), { status: statusFilter, search, ...extraFilters });
|
||
setSaveFilterName("");
|
||
toast.success("فیلتر ذخیره شد");
|
||
}}
|
||
>
|
||
ذخیره فیلتر
|
||
</Button>
|
||
<span className="text-sm text-[var(--muted)]">{total} مورد</span>
|
||
</div>
|
||
|
||
{viewMode === "table" ? (
|
||
<DataTable
|
||
columns={tableColumns}
|
||
rows={rows}
|
||
empty={
|
||
<EmptyState
|
||
title="موردی یافت نشد"
|
||
action={
|
||
manageAllowed ? (
|
||
<Button type="button" onClick={() => setCreateOpen(true)}>
|
||
ثبت اولین مورد
|
||
</Button>
|
||
) : undefined
|
||
}
|
||
/>
|
||
}
|
||
/>
|
||
) : (
|
||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||
{rows.length === 0 ? (
|
||
<EmptyState title="موردی یافت نشد" />
|
||
) : (
|
||
rows.map((r) => (
|
||
<button
|
||
key={String(r.id)}
|
||
type="button"
|
||
onClick={() => setDetailRow(r)}
|
||
className="rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-4 text-start transition hover:shadow-md"
|
||
>
|
||
<p className="font-semibold text-secondary">
|
||
{String(r[cardKey] ?? r.display_name ?? r.code ?? r.id)}
|
||
</p>
|
||
{r.status ? (
|
||
<BeautyStatusChip
|
||
status={String(r.status)}
|
||
domain="lifecycle"
|
||
className="mt-2"
|
||
/>
|
||
) : null}
|
||
<p className="mt-2 text-xs text-[var(--muted)]">{String(r.code ?? "")}</p>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!Array.isArray(listQ.data) && total > pageSize ? (
|
||
<Pagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} />
|
||
) : null}
|
||
|
||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} title={config.createLabel ?? "ثبت جدید"} size="lg">
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
{config.fields
|
||
.filter((f) => fieldVisible(f, "create", "form"))
|
||
.map((f) => (
|
||
<FieldInput
|
||
key={f.name}
|
||
field={f}
|
||
value={formValues[f.name] ?? ""}
|
||
onChange={(v) => setFormValues((prev) => ({ ...prev, [f.name]: v }))}
|
||
organizationId={formValues.organization_id}
|
||
/>
|
||
))}
|
||
</div>
|
||
<div className="mt-6 flex justify-end gap-2">
|
||
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
|
||
انصراف
|
||
</Button>
|
||
<Button type="button" onClick={submitCreate} disabled={createM.isPending}>
|
||
{createM.isPending ? "در حال ثبت…" : "ثبت"}
|
||
</Button>
|
||
</div>
|
||
</Dialog>
|
||
|
||
<Dialog open={!!editRow} onClose={() => setEditRow(null)} title="ویرایش" size="lg">
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
{config.fields
|
||
.filter((f) => fieldVisible(f, "edit", "form") && f.name !== "code")
|
||
.map((f) => (
|
||
<FieldInput
|
||
key={f.name}
|
||
field={f}
|
||
value={formValues[f.name] ?? ""}
|
||
onChange={(v) => setFormValues((prev) => ({ ...prev, [f.name]: v }))}
|
||
organizationId={formValues.organization_id}
|
||
/>
|
||
))}
|
||
</div>
|
||
<div className="mt-6 flex justify-end gap-2">
|
||
<Button type="button" variant="outline" onClick={() => setEditRow(null)}>
|
||
انصراف
|
||
</Button>
|
||
<Button type="button" onClick={submitEdit} disabled={updateM.isPending}>
|
||
{updateM.isPending ? "در حال ذخیره…" : "ذخیره"}
|
||
</Button>
|
||
</div>
|
||
</Dialog>
|
||
|
||
<DetailDrawer
|
||
open={!!detailRow}
|
||
onClose={() => setDetailRow(null)}
|
||
title="جزئیات"
|
||
description={detailRow ? String(detailRow.code ?? detailRow.id) : undefined}
|
||
side="left"
|
||
width="lg"
|
||
>
|
||
{detailRow ? (
|
||
<div className="space-y-4">
|
||
<dl className="grid gap-2 text-sm">
|
||
{config.fields.map((f) => (
|
||
<div
|
||
key={f.name}
|
||
className="flex justify-between gap-4 border-b border-[var(--border)] py-2"
|
||
>
|
||
<dt className="text-[var(--muted)]">{f.label}</dt>
|
||
<dd className="font-medium text-secondary">{renderCell(f, detailRow)}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
{config.auditFn && config.auditEntityType ? (
|
||
<section>
|
||
<h3 className="mb-3 font-semibold text-secondary">تاریخچه</h3>
|
||
{auditQ.isLoading ? (
|
||
<LoadingState label="بارگذاری لاگ…" />
|
||
) : (
|
||
<Timeline items={timelineItems} />
|
||
)}
|
||
</section>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</DetailDrawer>
|
||
|
||
<ConfirmDialog
|
||
open={!!deleteRow}
|
||
onClose={() => setDeleteRow(null)}
|
||
onConfirm={() => deleteRow && deleteM.mutate(String(deleteRow.id))}
|
||
title="حذف مورد"
|
||
description="این عملیات بهصورت نرم حذف انجام میشود."
|
||
confirmLabel="حذف"
|
||
danger
|
||
loading={deleteM.isPending}
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
open={!!confirmAction}
|
||
onClose={() => setConfirmAction(null)}
|
||
onConfirm={() => confirmAction && workflowM.mutate(confirmAction)}
|
||
title={confirmAction?.action.confirmTitle ?? confirmAction?.action.label ?? "تأیید"}
|
||
description={confirmAction?.action.confirmDescription}
|
||
loading={workflowM.isPending}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|