TorbatYar/frontend/app/accounting/settings/auto-posting/page.tsx
Mortezakoohjani d92e1df332 Add automatic GL posting policy with inline voucher balance validation.
Enable tenant-controlled auto-post for sales/purchase/treasury via Posting Engine, block unbalanced voucher lines on FE and BE, and expose international posting controls in settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 17:55:37 +03:30

186 lines
8.0 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import {
PageHeader,
LoadingState,
ErrorState,
Card,
CardContent,
CardHeader,
CardTitle,
Button,
Checkbox,
Select,
Badge,
} from "@/components/ds";
const AUTO_KEYS: { key: string; label: string; hint: string }[] = [
{ key: "sales_invoice", label: "فاکتور فروش", hint: "با تأیید فاکتور فروش، سند GL خودکار صادر شود" },
{ key: "sales_return", label: "مرجوعی فروش", hint: "ثبت خودکار برگشت فروش در دفتر" },
{ key: "purchase_goods_receipt", label: "رسید کالا", hint: "Dr موجودی / Cr GRNI هنگام تأیید رسید" },
{ key: "purchase_invoice", label: "فاکتور خرید", hint: "ثبت بدهی تأمین‌کننده هنگام تأیید فاکتور" },
{ key: "purchase_return", label: "مرجوعی خرید", hint: "ثبت خودکار مرجوعی خرید" },
{ key: "treasury_receipt", label: "دریافت خزانه", hint: "دریافت نقدی/بانکی همیشه به Posting Engine" },
{ key: "treasury_payment", label: "پرداخت خزانه", hint: "پرداخت نقدی/بانکی همیشه به Posting Engine" },
{ key: "payroll", label: "حقوق و دستمزد", hint: "پس از محاسبه/تأیید حقوق" },
{ key: "assets_depreciation", label: "استهلاک دارایی", hint: "ثبت خودکار استهلاک دوره‌ای" },
];
const POLICY_KEYS: { key: string; label: string; hint: string }[] = [
{
key: "require_balanced",
label: "اجبار تراز بدهکار/بستانکار",
hint: "سند نامتوازن هرگز ذخیره یا قطعی نشود (استاندارد دوبل‌انتری)",
},
{
key: "require_source_reference",
label: "الزام مرجع سند مبدأ",
hint: "اسناد اتومات باید source_module و reference داشته باشند",
},
{
key: "lock_after_post",
label: "قفل پس از ثبت قطعی",
hint: "ویرایش مستقیم ممنوع؛ فقط برگشت از طریق Posting Engine",
},
{
key: "sequential_numbers",
label: "شماره‌گذاری متوالی",
hint: "استفاده از Document Number Sequence به‌جای شناسه تصادفی",
},
{
key: "idempotent_source",
label: "جلوگیری از ثبت تکراری",
hint: "یک سند مبدأ بیش از یک بار به GL نرود",
},
];
export default function AutoPostingSettingsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const policyQ = useQuery({
queryKey: ["accounting", tenantId, "posting-policy"],
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
enabled: !!tenantId,
});
const saveM = useMutation({
mutationFn: (body: Parameters<typeof accountingApi.setup.updatePostingPolicy>[1]) =>
accountingApi.setup.updatePostingPolicy(tenantId!, body),
onSuccess: async () => {
toast.success("سیاست اسناد اتومات ذخیره شد");
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "posting-policy"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || policyQ.isLoading) return <LoadingState />;
if (policyQ.error) {
return <ErrorState message={policyQ.error.message} onRetry={() => policyQ.refetch()} />;
}
const policy = policyQ.data!;
const toggleAuto = (key: string, value: boolean) => {
saveM.mutate({
auto_post: { ...policy.auto_post, [key]: value },
});
};
const togglePolicy = (key: string, value: boolean) => {
saveM.mutate({ [key]: value } as Parameters<typeof accountingApi.setup.updatePostingPolicy>[1]);
};
return (
<div>
<PageHeader
title="اسناد اتوماتیک حسابداری"
description="کنترل ثبت خودکار در دفتر کل مطابق استانداردهای بین‌المللی دوبل‌انتری: تراز، قفل پس از ثبت، شماره متوالی، مرجع مبدأ و جلوگیری از ثبت تکراری."
/>
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
حالت ثبت
<Badge tone="primary">{policy.posting_mode === "direct_post" ? "ثبت مستقیم" : "پیش‌نویس سپس قطعی"}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="max-w-md space-y-2">
<Select
value={policy.posting_mode}
disabled={saveM.isPending}
onChange={(e) => saveM.mutate({ posting_mode: e.target.value })}
>
<option value="direct_post">ثبت مستقیم در دفتر (پس از تأیید سند عملیاتی)</option>
<option value="draft_then_post">ایجاد پیشنویس و قطعیسازی جداگانه</option>
</Select>
<p className="text-xs text-[var(--muted)]">
در حالت مستقیم، با تأیید سند عملیاتی (اگر ماژول روشن باشد) سند GL بلافاصله از طریق Posting Engine صادر میشود.
</p>
</CardContent>
</Card>
<div className="mb-6 grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>قواعد الزامی (Compliance)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{POLICY_KEYS.map((item) => (
<div key={item.key} className="flex items-start gap-3 rounded-xl border border-[var(--border)] p-3">
<Checkbox
checked={Boolean((policy as Record<string, unknown>)[item.key])}
disabled={saveM.isPending}
onChange={(e) => togglePolicy(item.key, e.target.checked)}
aria-label={item.label}
/>
<span>
<span className="block text-sm font-medium text-secondary">{item.label}</span>
<span className="mt-0.5 block text-xs text-[var(--muted)]">{item.hint}</span>
</span>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>ثبت خودکار به تفکیک ماژول</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{AUTO_KEYS.map((item) => (
<div key={item.key} className="flex items-start gap-3 rounded-xl border border-[var(--border)] p-3">
<Checkbox
checked={Boolean(policy.auto_post?.[item.key])}
disabled={saveM.isPending}
onChange={(e) => toggleAuto(item.key, e.target.checked)}
aria-label={item.label}
/>
<span>
<span className="block text-sm font-medium text-secondary">{item.label}</span>
<span className="mt-0.5 block text-xs text-[var(--muted)]">{item.hint}</span>
</span>
</div>
))}
</CardContent>
</Card>
</div>
<Card>
<CardContent className="flex flex-wrap items-center justify-between gap-3 pt-6">
<p className="text-sm text-[var(--muted)]">
قلب حسابداری فقط از مسیر Posting Engine میگذرد (ADR-010). تنظیمات بالا فقط زمان و شرط صدور را کنترل میکنند.
</p>
<Button type="button" variant="outline" disabled={saveM.isPending} onClick={() => policyQ.refetch()}>
بازخوانی
</Button>
</CardContent>
</Card>
</div>
);
}