fix(suppliers): enforce Category → Supplier hierarchy, remove rogue-category bug

Domain model (v2.6): suppliers.category_id NOT NULL (1:N from the 8 fixed
categories), materials are global and link to suppliers via
supplier_materials. This fixes three regressions of the old inverted tree:

- remove the intermediate 'یک متریال را انتخاب کنید' screen: a category
  card now routes straight to that category's supplier list
  (/suppliers/category/:category); TopicPage is deleted and legacy
  /topics/:topic[/:category] URLs redirect to the new hierarchy
- kill the accidental-category bug: api.materials.create silently POSTed
  /material-categories for any unknown 'category' (a material name!), which
  is how the rogue «پریز» category (+ junk material) appeared. Unknown
  categories now fail loudly instead of creating catalog rows (DB cleaned:
  exactly the 8 canonical categories remain)
- fix inverted wording: supplier list is «تأمین‌کننده‌های این دسته» with
  category-first breadcrumbs; the legacy material multi-select on the
  add-supplier form is gone (a supplier gets the page's category)
- supplier details screen logic untouched — only its breadcrumb link
  targets were updated to the new routes
This commit is contained in:
ali_mohaghegh
2026-09-03 15:00:50 +03:30
parent b4ed18af71
commit f9ec68815e
7 changed files with 44 additions and 231 deletions
+12 -10
View File
@@ -10,7 +10,6 @@ const WizardPage = lazy(() => import("./pages/Wizard/WizardPage").then((m) => ({
const SuppliersPage = lazy(() => const SuppliersPage = lazy(() =>
import("./pages/Suppliers/SuppliersPage").then((m) => ({ default: m.SuppliersPage })), import("./pages/Suppliers/SuppliersPage").then((m) => ({ default: m.SuppliersPage })),
); );
const TopicPage = lazy(() => import("./pages/Suppliers/TopicPage").then((m) => ({ default: m.TopicPage })));
const CategoryPage = lazy(() => const CategoryPage = lazy(() =>
import("./pages/Suppliers/CategoryPage").then((m) => ({ default: m.CategoryPage })), import("./pages/Suppliers/CategoryPage").then((m) => ({ default: m.CategoryPage })),
); );
@@ -93,22 +92,25 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
{/* سلسله‌مراتب دامنه: دسته → تأمین‌کننده. کارت دسته مستقیم به
فهرست تأمین‌کننده‌های همان دسته می‌رود (بدون صفحه‌ی واسط متریال). */}
<Route <Route
path="/topics/:topic" path="/suppliers/category/:category"
element={
<RequireAuth>
<TopicPage />
</RequireAuth>
}
/>
<Route
path="/topics/:topic/:category"
element={ element={
<RequireAuth> <RequireAuth>
<CategoryPage /> <CategoryPage />
</RequireAuth> </RequireAuth>
} }
/> />
{/* مسیرهای قدیمی (درخت معکوس topic → material → supplier) — ریدایرکت */}
<Route
path="/topics/:topic/:category"
element={<Navigate to="/suppliers/category/:category" replace />}
/>
<Route
path="/topics/:topic"
element={<Navigate to="/suppliers" replace />}
/>
<Route <Route
path="/suppliers/:id" path="/suppliers/:id"
element={ element={
+9 -12
View File
@@ -959,19 +959,16 @@ export const api = {
}, },
create: async (m: Material): Promise<Material> => { create: async (m: Material): Promise<Material> => {
const lookup = await fetchLookup(); const lookup = await fetchLookup();
let categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined; const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined;
// اگر دسته در بک‌اند نیست (مثلاً بعد از حذف seed) — بساز // دسته‌ها همان ۸ دسته‌ی ثابت سیستم‌اند و هرگز به‌صورت خودکار ساخته
if (!categoryId && m.category) { // نمی‌شوند — قبلاً این‌جا برای هر نامِ ناشناخته یک رکورد material_categories
const cat = await postData<RawMaterialCategory>("/material-categories", { // می‌ساخت و باگ «پریز» (دسته‌ی rogue) را به وجود آورد.
slug: norm(m.category) || "cat",
name: m.category,
is_active: true,
});
categoryId = cat.id;
lookup.categoryNameToId.set(norm(m.category), cat.id);
}
if (!categoryId) { if (!categoryId) {
throw new Error(`متریال «${m.category}» در بک‌اند تعریف نشده است`); throw new Error(
m.category
? `دسته «${m.category}» در سامانه تعریف نشده است — یکی از ۸ دسته‌ی موجود را انتخاب کنید`
: "دسته‌ی متریال مشخص نیست — یکی از ۸ دسته‌ی موجود را انتخاب کنید",
);
} }
// واحد مرجع — اگر تعریف نشده، بساز (بک‌اند canonical_unit_id الزامی دارد) // واحد مرجع — اگر تعریف نشده، بساز (بک‌اند canonical_unit_id الزامی دارد)
const unitName = m.base_unit || "عدد"; const unitName = m.base_unit || "عدد";
+17 -34
View File
@@ -6,18 +6,18 @@ import type { Supplier } from "../../lib/types";
import { nextId } from "../../lib/api"; import { nextId } from "../../lib/api";
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks"; import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
import { useAppStore } from "../../store"; import { useAppStore } from "../../store";
import { normalize } from "../../lib/normalize"; import { topicByName } from "../../lib/topics";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState"; import { EmptyState } from "../../components/EmptyState";
import { Combobox } from "../../components/Combobox"; import { Combobox } from "../../components/Combobox";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox"; import { SupplierCard, suppliersOfCategory } from "./supplierUtils";
import { SupplierCard, suppliersOfCategory, topicCategories, topicFromParam } from "./supplierUtils";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
// صفحه‌ی تأمین‌کننده‌های یک متریال + فرم افزودن تأمین‌کننده // صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده
// (سلسله‌مراتب دامنه: دسته → تأمین‌کننده؛ در v2.6 دسته = تاپیک)
export function CategoryPage() { export function CategoryPage() {
useStoreData(); useStoreData();
const { topic: topicParam = "", category: catParam = "" } = useParams(); const { category: catParam = "" } = useParams();
const materials = useMaterials(); const materials = useMaterials();
const suppliers = useSuppliers(); const suppliers = useSuppliers();
@@ -26,15 +26,10 @@ export function CategoryPage() {
const removeSupplier = useRemoveSupplier(); const removeSupplier = useRemoveSupplier();
const topics = useAppStore((s) => s.topics); const topics = useAppStore((s) => s.topics);
const topic = useMemo(() => topicFromParam(topicParam, topics), [topicParam, topics]); const cat = useMemo(() => topicByName(catParam, topics)?.name ?? null, [catParam, topics]);
const matched = useMemo(
() => (topic ? topicCategories(topic, materials, suppliers).find((c) => normalize(c) === normalize(catParam)) : undefined),
[topic, catParam, materials, suppliers],
);
const [form, setForm] = useState<{ name: string; categories: string[]; location: string; phone: string; contact: string }>({ const [form, setForm] = useState<{ name: string; location: string; phone: string; contact: string }>({
name: "", name: "",
categories: [],
location: "", location: "",
phone: "", phone: "",
contact: "", contact: "",
@@ -46,13 +41,11 @@ export function CategoryPage() {
[suppliers], [suppliers],
); );
if (!topic || !matched) { if (!cat) return <Navigate to="/suppliers" replace />;
return topic ? <Navigate to={`/topics/${encodeURIComponent(topic)}`} replace /> : <Navigate to="/suppliers" replace />;
}
const sups = suppliersOfCategory(topic, matched, suppliers); const sups = suppliersOfCategory(cat, cat, suppliers);
// متریال‌های خریداری‌شده از هر تأمین‌کننده // متریال‌های خریداری‌شده از هر تأمین‌کننده (برای نمایش روی کارت)
const supMats = useMemo(() => { const supMats = useMemo(() => {
const map = new Map<string, string[]>(); const map = new Map<string, string[]>();
quotes.forEach((q) => { quotes.forEach((q) => {
@@ -75,19 +68,18 @@ export function CategoryPage() {
toast.error("نام تأمین‌کننده را وارد کنید."); toast.error("نام تأمین‌کننده را وارد کنید.");
return; return;
} }
// اگر متریالی انتخاب نشده، تأمین‌کننده به همین متریالِ صفحه اضافه می‌شود // تأمین‌کننده دقیقاً زیر همین دسته ثبت می‌شود (۱ دسته به ازای هر تأمین‌کننده)
const cats = form.categories.length ? form.categories : [matched];
const s: Supplier = { const s: Supplier = {
id: nextId("s", suppliers), id: nextId("s", suppliers),
topic, topic: cat,
categories: cats, categories: [cat],
name, name,
location: form.location.trim(), location: form.location.trim(),
phone: form.phone.trim(), phone: form.phone.trim(),
contact: form.contact.trim(), contact: form.contact.trim(),
}; };
void addSupplier.mutateAsync(s); void addSupplier.mutateAsync(s);
setForm({ name: "", categories: [], location: "", phone: "", contact: "" }); setForm({ name: "", location: "", phone: "", contact: "" });
toast.success("تأمین‌کننده افزوده شد."); toast.success("تأمین‌کننده افزوده شد.");
}; };
@@ -101,14 +93,14 @@ export function CategoryPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header title={matched} sub={`تأمین‌کننده‌های این متریال — ${topic}`} backTo="/suppliers" backLabel="موضوع‌ها" /> <Header title={cat} sub="تأمین‌کننده‌های این دسته" backTo="/suppliers" backLabel="دسته‌بندی‌ها" />
<PageLayout className="flex flex-col gap-4"> <PageLayout className="flex flex-col gap-4">
{!sups.length ? ( {!sups.length ? (
<EmptyState icon={Inbox} text="هنوز تأمین‌کننده‌ای برای این متریال ثبت نشده است." /> <EmptyState icon={Inbox} text="هنوز تأمین‌کننده‌ای برای این دسته ثبت نشده است." />
) : ( ) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{sups.map((s) => ( {sups.map((s) => (
<SupplierCard key={s.id} s={s} topic={topic} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} /> <SupplierCard key={s.id} s={s} topic={cat} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
))} ))}
</div> </div>
)} )}
@@ -130,15 +122,6 @@ export function CategoryPage() {
onInputChange={(v) => setForm((f) => ({ ...f, name: v }))} onInputChange={(v) => setForm((f) => ({ ...f, name: v }))}
/> />
</div> </div>
<div className="field">
<label>متریال (ها)</label>
<MultiSelectCombobox
values={form.categories}
onChange={(cats) => setForm((f) => ({ ...f, categories: cats }))}
options={topicCategories(topic, materials, suppliers)}
placeholder="متریال را انتخاب یا تایپ کنید..."
/>
</div>
<div className="field"> <div className="field">
<label>موقعیت (شهر - منطقه)</label> <label>موقعیت (شهر - منطقه)</label>
<input <input
@@ -152,12 +152,12 @@ export function SupplierDetailPage() {
پیشنهاد جدید پیشنهاد جدید
</Link> </Link>
{firstCat ? ( {firstCat ? (
<Link to={`/topics/${encodeURIComponent(topicName)}/${encodeURIComponent(firstCat)}`} className="btn-line-file no-underline"> <Link to={`/suppliers/category/${encodeURIComponent(firstCat)}`} className="btn-line-file no-underline">
‹ بازگشت به دسته ‹ بازگشت به دسته
</Link> </Link>
) : ( ) : (
<Link to={`/topics/${encodeURIComponent(topicName)}`} className="btn-line-file no-underline"> <Link to="/suppliers" className="btn-line-file no-underline">
‹ بازگشت به موضوع ‹ بازگشت به دسته‌بندی‌ها
</Link> </Link>
)} )}
</div> </div>
@@ -25,14 +25,14 @@ export function SuppliersPage() {
<div className="min-h-screen"> <div className="min-h-screen">
<Header <Header
title="تأمین‌کننده‌ها" title="تأمین‌کننده‌ها"
sub="اول موضوع (دسته‌بندی) را انتخاب کنید" sub="اول دسته‌بندی را انتخاب کنید"
backTo="/" backTo="/"
backLabel="بازگشت به دفتر" backLabel="بازگشت به دفتر"
/> />
<PageLayout> <PageLayout>
<TopicGrid <TopicGrid
topics={topics} topics={topics}
onSelect={(topic) => navigate(`/topics/${encodeURIComponent(topic)}`)} onSelect={(topic) => navigate(`/suppliers/category/${encodeURIComponent(topic)}`)}
showCounts showCounts
counts={counts} counts={counts}
/> />
-162
View File
@@ -1,162 +0,0 @@
import { useMemo, useState } from "react";
import { Navigate, useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { FolderOpen, Plus } from "lucide-react";
import type { Material } from "../../lib/types";
import { nextId } from "../../lib/api";
import { useAddMaterial, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
import { useAppStore } from "../../store";
import { normalize } from "../../lib/normalize";
import { faNum } from "../../lib/formatters";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { SupplierCard, suppliersOfCategory, topicCategories, topicFromParam } from "./supplierUtils";
import { Button } from "../../components/ui/button";
// صفحه‌ی متریال‌های یک موضوع + تأمین‌کننده‌های بدون متریال + افزودن متریال
export function TopicPage() {
useStoreData();
const { topic: topicParam = "" } = useParams();
const navigate = useNavigate();
const materials = useMaterials();
const suppliers = useSuppliers();
const quotes = useQuotes();
const addMaterial = useAddMaterial();
const removeSupplier = useRemoveSupplier();
const topics = useAppStore((s) => s.topics);
const topic = useMemo(() => topicFromParam(topicParam, topics), [topicParam, topics]);
const [newCatName, setNewCatName] = useState("");
const cats = useMemo(
() => (topic ? topicCategories(topic, materials, suppliers) : []),
[topic, materials, suppliers],
);
const uncat = useMemo(
() =>
topic
? suppliers.filter((s) => normalize(s.topic) === normalize(topic) && !(s.categories || []).length)
: [],
[topic, suppliers],
);
if (!topic) return <Navigate to="/suppliers" replace />;
const addCategory = () => {
const name = newCatName.trim();
if (!name) return;
const dup = materials.some(
(m) => normalize(m.topic) === normalize(topic) && normalize(m.category) === normalize(name),
);
if (dup) {
toast.error("این متریال قبلاً ثبت شده است.");
return;
}
const m: Material = {
id: nextId("m", materials),
name,
topic,
category: name,
brand: "",
specs: [],
base_unit: "",
};
void addMaterial.mutateAsync(m);
setNewCatName("");
toast.success("متریال افزوده شد.");
};
const removeSup = (id: string) => {
const s = suppliers.find((x) => x.id === id);
if (!s) return;
if (confirm(`تأمین‌کننده «${s.name}» حذف شود؟ پیشنهادهای قبلی او در دفتر باقی می‌مانند.`)) {
void removeSupplier.mutateAsync(id);
}
};
// متریال‌های خریداری‌شده از هر تأمین‌کننده
const supMats = useMemo(() => {
const map = new Map<string, string[]>();
quotes.forEach((q) => {
(q.line_items || []).forEach((li) => {
if (li.rejected) return;
const m = materials.find((x) => x.id === li.material_id);
if (!m) return;
const label = `${m.category}${m.brand ? " " + m.brand : ""}${m.specs.length ? " (" + m.specs.join("، ") + ")" : ""}`;
if (!map.has(q.supplier_id)) map.set(q.supplier_id, []);
const list = map.get(q.supplier_id)!;
if (!list.includes(label)) list.push(label);
});
});
return map;
}, [quotes, materials]);
return (
<div className="min-h-screen">
<Header title={topic} sub="یک متریال را انتخاب کنید" backTo="/suppliers" backLabel="موضوع‌ها" />
<PageLayout className="flex flex-col gap-4">
{cats.length ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{cats.map((c) => (
<button
key={c}
type="button"
className="topic-card"
onClick={() => navigate(`/topics/${encodeURIComponent(topic)}/${encodeURIComponent(c)}`)}
>
<div className="t-name" style={{ fontSize: 13 }}>
{c}
</div>
<div className="t-count">{faNum(suppliersOfCategory(topic, c, suppliers).length)} تأمین‌کننده</div>
</button>
))}
</div>
) : (
<EmptyState icon={FolderOpen} text="هنوز متریالی برای این موضوع ثبت نشده — از پایین یک متریال اضافه کنید." />
)}
{uncat.length > 0 && (
<div>
<h3 className="mb-2 text-[13.5px] font-bold">تأمین‌کننده‌های این موضوع</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{uncat.map((s) => (
<SupplierCard key={s.id} s={s} topic={topic} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
))}
</div>
</div>
)}
<div className="add-panel">
<h4>
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن متریال جدید
</h4>
<div className="grid max-w-md grid-cols-[1fr_auto] items-end gap-2">
<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={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addCategory();
}
}}
/>
</div>
<Button onClick={addCategory}>افزودن متریال</Button>
</div>
<div className="mt-2 text-[11px] text-muted">
بعد از افزودن، داخل همان متریال می‌توانید تأمین‌کننده اضافه یا حذف کنید.
</div>
</div>
</PageLayout>
</div>
);
}
@@ -1,8 +1,7 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { MapPin, Phone, User, Plus } from "lucide-react"; import { MapPin, Phone, User, Plus } from "lucide-react";
import type { Material, Supplier, Topic } from "../../lib/types"; import type { Material, Supplier } from "../../lib/types";
import { normalize } from "../../lib/normalize"; import { normalize } from "../../lib/normalize";
import { topicByName } from "../../lib/topics";
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] { export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
const out = new Set<string>(); const out = new Set<string>();
materials materials
@@ -20,12 +19,6 @@ export function suppliersOfCategory(topic: string, cat: string, suppliers: Suppl
); );
} }
export function topicFromParam(param: string | null, topics: Topic[]): string | null {
if (!param) return null;
const t = topicByName(param, topics);
return t ? t.name : null;
}
// کارت تأمین‌کننده // کارت تأمین‌کننده
export function SupplierCard({ export function SupplierCard({
s, s,