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 سه‌سطحی با انتخاب داینامیک و پشتیبانی از متریال
// «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 هر <Bar> مستقیماً از سطح پراپرتی‌های داده خوانده می‌شود.
interface StackedBar {
id: string;
label: string;
unitLabel: string;
total: number;
segments: Record<string, number>;
[segmentKey: string]: number | string | Record<string, number>;
}
// سطح ریشه + برچسب‌های ثابت 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<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 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, 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 تازه‌ای می‌آید و
// اعتبارسنجی مسیر 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({
<>
<button
type="button"
onClick={() => {
onClick={(event) => {
// رویداد منو هرگز نباید به کلیک‌های نمودار برسد
event.stopPropagation();
onClearAll();
setOpen(false);
}}
@@ -249,7 +371,11 @@ function MultiSelectDropdown({
return (
<div
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"
>
{isSelected ? (
@@ -271,12 +397,10 @@ function MultiSelectDropdown({
export function DrilldownMatrix() {
const query = useDrilldown();
// مسیر drill-down محلی — تنها منبع حقیقت برای سطح و گره‌های پیمایش‌شده؛
// سطح از طول مسیر مشتق می‌شود تا هرگز با شناسه‌های گره ناهماهنگ نشود.
const [path, setPath] = useState<DrillNode[]>([]);
const level: Level = path.length === 0 ? 0 : path.length === 1 ? 1 : 2;
const materialId = path[0]?.id ?? null;
const projectId = path[1]?.id ?? null;
// متریال انتخاب‌شده (سطح ۱) — تنها حالت محلی نمودار؛ سطح از آن مشتق می‌شود
const [selectedMaterial, setSelectedMaterial] = useState<DrillNode | null>(null);
const level: Level = selectedMaterial ? DRILL_LEVEL : ROOT_LEVEL;
const materialId = selectedMaterial?.id ?? null;
// فیلترهای دستی (چندگزینه‌ای پروژه/انبار) — وابسته به payload جاری
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
@@ -317,8 +441,8 @@ export function DrilldownMatrix() {
// ——— همگام‌سازی با فیلتر سراسری (Zustand) ———
// فیلتر سراسری، payload را زیر پای حالت محلی عوض می‌کند؛ به‌جای پاک‌کردن
// کورکورانه‌ی مسیر، حالت محلی را با داده‌ی تازه اعتبارسنجی می‌کنیم تا نه در
// سطح کهنه گیر کنیم و نه بی‌دلیل به عقب بپریم.
// کورکورانه‌ی انتخاب‌ها، حالت محلی را با داده‌ی تازه اعتبارسنجی می‌کنیم تا نه
// در سطح کهنه گیر کنیم و نه بی‌دلیل به عقب بپریم.
const globalFilterKey = useDashboardFilter(selectGlobalFilterKey);
// ۱) انتخاب‌های وابسته به payload (پروژه/انبار) نباید به شناسه‌هایی اشاره کنند
@@ -335,19 +459,14 @@ export function DrilldownMatrix() {
);
}, [globalFilterKey, data, projectOptions, warehouseOptions]);
// ۲) اگر متریال یا پروژه‌ی در حال مشاهده از payload حذف شده باشد، فقط تا
// عمیق‌ترین سطح معتبر عقب می‌آییم؛ مسیر معتبر دست‌نخورده می‌ماند.
// ۲) اگر متریال انتخاب‌شده از payload حذف شده باشد، نمودار به نمای متریال‌ها
// برمی‌گردد؛ انتخاب معتبر دست‌نخورده می‌ماند.
useEffect(() => {
if (!data || path.length === 0) return;
const material = data.materials.find((m) => m.id === path[0].id);
if (!material) {
setPath([]);
return;
if (!data || !selectedMaterial) return;
if (!data.materials.some((m) => m.id === selectedMaterial.id)) {
setSelectedMaterial(null);
}
if (path.length > 1 && !material.projects.some((p) => p.id === path[1].id)) {
setPath((prev) => prev.slice(0, 1));
}
}, [globalFilterKey, data, path]);
}, [globalFilterKey, data, selectedMaterial]);
// ——— اندازه‌گیری عرض کانتینر نمودار ———
// عرض صریح BarChart از همین مقدار و تعداد میله‌ها حساب می‌شود؛ کانتینر
@@ -362,114 +481,202 @@ export function DrilldownMatrix() {
return () => observer.disconnect();
}, []);
// Helper to sum all units and get unit label
const getAggregatedQtyAndUnit = (qtyByUnit: Record<string, number>) => {
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 };
};
// Core Aggregation Logic
const slices: Slice[] = useMemo(() => {
if (!data) return [];
const res: Slice[] = [];
// ——— داده‌ی نمودار ۱۰۰٪ استک‌شده ———
// همه‌ی محورها، گروه‌ها، واحدها و رنگ‌ها از payload مشتق می‌شوند؛ هیچ نگاشت
// دستی (متریال/پروژه/رنگ) در کد وجود ندارد.
const { bars, stacks, unitLabels } = useMemo(() => {
const empty = {
bars: [] as StackedBar[],
stacks: [] as StackGroup[],
unitLabels: [] as string[],
};
if (!data) return empty;
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<string, number> = {};
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<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 });
});
interface GroupAcc {
name: string;
byUnit: Record<string, number>;
}
const barsAcc = new Map<
string,
{ id: string; label: string; ownUnits: Record<string, number>; groups: Map<string, GroupAcc> }
>();
// هر میله حتی بدون گروه هم ساخته می‌شود تا برچسبش روی محور بماند
// (متریال‌های کاتالوگی با موجودی صفر هم روی محور دیده می‌شوند).
const ensureBar = (id: string, label: string, ownUnits: Record<string, number>) => {
let bar = barsAcc.get(id);
if (!bar) {
bar = { id, label, ownUnits: numericValues(ownUnits), groups: new Map() };
barsAcc.set(id, bar);
}
} 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)) {
return bar;
};
const addGroup = (
bar: { groups: Map<string, GroupAcc> },
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) => {
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 });
addGroup(bar, proj.id, proj.name, wh.qty_by_unit);
});
});
});
} 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 فاصله‌ی کافی داشته باشند؛ هرگز باریک‌تر از عرض واقعی
// کانتینر (و کف تعیین‌شده) نمی‌شود.
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) => {
// هر کلیک یک سطح عمیق‌تر: متریال ← پروژه ← انبار (عمیق‌تر از این وجود ندارد)
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;
});
// ——— قفل هوشمند drill-down ———
// اگر کاربر انبار(های) مشخصی را در دامنه‌ی سراسری فیلتر کرده باشد، عمیق‌تر
// رفتن روی نمودار بی‌معناست؛ کلیک بی‌اثر و نشانگر پیش‌فرض می‌شود.
const drillLocked = selectedWarehouseIds.length > 0;
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 });
};
// پرش به هر گرهی مسیر — هم سطح نمودار را برمیگرداند و هم فیلترهای ریزتر
// (اسکوپ‌های پروژه/انبار) را خودکار آزاد می‌کند تا کاربر مجبور نباشد تیک‌ها را
// دستی بردارد. فیلتر سراسری «چه چیزی» (Zustand) دست‌نخورده می‌ماند.
const jumpToLevel = (target: Level) => {
setPath((prev) => prev.slice(0, target));
if (target === ROOT_LEVEL) {
// بازگشت به «کل سازمان»: هر دو انتخاب پروژه و انبار پاک می‌شوند
setSelectedProjectIds([]);
setSelectedWarehouseIds([]);
return;
}
// گره‌های پایین‌تر: فقط ریزترین انتخاب (انبارها) آزاد می‌شود؛
// اسکوپ پروژه‌ی جاری دست‌نخورده می‌ماند.
// بازگشت کامل به «کل سازمان» — متریال انتخابی و هر دو دامنه پاک می‌شوند
const resetToRoot = () => {
setSelectedMaterial(null);
setSelectedProjectIds([]);
setSelectedWarehouseIds([]);
};
// گره‌های drill-down در breadcrumb (متریال، سپس پروژه) — گره‌های دامنه جدا رندر می‌شوند
const drillCrumbs = path.map<{ level: Level; label: string }>((node, index) => ({
level: index === 0 ? 1 : 2,
label: node.name,
}));
// حذف تگ متریال — فقط حالت محلی نمودار صفر می‌شود و دامنه‌های سراسری دست‌نخورده می‌مانند
const clearMaterial = () => setSelectedMaterial(null);
// دامنه‌ی پروژه فقط دامنه‌ی انبارِ زیرمجموعه‌اش را آزاد می‌کند
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) =>
@@ -480,43 +687,11 @@ export function DrilldownMatrix() {
: "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 (
<ChartCard title={LABELS.widgetTitle}>
{/* ——— breadcrumb یکپارچه: تنها مسیر پیمایش بالای نمودار ———
گره‌های دامنه (پروژه/انبار) همان فیلترهای سراسری‌اند و گره‌های بعدی
مسیر drill-down؛ کلیک روی گره‌های عقب‌رونده هم سطح نمودار را برمی‌گرداند
و هم دامنه‌های عمیق‌تر را آزاد می‌کند. */}
گره‌های دامنه (پروژه/انبار) همان فیلترهای سراسری‌اند و متریال انتخاب‌شده
به‌صورت تگ حذف‌پذیر می‌آید؛ ✕ و ریشه هر دو انتخاب متریال را صفر می‌کنند. */}
<nav
dir="rtl"
aria-label="مسیر پیمایش گزارش"
@@ -525,7 +700,7 @@ export function DrilldownMatrix() {
{/* ریشه — بازگشت کامل به کل سازمان */}
<button
type="button"
onClick={() => jumpToLevel(ROOT_LEVEL)}
onClick={resetToRoot}
aria-current={level === ROOT_LEVEL ? "page" : undefined}
title={crumbHint(ROOT_LEVEL, LABELS.root)}
className={crumbClass(level === ROOT_LEVEL)}
@@ -533,7 +708,7 @@ export function DrilldownMatrix() {
{LABELS.root}
</button>
{/* گره‌ی دامنه‌ی پروژه — فیلتر سراسری چندانتخابی، برچسب بازتاب انتخاب‌ها */}
{/* گره‌ی دامنه‌ی پروژه — فیلتر سراسری چند‌انتخابی، برچسب بازتاب انتخاب‌ها */}
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<MultiSelectDropdown
label={projectScopeLabel()}
@@ -561,36 +736,46 @@ export function DrilldownMatrix() {
</>
)}
{/* گره‌های drill-down — هر گره همان سطح را بازیابی می‌کند */}
{drillCrumbs.map((crumb) => {
const isCurrent = crumb.level === level;
return (
<Fragment key={crumb.level}>
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
{/* تگ متریال انتخاب‌شده (سطح ۱) — بدنه و ✕ هر دو انتخاب را حذف می‌کنند */}
{selectedMaterial && (
<>
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<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">
<button
type="button"
onClick={() => jumpToLevel(crumb.level)}
aria-current={isCurrent ? "page" : undefined}
title={crumbHint(crumb.level, crumb.label)}
className={crumbClass(isCurrent)}
onClick={clearMaterial}
title={crumbHint(DRILL_LEVEL, selectedMaterial.name)}
className="inline-flex min-w-0 cursor-pointer items-center gap-1 transition-opacity hover:opacity-80"
>
{crumb.label}
<Package aria-hidden className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-[10rem] truncate">{selectedMaterial.name}</span>
</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">
مجموع نمایش داده شده: {fmtNum(levelTotal)}
</span>
<span className="ms-auto text-[11px] tabular-nums text-muted">{totalText}</span>
</nav>
{/* مواد کاتالوگی با موجودی صفر عمداً رندر می‌شوند (محور = کاتالوگ)؛ پیام
«یافت نشد» فقط وقتی است که هیچ ماده‌ای با فیلترها هم‌خوانی نداشته باشد */}
{/* میله‌های صفر عمداً رندر می‌شوند (محور = کاتالوگ)؛ پیام «یافت نشد» فقط
وقتی است که هیچ میله‌ای با فیلترها هم‌خوانی نداشته باشد */}
{query.isLoading ? (
<ChartLoading />
) : !slices.length ? (
<ChartEmpty message="موجودی‌ای با این فیلترها یافت نشد" />
) : !bars.length ? (
<ChartEmpty message={LABELS.emptyChart} />
) : (
<>
{/* کانتینر اسکرول‌دار — عرض صریح نمودار از chartWidth می‌آید تا
@@ -604,12 +789,13 @@ export function DrilldownMatrix() {
<BarChart
width={chartWidth}
height={CHART_HEIGHT}
data={slices}
data={bars}
stackOffset="expand"
margin={{ top: 26, right: 12, left: 8, bottom: 12 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis
dataKey="name"
dataKey="label"
tick={AxisTick}
tickLine={false}
axisLine={{ stroke: "#e5e9f0" }}
@@ -619,85 +805,60 @@ export function DrilldownMatrix() {
/>
<YAxis
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick}
tickFormatter={percentTick}
tickLine={false}
axisLine={false}
width={56}
domain={[0, 1]}
tickCount={5}
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
content={(props: any) => {
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>
);
}}
content={(props) => <StackTooltip {...props} stacks={stacks} />}
/>
<Bar
dataKey="displayQuantity"
name="موجودی"
radius={[6, 6, 0, 0]}
maxBarSize={level === 0 ? 110 : 64}
isAnimationActive={false}
onClick={(entry: unknown) => {
const payload = (entry as { payload?: Slice })?.payload;
if (payload) handleBarClick(payload);
}}
className={level < 2 ? "cursor-pointer transition-opacity hover:opacity-80" : ""}
>
<LabelList
dataKey="displayQuantity"
position="top"
fill="#0f172a"
fontSize={11}
fontWeight={700}
formatter={(v: unknown) => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? fmtNum(n) : "";
}}
{/* هر گروه استک یک Bar است؛ رنگ از ترتیب گروه و گوشه‌های گرد فقط
برای بالاترین گروه (آخرین Bar) اعمال می‌شود */}
{stacks.map((stack, index) => (
<Bar
key={stack.key}
dataKey={stack.key}
stackId={STACK_ID}
fill={stack.color}
radius={index === stacks.length - 1 ? BAR_TOP_RADIUS : 0}
maxBarSize={MAX_BAR_SIZE[level]}
isAnimationActive={false}
onClick={handleBarClick}
className={cn(
"transition-opacity",
drillLocked ? "cursor-default" : "cursor-pointer hover:opacity-90",
)}
/>
{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>
</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">
<MousePointerClick className="h-3.5 w-3.5" />
{getTooltipPrompt()}
{drillHint()}
</p>
)}
</>