Wire DocumentNumberSequence allocator, ledger-based subsidiary/detail reports, and posted-voucher reopen flow without mock ops forms. Co-authored-by: Cursor <cursoragent@cursor.com>
491 lines
18 KiB
TypeScript
491 lines
18 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useSearchParams } from "next/navigation";
|
||
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, parseMoneyInput } 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";
|
||
|
||
function isTabId(v: string | null): v is TabId {
|
||
return v === "cash-boxes" || v === "banks" || v === "bank-accounts" || v === "receipt";
|
||
}
|
||
|
||
export default function TreasuryPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const searchParams = useSearchParams();
|
||
const initialTab = searchParams.get("tab");
|
||
const [tab, setTab] = useState<TabId>(isTabId(initialTab) ? initialTab : "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: parseMoneyInput(d.opening_balance || "0") || "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: parseMoneyInput(d.amount) || "0",
|
||
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>
|
||
);
|
||
}
|