TorbatYar/frontend/app/accounting/inventory/issues/page.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

343 lines
13 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 { 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>
);
}