feat(dashboard): update drill-down logic and implement 100% stacked bars
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user