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
+17 -34
View File
@@ -6,18 +6,18 @@ import type { Supplier } from "../../lib/types";
import { nextId } from "../../lib/api";
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
import { useAppStore } from "../../store";
import { normalize } from "../../lib/normalize";
import { topicByName } from "../../lib/topics";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { Combobox } from "../../components/Combobox";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { SupplierCard, suppliersOfCategory, topicCategories, topicFromParam } from "./supplierUtils";
import { SupplierCard, suppliersOfCategory } from "./supplierUtils";
import { Button } from "../../components/ui/button";
// صفحه‌ی تأمین‌کننده‌های یک متریال + فرم افزودن تأمین‌کننده
// صفحه‌ی تأمین‌کننده‌های یک دسته + فرم افزودن تأمین‌کننده
// (سلسله‌مراتب دامنه: دسته → تأمین‌کننده؛ در v2.6 دسته = تاپیک)
export function CategoryPage() {
useStoreData();
const { topic: topicParam = "", category: catParam = "" } = useParams();
const { category: catParam = "" } = useParams();
const materials = useMaterials();
const suppliers = useSuppliers();
@@ -26,15 +26,10 @@ export function CategoryPage() {
const removeSupplier = useRemoveSupplier();
const topics = useAppStore((s) => s.topics);
const topic = useMemo(() => topicFromParam(topicParam, topics), [topicParam, topics]);
const matched = useMemo(
() => (topic ? topicCategories(topic, materials, suppliers).find((c) => normalize(c) === normalize(catParam)) : undefined),
[topic, catParam, materials, suppliers],
);
const cat = useMemo(() => topicByName(catParam, topics)?.name ?? null, [catParam, topics]);
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: "",
categories: [],
location: "",
phone: "",
contact: "",
@@ -46,13 +41,11 @@ export function CategoryPage() {
[suppliers],
);
if (!topic || !matched) {
return topic ? <Navigate to={`/topics/${encodeURIComponent(topic)}`} replace /> : <Navigate to="/suppliers" replace />;
}
if (!cat) return <Navigate to="/suppliers" replace />;
const sups = suppliersOfCategory(topic, matched, suppliers);
const sups = suppliersOfCategory(cat, cat, suppliers);
// متریال‌های خریداری‌شده از هر تأمین‌کننده
// متریال‌های خریداری‌شده از هر تأمین‌کننده (برای نمایش روی کارت)
const supMats = useMemo(() => {
const map = new Map<string, string[]>();
quotes.forEach((q) => {
@@ -75,19 +68,18 @@ export function CategoryPage() {
toast.error("نام تأمین‌کننده را وارد کنید.");
return;
}
// اگر متریالی انتخاب نشده، تأمین‌کننده به همین متریالِ صفحه اضافه می‌شود
const cats = form.categories.length ? form.categories : [matched];
// تأمین‌کننده دقیقاً زیر همین دسته ثبت می‌شود (۱ دسته به ازای هر تأمین‌کننده)
const s: Supplier = {
id: nextId("s", suppliers),
topic,
categories: cats,
topic: cat,
categories: [cat],
name,
location: form.location.trim(),
phone: form.phone.trim(),
contact: form.contact.trim(),
};
void addSupplier.mutateAsync(s);
setForm({ name: "", categories: [], location: "", phone: "", contact: "" });
setForm({ name: "", location: "", phone: "", contact: "" });
toast.success("تأمین‌کننده افزوده شد.");
};
@@ -101,14 +93,14 @@ export function CategoryPage() {
return (
<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">
{!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">
{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>
)}
@@ -130,15 +122,6 @@ export function CategoryPage() {
onInputChange={(v) => setForm((f) => ({ ...f, name: v }))}
/>
</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">
<label>موقعیت (شهر - منطقه)</label>
<input