From 58ff8dc4961ca15ae71a7d7b6e549bb21e76883d Mon Sep 17 00:00:00 2001 From: "reza.g" Date: Tue, 8 Sep 2026 14:42:16 +0330 Subject: [PATCH] feat(units): global-material conversion workspace (no category landing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - «ضرایب تبدیل» opens directly on the global materials list (no topic/category page); sidebar accordion: unique base materials (brand/spec prefixes stripped) with expandable spec variants; search toolbar filters live. - Detail binds to the selected material: reference unit (editable combobox over global units, changes canonical_unit_id), conversion factors from material_units incl. optional brand scope (restored برند field), add/edit/ delete with live preview (canonical-first wording), unassigned-units warning section, global standard conversions kept. - Self-referencing children excluded (only base+spec variants listed); groups without variants selectable via their representative material. - New lib/units.ts uniqueUnits(): unit dropdowns show each name once, preferring the canonical coded row (kg/piece) over duplicate custom rows with identical Persian names. --- frontend/src/lib/units.ts | 27 + frontend/src/pages/Units/UnitsPage.tsx | 1253 ++++++++++++++---------- 2 files changed, 740 insertions(+), 540 deletions(-) create mode 100644 frontend/src/lib/units.ts diff --git a/frontend/src/lib/units.ts b/frontend/src/lib/units.ts new file mode 100644 index 0000000..a8e75e0 --- /dev/null +++ b/frontend/src/lib/units.ts @@ -0,0 +1,27 @@ +import { normalize } from "./normalize"; + +export interface UnitLike { + id: number; + code: string; + name_fa: string | null; +} + +export function unitDisplayName(u: UnitLike): string { + return u.name_fa || u.code; +} + +// واحدها با «نام نمایشی» یکتا می‌شوند: وقتی دو ردیف نام فارسی یکسان دارند +// (مثلاً ردیف سفارشی «عدد» و ردیف پایه‌ی piece/عدد)، ردیفی که کد استاندارد +// دارد (code ≠ نام فارسی، مثل kg یا piece) ارجح است. +export function uniqueUnits(units: UnitLike[]): UnitLike[] { + const best = new Map(); + units.forEach((u) => { + const display = unitDisplayName(u); + const key = normalize(display); + if (!key) return; + const cur = best.get(key); + const isCanonical = u.code !== display; + if (!cur || (isCanonical && cur.code === display)) best.set(key, u); + }); + return [...best.values()].sort((a, b) => unitDisplayName(a).localeCompare(unitDisplayName(b), "fa")); +} diff --git a/frontend/src/pages/Units/UnitsPage.tsx b/frontend/src/pages/Units/UnitsPage.tsx index 89e45bb..4dc1f74 100644 --- a/frontend/src/pages/Units/UnitsPage.tsx +++ b/frontend/src/pages/Units/UnitsPage.tsx @@ -1,65 +1,113 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { useSearchParams } from "react-router-dom"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; -import { Scale, Repeat, TriangleAlert, Link2, Pencil, Trash2, Plus } from "lucide-react"; -import type { CategoryUnit, StandardConversion, UnitConversion } from "../../lib/types"; -import { api, nextId } from "../../lib/api"; import { - useDeleteConversion, + 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 MaterialUnitRow } from "../../lib/api"; +import { + refreshProcurementStore, useDeleteStandardConversion, useMaterials, useQuotes, - useSaveConversion, - useSaveRefUnit, useSaveStandardConversion, useStoreData, - useSuppliers, - useUnits, } from "../../hooks"; import { normalize } from "../../lib/normalize"; -import { topicByName } from "../../lib/topics"; +import { uniqueUnits } from "../../lib/units"; import { useAppStore } from "../../store"; import { faNum } from "../../lib/formatters"; import { parseNum } from "../../lib/numutil"; -import { findConversion, materialById } from "../../lib/quoteUtils"; import { Header, PageLayout } from "../../components/layout/Header"; -import { TopicGrid } from "../../components/TopicGrid"; import { Combobox } from "../../components/Combobox"; import { NumberInput } from "../../components/NumberInput"; import { cn } from "../../lib/utils"; import { Button } from "../../components/ui/button"; -interface RefUnitDraft { - id: string; - category: string; - ref_unit: string; +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 [params] = useSearchParams(); - - const suppliers = useSuppliers(); const materials = useMaterials(); const quotes = useQuotes(); - const topics = useAppStore((s) => s.topics); - const { categoryUnits, unitConversions, standardConversions } = useUnits(); - const saveRefUnit = useSaveRefUnit(); - const saveConversion = useSaveConversion(); - const deleteConversion = useDeleteConversion(); + const standardConversions = useAppStore((s) => s.standardConversions); const saveStd = useSaveStandardConversion(); const deleteStd = useDeleteStandardConversion(); // ---------- state ---------- - const [selectedTopic, setSelectedTopic] = useState(null); - const [selectedCategory, setSelectedCategory] = useState(null); - const [refDraft, setRefDraft] = useState(null); + 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([]); - // فرم ضریب دسته‌ای - const [formFrom, setFormFrom] = useState(""); + // واحدها و برندهای سراسری (فهرست دیتابیس) + const [units, setUnits] = useState([]); + const [allBrands, setAllBrands] = useState([]); + + // فرم ضریب متریال + const [formUnit, setFormUnit] = useState(""); const [formFactor, setFormFactor] = useState(""); const [formBrand, setFormBrand] = useState(""); @@ -68,183 +116,304 @@ export function UnitsPage() { const [stdFactor, setStdFactor] = useState(""); const [stdTo, setStdTo] = useState(""); - // ---------- مشتقات ---------- - const catTopicMap = useMemo(() => { - const map: Record = {}; - materials.forEach((m) => { - if (m.category && m.topic) map[m.category] = m.topic; - }); - suppliers.forEach((s) => { - (s.categories || []).forEach((c) => { - if (c && !map[c]) map[c] = s.topic; - }); - }); - return map; - }, [materials, suppliers]); - - const categoriesOfTopic = useMemo(() => { - if (!selectedTopic) return []; - return Object.keys(catTopicMap) - .filter((c) => normalize(catTopicMap[c]) === normalize(selectedTopic)) - .sort((a, b) => a.localeCompare(b, "fa")); - }, [catTopicMap, selectedTopic]); - - const refUnitFor = (cat: string): string => - categoryUnits.find((c) => normalize(c.category) === normalize(cat))?.ref_unit || ""; - - const conversionsFor = (cat: string): UnitConversion[] => - unitConversions - .filter((c) => normalize(c.category) === normalize(cat)) - .sort((a, b) => (b.brand ? 1 : 0) - (a.brand ? 1 : 0) || a.from_unit.localeCompare(b.from_unit, "fa")); - - // واحدهای استفاده‌شده در quoteهای دسته که ضریب ندارند - const unknownUnitsFor = (cat: string): { unit: string; count: number }[] => { - const ref = refUnitFor(cat); - const out = new Map(); - quotes.forEach((q) => - (q.line_items || []).forEach((li) => { - if (li.rejected || !li.unit) return; - const m = materialById(materials, li.material_id); - if (m.category === "؟" || normalize(m.category) !== normalize(cat)) return; - if (ref && normalize(li.unit) === normalize(ref)) return; - if (ref && findConversion({ unitConversions, standardConversions }, cat, m.brand, li.unit, ref)) 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")); - }; - - const suggestedUnitsFor = (cat: string): string[] => { - const out = new Set(); - quotes.forEach((q) => - (q.line_items || []).forEach((li) => { - if (li.rejected || !li.unit) return; - const m = materialById(materials, li.material_id); - if (m.category !== "؟" && normalize(m.category) === normalize(cat)) out.add(li.unit); - }), - ); - conversionsFor(cat).forEach((c) => out.add(c.from_unit)); - standardConversions.forEach((c) => { - out.add(c.from_unit); - out.add(c.to_unit); - }); - return [...out].filter(Boolean).sort((a, b) => a.localeCompare(b, "fa")); - }; - - // برندهای پیشنهادی — همه‌ی برندها از همه‌ی متریال‌ها (مثل «ثبت پیشنهاد جدید») + - // برندهای ثبت‌شده در بک‌اند + برندهای ضرایب - const suggestedBrandsFor = (): string[] => { - const out = new Set(); - materials - .filter((m) => m.brand) - .forEach((m) => out.add(m.brand)); - unitConversions.forEach((c) => c.brand && out.add(c.brand)); - allBrands.forEach((b) => out.add(b)); - return [...out].sort((a, b) => a.localeCompare(b, "fa")); - }; - - const [allBrands, setAllBrands] = 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))) + .then((list) => alive && setAllBrands(list.map((b) => b.name).filter(Boolean))) .catch(() => {}); return () => { alive = false; }; }, []); - const topicCounts = useMemo(() => { - const out: Record = {}; - topics.forEach((t) => { - out[t.name] = Object.keys(catTopicMap).filter((c) => normalize(catTopicMap[c]) === normalize(t.name)).length; - }); - return out; - }, [topics, catTopicMap]); + // ---------- متریال انتخاب‌شده ---------- + const material = useMemo( + () => materials.find((m) => m.id === materialId) || null, + [materials, materialId], + ); + const ref = material?.base_unit || ""; - // ---------- actions ---------- - const saveRef = () => { - if (!selectedCategory) return; - const val = refDraft?.ref_unit.trim() || ""; - if (!val) return; - const cu: CategoryUnit = refDraft?.id - ? { id: refDraft.id, category: selectedCategory, ref_unit: val } - : { id: nextId("cu", categoryUnits), category: selectedCategory, ref_unit: val }; - void saveRefUnit - .mutateAsync(cu) - .catch((err) => toast.error(err instanceof Error ? err.message : "ذخیره‌ی واحد مرجع ناموفق بود")); + // ---------- گروه‌های آکاردئونی سایدبار ---------- + // والدها بر اساس «نام پایه» یکتا می‌شوند. فرزندِ آکاردئون فقط «ترکیب‌های + // متریال + مشخصه» است (مثل «سیم مسی»)؛ فرزندِ خودارجاع (همان نام پایه + // بدون مشخصه — مثل «سیم» زیرِ «سیم») هرگز نمایش داده نمی‌شود. اگر گروهی + // مشخصه/واریانت نداشت، خودِ والد قابل انتخاب است (متریال نماینده). + 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([]); + return; + } + void loadRows(materialId); + setRefDraft(null); + setEditingId(null); + setFormUnit(""); + setFormFactor(""); + setFormBrand(""); + }, [materialId, loadRows]); + + 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(() => { - if (!selectedCategory) return null; - const ref = refUnitFor(selectedCategory); - const from = formFrom.trim(); + const from = formUnit.trim(); if (!from && !formFactor.trim() && !formBrand.trim()) - return 'مثلاً: واحد پیشنهادشده «کیسه»، ضریب «۵۰» و مرجع «کیلوگرم» → یعنی هر کیسه = ۵۰ کیلوگرم'; + return 'مثلاً: واحد مرجع «کیلوگرم»، واحد پیشنهادشده «کیسه» و ضریب «۵۰» → یعنی هر کیلوگرم = ۵۰ کیسه'; if (!from) return "واحد پیشنهادشده را بنویسید (مثلاً کیسه)."; - if (!ref) return "⚠️ اول «واحد مرجع» این دسته را در بالا مشخص کنید."; + if (!ref) return "⚠️ این متریال واحد مرجع ندارد."; const f = factorNum(); if (f == null) return "ضریب را وارد کنید (مثلاً ۵۰) تا معنی تبدیل مشخص شود."; + // ترتیب درست: واحد مرجع اول، بعد برند، ضریب و واحد پیشنهادشده const brandPart = formBrand.trim() ? ` از برند «${formBrand.trim()}»` : ""; - return `✅ یعنی هر ${from}${brandPart} برابر ${faNum(f)} ${ref} است.`; - }, [formFrom, formFactor, formBrand, selectedCategory, categoryUnits]); // eslint-disable-line react-hooks/exhaustive-deps + return `✅ یعنی هر ${ref}${brandPart} برابر ${faNum(f)} ${from} است.`; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formUnit, formFactor, formBrand, ref]); - const saveConv = () => { - if (!selectedCategory) return; - const from = formFrom.trim(); + // برند انتخابی را به 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; - const to = refUnitFor(selectedCategory); - if (!to) { - toast.error("اول واحد مرجع این دسته را مشخص کنید."); + if (!ref) { + toast.error("اول واحد مرجع این متریال را مشخص کنید."); return; } const factor = factorNum(); if (factor == null) return; - const onError = (err: unknown) => { + 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 : "ذخیره‌ی ضریب تبدیل ناموفق بود"); - }; - if (editingId) { - const c: UnitConversion = { - id: editingId, - category: selectedCategory, - brand: formBrand.trim(), - from_unit: from, - to_unit: to, - factor, - }; - void saveConversion.mutateAsync(c).catch(onError); - } else { - void saveConversion - .mutateAsync({ - id: nextId("uc", unitConversions), - category: selectedCategory, - brand: formBrand.trim(), - from_unit: from, - to_unit: to, - factor, - }) - .catch(onError); } - setEditingId(null); - setFormFrom(""); - setFormFactor(""); - setFormBrand(""); }; - const startEdit = (id: string) => { - const c = unitConversions.find((x) => x.id === id); - if (!c) return; - setEditingId(id); - setFormFrom(c.from_unit); - setFormFactor(String(c.factor)); - setFormBrand(c.brand || ""); + 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(); @@ -276,390 +445,394 @@ export function UnitsPage() { setStdTo(c.to_unit); }; - const backToTopics = () => { - setSelectedTopic(null); - setSelectedCategory(null); - setEditingId(null); - setEditingStdId(null); - }; - - // ---------- پیش‌فرض از URL ---------- - // یک بار به‌ازای هر پارامتر اعمال می‌شود (وقتی داده‌ی لازم لود شد) - const appliedParams = useRef(""); - useEffect(() => { - const key = `${params.get("cat") ?? ""}|${params.get("topic") ?? ""}`; - if (appliedParams.current === key) return; - const cat = params.get("cat"); - const topicParam = params.get("topic"); - const unitParam = params.get("unit"); - let catFound: string | null = null; - if (cat) { - catFound = Object.keys(catTopicMap).find((c) => normalize(c) === normalize(cat)) || null; - if (catFound) { - setSelectedTopic(catTopicMap[catFound]); - setSelectedCategory(catFound); - } - } - if (!catFound && topicParam) { - const t = topicByName(topicParam, topics); - if (t) { - setSelectedTopic(t.name); - catFound = ""; - } - } - if (unitParam && catFound) { - setFormFrom(unitParam); - } - if (catFound !== null) appliedParams.current = key; - }, [params, topics, catTopicMap]); - // ---------- render ---------- - const ref = selectedCategory ? refUnitFor(selectedCategory) : ""; - const conversions = selectedCategory ? conversionsFor(selectedCategory) : []; - const unknownUnits = selectedCategory ? unknownUnitsFor(selectedCategory) : []; - - // گروه‌بندی بر اساس from_unit - const grouped = useMemo(() => { - const map = new Map(); - conversions.forEach((c) => { - const key = c.from_unit; - if (!map.has(key)) map.set(key, []); - map.get(key)!.push(c); - }); - return [...map.entries()]; - }, [conversions]); - return (
-
+
- {!selectedTopic ? ( -
-
- اول موضوع را انتخاب کنید تا فقط دسته‌های همان موضوع بیاید +
+ {/* آکاردئون متریال‌ها + نوار جستجو */} +
- ) : ( -
- {/* درخت دسته‌ها */} - - - {/* پنل اصلی */} -
- {!selectedCategory ? ( -
- یک دسته را از سمت راست انتخاب کنید -
- ) : ( - <> - {/* واحد مرجع */} -
-
- واحد مرجع این دسته -
-
همه‌ی پیشنهادهای این دسته برای مقایسه به این واحد تبدیل می‌شوند
-
- setRefDraft((d) => ({ id: d?.id || "", category: selectedCategory, ref_unit: e.target.value }))} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - saveRef(); - } - }} - /> - -
-
- - {/* ضرایب دسته */} -

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

- {!grouped.length ? ( -
هنوز ضریبی برای این دسته ثبت نشده است.
- ) : ( - grouped.map(([fromUnit, rows]) => ( -
-
- {fromUnit} ← {rows[0].to_unit || ref} -
- {rows.map((c) => ( -
- {c.brand ? `برند: ${c.brand}` : "همه برندها"} - {faNum(c.factor)} - × - {c.to_unit || ref} - - - - +
+ + setSearch(e.target.value)} + /> +
+
+ {filteredGroups.length ? ( + filteredGroups.map((g) => { + const open = isOpen(g); + const groupActive = + materialId != null && (g.variants.some((v) => v.id === materialId) || g.repId === materialId); + const hasChildren = g.variants.length > 0; + return ( +
+ + {hasChildren && ( +
+
+
+ {g.variants.map((v) => ( + + ))} +
- ))} -
- )) - )} - - {/* فرم افزودن ضریب */} -
-

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

-
-
- - setFormFrom(it.label)} - items={suggestedUnitsFor(selectedCategory)} - allowAdd - addSuffix="به عنوان واحد جدید" - placeholder="مثلاً حلقه" - onInputChange={(v) => setFormFrom(v)} - /> -
-
- - setFormFactor(n != null ? String(n) : "")} - placeholder="مثلاً 0.98" - inputMode="decimal" - /> -
-
- - setFormBrand(it.label)} - items={suggestedBrandsFor()} - allowAdd - addSuffix="به عنوان برند جدید" - placeholder="مثلاً سیمکو" - onInputChange={(v) => setFormBrand(v)} - /> -
-
- - -
-
- - {editingId && ( - - )} -
-
-
{convLive}
-
- - {/* واحدهای ناشناخته */} - {unknownUnits.length > 0 && ( - <> -

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

- {unknownUnits.map((u) => ( -
- ⚠️ {u.unit} - {faNum(u.count)} پیشنهاد بدون ضریب - - -
- ))} - - )} + )} +
+ ); + }) + ) : ( +
+ {materials.length ? "متریالی با این نام پیدا نشد." : "متریالی در کاتالوگ ثبت نشده است."} +
+ )} +
+ - {/* تبدیل‌های ثابت */} -

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

- {!standardConversions.length ? ( -
تبدیل ثابتی ثبت نشده است.
- ) : ( - standardConversions.map((c) => ( -
- {c.from_unit} - ← - {faNum(c.factor)} - ← - {c.to_unit} - ثابت + {/* پنل اصلی */} +
+ {!material ? ( +
+ متریالی را از فهرست سمت راست انتخاب کنید +
+ ) : ( + <> + {/* واحد مرجع متریال */} +
+
+ واحد مرجع متریال «{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 : "ثبت واحد جدید ناموفق بود"); + }); + }} + /> + +
+
+ + {/* ضرایب متریال */} +

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

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

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

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

+ {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}
+
+ + {/* واحدهای ناشناخته */} + {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 && ( + + )} +
+
+
+ تبدیل ثابت برای همه‌ی متریال‌ها اعمال می‌شود؛ ضریب یعنی: ۱ از واحد = ضریب × به واحد +
+
+ + )} +
+
);