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.
This commit is contained in:
ZCode
2026-09-20 19:44:23 +00:00
parent 2fd72fecf7
commit b469238153
29 changed files with 73 additions and 947 deletions
@@ -28,7 +28,7 @@ export function DashboardPage() {
const suppliers = useSuppliers();
const materials = useMaterials();
const projects = useProjects();
const { categoryUnits, unitConversions, standardConversions } = useUnits();
const { standardConversions } = useUnits();
const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds);
const toggleExpand = useAppStore((s) => s.toggleExpand);
@@ -40,8 +40,8 @@ export function DashboardPage() {
const [editId, setEditId] = useState<string | null>(null);
const store = useMemo(
() => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics }),
[quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics],
() => ({ quotes, suppliers, materials, projects, standardConversions, topics }),
[quotes, suppliers, materials, projects, standardConversions, topics],
);
// فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at)
@@ -308,7 +308,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
brand,
specs,
base_unit: unit,
supplier_category: "",
};
materialsCopy.push(nm);
const created = await addMaterial.mutateAsync(nm);
+1 -9
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Package, Scale, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react";
import { Package, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react";
import type { Attachment, Quote, Store } from "../../lib/types";
import { api } from "../../lib/api";
import {
@@ -7,7 +7,6 @@ import {
itemTermsheetIds,
lineItemTotal,
materialById,
refConverted,
} from "../../lib/quoteUtils";
import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters";
import { groupCommas } from "../../lib/numutil";
@@ -76,7 +75,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
const tsIds = itemTermsheetIds(li).filter((tid) => tsList.some((t) => t.id === tid));
const total = lineItemTotal(li);
const projs = itemProjects(q, li);
const conv = isRejected ? null : refConverted(store, li);
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته می‌شود (نمایشی):
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
@@ -140,12 +138,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
{whNames.length > 0 && <span>به انبار: {whNames.join("، ")}</span>}
</div>
)}
{conv && (
<div className="fb-conv">
<Scale className="me-1 inline h-3 w-3" /> ≈ {groupCommas(conv.qty, true)} {conv.unit}
{conv.perRef != null ? ` · ${groupCommas(conv.perRef, true)} تومان/${conv.unit}` : ""}
</div>
)}
</div>
);
};
@@ -220,7 +220,7 @@ export function ChangeInventoryPage() {
let m = matByName(matName);
if (!m) {
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
m = { id: created.id, name: created.name, category: null, canonical_unit: null };
m = { id: created.id, name: created.name, canonical_unit: null };
}
const op: Record<string, unknown> = {
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
+5 -54
View File
@@ -7,7 +7,7 @@ import { useAuthStore } from "../../store/auth";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { inventorySubheader } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { useInventoryData, brandName, catName, materialName, unitName, whName } from "./useInventoryData";
import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData";
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
@@ -55,8 +55,7 @@ export function InventoryPage() {
const isPM = user?.role === "pm";
const [params] = useSearchParams();
const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({
category: "",
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
warehouse: params.get("warehouse") || "",
material: "",
project: "",
@@ -81,7 +80,6 @@ export function InventoryPage() {
inventory.forEach((r) => {
const whId = r.warehouse?.id;
if (!whId || !visibleIds.has(whId)) return;
if (filters.category && r.material.category?.id !== filters.category) return;
if (filters.warehouse && whId !== filters.warehouse) return;
if (filters.material && !r.material.name.includes(filters.material.trim())) return;
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
@@ -132,7 +130,7 @@ export function InventoryPage() {
.slice()
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
const hasFilter = !!(filters.category || filters.warehouse || (isAdmin && filters.project));
const hasFilter = !!(filters.warehouse || (isAdmin && filters.project));
if (loading) return <div className="min-h-screen" />;
if (!user || (!isAdmin && !isPM)) {
@@ -248,40 +246,6 @@ export function InventoryPage() {
{/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */}
{showMobileFilters && (
<div className="mt-2.5 border-t border-line/60 pt-2.5 flex flex-col gap-2.5">
{/* دسته‌بندی‌ها */}
<div>
<div className="mb-1.5 text-[11px] font-bold text-muted">دسته‌بندی متریال:</div>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => setFilters((f) => ({ ...f, category: "" }))}
className={cn(
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
!filters.category
? "bg-accent text-white font-bold"
: "border border-line bg-bg/40 text-ink hover:bg-panel"
)}
>
همه دسته‌بندی‌ها
</button>
{lookup.categories.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setFilters((f) => ({ ...f, category: f.category === c.id ? "" : c.id }))}
className={cn(
"rounded-lg px-2.5 py-1 text-[11.5px] transition-colors",
filters.category === c.id
? "bg-accent text-white font-bold"
: "border border-line bg-bg/40 text-ink hover:bg-panel"
)}
>
{c.name}
</button>
))}
</div>
</div>
{/* پروژه‌ها (مخصوص ادمین) */}
{isAdmin && lookup.projects.length > 0 && (
<div>
@@ -324,7 +288,7 @@ export function InventoryPage() {
<button
type="button"
className="text-xs text-red font-medium hover:underline inline-flex items-center gap-1"
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
onClick={() => setFilters({ warehouse: "", material: "", project: "" })}
>
<X className="h-3 w-3" />
پاک کردن فیلترها
@@ -347,7 +311,6 @@ export function InventoryPage() {
) : (
groups.map((g) => {
const isExp = expandedKey === g.key;
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
return (
<InventoryMobileCard
key={g.key}
@@ -355,7 +318,6 @@ export function InventoryPage() {
isExp={isExp}
isAdmin={isAdmin}
matName={materialName(lookup.materials, g.matId)}
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
wh={whName(warehouses, g.whId)}
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
unit={unitName(lookup.units, g.unitId)}
@@ -410,7 +372,6 @@ export function InventoryPage() {
<tbody>
{groups.map((g) => {
const isExp = expandedKey === g.key;
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
return (
<GroupRow
key={g.key}
@@ -418,7 +379,6 @@ export function InventoryPage() {
isExp={isExp}
isAdmin={isAdmin}
matName={materialName(lookup.materials, g.matId)}
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
wh={whName(warehouses, g.whId)}
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
unit={unitName(lookup.units, g.unitId)}
@@ -475,7 +435,6 @@ function InventoryMobileCard({
isExp,
isAdmin,
matName,
matCatName,
wh,
projName,
unit,
@@ -486,7 +445,6 @@ function InventoryMobileCard({
isExp: boolean;
isAdmin: boolean;
matName: string;
matCatName: string;
wh: string;
projName: string;
unit: string;
@@ -508,11 +466,6 @@ function InventoryMobileCard({
<h3 className="text-[13.5px] font-bold leading-snug text-ink break-words">
{matName}
</h3>
{matCatName !== "—" && (
<span className="mt-0.5 inline-block text-[11px] text-muted">
{matCatName}
</span>
)}
</div>
<div
className={cn(
@@ -574,12 +527,11 @@ function InventoryMobileCard({
);
}
function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, onToggle, timeline }: {
function GroupRow({ g, isExp, isAdmin, matName, wh, projName, unit, onToggle, timeline }: {
g: Group;
isExp: boolean;
isAdmin: boolean;
matName: string;
matCatName: string;
wh: string;
projName: string;
unit: string;
@@ -591,7 +543,6 @@ function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit,
<tr className={`border-b border-line/60 cursor-pointer transition-colors ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}>
<td className="py-2.5 text-center text-muted">{isExp ? "▾" : "▸"}</td>
<td className="py-2.5 pe-3 font-bold text-ink">{matName}</td>
<td className="py-2.5 pe-3 text-muted">{matCatName}</td>
<td className="py-2.5 pe-3">
<div>{wh}</div>
{isAdmin && <span className="block text-[10.5px] text-muted">{projName}</span>}
@@ -9,10 +9,9 @@ import { useDataChanged } from "../../lib/crossTab";
import type { InventoryRow, InventoryTransaction, Warehouse } from "../../lib/types";
export interface LookupData {
categories: { id: string; slug: string; name: string }[];
units: { id: number; code: string; name_fa: string | null }[];
brands: { id: string; name: string }[];
materials: { id: string; name: string; category: { id: string; name: string } | null; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[];
materials: { id: string; name: string; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[];
projects: { id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[];
}
@@ -34,14 +33,13 @@ export function useInventoryData(): InventoryData {
const [inventory, setInventory] = useState<InventoryRow[]>([]);
const [transactions, setTransactions] = useState<InventoryTransaction[]>([]);
const [lookup, setLookup] = useState<LookupData>({
categories: [], units: [], brands: [], materials: [], projects: [],
units: [], brands: [], materials: [], projects: [],
});
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const [cats, units, brands, mats, projs, whs, inv, txs] = await Promise.all([
apiV26.lookup.categories(),
const [units, brands, mats, projs, whs, inv, txs] = await Promise.all([
apiV26.lookup.units(),
apiV26.lookup.brands(),
apiV26.lookup.materials(),
@@ -50,7 +48,7 @@ export function useInventoryData(): InventoryData {
apiV26.inventory.list({ project_id: projectId }),
apiV26.inventory.transactions({ project_id: projectId }),
]);
setLookup({ categories: cats, units, brands, materials: mats, projects: projs });
setLookup({ units, brands, materials: mats, projects: projs });
setWarehouses(whs);
setInventory(inv);
setTransactions(txs);
@@ -108,11 +106,6 @@ export function materialName(materials: LookupData["materials"], id: string): st
return m ? m.name : "—";
}
export function catName(categories: LookupData["categories"], id: string | null | undefined): string {
const c = categories.find((x) => x.id === id);
return c ? c.name : "—";
}
export function projName(projects: LookupData["projects"], id: string | null | undefined): string {
const p = projects.find((x) => x.id === id);
return p ? p.name : "—";
+2 -10
View File
@@ -19,7 +19,6 @@ import {
lineItemTotal,
materialById,
materialLabel,
refConverted,
supplierById,
} from "../../lib/quoteUtils";
import { faNum, fmtDate } from "../../lib/formatters";
@@ -81,11 +80,11 @@ export function ProjectsPage() {
const quotes = useQuotes();
const suppliers = useSuppliers();
const materials = useMaterials();
const { categoryUnits, unitConversions, standardConversions } = useUnits();
const { standardConversions } = useUnits();
const addProject = useAddProject();
const topics = useAppStore((s) => s.topics);
const store: Store = { quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics };
const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics };
const [openProject, setOpenProject] = useState<string | null>(null);
const [newName, setNewName] = useState("");
@@ -189,17 +188,10 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
const itemLine = (li: Quote["line_items"][number]) => {
const total = lineItemTotal(li);
const conv = refConverted(store, li);
return (
<div key={li.id}>
{matName(li.material_id)} — {faNum(li.quantity)} {li.unit} × {faNum(li.unit_price || 0)} ={" "}
<b>{faNum(total || 0)} تومان</b>
{conv && (
<span className="pj-conv">
{" "}
— <span className="pj-conv">≈ {faNum(conv.qty)} {conv.unit}({faNum(conv.perRef ?? 0)} تومان/{conv.unit})</span>
</span>
)}
</div>
);
};
@@ -48,21 +48,8 @@ export function SupplierDetailPage() {
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials]);
// متریال‌های خریداری‌شده از این تأمین‌کننده (از پیشنهادها)
const purchased = useMemo(() => {
const out = new Set<string>();
quotes.forEach((q) => {
if (q.supplier_id !== id) return;
(q.line_items || []).forEach((li) => {
if (li.rejected) return;
const m = materials.find((x) => x.id === li.material_id);
// دسته‌ی نمایشی متریال = دسته‌ی نخستین تأمین‌کننده‌ی متصل (v2.7)
if (!m || !m.supplier_category) return;
out.add(m.supplier_category);
});
});
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
}, [quotes, materials, id]);
// دسته‌ی این تأمین‌کننده — دسته فقط برچسبِ خودِ تأمین‌کننده است (v2.7)
const purchased = useMemo(() => [...(s?.categories || [])].sort((a, b) => a.localeCompare(b, "fa")), [s]);
if (!s) {
return (
+2 -2
View File
@@ -77,7 +77,7 @@ export function materialOptions(
// هم‌نام پیشنهاد می‌شوند.
if (cat)
list = list.filter(
(m) => normalize(materialBaseName(m.name, m.brand, m.specs, m.supplier_category)) === normalize(cat),
(m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat),
);
// مشخصه‌ها سراسری هستند: رجیستری attribute-definitions + مشخصه‌های همه‌ی
// متریال‌ها + مشخصه‌های تایپ‌شده در سایر ردیف‌ها. به تاپیک/دسته/برندِ قلمِ
@@ -109,7 +109,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
const allMaterialNames = useMemo(() => {
const set = new Set<string>();
materials.forEach((m) => {
const base = materialBaseName(m.name, m.brand, m.specs, m.supplier_category);
const base = materialBaseName(m.name, m.brand, m.specs);
if (base) set.add(base);
});
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
-1
View File
@@ -317,7 +317,6 @@ export function WizardPage() {
brand,
specs,
base_unit: unit || "عدد",
supplier_category: "",
});
materialId = created.id;
} catch (err) {