feat(dashboard): update drill-down logic and implement 100% stacked bars

This commit is contained in:
fatemeh sane
2026-10-14 21:45:20 +03:30
parent 3d16c435d0
commit eedb821e3b
@@ -1,20 +1,20 @@
// ============================================================ // ============================================================
// ویجت ۱ — ماتریس Drill-Down سه‌سطحی با انتخاب داینامیک و پشتیبانی از متریال // ویجت ۱ — ماتریس Drill-Down سلسله‌مراتبی با نمودار ۱۰۰٪ استک‌شده
// «breadcrumb یکپارچه»: تنها مسیر پیمایش بالای نمودار، هم‌زمان بازتاب دو منبع // • سطح ۰: محور X = متریال‌ها، استک‌ها = پروژه‌ها
// حالت است — دامنه‌های سراسری پروژه/انبار (کنترل‌های چندانتخابی، همان گره‌های // • سطح ۱: محور X = پروژه‌های متریال انتخاب‌شده، استک‌ها = انبارها
// میانی) و مسیر drill-down داخلی (متریال ← پروژه). کلیک روی گره‌های عقب‌رونده // • قفل هوشمند: با فیلتر فعال انبار، drill-down غیرفعال می‌شود (کلیک بی‌اثر
// هم سطح نمودار را برمی‌گرداند و هم دامنه‌های عمیق‌تر را آزاد می‌کند؛ فیلتر // و نشانگر پیش‌فرض) چون اسکوپ انبار از قبل بسته شده است.
// سراسری «چه چیزی» (Zustand) دست‌نخورده می‌ماند. با هر تغییر فیلتر سراسری، // • «breadcrumb یکپارچه»: تنها مسیر پیمایش بالای نمودار؛ دامنه‌های سراسری
// مسیر در برابر payload تازه اعتبارسنجی می‌شود تا در سطح کهنه گیر نکند. // پروژه/انبار (کنترل‌های چندانتخابی) و متریال انتخاب‌شده به‌صورت تگ حذف‌پذیر.
// فیلتر سراسری «چه چیزی» (Zustand) دست‌نخورده می‌ماند و متریال انتخابی با
// هر تغییر آن در برابر payload تازه اعتبارسنجی می‌شود.
// ============================================================ // ============================================================
import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {
Bar, Bar,
BarChart, BarChart,
CartesianGrid, CartesianGrid,
Cell,
LabelList,
Tooltip, Tooltip,
XAxis, XAxis,
YAxis, YAxis,
@@ -25,63 +25,104 @@ import {
ChevronDown, ChevronDown,
ChevronLeft, ChevronLeft,
MousePointerClick, MousePointerClick,
Package,
RotateCcw, RotateCcw,
Square, Square,
X,
} from "lucide-react"; } from "lucide-react";
import { useDrilldown } from "../../hooks/useDashboardData"; import { useDrilldown } from "../../hooks/useDashboardData";
import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter"; import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter";
import { faNum, fmtNum } from "../../lib/formatters"; import { faNum, fmtNum } from "../../lib/formatters";
import { import {
CHART_COLORS,
ChartCard, ChartCard,
ChartEmpty, ChartEmpty,
ChartLoading, ChartLoading,
faAxisTick,
} from "./chartShared"; } from "./chartShared";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
type Level = 0 | 1 | 2; // ۰=متریال‌ها، ۱=پروژه‌ها، ۲=انبارها type Level = 0 | 1; // ۰=متریال‌ها (استک: پروژه‌ها)، ۱=پروژه‌ها (استک: انبارها)
interface Slice { // متریال انتخاب‌شده برای drill-down — نام همان لحظه‌ی کلیک ثبت می‌شود تا برچسب
id: string; // تگ breadcrumb حتی هنگام بارگذاری مجدد داده‌ها پایدار بماند.
name: string;
displayQuantity: number;
rows: number;
unitLabel: string;
}
// گرهی مسیر drill-down — نام همان لحظه‌ی کلیک ثبت می‌شود تا برچسب breadcrumb
// حتی هنگام بارگذاری مجدد داده‌ها پایدار بماند.
interface DrillNode { interface DrillNode {
id: string; id: string;
name: string; name: string;
} }
const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"]; // یک گروه استک (پروژه در سطح ۰، انبار در سطح ۱) — نام و رنگ از payload مشتق می‌شوند
interface StackGroup {
key: string;
name: string;
color: string;
}
// یک میله روی محور X — سهم هر گروه (segments) از مجموع همان واحد.
// کمیت هر گروه علاوه بر segments، به‌صورت پراپرتیِ مسطح و عددی هم روی خودِ شیء
// می‌نشیند؛ چون dataKey هر <Bar> مستقیماً از سطح پراپرتی‌های داده خوانده می‌شود.
interface StackedBar {
id: string;
label: string;
unitLabel: string;
total: number;
segments: Record<string, number>;
[segmentKey: string]: number | string | Record<string, number>;
}
// سطح ریشه + برچسب‌های ثابت UI (یک‌جا تعریف می‌شوند تا در JSX تکرار نشوند) // سطح ریشه + برچسب‌های ثابت UI (یک‌جا تعریف می‌شوند تا در JSX تکرار نشوند)
const ROOT_LEVEL: Level = 0; const ROOT_LEVEL: Level = 0;
const DRILL_LEVEL: Level = 1;
const LABELS = { const LABELS = {
root: "کل سازمان", root: "کل سازمان",
allProjects: "همه‌ی پروژه‌ها", allProjects: "همه‌ی پروژه‌ها",
allWarehouses: "همه‌ی انبارها", allWarehouses: "همه‌ی انبارها",
widgetTitle:"", widgetTitle: "",
projectScopeHint: "دامنه‌ی پروژه — برای تغییر انتخاب‌ها کلیک کنید", projectScopeHint: "دامنه‌ی پروژه — برای تغییر انتخاب‌ها کلیک کنید",
warehouseScopeHint: "دامنه‌ی انبار — برای تغییر انتخاب‌ها کلیک کنید", warehouseScopeHint: "دامنه‌ی انبار — برای تغییر انتخاب‌ها کلیک کنید",
drillHint: "برای مشاهده‌ی سهم پروژه‌ها و انبارها روی میله‌ها کلیک کنید",
lockedHint: "فیلتر انبار فعال است؛ برای drill-down ابتدا آن را بردارید",
removeMaterial: "حذف متریال انتخاب‌شده و بازگشت به نمای متریال‌ها",
totalLabel: "مجموع نمایش داده شده",
itemsLabel: "اقلام نمایش داده شده",
tooltipTotal: "مجموع",
emptyChart: "موجودی‌ای با این فیلترها یافت نشد",
} as const; } as const;
// پسوند شمارش گزینه‌های چندانتخابی («… و ۲ پروژه‌ی دیگر») // فراداده‌ی سطوح — برچسب‌های ثابت UI برای محور و گروه‌ها (نام‌ها از payload می‌آیند)
const LEVEL_META: Record<Level, { axisTitle: string; groupTitle: string }> = {
0: { axisTitle: "متریال‌ها", groupTitle: "پروژه‌ها" },
1: { axisTitle: "پروژه‌ها", groupTitle: "انبارها" },
};
// شناسه‌ی استک در Recharts + سقف عرض میله‌ها به‌ازای هر سطح
const STACK_ID = "dashboard-stack";
const MAX_BAR_SIZE: Record<Level, number> = { 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 otherProjectsLabel = (count: number) => `${faNum(count)} پروژه‌ی دیگر`;
const otherWarehousesLabel = (count: number) => `${faNum(count)} انبار دیگر`; const otherWarehousesLabel = (count: number) => `${faNum(count)} انبار دیگر`;
function crumbHint(level: Level, label: string): string { function crumbHint(level: Level, label: string): string {
if (level === ROOT_LEVEL) return `بازگشت به ${LABELS.root} — پاک‌کردن دامنه‌ها و مسیر`; if (level === ROOT_LEVEL) return `بازگشت به ${LABELS.root} — پاک‌کردن دامنه‌ها و متریال انتخابی`;
if (level === 1) return `مشاهده‌ی پروژه‌های «${label}»`; return `حذف «${label}» و بازگشت به نمای ${LEVEL_META[ROOT_LEVEL].axisTitle}`;
return `مشاهده‌ی انبارهای «${label}»`;
} }
// برچسب یک دامنه‌ی چندانتخابی: «همه‌ی …» / نام / «نام + پسوند شمارش» // برچسب یک دامنه‌ی چند‌انتخابی: «همه‌ی …» / نام / «نام + پسوند شمارش»
function scopeLabel( function scopeLabel(
options: { id: string; name: string }[], options: { id: string; name: string }[],
selectedIds: string[], selectedIds: string[],
@@ -95,9 +136,9 @@ function scopeLabel(
} }
// ——— چیدمان نمودار: میله‌ها و برچسب‌های محور X ——— // ——— چیدمان نمودار: میله‌ها و برچسب‌های محور X ———
// هر ماده یک اسلات عرض ثابت می‌گیرد تا میله‌ها پخش شوند و برچسب‌های فارسی // هر میله یک اسلات عرض ثابت می‌گیرد تا میله‌ها پخش شوند و برچسب‌های فارسی
// بلند روی هم نیفتند؛ نام‌های بلندتر در چند خط شکسته می‌شوند. // بلند روی هم نیفتند؛ نام‌های بلندتر در چند خط شکسته می‌شوند.
const AXIS_SLOT_PX = 200; // عرض سهم هر ماده روی محور (px) const AXIS_SLOT_PX = 200; // عرض سهم هر میله روی محور (px)
const MIN_CHART_WIDTH = 800; // کف عرض نمودار (px) const MIN_CHART_WIDTH = 800; // کف عرض نمودار (px)
const CHART_HEIGHT = 360; // ارتفاع نمودار (px) const CHART_HEIGHT = 360; // ارتفاع نمودار (px)
const AXIS_HEIGHT = 110; // ارتفاع ناحیه‌ی برچسب‌های محور X (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, number>): 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<string, number>): Record<string, number> {
const out: Record<string, number> = {};
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 (
<div dir="rtl" className="rounded-xl border border-line bg-panel/95 px-3 py-2 shadow-lg backdrop-blur-sm">
<div className="mb-1 text-[11px] font-bold text-muted">{String(label ?? bar.label)}</div>
<div className="flex flex-col gap-1">
{rows.map((row) => (
<div key={row.stack.key} className="flex items-center gap-2 text-[12px] leading-5">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: row.stack.color }} />
<span className="max-w-[10rem] truncate text-muted">{row.stack.name}</span>
<span className="ms-auto font-bold tabular-nums text-ink">
{fmtNum(row.value)} {bar.unitLabel}
</span>
<span className="w-10 text-end text-[11px] tabular-nums text-accent">
{faNum(Math.round((row.value / total) * 100))}٪
</span>
</div>
))}
</div>
<div className="mt-1 border-t border-line pt-1 text-[11px] text-muted">
{LABELS.tooltipTotal}{" "}
<span className="font-bold tabular-nums text-ink">
{fmtNum(total)} {bar.unitLabel}
</span>
</div>
</div>
);
}
// اثر انگشت فیلتر سراسری (Zustand) — هر تغییر آن یعنی payload تازه‌ای می‌آید و // اثر انگشت فیلتر سراسری (Zustand) — هر تغییر آن یعنی payload تازه‌ای می‌آید و
// اعتبارسنجی مسیر drill-down باید دوباره اجرا شود. // اعتبارسنجی متریال انتخابی باید دوباره اجرا شود.
const selectGlobalFilterKey = (s: DashboardFilterState): string => const selectGlobalFilterKey = (s: DashboardFilterState): string =>
JSON.stringify([s.categoryId, s.materialId, s.brandId, s.specsHash]); JSON.stringify([s.categoryId, s.materialId, s.brandId, s.specsHash]);
// --- MultiSelect Dropdown Component --- // --- MultiSelect Dropdown Component ---
// کنترل چندانتخابی دامنه (پروژه/انبار) — به‌عنوان گرهی چندانتخابیِ breadcrumb // کنترل چند‌انتخابی دامنه (پروژه/انبار) — به‌عنوان گرهی چند‌انتخابیِ breadcrumb
// رندر می‌شود؛ برچسبش بازتاب انتخاب‌های فعلی است و ردیف «همه‌ی …» انتخاب‌ها را // رندر می‌شود؛ برچسبش بازتاب انتخاب‌های فعلی است و ردیف «همه‌ی …» انتخاب‌ها را
// آزاد می‌کند. // آزاد می‌کند. کلیک‌های درون منو با stopPropagation جدا می‌شوند تا هرگز به
// handlerهای نمودار نرسند.
function MultiSelectDropdown({ function MultiSelectDropdown({
label, label,
options, options,
@@ -229,7 +349,9 @@ function MultiSelectDropdown({
<> <>
<button <button
type="button" type="button"
onClick={() => { onClick={(event) => {
// رویداد منو هرگز نباید به کلیک‌های نمودار برسد
event.stopPropagation();
onClearAll(); onClearAll();
setOpen(false); setOpen(false);
}} }}
@@ -249,7 +371,11 @@ function MultiSelectDropdown({
return ( return (
<div <div
key={opt.id} key={opt.id}
onClick={() => toggleItem(opt.id)} onClick={(event) => {
// رویداد انتخاب گزینه هم ایزوله می‌شود
event.stopPropagation();
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" className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-slate-bg"
> >
{isSelected ? ( {isSelected ? (
@@ -271,12 +397,10 @@ function MultiSelectDropdown({
export function DrilldownMatrix() { export function DrilldownMatrix() {
const query = useDrilldown(); const query = useDrilldown();
// مسیر drill-down محلی — تنها منبع حقیقت برای سطح و گره‌های پیمایش‌شده؛ // متریال انتخاب‌شده (سطح ۱) — تنها حالت محلی نمودار؛ سطح از آن مشتق می‌شود
// سطح از طول مسیر مشتق می‌شود تا هرگز با شناسه‌های گره ناهماهنگ نشود. const [selectedMaterial, setSelectedMaterial] = useState<DrillNode | null>(null);
const [path, setPath] = useState<DrillNode[]>([]); const level: Level = selectedMaterial ? DRILL_LEVEL : ROOT_LEVEL;
const level: Level = path.length === 0 ? 0 : path.length === 1 ? 1 : 2; const materialId = selectedMaterial?.id ?? null;
const materialId = path[0]?.id ?? null;
const projectId = path[1]?.id ?? null;
// فیلترهای دستی (چندگزینه‌ای پروژه/انبار) — وابسته به payload جاری // فیلترهای دستی (چندگزینه‌ای پروژه/انبار) — وابسته به payload جاری
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]); const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
@@ -317,8 +441,8 @@ export function DrilldownMatrix() {
// ——— همگام‌سازی با فیلتر سراسری (Zustand) ——— // ——— همگام‌سازی با فیلتر سراسری (Zustand) ———
// فیلتر سراسری، payload را زیر پای حالت محلی عوض می‌کند؛ به‌جای پاک‌کردن // فیلتر سراسری، payload را زیر پای حالت محلی عوض می‌کند؛ به‌جای پاک‌کردن
// کورکورانه‌ی مسیر، حالت محلی را با داده‌ی تازه اعتبارسنجی می‌کنیم تا نه در // کورکورانه‌ی انتخاب‌ها، حالت محلی را با داده‌ی تازه اعتبارسنجی می‌کنیم تا نه
// سطح کهنه گیر کنیم و نه بی‌دلیل به عقب بپریم. // در سطح کهنه گیر کنیم و نه بی‌دلیل به عقب بپریم.
const globalFilterKey = useDashboardFilter(selectGlobalFilterKey); const globalFilterKey = useDashboardFilter(selectGlobalFilterKey);
// ۱) انتخاب‌های وابسته به payload (پروژه/انبار) نباید به شناسه‌هایی اشاره کنند // ۱) انتخاب‌های وابسته به payload (پروژه/انبار) نباید به شناسه‌هایی اشاره کنند
@@ -335,19 +459,14 @@ export function DrilldownMatrix() {
); );
}, [globalFilterKey, data, projectOptions, warehouseOptions]); }, [globalFilterKey, data, projectOptions, warehouseOptions]);
// ۲) اگر متریال یا پروژه‌ی در حال مشاهده از payload حذف شده باشد، فقط تا // ۲) اگر متریال انتخاب‌شده از payload حذف شده باشد، نمودار به نمای متریال‌ها
// عمیق‌ترین سطح معتبر عقب می‌آییم؛ مسیر معتبر دست‌نخورده می‌ماند. // برمی‌گردد؛ انتخاب معتبر دست‌نخورده می‌ماند.
useEffect(() => { useEffect(() => {
if (!data || path.length === 0) return; if (!data || !selectedMaterial) return;
const material = data.materials.find((m) => m.id === path[0].id); if (!data.materials.some((m) => m.id === selectedMaterial.id)) {
if (!material) { setSelectedMaterial(null);
setPath([]);
return;
} }
if (path.length > 1 && !material.projects.some((p) => p.id === path[1].id)) { }, [globalFilterKey, data, selectedMaterial]);
setPath((prev) => prev.slice(0, 1));
}
}, [globalFilterKey, data, path]);
// ——— اندازه‌گیری عرض کانتینر نمودار ——— // ——— اندازه‌گیری عرض کانتینر نمودار ———
// عرض صریح BarChart از همین مقدار و تعداد میله‌ها حساب می‌شود؛ کانتینر // عرض صریح BarChart از همین مقدار و تعداد میله‌ها حساب می‌شود؛ کانتینر
@@ -362,114 +481,202 @@ export function DrilldownMatrix() {
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
// Helper to sum all units and get unit label // ——— داده‌ی نمودار ۱۰۰٪ استک‌شده ———
const getAggregatedQtyAndUnit = (qtyByUnit: Record<string, number>) => { // همه‌ی محورها، گروه‌ها، واحدها و رنگ‌ها از payload مشتق می‌شوند؛ هیچ نگاشت
let sum = 0; // دستی (متریال/پروژه/رنگ) در کد وجود ندارد.
const unitCodes = Object.keys(qtyByUnit); const { bars, stacks, unitLabels } = useMemo(() => {
unitCodes.forEach(code => sum += qtyByUnit[code]); const empty = {
const unitLabel = unitCodes.map(code => data?.units[code] || code).join(" و "); bars: [] as StackedBar[],
return { qty: sum, unitLabel }; stacks: [] as StackGroup[],
}; unitLabels: [] as string[],
};
// Core Aggregation Logic if (!data) return empty;
const slices: Slice[] = useMemo(() => {
if (!data) return [];
const res: Slice[] = [];
const matchesProject = (pid: string) => const matchesProject = (pid: string) =>
selectedProjectIds.length === 0 || selectedProjectIds.includes(pid); selectedProjectIds.length === 0 || selectedProjectIds.includes(pid);
const matchesWarehouse = (wid: string) => const matchesWarehouse = (wid: string) =>
selectedWarehouseIds.length === 0 || selectedWarehouseIds.includes(wid); selectedWarehouseIds.length === 0 || selectedWarehouseIds.includes(wid);
if (level === 0) { interface GroupAcc {
data.materials.forEach((mat) => { name: string;
const mergedQtyByUnit: Record<string, number> = {}; byUnit: Record<string, number>;
let rows = 0; }
mat.projects.forEach((proj) => { const barsAcc = new Map<
if (!matchesProject(proj.id)) return; string,
proj.warehouses.forEach((wh) => { { id: string; label: string; ownUnits: Record<string, number>; groups: Map<string, GroupAcc> }
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 ensureBar = (id: string, label: string, ownUnits: Record<string, number>) => {
}); let bar = barsAcc.get(id);
}); if (!bar) {
const { qty, unitLabel } = getAggregatedQtyAndUnit(mergedQtyByUnit); bar = { id, label, ownUnits: numericValues(ownUnits), groups: new Map() };
if (qty >= 0) res.push({ id: mat.id, name: mat.name, displayQuantity: qty, rows, unitLabel }); barsAcc.set(id, bar);
});
} 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<string, number> = {};
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) { return bar;
const mat = data.materials.find((m) => m.id === materialId); };
if (mat) { const addGroup = (
const proj = mat.projects.find((p) => p.id === projectId); bar: { groups: Map<string, GroupAcc> },
if (proj && matchesProject(proj.id)) { groupId: string,
groupName: string,
qtyByUnit: Record<string, number>,
) => {
let group = bar.groups.get(groupId);
if (!group) {
group = { name: groupName, byUnit: {} };
bar.groups.set(groupId, group);
}
for (const [unitCode, value] of Object.entries(numericValues(qtyByUnit))) {
group.byUnit[unitCode] = (group.byUnit[unitCode] ?? 0) + value;
}
};
if (level === ROOT_LEVEL) {
// میله = متریال، گروه = پروژه
data.materials.forEach((mat) => {
const bar = ensureBar(mat.id, mat.name, mat.qty_by_unit);
mat.projects.forEach((proj) => {
if (!matchesProject(proj.id)) return;
proj.warehouses.forEach((wh) => { proj.warehouses.forEach((wh) => {
if (!matchesWarehouse(wh.id)) return; if (!matchesWarehouse(wh.id)) return;
const { qty, unitLabel } = getAggregatedQtyAndUnit(wh.qty_by_unit); addGroup(bar, proj.id, proj.name, wh.qty_by_unit);
if (qty >= 0) res.push({ id: wh.id, name: wh.name, displayQuantity: qty, rows: wh.rows, unitLabel });
}); });
});
});
} else {
// میله = پروژه‌ی متریال انتخاب‌شده، گروه = انبار
const mat = data.materials.find((m) => m.id === materialId);
mat?.projects.forEach((proj) => {
if (!matchesProject(proj.id)) return;
const bar = ensureBar(proj.id, proj.name, proj.qty_by_unit);
proj.warehouses.forEach((wh) => {
if (!matchesWarehouse(wh.id)) return;
addGroup(bar, wh.id, wh.name, wh.qty_by_unit);
});
});
}
const groupNames = new Map<string, string>();
const bars: StackedBar[] = [];
for (const bar of barsAcc.values()) {
// واحد غالب میله: از مجموع گروه‌ها و در نبود گروه، از واحد خودِ موجودیت
const unitTotals: Record<string, number> = { ...bar.ownUnits };
for (const group of bar.groups.values()) {
for (const [unitCode, value] of Object.entries(group.byUnit)) {
unitTotals[unitCode] = (unitTotals[unitCode] ?? 0) + value;
} }
} }
const unitCode = pickDominantUnit(unitTotals);
const segments: Record<string, number> = {};
let total = 0;
for (const [groupId, group] of bar.groups) {
const value = unitCode ? group.byUnit[unitCode] ?? 0 : 0;
segments[groupId] = value;
total += value;
groupNames.set(groupId, group.name);
}
bars.push({
id: bar.id,
label: bar.label,
unitLabel: unitCode ? data.units[unitCode] || unitCode : "",
total,
segments,
// مسطح‌سازی برای Recharts: هر <Bar dataKey={stack.key}> مقدار عددی را
// مستقیماً از سطح پراپرتی‌های همین شیء می‌خواند
...segments,
});
} }
bars.sort((a, b) => b.total - a.total);
return res.sort((a, b) => b.displayQuantity - a.displayQuantity); // فهرست استک‌ها = اجتماع گروه‌های حاضر؛ ترتیب (و بنابراین رنگ‌ها) از مجموع
}, [data, level, materialId, projectId, selectedProjectIds, selectedWarehouseIds]); // کمیت‌ها مشتق می‌شود و در حالت تساوی با نام، پایدار می‌ماند.
const stackTotals = new Map<string, number>();
for (const bar of bars) {
for (const [groupId, value] of Object.entries(bar.segments)) {
stackTotals.set(groupId, (stackTotals.get(groupId) ?? 0) + value);
}
}
const stacks: StackGroup[] = [...stackTotals.entries()]
.sort(
(a, b) =>
b[1] - a[1] ||
(groupNames.get(a[0]) ?? "").localeCompare(groupNames.get(b[0]) ?? "", "fa"),
)
.map(([key], index) => ({
key,
name: groupNames.get(key) ?? key,
color: groupColor(index),
}));
const levelTotal = slices.reduce((s, x) => s + x.displayQuantity, 0); return {
bars,
stacks,
unitLabels: [...new Set(bars.map((b) => b.unitLabel).filter(Boolean))],
};
}, [data, level, materialId, selectedProjectIds, selectedWarehouseIds]);
// عرض صریح نمودار: هر ماده یک اسلات ثابت می‌گیرد تا میله‌ها پخش شوند و // عرض صریح نمودار: هر میله یک اسلات ثابت می‌گیرد تا میله‌ها پخش شوند و
// برچسب‌های محور X فاصله‌ی کافی داشته باشند؛ هرگز باریک‌تر از عرض واقعی // برچسب‌های محور X فاصله‌ی کافی داشته باشند؛ هرگز باریک‌تر از عرض واقعی
// کانتینر (و کف تعیین‌شده) نمی‌شود. // کانتینر (و کف تعیین‌شده) نمی‌شود.
const chartWidth = Math.max(plotWidth, MIN_CHART_WIDTH, slices.length * AXIS_SLOT_PX); const chartWidth = Math.max(plotWidth, MIN_CHART_WIDTH, bars.length * AXIS_SLOT_PX);
const handleBarClick = (slice: Slice) => { // ——— قفل هوشمند drill-down ———
// هر کلیک یک سطح عمیق‌تر: متریال ← پروژه ← انبار (عمیق‌تر از این وجود ندارد) // اگر کاربر انبار(های) مشخصی را در دامنه‌ی سراسری فیلتر کرده باشد، عمیق‌تر
setPath((prev) => { // رفتن روی نمودار بی‌معناست؛ کلیک بی‌اثر و نشانگر پیش‌فرض می‌شود.
if (prev.length === 0) return [{ id: slice.id, name: slice.name }]; const drillLocked = selectedWarehouseIds.length > 0;
if (prev.length === 1) return [...prev, { id: slice.id, name: slice.name }];
return prev; const handleBarClick = (entry: unknown) => {
}); // قفل فعال → هیچ پرشی انجام نمی‌شود
if (drillLocked) return;
// سطح پروژه‌ها، سطح پایانی است؛ فقط از نمای متریال‌ها drill می‌شویم
if (level !== ROOT_LEVEL) return;
const bar = (entry as { payload?: StackedBar })?.payload;
if (!bar) return;
setSelectedMaterial({ id: bar.id, name: bar.label });
}; };
// پرش به هر گرهی مسیر — هم سطح نمودار را برمیگرداند و هم فیلترهای ریزتر // بازگشت کامل به «کل سازمان» — متریال انتخابی و هر دو دامنه پاک می‌شوند
// (اسکوپ‌های پروژه/انبار) را خودکار آزاد می‌کند تا کاربر مجبور نباشد تیک‌ها را const resetToRoot = () => {
// دستی بردارد. فیلتر سراسری «چه چیزی» (Zustand) دست‌نخورده می‌ماند. setSelectedMaterial(null);
const jumpToLevel = (target: Level) => { setSelectedProjectIds([]);
setPath((prev) => prev.slice(0, target));
if (target === ROOT_LEVEL) {
// بازگشت به «کل سازمان»: هر دو انتخاب پروژه و انبار پاک می‌شوند
setSelectedProjectIds([]);
setSelectedWarehouseIds([]);
return;
}
// گره‌های پایین‌تر: فقط ریزترین انتخاب (انبارها) آزاد می‌شود؛
// اسکوپ پروژه‌ی جاری دست‌نخورده می‌ماند.
setSelectedWarehouseIds([]); setSelectedWarehouseIds([]);
}; };
// گره‌های drill-down در breadcrumb (متریال، سپس پروژه) — گره‌های دامنه جدا رندر می‌شوند // حذف تگ متریال — فقط حالت محلی نمودار صفر می‌شود و دامنه‌های سراسری دست‌نخورده می‌مانند
const drillCrumbs = path.map<{ level: Level; label: string }>((node, index) => ({ const clearMaterial = () => setSelectedMaterial(null);
level: index === 0 ? 1 : 2,
label: node.name, // دامنه‌ی پروژه فقط دامنه‌ی انبارِ زیرمجموعه‌اش را آزاد می‌کند
})); const handleProjectScopeChange = (ids: string[]) => {
setSelectedProjectIds(ids);
if (ids.length === 0) setSelectedWarehouseIds([]);
};
// ردیف «همه‌ی پروژه‌ها» در منوی گره: دامنه‌ی پروژه و دامنه‌ی عمیق‌ترش (انبار) آزاد می‌شود
const clearProjectScope = () => {
setSelectedProjectIds([]);
setSelectedWarehouseIds([]);
};
// انبار ریزترین دامنه است: فقط خودش آزاد می‌شود و قفل drill-down برداشته می‌شود
const clearWarehouseScope = () => setSelectedWarehouseIds([]);
const projectScopeLabel = () =>
scopeLabel(projectOptions, selectedProjectIds, LABELS.allProjects, otherProjectsLabel);
const warehouseScopeLabel = () =>
scopeLabel(warehouseOptions, selectedWarehouseIds, LABELS.allWarehouses, otherWarehousesLabel);
// راهنمای تعامل با نمودار — هماهنگ با سطح جاری و قفل هوشمند
const drillHint = (): string => {
if (level !== ROOT_LEVEL) return "";
return drillLocked ? LABELS.lockedHint : LABELS.drillHint;
};
// جمع زدن کمیت‌های با واحدهای متفاوت بی‌معناست؛ در آن حالت تعداد اقلام می‌آید
const totalText =
unitLabels.length === 1
? `${LABELS.totalLabel}: ${fmtNum(bars.reduce((sum, bar) => sum + bar.total, 0))} ${unitLabels[0]}`
: `${LABELS.itemsLabel}: ${faNum(bars.length)}`;
// ظاهر مشترک گره‌های کلیکی مسیر (گره‌ی جاری: برجسته؛ گره‌های عقب‌رونده: قابل کلیک) // ظاهر مشترک گره‌های کلیکی مسیر (گره‌ی جاری: برجسته؛ گره‌های عقب‌رونده: قابل کلیک)
const crumbClass = (isCurrent: boolean) => const crumbClass = (isCurrent: boolean) =>
@@ -480,43 +687,11 @@ export function DrilldownMatrix() {
: "text-muted hover:bg-slate-bg hover:text-ink", : "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 clearProjectScope = () => {
setSelectedProjectIds([]);
setSelectedWarehouseIds([]);
};
// انبار ریزترین دامنه است: فقط خودش آزاد می‌شود و مسیر drill-down دست‌نخورده می‌ماند
const clearWarehouseScope = () => setSelectedWarehouseIds([]);
const projectScopeLabel = () =>
scopeLabel(projectOptions, selectedProjectIds, LABELS.allProjects, otherProjectsLabel);
const warehouseScopeLabel = () =>
scopeLabel(warehouseOptions, selectedWarehouseIds, LABELS.allWarehouses, otherWarehousesLabel);
const getTooltipPrompt = () => {
if (level === 0) return "برای مشاهده پروژه‌ها کلیک کنید";
if (level === 1) return "برای مشاهده انبارها کلیک کنید";
return "";
};
return ( return (
<ChartCard title={LABELS.widgetTitle}> <ChartCard title={LABELS.widgetTitle}>
{/* ——— breadcrumb یکپارچه: تنها مسیر پیمایش بالای نمودار ——— {/* ——— breadcrumb یکپارچه: تنها مسیر پیمایش بالای نمودار ———
گره‌های دامنه (پروژه/انبار) همان فیلترهای سراسری‌اند و گره‌های بعدی گره‌های دامنه (پروژه/انبار) همان فیلترهای سراسری‌اند و متریال انتخاب‌شده
مسیر drill-down؛ کلیک روی گره‌های عقب‌رونده هم سطح نمودار را برمی‌گرداند به‌صورت تگ حذف‌پذیر می‌آید؛ ✕ و ریشه هر دو انتخاب متریال را صفر می‌کنند. */}
و هم دامنه‌های عمیق‌تر را آزاد می‌کند. */}
<nav <nav
dir="rtl" dir="rtl"
aria-label="مسیر پیمایش گزارش" aria-label="مسیر پیمایش گزارش"
@@ -525,7 +700,7 @@ export function DrilldownMatrix() {
{/* ریشه — بازگشت کامل به کل سازمان */} {/* ریشه — بازگشت کامل به کل سازمان */}
<button <button
type="button" type="button"
onClick={() => jumpToLevel(ROOT_LEVEL)} onClick={resetToRoot}
aria-current={level === ROOT_LEVEL ? "page" : undefined} aria-current={level === ROOT_LEVEL ? "page" : undefined}
title={crumbHint(ROOT_LEVEL, LABELS.root)} title={crumbHint(ROOT_LEVEL, LABELS.root)}
className={crumbClass(level === ROOT_LEVEL)} className={crumbClass(level === ROOT_LEVEL)}
@@ -533,7 +708,7 @@ export function DrilldownMatrix() {
{LABELS.root} {LABELS.root}
</button> </button>
{/* گره‌ی دامنه‌ی پروژه — فیلتر سراسری چندانتخابی، برچسب بازتاب انتخاب‌ها */} {/* گره‌ی دامنه‌ی پروژه — فیلتر سراسری چند‌انتخابی، برچسب بازتاب انتخاب‌ها */}
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" /> <ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<MultiSelectDropdown <MultiSelectDropdown
label={projectScopeLabel()} label={projectScopeLabel()}
@@ -561,36 +736,46 @@ export function DrilldownMatrix() {
</> </>
)} )}
{/* گره‌های drill-down — هر گره همان سطح را بازیابی می‌کند */} {/* تگ متریال انتخاب‌شده (سطح ۱) — بدنه و ✕ هر دو انتخاب را حذف می‌کنند */}
{drillCrumbs.map((crumb) => { {selectedMaterial && (
const isCurrent = crumb.level === level; <>
return ( <ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<Fragment key={crumb.level}> <span className="inline-flex min-w-0 items-center gap-1 rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent">
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<button <button
type="button" type="button"
onClick={() => jumpToLevel(crumb.level)} onClick={clearMaterial}
aria-current={isCurrent ? "page" : undefined} title={crumbHint(DRILL_LEVEL, selectedMaterial.name)}
title={crumbHint(crumb.level, crumb.label)} className="inline-flex min-w-0 cursor-pointer items-center gap-1 transition-opacity hover:opacity-80"
className={crumbClass(isCurrent)}
> >
{crumb.label} <Package aria-hidden className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-[10rem] truncate">{selectedMaterial.name}</span>
</button> </button>
</Fragment> <button
); type="button"
})} onClick={(event) => {
// ✕ نباید به handler بدنه‌ی تگ برسد
event.stopPropagation();
clearMaterial();
}}
aria-label={LABELS.removeMaterial}
title={LABELS.removeMaterial}
className="inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full text-accent/70 transition-colors hover:bg-accent/10 hover:text-accent"
>
<X aria-hidden className="h-3 w-3" />
</button>
</span>
</>
)}
<span className="ms-auto text-[11px] tabular-nums text-muted"> <span className="ms-auto text-[11px] tabular-nums text-muted">{totalText}</span>
مجموع نمایش داده شده: {fmtNum(levelTotal)}
</span>
</nav> </nav>
{/* مواد کاتالوگی با موجودی صفر عمداً رندر می‌شوند (محور = کاتالوگ)؛ پیام {/* میله‌های صفر عمداً رندر می‌شوند (محور = کاتالوگ)؛ پیام «یافت نشد» فقط
«یافت نشد» فقط وقتی است که هیچ ماده‌ای با فیلترها هم‌خوانی نداشته باشد */} وقتی است که هیچ میله‌ای با فیلترها هم‌خوانی نداشته باشد */}
{query.isLoading ? ( {query.isLoading ? (
<ChartLoading /> <ChartLoading />
) : !slices.length ? ( ) : !bars.length ? (
<ChartEmpty message="موجودی‌ای با این فیلترها یافت نشد" /> <ChartEmpty message={LABELS.emptyChart} />
) : ( ) : (
<> <>
{/* کانتینر اسکرول‌دار — عرض صریح نمودار از chartWidth می‌آید تا {/* کانتینر اسکرول‌دار — عرض صریح نمودار از chartWidth می‌آید تا
@@ -604,12 +789,13 @@ export function DrilldownMatrix() {
<BarChart <BarChart
width={chartWidth} width={chartWidth}
height={CHART_HEIGHT} height={CHART_HEIGHT}
data={slices} data={bars}
stackOffset="expand"
margin={{ top: 26, right: 12, left: 8, bottom: 12 }} margin={{ top: 26, right: 12, left: 8, bottom: 12 }}
> >
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis <XAxis
dataKey="name" dataKey="label"
tick={AxisTick} tick={AxisTick}
tickLine={false} tickLine={false}
axisLine={{ stroke: "#e5e9f0" }} axisLine={{ stroke: "#e5e9f0" }}
@@ -619,85 +805,60 @@ export function DrilldownMatrix() {
/> />
<YAxis <YAxis
tick={{ fontSize: 11, fill: "#64748b" }} tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick} tickFormatter={percentTick}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
width={56} width={56}
domain={[0, 1]}
tickCount={5}
/> />
<Tooltip <Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }} cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
content={(props: any) => { content={(props) => <StackTooltip {...props} stacks={stacks} />}
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 (
<div dir="rtl" className="rounded-xl border border-line bg-panel/95 px-3 py-2 shadow-lg backdrop-blur-sm">
{label !== undefined && label !== "" && (
<div className="mb-1 text-[11px] font-bold text-muted">
<div className="font-bold">{label}</div>
{level < 2 && (
<div className="text-accent text-[10px] mt-1">{getTooltipPrompt()}</div>
)}
</div>
)}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-[12px] leading-5">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: color }} />
<span className="text-muted">{name}</span>
<span className="ms-auto font-bold tabular-nums text-ink">
{fmtNum(val)} {data.unitLabel}
</span>
</div>
</div>
</div>
);
}}
/> />
<Bar {/* هر گروه استک یک Bar است؛ رنگ از ترتیب گروه و گوشه‌های گرد فقط
dataKey="displayQuantity" برای بالاترین گروه (آخرین Bar) اعمال می‌شود */}
name="موجودی" {stacks.map((stack, index) => (
radius={[6, 6, 0, 0]} <Bar
maxBarSize={level === 0 ? 110 : 64} key={stack.key}
isAnimationActive={false} dataKey={stack.key}
onClick={(entry: unknown) => { stackId={STACK_ID}
const payload = (entry as { payload?: Slice })?.payload; fill={stack.color}
if (payload) handleBarClick(payload); radius={index === stacks.length - 1 ? BAR_TOP_RADIUS : 0}
}} maxBarSize={MAX_BAR_SIZE[level]}
className={level < 2 ? "cursor-pointer transition-opacity hover:opacity-80" : ""} isAnimationActive={false}
> onClick={handleBarClick}
<LabelList className={cn(
dataKey="displayQuantity" "transition-opacity",
position="top" drillLocked ? "cursor-default" : "cursor-pointer hover:opacity-90",
fill="#0f172a" )}
fontSize={11}
fontWeight={700}
formatter={(v: unknown) => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? fmtNum(n) : "";
}}
/> />
{slices.map((s, i) => ( ))}
<Cell
key={s.id}
fill={
level === 0
? CHART_COLORS.accent
: level === 1
? "#818cf8"
: WAREHOUSE_PALETTE[i % WAREHOUSE_PALETTE.length]
}
/>
))}
</Bar>
</BarChart> </BarChart>
</div> </div>
{level < 2 && (
{/* راهنمای رنگ‌ها — از همان گروه‌های payload ساخته می‌شود */}
{stacks.length > 0 && (
<div
dir="rtl"
className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted"
>
<span className="font-bold">
{LEVEL_META[level].axisTitle} × {LEVEL_META[level].groupTitle}:
</span>
{stacks.map((stack) => (
<span key={stack.key} className="inline-flex items-center gap-1">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: stack.color }} />
<span className="max-w-[14rem] truncate">{stack.name}</span>
</span>
))}
</div>
)}
{drillHint() && (
<p className="mt-1 flex items-center justify-center gap-1 text-[10.5px] text-muted"> <p className="mt-1 flex items-center justify-center gap-1 text-[10.5px] text-muted">
<MousePointerClick className="h-3.5 w-3.5" /> <MousePointerClick className="h-3.5 w-3.5" />
{getTooltipPrompt()} {drillHint()}
</p> </p>
)} )}
</> </>