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>
789 lines
29 KiB
TypeScript
789 lines
29 KiB
TypeScript
"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>
|
||
);
|
||
}
|