Move business logic out of App Router into module packages, add boundary validation scripts, and keep all routes as thin re-exports without changing URLs or API behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
246 lines
8.7 KiB
TypeScript
246 lines
8.7 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useForm } from "react-hook-form";
|
||
import { z } from "zod";
|
||
import { zodResolver } from "@hookform/resolvers/zod";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { toast } from "sonner";
|
||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||
import { beautyBusinessApi, type Organization } from "@/modules/beauty/services/beauty-business-api";
|
||
import { useTenantId } from "@/hooks/useTenantId";
|
||
import {
|
||
Button,
|
||
Dialog,
|
||
Drawer,
|
||
Input,
|
||
EmptyState,
|
||
FormField,
|
||
ConfirmDialog,
|
||
Select,
|
||
} from "@/components/ds";
|
||
import { BeautyTablePage, BeautyStatusChip, businessFormatLabels } from "@/modules/beauty/design-system";
|
||
import { BeautyPageLoader, BeautyPageError } from "@/modules/beauty/pages/shared";
|
||
|
||
const createSchema = z.object({
|
||
code: z.string().min(1, "کد الزامی است"),
|
||
name: z.string().min(1, "نام الزامی است"),
|
||
description: z.string().optional(),
|
||
business_format: z.enum(["salon", "clinic", "spa", "barbershop", "mixed"]).optional(),
|
||
});
|
||
|
||
const editSchema = z.object({
|
||
name: z.string().min(1, "نام الزامی است"),
|
||
description: z.string().optional(),
|
||
status: z.enum(["draft", "active", "inactive", "suspended", "archived"]),
|
||
});
|
||
|
||
const statusLabel: Record<string, string> = {
|
||
draft: "پیشنویس",
|
||
active: "فعال",
|
||
inactive: "غیرفعال",
|
||
suspended: "معلق",
|
||
archived: "آرشیو",
|
||
};
|
||
|
||
export default function OrganizationsPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [editRow, setEditRow] = useState<Organization | null>(null);
|
||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||
|
||
const listQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "organizations"],
|
||
queryFn: () => beautyBusinessApi.organizations.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
const createForm = useForm<z.infer<typeof createSchema>>({
|
||
resolver: zodResolver(createSchema),
|
||
defaultValues: { code: "", name: "", description: "", business_format: "salon" },
|
||
});
|
||
|
||
const editForm = useForm<z.infer<typeof editSchema>>({
|
||
resolver: zodResolver(editSchema),
|
||
});
|
||
|
||
const invalidate = () =>
|
||
qc.invalidateQueries({ queryKey: ["beauty", tenantId, "organizations"] });
|
||
|
||
const createM = useMutation({
|
||
mutationFn: (d: z.infer<typeof createSchema>) =>
|
||
beautyBusinessApi.organizations.create(tenantId!, d),
|
||
onSuccess: async () => {
|
||
toast.success("سازمان ایجاد شد");
|
||
setCreateOpen(false);
|
||
createForm.reset();
|
||
await invalidate();
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const updateM = useMutation({
|
||
mutationFn: (d: z.infer<typeof editSchema>) =>
|
||
beautyBusinessApi.organizations.update(tenantId!, editRow!.id, {
|
||
...d,
|
||
version: editRow!.version,
|
||
}),
|
||
onSuccess: async () => {
|
||
toast.success("سازمان بهروز شد");
|
||
setEditRow(null);
|
||
await invalidate();
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const deleteM = useMutation({
|
||
mutationFn: (id: string) => beautyBusinessApi.organizations.delete(tenantId!, id),
|
||
onSuccess: async () => {
|
||
toast.success("سازمان حذف شد");
|
||
setDeleteId(null);
|
||
await invalidate();
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
const openEdit = (row: Organization) => {
|
||
setEditRow(row);
|
||
editForm.reset({
|
||
name: row.name,
|
||
description: row.description ?? "",
|
||
status: row.status,
|
||
});
|
||
};
|
||
|
||
if (!tenantId || listQ.isLoading) return <BeautyPageLoader />;
|
||
if (listQ.error) {
|
||
return <BeautyPageError error={listQ.error} onRetry={() => listQ.refetch()} />;
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<BeautyTablePage
|
||
title="سازمانها"
|
||
description="مدیریت سازمانهای سالن و زیبایی."
|
||
actions={
|
||
<Button onClick={() => setCreateOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
سازمان جدید
|
||
</Button>
|
||
}
|
||
columns={[
|
||
{ key: "code", header: "کد" },
|
||
{ key: "name", header: "نام" },
|
||
{
|
||
key: "business_format",
|
||
header: "نوع",
|
||
render: (r) => businessFormatLabels[String(r.business_format)] ?? String(r.business_format),
|
||
},
|
||
{
|
||
key: "status",
|
||
header: "وضعیت",
|
||
render: (r) => <BeautyStatusChip status={String(r.status)} domain="lifecycle" />,
|
||
},
|
||
{
|
||
key: "actions",
|
||
header: "عملیات",
|
||
render: (r) => (
|
||
<div className="flex gap-1">
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="secondary"
|
||
onClick={() => openEdit(r as unknown as Organization)}
|
||
>
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
ویرایش
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setDeleteId(String(r.id))}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
),
|
||
},
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState action={<Button onClick={() => setCreateOpen(true)}>ایجاد سازمان</Button>} />}
|
||
/>
|
||
|
||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} title="سازمان جدید">
|
||
<form className="space-y-3" onSubmit={createForm.handleSubmit((d) => createM.mutate(d))}>
|
||
<FormField label="کد" error={createForm.formState.errors.code?.message}>
|
||
<Input {...createForm.register("code")} />
|
||
</FormField>
|
||
<FormField label="نام" error={createForm.formState.errors.name?.message}>
|
||
<Input {...createForm.register("name")} />
|
||
</FormField>
|
||
<FormField label="توضیحات">
|
||
<Input {...createForm.register("description")} />
|
||
</FormField>
|
||
<FormField label="نوع کسبوکار">
|
||
<Select {...createForm.register("business_format")}>
|
||
<option value="salon">سالن</option>
|
||
<option value="clinic">کلینیک</option>
|
||
<option value="spa">اسپا</option>
|
||
<option value="barbershop">آرایشگاه</option>
|
||
<option value="mixed">ترکیبی</option>
|
||
</Select>
|
||
</FormField>
|
||
<div className="flex justify-end gap-2">
|
||
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
|
||
انصراف
|
||
</Button>
|
||
<Button type="submit" disabled={createM.isPending}>
|
||
ذخیره
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Dialog>
|
||
|
||
<Drawer open={!!editRow} onClose={() => setEditRow(null)} title="ویرایش سازمان">
|
||
<form className="space-y-4" onSubmit={editForm.handleSubmit((d) => updateM.mutate(d))}>
|
||
<FormField label="نام" error={editForm.formState.errors.name?.message}>
|
||
<Input {...editForm.register("name")} />
|
||
</FormField>
|
||
<FormField label="توضیحات">
|
||
<Input {...editForm.register("description")} />
|
||
</FormField>
|
||
<FormField label="وضعیت">
|
||
<Select {...editForm.register("status")}>
|
||
{Object.entries(statusLabel).map(([v, l]) => (
|
||
<option key={v} value={v}>
|
||
{l}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
<div className="flex justify-end gap-2">
|
||
<Button type="button" variant="outline" onClick={() => setEditRow(null)}>
|
||
انصراف
|
||
</Button>
|
||
<Button type="submit" disabled={updateM.isPending}>
|
||
ذخیره
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Drawer>
|
||
|
||
<ConfirmDialog
|
||
open={!!deleteId}
|
||
onClose={() => setDeleteId(null)}
|
||
onConfirm={() => deleteId && deleteM.mutate(deleteId)}
|
||
title="حذف سازمان"
|
||
description="سازمان بهصورت نرم حذف میشود."
|
||
confirmLabel="حذف"
|
||
loading={deleteM.isPending}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|