feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login
This commit is contained in:
@@ -0,0 +1,657 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
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,
|
||||
useDeleteStandardConversion,
|
||||
useMaterials,
|
||||
useQuotes,
|
||||
useSaveConversion,
|
||||
useSaveRefUnit,
|
||||
useSaveStandardConversion,
|
||||
useStoreData,
|
||||
useSuppliers,
|
||||
useUnits,
|
||||
} from "../../hooks";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { TOPICS } from "../../lib/constants";
|
||||
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;
|
||||
}
|
||||
|
||||
// داشبورد واحدها — دو مرحله: انتخاب تاپیک → تنظیم واحد مرجع و ضریب تبدیل دسته
|
||||
export function UnitsPage() {
|
||||
useStoreData();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const suppliers = useSuppliers();
|
||||
const materials = useMaterials();
|
||||
const quotes = useQuotes();
|
||||
const { categoryUnits, unitConversions, standardConversions } = useUnits();
|
||||
const saveRefUnit = useSaveRefUnit();
|
||||
const saveConversion = useSaveConversion();
|
||||
const deleteConversion = useDeleteConversion();
|
||||
const saveStd = useSaveStandardConversion();
|
||||
const deleteStd = useDeleteStandardConversion();
|
||||
|
||||
// ---------- state ----------
|
||||
const [selectedTopic, setSelectedTopic] = useState<string | null>(null);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [refDraft, setRefDraft] = useState<RefUnitDraft | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingStdId, setEditingStdId] = useState<string | null>(null);
|
||||
|
||||
// فرم ضریب دستهای
|
||||
const [formFrom, setFormFrom] = useState("");
|
||||
const [formFactor, setFormFactor] = useState("");
|
||||
const [formBrand, setFormBrand] = useState("");
|
||||
|
||||
// فرم تبدیل ثابت
|
||||
const [stdFrom, setStdFrom] = useState("");
|
||||
const [stdFactor, setStdFactor] = 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(() => {
|
||||
let alive = true;
|
||||
void api
|
||||
.brandsList()
|
||||
.then((list) => alive && setAllBrands(list.map((b) => b.name)))
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
const topicCounts = useMemo(() => {
|
||||
const out: Record<string, number> = {};
|
||||
TOPICS.forEach((t) => {
|
||||
out[t.name] = Object.keys(catTopicMap).filter((c) => normalize(catTopicMap[c]) === normalize(t.name)).length;
|
||||
});
|
||||
return out;
|
||||
}, [catTopicMap]);
|
||||
|
||||
// ---------- 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 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();
|
||||
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 `✅ یعنی هر ${from}${brandPart} برابر ${faNum(f)} ${ref} است.`;
|
||||
}, [formFrom, formFactor, formBrand, selectedCategory, categoryUnits]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const saveConv = () => {
|
||||
if (!selectedCategory) return;
|
||||
const from = formFrom.trim();
|
||||
if (!from) return;
|
||||
const to = refUnitFor(selectedCategory);
|
||||
if (!to) {
|
||||
toast.error("اول واحد مرجع این دسته را مشخص کنید.");
|
||||
return;
|
||||
}
|
||||
const factor = factorNum();
|
||||
if (factor == null) return;
|
||||
const onError = (err: unknown) => {
|
||||
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 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);
|
||||
};
|
||||
|
||||
const backToTopics = () => {
|
||||
setSelectedTopic(null);
|
||||
setSelectedCategory(null);
|
||||
setEditingId(null);
|
||||
setEditingStdId(null);
|
||||
};
|
||||
|
||||
// ---------- پیشفرض از URL ----------
|
||||
useMemo(() => {
|
||||
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 = TOPICS.find((x) => normalize(x.name) === normalize(topicParam));
|
||||
if (t) setSelectedTopic(t.name);
|
||||
}
|
||||
if (unitParam && catFound) {
|
||||
setFormFrom(unitParam);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ---------- 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 (
|
||||
<div className="min-h-screen">
|
||||
<Header title="ضرایب تبدیل" sub="اول موضوع، بعد دسته — واحد مرجع و ضریب تبدیل همان دسته را تنظیم کنید" backTo="/" backLabel="بازگشت به دفتر" />
|
||||
<PageLayout>
|
||||
{!selectedTopic ? (
|
||||
<div>
|
||||
<div className="units-hint mb-3 text-[12px] text-muted">
|
||||
اول موضوع را انتخاب کنید تا فقط دستههای همان موضوع بیاید
|
||||
</div>
|
||||
<TopicGrid onSelect={(t) => setSelectedTopic(t)} showCounts counts={topicCounts} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="units-layout">
|
||||
{/* درخت دستهها */}
|
||||
<aside className="units-tree">
|
||||
<div className="tree-head">
|
||||
<button type="button" className="btn-line-file" onClick={backToTopics}>
|
||||
‹ موضوعها
|
||||
</button>
|
||||
<span className="text-[12px]">
|
||||
دستههای <b>{selectedTopic}</b>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{categoriesOfTopic.length ? (
|
||||
categoriesOfTopic.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={cn("ut-cat", selectedCategory === c && "selected")}
|
||||
onClick={() => {
|
||||
setSelectedCategory(c);
|
||||
setEditingId(null);
|
||||
setRefDraft(categoryUnits.find((x) => normalize(x.category) === normalize(c)) || null);
|
||||
}}
|
||||
>
|
||||
<span>{c}</span>
|
||||
<span className="ut-ref">{refUnitFor(c) || "—"}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="text-[12px] text-muted">دستهای در این موضوع ثبت نشده است.</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* پنل اصلی */}
|
||||
<section className="units-panel">
|
||||
{!selectedCategory ? (
|
||||
<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" /> واحد مرجع این دسته
|
||||
</div>
|
||||
<div className="ref-hint">همهی پیشنهادهای این دسته برای مقایسه به این واحد تبدیل میشوند</div>
|
||||
<div className="ref-controls">
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
placeholder="مثلاً متر"
|
||||
autoComplete="off"
|
||||
value={refDraft?.ref_unit ?? ref}
|
||||
onChange={(e) => setRefDraft((d) => ({ id: d?.id || "", category: selectedCategory, ref_unit: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveRef();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" onClick={saveRef}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ضرایب دسته */}
|
||||
<h4 className="sec-title">
|
||||
<Repeat className="me-1 inline h-4 w-4 text-accent" /> ضریبهای تبدیل
|
||||
</h4>
|
||||
{!grouped.length ? (
|
||||
<div className="conv-empty">هنوز ضریبی برای این دسته ثبت نشده است.</div>
|
||||
) : (
|
||||
grouped.map(([fromUnit, rows]) => (
|
||||
<div key={fromUnit}>
|
||||
<div className="cg-head">
|
||||
{fromUnit} ← {rows[0].to_unit || ref}
|
||||
</div>
|
||||
{rows.map((c) => (
|
||||
<div key={c.id} className="conv-row">
|
||||
<span className="cv-brand">{c.brand ? `برند: ${c.brand}` : "همه برندها"}</span>
|
||||
<span className="cv-factor">{faNum(c.factor)}</span>
|
||||
<span className="cv-arrow">×</span>
|
||||
<span className="cv-unit">{c.to_unit || ref}</span>
|
||||
<span className="conv-actions">
|
||||
<button type="button" title="ویرایش" onClick={() => startEdit(c.id)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="حذف"
|
||||
onClick={() => void deleteConversion.mutateAsync(c.id)}
|
||||
>
|
||||
<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={formFrom}
|
||||
onChange={(it: { id: string; label: string }) => setFormFrom(it.label)}
|
||||
items={suggestedUnitsFor(selectedCategory)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان واحد جدید"
|
||||
placeholder="مثلاً حلقه"
|
||||
onInputChange={(v) => setFormFrom(v)}
|
||||
/>
|
||||
</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: { id: string; label: string }) => setFormBrand(it.label)}
|
||||
items={suggestedBrandsFor()}
|
||||
allowAdd
|
||||
addSuffix="به عنوان برند جدید"
|
||||
placeholder="مثلاً سیمکو"
|
||||
onInputChange={(v) => setFormBrand(v)}
|
||||
/>
|
||||
</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={saveConv}>
|
||||
{editingId ? "ذخیره تغییرات" : "افزودن"}
|
||||
</Button>
|
||||
{editingId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="slate"
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setFormFrom("");
|
||||
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);
|
||||
setFormFrom(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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user