TorbatYar/frontend/app/accounting/customers/page.tsx
Mortezakoohjani 7953e47f8b Ship accounting UX: Rial integers, auto doc numbers, reverse-and-edit, real reports, customers nav.
Wire DocumentNumberSequence allocator, ledger-based subsidiary/detail reports, and posted-voucher reopen flow without mock ops forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 15:52:06 +03:30

410 lines
15 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 { Controller, 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 { Eye, Pencil, Plus } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney } from "@/lib/utils";
import {
PageHeader,
Button,
Dialog,
Drawer,
Input,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
FormField,
MoneyInput,
Checkbox,
DatePicker,
JalaliDateText,
StatCard,
} from "@/components/ds";
const createSchema = z.object({
code: z.string().optional(),
name: z.string().min(1, "نام الزامی است"),
credit_limit: z.string().optional(),
});
const editSchema = z.object({
name: z.string().min(1, "نام الزامی است"),
credit_limit: z.string().optional(),
is_active: z.boolean(),
});
const invoiceSchema = z.object({
invoice_number: z.string().optional(),
invoice_date: z.string().min(1, "تاریخ الزامی است"),
due_date: z.string().optional(),
total_amount: z.string().min(1, "مبلغ الزامی است"),
});
type CustomerRow = {
id: string;
code: string;
name: string;
outstanding_balance: string;
is_active: boolean;
};
export default function CustomersPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editCustomer, setEditCustomer] = useState<CustomerRow | null>(null);
const [detailCustomer, setDetailCustomer] = useState<CustomerRow | null>(null);
const [invoiceOpen, setInvoiceOpen] = useState(false);
const listQ = useQuery({
queryKey: ["accounting", tenantId, "customers"],
queryFn: () => accountingApi.customers.list(tenantId!),
enabled: !!tenantId,
});
const agingQ = useQuery({
queryKey: ["accounting", tenantId, "customer-aging", detailCustomer?.id],
queryFn: () => accountingApi.customers.aging(tenantId!, detailCustomer!.id),
enabled: !!tenantId && !!detailCustomer,
});
const invoicesQ = useQuery({
queryKey: ["accounting", tenantId, "customer-invoices", detailCustomer?.id],
queryFn: () => accountingApi.customers.listInvoices(tenantId!, detailCustomer!.id),
enabled: !!tenantId && !!detailCustomer,
});
const createForm = useForm<z.infer<typeof createSchema>>({
resolver: zodResolver(createSchema),
defaultValues: { code: "", name: "", credit_limit: "" },
});
const editForm = useForm<z.infer<typeof editSchema>>({
resolver: zodResolver(editSchema),
defaultValues: { name: "", credit_limit: "", is_active: true },
});
const invoiceForm = useForm<z.infer<typeof invoiceSchema>>({
resolver: zodResolver(invoiceSchema),
defaultValues: { invoice_number: "", invoice_date: "", due_date: "", total_amount: "" },
});
const invalidateCustomers = () =>
qc.invalidateQueries({ queryKey: ["accounting", tenantId, "customers"] });
const invalidateInvoices = () =>
qc.invalidateQueries({ queryKey: ["accounting", tenantId, "customer-invoices"] });
const createM = useMutation({
mutationFn: (d: z.infer<typeof createSchema>) =>
accountingApi.customers.create(tenantId!, {
code: d.code?.trim() || undefined,
name: d.name,
credit_limit: d.credit_limit || undefined,
}),
onSuccess: async () => {
toast.success("مشتری ایجاد شد");
setCreateOpen(false);
createForm.reset();
await invalidateCustomers();
},
onError: (e: Error) => toast.error(e.message),
});
const updateM = useMutation({
mutationFn: (d: z.infer<typeof editSchema>) =>
accountingApi.customers.update(tenantId!, editCustomer!.id, {
name: d.name,
credit_limit: d.credit_limit || null,
is_active: d.is_active,
}),
onSuccess: async () => {
toast.success("مشتری به‌روز شد");
setEditCustomer(null);
await invalidateCustomers();
},
onError: (e: Error) => toast.error(e.message),
});
const createInvoiceM = useMutation({
mutationFn: (d: z.infer<typeof invoiceSchema>) =>
accountingApi.customers.createInvoice(tenantId!, {
customer_id: detailCustomer!.id,
invoice_number: d.invoice_number?.trim() || undefined,
invoice_date: d.invoice_date,
due_date: d.due_date || undefined,
total_amount: d.total_amount,
}),
onSuccess: async () => {
toast.success("فاکتور ثبت شد");
setInvoiceOpen(false);
invoiceForm.reset();
await invalidateInvoices();
await invalidateCustomers();
if (detailCustomer) {
await qc.invalidateQueries({
queryKey: ["accounting", tenantId, "customer-aging", detailCustomer.id],
});
}
},
onError: (e: Error) => toast.error(e.message),
});
const openEdit = (row: CustomerRow) => {
setEditCustomer(row);
editForm.reset({
name: row.name,
credit_limit: "",
is_active: row.is_active,
});
};
if (!tenantId || listQ.isLoading) return <LoadingState />;
if (listQ.error) {
return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
}
const aging = agingQ.data;
return (
<div>
<PageHeader
title="مشتریان"
description="حساب‌های دریافتنی — مدیریت مشتری، سررسید و فاکتور."
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
مشتری جدید
</Button>
}
/>
<DataTable
columns={[
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{
key: "outstanding_balance",
header: "مانده",
render: (r) => formatMoney(String(r.outstanding_balance)),
},
{
key: "is_active",
header: "وضعیت",
render: (r) => (
<Badge tone={r.is_active ? "success" : "default"}>
{r.is_active ? "فعال" : "غیرفعال"}
</Badge>
),
},
{
key: "actions",
header: "عملیات",
render: (r) => (
<div className="flex flex-wrap gap-1">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setDetailCustomer(r as unknown as CustomerRow)}
>
<Eye className="h-3.5 w-3.5" />
جزئیات
</Button>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => openEdit(r as unknown as CustomerRow)}
>
<Pencil 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")} placeholder="خودکار" />
</FormField>
<FormField label="نام" error={createForm.formState.errors.name?.message}>
<Input {...createForm.register("name")} />
</FormField>
<FormField label="سقف اعتبار">
<MoneyInput {...createForm.register("credit_limit")} />
</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={!!editCustomer}
onClose={() => setEditCustomer(null)}
title="ویرایش مشتری"
description={editCustomer ? `${editCustomer.code}${editCustomer.name}` : undefined}
>
<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="سقف اعتبار">
<MoneyInput {...editForm.register("credit_limit")} />
</FormField>
<Checkbox label="فعال" {...editForm.register("is_active")} />
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => setEditCustomer(null)}>
انصراف
</Button>
<Button type="submit" disabled={updateM.isPending}>
ذخیره
</Button>
</div>
</form>
</Drawer>
<Drawer
open={!!detailCustomer}
onClose={() => {
setDetailCustomer(null);
setInvoiceOpen(false);
}}
title={detailCustomer?.name ?? "جزئیات مشتری"}
description={detailCustomer ? `کد: ${detailCustomer.code}` : undefined}
width="lg"
>
{detailCustomer && (
<div className="space-y-6">
<div className="grid gap-3 sm:grid-cols-2">
<StatCard label="مانده کل" value={formatMoney(detailCustomer.outstanding_balance)} />
{agingQ.isLoading ? (
<p className="text-sm text-[var(--muted)]">در حال بارگذاری سررسید</p>
) : agingQ.error ? (
<p className="text-sm text-red-600">{agingQ.error.message}</p>
) : aging ? (
<>
<StatCard label="جاری" value={formatMoney(aging.current)} />
<StatCard label="۱–۳۰ روز" value={formatMoney(aging.days_1_30)} />
<StatCard label="۳۱–۶۰ روز" value={formatMoney(aging.days_31_60)} />
<StatCard label="۶۱–۹۰ روز" value={formatMoney(aging.days_61_90)} />
<StatCard label="۹۰+ روز" value={formatMoney(aging.days_90_plus)} />
<StatCard label="مجموع سررسید" value={formatMoney(aging.total)} />
</>
) : null}
</div>
<div>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-secondary">فاکتورهای دریافتنی</h3>
<Button type="button" size="sm" onClick={() => setInvoiceOpen(true)}>
<Plus className="h-3.5 w-3.5" />
فاکتور جدید
</Button>
</div>
{invoicesQ.isLoading ? (
<LoadingState label="بارگذاری فاکتورها…" />
) : invoicesQ.error ? (
<ErrorState message={invoicesQ.error.message} onRetry={() => invoicesQ.refetch()} />
) : (
<DataTable
columns={[
{ key: "invoice_number", header: "شماره" },
{
key: "invoice_date",
header: "تاریخ",
render: (r) => <JalaliDateText value={String(r.invoice_date ?? "")} />,
},
{
key: "total_amount",
header: "مبلغ",
render: (r) => formatMoney(String(r.total_amount)),
},
{
key: "remaining_amount",
header: "مانده",
render: (r) => formatMoney(String(r.remaining_amount)),
},
{
key: "status",
header: "وضعیت",
render: (r) => <Badge>{String(r.status)}</Badge>,
},
]}
rows={(invoicesQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState title="فاکتوری ثبت نشده" />}
/>
)}
</div>
</div>
)}
</Drawer>
<Dialog open={invoiceOpen} onClose={() => setInvoiceOpen(false)} title="فاکتور دریافتنی جدید">
<form className="space-y-3" onSubmit={invoiceForm.handleSubmit((d) => createInvoiceM.mutate(d))}>
<FormField label="شماره فاکتور (اختیاری — خودکار)" error={invoiceForm.formState.errors.invoice_number?.message}>
<Input {...invoiceForm.register("invoice_number")} placeholder="خودکار" />
</FormField>
<FormField label="تاریخ فاکتور" error={invoiceForm.formState.errors.invoice_date?.message}>
<Controller
control={invoiceForm.control}
name="invoice_date"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
</FormField>
<FormField label="سررسید">
<Controller
control={invoiceForm.control}
name="due_date"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
</FormField>
<FormField label="مبلغ کل" error={invoiceForm.formState.errors.total_amount?.message}>
<MoneyInput {...invoiceForm.register("total_amount")} />
</FormField>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setInvoiceOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createInvoiceM.isPending}>
ثبت فاکتور
</Button>
</div>
</form>
</Dialog>
</div>
);
}