"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; 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(
{children}
, 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 | null>(null); const [editRow, setEditRow] = useState | null>(null); const [confirmId, setConfirmId] = useState(null); const [postRow, setPostRow] = useState | 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({ resolver: zodResolver(formSchema), defaultValues: defaults, }); const editForm = useForm({ 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 = {}; 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 = {}; 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) => { 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 ; if (listQ.error) { return listQ.refetch()} />; } const rows = (listQ.data ?? []) as unknown as Record[]; 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 ) => (
( )} /> { f.setValue("party_name", party?.name || "", { shouldDirty: true }); f.setValue("party_id", party?.id || "", { shouldDirty: true }); }} /> {enableWarehouseContext ? ( <> {showMovementType ? ( ) : null} {showPurchaseCreditMode ? ( <>
) : (
)} ) : null} {!withLines ? ( ) : ( )}
{withLines ? (

اقلام سند

{linesArray.fields.map((field, index) => (
{ 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 }); }} />
))}
) : null}
); return (
{createLabel} } /> {rows.length === 0 ? ( {createLabel} } /> ) : ( , }, { key: "party_name", header: partyLabel }, ...(enableWarehouseContext ? [ ...(showMovementType ? [ { key: "movement_type", header: "نوع", render: (r: Record) => parseMeta(r.meta_json).movement_type === "out" ? "خروج" : "ورود", }, ] : []), { key: "warehouse", header: "انبار", render: (r: Record) => parseMeta(r.meta_json).warehouse_name || "—", }, ...(showPurchaseCreditMode ? [ { key: "credit_mode", header: "بستانکار", render: (r: Record) => { 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) => ( {String(r.status)} ), }, { key: "id", header: "عملیات", render: (r) => (
{r.status === "draft" ? ( <> ) : null} {enableAccountingPost ? ( ) : null}
), }, ]} rows={rows} /> )} setCreateOpen(false)} title={createLabel} wide={withLines}> {renderDocForm( form, createLines, (d) => createM.mutate(d), createM.isPending, () => setCreateOpen(false), createTotal )} 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 )} setDetailRow(null)} title={`جزئیات ${title}`} wide> {detailRow ? (

شماره: {String(detailRow.number)}

تاریخ:{" "}

{partyLabel}:{" "} {String(detailRow.party_name || "—")}

{enableWarehouseContext ? ( <> {showMovementType ? (

نوع:{" "} {parseMeta(detailRow.meta_json).movement_type === "out" ? "خروج از انبار" : "ورود به انبار"}

) : null}

انبار:{" "} {parseMeta(detailRow.meta_json).warehouse_name || "—"}

{showPurchaseCreditMode ? ( <>

طرف بستانکار:{" "} {parseMeta(detailRow.meta_json).credit_mode === "grni" ? "حساب GRNI (رسید در راه)" : "تأمین‌کننده / پرداختنی"}

مبدأ کالا:{" "} {parseMeta(detailRow.meta_json).source_note || "—"}

) : parseMeta(detailRow.meta_json).source_note ? (

مرجع:{" "} {parseMeta(detailRow.meta_json).source_note}

) : null} ) : null}

مبلغ:{" "} {formatMoney(String(detailRow.amount ?? "0"))}

وضعیت: {String(detailRow.status)}

توضیح: {String(detailRow.description || "—")}

{withLines ? (

اقلام

    {(parseMeta(detailRow.meta_json).lines ?? []).map((line, i) => (
  • {line.title} × {line.qty} {formatMoney(String(Number(line.qty || 0) * Number(line.unit_price || 0)))}
  • ))} {!parseMeta(detailRow.meta_json).lines?.length ? (
  • ردیفی ثبت نشده
  • ) : null}
) : null}
) : null}
setPostRow(null)} title="ثبت حسابداری سند">
{ e.preventDefault(); postM.mutate(); }} >

سند {String(postRow?.number)} — مبلغ {formatMoney(String(postRow?.amount ?? "0"))}

{enableAccountingPost === "sales" ? (
) : null} {preview ? (
تراز: {preview.is_balanced ? "بله" : "خیر"} — بدهکار: {formatMoney(preview.total_debit)} {preview.total_credit ? ` — بستانکار: ${formatMoney(preview.total_credit)}` : ""}
) : null}
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} />
); }