From 34b987398a1202265a685b7ee1bfdb4f015e07f6 Mon Sep 17 00:00:00 2001 From: fatemeh sane Date: Thu, 8 Oct 2026 15:28:53 +0330 Subject: [PATCH] Fix drill-down logic and structure in dashboard --- backend/app/services/dashboard_service.py | 64 ++- .../components/dashboard/DrilldownMatrix.tsx | 537 ++++++++++++------ frontend/src/lib/types.ts | 10 +- 3 files changed, 427 insertions(+), 184 deletions(-) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 716af08..ac07018 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -176,14 +176,17 @@ async def inventory_drilldown( brand_id=None, specs_hash=None, ) -> dict: - """Stock quantity as a three-level tree; each aggregate keyed by unit code - so incompatible units never merge into one number.""" + """Stock quantity as a three-level tree: Material -> Project -> Warehouse.""" stmt = ( select( - Project.id, - Project.name, - Warehouse.id, - Warehouse.name, + Material.id.label("mid"), + Material.name.label("mname"), + Brand.name.label("bname"), + InventoryItem.specs, + Project.id.label("pid"), + Project.name.label("pname"), + Warehouse.id.label("wid"), + Warehouse.name.label("wname"), Unit.code, Unit.name_fa, func.sum(InventoryItem.quantity).label("qty"), @@ -194,9 +197,8 @@ async def inventory_drilldown( .join(Project, Project.id == Warehouse.project_id) .join(Material, Material.id == InventoryItem.material_id) .join(Unit, Unit.id == InventoryItem.unit_id) - .where(InventoryItem.quantity > 0) - .group_by(Project.id, Project.name, Warehouse.id, Warehouse.name, Unit.code, Unit.name_fa) - .order_by(Project.name, Warehouse.name) + .outerjoin(Brand, Brand.id == InventoryItem.brand_id) + .group_by(Material.id, Material.name, Brand.name, InventoryItem.specs, Project.id, Project.name, Warehouse.id, Warehouse.name, Unit.code, Unit.name_fa) ) stmt = _stock_filters( stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash @@ -207,33 +209,59 @@ async def inventory_drilldown( units_legend: dict[str, str] = {} total_by_unit: dict[str, float] = {} total_rows = 0 - projects_tree: dict[uuid.UUID, dict] = {} - for pid, pname, wid, wname, ucode, uname_fa, qty, row_count in rows: + + materials_tree: dict[str, dict] = {} + + for mid, mname, bname, specs, pid, pname, wid, wname, ucode, uname_fa, qty, row_count in rows: q = _qty(qty) units_legend[ucode] = uname_fa total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4) total_rows += row_count - proj = projects_tree.setdefault( + + # Build full material name + parts = [mname] + if bname: + parts.append(bname) + if specs: + parts.extend(str(v) for v in specs.values() if v) + full_name = " ".join(parts) + # Use full_name + unit as unique key for material level, or just full_name + mat_key = f"{mid}_{full_name}" + + mat = materials_tree.setdefault( + mat_key, + {"id": mat_key, "name": full_name, "qty_by_unit": {}, "rows": 0, "_projects": {}}, + ) + mat["qty_by_unit"][ucode] = round(mat["qty_by_unit"].get(ucode, 0.0) + q, 4) + mat["rows"] += row_count + + proj = mat["_projects"].setdefault( pid, {"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}}, ) proj["qty_by_unit"][ucode] = round(proj["qty_by_unit"].get(ucode, 0.0) + q, 4) proj["rows"] += row_count + wh = proj["_warehouses"].setdefault(wid, {"id": str(wid), "name": wname, "qty_by_unit": {}, "rows": 0}) wh["qty_by_unit"][ucode] = round(wh["qty_by_unit"].get(ucode, 0.0) + q, 4) wh["rows"] += row_count - projects = [] - for proj in projects_tree.values(): - warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"]) - projects.append({**proj, "warehouses": warehouses}) - projects.sort(key=lambda p: p["name"]) + materials = [] + for mat in materials_tree.values(): + projects_list = [] + for proj in mat.pop("_projects").values(): + warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"]) + projects_list.append({**proj, "warehouses": warehouses}) + projects_list.sort(key=lambda p: p["name"]) + materials.append({**mat, "projects": projects_list}) + + materials.sort(key=lambda m: m["name"]) return { "data": { "units": units_legend, "total": {"qty_by_unit": total_by_unit, "rows": total_rows}, - "projects": projects, + "materials": materials, } } diff --git a/frontend/src/components/dashboard/DrilldownMatrix.tsx b/frontend/src/components/dashboard/DrilldownMatrix.tsx index 3e520d8..5e18ec8 100644 --- a/frontend/src/components/dashboard/DrilldownMatrix.tsx +++ b/frontend/src/components/dashboard/DrilldownMatrix.tsx @@ -1,26 +1,19 @@ // ============================================================ -// ویجت ۱ — ماتریس Drill-Down سه‌سطحی (سبک Power BI) -// سطح ۱ «کل سازمان» → یک ستون: تجمیع موجودی همه‌ی انبارهای سازمان -// سطح ۲ «پروژه‌ها» → با کلیک روی سطح ۱: سهم هر پروژه -// سطح ۳ «انبارها» → با کلیک روی هر پروژه: انبارهای همان پروژه -// همه‌ی سطوح از پاسخ /dashboard/inventory/drilldown ساخته می‌شوند — -// هیچ مقدار/نام/وضعیتی در فرانت‌اند هارد‌کد نشده است. اگر پروژه‌ی -// انتخاب‌شده با تغییر فیلترها از داده حذف شود، سطح خودکار به بالا برمی‌گردد. +// ویجت ۱ — ماتریس Drill-Down سه‌سطحی با انتخاب داینامیک و پشتیبانی از متریال // ============================================================ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useRef } from "react"; import { Bar, BarChart, CartesianGrid, Cell, LabelList, - ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; -import { ChevronLeft, MousePointerClick } from "lucide-react"; +import { ChevronLeft, MousePointerClick, ChevronDown, CheckSquare, Square } from "lucide-react"; import { useDrilldown } from "../../hooks/useDashboardData"; import { fmtNum } from "../../lib/formatters"; import { @@ -28,131 +21,321 @@ import { ChartCard, ChartEmpty, ChartLoading, - ChartTooltip, - UnitPicker, faAxisTick, } from "./chartShared"; +import { cn } from "../../lib/utils"; -type Level = 0 | 1 | 2; // 0=سازمان، 1=پروژه‌ها، 2=انبارها +type Level = 0 | 1 | 2; // 0=Materials, 1=Projects, 2=Warehouses interface Slice { id: string; name: string; - value: number; + displayQuantity: number; rows: number; + unitLabel: string; } -const LEVEL_TITLES: Record = { - 0: "کل سازمان", - 1: "پروژه‌ها", - 2: "انبارها", -}; - -// پالت سطح انبارها — سایه‌های ایندیگو برای تمایز انبارهای یک پروژه const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"]; +// --- MultiSelect Dropdown Component --- +function MultiSelectDropdown({ + label, + options, + selectedIds, + onChange, + disabled +}: { + label: string; + options: { id: string; name: string }[]; + selectedIds: string[]; + onChange: (ids: string[]) => void; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (ref.current && !ref.current.contains(event.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const toggleItem = (id: string) => { + if (selectedIds.includes(id)) { + onChange(selectedIds.filter((x) => x !== id)); + } else { + onChange([...selectedIds, id]); + } + }; + + return ( +
+ + + {open && ( +
+ {options.length === 0 ? ( +
گزینه‌ای یافت نشد
+ ) : ( + options.map((opt) => { + const isSelected = selectedIds.includes(opt.id); + return ( +
toggleItem(opt.id)} + className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-slate-bg" + > + {isSelected ? ( + + ) : ( + + )} + {opt.name} +
+ ); + }) + )} +
+ )} +
+ ); +} + export function DrilldownMatrix() { const query = useDrilldown(); - const [unit, setUnit] = useState(null); + + // Drill-down UI state const [level, setLevel] = useState(0); + const [materialId, setMaterialId] = useState(null); const [projectId, setProjectId] = useState(null); + // Manual Filter Dropdowns State + const [selectedProjectIds, setSelectedProjectIds] = useState([]); + const [selectedWarehouseIds, setSelectedWarehouseIds] = useState([]); + const data = query.data; - // واحد فعال: انتخاب کاربر یا بزرگ‌ترین مجموع (پایدار بین رندرها) - const activeUnit = useMemo(() => { - if (unit && data?.units?.[unit]) return unit; - const entries = Object.entries(data?.total?.qty_by_unit ?? {}); - if (!entries.length) return null; - return entries.sort((a, b) => b[1] - a[1])[0][0]; - }, [unit, data]); + // Aggregate Dropdown Options dynamically from payload + const projectOptions = useMemo(() => { + if (!data) return []; + const map = new Map(); + data.materials.forEach((m) => { + m.projects.forEach((p) => { + map.set(p.id, p.name); + }); + }); + return Array.from(map.entries()).map(([id, name]) => ({ id, name })); + }, [data]); - const selectedProject = useMemo( - () => (projectId && data ? data.projects.find((p) => p.id === projectId) ?? null : null), - [data, projectId], - ); + const warehouseOptions = useMemo(() => { + if (!data) return []; + const map = new Map(); + data.materials.forEach((m) => { + m.projects.forEach((p) => { + if (selectedProjectIds.length === 0 || selectedProjectIds.includes(p.id)) { + p.warehouses.forEach((w) => { + map.set(w.id, w.name); + }); + } + }); + }); + return Array.from(map.entries()).map(([id, name]) => ({ id, name })); + }, [data, selectedProjectIds]); - // کلمپِ خودکار: اگر پروژه‌ی انتخابی با فیلترهای جدید از داده خارج شد، - // به سطح پروژه‌ها برگرد (بدون هیچ مقدار ثابت). + // Prevent invalid filter states useEffect(() => { - if (level === 2 && projectId && data && !selectedProject) { - setProjectId(null); - setLevel(1); + if (selectedProjectIds.length > 0) { + const validIds = warehouseOptions.map((o) => o.id); + const filtered = selectedWarehouseIds.filter((id) => validIds.includes(id)); + if (filtered.length !== selectedWarehouseIds.length) { + setSelectedWarehouseIds(filtered); + } } - if (data && data.projects.length === 0 && level !== 0) { - setProjectId(null); - setLevel(0); - } - }, [data, level, projectId, selectedProject]); + }, [selectedProjectIds, warehouseOptions]); - const pick = (q: Record) => (activeUnit ? q[activeUnit] ?? 0 : 0); + // Helper to sum all units and get unit label + const getAggregatedQtyAndUnit = (qtyByUnit: Record) => { + let sum = 0; + const unitCodes = Object.keys(qtyByUnit); + unitCodes.forEach(code => sum += qtyByUnit[code]); + const unitLabel = unitCodes.map(code => data?.units[code] || code).join(" و "); + return { qty: sum, unitLabel }; + }; - // برش‌های سطح فعال — کاملاً از پاسخ API + // Core Aggregation Logic const slices: Slice[] = useMemo(() => { if (!data) return []; - if (level === 0) { - return [{ id: "__org__", name: "کل سازمان", value: pick(data.total.qty_by_unit), rows: data.total.rows }]; - } - if (level === 1) { - return data.projects.map((p) => ({ - id: p.id, - name: p.name, - value: pick(p.qty_by_unit), - rows: p.rows, - })); - } - return (selectedProject?.warehouses ?? []).map((w) => ({ - id: w.id, - name: w.name, - value: pick(w.qty_by_unit), - rows: w.rows, - })); - }, [data, level, selectedProject, activeUnit]); + const res: Slice[] = []; - const unitLabel = activeUnit ? data?.units?.[activeUnit] || activeUnit : ""; - const levelTotal = slices.reduce((s, x) => s + x.value, 0); - const total = data ? pick(data.total.qty_by_unit) : 0; + const matchesProject = (pid: string) => + selectedProjectIds.length === 0 || selectedProjectIds.includes(pid); + const matchesWarehouse = (wid: string) => + selectedWarehouseIds.length === 0 || selectedWarehouseIds.includes(wid); + + if (level === 0) { + data.materials.forEach((mat) => { + const mergedQtyByUnit: Record = {}; + let rows = 0; + mat.projects.forEach((proj) => { + if (!matchesProject(proj.id)) return; + proj.warehouses.forEach((wh) => { + if (!matchesWarehouse(wh.id)) return; + Object.keys(wh.qty_by_unit).forEach(code => { + mergedQtyByUnit[code] = (mergedQtyByUnit[code] || 0) + wh.qty_by_unit[code]; + }); + rows += wh.rows; + }); + }); + const { qty, unitLabel } = getAggregatedQtyAndUnit(mergedQtyByUnit); + if (qty >= 0) res.push({ id: mat.id, name: mat.name, displayQuantity: qty, rows, unitLabel }); + }); + } else if (level === 1 && materialId) { + const mat = data.materials.find((m) => m.id === materialId); + if (mat) { + mat.projects.forEach((proj) => { + if (!matchesProject(proj.id)) return; + const mergedQtyByUnit: Record = {}; + let rows = 0; + proj.warehouses.forEach((wh) => { + if (!matchesWarehouse(wh.id)) return; + Object.keys(wh.qty_by_unit).forEach(code => { + mergedQtyByUnit[code] = (mergedQtyByUnit[code] || 0) + wh.qty_by_unit[code]; + }); + rows += wh.rows; + }); + const { qty, unitLabel } = getAggregatedQtyAndUnit(mergedQtyByUnit); + if (qty >= 0) res.push({ id: proj.id, name: proj.name, displayQuantity: qty, rows, unitLabel }); + }); + } + } else if (level === 2 && materialId && projectId) { + const mat = data.materials.find((m) => m.id === materialId); + if (mat) { + const proj = mat.projects.find((p) => p.id === projectId); + if (proj && matchesProject(proj.id)) { + proj.warehouses.forEach((wh) => { + if (!matchesWarehouse(wh.id)) return; + const { qty, unitLabel } = getAggregatedQtyAndUnit(wh.qty_by_unit); + if (qty >= 0) res.push({ id: wh.id, name: wh.name, displayQuantity: qty, rows: wh.rows, unitLabel }); + }); + } + } + } + + return res.sort((a, b) => b.displayQuantity - a.displayQuantity); + }, [data, level, materialId, projectId, selectedProjectIds, selectedWarehouseIds]); + + const levelTotal = slices.reduce((s, x) => s + x.displayQuantity, 0); const handleBarClick = (slice: Slice) => { - if (level === 0 && data && data.projects.length > 0) { + if (level === 0) { + setMaterialId(slice.id); setLevel(1); } else if (level === 1) { setProjectId(slice.id); setLevel(2); } - // سطح ۳ برگ است — کلیک دیگری لازم نیست }; - const subText = - level === 0 - ? "تجمیع موجودی همه‌ی انبارهای سازمان برای فیلترهای فعال — برای مشاهده‌ی سهم پروژه‌ها کلیک کنید" - : level === 1 - ? "سهم هر پروژه از موجودی — برای مشاهده‌ی انبارهای هر پروژه روی ستون آن کلیک کنید" - : `انبارهای پروژه «${selectedProject?.name ?? ""}» — برای بازگشت روی مسیر کلیک کنید`; + const getOrgTitle = () => { + if (selectedProjectIds.length === 0) return "کل سازمان"; + return projectOptions + .filter((o) => selectedProjectIds.includes(o.id)) + .map((o) => o.name) + .join(" و "); + }; + + const getWarehouseTitle = () => { + if (selectedWarehouseIds.length === 0) return "همه انبارها"; + return warehouseOptions + .filter((o) => selectedWarehouseIds.includes(o.id)) + .map((o) => o.name) + .join(" و "); + }; + + const selectedMaterialName = + level > 0 && materialId + ? data?.materials.find((m) => m.id === materialId)?.name || "" + : ""; + + const selectedProjectName = + level > 1 && projectId && materialId + ? data?.materials + .find((m) => m.id === materialId) + ?.projects.find((p) => p.id === projectId)?.name || "" + : ""; + + const renderBreadcrumbHeader = () => { + return ( +
+ { + setSelectedProjectIds(ids); + if (level === 2 && ids.length > 0 && projectId && !ids.includes(projectId)) { + setLevel(1); + } + }} + /> + {(selectedProjectIds.length > 0 || selectedWarehouseIds.length > 0) && ( + <> + + + + )} +
+ ); + }; + + const getTooltipPrompt = () => { + if (level === 0) return "برای مشاهده پروژه‌ها کلیک کنید"; + if (level === 1) return "برای مشاهده انبارها کلیک کنید"; + return ""; + }; return ( - : null} - > - {/* مسیر Drill-Down (RTL) */} + {query.isLoading ? ( - ) : !slices.length || (levelTotal === 0 && level === 0) ? ( + ) : !slices.length || levelTotal === 0 ? ( ) : ( <> -
- - - - 4 ? -18 : 0} - textAnchor={slices.length > 4 ? "end" : "middle"} - /> - - `${fmtNum(v)} ${unitLabel}`} - /> - } - /> - { - const payload = (entry as { payload?: Slice })?.payload; - if (payload) handleBarClick(payload); +
+ + + + + { + const { active, payload, label } = props; + if (!active || !payload || payload.length === 0) return null; + const data = payload[0].payload as Slice; + const val = payload[0].payload.displayQuantity; + const name = payload[0].name; + const color = payload[0].color; + return ( +
+ {label !== undefined && label !== "" && ( +
+
{label}
+ {level < 2 && ( +
{getTooltipPrompt()}
+ )} +
+ )} +
+
+ + {name} + + {fmtNum(val)} {data.unitLabel} + +
+
+
+ ); + }} + /> + { + const payload = (entry as { payload?: Slice })?.payload; + if (payload) handleBarClick(payload); + }} + className={level < 2 ? "cursor-pointer transition-opacity hover:opacity-80" : ""} + > + { + const n = Number(v); + return Number.isFinite(n) && n > 0 ? fmtNum(n) : ""; }} - className={level < 2 ? "cursor-pointer" : ""} - > - { - const n = Number(v); - return Number.isFinite(n) && n > 0 ? fmtNum(n) : ""; - }} + /> + {slices.map((s, i) => ( + - {slices.map((s, i) => ( - - ))} - -
- + ))} + +
{level < 2 && (

- {level === 0 ? "برای دیدن پروژه‌ها روی ستون کلیک کنید" : "برای دیدن انبارهای هر پروژه روی ستون آن کلیک کنید"} + {getTooltipPrompt()}

)} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b7838c1..9fddf55 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -261,10 +261,18 @@ export interface DrilldownProject { warehouses: DrilldownWarehouse[]; } +export interface DrilldownMaterial { + id: string; + name: string; + qty_by_unit: QtyByUnit; + rows: number; + projects: DrilldownProject[]; +} + export interface DrilldownData { units: Record; total: { qty_by_unit: QtyByUnit; rows: number }; - projects: DrilldownProject[]; + materials: DrilldownMaterial[]; } // — جدول موجودی راکد —