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
+1 -8
View File
@@ -3,11 +3,4 @@ export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./use
export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers";
export { useMaterials, useAddMaterial } from "./useMaterials";
export { useProjects, useAddProject } from "./useProjects";
export {
useUnits,
useSaveRefUnit,
useSaveConversion,
useDeleteConversion,
useSaveStandardConversion,
useDeleteStandardConversion,
} from "./useUnits";
export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits";
+4 -40
View File
@@ -1,52 +1,16 @@
// ============================================================
// hooks واحدها — واحد مرجع، ضرایب دسته‌ای و ثابت
// hooks واحدها — تبدیل‌های ثابت جهانی (دسته‌ای‌ها حذف شدند)
// ============================================================
import { useMutation } from "@tanstack/react-query";
import type { CategoryUnit, StandardConversion, UnitConversion } from "../lib/types";
import type { StandardConversion } from "../lib/types";
import { api } from "../lib/api";
import { useAppStore } from "../store";
import { useInvalidateStore } from "./useStoreData";
export function useUnits(): {
categoryUnits: CategoryUnit[];
unitConversions: UnitConversion[];
standardConversions: StandardConversion[];
} {
const categoryUnits = useAppStore((s) => s.categoryUnits);
const unitConversions = useAppStore((s) => s.unitConversions);
export function useUnits(): { standardConversions: StandardConversion[] } {
const standardConversions = useAppStore((s) => s.standardConversions);
return { categoryUnits, unitConversions, standardConversions };
}
export function useSaveRefUnit() {
const setCategoryUnit = useAppStore((s) => s.setCategoryUnit);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (cu: CategoryUnit) => api.units.saveCategoryUnit(cu),
onMutate: (cu) => setCategoryUnit(cu),
onSettled: () => invalidate(),
});
}
export function useSaveConversion() {
const setConversion = useAppStore((s) => s.setConversion);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (c: UnitConversion) => api.units.saveConversion(c),
onMutate: (c) => setConversion(c),
onSettled: () => invalidate(),
});
}
export function useDeleteConversion() {
const deleteConversion = useAppStore((s) => s.deleteConversion);
const invalidate = useInvalidateStore();
return useMutation({
mutationFn: (id: string) => api.units.deleteConversion(id),
onMutate: (id) => deleteConversion(id),
onSettled: () => invalidate(),
});
return { standardConversions };
}
export function useSaveStandardConversion() {
+11 -116
View File
@@ -18,7 +18,6 @@ import { jalaliToGregorian, toAsciiDigits } from "./jalali";
import { useAuthStore } from "../store/auth";
import type {
Attachment,
CategoryUnit,
ConfirmedReceipt,
InventoryRow,
InventoryTransaction,
@@ -30,7 +29,6 @@ import type {
Store,
Supplier,
TransactionType,
UnitConversion,
Warehouse,
} from "./types";
@@ -252,10 +250,6 @@ async function patchData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
return res.data;
}
async function putData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
return res.data;
}
async function deleteData<T>(path: string): Promise<T> {
const res = await request<{ data: T }>(path, { method: "DELETE" });
return res.data;
@@ -322,7 +316,6 @@ interface RawSupplier {
phone?: string | null;
contact_person?: string | null;
tax_id?: string | null;
categories?: string[] | null; // متریال‌های تأمین‌شده
// v2.6: هر تأمین‌کننده دقیقاً یک دسته دارد (FK) — بک‌اند این را می‌فرستد
category_id?: string | null;
category?: { id: string; slug: string; name: string } | null;
@@ -402,21 +395,6 @@ interface RawQuote {
line_items?: RawLineItem[] | null;
termsheets?: RawTermsheet[] | null;
}
interface RawCategoryUnit {
id: string;
category_id: string;
ref_unit: string;
category?: RawCategory | null;
}
interface RawUnitConversion {
id: string;
category_id: string;
brand?: string | null;
from_unit: string;
to_unit: string;
factor: number;
category?: RawCategory | null;
}
interface RawStandardConversion {
id: string;
from_unit: string;
@@ -562,9 +540,6 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
topic:
raw.topic ||
(raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""),
// v2.7: دسته‌ی متریال در بک‌اند از نخستین تأمین‌کننده‌ی متصل می‌آید
// (متریالِ بدون تأمین‌کننده → خالی)
supplier_category: raw.category?.name || "",
brand: raw.brands?.[0]?.name || "",
// مشخصه‌ها از attribute های متریال خوانده می‌شوند (EAV)
specs: (raw.attributes || [])
@@ -645,15 +620,13 @@ function adaptQuote(raw: RawQuote): Quote {
}
function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier {
// v2.6/v2.7: هر تأمین‌کننده دقیقاً یک دسته دارد (FK) و بک‌اند آن را به شکل
// category برمی‌گرداند — همین اولویت اول است. categories قدیمی (ستون حذف‌شده)
// و دسته‌های استنتاج‌شده از پیشنهادها فقط پشتیبان‌اند.
// هر تأمین‌کننده دقیقاً یک دسته دارد (FK) و بک‌اند آن را به شکل category
// برمی‌گرداند — منبع اصلی همین است؛ پارامتر categories فقط پشتیبان است.
const own = raw.category?.name ? [raw.category.name] : [];
const legacy = Array.isArray(raw.categories) ? raw.categories.filter(Boolean) : [];
return {
id: raw.id,
topic,
categories: own.length ? own : legacy.length ? legacy : categories,
categories: own.length ? own : categories,
name: raw.name,
location: raw.location || "",
phone: raw.phone || "",
@@ -671,54 +644,29 @@ function deriveSupplierTopics(
materialsById: Map<string, Material>,
): Supplier[] {
const topics = new Map<string, Set<string>>();
const cats = new Map<string, Set<string>>();
for (const q of quotes) {
for (const li of q.line_items) {
const m = materialsById.get(li.material_id);
if (!m) continue;
if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set());
if (!cats.has(q.supplier_id)) cats.set(q.supplier_id, new Set());
if (m.topic) topics.get(q.supplier_id)!.add(m.topic);
if (m.supplier_category) cats.get(q.supplier_id)!.add(m.supplier_category);
}
}
return rawSuppliers.map((s) => {
const t = topics.get(s.id);
const c = cats.get(s.id);
const derivedTopic = t && t.size ? [...t][0] : "";
const derivedCats = c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : [];
// اگر از quotes چیزی پیدا نشد، registry را چک کن
if (!derivedTopic && !derivedCats.length) {
if (!derivedTopic) {
const reg = supplierTopicRegistry.get(s.id);
if (reg) {
return adaptSupplier(s, reg.topic, reg.categories);
}
if (reg) return adaptSupplier(s, reg.topic, reg.categories);
}
// v2.7: متریال‌ها از دسته جدا شدند، پس استنتاج از اقلام پیشنهاد معمولاً
// دسته‌ای نمی‌دهد و صفحه‌ی تأمین‌کننده‌ها بی‌نام می‌شد. دستهٔ ثبت‌شده روی
// خودِ تأمین‌کننده (FK بک‌اند) منبع اصلی است؛ نامش با نام تاپیک‌ها یکی است.
// دستهٔ ثبت‌شده روی خودِ تأمین‌کننده (FK بک‌اند) منبع اصلی است و نامش با نام
// تاپیک‌ها یکی است؛ تاپیک از اقلام پیشنهاد استنتاج می‌شود.
const backendCat = s.category?.name || "";
return adaptSupplier(
s,
derivedTopic || backendCat,
derivedCats.length ? derivedCats : backendCat ? [backendCat] : [],
);
return adaptSupplier(s, derivedTopic || backendCat, backendCat ? [backendCat] : []);
});
}
function adaptCategoryUnit(raw: RawCategoryUnit): CategoryUnit {
return { id: raw.id, category: raw.category?.name || "", ref_unit: raw.ref_unit };
}
function adaptUnitConversion(raw: RawUnitConversion, lookup: Lookup): UnitConversion {
return {
id: raw.id,
category: raw.category?.name || lookup.categoryIdToName.get(raw.category_id) || "",
brand: raw.brand || "",
from_unit: raw.from_unit,
to_unit: raw.to_unit,
factor: raw.factor,
};
}
function adaptStandardConversion(raw: RawStandardConversion): StandardConversion {
return { id: raw.id, from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor };
}
@@ -836,16 +784,14 @@ function registerSupplierTopic(id: string, topic: string, categories: string[]):
export const api = {
// ---------- کل store (یک بار در ریشه) ----------
async listAll(): Promise<Store> {
const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, cruRes, ucRes, scuRes] =
const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, scuRes] =
await Promise.all([
getData<RawQuote[]>(`/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`),
getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`),
getData<RawMaterial[]>(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`),
getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`),
getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`),
getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`),
getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`),
getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`),
getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`),
getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`),
]);
@@ -860,8 +806,6 @@ export const api = {
materials,
projects: projectsRes.map((p) => ({ id: p.id, name: p.name })),
quotes,
categoryUnits: cruRes.map(adaptCategoryUnit),
unitConversions: ucRes.map((uc) => adaptUnitConversion(uc, lookup)),
standardConversions: scuRes.map(adaptStandardConversion),
topics: buildTopics(categoriesRes),
};
@@ -1136,7 +1080,7 @@ export const api = {
materials: {
list: async (): Promise<Material[]> => {
const raw = await getData<RawMaterial[]>(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`);
const raw = await getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`);
const units = await getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`);
const categories = await getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`);
return raw.map((m) => adaptMaterial(m, buildLookup(units, categories)));
@@ -1196,46 +1140,6 @@ export const api = {
},
units: {
getCategoryUnits: async (): Promise<CategoryUnit[]> => {
const raw = await getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`);
return raw.map(adaptCategoryUnit);
},
saveCategoryUnit: async (cu: CategoryUnit): Promise<void> => {
const lookup = await fetchLookup();
const categoryId = lookup.categoryNameToId.get(norm(cu.category));
if (!categoryId) throw new Error(`دسته‌بندی «${cu.category}» یافت نشد`);
await putData(`/category-reference-units/${categoryId}`, { ref_unit: cu.ref_unit });
},
getConversions: async (): Promise<UnitConversion[]> => {
const lookup = await fetchLookup();
const raw = await getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`);
return raw.map((uc) => adaptUnitConversion(uc, lookup));
},
saveConversion: async (c: UnitConversion): Promise<void> => {
const lookup = await fetchLookup();
const categoryId = lookup.categoryNameToId.get(norm(c.category));
if (!categoryId) throw new Error(`دسته‌بندی «${c.category}» یافت نشد`);
const body = {
category_id: categoryId,
brand: c.brand || null,
from_unit: c.from_unit,
to_unit: c.to_unit,
factor: c.factor,
};
if (looksLikeUuid(c.id)) {
await patchData(`/unit-conversions/${c.id}`, {
brand: c.brand || null,
from_unit: c.from_unit,
to_unit: c.to_unit,
factor: c.factor,
});
} else {
await postData("/unit-conversions", body);
}
},
deleteConversion: async (id: string): Promise<void> => {
await deleteData(`/unit-conversions/${id}`);
},
getStandardConversions: async (): Promise<StandardConversion[]> => {
const raw = await getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`);
return raw.map(adaptStandardConversion);
@@ -1781,15 +1685,6 @@ export const apiV26 = {
types: async (): Promise<{ type: TransactionType; label: string }[]> => {
return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types");
},
materialOptions: async (category_id: string, warehouse_id?: string): Promise<{ id: string; name: string; brands: { id: string; name: string }[] }[]> => {
const params: Record<string, string> = { category_id };
if (warehouse_id) params.warehouse_id = warehouse_id;
const pid = activeProjectId();
if (pid) params.project_id = pid;
return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>(
`/inventory/materials/options?${new URLSearchParams(params).toString()}`,
);
},
},
pm: {
+7 -60
View File
@@ -2,7 +2,7 @@
// منطق بیزنس مشترک پیشنهادها — پورت از app.js
// ============================================================
import type { LineItem, Material, Quote, StandardConversion, Store, Supplier, UnitConversion } from "./types";
import type { LineItem, Material, Quote, Store, Supplier } from "./types";
import { normalize } from "./normalize";
import { groupCommas } from "./numutil";
import { fmtDate } from "./formatters";
@@ -17,7 +17,6 @@ export function materialById(materials: Material[], id: string): Material {
id: "",
name: "",
topic: "",
supplier_category: "",
brand: "",
specs: [],
base_unit: "",
@@ -102,15 +101,14 @@ export function materialLabel(m: Pick<Material, "name" | "brand" | "specs"> | nu
// نام پایه‌ی متریال از نامِ ذخیره‌شده.
// متریال‌های جدید فقط نامِ خودِ متریال را در name دارند (برند در brand و
// مشخصه‌ها در attributes ذخیره می‌شوند) و دست‌نخورده برمی‌گردند. رکوردهای قدیمی
// name را به شکل «برند + دسته + مشخصه» ذخیره کرده‌اند؛ آن قطعات جدا می‌شوند تا
// نامِ پایه باقی بماند. دسته‌ی همان رکوردهای قدیمی همان دسته‌ی نخستین
// تأمین‌کننده است (supplier_category).
export function materialBaseName(name: string, brand: string, specs: string[], category = ""): string {
// name را به شکل «برند + مشخصه» ذخیره کرده‌اند؛ آن قطعات جدا می‌شوند تا نامِ
// پایه باقی بماند.
export function materialBaseName(name: string, brand: string, specs: string[]): string {
const fallback = (name || "").trim();
if (!fallback) return "";
let base = fallback;
// بلندترین قطعه اول حذف می‌شود تا حذف‌های جزئی، رشته را ناقص نکنند
const parts = [brand, category, ...specs]
const parts = [brand, ...specs]
.map((p) => (p || "").trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length);
@@ -151,7 +149,7 @@ export function matchMaterial(
materials.find(
(m) =>
base &&
normalize(materialBaseName(m.name, m.brand, m.specs, m.supplier_category)) === base &&
normalize(materialBaseName(m.name, m.brand, m.specs)) === base &&
sameIdentity(m),
)
);
@@ -216,57 +214,6 @@ export function itemProjects(q: Quote, li: LineItem): string[] {
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean);
}
// ---------- واحد مرجع و ضریب تبدیل ----------
// واحد مرجع یک دسته
export function refUnitForCategory(store: Pick<Store, "categoryUnits">, category: string): string {
const cu = (store.categoryUnits || []).find((c) => normalize(c.category) === normalize(category));
return cu ? cu.ref_unit : "";
}
// ضریب تبدیل با اولویت: دسته‌ای (برند مشخص > همه برندها) > تبدیل ثابت جهانی
export function findConversion(
store: Pick<Store, "unitConversions" | "standardConversions">,
category: string,
brand: string,
fromUnit: string,
toUnit: string,
): UnitConversion | StandardConversion | null {
const list = (store.unitConversions || []).filter(
(c) =>
normalize(c.category) === normalize(category) &&
normalize(c.from_unit) === normalize(fromUnit) &&
normalize(c.to_unit) === normalize(toUnit) &&
(!c.brand || normalize(c.brand) === normalize(brand)),
);
list.sort((a, b) => (b.brand ? 1 : 0) - (a.brand ? 1 : 0));
if (list[0]) return list[0];
return (
(store.standardConversions || []).find(
(c) => normalize(c.from_unit) === normalize(fromUnit) && normalize(c.to_unit) === normalize(toUnit),
) || null
);
}
// تبدیل یک قلم به واحد مرجع دسته؛ null وقتی ضریبی نیست یا واحد از قبل مرجع است
export function refConverted(
store: Pick<Store, "categoryUnits" | "unitConversions" | "standardConversions" | "materials">,
li: LineItem,
): { qty: number; unit: string; perRef: number | null } | null {
const m = materialById(store.materials, li.material_id);
// categoryUnits/unitConversions با «نام دسته» کلید خورده‌اند؛ دسته‌ی متریال
// همان دسته‌ی نخستین تأمین‌کننده است (v2.7). متریال بدون تأمین‌کننده دسته
// ندارد و تبدیلش انجام نمی‌شود.
const ref = refUnitForCategory(store, m.supplier_category);
if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null;
const conv = findConversion(store, m.supplier_category, m.brand, li.unit, ref);
if (!conv) return null;
const qty = (li.quantity || 0) * conv.factor;
const total = li.total_price != null ? li.total_price : (li.unit_price || 0) * (li.quantity || 0);
const perRef = qty > 0 ? Math.round(total / qty) : null;
return { qty, unit: ref, perRef };
}
// واحدهای ناشناخته‌ی استفاده‌شده در یک quote → لینک به داشبورد واحدها
// واحدها دیگر hardcode نیستند — همه از بک‌اند می‌آیند؛ هشداری نمایش داده نمی‌شود
export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] {
@@ -364,7 +311,7 @@ export function filterQuotes(
if (q) {
const hay =
`${supObj.name} ${quote.project || ""} ` +
matInfo.map((m) => `${m.supplier_category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
matInfo.map((m) => `${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
if (!hay.toLowerCase().includes(q)) return false;
}
return true;
-23
View File
@@ -22,10 +22,6 @@ export interface Material {
id: string; // "m1", "m2", ...
name: string; // نامِ خودِ متریال — برند در brand و مشخصه‌ها در specs
topic: string;
// دسته‌ی نمایشی — از نخستین تأمین‌کننده‌ی متصل به متریال می‌آید (v2.7:
// خودِ متریال دیگر دسته ندارد و دسته روی تأمین‌کننده است). متریال بدون
// تأمین‌کننده = رشته‌ی خالی. این مقدار کلیدِ تبدیل‌های واحد هم هست.
supplier_category: string;
brand: string; // مثلاً "ذوب آهن"
specs: string[]; // آرایه‌ای از مشخصه‌ها (مثلاً ["A2"])
base_unit: string; // واحد پایه
@@ -100,23 +96,6 @@ export interface Quote {
termsheets: Termsheet[];
}
// واحد مرجع هر دسته
export interface CategoryUnit {
id: string;
category: string;
ref_unit: string;
}
// ضریب تبدیل (دسته‌ای)
export interface UnitConversion {
id: string;
category: string;
brand: string; // خالی = همه برندها
from_unit: string;
to_unit: string;
factor: number; // 1 from_unit = factor * to_unit
}
// ضریب تبدیل استاندارد (جهانی)
export interface StandardConversion {
id: string;
@@ -131,8 +110,6 @@ export interface Store {
materials: Material[];
projects: Project[];
quotes: Quote[];
categoryUnits: CategoryUnit[];
unitConversions: UnitConversion[];
standardConversions: StandardConversion[];
topics: Topic[]; // دسته‌بندی‌های ۸گانه از /material-categories
}
@@ -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) {
-29
View File
@@ -7,7 +7,6 @@
import { create } from "zustand";
import type {
CategoryUnit,
Material,
Project,
Quote,
@@ -15,7 +14,6 @@ import type {
Store,
Supplier,
Topic,
UnitConversion,
} from "../lib/types";
interface AppStore {
@@ -24,8 +22,6 @@ interface AppStore {
materials: Material[];
projects: Project[];
quotes: Quote[];
categoryUnits: CategoryUnit[];
unitConversions: UnitConversion[];
standardConversions: StandardConversion[];
topics: Topic[]; // دسته‌بندی‌های ۸گانه — از /material-categories
@@ -45,9 +41,6 @@ interface AppStore {
deleteSupplier: (id: string) => void;
addMaterial: (m: Material) => void;
addProject: (p: Project) => void;
setCategoryUnit: (cu: CategoryUnit) => void;
setConversion: (c: UnitConversion) => void;
deleteConversion: (id: string) => void;
setStandardConversion: (sc: StandardConversion) => void;
deleteStandardConversion: (id: string) => void;
@@ -62,8 +55,6 @@ export const useAppStore = create<AppStore>()((set) => ({
materials: [],
projects: [],
quotes: [],
categoryUnits: [],
unitConversions: [],
standardConversions: [],
topics: [],
@@ -77,8 +68,6 @@ export const useAppStore = create<AppStore>()((set) => ({
materials: store.materials,
projects: store.projects,
quotes: store.quotes,
categoryUnits: store.categoryUnits,
unitConversions: store.unitConversions,
standardConversions: store.standardConversions,
topics: store.topics,
}),
@@ -96,24 +85,6 @@ export const useAppStore = create<AppStore>()((set) => ({
deleteSupplier: (id) => set((s) => ({ suppliers: s.suppliers.filter((x) => x.id !== id) })),
addMaterial: (m) => set((s) => ({ materials: [...s.materials, m] })),
addProject: (p) => set((s) => ({ projects: [...s.projects, p] })),
setCategoryUnit: (cu) =>
set((s) => {
const idx = s.categoryUnits.findIndex((x) => x.id === cu.id);
const categoryUnits = [...s.categoryUnits];
if (idx >= 0) categoryUnits[idx] = cu;
else categoryUnits.push(cu);
return { categoryUnits };
}),
setConversion: (c) =>
set((s) => {
const idx = s.unitConversions.findIndex((x) => x.id === c.id);
const unitConversions = [...s.unitConversions];
if (idx >= 0) unitConversions[idx] = c;
else unitConversions.push(c);
return { unitConversions };
}),
deleteConversion: (id) =>
set((s) => ({ unitConversions: s.unitConversions.filter((x) => x.id !== id) })),
setStandardConversion: (sc) =>
set((s) => {
const idx = s.standardConversions.findIndex((x) => x.id === sc.id);