402 lines
16 KiB
TypeScript
402 lines
16 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { Trash2, Plus, Copy } from "lucide-react";
|
|
import type { Material } from "../../lib/types";
|
|
import { normalize } from "../../lib/normalize";
|
|
import { useMaterials } from "../../hooks";
|
|
import { matchMaterial, materialBaseName } from "../../lib/quoteUtils";
|
|
import { Combobox } from "../../components/Combobox";
|
|
import { NumberInput } from "../../components/NumberInput";
|
|
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
|
import { api, apiV26 } from "../../lib/api";
|
|
import { faNum } from "../../lib/formatters";
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export function materialOptions(
|
|
materials: Material[],
|
|
_topic: string,
|
|
item: ItemDraft,
|
|
field: "brand" | "spec",
|
|
allItems: ItemDraft[] = [],
|
|
apiBrands: string[] = [],
|
|
apiSpecs: string[] = [],
|
|
): string[] {
|
|
let list = materials;
|
|
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 === "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(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
|
|
);
|
|
|
|
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);
|
|
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 duplicate = (index: number) => {
|
|
const target = items[index];
|
|
if (!target) return;
|
|
|
|
const clonedItem: ItemDraft = {
|
|
...target,
|
|
tempId: "it" + Date.now() + Math.random().toString(36).slice(2, 6),
|
|
specs: [...target.specs],
|
|
};
|
|
|
|
const nextItems = [...items];
|
|
nextItems.splice(index + 1, 0, clonedItem);
|
|
onItemsChange(nextItems);
|
|
};
|
|
|
|
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 = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
|
|
if (dup) return;
|
|
onRemember(rec);
|
|
};
|
|
|
|
const commonInputClass = "!h-9 !text-xs font-normal text-right placeholder:text-xs placeholder:font-normal";
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* هدر */}
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white shadow-xs">
|
|
۲
|
|
</span>
|
|
<h3 className="text-sm sm:text-base font-bold text-ink">
|
|
اقلام سفارش
|
|
</h3>
|
|
</div>
|
|
<span className="rounded-full bg-blue-500/10 px-2.5 py-0.5 text-[11px] font-semibold text-blue-500 border border-blue-500/20">
|
|
{faNum(items.length)} قلم
|
|
</span>
|
|
</div>
|
|
<p className="text-[11.5px] sm:text-xs text-muted leading-relaxed">
|
|
مشخصات، تعداد و قیمت هر قلم را ثبت کنید. گزینههای جدید با تایپ ذخیره میشوند.
|
|
</p>
|
|
</div>
|
|
|
|
{/* لیست کارتهای اقلام */}
|
|
<div data-field="line_items" className="flex flex-col gap-3">
|
|
{items.map((it, idx) => (
|
|
<div
|
|
key={it.tempId}
|
|
className="rounded-xl border border-line bg-panel p-3.5 sm:p-4 shadow-2xs space-y-3 transition-all"
|
|
>
|
|
{/* سربرگ قلم */}
|
|
<div className="flex items-center justify-between border-b border-line pb-2">
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-500/10 text-[11px] font-bold text-blue-500">
|
|
{faNum(idx + 1)}
|
|
</span>
|
|
<span className="text-xs font-bold text-ink truncate">
|
|
{it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
|
|
</span>
|
|
</div>
|
|
|
|
{/* اکشنهای کارت: کپی و حذف */}
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-muted hover:text-ink hover:bg-line/50 transition-colors"
|
|
title="تکرار این قلم"
|
|
onClick={() => duplicate(idx)}
|
|
>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
<span className="text-[11px]">کپی</span>
|
|
</button>
|
|
|
|
{items.length > 1 && (
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-500/10 transition-colors"
|
|
title="حذف قلم"
|
|
onClick={() => remove(it.tempId)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
<span className="text-[11px]">حذف</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ردیف ۱: متریال و برند */}
|
|
<div className="grid grid-cols-2 gap-2.5">
|
|
<div className="flex flex-col gap-1 min-w-0">
|
|
<label className="text-[11px] font-semibold text-muted text-right">متریال</label>
|
|
<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={commonInputClass}
|
|
className="min-w-0"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1 min-w-0">
|
|
<label className="text-[11px] font-semibold text-muted text-right">برند</label>
|
|
<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={commonInputClass}
|
|
className="min-w-0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ردیف ۲: مشخصه فنی */}
|
|
<div className="flex flex-col gap-1 min-w-0">
|
|
<label className="text-[11px] font-semibold text-muted text-right">مشخصه فنی / ابعاد</label>
|
|
<MultiSelectCombobox
|
|
values={it.specs}
|
|
onChange={(specs) => patch(it.tempId, { specs })}
|
|
options={materialOptions(materials, selectedTopic, it, "spec", items, [], specOptions)}
|
|
placeholder="انتخاب مشخصات (مثلاً ضخامت، رنگ، تیپ...)"
|
|
className="min-w-0"
|
|
/>
|
|
</div>
|
|
|
|
{/* بخش مقادیر و محاسبات مالی */}
|
|
<div className="rounded-lg bg-bg/50 p-2.5 border border-line space-y-3">
|
|
{/* ردیف ۳: تعداد + واحد سنجش */}
|
|
<div className="grid grid-cols-[100px_1fr] sm:grid-cols-[120px_1fr] gap-2.5">
|
|
<div className="flex flex-col gap-1 min-w-0">
|
|
<label className="text-[10.5px] font-semibold text-muted text-right">تعداد</label>
|
|
<NumberInput
|
|
value={it.qty}
|
|
onChange={(qty) => {
|
|
patch(it.tempId, {
|
|
qty,
|
|
total: qty != null && it.price != null ? Math.round(qty * it.price) : it.total,
|
|
});
|
|
}}
|
|
placeholder="مقدار"
|
|
className="!text-right text-right"
|
|
inputClassName={commonInputClass}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1 min-w-0">
|
|
<label className="text-[10.5px] font-semibold text-muted text-right">واحد سنجش</label>
|
|
<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={commonInputClass}
|
|
className="min-w-0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ردیف ۴: قیمت هر واحد */}
|
|
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
|
|
<label className="text-[10.5px] font-semibold text-muted text-right">
|
|
{it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"}
|
|
</label>
|
|
<NumberInput
|
|
value={it.price}
|
|
onChange={(price) => {
|
|
patch(it.tempId, {
|
|
price,
|
|
total: it.qty != null && price != null ? Math.round(it.qty * price) : it.total,
|
|
});
|
|
}}
|
|
placeholder="قیمت هر واحد"
|
|
showWords
|
|
className="!text-right text-right"
|
|
inputClassName={commonInputClass}
|
|
/>
|
|
</div>
|
|
|
|
{/* ردیف ۵: قیمت کل قلم */}
|
|
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
|
|
<label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label>
|
|
<NumberInput
|
|
value={it.total}
|
|
onChange={(total) => {
|
|
patch(it.tempId, {
|
|
total,
|
|
price:
|
|
total != null && it.qty != null && it.qty > 0 ? Math.round(total / it.qty) : it.price,
|
|
});
|
|
}}
|
|
placeholder="قیمت کل قلم"
|
|
showWords
|
|
className="!text-right text-right"
|
|
inputClassName={commonInputClass}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* دکمه افزودن قلم جدید */}
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center justify-center gap-1.5 rounded-xl border border-dashed border-line bg-panel py-2.5 text-xs font-semibold text-ink hover:border-accent hover:text-accent active:scale-[0.99] transition-all"
|
|
onClick={add}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
<span>افزودن قلم جدید</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
} |