TorbatYar/frontend/app/accounting/chart-of-accounts/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

643 lines
24 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 { useMemo, useState } from "react";
import { 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, Archive, Pencil } from "lucide-react";
import { accountingApi, type Account, type ChartOfAccounts } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import {
PageHeader,
Button,
Dialog,
Input,
Label,
FieldError,
Textarea,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
Select,
Drawer,
TableToolbar,
Tabs,
Checkbox,
} from "@/components/ds";
const chartSchema = z.object({
code: z.string().min(1, "کد الزامی است"),
name: z.string().min(1, "نام الزامی است"),
description: z.string().optional(),
is_default: z.boolean().optional(),
});
const accountSchema = z.object({
chart_id: z.string().uuid("دفتر حساب را انتخاب کنید"),
code: z.string().min(1, "کد الزامی است"),
name: z.string().min(1, "نام الزامی است"),
account_type: z.string().min(1),
account_category: z.string().min(1),
parent_id: z.string().optional(),
is_group: z.boolean().optional(),
is_postable: z.boolean().optional(),
description: z.string().optional(),
});
type ChartForm = z.infer<typeof chartSchema>;
type AccountForm = z.infer<typeof accountSchema>;
const ACCOUNT_TYPES = [
{ value: "asset", label: "دارایی" },
{ value: "liability", label: "بدهی" },
{ value: "equity", label: "حقوق صاحبان سهام" },
{ value: "revenue", label: "درآمد" },
{ value: "expense", label: "هزینه" },
];
const ACCOUNT_CATEGORIES = [
{ value: "current_asset", label: "دارایی جاری" },
{ value: "non_current_asset", label: "دارایی غیرجاری" },
{ value: "current_liability", label: "بدهی جاری" },
{ value: "non_current_liability", label: "بدهی غیرجاری" },
{ value: "equity", label: "حقوق صاحبان سهام" },
{ value: "operating_revenue", label: "درآمد عملیاتی" },
{ value: "other_revenue", label: "سایر درآمدها" },
{ value: "operating_expense", label: "هزینه عملیاتی" },
{ value: "other_expense", label: "سایر هزینه‌ها" },
];
export default function ChartOfAccountsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [tab, setTab] = useState("accounts");
const [chartOpen, setChartOpen] = useState(false);
const [accountOpen, setAccountOpen] = useState(false);
const [editChart, setEditChart] = useState<ChartOfAccounts | null>(null);
const [selected, setSelected] = useState<Account | null>(null);
const [filterChart, setFilterChart] = useState<string>("");
const [search, setSearch] = useState("");
const [showInactive, setShowInactive] = useState(false);
const chartsQ = useQuery({
queryKey: ["accounting", tenantId, "charts"],
queryFn: () => accountingApi.charts.list(tenantId!),
enabled: !!tenantId,
});
const accountsQ = useQuery({
queryKey: ["accounting", tenantId, "accounts", filterChart],
queryFn: () => accountingApi.accounts.list(tenantId!, 1, 500, filterChart || undefined),
enabled: !!tenantId,
});
const chartForm = useForm<ChartForm>({
resolver: zodResolver(chartSchema),
defaultValues: { code: "", name: "", description: "", is_default: false },
});
const accountForm = useForm<AccountForm>({
resolver: zodResolver(accountSchema),
defaultValues: {
chart_id: "",
code: "",
name: "",
account_type: "asset",
account_category: "current_asset",
parent_id: "",
is_group: false,
is_postable: true,
description: "",
},
});
const editChartForm = useForm<ChartForm>({
resolver: zodResolver(chartSchema.omit({ code: true }).extend({ code: z.string().optional() })),
});
const editAccountForm = useForm({
defaultValues: { name: "", status: "active", description: "", is_postable: true },
});
const invalidate = async () => {
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "charts"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "accounts"] });
};
const createChart = useMutation({
mutationFn: (data: ChartForm) => accountingApi.charts.create(tenantId!, data),
onSuccess: async () => {
toast.success("دفتر حساب ایجاد شد");
setChartOpen(false);
chartForm.reset();
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const updateChart = useMutation({
mutationFn: (data: ChartForm) =>
accountingApi.charts.update(tenantId!, editChart!.id, {
name: data.name,
description: data.description,
is_default: data.is_default,
}),
onSuccess: async () => {
toast.success("دفتر به‌روز شد");
setEditChart(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const archiveChart = useMutation({
mutationFn: (id: string) => accountingApi.charts.archive(tenantId!, id),
onSuccess: async () => {
toast.success("دفتر آرشیو شد");
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const createAccount = useMutation({
mutationFn: (data: AccountForm) =>
accountingApi.accounts.create(tenantId!, {
...data,
parent_id: data.parent_id || null,
is_postable: data.is_postable ?? true,
is_group: data.is_group ?? false,
}),
onSuccess: async () => {
toast.success("حساب ایجاد شد");
setAccountOpen(false);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const updateAccount = useMutation({
mutationFn: (body: { name: string; status: string; description?: string; is_postable: boolean }) =>
accountingApi.accounts.update(tenantId!, selected!.id, body),
onSuccess: async () => {
toast.success("حساب به‌روز شد");
setSelected(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const archiveAccount = useMutation({
mutationFn: (id: string) => accountingApi.accounts.archive(tenantId!, id),
onSuccess: async () => {
toast.success("حساب آرشیو شد");
setSelected(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const rows = useMemo(() => {
let list = accountsQ.data ?? [];
if (!showInactive) list = list.filter((a) => a.status === "active");
if (search.trim()) {
const q = search.trim().toLowerCase();
list = list.filter(
(a) => a.code.toLowerCase().includes(q) || a.name.toLowerCase().includes(q)
);
}
return [...list].sort((a, b) => a.code.localeCompare(b.code, "fa"));
}, [accountsQ.data, search, showInactive]);
if (!tenantId) return <LoadingState />;
if (chartsQ.isLoading || accountsQ.isLoading) return <LoadingState />;
if (chartsQ.error || accountsQ.error) {
return (
<ErrorState
message={(chartsQ.error || accountsQ.error)?.message || "خطا"}
onRetry={() => {
void chartsQ.refetch();
void accountsQ.refetch();
}}
/>
);
}
const chartName = (id: string) => chartsQ.data?.find((c) => c.id === id)?.name ?? id;
return (
<div>
<PageHeader
title="دفتر حساب‌ها"
description="درخت حساب‌ها، ویرایش، آرشیو و جزئیات — API واقعی."
actions={
<>
<Button type="button" variant="outline" onClick={() => setChartOpen(true)}>
<Plus className="h-4 w-4" />
دفتر جدید
</Button>
<Button
type="button"
onClick={() => {
if (chartsQ.data?.[0]) accountForm.setValue("chart_id", filterChart || chartsQ.data[0].id);
setAccountOpen(true);
}}
disabled={!chartsQ.data?.length}
>
<Plus className="h-4 w-4" />
حساب جدید
</Button>
</>
}
/>
<Tabs
className="mb-4"
value={tab}
onChange={setTab}
items={[
{ id: "accounts", label: "حساب‌ها" },
{ id: "charts", label: "دفاتر" },
]}
/>
{tab === "charts" ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{chartsQ.data?.map((c) => (
<div key={c.id} className="rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-4">
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-semibold text-secondary">{c.name}</p>
<p className="mt-1 text-sm text-[var(--muted)]" dir="ltr">
{c.code}
</p>
</div>
<div className="flex flex-col items-end gap-1">
{c.is_default ? <Badge tone="primary">پیشفرض</Badge> : null}
<Badge tone={c.is_active ? "success" : "default"}>
{c.is_active ? "فعال" : "آرشیو"}
</Badge>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setEditChart(c);
editChartForm.reset({
code: c.code,
name: c.name,
description: c.description ?? "",
is_default: c.is_default,
});
}}
>
<Pencil className="h-3.5 w-3.5" />
ویرایش
</Button>
{c.is_active ? (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => archiveChart.mutate(c.id)}
>
<Archive className="h-3.5 w-3.5" />
آرشیو
</Button>
) : null}
<Button type="button" size="sm" variant="ghost" onClick={() => { setFilterChart(c.id); setTab("accounts"); }}>
مشاهده حسابها
</Button>
</div>
</div>
))}
{!chartsQ.data?.length ? (
<EmptyState
title="دفتری ثبت نشده"
description="ابتدا یک دفتر حساب بسازید یا از راه‌اندازی قالب وارد کنید."
action={
<Button type="button" onClick={() => setChartOpen(true)}>
ایجاد دفتر
</Button>
}
/>
) : null}
</div>
) : (
<>
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="جستجوی کد یا نام…"
actions={
<>
<Select
value={filterChart}
onChange={(e) => setFilterChart(e.target.value)}
className="w-44"
>
<option value="">همه دفاتر</option>
{chartsQ.data?.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</Select>
<Checkbox
label="نمایش غیرفعال"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
/>
</>
}
/>
{/* Mobile cards */}
<div className="mb-4 space-y-2 md:hidden">
{rows.map((r) => (
<button
key={r.id}
type="button"
className="w-full rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-3 text-right"
onClick={() => {
setSelected(r);
editAccountForm.reset({
name: r.name,
status: r.status,
description: r.description ?? "",
is_postable: r.is_postable,
});
}}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-secondary">{r.name}</span>
<Badge tone={r.status === "active" ? "success" : "default"}>{r.status}</Badge>
</div>
<p className="mt-1 text-xs text-[var(--muted)]" dir="ltr">
{r.code} · {chartName(r.chart_id)}
</p>
</button>
))}
{!rows.length ? <EmptyState title="حسابی نیست" description="حساب جدید اضافه کنید." /> : null}
</div>
<div className="hidden md:block">
<DataTable
columns={[
{
key: "code",
header: "کد",
render: (r) => (
<span style={{ paddingInlineStart: `${(Number(r.level) - 1) * 12}px` }} dir="ltr">
{String(r.code)}
</span>
),
},
{ key: "name", header: "نام" },
{
key: "chart_id",
header: "دفتر",
render: (r) => chartName(String(r.chart_id)),
},
{
key: "account_type",
header: "نوع",
render: (r) =>
ACCOUNT_TYPES.find((t) => t.value === r.account_type)?.label ?? String(r.account_type),
},
{
key: "status",
header: "وضعیت",
render: (r) => (
<Badge tone={r.status === "active" ? "success" : "default"}>{String(r.status)}</Badge>
),
},
{
key: "id",
header: "",
render: (r) => (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
const acc = r as unknown as Account;
setSelected(acc);
editAccountForm.reset({
name: acc.name,
status: acc.status,
description: acc.description ?? "",
is_postable: acc.is_postable,
});
}}
>
جزئیات
</Button>
),
},
]}
rows={rows as unknown as Record<string, unknown>[]}
empty={
<EmptyState
title="حسابی ثبت نشده"
description="ابتدا دفتر بسازید یا از قالب وارد کنید."
action={
<Button type="button" onClick={() => setChartOpen(true)}>
ایجاد دفتر حساب
</Button>
}
/>
}
/>
</div>
</>
)}
<Dialog open={chartOpen} onClose={() => setChartOpen(false)} title="ایجاد دفتر حساب">
<form className="space-y-3" onSubmit={chartForm.handleSubmit((d) => createChart.mutate(d))}>
<div>
<Label htmlFor="chart-code">کد</Label>
<Input id="chart-code" {...chartForm.register("code")} />
<FieldError>{chartForm.formState.errors.code?.message}</FieldError>
</div>
<div>
<Label htmlFor="chart-name">نام</Label>
<Input id="chart-name" {...chartForm.register("name")} />
<FieldError>{chartForm.formState.errors.name?.message}</FieldError>
</div>
<div>
<Label htmlFor="chart-desc">توضیح</Label>
<Textarea id="chart-desc" {...chartForm.register("description")} />
</div>
<Checkbox label="دفتر پیش‌فرض" {...chartForm.register("is_default")} />
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => setChartOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createChart.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={!!editChart} onClose={() => setEditChart(null)} title="ویرایش دفتر حساب">
<form className="space-y-3" onSubmit={editChartForm.handleSubmit((d) => updateChart.mutate(d as ChartForm))}>
<div>
<Label>کد</Label>
<Input disabled value={editChart?.code ?? ""} />
</div>
<div>
<Label htmlFor="edit-chart-name">نام</Label>
<Input id="edit-chart-name" {...editChartForm.register("name")} />
</div>
<div>
<Label>توضیح</Label>
<Textarea {...editChartForm.register("description")} />
</div>
<Checkbox label="دفتر پیش‌فرض" {...editChartForm.register("is_default")} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setEditChart(null)}>
انصراف
</Button>
<Button type="submit" disabled={updateChart.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={accountOpen} onClose={() => setAccountOpen(false)} title="ایجاد حساب" size="lg">
<form
className="grid gap-3 sm:grid-cols-2"
onSubmit={accountForm.handleSubmit((d) => createAccount.mutate(d))}
>
<div className="sm:col-span-2">
<Label>دفتر حساب</Label>
<Select {...accountForm.register("chart_id")}>
<option value="">انتخاب کنید</option>
{chartsQ.data?.filter((c) => c.is_active).map((c) => (
<option key={c.id} value={c.id}>
{c.code} {c.name}
</option>
))}
</Select>
<FieldError>{accountForm.formState.errors.chart_id?.message}</FieldError>
</div>
<div>
<Label>کد</Label>
<Input {...accountForm.register("code")} />
<FieldError>{accountForm.formState.errors.code?.message}</FieldError>
</div>
<div>
<Label>نام</Label>
<Input {...accountForm.register("name")} />
<FieldError>{accountForm.formState.errors.name?.message}</FieldError>
</div>
<div>
<Label>نوع</Label>
<Select {...accountForm.register("account_type")}>
{ACCOUNT_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
</div>
<div>
<Label>دسته</Label>
<Select {...accountForm.register("account_category")}>
{ACCOUNT_CATEGORIES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
</div>
<div className="sm:col-span-2">
<Label>حساب والد</Label>
<Select {...accountForm.register("parent_id")}>
<option value="">بدون والد</option>
{(accountsQ.data ?? [])
.filter((a) => a.status === "active")
.map((a) => (
<option key={a.id} value={a.id}>
{a.code} {a.name}
</option>
))}
</Select>
</div>
<Checkbox label="گروه حساب" {...accountForm.register("is_group")} />
<Checkbox label="قابل ثبت سند" {...accountForm.register("is_postable")} />
<div className="flex justify-end gap-2 sm:col-span-2">
<Button type="button" variant="outline" onClick={() => setAccountOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createAccount.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Drawer
open={!!selected}
onClose={() => setSelected(null)}
title={selected ? `${selected.code}${selected.name}` : "حساب"}
description={selected ? chartName(selected.chart_id) : undefined}
>
{selected ? (
<form
className="space-y-3"
onSubmit={editAccountForm.handleSubmit((d) =>
updateAccount.mutate({
name: d.name,
status: d.status,
description: d.description,
is_postable: d.is_postable,
})
)}
>
<div>
<Label>نام</Label>
<Input {...editAccountForm.register("name")} />
</div>
<div>
<Label>وضعیت</Label>
<Select {...editAccountForm.register("status")}>
<option value="active">active</option>
<option value="inactive">inactive</option>
<option value="closed">closed</option>
</Select>
</div>
<div>
<Label>توضیح</Label>
<Textarea {...editAccountForm.register("description")} />
</div>
<Checkbox label="قابل ثبت سند" {...editAccountForm.register("is_postable")} />
<p className="text-xs text-[var(--muted)]">
نوع: {selected.account_type} · دسته: {selected.account_category} · سطح: {selected.level}
</p>
<div className="flex flex-wrap gap-2 pt-2">
<Button type="submit" disabled={updateAccount.isPending}>
ذخیره تغییرات
</Button>
{selected.status === "active" ? (
<Button
type="button"
variant="outline"
onClick={() => archiveAccount.mutate(selected.id)}
>
آرشیو
</Button>
) : null}
</div>
</form>
) : null}
</Drawer>
</div>
);
}