diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index fe82041..9913b57 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -96,13 +96,11 @@ async def dashboard_scope( def _what_filters( - category_id: uuid.UUID | None, material_id: uuid.UUID | None, brand_id: uuid.UUID | None, specs_hash: str | None, ) -> dict: return { - "category_id": category_id, "material_id": material_id, "brand_id": brand_id, "specs_hash": specs_hash, @@ -111,7 +109,7 @@ def _what_filters( @router.get("/filters") async def dashboard_filters(db: AsyncSession = Depends(get_db)): - """Cascading options for the global filter bar (Category→Material→Brand→Specs).""" + """Options for the global filter bar (Material→Brand→Specs).""" return await dashboard_service.filter_options(db) @@ -119,7 +117,6 @@ async def dashboard_filters(db: AsyncSession = Depends(get_db)): async def inventory_drilldown( scope: DashboardScope = Depends(dashboard_scope), db: AsyncSession = Depends(get_db), - category_id: uuid.UUID | None = Query(default=None), material_id: uuid.UUID | None = Query(default=None), brand_id: uuid.UUID | None = Query(default=None), specs_hash: str | None = Query(default=None), @@ -129,7 +126,7 @@ async def inventory_drilldown( db, project_ids=scope.project_ids, project_id=scope.project_id, - **_what_filters(category_id, material_id, brand_id, specs_hash), + **_what_filters(material_id, brand_id, specs_hash), ) @@ -139,7 +136,6 @@ async def dead_stock( db: AsyncSession = Depends(get_db), idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"), limit: int = Query(default=200, ge=1, le=1000), - category_id: uuid.UUID | None = Query(default=None), material_id: uuid.UUID | None = Query(default=None), brand_id: uuid.UUID | None = Query(default=None), specs_hash: str | None = Query(default=None), @@ -151,7 +147,7 @@ async def dead_stock( project_id=scope.project_id, idle_days=idle_days, limit=limit, - **_what_filters(category_id, material_id, brand_id, specs_hash), + **_what_filters(material_id, brand_id, specs_hash), ) @@ -162,7 +158,6 @@ async def inventory_flow( date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیش‌فرض ۳۰ روز اخیر)"), date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"), warehouse_id: uuid.UUID | None = Query(default=None), - category_id: uuid.UUID | None = Query(default=None), material_id: uuid.UUID | None = Query(default=None), brand_id: uuid.UUID | None = Query(default=None), specs_hash: str | None = Query(default=None), @@ -182,7 +177,7 @@ async def inventory_flow( date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN), date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN), warehouse_id=warehouse_id, - **_what_filters(category_id, material_id, brand_id, specs_hash), + **_what_filters(material_id, brand_id, specs_hash), ) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index b8ded27..3b8f4b1 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -3,7 +3,7 @@ Read-only service. Every function receives the resolved scope (PM → the list of projects they manage; office roles → project_ids=None = whole organization) plus an optional explicit project filter, and the optional -«What» filters (category_id / material_id / brand_id / specs_hash). +«What» filters (material_id / brand_id / specs_hash). Quantities are NEVER mixed across units: inventory rows live in the material's canonical unit, so stock aggregates carry their unit code and the @@ -21,7 +21,7 @@ from sqlalchemy import case, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import aliased -from app.models.catalog import Brand, BrandMaterial, Material, MaterialCategory, Unit +from app.models.catalog import Brand, BrandMaterial, Material, Unit from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse from app.models.procurement import ( ItemDecision, @@ -44,10 +44,8 @@ OUTBOUND_TYPES = ("WITHDRAWAL", "DAMAGE", "WASTE", "LOSS") WASTE_TYPES = ("DAMAGE", "WASTE", "LOSS") -def _stock_filters(stmt, *, category_id=None, material_id=None, brand_id=None, specs_hash=None): +def _stock_filters(stmt, *, material_id=None, brand_id=None, specs_hash=None): """«What» filters for a statement that already joins inventory→material.""" - if category_id is not None: - stmt = stmt.where(Material.category_id == category_id) if material_id is not None: stmt = stmt.where(InventoryItem.material_id == material_id) if brand_id is not None: @@ -75,23 +73,15 @@ def _qty(value) -> float: # --------------------------------------------------------------------------- async def filter_options(db: AsyncSession) -> dict: - """Cascading options for the global filter bar. + """Options for the global filter bar (Material → Brand → Specs). - Categories/materials come from the catalog (active only). Brand options - per material are the UNION of the catalog links (brand_materials — every - brand defined for that material) and the distinct brands actually present - in stock, so the cascade is complete even for materials whose current - stock rows carry no brand yet. Spec options come from stock rows - (inventory.specs_hash), since specs are a stock-level dimension. - Materials without a category land in the synthetic «بدون دسته» bucket. + Materials come from the catalog (active only). Brand options per material + are the UNION of the catalog links (brand_materials — every brand defined + for that material) and the distinct brands actually present in stock, so + the cascade is complete even for materials whose current stock rows carry + no brand yet. Spec options come from stock rows (inventory.specs_hash), + since specs are a stock-level dimension. """ - categories = ( - await db.execute( - select(MaterialCategory) - .where(MaterialCategory.is_active.is_(True)) - .order_by(MaterialCategory.name) - ) - ).scalars().all() materials = ( await db.execute(select(Material).where(Material.status == "active").order_by(Material.name)) ).scalars().all() @@ -149,18 +139,8 @@ async def filter_options(db: AsyncSession) -> dict: ], } - buckets: dict[uuid.UUID | None, list] = {c.id: [] for c in categories} - buckets.setdefault(None, []) - for m in materials: - buckets.setdefault(m.category_id, []).append(material_entry(m)) - - out = [ - {"id": str(c.id), "name": c.name, "slug": c.slug, "materials": buckets.get(c.id, [])} - for c in categories - ] - if buckets.get(None): - out.append({"id": None, "name": "بدون دسته", "slug": None, "materials": buckets[None]}) - return {"data": {"categories": out}} + # فهرست تخت متریال‌ها — سرآغاز آبشار سه‌فیلتری (متریال → برند → مشخصات) + return {"data": {"materials": [material_entry(m) for m in materials]}} # --------------------------------------------------------------------------- @@ -172,7 +152,6 @@ async def inventory_drilldown( *, project_ids, project_id, - category_id=None, material_id=None, brand_id=None, specs_hash=None, @@ -251,8 +230,6 @@ async def inventory_drilldown( .where(Material.status == "active") ) # فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود می‌کنند - if category_id is not None: - stmt = stmt.where(Material.category_id == category_id) if material_id is not None: stmt = stmt.where(Material.id == material_id) # برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان @@ -347,7 +324,6 @@ async def dead_stock( project_ids, project_id, idle_days: int = 60, - category_id=None, material_id=None, brand_id=None, specs_hash=None, @@ -392,7 +368,7 @@ async def dead_stock( .limit(limit) ) stmt = _stock_filters( - stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash + stmt, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash ) stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id) rows = (await db.execute(stmt)).all() @@ -427,7 +403,6 @@ async def daily_flow( date_from: datetime, date_to: datetime, warehouse_id=None, - category_id=None, material_id=None, brand_id=None, specs_hash=None, @@ -454,7 +429,7 @@ async def daily_flow( scope_conditions.append(Warehouse.project_id.in_(project_ids)) if warehouse_id is not None: scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id) - what_conditions = [Material.category_id == category_id] if category_id is not None else [] + what_conditions = [] if material_id is not None: what_conditions.append(InventoryItem.material_id == material_id) if brand_id is not None: diff --git a/frontend/src/components/dashboard/DashboardFilterBar.tsx b/frontend/src/components/dashboard/DashboardFilterBar.tsx index 2685e5c..8bfd357 100644 --- a/frontend/src/components/dashboard/DashboardFilterBar.tsx +++ b/frontend/src/components/dashboard/DashboardFilterBar.tsx @@ -1,13 +1,13 @@ // ============================================================ -// فیلتر سراسری «چه چیزی» — آبشار: دسته‌بندی → متریال → برند → مشخصات +// فیلتر سراسری «چه چیزی» — آبشار: متریال → برند → مشخصات فنی // داده از /dashboard/filters می‌آید (بدون هیچ لیست ساختگی) و انتخاب // هر سطح، سطح‌های پایین‌دست را بازنشانی می‌کند: -// دسته → متریال‌های همان دسته // متریال → برندهای همان متریال (کاتالوگ + موجودی) // متریال → مشخصات فنی موجودِ همان متریال // هر فیلد «×» مخصوص خودش را دارد و گزینه‌ی «همه‌ی …» در ابتدای هر فهرست // همان سطح (و سطوح پایین‌دست) را پاک می‌کند؛ دکمه‌ی «حذف فیلترها» کل استور را // یک‌جا به نمای «کل سازمان» برمی‌گرداند. +// سه فیلتر در یک ردیفِ وسط‌چین با عرض یکسان می‌نشینند (موبایل: تک‌ستونی). // وضعیت در Zustand نگه داشته می‌شود تا با جابه‌جایی بین تب‌ها از دست نرود. // ============================================================ @@ -27,13 +27,13 @@ const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID]; // برچسب‌های ثابت UI — placeholder هر فیلد و گزینه‌ی «همه»اش یک متن مشترک دارند const LABELS = { - allCategories: "همه‌ی دسته‌ها", allMaterials: "همه‌ی متریال‌ها", allBrands: "همه‌ی برندها", allSpecs: "همه‌ی مشخصات", materialFirst: "ابتدا متریال را انتخاب کنید", clearAll: "حذف فیلترها", wholeOrg: "نمای کل سازمان", + cascade: "متریال ← برند ← مشخصات فنی", } as const; const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label }); @@ -48,23 +48,21 @@ export function DashboardFilterBar({ className }: { className?: string }) { const filterQuery = useDashboardFilterOptions(); const state = useDashboardFilter(); - const categories = filterQuery.data?.categories ?? []; - - // متریال‌های سطح فعلی: اگر دسته انتخاب شده باشد فقط متریال‌های همان دسته - const materialOptions = useMemo(() => { - const cat = categories.find((c) => (c.id ?? "") === (state.categoryId ?? "")); - const list = cat ? cat.materials : categories.flatMap((c) => c.materials); - // حذف متریال تکراری وقتی «همه‌ی دسته‌ها» فعال است + // فهرست متریال‌ها مستقیماً از کاتالوگ سراسری می‌آید؛ فقط یکتاسازی شناسه‌ها + // انجام می‌شود تا هیچ گزینه‌ی تکراری در فهرست نباشد. + const materials = useMemo(() => { const seen = new Set(); - return list.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true))); - }, [categories, state.categoryId]); + return (filterQuery.data?.materials ?? []).filter((m) => + seen.has(m.id) ? false : (seen.add(m.id), true), + ); + }, [filterQuery.data]); const selectedMaterial = useMemo( - () => materialOptions.find((m) => m.id === state.materialId) ?? null, - [materialOptions, state.materialId], + () => materials.find((m) => m.id === state.materialId) ?? null, + [materials, state.materialId], ); - const activeCount = [state.categoryId, state.materialId, state.brandId, state.specsHash].filter(Boolean).length; + const activeCount = [state.materialId, state.brandId, state.specsHash].filter(Boolean).length; // انتخاب از فهرست؛ گزینه‌ی «همه» همان سطح را پاک می‌کند (آبشار خودکار) const pick = @@ -73,17 +71,9 @@ export function DashboardFilterBar({ className }: { className?: string }) { item.id === ALL_OPTION_ID ? set(null, null) : set(item.id, item.label); // فهرست هر فیلد با گزینه‌ی «همه» در ابتدا - const categoryItems = useMemo( - () => [ - allOption(LABELS.allCategories), - ...categories.filter((c) => c.id).map((c) => ({ id: c.id as string, label: c.name })), - ], - [categories], - ); - const materialItems = useMemo( - () => [allOption(LABELS.allMaterials), ...materialOptions.map((m) => ({ id: m.id, label: m.name }))], - [materialOptions], + () => [allOption(LABELS.allMaterials), ...materials.map((m) => ({ id: m.id, label: m.name }))], + [materials], ); const brandItems = useMemo( @@ -113,7 +103,7 @@ export function DashboardFilterBar({ className }: { className?: string }) {

فیلتر جنس

-

دسته‌بندی ← متریال ← برند ← مشخصات فنی

+

{LABELS.cascade}

{activeCount > 0 && (