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>
343 lines
13 KiB
TypeScript
343 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import { Controller, useForm } from "react-hook-form";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { toast } from "sonner";
|
||
import { Plus } from "lucide-react";
|
||
import { accountingApi } from "@/lib/accounting-api";
|
||
import { useTenantId } from "@/hooks/useTenantId";
|
||
import { formatMoney, parseMoneyInput, toRialInteger } from "@/lib/utils";
|
||
import {
|
||
PageHeader,
|
||
Button,
|
||
Dialog,
|
||
Input,
|
||
DataTable,
|
||
EmptyState,
|
||
LoadingState,
|
||
ErrorState,
|
||
FormField,
|
||
MoneyInput,
|
||
DatePicker,
|
||
JalaliDateText,
|
||
Select,
|
||
Badge,
|
||
} from "@/components/ds";
|
||
import { ItemCombobox } from "@/components/accounting/EntityCombobox";
|
||
|
||
type FormValues = {
|
||
number: string;
|
||
doc_date: string;
|
||
reason: "expense" | "charity" | "consumption" | "barter";
|
||
item_label: string;
|
||
item_id: string;
|
||
qty: string;
|
||
unit_cost: string;
|
||
warehouse_id: string;
|
||
debit_account_id: string;
|
||
barter_debit_account_id: string;
|
||
barter_amount: string;
|
||
description: string;
|
||
};
|
||
|
||
const REASON_FA: Record<string, string> = {
|
||
expense: "هزینه",
|
||
charity: "خیرات / اهدایی",
|
||
consumption: "مصرف داخلی (آبدارخانه و …)",
|
||
barter: "تهاتر / معاوضه کالا",
|
||
};
|
||
|
||
export default function InventoryIssuesPage() {
|
||
const { tenantId } = useTenantId();
|
||
const qc = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
|
||
const listQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "ops-docs", "inventory", "issue"],
|
||
queryFn: () => accountingApi.ops.listDocuments(tenantId!, "inventory", "issue"),
|
||
enabled: !!tenantId,
|
||
});
|
||
const accountsQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "accounts"],
|
||
queryFn: () => accountingApi.accounts.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const warehousesQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "warehouses"],
|
||
queryFn: () => accountingApi.ops.listWarehouses(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const policyQ = useQuery({
|
||
queryKey: ["accounting", tenantId, "posting-policy"],
|
||
queryFn: () => accountingApi.setup.getPostingPolicy(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
const form = useForm<FormValues>({
|
||
defaultValues: {
|
||
number: "",
|
||
doc_date: new Date().toISOString().slice(0, 10),
|
||
reason: "expense",
|
||
item_label: "",
|
||
item_id: "",
|
||
qty: "1",
|
||
unit_cost: "0",
|
||
warehouse_id: "",
|
||
debit_account_id: "",
|
||
barter_debit_account_id: "",
|
||
barter_amount: "0",
|
||
description: "",
|
||
},
|
||
});
|
||
|
||
const reason = form.watch("reason");
|
||
const amount = useMemo(() => {
|
||
const q = Number(toRialInteger(form.watch("qty")) || "0");
|
||
const c = Number(toRialInteger(form.watch("unit_cost")) || "0");
|
||
return String(q * c);
|
||
}, [form.watch("qty"), form.watch("unit_cost")]);
|
||
|
||
const postable = (accountsQ.data ?? []).filter((a) => a.is_postable);
|
||
const autoIssue = Boolean(policyQ.data?.auto_post?.inventory_issue ?? true);
|
||
|
||
const createM = useMutation({
|
||
mutationFn: async (d: FormValues) => {
|
||
const amt = parseMoneyInput(amount) || "0";
|
||
if (Number(amt) <= 0) throw new Error("مبلغ خروج باید بزرگتر از صفر باشد");
|
||
if (!d.debit_account_id) throw new Error("حساب هزینه/مقصد الزامی است");
|
||
if (d.reason === "barter" && !d.barter_debit_account_id) {
|
||
throw new Error("برای تهاتر، حساب کالای دریافتی را انتخاب کنید");
|
||
}
|
||
|
||
const doc = await accountingApi.ops.createDocument(tenantId!, {
|
||
module: "inventory",
|
||
doc_type: "issue",
|
||
number: d.number?.trim() || undefined,
|
||
doc_date: d.doc_date,
|
||
amount: amt,
|
||
description: d.description || REASON_FA[d.reason],
|
||
status: "draft",
|
||
meta_json: JSON.stringify({
|
||
reason: d.reason,
|
||
item_id: d.item_id,
|
||
item_label: d.item_label,
|
||
qty: d.qty,
|
||
unit_cost: d.unit_cost,
|
||
warehouse_id: d.warehouse_id || undefined,
|
||
debit_account_id: d.debit_account_id,
|
||
barter_debit_account_id: d.barter_debit_account_id || undefined,
|
||
barter_amount: d.barter_amount || undefined,
|
||
}),
|
||
});
|
||
|
||
if (autoIssue) {
|
||
const posted = await accountingApi.purchaseInventory.postInventoryIssue(tenantId!, {
|
||
source_document_id: String(doc.id),
|
||
amount: amt,
|
||
reason: d.reason,
|
||
debit_account_id: d.debit_account_id,
|
||
barter_debit_account_id: d.barter_debit_account_id || undefined,
|
||
barter_amount:
|
||
d.reason === "barter" ? parseMoneyInput(d.barter_amount) || amt : undefined,
|
||
});
|
||
await accountingApi.ops.confirmDocument(tenantId!, String(doc.id)).catch(() => null);
|
||
return posted;
|
||
}
|
||
return doc;
|
||
},
|
||
onSuccess: async (res) => {
|
||
const vn =
|
||
res && typeof res === "object" && "voucher_number" in res
|
||
? String((res as { voucher_number?: string }).voucher_number || "")
|
||
: "";
|
||
toast.success(vn ? `خروج ثبت و سند ${vn} صادر شد` : "خروج کالا ثبت شد");
|
||
setOpen(false);
|
||
form.reset();
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "ops-docs", "inventory", "issue"] });
|
||
await qc.invalidateQueries({ queryKey: ["accounting", tenantId, "vouchers"] });
|
||
},
|
||
onError: (e: Error) => toast.error(e.message),
|
||
});
|
||
|
||
if (!tenantId || listQ.isLoading) return <LoadingState />;
|
||
if (listQ.error) return <ErrorState message={listQ.error.message} onRetry={() => listQ.refetch()} />;
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="خروج / هزینهکرد کالا"
|
||
description="خروج موجودی برای هزینه، خیرات، مصرف داخلی یا تهاتر — سند حسابداری اتومات (Dr هزینه / Cr موجودی)."
|
||
actions={
|
||
<Button type="button" onClick={() => setOpen(true)}>
|
||
<Plus className="h-4 w-4" />
|
||
خروج کالای جدید
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
<DataTable
|
||
columns={[
|
||
{ key: "number", header: "شماره" },
|
||
{
|
||
key: "doc_date",
|
||
header: "تاریخ",
|
||
render: (r) => <JalaliDateText value={String(r.doc_date ?? "")} />,
|
||
},
|
||
{
|
||
key: "reason",
|
||
header: "نوع",
|
||
render: (r) => {
|
||
try {
|
||
const meta = JSON.parse(String(r.meta_json || "{}")) as { reason?: string };
|
||
return REASON_FA[meta.reason || ""] || meta.reason || "—";
|
||
} catch {
|
||
return "—";
|
||
}
|
||
},
|
||
},
|
||
{
|
||
key: "amount",
|
||
header: "مبلغ",
|
||
render: (r) => formatMoney(String(r.amount ?? "0")),
|
||
},
|
||
{
|
||
key: "status",
|
||
header: "وضعیت",
|
||
render: (r) => <Badge tone={r.status === "posted" || r.status === "confirmed" ? "success" : "default"}>{String(r.status)}</Badge>,
|
||
},
|
||
{ key: "description", header: "شرح" },
|
||
]}
|
||
rows={(listQ.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="خروجی ثبت نشده" />}
|
||
/>
|
||
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="خروج / هزینهکرد کالا">
|
||
<form className="grid gap-3 sm:grid-cols-2" onSubmit={form.handleSubmit((d) => createM.mutate(d))}>
|
||
<FormField label="نوع خروج">
|
||
<Select
|
||
value={reason}
|
||
onChange={(e) => form.setValue("reason", e.target.value as FormValues["reason"])}
|
||
>
|
||
<option value="expense">هزینه کردن کالا</option>
|
||
<option value="charity">خیرات / اهدایی (بدون دریافت وجه)</option>
|
||
<option value="consumption">مصرف داخلی (آبدارخانه و …)</option>
|
||
<option value="barter">تهاتر — کالا میدهم، چیز دیگری میگیرم</option>
|
||
</Select>
|
||
</FormField>
|
||
<FormField label="تاریخ">
|
||
<Controller
|
||
control={form.control}
|
||
name="doc_date"
|
||
render={({ field }) => (
|
||
<DatePicker value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} />
|
||
)}
|
||
/>
|
||
</FormField>
|
||
<div className="sm:col-span-2">
|
||
<FormField label="کالای خروجی" hint="کالایی که از انبار خارج میشود">
|
||
<ItemCombobox
|
||
valueLabel={form.watch("item_label") || undefined}
|
||
onSelect={(item) => {
|
||
form.setValue("item_id", item.id);
|
||
form.setValue("item_label", `${item.code} — ${item.name}`);
|
||
form.setValue("unit_cost", parseMoneyInput(item.unit_cost) || "0");
|
||
}}
|
||
onClear={() => {
|
||
form.setValue("item_id", "");
|
||
form.setValue("item_label", "");
|
||
}}
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
<FormField label="تعداد">
|
||
<Input {...form.register("qty", { required: true })} />
|
||
</FormField>
|
||
<FormField label="بهای واحد">
|
||
<MoneyInput {...form.register("unit_cost", { required: true })} />
|
||
</FormField>
|
||
<FormField label="جمع مبلغ">
|
||
<Input value={formatMoney(amount)} readOnly dir="ltr" />
|
||
</FormField>
|
||
<FormField label="انبار مبدأ">
|
||
<Select {...form.register("warehouse_id")}>
|
||
<option value="">انتخاب…</option>
|
||
{(warehousesQ.data ?? []).map((w) => (
|
||
<option key={w.id} value={w.id}>
|
||
{w.code} — {w.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
<div className="sm:col-span-2">
|
||
<FormField
|
||
label={
|
||
reason === "charity"
|
||
? "حساب هزینه خیرات / اهدایی"
|
||
: reason === "consumption"
|
||
? "حساب هزینه مصرف (مثلاً آبدارخانه)"
|
||
: reason === "barter"
|
||
? "حساب مابهالتفاوت (اگر مبلغها برابر نباشند)"
|
||
: "حساب هزینه مقصد"
|
||
}
|
||
hint="بدهکار سند اتومات — موجودی بستانکار میشود"
|
||
>
|
||
<Select {...form.register("debit_account_id", { required: true })}>
|
||
<option value="">انتخاب حساب…</option>
|
||
{postable.map((a) => (
|
||
<option key={a.id} value={a.id}>
|
||
{a.code} — {a.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
</div>
|
||
{reason === "barter" ? (
|
||
<>
|
||
<div className="sm:col-span-2">
|
||
<FormField
|
||
label="حساب کالای دریافتی"
|
||
hint="مثلاً موجودی چای / ملزومات آبدارخانه که در ازای کالای خروجی میگیرید"
|
||
>
|
||
<Select {...form.register("barter_debit_account_id", { required: true })}>
|
||
<option value="">انتخاب حساب…</option>
|
||
{postable.map((a) => (
|
||
<option key={a.id} value={a.id}>
|
||
{a.code} — {a.name}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</FormField>
|
||
</div>
|
||
<FormField label="ارزش کالای دریافتی">
|
||
<MoneyInput {...form.register("barter_amount")} />
|
||
</FormField>
|
||
</>
|
||
) : null}
|
||
<div className="sm:col-span-2">
|
||
<FormField label="توضیح">
|
||
<Input
|
||
placeholder="مثال: اهدا به خیریه / مصرف آبدارخانه در ازای چای"
|
||
{...form.register("description")}
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
<p className="sm:col-span-2 text-xs text-[var(--muted)]">
|
||
{autoIssue
|
||
? "با ذخیره، سند حسابداری خودکار از Posting Engine صادر میشود."
|
||
: "ثبت خودکار خاموش است — فقط سند عملیاتی ذخیره میشود (تنظیمات → اسناد اتوماتیک)."}
|
||
</p>
|
||
<div className="flex justify-end gap-2 sm:col-span-2">
|
||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||
انصراف
|
||
</Button>
|
||
<Button type="submit" disabled={createM.isPending}>
|
||
ذخیره و صدور سند
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|