Files
ERP/frontend/src/pages/Inventory/ChangeInventoryPage.tsx
T
whqreza 529eb67d01 feat(warehouse): wizard-style spec chips + unique unit options
- «مشخصه فنی» in موجودی اولیه is now the same MultiSelectCombobox chip
  control as the wizard's «مشخصه» (name-only chips, stored as spec keys
  with empty values so stock variants stay separate).
- Unit combobox options deduped via uniqueUnits (canonical base preferred).
- Description/validation copy updated accordingly.
2026-09-08 14:42:24 +03:30

950 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { Combobox } from "../../components/Combobox";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { useAuthStore } from "../../store/auth";
import { inventorySubheader } from "./panelMenu";
import { apiV26 } from "../../lib/api";
import { notifyDataChanged } from "../../lib/crossTab";
import { useInventoryData, unitName, whName } from "./useInventoryData";
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import { uniqueUnits } from "../../lib/units";
import type { InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters";
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
type FormType = TransactionType | "TRANSFER";
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
// رنگ‌بندی معنایی: سبز زمردی = ورود، رز = خروج، آبی = خنثی/جابه‌جایی/شمارش
{ t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند/واحد را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپ‌شده هنگام ثبت به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود. ثبت حداقل یک مشخصه فنی الزامی است." },
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدل‌ها (برند و مشخصه متفاوت) نمایش داده می‌شوند. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." },
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شده‌اند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
{ t: "TRANSFER", label: "انتقال", dir: "جابه‌جایی", cls: "mv", color: "#2563eb", desc: "جابه‌جایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
{ t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت ضایعات خرید (مثلاً لوله‌های سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." },
{ t: "WASTE", label: "اتلاف", dir: "کاهش", cls: "out", color: "#e11d48", desc: "اتلاف بعد از خرید (خرابی در انبار یا هدررفت در کار) — انبار و متریال را انتخاب کنید." },
{ t: "LOSS", label: "مفقودی", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت مفقودی — انبار و متریال را انتخاب و تعداد مفقودی هر مدل را وارد کنید." },
{ t: "FOUND", label: "پیدا شده", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت پیدا شدن کالاهای مفقود — مفقودی‌های همان انبار و متریال (یا «همه مفقودی‌ها») نمایش داده می‌شود." },
{ t: "STOCK_COUNT", label: "شمارش موجودی", dir: "اصلاح", cls: "cnt", color: "#2563eb", desc: "شمارش فیزیکی — مقدار شمارش‌شده هر رکورد را وارد کنید تا موجودی اصلاح شود و اختلاف ثبت گردد." },
];
interface RowDraft { key: string; qty: string; note: string; }
function todayKey(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function gregKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function jalDateLabel(d: Date): string {
const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
}
// تغییر موجودی — پورت کامل صفحه‌ی ارسالی (همه انواع تراکنش)
export function ChangeInventoryPage() {
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
const [params] = useSearchParams();
const [type, setType] = useState<FormType>("PURCHASE");
const [whId, setWhId] = useState("");
const [fromId, setFromId] = useState("");
const [toId, setToId] = useState("");
const [matTxt, setMatTxt] = useState("");
const [brandTxt, setBrandTxt] = useState("");
const [unitTxt, setUnitTxt] = useState("");
const [qty, setQty] = useState("");
// تاریخ و ساعت به صورت ISO (تقویم شمسی — JalaliDatePicker)
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
// مشخصه‌های فنی — چیپ‌های چندتایی، دقیقاً مثل فیلد «مشخصه» در ویزارد دفتر تدارکات
const [specsChips, setSpecsChips] = useState<string[]>([]);
const [note, setNote] = useState("");
const [rows, setRows] = useState<RowDraft[]>([]);
const [busy, setBusy] = useState(false);
// prefill از URL (پرش از کنترل روزانه)
useEffect(() => {
const t = params.get("type");
const w = params.get("wh");
const m = params.get("mat");
if (t && FORM_TYPES.some((f) => f.t === t)) {
setType(t as TransactionType);
if (w) setWhId(w);
if (m) setMatTxt(m);
}
}, [params]);
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const isPM = user?.role === "pm";
const warehouses = data.warehouses;
const whOpts = useMemo(
() => (isAdmin ? warehouses : warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""))),
[warehouses, isAdmin, user?.project_id],
);
const formRecords = useMemo(() => {
const txt = matTxt.trim();
// انتقال از انبار مبدأ استفاده می‌کند، بقیه از انبار انتخابی
const wid = type === "TRANSFER" ? fromId : whId;
return data.inventory.filter((r) => {
if (r.warehouse?.id !== wid) return false;
if (txt && !r.material.name.includes(txt)) return false;
return true;
});
}, [data.inventory, whId, fromId, type, matTxt]);
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 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; };
// تبدیل امن تاریخ فرم به ISO — اگر فیلد خالی/نامعتبر بود (که قبلاً submit را
// بی‌صدا می‌شکست) به «هم‌اکنون» برمی‌گردد تا ثبت هیچ‌وقت قفل نشود.
const toServerIso = () => {
try {
const d = new Date(dateInput);
if (!Number.isNaN(d.getTime())) return d.toISOString();
} catch { /* نادیده */ }
return new Date().toISOString();
};
// هر چیپ یک مشخصه است؛ در specs انبار به‌صورت کلید با مقدار خالی ذخیره می‌شود
// تا رکوردهای انبارِ دارای مشخصه‌های متفاوت از هم جدا بمانند
const specsFromChips = () => {
const out: Record<string, string> = {};
specsChips.forEach((c) => {
const k = c.trim();
if (k) out[k] = "";
});
return out;
};
const specsComplete = specsChips.some((c) => c.trim());
const todayWithdrawals = useMemo(() => {
const tk = todayKey();
const txt = matTxt.trim();
return data.transactions
.filter((t) => {
if (t.transaction_type !== "WITHDRAWAL" || t.warehouse_id !== whId) return false;
if (gregKey(new Date(t.transaction_date)) !== tk) return false;
if (txt && !(t.material?.name || "").includes(txt)) return false;
return true;
})
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
}, [data.transactions, whId, matTxt]);
const lossTxs = useMemo(() => {
const txt = matTxt.trim();
return data.transactions
.filter((t) => {
if (t.transaction_type !== "LOSS" || t.warehouse_id !== whId) return false;
if (txt && !(t.material?.name || "").includes(txt)) return false;
return true;
})
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
}, [data.transactions, whId, matTxt]);
// --- پیدا شده: مانده‌ی مفقودی هر ردیف = مفقودی − مجموع پیداشده‌های قبلیِ همان رکورد
const lossKey = (t: InventoryTransaction) =>
[t.warehouse_id, t.material?.id || "", t.brand?.id || "", JSON.stringify(t.specs || {})].join("|");
const foundRemaining = useMemo(() => {
const foundByKey = new Map<string, number>();
data.transactions
.filter((x) => x.transaction_type === "FOUND")
.forEach((x) => {
const k = lossKey(x);
foundByKey.set(k, (foundByKey.get(k) || 0) + Math.abs(x.quantity_number || 0));
});
return lossTxs.map((t) => {
const loss = Math.abs(t.quantity_number || 0);
const found = foundByKey.get(lossKey(t)) || 0;
return Math.max(0, Math.round((loss - found) * 10000) / 10000);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lossTxs, data.transactions, whId, matTxt]);
// مفقودی‌های کاملاً پیداشده از فهرست «در انتظار پیدا شدن» حذف می‌شوند
const pendingLosses = useMemo(
() =>
lossTxs
.map((tx, i) => ({ tx, remaining: foundRemaining[i] ?? 0 }))
.filter((x) => x.remaining > 0),
[lossTxs, foundRemaining],
);
const postOne = async (op: Record<string, unknown>) => {
await apiV26.inventory.createTransaction(op as never);
};
const postSeq = async (ops: Record<string, unknown>[]) => {
setBusy(true);
try {
for (const op of ops) await postOne(op);
toast.success("ثبت شد ✓");
// اعلام به تب‌های دیگر (داشبورد/کنترل روزانه/... باز خودشان را تازه می‌کنند)
notifyDataChanged("inventory");
setRows([]);
setMatTxt("");
setBrandTxt("");
setUnitTxt("");
setQty("");
setSpecsChips([]);
setNote("");
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ثبت");
} finally {
setBusy(false);
}
};
// ---------- submit handlers ----------
const submitPurchase = async () => {
const matName = matTxt.trim();
const brandName = brandTxt.trim();
const unitName = unitTxt.trim();
const n = parseNum(qty);
if (!whId) return toast.error("انبار را انتخاب کنید");
if (!matName) return toast.error("نام متریال را وارد کنید");
if (!brandName) return toast.error("نام برند را وارد کنید");
if (!unitName) return toast.error("نام واحد را وارد کنید");
if (!(n > 0)) return toast.error("تعداد باید بزرگ‌تر از صفر باشد");
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید (نام را بنویسید و Enter بزنید)");
try {
// ساختِ به‌هنگام موجودیت‌های ناشناخته در کاتالوگ سراسری (بدون پیش‌نیاز تدارکات)
let uid = unitIdByName(unitName);
if (!uid) {
const created = await apiV26.catalog.createUnit(unitName);
uid = created.id;
}
let bid = brandIdByName(brandName);
if (!bid) {
const created = await apiV26.catalog.createBrand(brandName);
bid = created.id;
}
let m = matByName(matName);
if (!m) {
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
m = { id: created.id, name: created.name, category: null, canonical_unit: null };
}
const op: Record<string, unknown> = {
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
quantity: n, unit_id: uid, specs: specsFromChips(), note,
transaction_date: toServerIso(),
};
await postSeq([op]);
} catch (err) {
setBusy(false);
toast.error(err instanceof Error ? err.message : "خطا در ثبت موجودی اولیه");
}
};
const submitList = async (t: TransactionType) => {
const ops: Record<string, unknown>[] = rows
.map((r, i): Record<string, unknown> | null => {
const n = parseNum(r.qty);
if (!(n > 0)) return null;
const rec = formRecords[i];
if (!rec) return null;
if (t === "STOCK_COUNT") {
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: toServerIso() };
})
.filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
await postSeq(ops);
};
// ---------- شمارش موجودی: پیش‌نمایش اختلاف + تأیید قبل از ثبت ----------
interface CountConfirmRow {
material: string;
unit: string;
warehouse: string;
current: number;
counted: number;
variance: number;
}
const [countConfirm, setCountConfirm] = useState<CountConfirmRow[] | null>(null);
const [countBusy, setCountBusy] = useState(false);
const countOps = (): { ops: Record<string, unknown>[]; confirm: CountConfirmRow[] } | null => {
const ops: Record<string, unknown>[] = [];
const confirm: CountConfirmRow[] = [];
rows.forEach((r, i) => {
const n = parseNum(r.qty);
const rec = formRecords[i];
if (!(n > 0) || !rec) return;
ops.push({
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(),
});
confirm.push({
material: rec.material.name,
unit: unitName(data.lookup.units, rec.unit?.id),
warehouse: whName(warehouses, whId),
current: rec.quantity_number,
counted: n,
variance: Math.round((n - rec.quantity_number) * 10000) / 10000,
});
});
if (!ops.length) return null;
return { ops, confirm };
};
const requestCountConfirm = () => {
const built = countOps();
if (!built) return toast.error("هیچ ردیفی پر نشده است");
setCountConfirm(built.confirm);
};
const confirmCount = async () => {
const built = countOps();
if (!built) return;
setCountBusy(true);
try {
await postSeq(built.ops);
setCountConfirm(null);
} finally {
setCountBusy(false);
}
};
const submitReturn = async () => {
const ops: Record<string, unknown>[] = rows
.map((r, i): Record<string, unknown> | null => {
const n = parseNum(r.qty);
if (!(n > 0)) return null;
const t = todayWithdrawals[i];
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: toServerIso() };
})
.filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
await postSeq(ops);
};
const submitTransfer = async () => {
const ops: Record<string, unknown>[] = rows
.map((r, i): Record<string, unknown> | null => {
const n = parseNum(r.qty);
if (!(n > 0)) return null;
const rec = formRecords[i];
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: toServerIso() };
})
.filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
await postSeq(ops);
};
const submitFound = async () => {
const ops: Record<string, unknown>[] = rows
.map((r, i): Record<string, unknown> | null => {
const n = parseNum(r.qty);
if (!(n > 0)) return null;
const item = pendingLosses[i];
if (!item) return null;
if (n > item.remaining) return null; // سقف سمت کلاینت (بک‌اند هم رد می‌کند)
const t = item.tx;
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);
if (!ops.length) return toast.error("هیچ ردیف معتبری پر نشده است");
await postSeq(ops);
};
const doSubmit = () => {
try {
if (type === "PURCHASE") void submitPurchase();
else if (type === "RETURN") void submitReturn();
else if (type === "TRANSFER") void submitTransfer();
else if (type === "FOUND") void submitFound();
else if (type === "STOCK_COUNT") requestCountConfirm();
else void submitList(type);
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطای غیرمنتظره در ثبت");
}
};
const canSubmit = (): boolean => {
if (type === "PURCHASE") {
// نام‌های تایپ‌شده لازم نیست از قبل در فهرست باشند — در submit ساخته می‌شوند
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
}
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
// پیدا شده: هر مقدار واردشده باید داخل مانده‌ی مفقودی همان ردیف باشد
if (type === "FOUND") {
return !!(
whId &&
rows.length > 0 &&
rows.every((r, i) => {
const n = parseNum(r.qty);
return !(n > 0) || n <= (pendingLosses[i]?.remaining ?? 0);
}) &&
rows.some((r) => parseNum(r.qty) > 0)
);
}
return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
};
// sync rows when the record list changes
useEffect(() => {
if (type === "RETURN") {
setRows(todayWithdrawals.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
} else if (type === "FOUND") {
setRows(pendingLosses.map((x) => ({ key: x.tx.inventory_id, qty: "", note: "" })));
} else if (type === "TRANSFER") {
setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" })));
} else {
setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" })));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type, whId, fromId, toId, matTxt, formRecords.length, todayWithdrawals.length, pendingLosses.length]);
const meta = FORM_TYPES.find((f) => f.t === type)!;
if (isAdmin) {
return (
<div className="min-h-screen">
<Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<div className="rounded-xl border border-line bg-panel p-8 text-center">
<div className="text-3xl">🔒</div>
<h3 className="mt-2 text-[15px] font-bold">دسترسی فقط‌خواندنی</h3>
<p className="mt-1 text-[12.5px] text-muted">نقش ادمین اجازه ثبت تغییر موجودی ندارد. این بخش برای مدیران پروژه فعال است.</p>
</div>
</PageLayout>
</div>
);
}
if (!isPM) {
return (
<div className="min-h-screen">
<Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<div className="text-[13px] text-muted">این بخش فقط برای مدیر پروژه است.</div>
</PageLayout>
</div>
);
}
const isCount = type === "STOCK_COUNT";
const isTransfer = type === "TRANSFER";
return (
<div className="min-h-screen">
<Header
title="تغییر موجودی"
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد"
/>
<PageLayout className="flex flex-col gap-3">
{/* انتخاب نوع تراکنش */}
<div className="rounded-xl border border-line bg-panel p-3">
<h3 className="mb-2 text-[13px] font-bold">نوع تغییر موجودی</h3>
<div className="flex flex-wrap gap-1.5">
{FORM_TYPES.map((f) => (
<button
key={f.t}
type="button"
onClick={() => { setType(f.t); setRows([]); setMatTxt(""); setWhId(""); setFromId(""); setToId(""); }}
className={`rounded-lg border px-3 py-2 text-[12.5px] ${type === f.t ? "font-bold text-white" : "border-line bg-panel"}`}
style={type === f.t ? { background: f.color, borderColor: f.color } : undefined}
>
{f.label} <span className="text-[10.5px] opacity-80">({f.dir})</span>
</button>
))}
</div>
<p className="mt-2 text-[11.5px] leading-5 text-muted">{meta.desc}</p>
</div>
<div className="rounded-xl border border-line bg-panel p-4">
{/* انتخاب انبارها */}
{!isTransfer ? (
<div className="mb-3 grid gap-3 md:grid-cols-2">
<div className="field">
<label>انبار *</label>
<select
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
value={whId}
onChange={(e) => { setWhId(e.target.value); setMatTxt(""); setRows([]); }}
>
<option value="">— انتخاب کنید —</option>
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
</select>
</div>
{(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
<div className="field">
<label>{type === "FOUND" ? "متریال (خالی = همه مفقودی‌ها)" : "متریال *"}</label>
<input
list="dl-mat"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
placeholder={type === "RETURN" ? "نام متریال برداشت‌شده امروز…" : "نام متریال — از پیشنهادها یا جدید…"}
value={matTxt}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
/>
<datalist id="dl-mat">
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div>
)}
</div>
) : (
<div className="mb-3 grid gap-3 md:grid-cols-2">
<div className="field">
<label>از (انبار مبدأ) *</label>
<select
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
value={fromId}
onChange={(e) => { setFromId(e.target.value); setToId(""); setMatTxt(""); setRows([]); }}
>
<option value="">— انتخاب انبار مبدأ —</option>
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
</select>
</div>
<div className="field">
<label>به (انبار مقصد) *</label>
<select
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
value={toId}
disabled={!fromId}
onChange={(e) => { setToId(e.target.value); setMatTxt(""); setRows([]); }}
>
<option value="">— انتخاب انبار مقصد —</option>
{whOpts.filter((w) => w.id !== fromId).map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
</select>
</div>
<div className="field md:col-span-2">
<label>متریال *</label>
<input
list="dl-mat2"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
placeholder="نام متریال را تایپ کنید — همه مدل‌ها نمایش داده می‌شوند…"
value={matTxt}
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
/>
<datalist id="dl-mat2">
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
</datalist>
</div>
</div>
)}
{/* فرم خرید / موجودی اولیه */}
{type === "PURCHASE" && (
<div className="grid gap-3 md:grid-cols-2">
<div className="field md:col-span-2">
<label>متریال *</label>
<Combobox
value={matTxt}
items={data.lookup.materials.map((mm) => mm.name)}
allowAdd
addSuffix="به عنوان متریال جدید"
placeholder="نام متریال — از فهرست انتخاب یا نام جدید تایپ کنید"
onInputChange={(v) => { setMatTxt(v); setRows([]); }}
onChange={(it) => { setMatTxt(it.label); setRows([]); }}
/>
{!matByName(matTxt) && matTxt.trim() && (
<div className="mt-1.5 rounded-lg border border-accent/25 bg-accent-bg/60 px-3 py-2 text-[12px] text-accent-dark">
«{matTxt.trim()}» در فهرست نیست — هنگام ثبت، به‌صورت خودکار در کاتالوگ سراسری ساخته می‌شود.
</div>
)}
</div>
<div className="field">
<label>برند *</label>
<Combobox
value={brandTxt}
items={data.lookup.brands.map((b) => b.name)}
allowAdd
addSuffix="به عنوان برند جدید"
placeholder="نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"
onInputChange={setBrandTxt}
onChange={(it) => setBrandTxt(it.label)}
/>
</div>
<div className="field">
<label>واحد *</label>
<Combobox
value={unitTxt}
items={uniqueUnits(data.lookup.units).map((u) => u.name_fa || u.code)}
allowAdd
addSuffix="به عنوان واحد جدید"
placeholder="واحد اندازه‌گیری — از فهرست یا نام جدید"
onInputChange={setUnitTxt}
onChange={(it) => setUnitTxt(it.label)}
/>
</div>
<div className="field">
<label>تعداد *</label>
<input
inputMode="decimal"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm ltr"
placeholder="مثلاً ۲۰۰"
value={qty}
onChange={(e) => setQty(e.target.value)}
/>
</div>
<div className="field">
<label>تاریخ و زمان (شمسی)</label>
<JalaliDatePicker
value={dateInput}
onChange={(iso) => setDateInput(iso ?? new Date().toISOString())}
placeholder="۱۴۰۳/۰۵/۱۲"
withTime
/>
</div>
<div className="field md:col-span-2">
<label>مشخصه‌های فنی *</label>
<MultiSelectCombobox
values={specsChips}
onChange={setSpecsChips}
placeholder="نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"
className="w-full"
/>
</div>
<div className="field md:col-span-2">
<label>یادداشت</label>
<textarea
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm"
placeholder="شماره فاکتور، تأمین‌کننده، توضیحات…"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</div>
</div>
)}
{/* ردیف‌های عملیات لیستی */}
{(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT") && (
<RowTable
isCount={isCount}
rows={rows}
setRows={setRows}
records={formRecords}
unitNameFn={(id) => unitName(data.lookup.units, id)}
emptyMsg={
!whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید."
: !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدل‌های آن (با برند و مشخصه متفاوت) نمایش داده شود."
: !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا «موجودی اولیه» ثبت کنید."
: ""
}
/>
)}
{/* برگشت — برداشت‌های امروز */}
{type === "RETURN" && (
<ReturnTable rows={rows} setRows={setRows} txs={todayWithdrawals} whId={whId} matTxt={matTxt} unitNameFn={(id) => unitName(data.lookup.units, id)} />
)}
{/* انتقال */}
{type === "TRANSFER" && (
<RowTable
isCount={false}
rows={rows}
setRows={setRows}
records={formRecords}
unitNameFn={(id) => unitName(data.lookup.units, id)}
emptyMsg={
!fromId || !toId ? "برای نمایش موجودی‌ها، انبار مبدأ، مقصد و نام متریال را وارد کنید."
: !matTxt.trim() ? "نام متریال را تایپ کنید…"
: !formRecords.length ? "در انبار مبدأ برای این متریال موجودی نیست."
: ""
}
/>
)}
{/* پیدا شده — مفقودی‌ها (با سقف: پیدا شده ≤ مانده‌ی مفقودی) */}
{type === "FOUND" && (
<FoundTable
rows={rows}
setRows={setRows}
items={pendingLosses}
whId={whId}
unitNameFn={(id) => unitName(data.lookup.units, id)}
/>
)}
{/* دکمه ثبت */}
<div className="mt-4 flex items-center justify-between border-t border-line pt-3">
<span className="text-[12px] text-muted">👤 ثبت با نام: <b>{user?.display_name}</b></span>
<button
type="button"
disabled={!canSubmit() || busy}
onClick={doSubmit}
className="rounded-lg px-5 py-2 text-[13px] font-bold text-white disabled:opacity-40"
style={{ background: meta.color }}
>
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
</button>
</div>
</div>
{/* مودال تأیید شمارش موجودی (اختلاف قبل از ثبت) */}
{countConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !countBusy && setCountConfirm(null)}>
<div className="w-full max-w-lg rounded-xl border border-line bg-panel p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="mb-1 text-[14px] font-bold">🔍 تأیید شمارش موجودی</h3>
<p className="mb-3 text-[11.5px] leading-5 text-muted">
موجودی هر ردیف به «تعداد شمارش‌شده» اصلاح می‌شود و اختلاف (کسری/مازاد) ثبت خواهد شد.
</p>
<div className="flex max-h-72 flex-col gap-2 overflow-y-auto">
{countConfirm.map((c, i) => {
const deficit = c.variance < 0;
const surplus = c.variance > 0;
return (
<div key={i} className="rounded-lg border border-line p-2.5 text-[12px]">
<div className="flex flex-wrap items-center gap-2">
<b className="min-w-0 flex-1 truncate">{c.material}</b>
<span className="text-muted">انبار: {c.warehouse}</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1">
<span>موجودی سیستم: <b>{faNum(c.current)} {c.unit}</b></span>
<span>شمارش‌شده: <b>{faNum(c.counted)} {c.unit}</b></span>
<span
className={`rounded-md px-2 py-0.5 text-[11px] font-bold ${
deficit
? "bg-rose-50 text-rose-700"
: surplus
? "bg-emerald-50 text-emerald-700"
: "bg-slate-bg text-muted"
}`}
>
{deficit
? `−${faNum(Math.abs(c.variance))} ${c.unit} (کسری انبار)`
: surplus
? `+${faNum(c.variance)} ${c.unit} (مازاد انبار)`
: "بدون اختلاف ✓"}
</span>
</div>
</div>
);
})}
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
className="rounded-lg border border-line px-4 py-2 text-[12.5px]"
onClick={() => setCountConfirm(null)}
disabled={countBusy}
>
انصراف
</button>
<button
type="button"
className="rounded-lg bg-blue-600 px-4 py-2 text-[12.5px] font-bold text-white hover:bg-blue-700 disabled:opacity-50"
onClick={() => void confirmCount()}
disabled={countBusy}
>
{countBusy ? "در حال ثبت…" : "تأیید و به‌روزرسانی موجودی"}
</button>
</div>
</div>
</div>
)}
<div className="text-[11px] text-muted">
{isTransfer ? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود." : `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
</div>
</PageLayout>
</div>
);
}
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
isCount: boolean;
rows: RowDraft[];
setRows: (r: RowDraft[]) => void;
records: { id: string; brand: { id: string; name: string } | null; unit: { id: number; code: string; name_fa: string | null } | null; quantity_number: number; specs: Record<string, string> }[];
unitNameFn: (id: number | null | undefined) => string;
emptyMsg: string;
}) {
if (emptyMsg) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
return (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">مشخصه‌ها</th>
<th className="py-2 pe-3">برند</th>
<th className="py-2 pe-3">واحد</th>
<th className="py-2 pe-3">موجودی فعلی</th>
<th className="py-2 pe-3">{isCount ? "شمارش‌شده" : "تعداد"}</th>
<th className="py-2">یادداشت</th>
</tr>
</thead>
<tbody>
{records.map((r, i) => (
<tr key={r.id} className="border-b border-line/50">
<td className="py-1.5 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-1.5 pe-3">{r.brand?.name || "—"}</td>
<td className="py-1.5 pe-3">{unitNameFn(r.unit?.id)}</td>
<td className="py-1.5 pe-3 font-bold">{faNum(r.quantity_number)}</td>
<td className="py-1.5 pe-3">
<input
inputMode="decimal"
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
placeholder={isCount ? "شمارش‌شده" : "۰"}
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-1.5">
<input
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="توضیح (اختیاری)"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-1 text-[11.5px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت (در صورت نیاز) وارد کنید.</div>
</div>
);
}
function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: {
rows: RowDraft[];
setRows: (r: RowDraft[]) => void;
txs: InventoryTransaction[];
whId: string;
matTxt: string;
unitNameFn: (id: number | null | undefined) => string;
}) {
if (!whId || !matTxt.trim()) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">برای مشاهده برداشت‌های امروز، انبار و نام متریال را وارد کنید.</div>;
if (!txs.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">امروز برداشتی برای این متریال در انبار انتخابی ثبت نشده است — چیزی برای برگشت وجود ندارد.</div>;
return (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">مشخصه‌ها</th>
<th className="py-2 pe-3">برند</th>
<th className="py-2 pe-3">واحد</th>
<th className="py-2 pe-3">برداشت امروز</th>
<th className="py-2 pe-3">تعداد برگشتی</th>
<th className="py-2">یادداشت</th>
</tr>
</thead>
<tbody>
{txs.map((t, i) => (
<tr key={t.inventory_id + i} className="border-b border-line/50">
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
<td className="py-1.5 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
<td className="py-1.5 pe-3">
<input
inputMode="decimal"
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
placeholder="۰"
value={rows[i]?.qty || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-1.5">
<input
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="توضیح برگشت"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
rows: RowDraft[];
setRows: (r: RowDraft[]) => void;
items: { tx: InventoryTransaction; remaining: number }[];
whId: string;
unitNameFn: (id: number | null | undefined) => string;
}) {
if (!whId) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">ابتدا انبار را انتخاب کنید.</div>;
if (!items.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">مفقودی ثبت‌نشده‌ای (یا کاملاً پیداشده) در این انبار وجود ندارد.</div>;
return (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">متریال</th>
<th className="py-2 pe-3">مشخصه‌ها</th>
<th className="py-2 pe-3">برند</th>
<th className="py-2 pe-3">واحد</th>
<th className="py-2 pe-3">مانده‌ی مفقودی</th>
<th className="py-2 pe-3">تعداد پیداشده</th>
<th className="py-2">یادداشت</th>
</tr>
</thead>
<tbody>
{items.map(({ tx: t, remaining }, i) => {
const entered = parseNumLocal(rows[i]?.qty || "");
const over = entered > remaining;
return (
<tr key={t.inventory_id + i} className={`border-b border-line/50 ${over ? "bg-rose-50/40" : ""}`}>
<td className="py-1.5 pe-3 font-semibold">{t.material?.name || "—"}</td>
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
<td className="py-1.5 pe-3 font-bold text-rose-600">{faNum(remaining)}</td>
<td className="py-1.5 pe-3">
<input
inputMode="decimal"
className={`h-7 w-24 rounded-lg border bg-panel px-2 text-sm ltr ${over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line"}`}
placeholder="۰"
value={rows[i]?.qty || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
{over && (
<div className="mt-0.5 text-[10.5px] font-bold text-rose-700">
تعداد پیداشده نمی‌تواند بیشتر از مقدار مفقودی باشد.
</div>
)}
</td>
<td className="py-1.5">
<input
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="توضیح"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// تبدیل اعداد فارسی/اعشاری برای اعتبارسنجی نمایشی (بدون ارسال)
function parseNumLocal(s: string): number {
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
return isFinite(v) ? v : 0;
}