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; } // نام پایه‌ی متریال از نامِ ذخیره‌شده: پیشوند برند و پسوند مشخصه حذف می‌شوند // («راجین سیم مسی» → «سیم») — پیشنهادهای فیلد «متریال» فقط خودِ متریال را نشان // می‌دهند؛ برند و مشخصه ستون‌های جداگانه دارند. 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) export function materialOptions( materials: Material[], topic: string, item: ItemDraft, field: "cat" | "brand" | "spec", allItems: ItemDraft[] = [], apiBrands: string[] = [], ): string[] { let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic)); const cat = item.cat.trim(); const brand = item.brand.trim(); // مقادیر واردشده در سطرهای دیگر — برای پیشنهاد در سطرهای جدید const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => { const out = new Set(); 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 (cat) list = list.filter((m) => normalize(m.category) === normalize(cat)); if (field === "brand") { // همه‌ی برندها از همه‌ی materials + برندهای ثبت‌شده در بخش برندها (بک‌اند) 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 (brand) list = list.filter((m) => normalize(m.brand) === normalize(brand)); const base = new Set(list.flatMap((m) => (m.specs || []).filter(Boolean))); 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(() => { const all = new Set(); 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([]); 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(() => { /* بک‌اند در دسترس نیست */ }); return () => { alive = false; }; }, []); const patch = (tempId: string, p: Partial) => { 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 (

۲ اقلام سفارش

قلم است (متریال + برند + مشخصه + واحد + تعداد + قیمت). متریال، برند و مشخصه را می‌توان از گزینه‌ها انتخاب کرد یا تایپ کرد تا به‌عنوان گزینه‌ی جدید اضافه شود. شرایط پرداخت هر قلم را در مرحله‌ی بعد مشخص می‌کنید.' }} />
متریال برند مشخصه تعداد واحد قیمت واحد قیمت کل
{items.map((it) => (
patch(it.tempId, { cat: x.label })} // پیشنهادها: همه‌ی متریال‌های کاتالوگ سراسری با نام واقعی‌شان // (بدون فیلتر تاپیک) — انتخابِ یک متریال موجود در ذخیره، همان را reuse می‌کند 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" /> 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" /> patch(it.tempId, { specs })} options={materialOptions(materials, selectedTopic, it, "spec", items)} placeholder="مشخصه" className="min-w-0" /> { patch(it.tempId, { qty, total: qty != null && it.price != null ? Math.round(qty * it.price * 100) / 100 : it.total, }); }} placeholder="تعداد" showWords /> 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) => { // انتخاب گزینه‌ی «+ [واحد] به عنوان واحد جدید» → ثبت فوری در دیتابیس؛ // نامِ تایپ‌شده هم روی همان قلم اعمال می‌شود (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" /> { patch(it.tempId, { price, total: it.qty != null && price != null ? Math.round(it.qty * price * 100) / 100 : it.total, }); }} placeholder="قیمت واحد" showWords maxDecimals={2} /> { patch(it.tempId, { total, price: total != null && it.qty != null && it.qty > 0 ? Math.round((total / it.qty) * 100) / 100 : it.price, }); }} placeholder="قیمت کل" showWords />
))}
); }