import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Building2, ChevronDown, Link2, Pencil, Plus, Repeat, Scale, Search, Trash2, TriangleAlert, } from "lucide-react"; import type { StandardConversion } from "../../lib/types"; import { api, apiV26, nextId, type BrandLinkRow, type MaterialSupplier, type MaterialUnitRow } from "../../lib/api"; import { refreshProcurementStore, useDeleteStandardConversion, useMaterials, useQuotes, useSaveStandardConversion, useStoreData, } from "../../hooks"; import { normalize } from "../../lib/normalize"; import { uniqueUnits } from "../../lib/units"; import { useAppStore } from "../../store"; import { faNum } from "../../lib/formatters"; import { parseNum } from "../../lib/numutil"; import { Header, PageLayout } from "../../components/layout/Header"; import { Combobox } from "../../components/Combobox"; import { NumberInput } from "../../components/NumberInput"; import { cn } from "../../lib/utils"; import { Button } from "../../components/ui/button"; type UnitLite = import("../../lib/units").UnitLike; function unitDisplay(u: { code: string; name_fa: string | null } | null | undefined): string { if (!u) return ""; return u.name_fa || u.code; } // نام متریال‌های کامپوزیت «برند + نام + مشخصه» (مثل «راجین سیم مسی») را به // «نام پایه» (سیم) و «نام نمایشی قلم» (سیم مسی) می‌شکند — بدون پیشوند برند. function splitMaterialName(name: string, brand: string, specs: string[]): { base: string; variant: string } { let base = (name || "").trim(); const cleanedSpecs = specs.filter((s) => s && s.trim()); if (!base) return { base: "", variant: "" }; if (brand) { if (base.startsWith(brand)) base = base.slice(brand.length); else if (base.includes(brand)) base = base.split(brand).join(" "); } cleanedSpecs.forEach((s) => { if (!base) return; if (base.includes(s)) base = base.split(s).join(" "); }); base = base.replace(/\s+/g, " ").trim(); if (!base) base = (name || "").trim(); // فقط برند/مشخصه بود — نام اصلی را نگه دار const variantParts = [base, ...cleanedSpecs].filter(Boolean); const unique: string[] = []; variantParts.forEach((p) => { if (!unique.some((x) => normalize(x) === normalize(p))) unique.push(p); }); return { base, variant: unique.join(" ") }; } interface VariantItem { id: string; label: string; refUnit: string; sourceName: string; // نام کامل متریال — برای انتخاب «نام دقیق» هنگام یکتاسازی } interface MaterialGroup { base: string; variants: VariantItem[]; // فقط ترکیب‌های متریال+مشخصه (بدون فرزند خودارجاع) repId: string; // متریال نماینده‌ی گروه (نام دقیقِ پایه ترجیح دارد) repRef: string; repExact?: boolean; } // داشبورد ضرایب تبدیل — بدون صفحه‌ی تاپیک/دسته: سایدبار آکاردئونی از // متریال‌های سراسری (نام پایه + واریانت‌ها) با نوار جستجو؛ پنل جزئیات هر // متریال (واحد مرجع و ضریب‌هایش از material_units با قید برند اختیاری). export function UnitsPage() { useStoreData(); const materials = useMaterials(); const quotes = useQuotes(); const standardConversions = useAppStore((s) => s.standardConversions); const saveStd = useSaveStandardConversion(); const deleteStd = useDeleteStandardConversion(); // ---------- state ---------- const [search, setSearch] = useState(""); const [materialId, setMaterialId] = useState(null); const [expanded, setExpanded] = useState>(new Set()); const [refDraft, setRefDraft] = useState(null); const [editingId, setEditingId] = useState(null); const [editingStdId, setEditingStdId] = useState(null); const [rows, setRows] = useState([]); const [rowsLoading, setRowsLoading] = useState(false); const [brandLinks, setBrandLinks] = useState([]); // تأمین‌کننده‌های متریال انتخاب‌شده — type imported from api.ts const [materialSuppliers, setMaterialSuppliers] = useState([]); const [suppliersLoading, setSuppliersLoading] = useState(false); const [suppliersLoadedId, setSuppliersLoadedId] = useState(null); // واحدها و برندهای سراسری (فهرست دیتابیس) const [units, setUnits] = useState([]); const [allBrands, setAllBrands] = useState([]); // فرم ضریب متریال const [formUnit, setFormUnit] = useState(""); const [formFactor, setFormFactor] = useState(""); const [formBrand, setFormBrand] = useState(""); // فرم تبدیل ثابت const [stdFrom, setStdFrom] = useState(""); const [stdFactor, setStdFactor] = useState(""); const [stdTo, setStdTo] = useState(""); // ---------- واحدها / برندهای سراسری ---------- useEffect(() => { let alive = true; void api .unitsList() .then( (list) => alive && setUnits( uniqueUnits(list.map((u) => ({ id: u.id, code: u.code, name_fa: u.name_fa }))), ), ) .catch(() => {}); void api .brandsList() .then((list) => alive && setAllBrands(list.map((b) => b.name).filter(Boolean))) .catch(() => {}); return () => { alive = false; }; }, []); // ---------- متریال انتخاب‌شده ---------- const material = useMemo( () => materials.find((m) => m.id === materialId) || null, [materials, materialId], ); const ref = material?.base_unit || ""; // ---------- گروه‌های آکاردئونی سایدبار ---------- // والدها بر اساس «نام پایه» یکتا می‌شوند. فرزندِ آکاردئون فقط «ترکیب‌های // متریال + مشخصه» است (مثل «سیم مسی»)؛ فرزندِ خودارجاع (همان نام پایه // بدون مشخصه — مثل «سیم» زیرِ «سیم») هرگز نمایش داده نمی‌شود. اگر گروهی // مشخصه/واریانت نداشت، خودِ والد قابل انتخاب است (متریال نماینده). const groups = useMemo(() => { const map = new Map(); const variantSeen = new Map(); // normalize(label) → preferred materialId const addVariant = (group: MaterialGroup, v: VariantItem, exactName: boolean) => { const key = normalize(v.label); const existingId = variantSeen.get(key); if (existingId != null) { const existing = group.variants.find((x) => x.id === existingId); const existingExact = existing ? normalize(existing.label) === normalize(existing.sourceName) : false; // جایگزینی فقط وقتی مورد جدید «نام دقیق» دارد و مورد قبلی ندارد if (exactName && !existingExact) { group.variants = group.variants.filter((x) => x.id !== existingId); group.variants.push(v); variantSeen.set(key, v.id); } return; } variantSeen.set(key, v.id); group.variants.push(v); }; materials.forEach((m) => { const { base, variant } = splitMaterialName(m.name, m.brand, m.specs); if (!base) return; const key = normalize(base); let group = map.get(key); if (!group) { group = { base, variants: [], repId: "", repRef: "" }; map.set(key, group); } // متریال نماینده: نام دقیقِ برابر با پایه ترجیح دارد؛ وگرنه اولین مورد if (!group.repId) { group.repId = m.id; group.repRef = m.base_unit || ""; group.repExact = normalize(m.name) === normalize(base); } else if (!group.repExact && normalize(m.name) === normalize(base)) { group.repId = m.id; group.repRef = m.base_unit || ""; group.repExact = true; } // فرزند فقط وقتی مشخصه دارد که برچسبش با نام پایه فرق کند const label = variant || base; if (!label.trim()) return; if (normalize(label) === normalize(base)) return; // خودارجاع — حذف addVariant(group, { id: m.id, label, refUnit: m.base_unit, sourceName: m.name }, normalize(m.name) === normalize(label)); }); const list = [...map.values()]; list.forEach((g) => g.variants.sort((a, b) => a.label.localeCompare(b.label, "fa"))); return list.sort((a, b) => a.base.localeCompare(b.base, "fa")); }, [materials]); const filteredGroups = useMemo(() => { const q = normalize(search); if (!q) return groups; return groups .map((g) => ({ ...g, variants: g.variants.filter((v) => normalize(v.label).includes(q)), })) .filter((g) => normalize(g.base).includes(q) || g.variants.length > 0); }, [groups, search]); const isOpen = (g: MaterialGroup) => expanded.has(normalize(g.base)) || search.trim().length > 0; const toggleGroup = (g: MaterialGroup) => { const key = normalize(g.base); setExpanded((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; // اگر متریالی از بیرون انتخاب شد، گروهش باز باشد useEffect(() => { if (!materialId) return; const g = groups.find((gr) => gr.variants.some((v) => v.id === materialId) || gr.repId === materialId); if (g) setExpanded((prev) => new Set(prev).add(normalize(g.base))); }, [materialId, groups]); // ---------- ردیف‌های واحد متریال + اتصال‌های برند ---------- const loadRows = useCallback(async (mid: string) => { setRowsLoading(true); try { const [unitRows, links] = await Promise.all([ api.materialUnits.list(mid), api.brandMaterials.listByMaterial(mid), ]); setRows(unitRows); setBrandLinks(links); } catch (err) { toast.error(err instanceof Error ? err.message : "خواندن ضریب‌های متریال ناموفق بود"); setRows([]); setBrandLinks([]); } finally { setRowsLoading(false); } }, []); useEffect(() => { if (!materialId) { setRows([]); setBrandLinks([]); setMaterialSuppliers([]); return; } void loadRows(materialId); setRefDraft(null); setEditingId(null); setFormUnit(""); setFormFactor(""); setFormBrand(""); }, [materialId, loadRows]); // واکشی تأمین‌کننده‌های متصل به متریال انتخاب‌شده useEffect(() => { if (!materialId) { setMaterialSuppliers([]); setSuppliersLoadedId(null); return; } let alive = true; setSuppliersLoading(true); api.materials .listSuppliers(materialId) .then((list) => { if (alive) { setMaterialSuppliers(list); setSuppliersLoadedId(materialId); } }) .catch((err: unknown) => { if (!alive) return; setMaterialSuppliers([]); setSuppliersLoadedId(materialId); // Surface the real error so backend issues are not silently hidden const msg = err instanceof Error ? err.message : "خواندن تأمین‌کننده‌های متریال ناموفق بود"; toast.error(msg); }) .finally(() => { if (alive) setSuppliersLoading(false); }); return () => { alive = false; }; }, [materialId]); const brandNameFor = (row: MaterialUnitRow): string => { if (!row.brand_material_id) return ""; return brandLinks.find((b) => b.id === row.brand_material_id)?.brand_name || ""; }; // واحدهای استفاده‌شده در پیشنهادهای همین متریال که ضریب ندارند const unknownUnits = useMemo(() => { if (!material || !ref) return []; const out = new Map(); quotes.forEach((q) => (q.line_items || []).forEach((li) => { if (li.rejected || !li.unit) return; if (li.material_id !== material.id) return; if (normalize(li.unit) === normalize(ref)) return; if (rows.some((r) => normalize(unitDisplay(r.unit)) === normalize(li.unit))) return; const std = standardConversions.some( (c) => normalize(c.from_unit) === normalize(li.unit) && normalize(c.to_unit) === normalize(ref), ); if (std) return; out.set(li.unit, (out.get(li.unit) || 0) + 1); }), ); return [...out.entries()].map(([unit, count]) => ({ unit, count })).sort((a, b) => a.unit.localeCompare(b.unit, "fa")); }, [material, ref, rows, quotes, standardConversions]); // ---------- واحد مرجع متریال ---------- const unitIdByName = (name: string): number | null => { const wanted = normalize(name); return units.find((u) => normalize(u.name_fa || u.code) === wanted)?.id ?? null; }; const saveRef = async () => { if (!material) return; const val = (refDraft ?? ref).trim(); if (!val) return; try { let uid = unitIdByName(val); if (uid == null) { const created = await apiV26.catalog.createUnit(val); uid = created.id; setUnits((prev) => uniqueUnits([...prev, { id: created.id, code: created.code, name_fa: created.name_fa }])); } await api.materials.updateCanonicalUnit(material.id, uid); toast.success("واحد مرجع متریال ذخیره شد"); setRefDraft(null); refreshProcurementStore(); } catch (err) { toast.error(err instanceof Error ? err.message : "ذخیره‌ی واحد مرجع ناموفق بود"); } }; // ---------- ضریب تبدیل متریال (با قید برند اختیاری) ---------- const factorNum = (): number | null => { const n = parseNum(formFactor); return n != null && !Number.isNaN(n) && n > 0 ? n : null; }; const convLive = useMemo(() => { const from = formUnit.trim(); if (!from && !formFactor.trim() && !formBrand.trim()) return 'مثلاً: واحد مرجع «کیلوگرم»، واحد پیشنهادشده «کیسه» و ضریب «۵۰» → یعنی هر کیلوگرم = ۵۰ کیسه'; if (!from) return "واحد پیشنهادشده را بنویسید (مثلاً کیسه)."; if (!ref) return "⚠️ این متریال واحد مرجع ندارد."; const f = factorNum(); if (f == null) return "ضریب را وارد کنید (مثلاً ۵۰) تا معنی تبدیل مشخص شود."; // ترتیب درست: واحد مرجع اول، بعد برند، ضریب و واحد پیشنهادشده const brandPart = formBrand.trim() ? ` از برند «${formBrand.trim()}»` : ""; return `✅ یعنی هر ${ref}${brandPart} برابر ${faNum(f)} ${from} است.`; // eslint-disable-next-line react-hooks/exhaustive-deps }, [formUnit, formFactor, formBrand, ref]); // برند انتخابی را به brand_material_id متریال تبدیل می‌کند (ساخت/اتصال خودکار) const ensureBrandLink = async (brandName: string): Promise => { if (!material) return null; const wanted = normalize(brandName); const existing = brandLinks.find((b) => normalize(b.brand_name) === wanted); if (existing) return existing.id; const all = allBrands.find((b) => normalize(b) === wanted); let brandId: string | null = null; if (all) { const fresh = await api.brandsList(); brandId = fresh.find((b) => normalize(b.name) === wanted)?.id ?? null; } if (!brandId) { brandId = (await api.brands.create(brandName)).id; } await api.brandMaterials.link(material.id, brandId); const links = await api.brandMaterials.listByMaterial(material.id); setBrandLinks(links); return links.find((b) => normalize(b.brand_name) === wanted)?.id ?? null; }; const saveConv = async () => { if (!material) return; const from = formUnit.trim(); if (!from) return; if (!ref) { toast.error("اول واحد مرجع این متریال را مشخص کنید."); return; } const factor = factorNum(); if (factor == null) return; try { let uid = unitIdByName(from); if (uid == null) { const created = await apiV26.catalog.createUnit(from); uid = created.id; setUnits((prev) => uniqueUnits([...prev, { id: created.id, code: created.code, name_fa: created.name_fa }])); } const brandName = formBrand.trim(); const brandMaterialId = brandName ? await ensureBrandLink(brandName) : null; if (editingId) { await api.materialUnits.update(material.id, editingId, { conversion_factor: factor }); toast.success("ضریب تبدیل ویرایش شد"); } else { await api.materialUnits.create(material.id, { brand_material_id: brandMaterialId, unit_id: uid, conversion_factor: factor, }); toast.success("ضریب تبدیل افزوده شد"); } setEditingId(null); setFormUnit(""); setFormFactor(""); setFormBrand(""); await loadRows(material.id); } catch (err) { toast.error(err instanceof Error ? err.message : "ذخیره‌ی ضریب تبدیل ناموفق بود"); } }; const startEdit = (row: MaterialUnitRow) => { setEditingId(row.id); setFormUnit(unitDisplay(row.unit)); setFormFactor(String(row.conversion_factor)); setFormBrand(brandNameFor(row)); }; const deleteMu = async (row: MaterialUnitRow) => { if (!material) return; try { await api.materialUnits.remove(material.id, row.id); toast.success("ضریب تبدیل حذف شد"); await loadRows(material.id); } catch (err) { toast.error(err instanceof Error ? err.message : "حذف ضریب تبدیل ناموفق بود"); } }; // ---------- تبدیل‌های ثابت (سراسری) ---------- const saveStdConv = () => { const from = stdFrom.trim(); const to = stdTo.trim(); if (!from || !to) return; const n = parseNum(stdFactor); if (n == null || Number.isNaN(n) || n <= 0) return; if (editingStdId) { const c: StandardConversion = { id: editingStdId, from_unit: from, to_unit: to, factor: n }; void saveStd.mutateAsync(c).catch((err) => toast.error(err instanceof Error ? err.message : "ذخیره‌ی تبدیل ثابت ناموفق بود"), ); } else { void saveStd .mutateAsync({ id: nextId("sc", standardConversions), from_unit: from, to_unit: to, factor: n }) .catch((err) => toast.error(err instanceof Error ? err.message : "ذخیره‌ی تبدیل ثابت ناموفق بود")); } setEditingStdId(null); setStdFrom(""); setStdFactor(""); setStdTo(""); }; const startStdEdit = (id: string) => { const c = standardConversions.find((x) => x.id === id); if (!c) return; setEditingStdId(id); setStdFrom(c.from_unit); setStdFactor(String(c.factor)); setStdTo(c.to_unit); }; // ---------- render ---------- return (
{/* آکاردئون متریال‌ها + نوار جستجو */} {/* پنل اصلی */}
{!material ? (
متریالی را از فهرست سمت راست انتخاب کنید
) : ( <> {/* تامین‌کنندگان متریال — پاسخ GET /materials/{id}/suppliers */}

تامین‌کنندگان متریال

{suppliersLoading || materialId !== suppliersLoadedId ? (
) : materialSuppliers.length === 0 ? (
تامین‌کننده‌ای برای این متریال ثبت نشده است
) : (
{materialSuppliers.map((s) => (
{/* نام تأمین‌کننده — مستقیم از پاسخ API، بدون fallback */}
{s.name}
{(s.location || s.phone || s.contact_person) && (
{s.location && {s.location}} {s.phone && {s.phone}} {s.contact_person && {s.contact_person}}
)}
))}
)}
{/* واحد مرجع متریال */}
واحد مرجع متریال «{material.name}»
همه‌ی پیشنهادهای این متریال برای مقایسه به این واحد تبدیل می‌شوند
unitDisplay(u)).filter(Boolean)} allowAdd addSuffix="به عنوان واحد جدید" placeholder="مثلاً متر" className="w-56" onInputChange={setRefDraft} onChange={(it) => setRefDraft(it.label)} onAdd={(v) => { // ثبت فوری واحدِ جدیدِ تایپ‌شده و اعمال روی فیلد const name = v.trim(); if (!name) return; void apiV26.catalog .createUnit(name) .then((created) => { setUnits((prev) => prev.some((u) => normalize(unitDisplay(u)) === normalize(name)) ? prev : [...prev, { id: created.id, code: created.code, name_fa: created.name_fa }], ); setRefDraft(name); }) .catch((err) => { toast.error(err instanceof Error ? err.message : "ثبت واحد جدید ناموفق بود"); }); }} />
{/* واحدهای ناشناخته */} {unknownUnits.length > 0 && ( <>

واحدهای استفاده‌شده بدون ضریب

{unknownUnits.map((u) => (
⚠️ {u.unit} {faNum(u.count)} پیشنهاد بدون ضریب
))} )} {/* تبدیل‌های ثابت */}

تبدیل‌های ثابت (همه‌ی متریال‌ها)

{!standardConversions.length ? (
تبدیل ثابتی ثبت نشده است.
) : ( standardConversions.map((c) => (
{c.from_unit} ← {faNum(c.factor)} ← {c.to_unit} ثابت
)) )} {/* فرم تبدیل ثابت */}

{editingStdId ? ( <> ویرایش تبدیل ثابت ) : ( <> افزودن تبدیل ثابت )}

setStdFrom(e.target.value)} />
setStdFactor(n != null ? String(n) : "")} placeholder="مثلاً 1000" inputMode="decimal" />
setStdTo(e.target.value)} />
{editingStdId && ( )}
تبدیل ثابت برای همه‌ی متریال‌ها اعمال می‌شود؛ ضریب یعنی: ۱ از واحد = ضریب × به واحد
{/* ضرایب متریال */}

ضریب‌های تبدیل

{rowsLoading ? (
در حال بارگذاری…
) : !rows.length ? (
هنوز ضریبی برای این متریال ثبت نشده است.
) : ( rows.map((r) => (
{unitDisplay(r.unit) || `واحد #${r.unit_id}`} ← {ref}
{brandNameFor(r) || "همه برندها"} {faNum(r.conversion_factor)} × {ref}
)) )} {/* فرم افزودن ضریب */}

{editingId ? ( <> ویرایش ضریب تبدیل ) : ( <> افزودن ضریب تبدیل )}

setFormUnit(it.label)} items={units.map((u) => unitDisplay(u)).filter(Boolean)} allowAdd addSuffix="به عنوان واحد جدید" placeholder="مثلاً حلقه" onInputChange={setFormUnit} />
setFormFactor(n != null ? String(n) : "")} placeholder="مثلاً 0.98" inputMode="decimal" />
setFormBrand(it.label)} items={allBrands} allowAdd disabled={!!editingId} addSuffix="به عنوان برند جدید" placeholder="مثلاً راجین" onInputChange={setFormBrand} />
{editingId && ( )}
{convLive}
)}
); }