TorbatYar/frontend/components/accounting/OperationalDocumentPage.tsx
Mortezakoohjani 97a363fc53 Wire purchase returns, inventory issues, and GL transfers with auto vouchers.
Goods receipts and warehouse receipts now capture party vs warehouse separately; purchase returns link to invoices and reduce AP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 19:51:32 +03:30

992 lines
38 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(),
warehouse_id: z.string().optional(),
warehouse_name: z.string().optional(),
credit_mode: z.string().optional(),
source_note: z.string().optional(),
movement_type: 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[];
warehouse_id?: string;
warehouse_name?: string;
credit_mode?: string;
source_note?: string;
movement_type?: string;
} {
if (!meta) return {};
if (typeof meta === "string") {
try {
return JSON.parse(meta) as {
lines?: LineValues[];
warehouse_id?: string;
warehouse_name?: string;
credit_mode?: string;
source_note?: string;
};
} catch {
return {};
}
}
if (typeof meta === "object") {
return meta as {
lines?: LineValues[];
warehouse_id?: string;
warehouse_name?: string;
credit_mode?: string;
source_note?: string;
};
}
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,
enableWarehouseContext = false,
}: {
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";
/** Show warehouse select separately from party (طرف حساب). */
enableWarehouseContext?: boolean;
}) {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const withLines = kind === "invoice" || kind === "order" || kind === "receipt" || kind === "request";
const showPurchaseCreditMode = enableAccountingPost === "purchase_gr";
const showMovementType = module === "inventory";
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 warehousesQ = useQuery({
queryKey: ["accounting", tenantId, "warehouses"],
queryFn: () => accountingApi.ops.listWarehouses(tenantId!),
enabled: !!tenantId && enableWarehouseContext,
});
const defaults: FormValues = useMemo(
() => ({
number: "",
doc_date: new Date().toISOString().slice(0, 10),
party_name: "",
party_id: "",
description: "",
amount: "0",
warehouse_id: "",
warehouse_name: "",
credit_mode: "supplier",
source_note: "",
movement_type: "in",
// 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";
const meta: Record<string, unknown> = {};
if (withLines) meta.lines = d.lines ?? [];
if (enableWarehouseContext) {
meta.warehouse_id = d.warehouse_id || undefined;
meta.warehouse_name = d.warehouse_name || undefined;
meta.source_note = d.source_note || undefined;
if (showPurchaseCreditMode) {
meta.credit_mode = d.credit_mode || "supplier";
}
if (showMovementType) {
meta.movement_type = d.movement_type || "in";
}
}
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: Object.keys(meta).length ? JSON.stringify(meta) : 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";
const meta: Record<string, unknown> = {};
if (withLines) meta.lines = d.lines ?? [];
if (enableWarehouseContext) {
meta.warehouse_id = d.warehouse_id || undefined;
meta.warehouse_name = d.warehouse_name || undefined;
meta.source_note = d.source_note || undefined;
if (showPurchaseCreditMode) {
meta.credit_mode = d.credit_mode || "supplier";
}
if (showMovementType) {
meta.movement_type = d.movement_type || "in";
}
}
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: Object.keys(meta).length ? JSON.stringify(meta) : 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,
credit_mode: parseMeta(postRow.meta_json).credit_mode || "supplier",
warehouse_name: parseMeta(postRow.meta_json).warehouse_name || undefined,
source_note: parseMeta(postRow.meta_json).source_note || undefined,
party_name: postRow.party_name ? String(postRow.party_name) : undefined,
});
},
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"),
warehouse_id: meta.warehouse_id ?? "",
warehouse_name: meta.warehouse_name ?? "",
credit_mode: meta.credit_mode ?? "supplier",
source_note: meta.source_note ?? "",
movement_type: meta.movement_type ?? "in",
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} hint="شخص یا شرکت طرف معامله (جدا از انبار)">
<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>
{enableWarehouseContext ? (
<>
{showMovementType ? (
<FormField label="نوع رسید" hint="ورود به انبار یا خروج از انبار">
<Select {...f.register("movement_type")}>
<option value="in">ورود به انبار</option>
<option value="out">خروج از انبار</option>
</Select>
</FormField>
) : null}
<FormField
label={showMovementType ? "انبار" : "انبار مقصد"}
hint="انبار فیزیکی محل ورود/خروج کالا — جدا از طرف حساب"
error={
!(warehousesQ.data ?? []).length
? "هنوز انباری تعریف نشده — از منوی انبار → تنظیمات انبار بسازید"
: undefined
}
>
<Select
value={f.watch("warehouse_id") || ""}
onChange={(e) => {
const id = e.target.value;
const wh = (warehousesQ.data ?? []).find((w) => w.id === id);
f.setValue("warehouse_id", id, { shouldDirty: true });
f.setValue("warehouse_name", wh ? `${wh.code}${wh.name}` : "", { shouldDirty: true });
}}
>
<option value="">انتخاب انبار</option>
{(warehousesQ.data ?? []).map((w) => (
<option key={w.id} value={w.id}>
{w.code} {w.name}
</option>
))}
</Select>
</FormField>
{showPurchaseCreditMode ? (
<>
<FormField
label="طرف بستانکار (اعتبار)"
hint="تأمین‌کننده = بدهی خرید؛ GRNI = رسید بدون فاکتور"
>
<Select {...f.register("credit_mode")}>
<option value="supplier">تأمینکننده / حساب پرداختنی</option>
<option value="grni">حساب GRNI (رسید در راه)</option>
</Select>
</FormField>
<div className="sm:col-span-2">
<FormField label="مبدأ کالا / توضیح منبع" hint="از کجا آمده؟ (اختیاری)">
<Input placeholder="مثال: سفارش خرید PO-12 / باربری فلان" {...f.register("source_note")} />
</FormField>
</div>
</>
) : (
<div className="sm:col-span-2">
<FormField label="توضیح منبع / مرجع" hint="اختیاری">
<Input placeholder="مثال: حواله / سفارش / توضیح" {...f.register("source_note")} />
</FormField>
</div>
)}
</>
) : null}
{!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 },
...(enableWarehouseContext
? [
...(showMovementType
? [
{
key: "movement_type",
header: "نوع",
render: (r: Record<string, unknown>) =>
parseMeta(r.meta_json).movement_type === "out" ? "خروج" : "ورود",
},
]
: []),
{
key: "warehouse",
header: "انبار",
render: (r: Record<string, unknown>) =>
parseMeta(r.meta_json).warehouse_name || "—",
},
...(showPurchaseCreditMode
? [
{
key: "credit_mode",
header: "بستانکار",
render: (r: Record<string, unknown>) => {
const mode = parseMeta(r.meta_json).credit_mode;
return mode === "grni" ? "GRNI" : "تأمین‌کننده";
},
},
]
: []),
]
: []),
{
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>
{enableWarehouseContext ? (
<>
{showMovementType ? (
<p>
<span className="text-[var(--muted)]">نوع:</span>{" "}
{parseMeta(detailRow.meta_json).movement_type === "out" ? "خروج از انبار" : "ورود به انبار"}
</p>
) : null}
<p>
<span className="text-[var(--muted)]">انبار:</span>{" "}
{parseMeta(detailRow.meta_json).warehouse_name || "—"}
</p>
{showPurchaseCreditMode ? (
<>
<p>
<span className="text-[var(--muted)]">طرف بستانکار:</span>{" "}
{parseMeta(detailRow.meta_json).credit_mode === "grni"
? "حساب GRNI (رسید در راه)"
: "تأمین‌کننده / پرداختنی"}
</p>
<p className="sm:col-span-2">
<span className="text-[var(--muted)]">مبدأ کالا:</span>{" "}
{parseMeta(detailRow.meta_json).source_note || "—"}
</p>
</>
) : parseMeta(detailRow.meta_json).source_note ? (
<p className="sm:col-span-2">
<span className="text-[var(--muted)]">مرجع:</span>{" "}
{parseMeta(detailRow.meta_json).source_note}
</p>
) : null}
</>
) : null}
<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>
);
}