diff --git a/frontend/src/components/dashboard/DrilldownMatrix.tsx b/frontend/src/components/dashboard/DrilldownMatrix.tsx index 40dfaca..8698229 100644 --- a/frontend/src/components/dashboard/DrilldownMatrix.tsx +++ b/frontend/src/components/dashboard/DrilldownMatrix.tsx @@ -1,20 +1,20 @@ // ============================================================ -// ویجت ۱ — ماتریس Drill-Down سه‌سطحی با انتخاب داینامیک و پشتیبانی از متریال -// «breadcrumb یکپارچه»: تنها مسیر پیمایش بالای نمودار، هم‌زمان بازتاب دو منبع -// حالت است — دامنه‌های سراسری پروژه/انبار (کنترل‌های چندانتخابی، همان گره‌های -// میانی) و مسیر drill-down داخلی (متریال ← پروژه). کلیک روی گره‌های عقب‌رونده -// هم سطح نمودار را برمی‌گرداند و هم دامنه‌های عمیق‌تر را آزاد می‌کند؛ فیلتر -// سراسری «چه چیزی» (Zustand) دست‌نخورده می‌ماند. با هر تغییر فیلتر سراسری، -// مسیر در برابر payload تازه اعتبارسنجی می‌شود تا در سطح کهنه گیر نکند. +// ویجت ۱ — ماتریس Drill-Down سلسله‌مراتبی با نمودار ۱۰۰٪ استک‌شده +// • سطح ۰: محور X = متریال‌ها، استک‌ها = پروژه‌ها +// • سطح ۱: محور X = پروژه‌های متریال انتخاب‌شده، استک‌ها = انبارها +// • قفل هوشمند: با فیلتر فعال انبار، drill-down غیرفعال می‌شود (کلیک بی‌اثر +// و نشانگر پیش‌فرض) چون اسکوپ انبار از قبل بسته شده است. +// • «breadcrumb یکپارچه»: تنها مسیر پیمایش بالای نمودار؛ دامنه‌های سراسری +// پروژه/انبار (کنترل‌های چندانتخابی) و متریال انتخاب‌شده به‌صورت تگ حذف‌پذیر. +// فیلتر سراسری «چه چیزی» (Zustand) دست‌نخورده می‌ماند و متریال انتخابی با +// هر تغییر آن در برابر payload تازه اعتبارسنجی می‌شود. // ============================================================ -import { Fragment, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Bar, BarChart, CartesianGrid, - Cell, - LabelList, Tooltip, XAxis, YAxis, @@ -25,63 +25,104 @@ import { ChevronDown, ChevronLeft, MousePointerClick, + Package, RotateCcw, Square, + X, } from "lucide-react"; import { useDrilldown } from "../../hooks/useDashboardData"; import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter"; import { faNum, fmtNum } from "../../lib/formatters"; import { - CHART_COLORS, ChartCard, ChartEmpty, ChartLoading, - faAxisTick, } from "./chartShared"; import { cn } from "../../lib/utils"; -type Level = 0 | 1 | 2; // ۰=متریال‌ها، ۱=پروژه‌ها، ۲=انبارها +type Level = 0 | 1; // ۰=متریال‌ها (استک: پروژه‌ها)، ۱=پروژه‌ها (استک: انبارها) -interface Slice { - id: string; - name: string; - displayQuantity: number; - rows: number; - unitLabel: string; -} - -// گرهی مسیر drill-down — نام همان لحظه‌ی کلیک ثبت می‌شود تا برچسب breadcrumb -// حتی هنگام بارگذاری مجدد داده‌ها پایدار بماند. +// متریال انتخاب‌شده برای drill-down — نام همان لحظه‌ی کلیک ثبت می‌شود تا برچسب +// تگ breadcrumb حتی هنگام بارگذاری مجدد داده‌ها پایدار بماند. interface DrillNode { id: string; name: string; } -const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"]; +// یک گروه استک (پروژه در سطح ۰، انبار در سطح ۱) — نام و رنگ از payload مشتق می‌شوند +interface StackGroup { + key: string; + name: string; + color: string; +} + +// یک میله روی محور X — سهم هر گروه (segments) از مجموع همان واحد. +// کمیت هر گروه علاوه بر segments، به‌صورت پراپرتیِ مسطح و عددی هم روی خودِ شیء +// می‌نشیند؛ چون dataKey هر مستقیماً از سطح پراپرتی‌های داده خوانده می‌شود. +interface StackedBar { + id: string; + label: string; + unitLabel: string; + total: number; + segments: Record; + [segmentKey: string]: number | string | Record; +} // سطح ریشه + برچسب‌های ثابت UI (یک‌جا تعریف می‌شوند تا در JSX تکرار نشوند) const ROOT_LEVEL: Level = 0; +const DRILL_LEVEL: Level = 1; const LABELS = { root: "کل سازمان", allProjects: "همه‌ی پروژه‌ها", allWarehouses: "همه‌ی انبارها", - widgetTitle:"", + widgetTitle: "", projectScopeHint: "دامنه‌ی پروژه — برای تغییر انتخاب‌ها کلیک کنید", warehouseScopeHint: "دامنه‌ی انبار — برای تغییر انتخاب‌ها کلیک کنید", + drillHint: "برای مشاهده‌ی سهم پروژه‌ها و انبارها روی میله‌ها کلیک کنید", + lockedHint: "فیلتر انبار فعال است؛ برای drill-down ابتدا آن را بردارید", + removeMaterial: "حذف متریال انتخاب‌شده و بازگشت به نمای متریال‌ها", + totalLabel: "مجموع نمایش داده شده", + itemsLabel: "اقلام نمایش داده شده", + tooltipTotal: "مجموع", + emptyChart: "موجودی‌ای با این فیلترها یافت نشد", } as const; -// پسوند شمارش گزینه‌های چندانتخابی («… و ۲ پروژه‌ی دیگر») +// فراداده‌ی سطوح — برچسب‌های ثابت UI برای محور و گروه‌ها (نام‌ها از payload می‌آیند) +const LEVEL_META: Record = { + 0: { axisTitle: "متریال‌ها", groupTitle: "پروژه‌ها" }, + 1: { axisTitle: "پروژه‌ها", groupTitle: "انبارها" }, +}; + +// شناسه‌ی استک در Recharts + سقف عرض میله‌ها به‌ازای هر سطح +const STACK_ID = "dashboard-stack"; +const MAX_BAR_SIZE: Record = { 0: 110, 1: 64 }; +const BAR_TOP_RADIUS: [number, number, number, number] = [6, 6, 0, 0]; + +// ——— رنگ گروه‌های استک ——— +// رنگ هر گروه فقط از «ترتیب آن در payload» مشتق می‌شود (هیچ نگاشت دستی +// گروه→رنگ وجود ندارد)؛ چرخش طلایی تضمین می‌کند هر تعداد گروه، رنگ‌های +// به‌اندازه‌ی کافی متمایز بگیرند. +const GROUP_HUE_BASE = 232; // هم‌خانواده با رنگ accent سیستم +const GROUP_HUE_STEP = 137.5; // چرخش طلایی +const GROUP_SATURATION = 58; +const GROUP_LIGHTNESS = 52; + +function groupColor(index: number): string { + const hue = (GROUP_HUE_BASE + index * GROUP_HUE_STEP) % 360; + return `hsl(${hue.toFixed(1)} ${GROUP_SATURATION}% ${GROUP_LIGHTNESS}%)`; +} + +// پسوند شمارش گزینه‌های چند‌انتخابی («… و ۲ پروژه‌ی دیگر») 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}»`; + if (level === ROOT_LEVEL) return `بازگشت به ${LABELS.root} — پاک‌کردن دامنه‌ها و متریال انتخابی`; + return `حذف «${label}» و بازگشت به نمای ${LEVEL_META[ROOT_LEVEL].axisTitle}`; } -// برچسب یک دامنه‌ی چندانتخابی: «همه‌ی …» / نام / «نام + پسوند شمارش» +// برچسب یک دامنه‌ی چند‌انتخابی: «همه‌ی …» / نام / «نام + پسوند شمارش» function scopeLabel( options: { id: string; name: string }[], selectedIds: string[], @@ -95,9 +136,9 @@ function scopeLabel( } // ——— چیدمان نمودار: میله‌ها و برچسب‌های محور X ——— -// هر ماده یک اسلات عرض ثابت می‌گیرد تا میله‌ها پخش شوند و برچسب‌های فارسی +// هر میله یک اسلات عرض ثابت می‌گیرد تا میله‌ها پخش شوند و برچسب‌های فارسی // بلند روی هم نیفتند؛ نام‌های بلندتر در چند خط شکسته می‌شوند. -const AXIS_SLOT_PX = 200; // عرض سهم هر ماده روی محور (px) +const AXIS_SLOT_PX = 200; // عرض سهم هر میله روی محور (px) const MIN_CHART_WIDTH = 800; // کف عرض نمودار (px) const CHART_HEIGHT = 360; // ارتفاع نمودار (px) const AXIS_HEIGHT = 110; // ارتفاع ناحیه‌ی برچسب‌های محور X (px) @@ -156,15 +197,94 @@ function AxisTick({ x, y, payload }: XAxisTickContentProps) { ); } +// تیک محور Y در نمای ۱۰۰٪ — کسر ۰..۱ به درصد فارسی +function percentTick(value: unknown): string { + const n = Number(value); + return Number.isFinite(n) ? `${faNum(Math.round(n * 100))}٪` : ""; +} + +// کمیت‌ها هرگز بین واحدها جمع نمی‌شوند: برای هر میله «واحد غالب» (بیشترین +// مجموع) از خود payload انتخاب می‌شود و فقط کمیت‌های همان واحد استک می‌شوند. +function pickDominantUnit(qtyByUnit: Record): string | null { + let code: string | null = null; + let best = -Infinity; + for (const [unitCode, value] of Object.entries(qtyByUnit)) { + if (value > best) { + best = value; + code = unitCode; + } + } + return code; +} + +// کمیت‌ها همیشه عدد خالص می‌مانند: هر مقدار غیرعددی/نامعتبر (رشته، NaN) از +// محاسبه حذف می‌شود تا میله‌ها هرگز به‌خاطر داده‌ی نامعتبر نامرئی نشوند. +function numericValues(qtyByUnit: Record): Record { + const out: Record = {}; + for (const [unitCode, rawValue] of Object.entries(qtyByUnit)) { + const value = Number(rawValue); + if (Number.isFinite(value)) out[unitCode] = value; + } + return out; +} + +// تولتیپ استک — نام گروه، کمیت خام با واحد، و سهم درصدی از میله +function StackTooltip({ + active, + label, + payload, + stacks, +}: { + active?: boolean; + label?: string | number; + payload?: readonly { payload?: StackedBar }[]; + stacks: StackGroup[]; +}) { + if (!active || !payload?.length) return null; + const bar = payload[0]?.payload; + if (!bar) return null; + const rows = stacks + .map((stack) => ({ stack, value: bar.segments[stack.key] ?? 0 })) + .filter((row) => row.value > 0); + if (rows.length === 0) return null; + const total = rows.reduce((sum, row) => sum + row.value, 0); + return ( +
+
{String(label ?? bar.label)}
+
+ {rows.map((row) => ( +
+ + {row.stack.name} + + {fmtNum(row.value)} {bar.unitLabel} + + + {faNum(Math.round((row.value / total) * 100))}٪ + +
+ ))} +
+
+ {LABELS.tooltipTotal}{" "} + + {fmtNum(total)} {bar.unitLabel} + +
+
+ ); +} + // اثر انگشت فیلتر سراسری (Zustand) — هر تغییر آن یعنی payload تازه‌ای می‌آید و -// اعتبارسنجی مسیر drill-down باید دوباره اجرا شود. +// اعتبارسنجی متریال انتخابی باید دوباره اجرا شود. const selectGlobalFilterKey = (s: DashboardFilterState): string => JSON.stringify([s.categoryId, s.materialId, s.brandId, s.specsHash]); // --- MultiSelect Dropdown Component --- -// کنترل چندانتخابی دامنه (پروژه/انبار) — به‌عنوان گرهی چندانتخابیِ breadcrumb +// کنترل چند‌انتخابی دامنه (پروژه/انبار) — به‌عنوان گرهی چند‌انتخابیِ breadcrumb // رندر می‌شود؛ برچسبش بازتاب انتخاب‌های فعلی است و ردیف «همه‌ی …» انتخاب‌ها را -// آزاد می‌کند. +// آزاد می‌کند. کلیک‌های درون منو با stopPropagation جدا می‌شوند تا هرگز به +// handlerهای نمودار نرسند. function MultiSelectDropdown({ label, options, @@ -229,7 +349,9 @@ function MultiSelectDropdown({ <> - {/* گره‌ی دامنه‌ی پروژه — فیلتر سراسری چندانتخابی، برچسب بازتاب انتخاب‌ها */} + {/* گره‌ی دامنه‌ی پروژه — فیلتر سراسری چند‌انتخابی، برچسب بازتاب انتخاب‌ها */} )} - {/* گره‌های drill-down — هر گره همان سطح را بازیابی می‌کند */} - {drillCrumbs.map((crumb) => { - const isCurrent = crumb.level === level; - return ( - - + {/* تگ متریال انتخاب‌شده (سطح ۱) — بدنه و ✕ هر دو انتخاب را حذف می‌کنند */} + {selectedMaterial && ( + <> + + - - ); - })} + + + + )} - - مجموع نمایش داده شده: {fmtNum(levelTotal)} - + {totalText} - {/* مواد کاتالوگی با موجودی صفر عمداً رندر می‌شوند (محور = کاتالوگ)؛ پیام - «یافت نشد» فقط وقتی است که هیچ ماده‌ای با فیلترها هم‌خوانی نداشته باشد */} + {/* میله‌های صفر عمداً رندر می‌شوند (محور = کاتالوگ)؛ پیام «یافت نشد» فقط + وقتی است که هیچ میله‌ای با فیلترها هم‌خوانی نداشته باشد */} {query.isLoading ? ( - ) : !slices.length ? ( - + ) : !bars.length ? ( + ) : ( <> {/* کانتینر اسکرول‌دار — عرض صریح نمودار از chartWidth می‌آید تا @@ -604,12 +789,13 @@ export function DrilldownMatrix() { { - 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} - -
-
-
- ); - }} + content={(props) => } /> - { - 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) : ""; - }} + {/* هر گروه استک یک Bar است؛ رنگ از ترتیب گروه و گوشه‌های گرد فقط + برای بالاترین گروه (آخرین Bar) اعمال می‌شود */} + {stacks.map((stack, index) => ( + - {slices.map((s, i) => ( - - ))} - + ))}
- {level < 2 && ( + + {/* راهنمای رنگ‌ها — از همان گروه‌های payload ساخته می‌شود */} + {stacks.length > 0 && ( +
+ + {LEVEL_META[level].axisTitle} × {LEVEL_META[level].groupTitle}: + + {stacks.map((stack) => ( + + + {stack.name} + + ))} +
+ )} + + {drillHint() && (

- {getTooltipPrompt()} + {drillHint()}

)}