TorbatYar/frontend/modules/beauty/features/owner/appointments.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

214 lines
7.4 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 } from "lucide-react";
import { beautyBusinessApi, type Appointment } from "@/modules/beauty/services/beauty-business-api";
import { useTenantId } from "@/hooks/useTenantId";
import {
PageHeader,
Button,
Dialog,
Input,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
FormField,
Select,
JalaliDateText,
} from "@/components/ds";
const createSchema = z.object({
organization_id: z.string().min(1, "سازمان الزامی است"),
branch_id: z.string().min(1, "شعبه الزامی است"),
code: z.string().min(1, "کد الزامی است"),
scheduled_start: z.string().min(1, "شروع الزامی است"),
scheduled_end: z.string().min(1, "پایان الزامی است"),
customer_ref: z.string().optional(),
notes: z.string().optional(),
});
export default function AppointmentsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const orgsQ = useQuery({
queryKey: ["beauty", tenantId, "organizations"],
queryFn: () => beautyBusinessApi.organizations.list(tenantId!),
enabled: !!tenantId,
});
const branchesQ = useQuery({
queryKey: ["beauty", tenantId, "branches"],
queryFn: () => beautyBusinessApi.branches.list(tenantId!),
enabled: !!tenantId,
});
const listQ = useQuery({
queryKey: ["beauty", tenantId, "appointments"],
queryFn: () => beautyBusinessApi.appointments.list(tenantId!),
enabled: !!tenantId,
});
const createForm = useForm<z.infer<typeof createSchema>>({
resolver: zodResolver(createSchema),
defaultValues: {
organization_id: "",
branch_id: "",
code: "",
scheduled_start: "",
scheduled_end: "",
customer_ref: "",
notes: "",
},
});
const selectedOrg = createForm.watch("organization_id");
const filteredBranches = (branchesQ.data ?? []).filter(
(b) => !selectedOrg || b.organization_id === selectedOrg
);
const invalidate = () =>
qc.invalidateQueries({ queryKey: ["beauty", tenantId, "appointments"] });
const createM = useMutation({
mutationFn: (d: z.infer<typeof createSchema>) =>
beautyBusinessApi.appointments.create(tenantId!, {
...d,
scheduled_start: new Date(d.scheduled_start).toISOString(),
scheduled_end: new Date(d.scheduled_end).toISOString(),
customer_ref: d.customer_ref || undefined,
notes: d.notes || undefined,
}),
onSuccess: async () => {
toast.success("نوبت ایجاد شد");
setCreateOpen(false);
createForm.reset();
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const confirmM = useMutation({
mutationFn: (row: Appointment) =>
beautyBusinessApi.appointments.confirm(tenantId!, row.id, { version: row.version }),
onSuccess: async () => {
toast.success("نوبت تأیید شد");
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading) return <LoadingState />;
if (listQ.error) {
return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
}
return (
<div>
<PageHeader
title="نوبت‌ها"
description="مدیریت نوبت‌های سالن زیبایی."
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
نوبت جدید
</Button>
}
/>
<DataTable
columns={[
{ key: "code", header: "کد" },
{
key: "scheduled_start",
header: "شروع",
render: (r) => <JalaliDateText value={String(r.scheduled_start ?? "")} />,
},
{
key: "status",
header: "وضعیت",
render: (r) => <Badge>{String(r.status)}</Badge>,
},
{
key: "actions",
header: "عملیات",
render: (r) => {
const row = r as unknown as Appointment;
if (row.status !== "requested") return null;
return (
<Button
type="button"
size="sm"
variant="secondary"
disabled={confirmM.isPending}
onClick={() => confirmM.mutate(row)}
>
تأیید
</Button>
);
},
},
]}
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.organization_id?.message}>
<Select {...createForm.register("organization_id")}>
<option value="">انتخاب کنید</option>
{(orgsQ.data ?? []).map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</Select>
</FormField>
<FormField label="شعبه" error={createForm.formState.errors.branch_id?.message}>
<Select {...createForm.register("branch_id")}>
<option value="">انتخاب کنید</option>
{filteredBranches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</Select>
</FormField>
<FormField label="کد" error={createForm.formState.errors.code?.message}>
<Input {...createForm.register("code")} />
</FormField>
<FormField label="شروع (ISO)" error={createForm.formState.errors.scheduled_start?.message}>
<Input type="datetime-local" {...createForm.register("scheduled_start")} />
</FormField>
<FormField label="پایان (ISO)" error={createForm.formState.errors.scheduled_end?.message}>
<Input type="datetime-local" {...createForm.register("scheduled_end")} />
</FormField>
<FormField label="مرجع مشتری">
<Input {...createForm.register("customer_ref")} />
</FormField>
<FormField label="یادداشت">
<Input {...createForm.register("notes")} />
</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>
</div>
);
}