TorbatYar/frontend/components/accounting/OperationalDocumentPage.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

789 lines
29 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 { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Controller, useFieldArray, 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 { Eye, Pencil, Plus, Trash2, X } from "lucide-react";
import { accountingApi } from "@/lib/accounting-api";
import { useTenantId } from "@/hooks/useTenantId";
import { formatMoney, parseMoneyInput } from "@/lib/utils";
import {
PageHeader,
Button,
Input,
DataTable,
EmptyState,
LoadingState,
ErrorState,
Badge,
FormField,
MoneyInput,
DatePicker,
JalaliDateText,
ConfirmDialog,
Select,
} from "@/components/ds";
import { ItemCombobox, PartyCombobox } from "@/components/accounting/EntityCombobox";
export type DocKind =
| "invoice"
| "order"
| "request"
| "receipt"
| "transfer"
| "adjustment"
| "settlement"
| "generic";
type LineValues = { title: string; qty: string; unit_price: string; note?: string; item_id?: string };
const formSchema = z.object({
number: z.string().optional(),
doc_date: z.string().min(1, "تاریخ الزامی است"),
party_name: z.string().optional(),
party_id: z.string().optional(),
description: z.string().optional(),
amount: z.string().optional(),
lines: z
.array(
z.object({
title: z.string().min(1, "شرح ردیف الزامی است"),
qty: z.string().min(1, "تعداد الزامی است"),
unit_price: z.string().min(1, "فی الزامی است"),
note: z.string().optional(),
item_id: z.string().optional(),
})
)
.optional(),
});
type FormValues = z.infer<typeof formSchema>;
function PortalDialog({
open,
onClose,
title,
children,
wide,
}: {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
wide?: boolean;
}) {
if (!open || typeof document === "undefined") return null;
return createPortal(
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<button type="button" aria-label="بستن" className="absolute inset-0 bg-black/45" onClick={onClose} />
<div
role="dialog"
aria-modal="true"
className={`relative z-10 max-h-[92vh] w-full overflow-y-auto rounded-2xl border border-[var(--border)] bg-[var(--surface)] shadow-[var(--shadow-lg)] ${
wide ? "max-w-4xl" : "max-w-2xl"
}`}
>
<div className="sticky top-0 z-10 flex items-start justify-between gap-3 border-b border-[var(--border)] bg-[var(--surface)] px-5 py-4">
<h2 className="text-base font-semibold text-secondary">{title}</h2>
<Button type="button" variant="ghost" size="icon" onClick={onClose} aria-label="بستن">
<X className="h-4 w-4" />
</Button>
</div>
<div className="px-5 py-4">{children}</div>
</div>
</div>,
document.body
);
}
function sumLines(lines: LineValues[] | undefined): string {
if (!lines?.length) return "0";
const total = lines.reduce((acc, line) => {
const qty = Number(parseMoneyInput(line.qty) || "0");
const price = Number(parseMoneyInput(line.unit_price) || "0");
return acc + qty * price;
}, 0);
return String(total);
}
function parseMeta(meta: unknown): { lines?: LineValues[] } {
if (!meta) return {};
if (typeof meta === "string") {
try {
return JSON.parse(meta) as { lines?: LineValues[] };
} catch {
return {};
}
}
if (typeof meta === "object") return meta as { lines?: LineValues[] };
return {};
}
/**
* Full operational document screen for sales / purchase / inventory submenus.
* Real CRUD via /api/v1/ops/documents — no mocks.
*/
export function OperationalDocumentPage({
title,
description,
module,
docType,
createLabel,
partyLabel = "طرف حساب",
kind = "generic",
enableAccountingPost,
}: {
title: string;
description?: string;
module: string;
docType: string;
createLabel: string;
partyLabel?: string;
kind?: DocKind;
/** When set, draft rows get "ثبت حسابداری" using the matching engine. */
enableAccountingPost?: "sales" | "purchase_gr" | "purchase_invoice" | "purchase_return";
}) {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const withLines = kind === "invoice" || kind === "order" || kind === "receipt" || kind === "request";
const [createOpen, setCreateOpen] = useState(false);
const [detailRow, setDetailRow] = useState<Record<string, unknown> | null>(null);
const [editRow, setEditRow] = useState<Record<string, unknown> | null>(null);
const [confirmId, setConfirmId] = useState<string | null>(null);
const [postRow, setPostRow] = useState<Record<string, unknown> | null>(null);
const [preview, setPreview] = useState<{ is_balanced: boolean; total_debit: string; total_credit?: string } | null>(
null
);
const listQ = useQuery({
queryKey: ["accounting", tenantId, "ops-docs", module, docType],
queryFn: () => accountingApi.ops.listDocuments(tenantId!, module, docType),
enabled: !!tenantId,
});
const salesProfilesQ = useQuery({
queryKey: ["accounting", tenantId, "sales-profiles"],
queryFn: () => accountingApi.salesAccounting.listProfiles(tenantId!),
enabled: !!tenantId && enableAccountingPost === "sales",
});
const purchaseProfilesQ = useQuery({
queryKey: ["accounting", tenantId, "purchase-profiles"],
queryFn: () => accountingApi.purchaseInventory.listProfiles(tenantId!),
enabled:
!!tenantId &&
(enableAccountingPost === "purchase_gr" ||
enableAccountingPost === "purchase_invoice" ||
enableAccountingPost === "purchase_return"),
});
const policyQ = useQuery({
queryKey: ["accounting", tenantId, "posting-policy"],
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
enabled: !!tenantId,
});
const defaults: FormValues = useMemo(
() => ({
number: "",
doc_date: new Date().toISOString().slice(0, 10),
party_name: "",
party_id: "",
description: "",
amount: "0",
// Always an array so useFieldArray stays stable (even when lines UI is hidden).
lines: withLines ? [{ title: "", qty: "1", unit_price: "0", note: "", item_id: "" }] : [],
}),
[withLines]
);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: defaults,
});
const editForm = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: defaults,
});
const postForm = useForm({
defaultValues: { profile_id: "", discount_amount: "0", tax_amount: "0" },
});
const createLines = useFieldArray({ control: form.control, name: "lines" });
const editLines = useFieldArray({ control: editForm.control, name: "lines" });
const watchedCreateLines = form.watch("lines");
const createTotal = withLines ? sumLines(watchedCreateLines) : form.watch("amount") || "0";
const invalidate = async () => {
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", module, docType] });
};
const createM = useMutation({
mutationFn: (d: FormValues) => {
const amount = withLines ? sumLines(d.lines) : parseMoneyInput(d.amount || "0") || "0";
return accountingApi.ops.createDocument(tenantId!, {
module,
doc_type: docType,
number: d.number?.trim() || undefined,
doc_date: d.doc_date,
party_name: d.party_name || undefined,
party_id: d.party_id || undefined,
amount,
description: d.description || undefined,
status: "draft",
meta_json: withLines ? JSON.stringify({ lines: d.lines ?? [] }) : undefined,
});
},
onSuccess: async () => {
toast.success(`${title} ثبت شد`);
setCreateOpen(false);
form.reset(defaults);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const updateM = useMutation({
mutationFn: (d: FormValues) => {
const amount = withLines ? sumLines(d.lines) : parseMoneyInput(d.amount || "0") || "0";
return accountingApi.ops.updateDocument(tenantId!, String(editRow!.id), {
doc_date: d.doc_date,
party_name: d.party_name || undefined,
party_id: d.party_id || undefined,
amount,
description: d.description || undefined,
meta_json: withLines ? JSON.stringify({ lines: d.lines ?? [] }) : undefined,
});
},
onSuccess: async () => {
toast.success("ویرایش ذخیره شد");
setEditRow(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const confirmM = useMutation({
mutationFn: (id: string) => accountingApi.ops.confirmDocument(tenantId!, id),
onSuccess: async (doc) => {
const meta =
doc && typeof doc === "object" && "meta_json" in doc && typeof doc.meta_json === "string"
? (() => {
try {
return JSON.parse(doc.meta_json as string) as {
accounting_voucher?: { voucher_number?: string };
};
} catch {
return null;
}
})()
: null;
const vn = meta?.accounting_voucher?.voucher_number;
toast.success(vn ? `تأیید شد و سند حسابداری ${vn} صادر شد` : "تأیید شد");
setConfirmId(null);
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const previewM = useMutation({
mutationFn: async () => {
if (!postRow) throw new Error("سندی انتخاب نشده");
const amount = String(postRow.amount ?? "0");
const profileId = postForm.getValues("profile_id") || undefined;
if (enableAccountingPost === "sales") {
return accountingApi.salesAccounting.preview(tenantId!, {
document_type: docType === "return" ? "return" : "invoice",
total_amount: amount,
discount_amount: postForm.getValues("discount_amount") || "0",
tax_amount: postForm.getValues("tax_amount") || "0",
profile_id: profileId,
});
}
if (enableAccountingPost === "purchase_invoice" || enableAccountingPost === "purchase_return") {
return accountingApi.purchaseInventory.previewPurchaseInvoice(tenantId!, {
source_document_id: String(postRow.id),
amount,
profile_id: profileId,
});
}
return accountingApi.purchaseInventory.previewGoodsReceipt(tenantId!, {
source_document_id: String(postRow.id),
amount,
profile_id: profileId,
});
},
onSuccess: (data) => {
setPreview(data);
toast.success("پیش‌نمایش آماده شد");
},
onError: (e: Error) => toast.error(e.message),
});
const postM = useMutation({
mutationFn: async () => {
if (!postRow) throw new Error("سندی انتخاب نشده");
const amount = String(postRow.amount ?? "0");
const profileId = postForm.getValues("profile_id") || undefined;
if (enableAccountingPost === "sales") {
return accountingApi.salesAccounting.post(tenantId!, {
document_type: docType === "return" ? "return" : "invoice",
source_document_id: String(postRow.id),
total_amount: amount,
discount_amount: postForm.getValues("discount_amount") || "0",
tax_amount: postForm.getValues("tax_amount") || "0",
profile_id: profileId,
});
}
if (enableAccountingPost === "purchase_return") {
return accountingApi.purchaseInventory.postPurchaseReturn(tenantId!, {
source_document_id: String(postRow.id),
amount,
profile_id: profileId,
});
}
if (enableAccountingPost === "purchase_invoice") {
return accountingApi.purchaseInventory.postPurchaseInvoice(tenantId!, {
source_document_id: String(postRow.id),
amount,
profile_id: profileId,
});
}
return accountingApi.purchaseInventory.postGoodsReceipt(tenantId!, {
source_document_id: String(postRow.id),
amount,
profile_id: profileId,
});
},
onSuccess: async (data) => {
const voucher =
data && typeof data === "object" && "voucher_number" in data
? String((data as { voucher_number?: string }).voucher_number || "")
: "";
toast.success(voucher ? `سند حسابداری ${voucher} صادر شد` : "ثبت حسابداری انجام شد");
setPostRow(null);
setPreview(null);
if (postRow?.status === "draft") {
await accountingApi.ops.confirmDocument(tenantId!, String(postRow.id)).catch(() => null);
}
await invalidate();
},
onError: (e: Error) => toast.error(e.message),
});
const openCreate = async () => {
form.reset(defaults);
setCreateOpen(true);
try {
const seq = await accountingApi.setup.peekNumber(tenantId!, `ops.${module}.${docType}`);
form.setValue("number", seq.preview);
} catch {
// BE still allocates if blank on submit
}
};
const openEdit = (row: Record<string, unknown>) => {
const meta = parseMeta(row.meta_json);
editForm.reset({
number: String(row.number ?? ""),
doc_date: String(row.doc_date ?? ""),
party_name: String(row.party_name ?? ""),
party_id: String(row.party_id ?? ""),
description: String(row.description ?? ""),
amount: String(row.amount ?? "0"),
lines: withLines
? meta.lines?.length
? meta.lines
: [{ title: "", qty: "1", unit_price: String(row.amount ?? "0"), note: "", item_id: "" }]
: [],
});
setEditRow(row);
};
if (!tenantId || listQ.isLoading) return <LoadingState />;
if (listQ.error) {
return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
}
const rows = (listQ.data ?? []) as unknown as Record<string, unknown>[];
const profiles =
enableAccountingPost === "sales" ? salesProfilesQ.data ?? [] : purchaseProfilesQ.data ?? [];
const renderDocForm = (
f: typeof form,
linesArray: typeof createLines,
onSubmit: (d: FormValues) => void,
pending: boolean,
onCancel: () => void,
totalHint: string,
numberReadOnly?: boolean
) => (
<form className="grid gap-3 sm:grid-cols-2" onSubmit={f.handleSubmit(onSubmit)}>
<FormField
label="شماره سند"
error={f.formState.errors.number?.message}
>
<Input
{...f.register("number")}
readOnly={numberReadOnly}
placeholder="خودکار از سیستم"
/>
</FormField>
<FormField label="تاریخ" error={f.formState.errors.doc_date?.message}>
<Controller
control={f.control}
name="doc_date"
render={({ field }) => (
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
)}
/>
</FormField>
<FormField label={partyLabel}>
<PartyCombobox
module={module}
valueName={f.watch("party_name") || undefined}
placeholder={`جستجوی ${partyLabel}`}
onChange={(party) => {
f.setValue("party_name", party?.name || "", { shouldDirty: true });
f.setValue("party_id", party?.id || "", { shouldDirty: true });
}}
/>
</FormField>
{!withLines ? (
<FormField label="مبلغ" error={f.formState.errors.amount?.message}>
<MoneyInput {...f.register("amount")} />
</FormField>
) : (
<FormField label="جمع سند">
<Input value={formatMoney(totalHint)} readOnly dir="ltr" />
</FormField>
)}
<div className="sm:col-span-2">
<FormField label="توضیحات">
<Input {...f.register("description")} />
</FormField>
</div>
{withLines ? (
<div className="sm:col-span-2 space-y-2 rounded-xl border border-[var(--border)] p-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-secondary">اقلام سند</p>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => linesArray.append({ title: "", qty: "1", unit_price: "0", note: "", item_id: "" })}
>
<Plus className="h-4 w-4" />
ردیف
</Button>
</div>
<div className="space-y-2">
{linesArray.fields.map((field, index) => (
<div key={field.id} className="grid gap-2 rounded-lg bg-[var(--surface-muted)] p-2 sm:grid-cols-12">
<div className="sm:col-span-5">
<ItemCombobox
valueLabel={f.watch(`lines.${index}.title`) || undefined}
onSelect={(item) => {
f.setValue(`lines.${index}.item_id`, item.id, { shouldDirty: true });
f.setValue(`lines.${index}.title`, `${item.code}${item.name}`, { shouldDirty: true });
f.setValue(`lines.${index}.unit_price`, parseMoneyInput(item.unit_cost) || "0", {
shouldDirty: true,
});
}}
onClear={() => {
f.setValue(`lines.${index}.item_id`, "", { shouldDirty: true });
f.setValue(`lines.${index}.title`, "", { shouldDirty: true });
}}
/>
</div>
<div className="sm:col-span-2">
<Input placeholder="تعداد" {...f.register(`lines.${index}.qty`)} />
</div>
<div className="sm:col-span-3">
<MoneyInput placeholder="فی" {...f.register(`lines.${index}.unit_price`)} />
</div>
<div className="flex items-center justify-end sm:col-span-2">
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => linesArray.remove(index)}
disabled={linesArray.fields.length <= 1}
aria-label="حذف ردیف"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
) : null}
<div className="flex justify-end gap-2 sm:col-span-2">
<Button type="button" variant="outline" onClick={onCancel}>
انصراف
</Button>
<Button type="submit" disabled={pending}>
ذخیره {title}
</Button>
</div>
</form>
);
return (
<div>
<PageHeader
title={title}
description={description || "لیست، ثبت، ویرایش و تأیید — داده واقعی از سرویس حسابداری."}
actions={
<Button type="button" onClick={openCreate}>
<Plus className="h-4 w-4" />
{createLabel}
</Button>
}
/>
{rows.length === 0 ? (
<EmptyState
title={`${title} ثبت نشده`}
description={`برای شروع، «${createLabel}» را بزنید.`}
action={
<Button type="button" onClick={openCreate}>
<Plus className="h-4 w-4" />
{createLabel}
</Button>
}
/>
) : (
<DataTable
columns={[
{ key: "number", header: "شماره" },
{
key: "doc_date",
header: "تاریخ",
render: (r) => <JalaliDateText value={String(r.doc_date ?? "")} />,
},
{ key: "party_name", header: partyLabel },
{
key: "amount",
header: "مبلغ",
render: (r) => formatMoney(String(r.amount ?? "0")),
},
{
key: "status",
header: "وضعیت",
render: (r) => (
<Badge tone={r.status === "confirmed" ? "success" : "default"}>{String(r.status)}</Badge>
),
},
{
key: "id",
header: "عملیات",
render: (r) => (
<div className="flex flex-wrap gap-1">
<Button type="button" size="sm" variant="ghost" onClick={() => setDetailRow(r)}>
<Eye className="h-4 w-4" />
جزئیات
</Button>
{r.status === "draft" ? (
<>
<Button type="button" size="sm" variant="outline" onClick={() => openEdit(r)}>
<Pencil className="h-4 w-4" />
ویرایش
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => setConfirmId(String(r.id))}>
تأیید
</Button>
</>
) : null}
{enableAccountingPost ? (
<Button
type="button"
size="sm"
onClick={() => {
setPreview(null);
postForm.reset({ profile_id: "", discount_amount: "0", tax_amount: "0" });
setPostRow(r);
}}
>
ثبت حسابداری
</Button>
) : null}
</div>
),
},
]}
rows={rows}
/>
)}
<PortalDialog open={createOpen} onClose={() => setCreateOpen(false)} title={createLabel} wide={withLines}>
{renderDocForm(
form,
createLines,
(d) => createM.mutate(d),
createM.isPending,
() => setCreateOpen(false),
createTotal
)}
</PortalDialog>
<PortalDialog
open={!!editRow}
onClose={() => setEditRow(null)}
title={`ویرایش ${title}`}
wide={withLines}
>
{renderDocForm(
editForm,
editLines,
(d) => updateM.mutate(d),
updateM.isPending,
() => setEditRow(null),
withLines ? sumLines(editForm.watch("lines")) : editForm.watch("amount") || "0",
true
)}
</PortalDialog>
<PortalDialog open={!!detailRow} onClose={() => setDetailRow(null)} title={`جزئیات ${title}`} wide>
{detailRow ? (
<div className="space-y-3 text-sm">
<div className="grid gap-2 sm:grid-cols-2">
<p>
<span className="text-[var(--muted)]">شماره:</span> {String(detailRow.number)}
</p>
<p>
<span className="text-[var(--muted)]">تاریخ:</span>{" "}
<JalaliDateText value={String(detailRow.doc_date ?? "")} />
</p>
<p>
<span className="text-[var(--muted)]">{partyLabel}:</span>{" "}
{String(detailRow.party_name || "—")}
</p>
<p>
<span className="text-[var(--muted)]">مبلغ:</span>{" "}
{formatMoney(String(detailRow.amount ?? "0"))}
</p>
<p>
<span className="text-[var(--muted)]">وضعیت:</span> {String(detailRow.status)}
</p>
<p className="sm:col-span-2">
<span className="text-[var(--muted)]">توضیح:</span> {String(detailRow.description || "—")}
</p>
</div>
{withLines ? (
<div className="rounded-xl border border-[var(--border)] p-3">
<p className="mb-2 font-medium">اقلام</p>
<ul className="space-y-1">
{(parseMeta(detailRow.meta_json).lines ?? []).map((line, i) => (
<li key={i} className="flex justify-between gap-2 text-xs">
<span>
{line.title} × {line.qty}
</span>
<span dir="ltr">{formatMoney(String(Number(line.qty || 0) * Number(line.unit_price || 0)))}</span>
</li>
))}
{!parseMeta(detailRow.meta_json).lines?.length ? (
<li className="text-[var(--muted)]">ردیفی ثبت نشده</li>
) : null}
</ul>
</div>
) : null}
<div className="flex justify-end">
<Button type="button" variant="outline" onClick={() => setDetailRow(null)}>
بستن
</Button>
</div>
</div>
) : null}
</PortalDialog>
<PortalDialog open={!!postRow} onClose={() => setPostRow(null)} title="ثبت حسابداری سند">
<form
className="grid gap-3"
onSubmit={(e) => {
e.preventDefault();
postM.mutate();
}}
>
<p className="text-sm text-[var(--muted)]">
سند {String(postRow?.number)} مبلغ {formatMoney(String(postRow?.amount ?? "0"))}
</p>
<FormField label="پروفایل ثبت">
<Select {...postForm.register("profile_id")}>
<option value="">پروفایل پیشفرض</option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</Select>
</FormField>
{enableAccountingPost === "sales" ? (
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="تخفیف">
<MoneyInput {...postForm.register("discount_amount")} />
</FormField>
<FormField label="مالیات">
<MoneyInput {...postForm.register("tax_amount")} />
</FormField>
</div>
) : null}
{preview ? (
<div className="rounded-xl bg-[var(--surface-muted)] p-3 text-sm">
تراز: {preview.is_balanced ? "بله" : "خیر"} بدهکار: {formatMoney(preview.total_debit)}
{preview.total_credit ? ` — بستانکار: ${formatMoney(preview.total_credit)}` : ""}
</div>
) : null}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setPostRow(null)}>
انصراف
</Button>
<Button type="button" variant="outline" disabled={previewM.isPending} onClick={() => previewM.mutate()}>
پیشنمایش
</Button>
<Button type="submit" disabled={postM.isPending}>
صدور سند حسابداری
</Button>
</div>
</form>
</PortalDialog>
<ConfirmDialog
open={!!confirmId}
onClose={() => setConfirmId(null)}
onConfirm={() => confirmId && confirmM.mutate(confirmId)}
title="تأیید سند؟"
description={
(() => {
const auto = policyQ.data?.auto_post;
const key =
module === "sales" && docType === "invoice"
? "sales_invoice"
: module === "sales" && docType === "return"
? "sales_return"
: module === "purchase" && (docType === "goods_receipt" || docType === "goods-receipts")
? "purchase_goods_receipt"
: module === "purchase" && docType === "invoice"
? "purchase_invoice"
: module === "purchase" && docType === "return"
? "purchase_return"
: null;
if (key && auto?.[key]) {
return "با تأیید، سند عملیاتی تأیید و سند حسابداری خودکار از طریق Posting Engine صادر می‌شود.";
}
return "پس از تأیید، وضعیت سند به confirmed تغییر می‌کند. ثبت دفتر کل جداگانه از «ثبت حسابداری» انجام می‌شود.";
})()
}
confirmLabel="تأیید"
loading={confirmM.isPending}
/>
</div>
);
}