feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login

This commit is contained in:
ali_mohaghegh
2026-08-25 19:08:15 +03:30
parent 170aed66ea
commit 4e105c5cd5
103 changed files with 16352 additions and 0 deletions
+657
View File
@@ -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>
);
}