Files
ERP/frontend/src/pages/Units/UnitsPage.tsx
T
whqreza 58ff8dc496 feat(units): global-material conversion workspace (no category landing)
- «ضرایب تبدیل» 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.
2026-09-08 14:42:16 +03:30

840 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
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,
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<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [refDraft, setRefDraft] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingStdId, setEditingStdId] = useState<string | null>(null);
const [rows, setRows] = useState<MaterialUnitRow[]>([]);
const [rowsLoading, setRowsLoading] = useState(false);
const [brandLinks, setBrandLinks] = useState<BrandLinkRow[]>([]);
// واحدها و برندهای سراسری (فهرست دیتابیس)
const [units, setUnits] = useState<UnitLite[]>([]);
const [allBrands, setAllBrands] = useState<string[]>([]);
// فرم ضریب متریال
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<MaterialGroup[]>(() => {
const map = new Map<string, MaterialGroup>();
const variantSeen = new Map<string, string>(); // 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<string, number>();
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<string | null> => {
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 (
<div className="min-h-screen">
<Header
title="ضرایب تبدیل"
sub="متریال را انتخاب کنید — واحد مرجع و ضریب تبدیل همان متریال"
backTo="/"
backLabel="بازگشت به دفتر"
/>
<PageLayout>
<div className="units-layout">
{/* آکاردئون متریال‌ها + نوار جستجو */}
<aside className="units-tree">
<div className="tree-head">
<span className="text-[12px]">
متریال‌های <b>کاتالوگ</b>
</span>
</div>
<div className="mb-2 flex items-center gap-1.5 rounded-md border border-line bg-panel px-2.5 py-2">
<Search className="h-3.5 w-3.5 shrink-0 text-muted" />
<input
type="text"
className="w-full bg-transparent text-[13px] outline-none placeholder:text-muted"
placeholder="جستجوی متریال…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
{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 (
<div key={normalize(g.base)} className="rounded-lg border border-line bg-panel p-1.5">
<button
type="button"
className={cn("ut-cat w-full", groupActive && "selected")}
onClick={() => (hasChildren ? toggleGroup(g) : setMaterialId(g.repId))}
>
<span className="flex min-w-0 items-center gap-1">
{hasChildren && (
<ChevronDown
className={cn(
"h-3.5 w-3.5 shrink-0 text-muted transition-transform",
open && "rotate-180",
)}
/>
)}
<span className="truncate">{g.base}</span>
</span>
<span className="ut-ref">{hasChildren ? faNum(g.variants.length) : g.repRef || "—"}</span>
</button>
{hasChildren && (
<div
className={cn(
"grid transition-[grid-template-rows] duration-200 ease-out",
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
)}
>
<div className="overflow-hidden">
<div className="mt-1 flex flex-col gap-0.5">
{g.variants.map((v) => (
<button
key={v.id}
type="button"
className={cn(
"flex cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-1.5 text-[12.5px] transition-colors hover:bg-slate-bg",
materialId === v.id && "bg-accent-bg font-bold text-accent-dark",
)}
onClick={() => setMaterialId(v.id)}
>
<span className="truncate">{v.label}</span>
<span className="ut-ref">{v.refUnit || "—"}</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
);
})
) : (
<div className="text-[12px] text-muted">
{materials.length ? "متریالی با این نام پیدا نشد." : "متریالی در کاتالوگ ثبت نشده است."}
</div>
)}
</div>
</aside>
{/* پنل اصلی */}
<section className="units-panel">
{!material ? (
<div className="units-empty py-10 text-center text-sm text-muted">
متریالی را از فهرست سمت راست انتخاب کنید
</div>
) : (
<>
{/* واحد مرجع متریال */}
<div className="ref-unit-box">
<div className="ref-title">
<Scale className="me-1 inline h-4 w-4 text-accent" /> واحد مرجع متریال «{material.name}»
</div>
<div className="ref-hint">همه‌ی پیشنهادهای این متریال برای مقایسه به این واحد تبدیل می‌شوند</div>
<div className="ref-controls">
<Combobox
value={refDraft ?? ref}
items={units.map((u) => 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 : "ثبت واحد جدید ناموفق بود");
});
}}
/>
<Button size="sm" onClick={() => void saveRef()}>
ذخیره
</Button>
</div>
</div>
{/* ضرایب متریال */}
<h4 className="sec-title">
<Repeat className="me-1 inline h-4 w-4 text-accent" /> ضریب‌های تبدیل
</h4>
{rowsLoading ? (
<div className="conv-empty">در حال بارگذاری…</div>
) : !rows.length ? (
<div className="conv-empty">هنوز ضریبی برای این متریال ثبت نشده است.</div>
) : (
rows.map((r) => (
<div key={r.id}>
<div className="cg-head">
{unitDisplay(r.unit) || `واحد #${r.unit_id}`} ← {ref}
</div>
<div className="conv-row">
<span className="cv-brand">{brandNameFor(r) || "همه برندها"}</span>
<span className="cv-factor">{faNum(r.conversion_factor)}</span>
<span className="cv-arrow">×</span>
<span className="cv-unit">{ref}</span>
<span className="conv-actions">
<button type="button" title="ویرایش" onClick={() => startEdit(r)}>
<Pencil className="h-3.5 w-3.5" />
</button>
<button type="button" title="حذف" onClick={() => void deleteMu(r)}>
<Trash2 className="h-3.5 w-3.5" />
</button>
</span>
</div>
</div>
))
)}
{/* فرم افزودن ضریب */}
<div className="add-conv">
<h4 className="mb-2 text-[13.5px] font-bold">
{editingId ? (
<>
<Pencil className="me-1 inline h-3.5 w-3.5" /> ویرایش ضریب تبدیل
</>
) : (
<>
<Plus className="me-1 inline h-3.5 w-3.5" /> افزودن ضریب تبدیل
</>
)}
</h4>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1.2fr_0.6fr_1fr_0.8fr_auto] sm:items-end">
<div className="field">
<label>واحد پیشنهادشده</label>
<Combobox
value={formUnit}
onChange={(it) => setFormUnit(it.label)}
items={units.map((u) => unitDisplay(u)).filter(Boolean)}
allowAdd
addSuffix="به عنوان واحد جدید"
placeholder="مثلاً حلقه"
onInputChange={setFormUnit}
/>
</div>
<div className="field">
<label>ضریب</label>
<NumberInput
value={factorNum()}
onChange={(n) => setFormFactor(n != null ? String(n) : "")}
placeholder="مثلاً 0.98"
inputMode="decimal"
/>
</div>
<div className="field">
<label>برند (خالی = همه برندها)</label>
<Combobox
value={formBrand}
onChange={(it) => setFormBrand(it.label)}
items={allBrands}
allowAdd
disabled={!!editingId}
addSuffix="به عنوان برند جدید"
placeholder="مثلاً راجین"
onInputChange={setFormBrand}
/>
</div>
<div className="field">
<label>به واحد (مرجع)</label>
<input
type="text"
className="h-9 w-full rounded-lg border border-line bg-slate-bg px-3 text-sm text-muted"
value={ref || "مرجع"}
placeholder="مرجع"
readOnly
/>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={() => void saveConv()}>
{editingId ? "ذخیره تغییرات" : "افزودن"}
</Button>
{editingId && (
<Button
size="sm"
variant="slate"
onClick={() => {
setEditingId(null);
setFormUnit("");
setFormFactor("");
setFormBrand("");
}}
>
انصراف
</Button>
)}
</div>
</div>
<div className="conv-live">{convLive}</div>
</div>
{/* واحدهای ناشناخته */}
{unknownUnits.length > 0 && (
<>
<h4 className="sec-title">
<TriangleAlert className="me-1 inline h-4 w-4 text-amber" /> واحدهای استفاده‌شده بدون ضریب
</h4>
{unknownUnits.map((u) => (
<div key={u.unit} className="unknown-row">
<span className="uk-unit">⚠️ {u.unit}</span>
<span className="uk-count">{faNum(u.count)} پیشنهاد بدون ضریب</span>
<span className="ms-auto">
<Button
variant="line"
size="sm"
onClick={() => {
setEditingId(null);
setFormUnit(u.unit);
setFormFactor("");
setFormBrand("");
}}
>
+ افزودن ضریب
</Button>
</span>
</div>
))}
</>
)}
{/* تبدیل‌های ثابت */}
<h4 className="sec-title">
<Link2 className="me-1 inline h-4 w-4 text-accent" /> تبدیل‌های ثابت (همه‌ی متریال‌ها)
</h4>
{!standardConversions.length ? (
<div className="conv-empty">تبدیل ثابتی ثبت نشده است.</div>
) : (
standardConversions.map((c) => (
<div key={c.id} className="conv-row">
<span className="cv-unit">{c.from_unit}</span>
<span className="cv-arrow">←</span>
<span className="cv-factor">{faNum(c.factor)}</span>
<span className="cv-arrow">←</span>
<span className="cv-unit">{c.to_unit}</span>
<span className="cv-brand">ثابت</span>
<span className="conv-actions">
<button type="button" title="ویرایش" onClick={() => startStdEdit(c.id)}>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
title="حذف"
onClick={() => void deleteStd.mutateAsync(c.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</span>
</div>
))
)}
{/* فرم تبدیل ثابت */}
<div className="add-conv">
<h4 className="mb-2 text-[13.5px] font-bold">
{editingStdId ? (
<>
<Pencil className="me-1 inline h-3.5 w-3.5" /> ویرایش تبدیل ثابت
</>
) : (
<>
<Plus className="me-1 inline h-3.5 w-3.5" /> افزودن تبدیل ثابت
</>
)}
</h4>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_0.8fr_1fr_auto] sm:items-end">
<div className="field">
<label>از واحد</label>
<input
type="text"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="مثلاً تن"
autoComplete="off"
value={stdFrom}
onChange={(e) => setStdFrom(e.target.value)}
/>
</div>
<div className="field">
<label>ضریب</label>
<NumberInput
value={parseNum(stdFactor)}
onChange={(n) => setStdFactor(n != null ? String(n) : "")}
placeholder="مثلاً 1000"
inputMode="decimal"
/>
</div>
<div className="field">
<label>به واحد</label>
<input
type="text"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
placeholder="مثلاً کیلوگرم"
autoComplete="off"
value={stdTo}
onChange={(e) => setStdTo(e.target.value)}
/>
</div>
<div className="flex items-center gap-1.5">
<Button size="sm" onClick={saveStdConv}>
{editingStdId ? "ذخیره تغییرات" : "افزودن"}
</Button>
{editingStdId && (
<Button
size="sm"
variant="slate"
onClick={() => {
setEditingStdId(null);
setStdFrom("");
setStdFactor("");
setStdTo("");
}}
>
انصراف
</Button>
)}
</div>
</div>
<div className="conv-hint">
تبدیل ثابت برای همه‌ی متریال‌ها اعمال می‌شود؛ ضریب یعنی: ۱ از واحد = ضریب × به واحد
</div>
</div>
</>
)}
</section>
</div>
</PageLayout>
</div>
);
}