fix(inventory): enable smart warehouse stock binding and optional brand/specs for inter-project transfer

This commit is contained in:
2026-09-23 23:11:18 +03:30
parent e560018273
commit b2aba2fcfe
2 changed files with 164 additions and 46 deletions
@@ -238,17 +238,12 @@ export function ChangeInventoryPage() {
}
}, [type, loadTransfers]);
const matchedStock = useMemo(() => {
if (type !== "PROJECT_TRANSFER" || !whId || !matTxt.trim()) return null;
const mt = matTxt.trim();
const bt = brandTxt.trim();
return data.inventory.filter((r) => {
if (r.warehouse?.id !== whId) return false;
if (r.material.name !== mt) return false;
if (bt && r.brand?.name !== bt) return false;
return true;
});
}, [type, whId, matTxt, brandTxt, data.inventory]);
const whStock = useMemo(() => {
if (!whId) return [];
return (data.inventory || []).filter((r) => r.warehouse?.id === whId && r.quantity_number > 0);
}, [data.inventory, whId]);
const handleSaveEdit = async () => {
if (!editingTransfer) return;
@@ -340,36 +335,52 @@ export function ChangeInventoryPage() {
if (!whId) return toast.error("انبار مبدأ را انتخاب کنید");
if (!toProjectId) 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("حداقل یک مشخصه فنی ثبت کنید");
setBusy(true);
try {
let uid = unitIdByName(unitName);
// Find matching inventory in the selected source warehouse
const invInWh = (data.inventory || []).filter(
(r) => r.warehouse?.id === whId && r.material.name.trim() === matName
);
const matchedInv = invInWh.find(
(r) => (brandName ? r.brand?.name === brandName : !r.brand)
) || invInWh[0];
let uid = matchedInv?.unit?.id ?? unitIdByName(unitName);
if (!uid) {
const created = await apiV26.catalog.createUnit(unitName);
uid = created.id;
}
let bid = brandIdByName(brandName);
if (!bid) {
let bid: string | null = matchedInv?.brand?.id ?? (brandName ? brandIdByName(brandName) : null);
if (brandName && !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, canonical_unit: null };
let mid = matchedInv?.material.id;
if (!mid) {
let m = matByName(matName);
if (!m) {
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
mid = created.id;
} else {
mid = m.id;
}
}
const finalSpecs = matchedInv ? (matchedInv.specs || {}) : specsFromChips();
await apiV26.projectTransfers.create({
from_warehouse_id: whId,
to_project_id: toProjectId,
material_id: m.id,
brand_id: bid,
material_id: mid,
brand_id: bid || null,
unit_id: uid,
quantity: n,
specs: specsFromChips(),
specs: finalSpecs,
note,
transfer_date: toServerIso(),
}, user?.project_id);
@@ -530,7 +541,7 @@ export function ChangeInventoryPage() {
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
}
if (type === "PROJECT_TRANSFER") {
return !!(whId && toProjectId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
return !!(whId && toProjectId && matTxt.trim() && unitTxt.trim() && parseNum(qty) > 0);
}
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));
@@ -766,6 +777,69 @@ export function ChangeInventoryPage() {
{/* فرم خرید / موجودی اولیه یا انتقال بین کارگاهی */}
{(type === "PURCHASE" || type === "PROJECT_TRANSFER") && (
<div className="grid gap-3 md:grid-cols-2">
{/* چیپ‌های انتخاب سریع از موجودی‌های انبار مبدأ */}
{type === "PROJECT_TRANSFER" && whId && (
<div className="field md:col-span-2 rounded-xl border border-rose-500/20 bg-rose-500/5 p-3.5">
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<span>📦</span>
<span>موجودی‌های موجود در {whName(warehouses, whId)} — برای پر کردن خودکار فرم کلیک کنید:</span>
</span>
<span className="text-[11px] text-muted">{faNum(whStock.length)} قلم کالا</span>
</div>
{whStock.length === 0 ? (
<div className="text-xs text-muted py-1">هیچ کالایی با موجودی مثبت در این انبار یافت نشد.</div>
) : (
<div className="flex flex-wrap gap-2">
{whStock.map((r) => {
const isSelected = matTxt === r.material.name && (brandTxt === (r.brand?.name || "") || (!brandTxt && !r.brand));
const specsSummary = Object.entries(r.specs || {})
.map(([k, v]) => (v ? `${v}` : k.replace(/^spec_/, "")))
.filter(Boolean)
.join(" · ");
return (
<button
key={r.id}
type="button"
onClick={() => {
setMatTxt(r.material.name);
setBrandTxt(r.brand?.name || "");
setUnitTxt(unitName(data.lookup.units, r.unit?.id) || "");
const chips = Object.entries(r.specs || {})
.map(([k, v]) => (v ? `${v}` : k.replace(/^spec_/, "")))
.filter(Boolean);
setSpecsChips(chips);
}}
className={cn(
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs text-start transition-all shadow-2xs",
isSelected
? "border-rose-500 bg-rose-600 text-white font-bold ring-2 ring-rose-500/30"
: "border-line bg-panel hover:border-ink/40 text-ink"
)}
>
<span className="font-bold">{r.material.name}</span>
<span className={isSelected ? "text-white/80" : "text-muted font-normal"}>
({r.brand?.name || "بدون برند"})
</span>
{specsSummary && (
<span className={isSelected ? "text-white/90" : "text-muted text-[11px]"}>
[{specsSummary}]
</span>
)}
<span className={cn(
"rounded-md px-1.5 py-0.5 text-[11.5px] font-black",
isSelected ? "bg-white/20 text-white" : "bg-bg text-foreground"
)}>
{faNum(r.quantity_number)} {unitName(data.lookup.units, r.unit?.id)}
</span>
</button>
);
})}
</div>
)}
</div>
)}
<div className="field md:col-span-2">
<label className="text-xs font-semibold text-muted">متریال *</label>
<Combobox
@@ -784,13 +858,15 @@ export function ChangeInventoryPage() {
)}
</div>
<div className="field">
<label className="text-xs font-semibold text-muted">برند *</label>
<label className="text-xs font-semibold text-muted">
برند {type === "PROJECT_TRANSFER" ? "(اختیاری)" : "*"}
</label>
<Combobox
value={brandTxt}
items={data.lookup.brands.map((b) => b.name)}
allowAdd
addSuffix="به عنوان برند جدید"
placeholder="نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"
placeholder={type === "PROJECT_TRANSFER" ? "نام برند (اختیاری — برای اقلام بدون برند خالی بگذارید)" : "نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"}
onInputChange={setBrandTxt}
onChange={(it) => setBrandTxt(it.label)}
/>
@@ -827,11 +903,13 @@ export function ChangeInventoryPage() {
/>
</div>
<div className="field md:col-span-2">
<label className="text-xs font-semibold text-muted">مشخصه‌های فنی *</label>
<label className="text-xs font-semibold text-muted">
مشخصه‌های فنی {type === "PROJECT_TRANSFER" ? "(اختیاری)" : "*"}
</label>
<MultiSelectCombobox
values={specsChips}
onChange={setSpecsChips}
placeholder="نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"
placeholder={type === "PROJECT_TRANSFER" ? "مشخصات فنی (اختیاری — مثلاً مسی، سایز و…)" : "نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"}
className="w-full"
/>
</div>
@@ -844,20 +922,6 @@ export function ChangeInventoryPage() {
onChange={(e) => setNote(e.target.value)}
/>
</div>
{type === "PROJECT_TRANSFER" && (matchedStock?.length ?? 0) > 0 && (
<div className="field md:col-span-2 rounded-lg border border-line/70 bg-bg/50 p-3 text-xs">
<div className="font-semibold text-muted mb-1.5">موجودی‌های فعلی این متریال در انبار مبدأ ({whName(warehouses, whId)}):</div>
<div className="flex flex-wrap gap-2">
{(matchedStock || []).map((s) => (
<div key={s.id} className="rounded-md border border-line bg-panel px-2.5 py-1 text-[11.5px] shadow-2xs">
<span className="font-medium">{s.brand?.name || "بدون برند"}</span>
<span className="text-muted"> — {Object.entries(s.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "بدون مشخصه"} — </span>
<b className="text-foreground">{faNum(s.quantity_number)} {unitName(data.lookup.units, s.unit?.id)}</b>
</div>
))}
</div>
</div>
)}
</div>
)}