- متریال field suggests unique BASE material names from the whole catalog (brand/spec prefixes stripped; «راجین سیم مسی» → «سیم») — brand/specs have their own columns. - Save path reuses an existing material by full name only (no category equality constraint, which never matched topic-named categories) so selecting an existing material never duplicates it. - Unit field: suggestion list no longer includes the row's own typed text, which previously suppressed the create option; typing an unknown unit now shows «+ [value] به عنوان واحد جدید» (registered immediately via POST /units and added to suggestions).
331 lines
14 KiB
TypeScript
331 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;
|
||
}
|
||
|
||
// نام پایهی متریال از نامِ ذخیرهشده: پیشوند برند و پسوند مشخصه حذف میشوند
|
||
// («راجین سیم مسی» → «سیم») — پیشنهادهای فیلد «متریال» فقط خودِ متریال را نشان
|
||
// میدهند؛ برند و مشخصه ستونهای جداگانه دارند.
|
||
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<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 (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<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);
|
||
if (base) set.add(base);
|
||
});
|
||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||
}, [materials]);
|
||
|
||
// برندهای ثبتشده در بخش برندها (بکاند) — بهعلاوهی برندهای متریالها
|
||
const [brandOptions, setBrandOptions] = 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(() => { /* بکاند در دسترس نیست */ });
|
||
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-accent text-[12px] font-bold text-white">۲</span>
|
||
اقلام سفارش
|
||
</h3>
|
||
<div className="mb-3 text-[12px] text-muted" 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">
|
||
<Combobox
|
||
value={it.cat}
|
||
onChange={(x) => 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"
|
||
/>
|
||
<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)}
|
||
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) => {
|
||
// انتخاب گزینهی «+ [واحد] به عنوان واحد جدید» → ثبت فوری در دیتابیس؛
|
||
// نامِ تایپشده هم روی همان قلم اعمال میشود (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"
|
||
/>
|
||
<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="cursor-pointer text-muted hover:text-red" title="حذف" onClick={() => remove(it.tempId)}>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<button type="button" className="btn-line-file mt-2.5" onClick={add}>
|
||
+ افزودن قلم
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|