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>
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
"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", 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>
|
||
);
|
||
}
|