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>
580 lines
21 KiB
TypeScript
580 lines
21 KiB
TypeScript
"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>
|
||
);
|
||
}
|