TorbatYar/frontend/app/accounting/treasury/page.tsx
Mortezakoohjani 12c8615615 Ship enterprise Accounting FE/API with CRUD parity and production wiring.
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>
2026-07-24 15:26:43 +03:30

484 lines
18 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 { Plus } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney } from "@/lib/utils";
import {
PageHeader,
Button,
Dialog,
Input,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
Tabs,
FormField,
Select,
MoneyInput,
DatePicker,
} from "@/components/ds";
const cashBoxSchema = z.object({
code: z.string().min(1, "کد الزامی است"),
name: z.string().min(1, "نام الزامی است"),
opening_balance: z.string().optional(),
});
const bankSchema = z.object({
code: z.string().min(1, "کد الزامی است"),
name: z.string().min(1, "نام الزامی است"),
});
const bankAccountSchema = z.object({
bank_id: z.string().min(1, "بانک را انتخاب کنید"),
account_number: z.string().min(1, "شماره حساب الزامی است"),
iban: z.string().optional(),
});
const receiptSchema = z.object({
cash_box_id: z.string().min(1, "صندوق را انتخاب کنید"),
amount: z.string().min(1, "مبلغ الزامی است"),
debit_account_id: z.string().min(1, "حساب بدهکار را انتخاب کنید"),
credit_account_id: z.string().min(1, "حساب بستانکار را انتخاب کنید"),
description: z.string().optional(),
transaction_date: z.string().optional(),
});
type TabId = "cash-boxes" | "banks" | "bank-accounts" | "receipt";
export default function TreasuryPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [tab, setTab] = useState<TabId>("cash-boxes");
const [cashBoxOpen, setCashBoxOpen] = useState(false);
const [bankOpen, setBankOpen] = useState(false);
const [bankAccountOpen, setBankAccountOpen] = useState(false);
const cashBoxesQ = useQuery({
queryKey: ["accounting", tenantId, "cash-boxes"],
queryFn: () => accountingApi.treasury.listCashBoxes(tenantId!),
enabled: !!tenantId,
});
const banksQ = useQuery({
queryKey: ["accounting", tenantId, "banks"],
queryFn: () => accountingApi.treasury.listBanks(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 cashBoxForm = useForm<z.infer<typeof cashBoxSchema>>({
resolver: zodResolver(cashBoxSchema),
defaultValues: { code: "", name: "", opening_balance: "0" },
});
const bankForm = useForm<z.infer<typeof bankSchema>>({
resolver: zodResolver(bankSchema),
defaultValues: { code: "", name: "" },
});
const bankAccountForm = useForm<z.infer<typeof bankAccountSchema>>({
resolver: zodResolver(bankAccountSchema),
defaultValues: { bank_id: "", account_number: "", iban: "" },
});
const receiptForm = useForm<z.infer<typeof receiptSchema>>({
resolver: zodResolver(receiptSchema),
defaultValues: {
cash_box_id: "",
amount: "",
debit_account_id: "",
credit_account_id: "",
description: "",
transaction_date: "",
},
});
const postableAccounts = (accountsQ.data ?? []).filter((a) => a.is_postable && a.status === "active");
const invalidateCashBoxes = () =>
qc.invalidateQueries({ queryKey: ["accounting", tenantId, "cash-boxes"] });
const invalidateBanks = () => qc.invalidateQueries({ queryKey: ["accounting", tenantId, "banks"] });
const invalidateBankAccounts = () =>
qc.invalidateQueries({ queryKey: ["accounting", tenantId, "bank-accounts"] });
const createCashBoxM = useMutation({
mutationFn: (d: z.infer<typeof cashBoxSchema>) =>
accountingApi.treasury.createCashBox(tenantId!, {
code: d.code,
name: d.name,
opening_balance: d.opening_balance || "0",
}),
onSuccess: async () => {
toast.success("صندوق ایجاد شد");
setCashBoxOpen(false);
cashBoxForm.reset();
await invalidateCashBoxes();
},
onError: (e: Error) => toast.error(e.message),
});
const archiveCashBoxM = useMutation({
mutationFn: (id: string) =>
accountingApi.treasury.updateCashBox(tenantId!, id, { is_active: false }),
onSuccess: async () => {
toast.success("صندوق بایگانی شد");
await invalidateCashBoxes();
},
onError: (e: Error) => toast.error(e.message),
});
const createBankM = useMutation({
mutationFn: (d: z.infer<typeof bankSchema>) => accountingApi.treasury.createBank(tenantId!, d),
onSuccess: async () => {
toast.success("بانک ثبت شد");
setBankOpen(false);
bankForm.reset();
await invalidateBanks();
},
onError: (e: Error) => toast.error(e.message),
});
const createBankAccountM = useMutation({
mutationFn: (d: z.infer<typeof bankAccountSchema>) =>
accountingApi.treasury.createBankAccount(tenantId!, {
bank_id: d.bank_id,
account_number: d.account_number,
iban: d.iban || undefined,
}),
onSuccess: async () => {
toast.success("حساب بانکی ثبت شد");
setBankAccountOpen(false);
bankAccountForm.reset();
await invalidateBankAccounts();
},
onError: (e: Error) => toast.error(e.message),
});
const receiptM = useMutation({
mutationFn: (d: z.infer<typeof receiptSchema>) =>
accountingApi.treasury.cashReceipt(tenantId!, {
cash_box_id: d.cash_box_id,
amount: d.amount,
debit_account_id: d.debit_account_id,
credit_account_id: d.credit_account_id,
description: d.description || undefined,
transaction_date: d.transaction_date || undefined,
}),
onSuccess: () => {
toast.success("رسید دریافت نقد ثبت شد");
receiptForm.reset();
void invalidateCashBoxes();
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId) return <LoadingState />;
const loading = cashBoxesQ.isLoading || banksQ.isLoading || bankAccountsQ.isLoading || accountsQ.isLoading;
if (loading) return <LoadingState />;
const err = cashBoxesQ.error || banksQ.error || bankAccountsQ.error || accountsQ.error;
if (err) {
return (
<ErrorState
message={err instanceof Error ? err.message : "خطا"}
onRetry={() => {
void cashBoxesQ.refetch();
void banksQ.refetch();
void bankAccountsQ.refetch();
void accountsQ.refetch();
}}
/>
);
}
const bankName = (id: string) => banksQ.data?.find((b) => b.id === id)?.name ?? id;
const tabActions: Record<TabId, React.ReactNode> = {
"cash-boxes": (
<Button onClick={() => setCashBoxOpen(true)}>
<Plus className="h-4 w-4" />
صندوق جدید
</Button>
),
banks: (
<Button onClick={() => setBankOpen(true)}>
<Plus className="h-4 w-4" />
بانک جدید
</Button>
),
"bank-accounts": (
<Button onClick={() => setBankAccountOpen(true)} disabled={!banksQ.data?.length}>
<Plus className="h-4 w-4" />
حساب بانکی
</Button>
),
receipt: null,
};
return (
<div>
<PageHeader
title="خزانه"
description="صندوق‌ها، بانک‌ها و دریافت نقد — داده از API خزانه."
actions={tabActions[tab]}
/>
<Tabs
className="mb-6"
value={tab}
onChange={(id) => setTab(id as TabId)}
items={[
{ id: "cash-boxes", label: "صندوق‌ها" },
{ id: "banks", label: "بانک‌ها" },
{ id: "bank-accounts", label: "حساب‌های بانکی" },
{ id: "receipt", label: "دریافت نقد" },
]}
/>
{tab === "cash-boxes" && (
<DataTable
columns={[
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{
key: "current_balance",
header: "مانده",
render: (r) => formatMoney(String(r.current_balance)),
},
{
key: "is_active",
header: "وضعیت",
render: (r) => (
<Badge tone={r.is_active ? "success" : "default"}>
{r.is_active ? "فعال" : "غیرفعال"}
</Badge>
),
},
{
key: "actions",
header: "عملیات",
render: (r) =>
r.is_active ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={archiveCashBoxM.isPending}
onClick={() => archiveCashBoxM.mutate(String(r.id))}
>
بایگانی
</Button>
) : (
"—"
),
},
]}
rows={(cashBoxesQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={
<EmptyState action={<Button onClick={() => setCashBoxOpen(true)}>ایجاد صندوق</Button>} />
}
/>
)}
{tab === "banks" && (
<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={(banksQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState action={<Button onClick={() => setBankOpen(true)}>ثبت بانک</Button>} />}
/>
)}
{tab === "bank-accounts" && (
<DataTable
columns={[
{
key: "bank_id",
header: "بانک",
render: (r) => bankName(String(r.bank_id)),
},
{ key: "account_number", header: "شماره حساب" },
{ key: "iban", header: "شبا", render: (r) => String(r.iban ?? "—") },
{
key: "is_active",
header: "وضعیت",
render: (r) => (
<Badge tone={r.is_active ? "success" : "default"}>
{r.is_active ? "فعال" : "غیرفعال"}
</Badge>
),
},
]}
rows={(bankAccountsQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={
<EmptyState action={<Button onClick={() => setBankAccountOpen(true)}>ثبت حساب</Button>} />
}
/>
)}
{tab === "receipt" && (
<form
className="mx-auto max-w-lg space-y-4 rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-5"
onSubmit={receiptForm.handleSubmit((d) => receiptM.mutate(d))}
>
<FormField label="صندوق" error={receiptForm.formState.errors.cash_box_id?.message}>
<Select {...receiptForm.register("cash_box_id")}>
<option value="">انتخاب صندوق</option>
{(cashBoxesQ.data ?? [])
.filter((c) => c.is_active)
.map((c) => (
<option key={c.id} value={c.id}>
{c.code} {c.name}
</option>
))}
</Select>
</FormField>
<FormField label="مبلغ" error={receiptForm.formState.errors.amount?.message}>
<MoneyInput {...receiptForm.register("amount")} placeholder="0" />
</FormField>
<FormField label="حساب بدهکار" error={receiptForm.formState.errors.debit_account_id?.message}>
<Select {...receiptForm.register("debit_account_id")}>
<option value="">انتخاب حساب</option>
{postableAccounts.map((a) => (
<option key={a.id} value={a.id}>
{a.code} {a.name}
</option>
))}
</Select>
</FormField>
<FormField label="حساب بستانکار" error={receiptForm.formState.errors.credit_account_id?.message}>
<Select {...receiptForm.register("credit_account_id")}>
<option value="">انتخاب حساب</option>
{postableAccounts.map((a) => (
<option key={a.id} value={a.id}>
{a.code} {a.name}
</option>
))}
</Select>
</FormField>
<FormField label="تاریخ (اختیاری)">
<Controller
control={receiptForm.control}
name="transaction_date"
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
)}
/>
</FormField>
<FormField label="شرح (اختیاری)">
<Input {...receiptForm.register("description")} />
</FormField>
<div className="flex justify-end">
<Button type="submit" disabled={receiptM.isPending}>
ثبت دریافت
</Button>
</div>
</form>
)}
<Dialog open={cashBoxOpen} onClose={() => setCashBoxOpen(false)} title="صندوق جدید">
<form className="space-y-3" onSubmit={cashBoxForm.handleSubmit((d) => createCashBoxM.mutate(d))}>
<FormField label="کد" error={cashBoxForm.formState.errors.code?.message}>
<Input {...cashBoxForm.register("code")} />
</FormField>
<FormField label="نام" error={cashBoxForm.formState.errors.name?.message}>
<Input {...cashBoxForm.register("name")} />
</FormField>
<FormField label="مانده اولیه">
<MoneyInput {...cashBoxForm.register("opening_balance")} />
</FormField>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setCashBoxOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createCashBoxM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={bankOpen} onClose={() => setBankOpen(false)} title="بانک جدید">
<form className="space-y-3" onSubmit={bankForm.handleSubmit((d) => createBankM.mutate(d))}>
<FormField label="کد" error={bankForm.formState.errors.code?.message}>
<Input {...bankForm.register("code")} />
</FormField>
<FormField label="نام" error={bankForm.formState.errors.name?.message}>
<Input {...bankForm.register("name")} />
</FormField>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setBankOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createBankM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={bankAccountOpen} onClose={() => setBankAccountOpen(false)} title="حساب بانکی جدید">
<form
className="space-y-3"
onSubmit={bankAccountForm.handleSubmit((d) => createBankAccountM.mutate(d))}
>
<FormField label="بانک" error={bankAccountForm.formState.errors.bank_id?.message}>
<Select {...bankAccountForm.register("bank_id")}>
<option value="">انتخاب بانک</option>
{(banksQ.data ?? []).map((b) => (
<option key={b.id} value={b.id}>
{b.code} {b.name}
</option>
))}
</Select>
</FormField>
<FormField label="شماره حساب" error={bankAccountForm.formState.errors.account_number?.message}>
<Input {...bankAccountForm.register("account_number")} dir="ltr" />
</FormField>
<FormField label="شبا">
<Input {...bankAccountForm.register("iban")} dir="ltr" placeholder="IR…" />
</FormField>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setBankAccountOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createBankAccountM.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
</div>
);
}