TorbatYar/frontend/app/accounting/fiscal/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

580 lines
21 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 { Plus, Pencil } from "lucide-react";
import { accountingApi, type FiscalPeriod, type FiscalYear } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import {
PageHeader,
Button,
Dialog,
ConfirmDialog,
Input,
Label,
FieldError,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
DatePicker,
JalaliDateText,
Select,
Checkbox,
} from "@/components/ds";
const yearSchema = z.object({
name: z.string().min(1, "نام الزامی است"),
start_date: z.string().min(1, "تاریخ شروع الزامی است"),
end_date: z.string().min(1, "تاریخ پایان الزامی است"),
is_current: z.boolean().optional(),
});
const periodSchema = z.object({
fiscal_year_id: z.string().uuid("سال مالی را انتخاب کنید"),
name: z.string().min(1, "نام الزامی است"),
period_number: z.number().int().min(1),
start_date: z.string().min(1),
end_date: z.string().min(1),
is_current: z.boolean().optional(),
});
const editNameSchema = z.object({ name: z.string().min(1, "نام الزامی است") });
export default function FiscalPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [yearOpen, setYearOpen] = useState(false);
const [periodOpen, setPeriodOpen] = useState(false);
const [editYear, setEditYear] = useState<FiscalYear | null>(null);
const [editPeriod, setEditPeriod] = useState<FiscalPeriod | null>(null);
const [confirm, setConfirm] = useState<
| { type: "year-close"; id: string }
| { type: "period"; id: string; action: "lock" | "close" | "reopen" }
| null
>(null);
const yearsQ = useQuery({
queryKey: ["accounting", tenantId, "fiscal-years"],
queryFn: () => accountingApi.fiscal.listYears(tenantId!),
enabled: !!tenantId,
});
const periodsQ = useQuery({
queryKey: ["accounting", tenantId, "fiscal-periods"],
queryFn: () => accountingApi.fiscal.listPeriods(tenantId!),
enabled: !!tenantId,
});
const yearForm = useForm<z.infer<typeof yearSchema>>({
resolver: zodResolver(yearSchema),
defaultValues: { name: "", start_date: "", end_date: "", is_current: true },
});
const periodForm = useForm<z.infer<typeof periodSchema>>({
resolver: zodResolver(periodSchema),
defaultValues: {
fiscal_year_id: "",
name: "",
period_number: 1,
start_date: "",
end_date: "",
is_current: false,
},
});
const editYearForm = useForm<z.infer<typeof editNameSchema>>({
resolver: zodResolver(editNameSchema),
});
const editPeriodForm = useForm<z.infer<typeof editNameSchema>>({
resolver: zodResolver(editNameSchema),
});
const invalidate = async () => {
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "fiscal-years"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "fiscal-periods"] });
};
const createYear = useMutation({
mutationFn: (d: z.infer<typeof yearSchema>) => accountingApi.fiscal.createYear(tenantId!, d),
onSuccess: async () => {
toast.success("سال مالی ایجاد شد");
setYearOpen(false);
yearForm.reset();
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const updateYear = useMutation({
mutationFn: (d: z.infer<typeof editNameSchema>) =>
accountingApi.fiscal.updateYear(tenantId!, editYear!.id, { name: d.name }),
onSuccess: async () => {
toast.success("سال مالی به‌روز شد");
setEditYear(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const setCurrentYear = useMutation({
mutationFn: (id: string) => accountingApi.fiscal.setCurrentYear(tenantId!, id),
onSuccess: async () => {
toast.success("سال جاری تنظیم شد");
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const closeYear = useMutation({
mutationFn: (id: string) => accountingApi.fiscal.closeYear(tenantId!, id),
onSuccess: async () => {
toast.success("سال مالی بسته شد");
setConfirm(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const generatePeriods = useMutation({
mutationFn: (id: string) => accountingApi.fiscal.generatePeriods(tenantId!, id),
onSuccess: async (periods) => {
toast.success(`${periods.length} دوره ایجاد شد`);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const createPeriod = useMutation({
mutationFn: (d: z.infer<typeof periodSchema>) => accountingApi.fiscal.createPeriod(tenantId!, d),
onSuccess: async () => {
toast.success("دوره مالی ایجاد شد");
setPeriodOpen(false);
periodForm.reset();
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const updatePeriod = useMutation({
mutationFn: (d: z.infer<typeof editNameSchema>) =>
accountingApi.fiscal.updatePeriod(tenantId!, editPeriod!.id, { name: d.name }),
onSuccess: async () => {
toast.success("دوره به‌روز شد");
setEditPeriod(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const setCurrentPeriod = useMutation({
mutationFn: (id: string) => accountingApi.fiscal.setCurrentPeriod(tenantId!, id),
onSuccess: async () => {
toast.success("دوره جاری تنظیم شد");
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const periodAction = useMutation({
mutationFn: async () => {
if (!confirm || confirm.type !== "period") return;
if (confirm.action === "lock") return accountingApi.fiscal.lockPeriod(tenantId!, confirm.id);
if (confirm.action === "close") return accountingApi.fiscal.closePeriod(tenantId!, confirm.id);
return accountingApi.fiscal.reopenPeriod(tenantId!, confirm.id);
},
onSuccess: async () => {
toast.success("وضعیت دوره به‌روز شد");
setConfirm(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId) return <LoadingState />;
if (yearsQ.isLoading || periodsQ.isLoading) return <LoadingState />;
if (yearsQ.error || periodsQ.error) {
return <ErrorState message={(yearsQ.error || periodsQ.error)?.message || "خطا"} />;
}
const yearName = (id: string) => yearsQ.data?.find((y) => y.id === id)?.name ?? id;
const statusTone = (s: string) =>
s === "open" ? "success" : s === "locked" ? "warning" : s === "closed" ? "danger" : "default";
return (
<div>
<PageHeader
title="سال و دوره مالی"
description="مدیریت تقویم مالی، دوره جاری و قفل/بستن دوره‌ها."
actions={
<>
<Button type="button" variant="outline" onClick={() => setYearOpen(true)}>
<Plus className="h-4 w-4" />
سال مالی
</Button>
<Button
type="button"
onClick={() => {
const current = yearsQ.data?.find((y) => y.is_current) ?? yearsQ.data?.[0];
if (current) periodForm.setValue("fiscal_year_id", current.id);
setPeriodOpen(true);
}}
disabled={!yearsQ.data?.length}
>
<Plus className="h-4 w-4" />
دوره مالی
</Button>
</>
}
/>
<h2 className="mb-3 text-sm font-semibold text-secondary">سالهای مالی</h2>
<DataTable
columns={[
{ key: "name", header: "نام" },
{
key: "start_date",
header: "شروع",
render: (r) => <JalaliDateText value={String(r.start_date ?? "")} />,
},
{
key: "end_date",
header: "پایان",
render: (r) => <JalaliDateText value={String(r.end_date ?? "")} />,
},
{
key: "is_current",
header: "جاری",
render: (r) => (r.is_current ? <Badge tone="primary">بله</Badge> : "—"),
},
{
key: "is_closed",
header: "بسته",
render: (r) =>
r.is_closed ? <Badge tone="danger">بسته</Badge> : <Badge tone="success">باز</Badge>,
},
{
key: "actions",
header: "عملیات",
render: (r) => {
const id = String(r.id);
const closed = Boolean(r.is_closed);
const current = Boolean(r.is_current);
return (
<div className="flex flex-wrap gap-1">
{!current && !closed ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={setCurrentYear.isPending}
onClick={() => setCurrentYear.mutate(id)}
>
جاری
</Button>
) : null}
{!closed ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setConfirm({ type: "year-close", id })}
>
بستن سال
</Button>
) : null}
<Button
type="button"
size="sm"
variant="secondary"
disabled={generatePeriods.isPending}
onClick={() => generatePeriods.mutate(id)}
>
تولید دورهها
</Button>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
const y = yearsQ.data?.find((x) => x.id === id);
if (y) {
setEditYear(y);
editYearForm.reset({ name: y.name });
}
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</div>
);
},
},
]}
rows={(yearsQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={
<EmptyState title="سال مالی ندارید" action={<Button onClick={() => setYearOpen(true)}>ایجاد سال</Button>} />
}
/>
<h2 className="mb-3 mt-8 text-sm font-semibold text-secondary">دورههای مالی</h2>
<DataTable
columns={[
{ key: "name", header: "نام" },
{ key: "period_number", header: "شماره" },
{
key: "fiscal_year_id",
header: "سال",
render: (r) => yearName(String(r.fiscal_year_id)),
},
{
key: "start_date",
header: "شروع",
render: (r) => <JalaliDateText value={String(r.start_date ?? "")} />,
},
{
key: "is_current",
header: "جاری",
render: (r) => (r.is_current ? <Badge tone="primary">بله</Badge> : "—"),
},
{
key: "status",
header: "وضعیت",
render: (r) => <Badge tone={statusTone(String(r.status))}>{String(r.status)}</Badge>,
},
{
key: "actions",
header: "عملیات",
render: (r) => {
const id = String(r.id);
const status = String(r.status);
const current = Boolean(r.is_current);
return (
<div className="flex flex-wrap gap-1">
{!current && status !== "closed" ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={setCurrentPeriod.isPending}
onClick={() => setCurrentPeriod.mutate(id)}
>
جاری
</Button>
) : null}
{status === "open" ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setConfirm({ type: "period", id, action: "lock" })}
>
قفل
</Button>
) : null}
{status === "open" || status === "locked" ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setConfirm({ type: "period", id, action: "close" })}
>
بستن
</Button>
) : null}
{status === "closed" || status === "locked" ? (
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => setConfirm({ type: "period", id, action: "reopen" })}
>
بازگشایی
</Button>
) : null}
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => {
const p = periodsQ.data?.find((x) => x.id === id);
if (p) {
setEditPeriod(p);
editPeriodForm.reset({ name: p.name });
}
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</div>
);
},
},
]}
rows={(periodsQ.data ?? []) as unknown as Record<string, unknown>[]}
empty={<EmptyState title="دوره‌ای ثبت نشده" />}
/>
<Dialog open={yearOpen} onClose={() => setYearOpen(false)} title="ایجاد سال مالی">
<form className="space-y-3" onSubmit={yearForm.handleSubmit((d) => createYear.mutate(d))}>
<div>
<Label>نام</Label>
<Input {...yearForm.register("name")} placeholder="۱۴۰۵" />
<FieldError>{yearForm.formState.errors.name?.message}</FieldError>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>شروع</Label>
<Controller
control={yearForm.control}
name="start_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
<FieldError>{yearForm.formState.errors.start_date?.message}</FieldError>
</div>
<div>
<Label>پایان</Label>
<Controller
control={yearForm.control}
name="end_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
<FieldError>{yearForm.formState.errors.end_date?.message}</FieldError>
</div>
</div>
<Checkbox label="سال جاری" {...yearForm.register("is_current")} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setYearOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createYear.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={periodOpen} onClose={() => setPeriodOpen(false)} title="ایجاد دوره مالی">
<form className="space-y-3" onSubmit={periodForm.handleSubmit((d) => createPeriod.mutate(d))}>
<div>
<Label>سال مالی</Label>
<Select {...periodForm.register("fiscal_year_id")}>
<option value="">انتخاب</option>
{yearsQ.data?.map((y) => (
<option key={y.id} value={y.id}>
{y.name}
</option>
))}
</Select>
<FieldError>{periodForm.formState.errors.fiscal_year_id?.message}</FieldError>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>نام</Label>
<Input {...periodForm.register("name")} />
<FieldError>{periodForm.formState.errors.name?.message}</FieldError>
</div>
<div>
<Label>شماره دوره</Label>
<Input type="number" {...periodForm.register("period_number", { valueAsNumber: true })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>شروع</Label>
<Controller
control={periodForm.control}
name="start_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</div>
<div>
<Label>پایان</Label>
<Controller
control={periodForm.control}
name="end_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</div>
</div>
<Checkbox label="دوره جاری" {...periodForm.register("is_current")} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setPeriodOpen(false)}>
انصراف
</Button>
<Button type="submit" disabled={createPeriod.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={!!editYear} onClose={() => setEditYear(null)} title="ویرایش سال مالی">
<form className="space-y-3" onSubmit={editYearForm.handleSubmit((d) => updateYear.mutate(d))}>
<div>
<Label>نام</Label>
<Input {...editYearForm.register("name")} />
<FieldError>{editYearForm.formState.errors.name?.message}</FieldError>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setEditYear(null)}>
انصراف
</Button>
<Button type="submit" disabled={updateYear.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<Dialog open={!!editPeriod} onClose={() => setEditPeriod(null)} title="ویرایش دوره مالی">
<form className="space-y-3" onSubmit={editPeriodForm.handleSubmit((d) => updatePeriod.mutate(d))}>
<div>
<Label>نام</Label>
<Input {...editPeriodForm.register("name")} />
<FieldError>{editPeriodForm.formState.errors.name?.message}</FieldError>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setEditPeriod(null)}>
انصراف
</Button>
<Button type="submit" disabled={updatePeriod.isPending}>
ذخیره
</Button>
</div>
</form>
</Dialog>
<ConfirmDialog
open={confirm?.type === "year-close"}
onClose={() => setConfirm(null)}
onConfirm={() => confirm?.type === "year-close" && closeYear.mutate(confirm.id)}
title="بستن سال مالی؟"
description="پس از بستن، سال دیگر قابل ثبت سند جدید نیست."
confirmLabel="بستن سال"
loading={closeYear.isPending}
/>
<ConfirmDialog
open={confirm?.type === "period"}
onClose={() => setConfirm(null)}
onConfirm={() => periodAction.mutate()}
title="تغییر وضعیت دوره"
description="این عملیات روی دوره مالی واقعی اعمال می‌شود."
confirmLabel="اعمال"
loading={periodAction.isPending}
/>
</div>
);
}