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. 'مسی').
343 lines
14 KiB
TypeScript
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>
|
|
);
|
|
} |