From 3d16c435d0859f1877bbe9436cc0c6cf763c541f Mon Sep 17 00:00:00 2001 From: fatemeh sane Date: Tue, 13 Oct 2026 21:49:22 +0330 Subject: [PATCH] update dashboard logic --- backend/app/services/dashboard_service.py | 113 +++- frontend/src/components/Combobox.tsx | 37 +- .../dashboard/DashboardFilterBar.tsx | 111 +++- .../components/dashboard/DrilldownMatrix.tsx | 487 +++++++++++++----- 4 files changed, 566 insertions(+), 182 deletions(-) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index ac07018..b8ded27 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -19,6 +19,7 @@ from datetime import datetime, time, timedelta, timezone 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.inventory import InventoryItem, InventoryTransaction, Warehouse @@ -176,34 +177,91 @@ async def inventory_drilldown( brand_id=None, specs_hash=None, ) -> dict: - """Stock quantity as a three-level tree: Material -> Project -> Warehouse.""" - stmt = ( + """Stock quantity as a three-level tree: Material -> Project -> Warehouse. + + The x-axis mirrors the *catalog*, not the ledger: the statement starts from + ``materials`` and LEFT OUTER JOINs the per-(brand, specs, project, + warehouse, unit) stock aggregation. Active catalog materials without any + ledger rows therefore stay on the chart with ``COALESCE``d zero quantities + (labelled with their canonical unit) instead of disappearing in an inner + join. + """ + # تجمیع دفتر انبار به‌ازای (ماده، برند، مشخصات، پروژه، انبار، واحد) — سمت + # راست LEFT JOIN؛ محدودیت دامنه (پروژه‌های مجاز) همین‌جا اعمال می‌شود تا + # مواد بدون موجودیِ داخل دامنه هم از دست نروند. + stock = ( select( - Material.id.label("mid"), - Material.name.label("mname"), - Brand.name.label("bname"), - InventoryItem.specs, + InventoryItem.material_id.label("material_id"), + InventoryItem.brand_id.label("brand_id"), + InventoryItem.specs_hash.label("specs_hash"), + InventoryItem.specs.label("specs"), Project.id.label("pid"), Project.name.label("pname"), Warehouse.id.label("wid"), Warehouse.name.label("wname"), - Unit.code, - Unit.name_fa, + Unit.code.label("ucode"), + Unit.name_fa.label("uname_fa"), func.sum(InventoryItem.quantity).label("qty"), func.count().label("rows"), ) .select_from(InventoryItem) .join(Warehouse, Warehouse.id == InventoryItem.warehouse_id) .join(Project, Project.id == Warehouse.project_id) - .join(Material, Material.id == InventoryItem.material_id) .join(Unit, Unit.id == InventoryItem.unit_id) - .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) + .group_by( + InventoryItem.material_id, + InventoryItem.brand_id, + InventoryItem.specs_hash, + 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 + stock = _scope_filters(stock, project_ids=project_ids, project_id=project_id) + stock_sq = stock.subquery() + + # واحد کاتالوگی ماده — برچسب واحدِ ردیف‌های صفر («۰ عدد») + canon_unit = aliased(Unit) + + stmt = ( + select( + Material.id.label("mid"), + Material.name.label("mname"), + Brand.name.label("bname"), + stock_sq.c.specs, + stock_sq.c.pid, + stock_sq.c.pname, + stock_sq.c.wid, + stock_sq.c.wname, + stock_sq.c.ucode, + stock_sq.c.uname_fa, + func.coalesce(stock_sq.c.qty, 0).label("qty"), + func.coalesce(stock_sq.c.rows, 0).label("rows"), + canon_unit.code.label("canon_ucode"), + canon_unit.name_fa.label("canon_uname_fa"), + ) + .select_from(Material) + .outerjoin(stock_sq, stock_sq.c.material_id == Material.id) + .outerjoin(Brand, Brand.id == stock_sq.c.brand_id) + .outerjoin(canon_unit, canon_unit.id == Material.canonical_unit_id) + .where(Material.status == "active") ) - stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id) + # فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود می‌کنند + 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) + # برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان + # موجودی روی محور می‌مانند (رفتار قبلی دست‌نخورده) + if brand_id is not None: + stmt = stmt.where(stock_sq.c.brand_id == brand_id) + if specs_hash: + stmt = stmt.where(stock_sq.c.specs_hash == specs_hash) + rows = (await db.execute(stmt)).all() units_legend: dict[str, str] = {} @@ -212,12 +270,18 @@ async def inventory_drilldown( materials_tree: dict[str, dict] = {} - for mid, mname, bname, specs, pid, pname, wid, wname, ucode, uname_fa, qty, row_count in rows: + for mid, mname, bname, specs, pid, pname, wid, wname, ucode, uname_fa, qty, row_count, canon_ucode, canon_uname_fa in rows: q = _qty(qty) - units_legend[ucode] = uname_fa - total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4) + + # افسانه‌ی واحدها و جمع کل؛ مواد بدون ردیف موجودی با واحد کاتالوگی و صفر + if ucode is not None: + units_legend[ucode] = uname_fa + total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4) + elif canon_ucode is not None: + units_legend.setdefault(canon_ucode, canon_uname_fa) + total_by_unit.setdefault(canon_ucode, 0.0) total_rows += row_count - + # Build full material name parts = [mname] if bname: @@ -227,14 +291,21 @@ async def inventory_drilldown( 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) + if ucode is not None: + mat["qty_by_unit"][ucode] = round(mat["qty_by_unit"].get(ucode, 0.0) + q, 4) + elif canon_ucode is not None: + mat["qty_by_unit"].setdefault(canon_ucode, 0.0) mat["rows"] += row_count - + + # مواد بدون هیچ ردیف موجودی: فقط روی محور می‌مانند (بدون پروژه/انبار) + if pid is None: + continue + proj = mat["_projects"].setdefault( pid, {"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}}, diff --git a/frontend/src/components/Combobox.tsx b/frontend/src/components/Combobox.tsx index ff5f844..1aed006 100644 --- a/frontend/src/components/Combobox.tsx +++ b/frontend/src/components/Combobox.tsx @@ -17,6 +17,9 @@ export interface ComboboxProps { value?: string; onChange?: (item: ComboboxItem) => void; items: ComboboxSource; // آرایه از string یا {id, name} + // شناسه‌ی آیتم‌هایی که همیشه در ابتدای فهرست می‌مانند و با جستجوی کاربر + // فیلتر نمی‌شوند (مثل گزینه‌ی «همه‌ی …» در فیلترهای آبشاری داشبورد) + pinnedIds?: string[]; allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید" addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد addSuffix?: string; // متن بعد از + @@ -50,6 +53,7 @@ export function Combobox({ value, onChange, items, + pinnedIds, allowAdd = true, addPrefix, addSuffix = "به عنوان گزینه جدید", @@ -88,21 +92,37 @@ export function Combobox({ return all.filter((x) => normalize(x.label).includes(q)); }, [all, q]); + // آیتم‌های pin‌شده همیشه در ابتدای فهرست می‌مانند و با جستجو حذف نمی‌شوند؛ + // ناوبری با کلیدها هم روی همین فهرست نهایی (visible) انجام می‌شود. + const { visible, pinnedCount, hasUnpinned } = useMemo(() => { + const pin = pinnedIds ?? []; + if (pin.length === 0) return { visible: filtered, pinnedCount: 0, hasUnpinned: false }; + const pinned = pin + .map((id) => all.find((x) => x.id === id)) + .filter((x): x is ComboboxItem => x !== undefined); + const pinnedSet = new Set(pinned.map((x) => x.id)); + const rest = filtered.filter((x) => !pinnedSet.has(x.id)); + return { visible: [...pinned, ...rest], pinnedCount: pinned.length, hasUnpinned: rest.length > 0 }; + }, [all, filtered, pinnedIds]); + const showAdd = - allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q); + allowAdd && q.length > 0 && !visible.some((x) => normalize(x.label) === q); const showClear = Boolean(onClear) && !disabled && text.length > 0; - const listCount = filtered.length + (showAdd ? 1 : 0); + const listCount = visible.length + (showAdd ? 1 : 0); const ref = useClickOutside(() => setOpen(false)); const pick = (it: ComboboxItem) => { - setText(it.label); + // آیتم با شناسه‌ی خالی یعنی «انتخاب نشده» (گزینه‌ی «همه‌ی …»)؛ فیلد به + // placeholder برمی‌گردد تا متن/دکمه‌ی × برای مقدار پاک‌شده باقی نماند. + const nextText = it.id ? it.label : ""; + setText(nextText); setOpen(false); setHighlight(-1); onChange?.(it); - onInputChange?.(it.label); + onInputChange?.(nextText); }; const addCurrent = () => { @@ -127,9 +147,9 @@ export function Combobox({ setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1)); } else if (e.key === "Enter") { e.preventDefault(); - if (highlight >= 0 && highlight < filtered.length) { - pick(filtered[highlight]); - } else if (highlight === filtered.length && showAdd) { + if (highlight >= 0 && highlight < visible.length) { + pick(visible[highlight]); + } else if (highlight === visible.length && showAdd) { addCurrent(); } } else if (e.key === "Escape") { @@ -197,13 +217,14 @@ export function Combobox({ {listCount === 0 && (
موردی یافت نشد
)} - {filtered.map((it, i) => ( + {visible.map((it, i) => ( )} @@ -73,44 +136,48 @@ export function DashboardFilterBar({ className }: { className?: string }) { {/* ۱ — دسته‌بندی */} c.id).map((c) => ({ id: c.id as string, label: c.name }))} + items={categoryItems} + pinnedIds={ALL_PINNED_IDS} onClear={() => state.setCategory(null, null)} - onChange={(item) => state.setCategory(item.id, item.label)} + onChange={pick(state.setCategory)} /> {/* ۲ — متریال (وابسته به دسته‌ی انتخاب‌شده) */} ({ id: m.id, label: m.name }))} + items={materialItems} + pinnedIds={ALL_PINNED_IDS} onClear={() => state.setMaterial(null, null)} - onChange={(item) => state.setMaterial(item.id, item.label)} + onChange={pick(state.setMaterial)} /> {/* ۳ — برند (فقط برندهای همان متریال) */} ({ id: b.id, label: b.name }))} + items={brandItems} + pinnedIds={ALL_PINNED_IDS} onClear={() => state.setBrand(null, null)} - onChange={(item) => state.setBrand(item.id, item.label)} + onChange={pick(state.setBrand)} /> {/* ۴ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */} ({ id: s.hash, label: specLabel(s.specs) }))} + items={specItems} + pinnedIds={ALL_PINNED_IDS} onClear={() => state.setSpecs(null, null)} - onChange={(item) => state.setSpecs(item.id, item.label)} + onChange={pick(state.setSpecs)} /> diff --git a/frontend/src/components/dashboard/DrilldownMatrix.tsx b/frontend/src/components/dashboard/DrilldownMatrix.tsx index 5e18ec8..40dfaca 100644 --- a/frontend/src/components/dashboard/DrilldownMatrix.tsx +++ b/frontend/src/components/dashboard/DrilldownMatrix.tsx @@ -1,8 +1,14 @@ // ============================================================ // ویجت ۱ — ماتریس Drill-Down سه‌سطحی با انتخاب داینامیک و پشتیبانی از متریال +// «breadcrumb یکپارچه»: تنها مسیر پیمایش بالای نمودار، هم‌زمان بازتاب دو منبع +// حالت است — دامنه‌های سراسری پروژه/انبار (کنترل‌های چندانتخابی، همان گره‌های +// میانی) و مسیر drill-down داخلی (متریال ← پروژه). کلیک روی گره‌های عقب‌رونده +// هم سطح نمودار را برمی‌گرداند و هم دامنه‌های عمیق‌تر را آزاد می‌کند؛ فیلتر +// سراسری «چه چیزی» (Zustand) دست‌نخورده می‌ماند. با هر تغییر فیلتر سراسری، +// مسیر در برابر payload تازه اعتبارسنجی می‌شود تا در سطح کهنه گیر نکند. // ============================================================ -import { useEffect, useMemo, useState, useRef } from "react"; +import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { Bar, BarChart, @@ -13,9 +19,18 @@ import { XAxis, YAxis, } from "recharts"; -import { ChevronLeft, MousePointerClick, ChevronDown, CheckSquare, Square } from "lucide-react"; +import type { XAxisTickContentProps } from "recharts"; +import { + CheckSquare, + ChevronDown, + ChevronLeft, + MousePointerClick, + RotateCcw, + Square, +} from "lucide-react"; import { useDrilldown } from "../../hooks/useDashboardData"; -import { fmtNum } from "../../lib/formatters"; +import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter"; +import { faNum, fmtNum } from "../../lib/formatters"; import { CHART_COLORS, ChartCard, @@ -25,7 +40,7 @@ import { } from "./chartShared"; import { cn } from "../../lib/utils"; -type Level = 0 | 1 | 2; // 0=Materials, 1=Projects, 2=Warehouses +type Level = 0 | 1 | 2; // ۰=متریال‌ها، ۱=پروژه‌ها، ۲=انبارها interface Slice { id: string; @@ -35,21 +50,140 @@ interface Slice { unitLabel: string; } +// گرهی مسیر drill-down — نام همان لحظه‌ی کلیک ثبت می‌شود تا برچسب breadcrumb +// حتی هنگام بارگذاری مجدد داده‌ها پایدار بماند. +interface DrillNode { + id: string; + name: string; +} + const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"]; +// سطح ریشه + برچسب‌های ثابت UI (یک‌جا تعریف می‌شوند تا در JSX تکرار نشوند) +const ROOT_LEVEL: Level = 0; + +const LABELS = { + root: "کل سازمان", + allProjects: "همه‌ی پروژه‌ها", + allWarehouses: "همه‌ی انبارها", + widgetTitle:"", + projectScopeHint: "دامنه‌ی پروژه — برای تغییر انتخاب‌ها کلیک کنید", + warehouseScopeHint: "دامنه‌ی انبار — برای تغییر انتخاب‌ها کلیک کنید", +} as const; + +// پسوند شمارش گزینه‌های چندانتخابی («… و ۲ پروژه‌ی دیگر») +const otherProjectsLabel = (count: number) => `${faNum(count)} پروژه‌ی دیگر`; +const otherWarehousesLabel = (count: number) => `${faNum(count)} انبار دیگر`; + +function crumbHint(level: Level, label: string): string { + if (level === ROOT_LEVEL) return `بازگشت به ${LABELS.root} — پاک‌کردن دامنه‌ها و مسیر`; + if (level === 1) return `مشاهده‌ی پروژه‌های «${label}»`; + return `مشاهده‌ی انبارهای «${label}»`; +} + +// برچسب یک دامنه‌ی چندانتخابی: «همه‌ی …» / نام / «نام + پسوند شمارش» +function scopeLabel( + options: { id: string; name: string }[], + selectedIds: string[], + allLabel: string, + othersLabel: (count: number) => string, +): string { + if (selectedIds.length === 0) return allLabel; + const names = options.filter((o) => selectedIds.includes(o.id)).map((o) => o.name); + const first = names[0] ?? allLabel; + return names.length > 1 ? `${first} و ${othersLabel(names.length - 1)}` : first; +} + +// ——— چیدمان نمودار: میله‌ها و برچسب‌های محور X ——— +// هر ماده یک اسلات عرض ثابت می‌گیرد تا میله‌ها پخش شوند و برچسب‌های فارسی +// بلند روی هم نیفتند؛ نام‌های بلندتر در چند خط شکسته می‌شوند. +const AXIS_SLOT_PX = 200; // عرض سهم هر ماده روی محور (px) +const MIN_CHART_WIDTH = 800; // کف عرض نمودار (px) +const CHART_HEIGHT = 360; // ارتفاع نمودار (px) +const AXIS_HEIGHT = 110; // ارتفاع ناحیه‌ی برچسب‌های محور X (px) +const AXIS_TICK_MARGIN = 15; // فاصله‌ی خط محور تا خط اول برچسب (px) +const LABEL_LINE_CHARS = 20; // سقف نویسه‌ی هر خط برچسب +const LABEL_MAX_LINES = 3; // حداکثر خطوط هر برچسب +const LABEL_LINE_HEIGHT = 18; // فاصله‌ی خطوط برچسب (px) + +// شکستن نام بلند به خطوط کوتاه — پایه‌ی تیک محور X +function wrapAxisLabel(text: string): string[] { + const lines: string[] = []; + for (const word of text.split(/\s+/).filter(Boolean)) { + // واژه‌های بسیار بلند هم به تکه‌های قابل نمایش شکسته می‌شوند + const chunks: string[] = []; + for (let i = 0; i < word.length; i += LABEL_LINE_CHARS) { + chunks.push(word.slice(i, i + LABEL_LINE_CHARS)); + } + for (const chunk of chunks) { + const last = lines[lines.length - 1]; + if (last && `${last} ${chunk}`.length <= LABEL_LINE_CHARS) { + lines[lines.length - 1] = `${last} ${chunk}`; + } else { + lines.push(chunk); + } + } + } + if (lines.length <= LABEL_MAX_LINES) return lines; + const head = lines.slice(0, LABEL_MAX_LINES - 1); + head.push(`${lines.slice(LABEL_MAX_LINES - 1).join(" ").slice(0, LABEL_LINE_CHARS - 1)}…`); + return head; +} + +// تیک سفارشی محور X — برچسب چندخطی با فاصله‌ی کافی (بدون هم‌پوشانی). +// فاصله‌ی خط اول از محور از tickMargin خودِ XAxis می‌آید و شروع متن با +// dominantBaseline=hanging تراز می‌شود (مثل تیک پیش‌فرض Recharts). +function AxisTick({ x, y, payload }: XAxisTickContentProps) { + const xPos = typeof x === "number" ? x : Number(x); + const yPos = typeof y === "number" ? y : Number(y); + const lines = wrapAxisLabel(String(payload?.value ?? "")); + return ( + + {lines.map((line, i) => ( + + {line} + + ))} + + ); +} + +// اثر انگشت فیلتر سراسری (Zustand) — هر تغییر آن یعنی payload تازه‌ای می‌آید و +// اعتبارسنجی مسیر drill-down باید دوباره اجرا شود. +const selectGlobalFilterKey = (s: DashboardFilterState): string => + JSON.stringify([s.categoryId, s.materialId, s.brandId, s.specsHash]); + // --- MultiSelect Dropdown Component --- +// کنترل چندانتخابی دامنه (پروژه/انبار) — به‌عنوان گرهی چندانتخابیِ breadcrumb +// رندر می‌شود؛ برچسبش بازتاب انتخاب‌های فعلی است و ردیف «همه‌ی …» انتخاب‌ها را +// آزاد می‌کند. function MultiSelectDropdown({ label, options, selectedIds, onChange, - disabled + disabled, + allLabel, + onClearAll, + title, }: { label: string; options: { id: string; name: string }[]; selectedIds: string[]; onChange: (ids: string[]) => void; disabled?: boolean; + // با ارائه‌ی این دو، ردیف «همه‌ی …» در ابتدای فهرست ظاهر می‌شود + allLabel?: string; + onClearAll?: () => void; + title?: string; }) { const [open, setOpen] = useState(false); const ref = useRef(null); @@ -77,6 +211,7 @@ function MultiSelectDropdown({ - + {open && (
+ {allLabel && onClearAll && ( + <> + +
+ + )} {options.length === 0 ? (
گزینه‌ای یافت نشد
) : ( @@ -119,19 +270,25 @@ function MultiSelectDropdown({ export function DrilldownMatrix() { const query = useDrilldown(); - - // Drill-down UI state - const [level, setLevel] = useState(0); - const [materialId, setMaterialId] = useState(null); - const [projectId, setProjectId] = useState(null); - // Manual Filter Dropdowns State + // مسیر drill-down محلی — تنها منبع حقیقت برای سطح و گره‌های پیمایش‌شده؛ + // سطح از طول مسیر مشتق می‌شود تا هرگز با شناسه‌های گره ناهماهنگ نشود. + const [path, setPath] = useState([]); + const level: Level = path.length === 0 ? 0 : path.length === 1 ? 1 : 2; + const materialId = path[0]?.id ?? null; + const projectId = path[1]?.id ?? null; + + // فیلترهای دستی (چندگزینه‌ای پروژه/انبار) — وابسته به payload جاری const [selectedProjectIds, setSelectedProjectIds] = useState([]); const [selectedWarehouseIds, setSelectedWarehouseIds] = useState([]); + // عرض واقعی کانتینر نمودار — تا نمودار هرگز در عرض والد فشرده نشود + const plotRef = useRef(null); + const [plotWidth, setPlotWidth] = useState(0); + const data = query.data; - // Aggregate Dropdown Options dynamically from payload + // گزینه‌های آبشاری از دل payload ساخته می‌شوند const projectOptions = useMemo(() => { if (!data) return []; const map = new Map(); @@ -158,16 +315,52 @@ export function DrilldownMatrix() { return Array.from(map.entries()).map(([id, name]) => ({ id, name })); }, [data, selectedProjectIds]); - // Prevent invalid filter states + // ——— همگام‌سازی با فیلتر سراسری (Zustand) ——— + // فیلتر سراسری، payload را زیر پای حالت محلی عوض می‌کند؛ به‌جای پاک‌کردن + // کورکورانه‌ی مسیر، حالت محلی را با داده‌ی تازه اعتبارسنجی می‌کنیم تا نه در + // سطح کهنه گیر کنیم و نه بی‌دلیل به عقب بپریم. + const globalFilterKey = useDashboardFilter(selectGlobalFilterKey); + + // ۱) انتخاب‌های وابسته به payload (پروژه/انبار) نباید به شناسه‌هایی اشاره کنند + // که در نتیجه‌ی فیلتر جاری وجود ندارند؛ وگرنه نمودارِ خالی با چیپ‌های فعال می‌بینیم. useEffect(() => { - 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) return; + const validProjectIds = new Set(projectOptions.map((o) => o.id)); + setSelectedProjectIds((ids) => + ids.every((id) => validProjectIds.has(id)) ? ids : ids.filter((id) => validProjectIds.has(id)), + ); + const validWarehouseIds = new Set(warehouseOptions.map((o) => o.id)); + setSelectedWarehouseIds((ids) => + ids.every((id) => validWarehouseIds.has(id)) ? ids : ids.filter((id) => validWarehouseIds.has(id)), + ); + }, [globalFilterKey, data, projectOptions, warehouseOptions]); + + // ۲) اگر متریال یا پروژه‌ی در حال مشاهده از payload حذف شده باشد، فقط تا + // عمیق‌ترین سطح معتبر عقب می‌آییم؛ مسیر معتبر دست‌نخورده می‌ماند. + useEffect(() => { + if (!data || path.length === 0) return; + const material = data.materials.find((m) => m.id === path[0].id); + if (!material) { + setPath([]); + return; } - }, [selectedProjectIds, warehouseOptions]); + if (path.length > 1 && !material.projects.some((p) => p.id === path[1].id)) { + setPath((prev) => prev.slice(0, 1)); + } + }, [globalFilterKey, data, path]); + + // ——— اندازه‌گیری عرض کانتینر نمودار ——— + // عرض صریح BarChart از همین مقدار و تعداد میله‌ها حساب می‌شود؛ کانتینر + // اسکرول‌دار است، پس با هر تغییر اندازه‌ی پنجره/کارت دوباره اندازه‌گیری می‌شود. + useEffect(() => { + const el = plotRef.current; + if (!el) return; + const measure = () => setPlotWidth(el.clientWidth); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, []); // Helper to sum all units and get unit label const getAggregatedQtyAndUnit = (qtyByUnit: Record) => { @@ -242,72 +435,75 @@ export function DrilldownMatrix() { const levelTotal = slices.reduce((s, x) => s + x.displayQuantity, 0); + // عرض صریح نمودار: هر ماده یک اسلات ثابت می‌گیرد تا میله‌ها پخش شوند و + // برچسب‌های محور X فاصله‌ی کافی داشته باشند؛ هرگز باریک‌تر از عرض واقعی + // کانتینر (و کف تعیین‌شده) نمی‌شود. + const chartWidth = Math.max(plotWidth, MIN_CHART_WIDTH, slices.length * AXIS_SLOT_PX); + const handleBarClick = (slice: Slice) => { - if (level === 0) { - setMaterialId(slice.id); - setLevel(1); - } else if (level === 1) { - setProjectId(slice.id); - setLevel(2); + // هر کلیک یک سطح عمیق‌تر: متریال ← پروژه ← انبار (عمیق‌تر از این وجود ندارد) + setPath((prev) => { + if (prev.length === 0) return [{ id: slice.id, name: slice.name }]; + if (prev.length === 1) return [...prev, { id: slice.id, name: slice.name }]; + return prev; + }); + }; + + // پرش به هر گرهی مسیر — هم سطح نمودار را برمیگرداند و هم فیلترهای ریزتر + // (اسکوپ‌های پروژه/انبار) را خودکار آزاد می‌کند تا کاربر مجبور نباشد تیک‌ها را + // دستی بردارد. فیلتر سراسری «چه چیزی» (Zustand) دست‌نخورده می‌ماند. + const jumpToLevel = (target: Level) => { + setPath((prev) => prev.slice(0, target)); + if (target === ROOT_LEVEL) { + // بازگشت به «کل سازمان»: هر دو انتخاب پروژه و انبار پاک می‌شوند + setSelectedProjectIds([]); + setSelectedWarehouseIds([]); + return; + } + // گره‌های پایین‌تر: فقط ریزترین انتخاب (انبارها) آزاد می‌شود؛ + // اسکوپ پروژه‌ی جاری دست‌نخورده می‌ماند. + setSelectedWarehouseIds([]); + }; + + // گره‌های drill-down در breadcrumb (متریال، سپس پروژه) — گره‌های دامنه جدا رندر می‌شوند + const drillCrumbs = path.map<{ level: Level; label: string }>((node, index) => ({ + level: index === 0 ? 1 : 2, + label: node.name, + })); + + // ظاهر مشترک گره‌های کلیکی مسیر (گره‌ی جاری: برجسته؛ گره‌های عقب‌رونده: قابل کلیک) + const crumbClass = (isCurrent: boolean) => + cn( + "max-w-[12rem] cursor-pointer truncate rounded-md px-2 py-0.5 font-bold transition-colors active:scale-[0.98]", + isCurrent + ? "bg-accent-bg text-accent hover:bg-accent-bg/70" + : "text-muted hover:bg-slate-bg hover:text-ink", + ); + + // ——— دامنه‌های سراسری (پروژه/انبار) ——— + // این دو دامنه دیگر کنترل جدایی در هدر کارت ندارند؛ همان گره‌های میانیِ + // breadcrumb‌اند و برچسبشان از همین انتخاب‌ها می‌آید (همگام‌سازی دوطرفه). + const handleProjectScopeChange = (ids: string[]) => { + setSelectedProjectIds(ids); + // اگر پروژه‌ی در حال مشاهده از دامنه‌ی انتخاب خارج شود، به سطح پروژه‌ها برمی‌گردیم + if (ids.length > 0 && projectId && !ids.includes(projectId)) { + setPath((prev) => (prev.length > 1 ? prev.slice(0, 1) : prev)); } }; - const getOrgTitle = () => { - if (selectedProjectIds.length === 0) return "کل سازمان"; - return projectOptions - .filter((o) => selectedProjectIds.includes(o.id)) - .map((o) => o.name) - .join(" و "); + // ردیف «همه‌ی پروژه‌ها» در منوی گره: دامنه‌ی پروژه و دامنه‌ی عمیق‌ترش (انبار) آزاد می‌شود + const clearProjectScope = () => { + setSelectedProjectIds([]); + setSelectedWarehouseIds([]); }; - const getWarehouseTitle = () => { - if (selectedWarehouseIds.length === 0) return "همه انبارها"; - return warehouseOptions - .filter((o) => selectedWarehouseIds.includes(o.id)) - .map((o) => o.name) - .join(" و "); - }; + // انبار ریزترین دامنه است: فقط خودش آزاد می‌شود و مسیر drill-down دست‌نخورده می‌ماند + const clearWarehouseScope = () => setSelectedWarehouseIds([]); - 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 projectScopeLabel = () => + scopeLabel(projectOptions, selectedProjectIds, LABELS.allProjects, otherProjectsLabel); + const warehouseScopeLabel = () => + scopeLabel(warehouseOptions, selectedWarehouseIds, LABELS.allWarehouses, otherWarehousesLabel); const getTooltipPrompt = () => { if (level === 0) return "برای مشاهده پروژه‌ها کلیک کنید"; @@ -316,81 +512,110 @@ export function DrilldownMatrix() { }; return ( - -