fix(inventory): adjustment form - transfer rows, safe date handling, Shamsi date picker, drop new-item category field

This commit is contained in:
ali_mohaghegh
2026-08-27 15:04:25 +03:30
parent a191147eca
commit 0774f08823
@@ -5,8 +5,10 @@ import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu"; import { PanelMenu } from "../../components/PanelMenu";
import { panelMenuItems, useLogout } from "./panelMenu"; import { panelMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { apiV26 } from "../../lib/api"; import { apiV26 } from "../../lib/api";
import { useInventoryData, unitName } from "./useInventoryData"; import { useInventoryData, unitName } from "./useInventoryData";
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import type { InventoryTransaction, TransactionType } from "../../lib/types"; import type { InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { gregorianToJalali, toFa } from "../../lib/jalali"; import { gregorianToJalali, toFa } from "../../lib/jalali";
@@ -54,15 +56,11 @@ export function ChangeInventoryPage() {
const [fromId, setFromId] = useState(""); const [fromId, setFromId] = useState("");
const [toId, setToId] = useState(""); const [toId, setToId] = useState("");
const [matTxt, setMatTxt] = useState(""); const [matTxt, setMatTxt] = useState("");
const [catId, setCatId] = useState("");
const [brandTxt, setBrandTxt] = useState(""); const [brandTxt, setBrandTxt] = useState("");
const [unitTxt, setUnitTxt] = useState(""); const [unitTxt, setUnitTxt] = useState("");
const [qty, setQty] = useState(""); const [qty, setQty] = useState("");
const [dateInput, setDateInput] = useState(() => { // تاریخ و ساعت به صورت ISO (تقویم شمسی — JalaliDatePicker)
const d = new Date(); const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 16);
});
const [specsRows, setSpecsRows] = useState<{ rid: number; label: string; value: string }[]>([ const [specsRows, setSpecsRows] = useState<{ rid: number; label: string; value: string }[]>([
{ rid: 1, label: "", value: "" }, { rid: 1, label: "", value: "" },
]); ]);
@@ -82,7 +80,7 @@ export function ChangeInventoryPage() {
} }
}, [params]); }, [params]);
const isAdmin = user?.role === "admin"; const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const isPM = user?.role === "pm"; const isPM = user?.role === "pm";
const warehouses = data.warehouses; const warehouses = data.warehouses;
@@ -93,18 +91,30 @@ export function ChangeInventoryPage() {
const formRecords = useMemo(() => { const formRecords = useMemo(() => {
const txt = matTxt.trim(); const txt = matTxt.trim();
// انتقال از انبار مبدأ استفاده می‌کند، بقیه از انبار انتخابی
const wid = type === "TRANSFER" ? fromId : whId;
return data.inventory.filter((r) => { return data.inventory.filter((r) => {
if (r.warehouse?.id !== whId) return false; if (r.warehouse?.id !== wid) return false;
if (txt && !r.material.name.includes(txt)) return false; if (txt && !r.material.name.includes(txt)) return false;
return true; return true;
}); });
}, [data.inventory, whId, matTxt]); }, [data.inventory, whId, fromId, type, matTxt]);
const matByName = (name: string) => data.lookup.materials.find((m) => m.name === name) || null; const matByName = (name: string) => data.lookup.materials.find((m) => m.name === name) || null;
const brandIdByName = (name: string) => data.lookup.brands.find((b) => b.name === name)?.id || null; const brandIdByName = (name: string) => data.lookup.brands.find((b) => b.name === name)?.id || null;
const unitIdByName = (name: string) => data.lookup.units.find((u) => (u.name_fa || u.code) === name)?.id || null; const unitIdByName = (name: string) => data.lookup.units.find((u) => (u.name_fa || u.code) === name)?.id || null;
const parseNum = (s: string) => { const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, "")); return isFinite(v) ? v : NaN; }; const parseNum = (s: string) => { const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, "")); return isFinite(v) ? v : NaN; };
// تبدیل امن تاریخ فرم به ISO — اگر فیلد خالی/نامعتبر بود (که قبلاً submit را
// بی‌صدا می‌شکست) به «هم‌اکنون» برمی‌گردد تا ثبت هیچ‌وقت قفل نشود.
const toServerIso = () => {
try {
const d = new Date(dateInput);
if (!Number.isNaN(d.getTime())) return d.toISOString();
} catch { /* نادیده */ }
return new Date().toISOString();
};
const specsFromRows = () => { const specsFromRows = () => {
const out: Record<string, string> = {}; const out: Record<string, string> = {};
specsRows.forEach((r) => { specsRows.forEach((r) => {
@@ -170,7 +180,6 @@ export function ChangeInventoryPage() {
const n = parseNum(qty); const n = parseNum(qty);
if (!whId) return toast.error("انبار را انتخاب کنید"); if (!whId) return toast.error("انبار را انتخاب کنید");
if (!m) return toast.error("متریال در فهرست نیست — نام را دقیق تایپ کنید"); if (!m) return toast.error("متریال در فهرست نیست — نام را دقیق تایپ کنید");
if (!m.category && !catId) return toast.error("برای کالای جدید، دسته‌بندی را انتخاب کنید");
if (!bid) return toast.error("برند را انتخاب کنید"); if (!bid) return toast.error("برند را انتخاب کنید");
if (!uid) return toast.error("واحد را انتخاب کنید"); if (!uid) return toast.error("واحد را انتخاب کنید");
if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد"); if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد");
@@ -178,7 +187,7 @@ export function ChangeInventoryPage() {
const op: Record<string, unknown> = { const op: Record<string, unknown> = {
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid, transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
quantity: n, unit_id: uid, specs: specsFromRows(), note, quantity: n, unit_id: uid, specs: specsFromRows(), note,
transaction_date: new Date(dateInput).toISOString(), transaction_date: toServerIso(),
}; };
await postSeq([op]); await postSeq([op]);
}; };
@@ -192,9 +201,9 @@ export function ChangeInventoryPage() {
const rec = formRecords[i]; const rec = formRecords[i];
if (!rec) return null; if (!rec) return null;
if (isCount) { if (isCount) {
return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: new Date(dateInput).toISOString() }; return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: toServerIso() };
} }
return { transaction_type: t, warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: new Date(dateInput).toISOString() }; return { transaction_type: t, warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso() };
}) })
.filter((x): x is Record<string, unknown> => x !== null); .filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است"); if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
@@ -208,7 +217,7 @@ export function ChangeInventoryPage() {
if (!(n > 0)) return null; if (!(n > 0)) return null;
const t = todayWithdrawals[i]; const t = todayWithdrawals[i];
if (!t) return null; if (!t) return null;
return { transaction_type: "RETURN", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: new Date(dateInput).toISOString() }; return { transaction_type: "RETURN", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso() };
}) })
.filter((x): x is Record<string, unknown> => x !== null); .filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است"); if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
@@ -222,7 +231,7 @@ export function ChangeInventoryPage() {
if (!(n > 0)) return null; if (!(n > 0)) return null;
const rec = formRecords[i]; const rec = formRecords[i];
if (!rec) return null; if (!rec) return null;
return { transaction_type: "TRANSFER_OUT", warehouse_id: fromId, dest_warehouse_id: toId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: new Date(dateInput).toISOString() }; return { transaction_type: "TRANSFER_OUT", warehouse_id: fromId, dest_warehouse_id: toId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso() };
}) })
.filter((x): x is Record<string, unknown> => x !== null); .filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است"); if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
@@ -236,7 +245,7 @@ export function ChangeInventoryPage() {
if (!(n > 0)) return null; if (!(n > 0)) return null;
const t = lossTxs[i]; const t = lossTxs[i];
if (!t) return null; if (!t) return null;
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: new Date(dateInput).toISOString() }; return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso() };
}) })
.filter((x): x is Record<string, unknown> => x !== null); .filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است"); if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
@@ -244,11 +253,15 @@ export function ChangeInventoryPage() {
}; };
const doSubmit = () => { const doSubmit = () => {
try {
if (type === "PURCHASE") void submitPurchase(); if (type === "PURCHASE") void submitPurchase();
else if (type === "RETURN") void submitReturn(); else if (type === "RETURN") void submitReturn();
else if (type === "TRANSFER") void submitTransfer(); else if (type === "TRANSFER") void submitTransfer();
else if (type === "FOUND") void submitFound(); else if (type === "FOUND") void submitFound();
else void submitList(type); else void submitList(type);
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطای غیرمنتظره در ثبت");
}
}; };
const canSubmit = (): boolean => { const canSubmit = (): boolean => {
@@ -310,7 +323,7 @@ export function ChangeInventoryPage() {
<Header <Header
title="تغییر موجودی" title="تغییر موجودی"
sub={user?.project_name || ""} sub={user?.project_name || ""}
menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} actions={<InventoryTopNav pathname={location.pathname} onLogout={logout} />}
/> />
<PageLayout className="flex flex-col gap-3"> <PageLayout className="flex flex-col gap-3">
{/* انتخاب نوع تراکنش */} {/* انتخاب نوع تراکنش */}
@@ -444,26 +457,19 @@ export function ChangeInventoryPage() {
/> />
</div> </div>
<div className="field"> <div className="field">
<label>تاریخ و زمان</label> <label>تاریخ و زمان (شمسی)</label>
<input <JalaliDatePicker
type="datetime-local"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm ltr"
value={dateInput} value={dateInput}
onChange={(e) => setDateInput(e.target.value)} onChange={(iso) => setDateInput(iso ?? new Date().toISOString())}
placeholder="۱۴۰۳/۰۵/۱۲"
withTime
/> />
</div> </div>
{!matByName(matTxt) && matTxt.trim() && ( {!matByName(matTxt) && matTxt.trim() && (
<div className="field md:col-span-2"> <div className="field md:col-span-2">
<label>دسته‌بندی (کالای جدید) *</label> <div className="rounded-lg border border-amber/30 bg-amber/5 px-3 py-2 text-[12px] text-amber">
<select این متریال در فهرست نیست — ابتدا متریال را بسازید (مثلاً هنگام ثبت پیشنهاد در دفتر تدارکات)، سپس خرید را ثبت کنید.
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm" </div>
value={catId}
onChange={(e) => setCatId(e.target.value)}
>
<option value="">— انتخاب کنید —</option>
{data.lookup.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<div className="mt-1 text-[11px] text-muted">این متریال در فهرست نیست؛ فقط برای کالای جدید دسته‌بندی لازم است.</div>
</div> </div>
)} )}
<div className="field md:col-span-2"> <div className="field md:col-span-2">