diff --git a/frontend/src/pages/Wizard/Step2LineItems.tsx b/frontend/src/pages/Wizard/Step2LineItems.tsx index 2ba5711..c5b6c05 100644 --- a/frontend/src/pages/Wizard/Step2LineItems.tsx +++ b/frontend/src/pages/Wizard/Step2LineItems.tsx @@ -6,7 +6,7 @@ import { specsKey } from "../../lib/quoteUtils"; import { Combobox } from "../../components/Combobox"; import { NumberInput } from "../../components/NumberInput"; import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; -import { api } from "../../lib/api"; +import { api, apiV26 } from "../../lib/api"; export interface ItemDraft { tempId: string; @@ -41,6 +41,25 @@ export function itemRowLabel(it: ItemDraft): string { return qtyPart ? `${name} -- ${qtyPart}` : name; } +// نام پایه‌ی متریال از نامِ ذخیره‌شده: پیشوند برند و پسوند مشخصه حذف می‌شوند +// («راجین سیم مسی» → «سیم») — پیشنهادهای فیلد «متریال» فقط خودِ متریال را نشان +// می‌دهند؛ برند و مشخصه ستون‌های جداگانه دارند. +function materialBaseName(name: string, brand: string, specs: string[]): string { + let base = (name || "").trim(); + if (!base) return ""; + if (brand) { + if (base.startsWith(brand)) base = base.slice(brand.length); + else if (base.includes(brand)) base = base.split(brand).join(" "); + } + specs + .filter((s) => s && s.trim()) + .forEach((s) => { + if (base.includes(s)) base = base.split(s).join(" "); + }); + base = base.replace(/\s+/g, " ").trim(); + return base || (name || "").trim(); +} + // مقادیر مجزای category/brand/spec داخل تاپیک — با فیلتر پله‌به‌پله (تاپیک → دسته → برند) // + مقادیر تایپ‌شده در همه‌ی سطرهای قبلی (suggestion از همه‌ی فیلدها) // نکته: برند و واحد از همه‌ی دسته‌ها نمایش داده می‌شوند (issue #7) @@ -97,13 +116,24 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember 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(); + materials.forEach((m) => { + const base = materialBaseName(m.name, m.brand, m.specs); + if (base) set.add(base); + }); + return [...set].sort((a, b) => a.localeCompare(b, "fa")); + }, [materials]); + // برندهای ثبت‌شده در بخش برندها (بک‌اند) — به‌علاوه‌ی برندهای متریال‌ها const [brandOptions, setBrandOptions] = useState([]); @@ -189,7 +219,9 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember patch(it.tempId, { cat: x.label })} - items={materialOptions(materials, selectedTopic, it, "cat", items)} + // پیشنهادها: همه‌ی متریال‌های کاتالوگ سراسری با نام واقعی‌شان + // (بدون فیلتر تاپیک) — انتخابِ یک متریال موجود در ذخیره، همان را reuse می‌کند + items={allMaterialNames} allowAdd addSuffix="به عنوان متریال جدید" placeholder="متریال" @@ -231,11 +263,31 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember patch(it.tempId, { unit: x.label })} - items={allUnits} + // متنِ خودِ همین سطر از پیشنهادها حذف می‌شود؛ وگرنه متن تایپ‌شده + // «تطبیق دقیق» پیدا می‌کرد و گزینه‌ی «به عنوان واحد جدید» هرگز ظاهر نمی‌شد + items={allUnits.filter((u) => normalize(u) !== normalize(it.unit))} allowAdd - addSuffix="اضافه کردن واحد جدید" + addSuffix="به عنوان واحد جدید" placeholder="واحد" onInputChange={(v) => patch(it.tempId, { unit: v })} + onAdd={(v) => { + // انتخاب گزینه‌ی «+ [واحد] به عنوان واحد جدید» → ثبت فوری در دیتابیس؛ + // نامِ تایپ‌شده هم روی همان قلم اعمال می‌شود (onInputChange بالا) + 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(() => { + // اگر واحدِ همنام در همین لحظه ساخته شده باشد (تداخل 409) یا شبکه + // خطا دهد، هنگام ذخیره‌ی پیشنهاد دوباره resolve می‌شود — خطای بلوک‌کننده نیست + }); + }} inputClassName="!text-[12px] !h-9" className="min-w-0" /> diff --git a/frontend/src/pages/Wizard/WizardPage.tsx b/frontend/src/pages/Wizard/WizardPage.tsx index f48c80e..111aee8 100644 --- a/frontend/src/pages/Wizard/WizardPage.tsx +++ b/frontend/src/pages/Wizard/WizardPage.tsx @@ -256,12 +256,10 @@ export function WizardPage() { const total = it.total; if (!cat && !brand && qty == null && price == null) continue; - // جستجوی متریال: اول با نام (مثل بک‌اند ساخته شده) — بعد از رفرش، - // برند/مشخصه در حافظه نیستند ولی نام کامل در دیتابیس ذخیره شده + // جستجوی متریال: اول با نامِ کامل سراسری (دیتابیس) — بدون قید دسته/تاپیک، + // چون دسته‌ی ذخیره‌شده‌ی متریال‌ها نام تاپیک است نه نام تایپ‌شده‌ی کاربر const wantName = [brand, cat, ...specs].filter(Boolean).join(" ") || "کالای جدید"; - let material = materials.find( - (m) => normalize(m.category) === normalize(cat) && normalize(m.name) === normalize(wantName), - ); + let material = materials.find((m) => normalize(m.name) === normalize(wantName)); if (!material) { // جستجوی قبلی (برند و مشخصه‌ی ساختارمند در همین نشست) material = materials.find(