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