Files
ERP/frontend/src/pages/Wizard/Step2LineItems.tsx
T
MMD 7b3ae3cb60 feat(wizard): cache مشخصه globally instead of per topic/category/brand
The specs combobox drew only from materials in the selected topic, further
narrowed by the row's category and brand, so a spec was offered only when
some material in that topic shared the same category AND brand. On a new
quote the user therefore lost specs recorded in earlier quotes.

- api.specsList(): new global source reading the attribute-definitions
  registry (topic/category/brand independent)
- materialOptions('spec'): unions the registry, specs of all materials,
  and specs typed in sibling rows — no topic/category/brand filter
- EditModal: same global registry added to its spec options

Verified the registry endpoint returns labels and that the union includes
specs absent from material attributes (e.g. 'مسی').
2026-09-16 20:31:01 +03:30

343 lines
14 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import type { Material } from "../../lib/types";
import { normalize } from "../../lib/normalize";
import { useMaterials } from "../../hooks";
import { specsKey } from "../../lib/quoteUtils";
import { Combobox } from "../../components/Combobox";
import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { api, apiV26 } from "../../lib/api";
export interface ItemDraft {
tempId: string;
cat: string;
brand: string;
specs: string[];
qty: number | null;
unit: string;
price: number | null;
total: number | null;
}
export interface MaterialRec {
topic: string;
category: string;
brand: string;
specs: string[];
}
export interface Step2Props {
items: ItemDraft[];
onItemsChange: (items: ItemDraft[]) => void;
selectedTopic: string;
onRemember: (rec: MaterialRec) => void;
}
// برچسب انسانی یک قلم (برای termsheet، درگ‌اندروپ و بازبینی)
export function itemRowLabel(it: ItemDraft): string {
const parts = [it.cat, it.brand, it.specs.join(" - ")].filter(Boolean);
const name = parts.length ? parts.join(" - ") : "(قلم بدون‌نام)";
const qtyPart = it.qty != null && it.unit ? `${it.qty} ${it.unit}` : it.qty != null ? String(it.qty) : "";
return qtyPart ? `${name} -- ${qtyPart}` : name;
}
// نام پایه‌ی متریال از نامِ ذخیره‌شده.
// متریال‌های جدید فقط نامِ خودِ متریال را در name دارند (برند در brand_ids و
// مشخصه‌ها در attributes ذخیره می‌شوند) و دست‌نخورده برمی‌گردند. رکوردهای قدیمی
// name را به شکل «برند + دسته + مشخصه» ذخیره کرده‌اند؛ آن قطعات جدا می‌شوند تا
// نامِ پایه باقی بماند.
function materialBaseName(name: string, brand: string, specs: string[], category = ""): string {
const fallback = (name || "").trim();
if (!fallback) return "";
let base = fallback;
// بلندترین قطعه اول حذف می‌شود تا حذف‌های جزئی، رشته را ناقص نکنند
const parts = [brand, category, ...specs]
.map((p) => (p || "").trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length);
for (const p of parts) {
if (!base.includes(p)) continue;
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
if (!next) continue;
base = next;
}
// شکلِ قدیمیِ خراب می‌تواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
if (parts.some((p) => p === base)) return fallback;
return base || fallback;
}
// مقادیر مجزای category/brand/spec داخل تاپیک
export function materialOptions(
materials: Material[],
topic: string,
item: ItemDraft,
field: "cat" | "brand" | "spec",
allItems: ItemDraft[] = [],
apiBrands: string[] = [],
apiSpecs: string[] = [],
): string[] {
let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic));
const cat = item.cat.trim();
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
const out = new Set<string>();
allItems.forEach((it) => {
if (it.tempId === item.tempId) return;
const v = sel(it);
if (Array.isArray(v)) v.forEach((x) => x && out.add(x));
else if (v) out.add(v);
});
return [...out];
};
if (field === "cat") {
const base = new Set(list.map((m) => m.category).filter(Boolean));
fromRows((it) => it.cat).forEach((c) => base.add(c));
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
}
if (field === "brand") {
// برند سراسری است — مستقل از تاپیک و دسته
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
apiBrands.forEach((b) => b && allBrands.add(b));
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
}
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
// مشخصه‌ها سراسری هستند: رجیستری attribute-definitions + مشخصه‌های همه‌ی
// متریال‌ها + مشخصه‌های تایپ‌شده در سایر ردیف‌ها. به تاپیک/دسته/برندِ قلمِ
// فعلی گره نمی‌خورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
const base = new Set<string>();
apiSpecs.forEach((s) => s && base.add(s));
materials.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
list.forEach((m) => (m.specs || []).forEach((s) => s && base.add(s)));
fromRows((it) => it.specs).forEach((s) => base.add(s));
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
}
// مرحله ۲ — جدول پویای اقلام سفارش
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
const materials = useMaterials();
const [unitOptions, setUnitOptions] = useState<string[]>(() => {
const all = new Set<string>();
items.forEach((it) => { if (it.unit) all.add(it.unit); });
return [...all];
});
const allUnits = useMemo(() => {
const out = new Set(unitOptions);
items.forEach((it) => { if (it.unit) out.add(it.unit); });
return [...out];
}, [items, unitOptions]);
const allMaterialNames = useMemo(() => {
const set = new Set<string>();
materials.forEach((m) => {
const base = materialBaseName(m.name, m.brand, m.specs, m.category);
if (base) set.add(base);
});
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials]);
const [brandOptions, setBrandOptions] = useState<string[]>([]);
const [specOptions, setSpecOptions] = useState<string[]>([]);
useEffect(() => {
let alive = true;
void api
.unitsList()
.then((list) => alive && setUnitOptions((prev) => {
const merged = new Set(prev);
list.forEach((u) => { if (u.name_fa || u.code) merged.add(u.name_fa || u.code); });
return [...merged];
}))
.catch(() => {});
void api
.brandsList()
.then((list) => alive && setBrandOptions(list.map((b) => b.name).filter(Boolean)))
.catch(() => {});
void api
.specsList()
.then((list) => alive && setSpecOptions(list))
.catch(() => {});
return () => { alive = false; };
}, []);
const patch = (tempId: string, p: Partial<ItemDraft>) => {
onItemsChange(items.map((it) => (it.tempId === tempId ? { ...it, ...p } : it)));
};
const remove = (tempId: string) => {
onItemsChange(items.filter((it) => it.tempId !== tempId));
};
const add = () => {
onItemsChange([
...items,
{ tempId: "it" + Date.now() + Math.random().toString(36).slice(2, 6), cat: "", brand: "", specs: [], qty: null, unit: "", price: null, total: null },
]);
};
const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => {
if (!selectedTopic) return;
let rec: MaterialRec;
if (field === "i-cat") {
if (!it.cat.trim()) return;
rec = { topic: selectedTopic, category: it.cat.trim(), brand: "", specs: [] };
} else if (field === "i-brand") {
if (!it.brand.trim()) return;
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: [] };
} else {
if (!it.specs.length) return;
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
}
const dup = materials.some(
(m) =>
normalize(m.topic) === normalize(rec.topic) &&
normalize(m.category) === normalize(rec.category) &&
normalize(m.brand) === normalize(rec.brand) &&
specsKey(m.specs) === specsKey(rec.specs),
);
if (dup) return;
onRemember(rec);
};
return (
<div>
<h3 className="mb-1 text-[15px] font-bold">
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-[12px] font-bold text-white">۲</span>
اقلام سفارش
</h3>
<div className="mb-3 text-[12px] text-gray-500" dangerouslySetInnerHTML={{ __html: 'هر سطر یک <b>قلم</b> است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را می‌توان از گزینه‌ها انتخاب کرد یا تایپ کرد تا به‌عنوان گزینه‌ی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحله‌ی بعد مشخص می‌کنید.' }} />
<div className="item-row wizard-item-row item-row-head mb-1 pe-7">
<span>متریال</span>
<span>برند</span>
<span>مشخصه</span>
<span>تعداد</span>
<span>واحد</span>
<span>قیمت واحد</span>
<span>قیمت کل</span>
</div>
<div className="flex flex-col gap-2">
{items.map((it) => (
<div key={it.tempId} className="item-row wizard-item-row items-center">
<Combobox
value={it.cat}
onChange={(x) => patch(it.tempId, { cat: x.label })}
items={allMaterialNames}
allowAdd
addSuffix="به عنوان متریال جدید"
placeholder="متریال"
onInputChange={(v) => patch(it.tempId, { cat: v })}
onBlur={() => rememberField("i-cat", it)}
inputClassName="!text-[12px] !h-9"
className="min-w-0"
/>
<Combobox
value={it.brand}
onChange={(x) => patch(it.tempId, { brand: x.label })}
items={materialOptions(materials, selectedTopic, it, "brand", items, brandOptions)}
allowAdd
addSuffix="به عنوان برند جدید"
placeholder="برند"
onInputChange={(v) => patch(it.tempId, { brand: v })}
onBlur={() => rememberField("i-brand", it)}
inputClassName="!text-[12px] !h-9"
className="min-w-0"
/>
<MultiSelectCombobox
values={it.specs}
onChange={(specs) => patch(it.tempId, { specs })}
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
placeholder="مشخصه"
className="min-w-0"
/>
<NumberInput
value={it.qty}
onChange={(qty) => {
patch(it.tempId, {
qty,
total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total,
});
}}
placeholder="تعداد"
showWords
/>
<Combobox
value={it.unit}
onChange={(x) => patch(it.tempId, { unit: x.label })}
items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))}
allowAdd
addSuffix="به عنوان واحد جدید"
placeholder="واحد"
onInputChange={(v) => patch(it.tempId, { unit: v })}
onAdd={(v) => {
const name = v.trim();
if (!name) return;
void apiV26.catalog
.createUnit(name)
.then(() => {
setUnitOptions((prev) =>
prev.some((u) => normalize(u) === normalize(name)) ? prev : [...prev, name],
);
})
.catch(() => {});
}}
inputClassName="!text-[12px] !h-9"
className="min-w-0"
/>
<NumberInput
value={it.price}
onChange={(price) => {
patch(it.tempId, {
price,
total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total,
});
}}
placeholder="قیمت واحد"
showWords
maxDecimals={2}
/>
<NumberInput
value={it.total}
onChange={(total) => {
patch(it.tempId, {
total,
price:
total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price,
});
}}
placeholder="قیمت کل"
showWords
/>
{/* دکمه حذف مدرن با آیکون سطل زباله */}
<button
type="button"
className="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-red-50 hover:text-red-500 transition-colors"
title="حذف قلم"
onClick={() => remove(it.tempId)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
{/* دکمه افزودن قلم با استایل برجسته و خط‌چین */}
<button
type="button"
className="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/40 py-2.5 px-4 text-[13px] font-semibold text-blue-600 hover:border-blue-500 hover:bg-blue-100/70 transition-all duration-150"
onClick={add}
>
<span className="text-base font-bold leading-none">+</span>
<span>افزودن قلم جدید</span>
</button>
</div>
);
}