Files
ERP/frontend/src/pages/Suppliers/SupplierDetailPage.tsx
T
ZCode b469238153 refactor(catalog): reduce the category taxonomy to a supplier label
Categories are kept only as the label each supplier carries (suppliers.category_id
+ the material_categories list it points at). Everything that treated category as
a shared key across materials, inventory, quotes and unit conversions is removed —
including two features that were never populated (0 rows on every volume).

Removed (backend):
- routers category_attributes, category_ref_units, unit_conversions + registration
- ?category_id= filters on /materials, /inventory and /quotes (the /suppliers one
  stays — it filters suppliers by their own label)
- GET /inventory/materials/options: required a category and had no caller
- the material-payload category derivation (expand_service.category_by_material,
  the inventory serializer lookup) and the material category on /pm/on-the-way;
  the wire keys stay, now always null, so the payload shape is unchanged
- startup DDL: DROP the empty category_attributes / category_reference_units /
  unit_conversions tables (idempotent) and their CREATE statements; seed.py
  truncate list updated

Removed (frontend):
- the category-keyed conversion path: refUnitForCategory / findConversion /
  refConverted, the categoryUnits + unitConversions store slices, api clients,
  hooks and the "≈ converted quantity" line on quote detail and project pages
  (it needed rows in those tables to render, and there were none)
- material.supplier_category and its read sites (the edit modal grew one
  yesterday to restore labels; the label lives on the supplier now)
- the inventory page's category chips, the row filter and the «دسته» column,
  plus the lookup's categories fetch and catName helper

Kept: supplier label + its list and picker, /suppliers?category_id=, the global
standard_conversions feature and the units page (material_units conversions).

Verified against a restore of the production dump: startup DDL drops exactly the
three tables, suppliers keep their label (3/3), material_categories still serves
its 8 rows, the removed endpoints 404, and materials/inventory/quotes/units/
standard-conversions all return 200 with quote line items intact.
2026-09-20 19:44:23 +00:00

212 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { MapPin, Phone, User, Plus, Package, X } from "lucide-react";
import { toast } from "sonner";
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
import { topicByName } from "../../lib/topics";
import { useAppStore } from "../../store";
import { api } from "../../lib/api";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { Badge } from "../../components/ui/badge";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
import { normalize } from "../../lib/normalize";
import { PurchaseHistory } from "./PurchaseHistory";
// صفحه‌ی مشخصات کامل تأمین‌کننده + سابقه خرید + مدیریت متریال‌های تأمین‌شده
export function SupplierDetailPage() {
useStoreData();
const { id = "" } = useParams();
const suppliers = useSuppliers();
const quotes = useQuotes();
const materials = useMaterials();
const topics = useAppStore((s) => s.topics);
const s = useMemo(() => suppliers.find((x) => x.id === id) || null, [suppliers, id]);
const [pendingRemove, setPendingRemove] = useState<string | null>(null);
// متریال‌های متصل واقعی (از اتصال تأمین‌کننده-متریال بک‌اند)
const [linked, setLinked] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!id) return;
let alive = true;
void api.supplierMaterials
.list(id)
.then((rows) => alive && setLinked(rows.map((r) => r.material_name).filter(Boolean)))
.catch(() => alive && setLinked([]));
return () => {
alive = false;
};
}, [id]);
// متریال‌های تأمین‌شده (نام متریال‌ها) برای پیشنهاد در کومبوکس
const materialOptions = useMemo(() => {
const set = new Set<string>();
materials.forEach((m) => m.name && set.add(m.name));
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials]);
// دسته‌ی این تأمین‌کننده — دسته فقط برچسبِ خودِ تأمین‌کننده است (v2.7)
const purchased = useMemo(() => [...(s?.categories || [])].sort((a, b) => a.localeCompare(b, "fa")), [s]);
if (!s) {
return (
<div className="min-h-screen">
<Header title="اطلاعات تماس تأمین‌کننده" backTo="/suppliers" backLabel="تأمین‌کننده‌ها" />
<PageLayout>
<EmptyState emoji="😕" text="تأمین‌کننده‌ای یافت نشد." />
</PageLayout>
</div>
);
}
const topic = topicByName(s.topic, topics);
const topicName = topic ? topic.name : s.topic;
const firstCat = (s.categories || [])[0] || "";
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
const saveCategories = (names: string[]) => {
// v2.6: اتصال واقعی تأمین‌کننده ↔ متریال (کاتالوگ) — با نام متریال
if (!id || busy) return;
setBusy(true);
void (async () => {
try {
for (const name of names) {
if (linked.some((x) => normalize(x) === normalize(name))) continue;
const mat = materials.find((m) => normalize(m.name) === normalize(name));
if (!mat) {
toast.error(`متریال «${name}» در کاتالوگ یافت نشد — ابتدا از ویزارد بسازید.`);
continue;
}
await api.supplierMaterials.create(id, mat.id);
}
const rows = await api.supplierMaterials.list(id);
setLinked(rows.map((r) => r.material_name).filter(Boolean));
toast.success("متریال‌ها ذخیره شدند.");
} catch (err) {
toast.error(err instanceof Error ? err.message : "ذخیره‌ی متریال‌ها ناموفق بود");
} finally {
setBusy(false);
}
})();
};
const removeCat = (name: string) => {
if (!id || busy) return;
setBusy(true);
void (async () => {
try {
const mat = materials.find((m) => normalize(m.name) === normalize(name));
if (mat) await api.supplierMaterials.remove(id, mat.id);
const rows = await api.supplierMaterials.list(id);
setLinked(rows.map((r) => r.material_name).filter(Boolean));
toast.success("متریال حذف شد.");
} catch (err) {
toast.error(err instanceof Error ? err.message : "حذف متریال ناموفق بود");
} finally {
setBusy(false);
}
})();
};
return (
<div className="min-h-screen">
<Header title="اطلاعات تماس تأمین‌کننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمین‌کننده‌ها" />
<PageLayout>
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
<div className="flex items-center gap-3">
<div className="text-xl font-extrabold">{s.name}</div>
<Badge variant="outline">{topicName}</Badge>
</div>
<div className="mt-3 flex flex-col gap-1.5 text-[13.5px]">
<span>
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
</span>
<span>
<Phone className="me-1.5 inline h-4 w-4 text-muted" /> <b>تلفن:</b> {s.phone || "—"}
</span>
<span>
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
</span>
</div>
<div className="mt-4 flex items-center gap-2">
<Link
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
>
<Plus className="h-3.5 w-3.5" />
پیشنهاد جدید
</Link>
{firstCat ? (
<Link to={`/suppliers/category/${encodeURIComponent(firstCat)}`} className="btn-line-file no-underline">
‹ بازگشت به دسته
</Link>
) : (
<Link to="/suppliers" className="btn-line-file no-underline">
‹ بازگشت به دسته‌بندی‌ها
</Link>
)}
</div>
</div>
{/* متریال‌های این تأمین‌کننده — افزودن/حذف + مشاهده‌ی همه */}
<div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
<h4 className="mb-1 text-[13.5px] font-bold">
<Package className="me-1 inline h-4 w-4 text-accent" /> متریال‌های تأمین‌شده
</h4>
<div className="mb-2 text-[11.5px] text-muted">
متریال‌هایی که این تأمین‌کننده عرضه می‌کند را اینجا مدیریت کنید.
</div>
{(linked || []).length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{linked.map((c) => (
<span key={c} className="spec-chip">
{c}
<button type="button" className="spec-chip-x" title="حذف متریال" onClick={() => setPendingRemove(c)}>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<MultiSelectCombobox
values={[]}
onChange={(cats) => saveCategories(cats)}
options={materialOptions}
placeholder="افزودن متریال..."
/>
{purchased.length > 0 && (
<div className="mt-3 border-t border-line pt-2">
<div className="mb-1 text-[11px] font-semibold text-muted">متریال‌های خریداری‌شده (از پیشنهادها):</div>
<div className="flex flex-wrap gap-1.5">
{purchased.map((c) => (
<span key={c} className="spec-chip">
{c}
</span>
))}
</div>
</div>
)}
</div>
{/* سابقه خرید از این تأمین‌کننده — همه‌ی پیشنهادها + فاکتور/پیش‌فاکتور */}
<div className="mt-4 max-w-4xl">
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
</div>
<ConfirmDeleteDialog
open={pendingRemove !== null}
itemName={pendingRemove || ""}
onConfirm={() => {
if (pendingRemove !== null) removeCat(pendingRemove);
setPendingRemove(null);
}}
onCancel={() => setPendingRemove(null)}
/>
</PageLayout>
</div>
);
}