TorbatYar/frontend/modules/beauty/features/owner/organizations.tsx
Mortezakoohjani 6f4a484051 Migrate Beauty, Healthcare, and Accounting frontend to modular src/modules architecture.
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>
2026-07-26 22:28:27 +03:30

246 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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