Adds accounting-service PATCH/archive, fiscal helpers, COA templates and setup status, plus SuperApp Accounting UI (DS, scoreboard, masters, vouchers, ledger, ops modules) with session refresh and HTTPS public API URLs. Co-authored-by: Cursor <cursoragent@cursor.com>
410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
"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().min(1, "کد الزامی است"),
|
||
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().min(1, "شماره فاکتور الزامی است"),
|
||
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,
|
||
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,
|
||
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")} />
|
||
</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")} />
|
||
</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>
|
||
);
|
||
}
|