TorbatYar/frontend/app/accounting/setup/page.tsx
Mortezakoohjani 7d66932da8 Complete Accounting sidebar modules with ops CRUD, BFF proxy, and treasury APIs.
Wire nested nav pages for phases 5.1-5.11 to real list/create endpoints, fix COA import connectivity via same-origin proxy, and add operational migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 16:10:06 +03:30

290 lines
11 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 { useEffect, useState } from "react";
import Link from "next/link";
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 { CheckCircle2, Circle, X, Download, Eye } from "lucide-react";
import { accountingApi, type CoaTemplate } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import {
PageHeader,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Badge,
LoadingState,
ErrorState,
Dialog,
Input,
Label,
FieldError,
DataTable,
EmptyState,
} from "@/components/ds";
import { CompletionScoreboard } from "@/components/accounting/CompletionScoreboard";
const TOUR_KEY = "accounting-setup-tour-dismissed";
const importSchema = z.object({
chart_name: z.string().min(1, "نام دفتر الزامی است"),
chart_code: z.string().min(1, "کد دفتر الزامی است"),
});
type SetupStep = {
key: keyof typeof STEP_LINKS;
label: string;
done: boolean;
};
const STEP_LINKS = {
has_chart: { href: "/accounting/chart-of-accounts", label: "دفتر حساب‌ها" },
has_accounts: { href: "/accounting/chart-of-accounts", label: "حساب‌ها" },
has_currency: { href: "/accounting/currencies", label: "ارزها" },
has_base_currency: { href: "/accounting/currencies", label: "ارز پایه" },
has_fiscal_year: { href: "/accounting/fiscal", label: "سال مالی" },
has_fiscal_period: { href: "/accounting/fiscal", label: "دوره مالی" },
has_cash_box: { href: "/accounting/treasury/cash-boxes", label: "صندوق" },
has_customer: { href: "/accounting/customers", label: "مشتری" },
has_voucher: { href: "/accounting/vouchers", label: "سند حسابداری" },
} as const;
export default function SetupPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const [showTour, setShowTour] = useState(false);
const [previewTemplate, setPreviewTemplate] = useState<CoaTemplate | null>(null);
const [importTemplate, setImportTemplate] = useState<CoaTemplate | null>(null);
useEffect(() => {
if (typeof window === "undefined") return;
setShowTour(localStorage.getItem(TOUR_KEY) !== "1");
}, []);
const dismissTour = () => {
localStorage.setItem(TOUR_KEY, "1");
setShowTour(false);
};
const statusQ = useQuery({
queryKey: ["accounting", tenantId, "setup-status"],
queryFn: () => accountingApi.setup.status(tenantId!),
enabled: !!tenantId,
});
const templatesQ = useQuery({
queryKey: ["accounting", tenantId, "coa-templates"],
queryFn: () => accountingApi.templates.list(tenantId!),
enabled: !!tenantId,
});
const importForm = useForm<z.infer<typeof importSchema>>({
resolver: zodResolver(importSchema),
defaultValues: { chart_name: "", chart_code: "" },
});
const importM = useMutation({
mutationFn: (d: z.infer<typeof importSchema>) =>
accountingApi.templates.import(tenantId!, importTemplate!.id, d),
onSuccess: async (accounts) => {
toast.success(`${accounts.length} حساب از قالب وارد شد`);
setImportTemplate(null);
importForm.reset();
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "setup-status"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "charts"] });
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "accounts"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || statusQ.isLoading) return <LoadingState />;
if (statusQ.error) {
return <ErrorState message={statusQ.error.message} onRetry={() => statusQ.refetch()} />;
}
const s = statusQ.data!;
const steps: SetupStep[] = [
{ key: "has_chart", label: "ایجاد دفتر حساب", done: s.has_chart },
{ key: "has_accounts", label: "تعریف حساب‌ها", done: s.has_accounts },
{ key: "has_currency", label: "تعریف ارز", done: s.has_currency },
{ key: "has_base_currency", label: "تعیین ارز پایه", done: s.has_base_currency },
{ key: "has_fiscal_year", label: "سال مالی", done: s.has_fiscal_year },
{ key: "has_fiscal_period", label: "دوره مالی", done: s.has_fiscal_period },
{ key: "has_cash_box", label: "صندوق خزانه", done: s.has_cash_box },
{ key: "has_customer", label: "مشتری", done: s.has_customer },
{ key: "has_voucher", label: "اولین سند", done: s.has_voucher },
];
return (
<div>
<PageHeader
title="راه‌اندازی حسابداری"
description={`${s.completed_steps} از ${s.total_steps} مرحله — ${s.percent}٪ تکمیل`}
actions={
<Badge tone={s.percent >= 100 ? "success" : "primary"}>{s.percent}٪</Badge>
}
/>
{showTour ? (
<Card className="mb-6 border-primary/30 bg-primary/5">
<CardContent className="flex flex-col gap-3 pt-5 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="font-semibold text-secondary">به ماژول حسابداری خوش آمدید</p>
<p className="mt-1 text-sm text-[var(--muted)]">
این چکلیست مراحل اولیه را نشان میدهد. میتوانید از قالب آماده دفتر حساب وارد کنید یا
هر مرحله را از منو تکمیل کنید.
</p>
</div>
<Button type="button" variant="ghost" size="sm" onClick={dismissTour}>
<X className="h-4 w-4" />
دیگر نشان نده
</Button>
</CardContent>
</Card>
) : null}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>چکلیست راهاندازی</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{steps.map((step) => {
const link = STEP_LINKS[step.key];
return (
<Link
key={step.key}
href={link.href}
className="flex items-center gap-3 rounded-xl px-3 py-2.5 hover:bg-[var(--surface-muted)]"
>
{step.done ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-emerald-600" />
) : (
<Circle className="h-5 w-5 shrink-0 text-[var(--muted)]" />
)}
<span className="flex-1 text-sm font-medium text-secondary">{step.label}</span>
<Badge tone={step.done ? "success" : "default"}>{step.done ? "انجام شد" : "باقی‌مانده"}</Badge>
</Link>
);
})}
</CardContent>
</Card>
<CompletionScoreboard />
</div>
<Card className="mt-6">
<CardHeader>
<CardTitle>ویزارد قالب دفتر حساب (COA)</CardTitle>
</CardHeader>
<CardContent>
{templatesQ.isLoading ? (
<LoadingState />
) : templatesQ.error ? (
<ErrorState message={templatesQ.error.message} onRetry={() => templatesQ.refetch()} />
) : !(templatesQ.data ?? []).length ? (
<EmptyState title="قالبی در سرور موجود نیست" />
) : (
<div className="grid gap-3 sm:grid-cols-2">
{(templatesQ.data ?? []).map((t) => (
<div
key={t.id}
className="rounded-2xl border border-[var(--border)] bg-[var(--surface)] p-4"
>
<p className="font-semibold text-secondary">{t.name}</p>
<p className="mt-1 text-sm text-[var(--muted)]">{t.description}</p>
<p className="mt-2 text-xs text-[var(--muted)]">
{t.accounts.length} حساب · {t.business_type}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setPreviewTemplate(t)}
>
<Eye className="h-3.5 w-3.5" />
پیشنمایش
</Button>
<Button
type="button"
size="sm"
onClick={() => {
setImportTemplate(t);
importForm.reset({
chart_name: t.name,
chart_code: t.business_type.slice(0, 8).toUpperCase(),
});
}}
>
<Download className="h-3.5 w-3.5" />
وارد کردن
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Dialog
open={!!previewTemplate}
onClose={() => setPreviewTemplate(null)}
title={previewTemplate?.name ?? "پیش‌نمایش قالب"}
>
{previewTemplate ? (
<DataTable
columns={[
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "account_type", header: "نوع" },
{ key: "level", header: "سطح" },
{
key: "is_postable",
header: "قابل ثبت",
render: (r) => (r.is_postable ? "بله" : "خیر"),
},
]}
rows={previewTemplate.accounts as unknown as Record<string, unknown>[]}
/>
) : null}
</Dialog>
<Dialog open={!!importTemplate} onClose={() => setImportTemplate(null)} title="وارد کردن قالب">
<form
className="space-y-3"
onSubmit={importForm.handleSubmit((d) => importM.mutate(d))}
>
<p className="text-sm text-[var(--muted)]">
قالب «{importTemplate?.name}» {importTemplate?.accounts.length} حساب
</p>
<div>
<Label>نام دفتر</Label>
<Input {...importForm.register("chart_name")} />
<FieldError>{importForm.formState.errors.chart_name?.message}</FieldError>
</div>
<div>
<Label>کد دفتر</Label>
<Input {...importForm.register("chart_code")} dir="ltr" />
<FieldError>{importForm.formState.errors.chart_code?.message}</FieldError>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setImportTemplate(null)}>
انصراف
</Button>
<Button type="submit" disabled={importM.isPending}>
وارد کردن
</Button>
</div>
</form>
</Dialog>
</div>
);
}