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.
This commit is contained in:
@@ -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<string, UnitLike>();
|
||||||
|
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"));
|
||||||
|
}
|
||||||
@@ -1,65 +1,113 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
|
||||||
import { toast } from "sonner";
|
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 {
|
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,
|
useDeleteStandardConversion,
|
||||||
useMaterials,
|
useMaterials,
|
||||||
useQuotes,
|
useQuotes,
|
||||||
useSaveConversion,
|
|
||||||
useSaveRefUnit,
|
|
||||||
useSaveStandardConversion,
|
useSaveStandardConversion,
|
||||||
useStoreData,
|
useStoreData,
|
||||||
useSuppliers,
|
|
||||||
useUnits,
|
|
||||||
} from "../../hooks";
|
} from "../../hooks";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { topicByName } from "../../lib/topics";
|
import { uniqueUnits } from "../../lib/units";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
import { parseNum } from "../../lib/numutil";
|
import { parseNum } from "../../lib/numutil";
|
||||||
import { findConversion, materialById } from "../../lib/quoteUtils";
|
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { TopicGrid } from "../../components/TopicGrid";
|
|
||||||
import { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
import { NumberInput } from "../../components/NumberInput";
|
import { NumberInput } from "../../components/NumberInput";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
|
||||||
interface RefUnitDraft {
|
type UnitLite = import("../../lib/units").UnitLike;
|
||||||
id: string;
|
|
||||||
category: string;
|
function unitDisplay(u: { code: string; name_fa: string | null } | null | undefined): string {
|
||||||
ref_unit: 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() {
|
export function UnitsPage() {
|
||||||
useStoreData();
|
useStoreData();
|
||||||
const [params] = useSearchParams();
|
|
||||||
|
|
||||||
const suppliers = useSuppliers();
|
|
||||||
const materials = useMaterials();
|
const materials = useMaterials();
|
||||||
const quotes = useQuotes();
|
const quotes = useQuotes();
|
||||||
const topics = useAppStore((s) => s.topics);
|
const standardConversions = useAppStore((s) => s.standardConversions);
|
||||||
const { categoryUnits, unitConversions, standardConversions } = useUnits();
|
|
||||||
const saveRefUnit = useSaveRefUnit();
|
|
||||||
const saveConversion = useSaveConversion();
|
|
||||||
const deleteConversion = useDeleteConversion();
|
|
||||||
const saveStd = useSaveStandardConversion();
|
const saveStd = useSaveStandardConversion();
|
||||||
const deleteStd = useDeleteStandardConversion();
|
const deleteStd = useDeleteStandardConversion();
|
||||||
|
|
||||||
// ---------- state ----------
|
// ---------- state ----------
|
||||||
const [selectedTopic, setSelectedTopic] = useState<string | null>(null);
|
const [search, setSearch] = useState("");
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [materialId, setMaterialId] = useState<string | null>(null);
|
||||||
const [refDraft, setRefDraft] = useState<RefUnitDraft | 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 [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editingStdId, setEditingStdId] = 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 [formFrom, setFormFrom] = useState("");
|
const [units, setUnits] = useState<UnitLite[]>([]);
|
||||||
|
const [allBrands, setAllBrands] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// فرم ضریب متریال
|
||||||
|
const [formUnit, setFormUnit] = useState("");
|
||||||
const [formFactor, setFormFactor] = useState("");
|
const [formFactor, setFormFactor] = useState("");
|
||||||
const [formBrand, setFormBrand] = useState("");
|
const [formBrand, setFormBrand] = useState("");
|
||||||
|
|
||||||
@@ -68,183 +116,304 @@ export function UnitsPage() {
|
|||||||
const [stdFactor, setStdFactor] = useState("");
|
const [stdFactor, setStdFactor] = useState("");
|
||||||
const [stdTo, setStdTo] = useState("");
|
const [stdTo, setStdTo] = useState("");
|
||||||
|
|
||||||
// ---------- مشتقات ----------
|
// ---------- واحدها / برندهای سراسری ----------
|
||||||
const catTopicMap = useMemo(() => {
|
|
||||||
const map: Record<string, string> = {};
|
|
||||||
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<string, number>();
|
|
||||||
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<string>();
|
|
||||||
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<string>();
|
|
||||||
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<string[]>([]);
|
|
||||||
// همهی برندها از بکاند
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
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
|
void api
|
||||||
.brandsList()
|
.brandsList()
|
||||||
.then((list) => alive && setAllBrands(list.map((b) => b.name)))
|
.then((list) => alive && setAllBrands(list.map((b) => b.name).filter(Boolean)))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => { alive = false; };
|
return () => { alive = false; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const topicCounts = useMemo(() => {
|
// ---------- متریال انتخابشده ----------
|
||||||
const out: Record<string, number> = {};
|
const material = useMemo(
|
||||||
topics.forEach((t) => {
|
() => materials.find((m) => m.id === materialId) || null,
|
||||||
out[t.name] = Object.keys(catTopicMap).filter((c) => normalize(catTopicMap[c]) === normalize(t.name)).length;
|
[materials, materialId],
|
||||||
});
|
);
|
||||||
return out;
|
const ref = material?.base_unit || "";
|
||||||
}, [topics, catTopicMap]);
|
|
||||||
|
|
||||||
// ---------- actions ----------
|
// ---------- گروههای آکاردئونی سایدبار ----------
|
||||||
const saveRef = () => {
|
// والدها بر اساس «نام پایه» یکتا میشوند. فرزندِ آکاردئون فقط «ترکیبهای
|
||||||
if (!selectedCategory) return;
|
// متریال + مشخصه» است (مثل «سیم مسی»)؛ فرزندِ خودارجاع (همان نام پایه
|
||||||
const val = refDraft?.ref_unit.trim() || "";
|
// بدون مشخصه — مثل «سیم» زیرِ «سیم») هرگز نمایش داده نمیشود. اگر گروهی
|
||||||
if (!val) return;
|
// مشخصه/واریانت نداشت، خودِ والد قابل انتخاب است (متریال نماینده).
|
||||||
const cu: CategoryUnit = refDraft?.id
|
const groups = useMemo<MaterialGroup[]>(() => {
|
||||||
? { id: refDraft.id, category: selectedCategory, ref_unit: val }
|
const map = new Map<string, MaterialGroup>();
|
||||||
: { id: nextId("cu", categoryUnits), category: selectedCategory, ref_unit: val };
|
const variantSeen = new Map<string, string>(); // normalize(label) → preferred materialId
|
||||||
void saveRefUnit
|
|
||||||
.mutateAsync(cu)
|
const addVariant = (group: MaterialGroup, v: VariantItem, exactName: boolean) => {
|
||||||
.catch((err) => toast.error(err instanceof Error ? err.message : "ذخیرهی واحد مرجع ناموفق بود"));
|
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 factorNum = (): number | null => {
|
||||||
const n = parseNum(formFactor);
|
const n = parseNum(formFactor);
|
||||||
return n != null && !Number.isNaN(n) && n > 0 ? n : null;
|
return n != null && !Number.isNaN(n) && n > 0 ? n : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const convLive = useMemo(() => {
|
const convLive = useMemo(() => {
|
||||||
if (!selectedCategory) return null;
|
const from = formUnit.trim();
|
||||||
const ref = refUnitFor(selectedCategory);
|
|
||||||
const from = formFrom.trim();
|
|
||||||
if (!from && !formFactor.trim() && !formBrand.trim())
|
if (!from && !formFactor.trim() && !formBrand.trim())
|
||||||
return 'مثلاً: واحد پیشنهادشده «کیسه»، ضریب «۵۰» و مرجع «کیلوگرم» → یعنی هر کیسه = ۵۰ کیلوگرم';
|
return 'مثلاً: واحد مرجع «کیلوگرم»، واحد پیشنهادشده «کیسه» و ضریب «۵۰» → یعنی هر کیلوگرم = ۵۰ کیسه';
|
||||||
if (!from) return "واحد پیشنهادشده را بنویسید (مثلاً کیسه).";
|
if (!from) return "واحد پیشنهادشده را بنویسید (مثلاً کیسه).";
|
||||||
if (!ref) return "⚠️ اول «واحد مرجع» این دسته را در بالا مشخص کنید.";
|
if (!ref) return "⚠️ این متریال واحد مرجع ندارد.";
|
||||||
const f = factorNum();
|
const f = factorNum();
|
||||||
if (f == null) return "ضریب را وارد کنید (مثلاً ۵۰) تا معنی تبدیل مشخص شود.";
|
if (f == null) return "ضریب را وارد کنید (مثلاً ۵۰) تا معنی تبدیل مشخص شود.";
|
||||||
|
// ترتیب درست: واحد مرجع اول، بعد برند، ضریب و واحد پیشنهادشده
|
||||||
const brandPart = formBrand.trim() ? ` از برند «${formBrand.trim()}»` : "";
|
const brandPart = formBrand.trim() ? ` از برند «${formBrand.trim()}»` : "";
|
||||||
return `✅ یعنی هر ${from}${brandPart} برابر ${faNum(f)} ${ref} است.`;
|
return `✅ یعنی هر ${ref}${brandPart} برابر ${faNum(f)} ${from} است.`;
|
||||||
}, [formFrom, formFactor, formBrand, selectedCategory, categoryUnits]); // eslint-disable-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [formUnit, formFactor, formBrand, ref]);
|
||||||
|
|
||||||
const saveConv = () => {
|
// برند انتخابی را به brand_material_id متریال تبدیل میکند (ساخت/اتصال خودکار)
|
||||||
if (!selectedCategory) return;
|
const ensureBrandLink = async (brandName: string): Promise<string | null> => {
|
||||||
const from = formFrom.trim();
|
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 (!from) return;
|
||||||
const to = refUnitFor(selectedCategory);
|
if (!ref) {
|
||||||
if (!to) {
|
toast.error("اول واحد مرجع این متریال را مشخص کنید.");
|
||||||
toast.error("اول واحد مرجع این دسته را مشخص کنید.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const factor = factorNum();
|
const factor = factorNum();
|
||||||
if (factor == null) return;
|
if (factor == null) return;
|
||||||
const onError = (err: unknown) => {
|
try {
|
||||||
toast.error(err instanceof Error ? err.message : "ذخیرهی ضریب تبدیل ناموفق بود");
|
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) {
|
if (editingId) {
|
||||||
const c: UnitConversion = {
|
await api.materialUnits.update(material.id, editingId, { conversion_factor: factor });
|
||||||
id: editingId,
|
toast.success("ضریب تبدیل ویرایش شد");
|
||||||
category: selectedCategory,
|
|
||||||
brand: formBrand.trim(),
|
|
||||||
from_unit: from,
|
|
||||||
to_unit: to,
|
|
||||||
factor,
|
|
||||||
};
|
|
||||||
void saveConversion.mutateAsync(c).catch(onError);
|
|
||||||
} else {
|
} else {
|
||||||
void saveConversion
|
await api.materialUnits.create(material.id, {
|
||||||
.mutateAsync({
|
brand_material_id: brandMaterialId,
|
||||||
id: nextId("uc", unitConversions),
|
unit_id: uid,
|
||||||
category: selectedCategory,
|
conversion_factor: factor,
|
||||||
brand: formBrand.trim(),
|
});
|
||||||
from_unit: from,
|
toast.success("ضریب تبدیل افزوده شد");
|
||||||
to_unit: to,
|
|
||||||
factor,
|
|
||||||
})
|
|
||||||
.catch(onError);
|
|
||||||
}
|
}
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setFormFrom("");
|
setFormUnit("");
|
||||||
setFormFactor("");
|
setFormFactor("");
|
||||||
setFormBrand("");
|
setFormBrand("");
|
||||||
|
await loadRows(material.id);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "ذخیرهی ضریب تبدیل ناموفق بود");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEdit = (id: string) => {
|
const startEdit = (row: MaterialUnitRow) => {
|
||||||
const c = unitConversions.find((x) => x.id === id);
|
setEditingId(row.id);
|
||||||
if (!c) return;
|
setFormUnit(unitDisplay(row.unit));
|
||||||
setEditingId(id);
|
setFormFactor(String(row.conversion_factor));
|
||||||
setFormFrom(c.from_unit);
|
setFormBrand(brandNameFor(row));
|
||||||
setFormFactor(String(c.factor));
|
|
||||||
setFormBrand(c.brand || "");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 saveStdConv = () => {
|
||||||
const from = stdFrom.trim();
|
const from = stdFrom.trim();
|
||||||
const to = stdTo.trim();
|
const to = stdTo.trim();
|
||||||
@@ -276,172 +445,176 @@ export function UnitsPage() {
|
|||||||
setStdTo(c.to_unit);
|
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 ----------
|
// ---------- 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<string, UnitConversion[]>();
|
|
||||||
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 (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header title="ضرایب تبدیل" sub="اول موضوع، بعد دسته — واحد مرجع و ضریب تبدیل متریال را تنظیم کنید" backTo="/" backLabel="بازگشت به دفتر" />
|
<Header
|
||||||
|
title="ضرایب تبدیل"
|
||||||
|
sub="متریال را انتخاب کنید — واحد مرجع و ضریب تبدیل همان متریال"
|
||||||
|
backTo="/"
|
||||||
|
backLabel="بازگشت به دفتر"
|
||||||
|
/>
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
{!selectedTopic ? (
|
|
||||||
<div>
|
|
||||||
<div className="units-hint mb-3 text-[12px] text-muted">
|
|
||||||
اول موضوع را انتخاب کنید تا فقط دستههای همان موضوع بیاید
|
|
||||||
</div>
|
|
||||||
<TopicGrid topics={topics} onSelect={(t) => setSelectedTopic(t)} showCounts counts={topicCounts} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="units-layout">
|
<div className="units-layout">
|
||||||
{/* درخت دستهها */}
|
{/* آکاردئون متریالها + نوار جستجو */}
|
||||||
<aside className="units-tree">
|
<aside className="units-tree">
|
||||||
<div className="tree-head">
|
<div className="tree-head">
|
||||||
<button type="button" className="btn-line-file" onClick={backToTopics}>
|
|
||||||
‹ موضوعها
|
|
||||||
</button>
|
|
||||||
<span className="text-[12px]">
|
<span className="text-[12px]">
|
||||||
دستههای <b>{selectedTopic}</b>
|
متریالهای <b>کاتالوگ</b>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="mb-2 flex items-center gap-1.5 rounded-md border border-line bg-panel px-2.5 py-2">
|
||||||
{categoriesOfTopic.length ? (
|
<Search className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||||
categoriesOfTopic.map((c) => (
|
<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
|
<button
|
||||||
key={c}
|
|
||||||
type="button"
|
type="button"
|
||||||
className={cn("ut-cat", selectedCategory === c && "selected")}
|
className={cn("ut-cat w-full", groupActive && "selected")}
|
||||||
onClick={() => {
|
onClick={() => (hasChildren ? toggleGroup(g) : setMaterialId(g.repId))}
|
||||||
setSelectedCategory(c);
|
|
||||||
setEditingId(null);
|
|
||||||
setRefDraft(categoryUnits.find((x) => normalize(x.category) === normalize(c)) || null);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span>{c}</span>
|
<span className="flex min-w-0 items-center gap-1">
|
||||||
<span className="ut-ref">{refUnitFor(c) || "—"}</span>
|
{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>
|
</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">دستهای در این موضوع ثبت نشده است.</div>
|
<div className="text-[12px] text-muted">
|
||||||
|
{materials.length ? "متریالی با این نام پیدا نشد." : "متریالی در کاتالوگ ثبت نشده است."}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* پنل اصلی */}
|
{/* پنل اصلی */}
|
||||||
<section className="units-panel">
|
<section className="units-panel">
|
||||||
{!selectedCategory ? (
|
{!material ? (
|
||||||
<div className="units-empty py-10 text-center text-sm text-muted">
|
<div className="units-empty py-10 text-center text-sm text-muted">
|
||||||
یک دسته را از سمت راست انتخاب کنید
|
متریالی را از فهرست سمت راست انتخاب کنید
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* واحد مرجع */}
|
{/* واحد مرجع متریال */}
|
||||||
<div className="ref-unit-box">
|
<div className="ref-unit-box">
|
||||||
<div className="ref-title">
|
<div className="ref-title">
|
||||||
<Scale className="me-1 inline h-4 w-4 text-accent" /> واحد مرجع این دسته
|
<Scale className="me-1 inline h-4 w-4 text-accent" /> واحد مرجع متریال «{material.name}»
|
||||||
</div>
|
</div>
|
||||||
<div className="ref-hint">همهی پیشنهادهای این دسته برای مقایسه به این واحد تبدیل میشوند</div>
|
<div className="ref-hint">همهی پیشنهادهای این متریال برای مقایسه به این واحد تبدیل میشوند</div>
|
||||||
<div className="ref-controls">
|
<div className="ref-controls">
|
||||||
<input
|
<Combobox
|
||||||
type="text"
|
value={refDraft ?? ref}
|
||||||
className="h-9 w-48 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"
|
items={units.map((u) => unitDisplay(u)).filter(Boolean)}
|
||||||
|
allowAdd
|
||||||
|
addSuffix="به عنوان واحد جدید"
|
||||||
placeholder="مثلاً متر"
|
placeholder="مثلاً متر"
|
||||||
autoComplete="off"
|
className="w-56"
|
||||||
value={refDraft?.ref_unit ?? ref}
|
onInputChange={setRefDraft}
|
||||||
onChange={(e) => setRefDraft((d) => ({ id: d?.id || "", category: selectedCategory, ref_unit: e.target.value }))}
|
onChange={(it) => setRefDraft(it.label)}
|
||||||
onKeyDown={(e) => {
|
onAdd={(v) => {
|
||||||
if (e.key === "Enter") {
|
// ثبت فوری واحدِ جدیدِ تایپشده و اعمال روی فیلد
|
||||||
e.preventDefault();
|
const name = v.trim();
|
||||||
saveRef();
|
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={saveRef}>
|
<Button size="sm" onClick={() => void saveRef()}>
|
||||||
ذخیره
|
ذخیره
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ضرایب دسته */}
|
{/* ضرایب متریال */}
|
||||||
<h4 className="sec-title">
|
<h4 className="sec-title">
|
||||||
<Repeat className="me-1 inline h-4 w-4 text-accent" /> ضریبهای تبدیل
|
<Repeat className="me-1 inline h-4 w-4 text-accent" /> ضریبهای تبدیل
|
||||||
</h4>
|
</h4>
|
||||||
{!grouped.length ? (
|
{rowsLoading ? (
|
||||||
<div className="conv-empty">هنوز ضریبی برای این دسته ثبت نشده است.</div>
|
<div className="conv-empty">در حال بارگذاری…</div>
|
||||||
|
) : !rows.length ? (
|
||||||
|
<div className="conv-empty">هنوز ضریبی برای این متریال ثبت نشده است.</div>
|
||||||
) : (
|
) : (
|
||||||
grouped.map(([fromUnit, rows]) => (
|
rows.map((r) => (
|
||||||
<div key={fromUnit}>
|
<div key={r.id}>
|
||||||
<div className="cg-head">
|
<div className="cg-head">
|
||||||
{fromUnit} ← {rows[0].to_unit || ref}
|
{unitDisplay(r.unit) || `واحد #${r.unit_id}`} ← {ref}
|
||||||
</div>
|
</div>
|
||||||
{rows.map((c) => (
|
<div className="conv-row">
|
||||||
<div key={c.id} className="conv-row">
|
<span className="cv-brand">{brandNameFor(r) || "همه برندها"}</span>
|
||||||
<span className="cv-brand">{c.brand ? `برند: ${c.brand}` : "همه برندها"}</span>
|
<span className="cv-factor">{faNum(r.conversion_factor)}</span>
|
||||||
<span className="cv-factor">{faNum(c.factor)}</span>
|
|
||||||
<span className="cv-arrow">×</span>
|
<span className="cv-arrow">×</span>
|
||||||
<span className="cv-unit">{c.to_unit || ref}</span>
|
<span className="cv-unit">{ref}</span>
|
||||||
<span className="conv-actions">
|
<span className="conv-actions">
|
||||||
<button type="button" title="ویرایش" onClick={() => startEdit(c.id)}>
|
<button type="button" title="ویرایش" onClick={() => startEdit(r)}>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" title="حذف" onClick={() => void deleteMu(r)}>
|
||||||
type="button"
|
|
||||||
title="حذف"
|
|
||||||
onClick={() => void deleteConversion.mutateAsync(c.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -463,13 +636,13 @@ export function UnitsPage() {
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<label>واحد پیشنهادشده</label>
|
<label>واحد پیشنهادشده</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={formFrom}
|
value={formUnit}
|
||||||
onChange={(it: { id: string; label: string }) => setFormFrom(it.label)}
|
onChange={(it) => setFormUnit(it.label)}
|
||||||
items={suggestedUnitsFor(selectedCategory)}
|
items={units.map((u) => unitDisplay(u)).filter(Boolean)}
|
||||||
allowAdd
|
allowAdd
|
||||||
addSuffix="به عنوان واحد جدید"
|
addSuffix="به عنوان واحد جدید"
|
||||||
placeholder="مثلاً حلقه"
|
placeholder="مثلاً حلقه"
|
||||||
onInputChange={(v) => setFormFrom(v)}
|
onInputChange={setFormUnit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@@ -485,12 +658,13 @@ export function UnitsPage() {
|
|||||||
<label>برند (خالی = همه برندها)</label>
|
<label>برند (خالی = همه برندها)</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={formBrand}
|
value={formBrand}
|
||||||
onChange={(it: { id: string; label: string }) => setFormBrand(it.label)}
|
onChange={(it) => setFormBrand(it.label)}
|
||||||
items={suggestedBrandsFor()}
|
items={allBrands}
|
||||||
allowAdd
|
allowAdd
|
||||||
|
disabled={!!editingId}
|
||||||
addSuffix="به عنوان برند جدید"
|
addSuffix="به عنوان برند جدید"
|
||||||
placeholder="مثلاً سیمکو"
|
placeholder="مثلاً راجین"
|
||||||
onInputChange={(v) => setFormBrand(v)}
|
onInputChange={setFormBrand}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@@ -504,7 +678,7 @@ export function UnitsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Button size="sm" onClick={saveConv}>
|
<Button size="sm" onClick={() => void saveConv()}>
|
||||||
{editingId ? "ذخیره تغییرات" : "افزودن"}
|
{editingId ? "ذخیره تغییرات" : "افزودن"}
|
||||||
</Button>
|
</Button>
|
||||||
{editingId && (
|
{editingId && (
|
||||||
@@ -513,7 +687,7 @@ export function UnitsPage() {
|
|||||||
variant="slate"
|
variant="slate"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setFormFrom("");
|
setFormUnit("");
|
||||||
setFormFactor("");
|
setFormFactor("");
|
||||||
setFormBrand("");
|
setFormBrand("");
|
||||||
}}
|
}}
|
||||||
@@ -542,7 +716,7 @@ export function UnitsPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setFormFrom(u.unit);
|
setFormUnit(u.unit);
|
||||||
setFormFactor("");
|
setFormFactor("");
|
||||||
setFormBrand("");
|
setFormBrand("");
|
||||||
}}
|
}}
|
||||||
@@ -557,7 +731,7 @@ export function UnitsPage() {
|
|||||||
|
|
||||||
{/* تبدیلهای ثابت */}
|
{/* تبدیلهای ثابت */}
|
||||||
<h4 className="sec-title">
|
<h4 className="sec-title">
|
||||||
<Link2 className="me-1 inline h-4 w-4 text-accent" /> تبدیلهای ثابت (همهی دستهها)
|
<Link2 className="me-1 inline h-4 w-4 text-accent" /> تبدیلهای ثابت (همهی متریالها)
|
||||||
</h4>
|
</h4>
|
||||||
{!standardConversions.length ? (
|
{!standardConversions.length ? (
|
||||||
<div className="conv-empty">تبدیل ثابتی ثبت نشده است.</div>
|
<div className="conv-empty">تبدیل ثابتی ثبت نشده است.</div>
|
||||||
@@ -652,14 +826,13 @@ export function UnitsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="conv-hint">
|
<div className="conv-hint">
|
||||||
تبدیل ثابت برای همهی دستهها اعمال میشود؛ «عدد» واحد مرجع خانوادهی عددی است. ضریب یعنی: ۱ از واحد = ضریب × به واحد
|
تبدیل ثابت برای همهی متریالها اعمال میشود؛ ضریب یعنی: ۱ از واحد = ضریب × به واحد
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user