TorbatYar/frontend/modules/beauty/components/crud/BeautyResourceWorkspace.tsx
Mortezakoohjani d579d0b142 feat(loyalty): add Loyalty Platform Frontend module
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>
2026-07-27 10:50:55 +03:30

768 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}