TorbatYar/frontend/components/accounting/BusinessDocsPage.tsx
Mortezakoohjani 97a363fc53 Wire purchase returns, inventory issues, and GL transfers with auto vouchers.
Goods receipts and warehouse receipts now capture party vs warehouse separately; purchase returns link to invoices and reduce AP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 19:51:32 +03:30

1329 lines
48 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, parseMoneyInput } from "@/lib/utils";
import {
PageHeader,
Button,
Dialog,
Input,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
FormField,
MoneyInput,
DatePicker,
JalaliDateText,
ConfirmDialog,
Tabs,
Select,
} from "@/components/ds";
import { PartyCombobox } from "@/components/accounting/EntityCombobox";
const schema = z.object({
number: z.string().min(1, "شماره الزامی است"),
doc_date: z.string().min(1, "تاریخ الزامی است"),
party_name: z.string().optional(),
party_id: z.string().optional(),
amount: z.string().min(1, "مبلغ الزامی است"),
description: z.string().optional(),
});
type FormValues = z.infer<typeof schema>;
export function BusinessDocsPage({
title,
description,
module,
docType,
}: {
title: string;
description?: string;
module: string;
docType: string;
}) {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [confirmId, setConfirmId] = useState<string | null>(null);
const [detailRow, setDetailRow] = useState<Record<string, unknown> | null>(null);
const [editRow, setEditRow] = useState<Record<string, unknown> | null>(null);
const listQ = useQuery({
queryKey: ["accounting", tenantId, "ops-docs", module, docType],
queryFn: () => accountingApi.ops.listDocuments(tenantId!, module, docType),
enabled: !!tenantId,
});
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
number: "",
doc_date: new Date().toISOString().slice(0, 10),
party_name: "",
party_id: "",
amount: "0",
description: "",
},
});
const editForm = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
number: "",
doc_date: "",
party_name: "",
party_id: "",
amount: "0",
description: "",
},
});
const createM = useMutation({
mutationFn: (d: FormValues) =>
accountingApi.ops.createDocument(tenantId!, {
module,
doc_type: docType,
number: d.number,
doc_date: d.doc_date,
party_name: d.party_name || undefined,
party_id: d.party_id || undefined,
amount: parseMoneyInput(d.amount) || "0",
description: d.description || undefined,
status: "draft",
}),
onSuccess: async () => {
toast.success("ثبت شد");
setOpen(false);
form.reset({
number: "",
doc_date: new Date().toISOString().slice(0, 10),
party_name: "",
party_id: "",
amount: "0",
description: "",
});
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", module, docType] });
},
onError: (e: Error) => toast.error(e.message),
});
const confirmM = useMutation({
mutationFn: (id: string) => accountingApi.ops.confirmDocument(tenantId!, id),
onSuccess: async () => {
toast.success("تأیید شد");
setConfirmId(null);
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", module, docType] });
},
onError: (e: Error) => toast.error(e.message),
});
const openEdit = (row: Record<string, unknown>) => {
setEditRow(row);
editForm.reset({
number: String(row.number ?? ""),
doc_date: String(row.doc_date ?? ""),
party_name: String(row.party_name ?? ""),
party_id: String(row.party_id ?? ""),
amount: String(row.amount ?? "0"),
description: String(row.description ?? ""),
});
};
const updateM = useMutation({
mutationFn: (d: FormValues) =>
accountingApi.ops.updateDocument(tenantId!, String(editRow!.id), {
doc_date: d.doc_date,
party_name: d.party_name || undefined,
party_id: d.party_id || undefined,
amount: parseMoneyInput(d.amount) || "0",
description: d.description || undefined,
}),
onSuccess: async () => {
toast.success("ویرایش ذخیره شد");
setEditRow(null);
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", module, docType] });
},
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={title}
description={description || "لیست و ثبت — متصل به API واقعی حسابداری."}
actions={
<Button type="button" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" />
{title.includes("فاکتور") || title.includes("سند") || title.includes("چک")
? `${title} جدید`
: `ثبت ${title}`}
</Button>
}
/>
<DataTable
columns={[
{ key: "number", header: "شماره" },
{
key: "doc_date",
header: "تاریخ",
render: (r) => <JalaliDateText value={String(r.doc_date ?? "")} />,
},
{ key: "party_name", header: "طرف حساب" },
{
key: "amount",
header: "مبلغ",
render: (r) => formatMoney(String(r.amount ?? "0")),
},
{
key: "status",
header: "وضعیت",
render: (r) => (
<Badge tone={r.status === "confirmed" ? "success" : "default"}>{String(r.status)}</Badge>
),
},
{
key: "id",
header: "عملیات",
render: (r) => (
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="ghost" onClick={() => setDetailRow(r)}>
<Eye className="h-4 w-4" />
جزئیات
</Button>
{r.status === "draft" ? (
<>
<Button type="button" size="sm" variant="outline" onClick={() => openEdit(r)}>
<Pencil className="h-4 w-4" />
ویرایش
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => setConfirmId(String(r.id))}>
تأیید
</Button>
</>
) : null}
</div>
),
},
]}
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={
<EmptyState
title="موردی ثبت نشده"
description="با دکمه ثبت جدید در بالای صفحه شروع کنید."
action={
<Button type="button" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" />
ثبت {title}
</Button>
}
/>
}
/>
<Dialog open={open} onClose={() => setOpen(false)} title={`ثبت ${title}`} size="lg">
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
<FormField label="شماره" error={form.formState.errors.number?.message}>
<Input {...form.register("number")} />
</FormField>
<FormField label="تاریخ" error={form.formState.errors.doc_date?.message}>
<Controller
control={form.control}
name="doc_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</FormField>
<FormField label="طرف حساب">
<PartyCombobox
module={module}
valueName={form.watch("party_name") || undefined}
onChange={(party) => {
form.setValue("party_name", party?.name || "");
form.setValue("party_id", party?.id || "");
}}
/>
</FormField>
<FormField label="مبلغ" error={form.formState.errors.amount?.message}>
<MoneyInput {...form.register("amount")} />
</FormField>
<div className="sm:col-span-2">
<FormField label="توضیح">
<Input {...form.register("description")} />
</FormField>
</div>
<div className="flex justify-end gap-2 sm:col-span-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={!!editRow} onClose={() => setEditRow(null)} title={`ویرایش ${title}`} size="lg">
<form className="grid gap-3 sm:grid-cols-2" onSubmit={editForm.handleSubmit((d) => updateM.mutate(d))}>
<FormField label="شماره">
<Input {...editForm.register("number")} readOnly />
</FormField>
<FormField label="تاریخ" error={editForm.formState.errors.doc_date?.message}>
<Controller
control={editForm.control}
name="doc_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</FormField>
<FormField label="طرف حساب">
<PartyCombobox
module={module}
valueName={editForm.watch("party_name") || undefined}
onChange={(party) => {
editForm.setValue("party_name", party?.name || "");
editForm.setValue("party_id", party?.id || "");
}}
/>
</FormField>
<FormField label="مبلغ" error={editForm.formState.errors.amount?.message}>
<MoneyInput {...editForm.register("amount")} />
</FormField>
<div className="sm:col-span-2">
<FormField label="توضیح">
<Input {...editForm.register("description")} />
</FormField>
</div>
<div className="flex justify-end gap-2 sm:col-span-2">
<Button type="button" variant="outline" onClick={() => setEditRow(null)}>انصراف</Button>
<Button type="submit" disabled={updateM.isPending}>ذخیره تغییرات</Button>
</div>
</form>
</Dialog>
<Dialog open={!!detailRow} onClose={() => setDetailRow(null)} title={`جزئیات ${title}`}>
{detailRow ? (
<dl className="grid gap-4 text-sm sm:grid-cols-2">
<div><dt className="text-[var(--muted)]">شماره</dt><dd className="mt-1 text-secondary">{String(detailRow.number ?? "—")}</dd></div>
<div><dt className="text-[var(--muted)]">تاریخ</dt><dd className="mt-1 text-secondary"><JalaliDateText value={String(detailRow.doc_date ?? "")} /></dd></div>
<div><dt className="text-[var(--muted)]">طرف حساب</dt><dd className="mt-1 text-secondary">{String(detailRow.party_name ?? "—")}</dd></div>
<div><dt className="text-[var(--muted)]">مبلغ</dt><dd className="mt-1 text-secondary">{formatMoney(String(detailRow.amount ?? "0"))}</dd></div>
<div><dt className="text-[var(--muted)]">وضعیت</dt><dd className="mt-1"><Badge>{String(detailRow.status ?? "—")}</Badge></dd></div>
<div className="sm:col-span-2"><dt className="text-[var(--muted)]">توضیح</dt><dd className="mt-1 text-secondary">{String(detailRow.description ?? "—")}</dd></div>
</dl>
) : null}
</Dialog>
<ConfirmDialog
open={!!confirmId}
onClose={() => setConfirmId(null)}
onConfirm={() => confirmId && confirmM.mutate(confirmId)}
title="تأیید سند؟"
description="پس از تأیید، وضعیت به confirmed تغییر می‌کند."
confirmLabel="تأیید"
loading={confirmM.isPending}
/>
</div>
);
}
export function BlockedModulePage({ title }: { title: string }) {
return (
<div>
<PageHeader title={title} description="این بخش تا Active شدن AI Provider مسدود است." />
<EmptyState
title="مسدود"
description="طبق تصمیم پروژه، ماژول هوش مصنوعی حسابداری تا فعال‌سازی provider نمایش داده نمی‌شود."
/>
</div>
);
}
export function InventoryItemsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const form = useForm({
defaultValues: { code: "", name: "", unit: "عدد", quantity_on_hand: "0", unit_cost: "0" },
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "ops-items"],
queryFn: () => accountingApi.ops.listItems(tenantId!),
enabled: !!tenantId,
});
const createM = useMutation({
mutationFn: (d: { code: string; name: string; unit: string; quantity_on_hand: string; unit_cost: string }) =>
accountingApi.ops.createItem(tenantId!, d),
onSuccess: async () => {
toast.success("کالا ثبت شد");
setOpen(false);
form.reset();
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-items"] });
},
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="فهرست اقلام انبار — CRUD واقعی."
actions={
<Button type="button" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" />
کالای جدید
</Button>
}
/>
<DataTable
columns={[
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "unit", header: "واحد" },
{
key: "quantity_on_hand",
header: "موجودی",
render: (r) => String(r.quantity_on_hand ?? "0"),
},
{
key: "unit_cost",
header: "بهای واحد",
render: (r) => formatMoney(String(r.unit_cost ?? "0")),
},
{
key: "is_active",
header: "وضعیت",
render: (r) => <Badge tone={r.is_active ? "success" : "default"}>{r.is_active ? "فعال" : "غیرفعال"}</Badge>,
},
]}
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState title="کالایی ثبت نشده" />}
/>
<Dialog open={open} onClose={() => setOpen(false)} title="ثبت کالا">
<form
className="grid gap-3 sm:grid-cols-2"
onSubmit={form.handleSubmit((d) => createM.mutate(d))}
>
<FormField label="کد">
<Input {...form.register("code", { required: true })} />
</FormField>
<FormField label="نام">
<Input {...form.register("name", { required: true })} />
</FormField>
<FormField label="واحد">
<Input {...form.register("unit")} />
</FormField>
<FormField label="موجودی">
<Input {...form.register("quantity_on_hand")} />
</FormField>
<FormField label="بهای واحد">
<MoneyInput {...form.register("unit_cost")} />
</FormField>
<div className="flex justify-end gap-2 sm:col-span-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
</div>
);
}
export function WarehousesPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const form = useForm({ defaultValues: { code: "", name: "" } });
const listQ = useQuery({
queryKey: ["accounting", tenantId, "ops-warehouses"],
queryFn: () => accountingApi.ops.listWarehouses(tenantId!),
enabled: !!tenantId,
});
const createM = useMutation({
mutationFn: (d: { code: string; name: string }) => accountingApi.ops.createWarehouse(tenantId!, d),
onSuccess: async () => {
toast.success("انبار ثبت شد");
setOpen(false);
form.reset();
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-warehouses"] });
},
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 type="button" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" />
انبار جدید
</Button>
}
/>
<DataTable
columns={[
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{
key: "is_active",
header: "وضعیت",
render: (r) => <Badge tone={r.is_active ? "success" : "default"}>{r.is_active ? "فعال" : "غیرفعال"}</Badge>,
},
]}
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState title="انباری ثبت نشده" />}
/>
<Dialog open={open} onClose={() => setOpen(false)} title="ثبت انبار">
<form className="grid gap-3" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
<FormField label="کد">
<Input {...form.register("code", { required: true })} />
</FormField>
<FormField label="نام">
<Input {...form.register("name", { required: true })} />
</FormField>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
</div>
);
}
export function ChequesPage({
defaultIncoming,
initialOpen = false,
}: {
defaultIncoming?: boolean;
initialOpen?: boolean;
}) {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [tab, setTab] = useState<"all" | "in" | "out" | "bounced">("all");
const [open, setOpen] = useState(initialOpen);
const form = useForm({
defaultValues: {
cheque_number: "",
amount: "0",
issue_date: new Date().toISOString().slice(0, 10),
due_date: "",
is_incoming: defaultIncoming !== false,
payee: "",
},
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "cheques"],
queryFn: () => accountingApi.treasury.listCheques(tenantId!),
enabled: !!tenantId,
});
const createM = useMutation({
mutationFn: (d: {
cheque_number: string;
amount: string;
issue_date: string;
due_date?: string;
is_incoming: boolean;
payee?: string;
}) =>
accountingApi.treasury.createCheque(tenantId!, {
...d,
due_date: d.due_date || undefined,
status: d.is_incoming ? "received" : "issued",
}),
onSuccess: async () => {
toast.success("چک ثبت شد");
setOpen(false);
form.reset({
cheque_number: "",
amount: "0",
issue_date: new Date().toISOString().slice(0, 10),
due_date: "",
is_incoming: true,
payee: "",
});
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "cheques"] });
},
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()} />;
const rows = (listQ.data ?? []).filter((c) => {
if (tab === "in") return c.is_incoming;
if (tab === "out") return !c.is_incoming;
if (tab === "bounced") return c.status === "bounced";
return true;
});
return (
<div>
<PageHeader
title="چک‌ها"
description="لیست و ثبت چک‌های دریافتی و پرداختی."
actions={
<Button type="button" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" />
ثبت چک
</Button>
}
/>
<Tabs
value={tab}
onChange={(v) => setTab(v as typeof tab)}
items={[
{ id: "all", label: "همه" },
{ id: "in", label: "دریافتی" },
{ id: "out", label: "پرداختی" },
{ id: "bounced", label: "برگشتی" },
]}
/>
<div className="mt-4">
<DataTable
columns={[
{ key: "cheque_number", header: "شماره چک" },
{
key: "due_date",
header: "سررسید",
render: (r) => (r.due_date ? <JalaliDateText value={String(r.due_date)} /> : "—"),
},
{
key: "issue_date",
header: "تاریخ",
render: (r) => <JalaliDateText value={String(r.issue_date)} />,
},
{ key: "payee", header: "ذینفع" },
{
key: "status",
header: "وضعیت",
render: (r) => <Badge>{String(r.status)}</Badge>,
},
{
key: "amount",
header: "مبلغ",
render: (r) => formatMoney(String(r.amount)),
},
]}
rows={rows as unknown as Record<string, unknown>[]}
empty={<EmptyState title="چکی ثبت نشده" />}
/>
</div>
<Dialog open={open} onClose={() => setOpen(false)} title="ثبت چک جدید" size="lg">
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
<div className="sm:col-span-2">
<FormField label="نوع">
<Select
value={form.watch("is_incoming") ? "in" : "out"}
onChange={(e) => form.setValue("is_incoming", e.target.value === "in")}
>
<option value="in">دریافتی</option>
<option value="out">پرداختی</option>
</Select>
</FormField>
</div>
<FormField label="شماره چک">
<Input {...form.register("cheque_number", { required: true })} />
</FormField>
<FormField label="مبلغ">
<MoneyInput {...form.register("amount", { required: true })} />
</FormField>
<FormField label="تاریخ صدور">
<Controller
control={form.control}
name="issue_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</FormField>
<FormField label="سررسید">
<Controller
control={form.control}
name="due_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</FormField>
<FormField label="ذینفع / طرف حساب">
<Input {...form.register("payee")} />
</FormField>
<div className="flex justify-end gap-2 sm:col-span-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
</div>
);
}
export function ReceiptsPaymentsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [mode, setMode] = useState<"receipt" | "payment">("receipt");
const [open, setOpen] = useState(false);
const form = useForm({
defaultValues: {
number: "",
date: new Date().toISOString().slice(0, 10),
amount: "0",
payment_method: "cash",
cash_box_id: "",
bank_account_id: "",
counter_account_id: "",
description: "",
},
});
const receiptsQ = useQuery({
queryKey: ["accounting", tenantId, "receipts"],
queryFn: () => accountingApi.treasury.listReceipts(tenantId!),
enabled: !!tenantId,
});
const paymentsQ = useQuery({
queryKey: ["accounting", tenantId, "payments"],
queryFn: () => accountingApi.treasury.listPayments(tenantId!),
enabled: !!tenantId,
});
const cashBoxesQ = useQuery({
queryKey: ["accounting", tenantId, "cash-boxes"],
queryFn: () => accountingApi.treasury.listCashBoxes(tenantId!),
enabled: !!tenantId,
});
const bankAccountsQ = useQuery({
queryKey: ["accounting", tenantId, "bank-accounts"],
queryFn: () => accountingApi.treasury.listBankAccounts(tenantId!),
enabled: !!tenantId,
});
const accountsQ = useQuery({
queryKey: ["accounting", tenantId, "accounts"],
queryFn: () => accountingApi.accounts.list(tenantId!),
enabled: !!tenantId,
});
const policyQ = useQuery({
queryKey: ["accounting", tenantId, "posting-policy"],
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
enabled: !!tenantId,
});
const autoPost =
mode === "receipt"
? Boolean(policyQ.data?.auto_post?.treasury_receipt ?? true)
: Boolean(policyQ.data?.auto_post?.treasury_payment ?? true);
const createM = useMutation({
mutationFn: async (d: {
number: string;
date: string;
amount: string;
payment_method: string;
cash_box_id?: string;
bank_account_id?: string;
counter_account_id?: string;
description?: string;
}) => {
if (autoPost && !d.counter_account_id) {
throw new Error("حساب مقابل برای ثبت خودکار سند حسابداری الزامی است");
}
const counter = d.counter_account_id || undefined;
if (mode === "receipt") {
return accountingApi.treasury.createReceipt(tenantId!, {
receipt_number: d.number,
receipt_date: d.date,
amount: d.amount,
payment_method: d.payment_method,
cash_box_id: d.payment_method === "cash" ? d.cash_box_id || undefined : undefined,
bank_account_id: d.payment_method === "bank" ? d.bank_account_id || undefined : undefined,
counter_account_id: counter,
description: d.description,
});
}
return accountingApi.treasury.createPayment(tenantId!, {
payment_number: d.number,
payment_date: d.date,
amount: d.amount,
payment_method: d.payment_method,
cash_box_id: d.payment_method === "cash" ? d.cash_box_id || undefined : undefined,
bank_account_id: d.payment_method === "bank" ? d.bank_account_id || undefined : undefined,
counter_account_id: counter,
description: d.description,
});
},
onSuccess: async (res) => {
const vn =
res && typeof res === "object" && "voucher_number" in res
? String((res as { voucher_number?: string | null }).voucher_number || "")
: "";
toast.success(vn ? `ثبت شد — سند حسابداری ${vn}` : "ثبت شد");
setOpen(false);
form.reset();
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "receipts"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "payments"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (
!tenantId ||
receiptsQ.isLoading ||
paymentsQ.isLoading ||
cashBoxesQ.isLoading ||
bankAccountsQ.isLoading
) {
return <LoadingState />;
}
if (receiptsQ.error || paymentsQ.error || cashBoxesQ.error || bankAccountsQ.error) {
return (
<ErrorState
message={(receiptsQ.error || paymentsQ.error || cashBoxesQ.error || bankAccountsQ.error)!.message}
onRetry={() => {
receiptsQ.refetch();
paymentsQ.refetch();
}}
/>
);
}
const postable = (accountsQ.data ?? []).filter((a) => a.is_postable);
const rows =
mode === "receipt"
? (receiptsQ.data ?? []).map((r) => ({
id: r.id,
number: r.receipt_number,
date: r.receipt_date,
amount: r.amount,
payment_method: r.payment_method,
description: r.description,
voucher_id: r.voucher_id,
}))
: (paymentsQ.data ?? []).map((p) => ({
id: p.id,
number: p.payment_number,
date: p.payment_date,
amount: p.amount,
payment_method: p.payment_method,
description: p.description,
voucher_id: p.voucher_id,
}));
return (
<div>
<PageHeader
title="دریافت و پرداخت"
description={
autoPost
? "ثبت دریافت/پرداخت با صدور خودکار سند حسابداری (قابل تنظیم در اسناد اتوماتیک)."
: "ثبت دریافت/پرداخت خزانه — ثبت خودکار دفتر کل فعلاً خاموش است."
}
actions={
<Button type="button" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" />
ثبت جدید
</Button>
}
/>
<Tabs
value={mode}
onChange={(v) => setMode(v as typeof mode)}
items={[
{ id: "receipt", label: "دریافت‌ها" },
{ id: "payment", label: "پرداخت‌ها" },
]}
/>
<div className="mt-4">
<DataTable
columns={[
{ key: "number", header: "شماره" },
{
key: "date",
header: "تاریخ",
render: (r) => <JalaliDateText value={String(r.date)} />,
},
{ key: "payment_method", header: "روش" },
{
key: "amount",
header: "مبلغ",
render: (r) => formatMoney(String(r.amount)),
},
{
key: "voucher_id",
header: "سند GL",
render: (r) => (r.voucher_id ? <Badge tone="success">ثبتشده</Badge> : "—"),
},
{ key: "description", header: "توضیح" },
]}
rows={rows as unknown as Record<string, unknown>[]}
empty={<EmptyState title="موردی نیست" />}
/>
</div>
<Dialog open={open} onClose={() => setOpen(false)} title={mode === "receipt" ? "ثبت دریافت" : "ثبت پرداخت"}>
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
<div className="sm:col-span-2">
<FormField label="نوع عملیات">
<Select value={mode} onChange={(e) => setMode(e.target.value as typeof mode)}>
<option value="receipt">دریافت</option>
<option value="payment">پرداخت</option>
</Select>
</FormField>
</div>
<FormField label="شماره">
<Input {...form.register("number", { required: true })} />
</FormField>
<FormField label="مبلغ">
<MoneyInput {...form.register("amount", { required: true })} />
</FormField>
<FormField label="تاریخ">
<Controller
control={form.control}
name="date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</FormField>
<FormField label="روش">
<Select
value={form.watch("payment_method")}
onChange={(e) => form.setValue("payment_method", e.target.value)}
>
<option value="cash">نقد</option>
<option value="bank">حواله بانکی</option>
<option value="cheque">چک</option>
</Select>
</FormField>
<FormField label="صندوق">
<Select {...form.register("cash_box_id")} disabled={form.watch("payment_method") !== "cash"}>
<option value="">انتخاب صندوق</option>
{(cashBoxesQ.data ?? []).map((cashBox) => (
<option key={cashBox.id} value={cashBox.id}>
{cashBox.name}
</option>
))}
</Select>
</FormField>
<FormField label="حساب بانکی">
<Select {...form.register("bank_account_id")} disabled={form.watch("payment_method") !== "bank"}>
<option value="">انتخاب حساب</option>
{(bankAccountsQ.data ?? []).map((account) => (
<option key={account.id} value={account.id}>
{account.account_number}
</option>
))}
</Select>
</FormField>
<div className="sm:col-span-2">
<FormField label={mode === "receipt" ? "حساب مقابل (بستانکار)" : "حساب مقابل (بدهکار)"}>
<Select {...form.register("counter_account_id", { required: autoPost })}>
<option value="">انتخاب حساب</option>
{postable.map((a) => (
<option key={a.id} value={a.id}>
{a.code} {a.name}
</option>
))}
</Select>
</FormField>
{autoPost ? (
<p className="mt-1 text-xs text-[var(--muted)]">
ثبت خودکار سند دفتر کل فعال است حساب صندوق/بانک از تنظیمات صندوق/حساب بانکی گرفته میشود.
</p>
) : null}
</div>
<div className="sm:col-span-2">
<FormField label="توضیح">
<Input {...form.register("description")} />
</FormField>
</div>
<div className="flex justify-end gap-2 sm:col-span-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
</div>
);
}
export function TransfersPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const cashBoxesQ = useQuery({
queryKey: ["accounting", tenantId, "cash-boxes"],
queryFn: () => accountingApi.treasury.listCashBoxes(tenantId!),
enabled: !!tenantId,
});
const bankAccountsQ = useQuery({
queryKey: ["accounting", tenantId, "bank-accounts"],
queryFn: () => accountingApi.treasury.listBankAccounts(tenantId!),
enabled: !!tenantId,
});
const accountsQ = useQuery({
queryKey: ["accounting", tenantId, "accounts"],
queryFn: () => accountingApi.accounts.list(tenantId!),
enabled: !!tenantId,
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "transfers"],
queryFn: () => accountingApi.treasury.listTransfers(tenantId!),
enabled: !!tenantId,
});
const form = useForm({
defaultValues: {
transfer_date: new Date().toISOString().slice(0, 10),
amount: "0",
from_key: "",
to_key: "",
description: "",
},
});
const createM = useMutation({
mutationFn: (d: {
transfer_date: string;
amount: string;
from_key: string;
to_key: string;
description?: string;
}) => {
const [from_type, from_id] = d.from_key.split(":");
const [to_type, to_id] = d.to_key.split(":");
if (!from_type || !from_id || !to_type || !to_id) {
throw new Error("حساب مبدأ و مقصد را انتخاب کنید");
}
if (d.from_key === d.to_key) {
throw new Error("مبدأ و مقصد نباید یکسان باشند");
}
return accountingApi.treasury.createTransfer(tenantId!, {
transfer_date: d.transfer_date,
amount: d.amount,
from_type,
from_id,
to_type,
to_id,
description: d.description,
});
},
onSuccess: async (res) => {
const vn =
res && typeof res === "object" && "voucher_number" in res
? String((res as { voucher_number?: string | null }).voucher_number || "")
: "";
toast.success(vn ? `انتقال ثبت و سند ${vn} صادر شد` : "انتقال ثبت شد");
setOpen(false);
form.reset();
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "transfers"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] });
},
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()} />;
const labelMap = new Map<string, string>();
const accountOptions = [
...(cashBoxesQ.data ?? []).map((c) => {
const value = `cash:${c.id}`;
const label = `صندوق: ${c.name}`;
labelMap.set(value, label);
return { value, label };
}),
...(bankAccountsQ.data ?? []).map((b) => {
const value = `bank:${b.id}`;
const label = `بانک: ${b.account_number}`;
labelMap.set(value, label);
return { value, label };
}),
...(accountsQ.data ?? [])
.filter((a) => a.is_postable)
.map((a) => {
const value = `account:${a.id}`;
const label = `حساب: ${a.code}${a.name}`;
labelMap.set(value, label);
return { value, label };
}),
];
const sideLabel = (type: unknown, id: unknown) =>
labelMap.get(`${String(type)}:${String(id)}`) || `${String(type)}`;
return (
<div>
<PageHeader
title="انتقال بین حساب‌ها"
description="از صندوق، بانک یا هر حساب دفتر کل به هر حساب دیگر — سند اتومات: بدهکار مقصد / بستانکار مبدأ."
actions={
<Button type="button" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" />
انتقال جدید
</Button>
}
/>
<DataTable
columns={[
{
key: "transfer_date",
header: "تاریخ",
render: (r) => <JalaliDateText value={String(r.transfer_date)} />,
},
{
key: "amount",
header: "مبلغ",
render: (r) => formatMoney(String(r.amount)),
},
{
key: "from_id",
header: "از",
render: (r) => sideLabel(r.from_type, r.from_id),
},
{
key: "to_id",
header: "به",
render: (r) => sideLabel(r.to_type, r.to_id),
},
{
key: "voucher_id",
header: "سند GL",
render: (r) => (r.voucher_id ? <Badge tone="success">صادر شد</Badge> : "—"),
},
{ key: "description", header: "توضیح" },
]}
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState title="انتقالی ثبت نشده" />}
/>
<Dialog open={open} onClose={() => setOpen(false)} title="انتقال بین حساب‌ها">
<form className="grid gap-3" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
<FormField label="تاریخ">
<Controller
control={form.control}
name="transfer_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</FormField>
<FormField label="مبلغ">
<MoneyInput {...form.register("amount", { required: true })} />
</FormField>
<FormField label="از حساب (بستانکار می‌شود)" hint="مثال: بانک ملت، صندوق، اشخاص">
<Select
value={form.watch("from_key")}
onChange={(e) => form.setValue("from_key", e.target.value)}
>
<option value="">انتخاب</option>
{accountOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</Select>
</FormField>
<FormField label="به حساب (بدهکار می‌شود)" hint="مثال: اشخاص، هزینه، صندوق دیگر">
<Select
value={form.watch("to_key")}
onChange={(e) => form.setValue("to_key", e.target.value)}
>
<option value="">انتخاب</option>
{accountOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</Select>
</FormField>
<FormField label="توضیح">
<Input {...form.register("description")} />
</FormField>
<p className="text-xs text-[var(--muted)]">
با ذخیره، سند حسابداری خودکار ساخته میشود نیازی به ثبت دستی سند نیست.
</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending}>
ذخیره و صدور سند
</Button>
</div>
</form>
</Dialog>
</div>
);
}
export function ReconciliationPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const banksQ = useQuery({
queryKey: ["accounting", tenantId, "bank-accounts"],
queryFn: () => accountingApi.treasury.listBankAccounts(tenantId!),
enabled: !!tenantId,
});
const listQ = useQuery({
queryKey: ["accounting", tenantId, "reconciliations"],
queryFn: () => accountingApi.treasury.listReconciliations(tenantId!),
enabled: !!tenantId,
});
const form = useForm({
defaultValues: {
bank_account_id: "",
statement_date: new Date().toISOString().slice(0, 10),
statement_balance: "0",
book_balance: "0",
},
});
const createM = useMutation({
mutationFn: (d: {
bank_account_id: string;
statement_date: string;
statement_balance: string;
book_balance: string;
}) => accountingApi.treasury.createReconciliation(tenantId!, d),
onSuccess: async () => {
toast.success("تطبیق ثبت شد");
setOpen(false);
form.reset();
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "reconciliations"] });
},
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 type="button" onClick={() => setOpen(true)}>
<Plus className="h-4 w-4" />
تطبیق جدید
</Button>
}
/>
<DataTable
columns={[
{
key: "statement_date",
header: "تاریخ صورت‌حساب",
render: (r) => <JalaliDateText value={String(r.statement_date)} />,
},
{
key: "statement_balance",
header: "مانده صورت‌حساب",
render: (r) => formatMoney(String(r.statement_balance)),
},
{
key: "book_balance",
header: "مانده دفتر",
render: (r) => formatMoney(String(r.book_balance)),
},
{
key: "difference",
header: "اختلاف",
render: (r) => formatMoney(String(r.difference)),
},
{
key: "status",
header: "وضعیت",
render: (r) => <Badge>{String(r.status)}</Badge>,
},
]}
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState title="تطبیقی ثبت نشده" />}
/>
<Dialog open={open} onClose={() => setOpen(false)} title="تطبیق بانکی">
<form className="grid gap-3" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
<FormField label="حساب بانکی">
<Select
value={form.watch("bank_account_id")}
onChange={(e) => form.setValue("bank_account_id", e.target.value)}
>
<option value="">انتخاب</option>
{(banksQ.data ?? []).map((b) => (
<option key={b.id} value={b.id}>
{b.account_number}
</option>
))}
</Select>
</FormField>
<FormField label="تاریخ">
<Controller
control={form.control}
name="statement_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</FormField>
<FormField label="مانده صورت‌حساب">
<MoneyInput {...form.register("statement_balance")} />
</FormField>
<FormField label="مانده دفتر">
<MoneyInput {...form.register("book_balance")} />
</FormField>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
</div>
);
}