update dashboard logic

This commit is contained in:
fatemeh sane
2026-10-13 21:49:22 +03:30
parent 34b987398a
commit 3d16c435d0
4 changed files with 566 additions and 182 deletions
+29 -8
View File
@@ -17,6 +17,9 @@ export interface ComboboxProps {
value?: string;
onChange?: (item: ComboboxItem) => void;
items: ComboboxSource; // آرایه از string یا {id, name}
// شناسه‌ی آیتم‌هایی که همیشه در ابتدای فهرست می‌مانند و با جستجوی کاربر
// فیلتر نمی‌شوند (مثل گزینه‌ی «همه‌ی …» در فیلترهای آبشاری داشبورد)
pinnedIds?: string[];
allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید"
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
addSuffix?: string; // متن بعد از +
@@ -50,6 +53,7 @@ export function Combobox({
value,
onChange,
items,
pinnedIds,
allowAdd = true,
addPrefix,
addSuffix = "به عنوان گزینه جدید",
@@ -88,21 +92,37 @@ export function Combobox({
return all.filter((x) => normalize(x.label).includes(q));
}, [all, q]);
// آیتم‌های pin‌شده همیشه در ابتدای فهرست می‌مانند و با جستجو حذف نمی‌شوند؛
// ناوبری با کلیدها هم روی همین فهرست نهایی (visible) انجام می‌شود.
const { visible, pinnedCount, hasUnpinned } = useMemo(() => {
const pin = pinnedIds ?? [];
if (pin.length === 0) return { visible: filtered, pinnedCount: 0, hasUnpinned: false };
const pinned = pin
.map((id) => all.find((x) => x.id === id))
.filter((x): x is ComboboxItem => x !== undefined);
const pinnedSet = new Set(pinned.map((x) => x.id));
const rest = filtered.filter((x) => !pinnedSet.has(x.id));
return { visible: [...pinned, ...rest], pinnedCount: pinned.length, hasUnpinned: rest.length > 0 };
}, [all, filtered, pinnedIds]);
const showAdd =
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q);
allowAdd && q.length > 0 && !visible.some((x) => normalize(x.label) === q);
const showClear = Boolean(onClear) && !disabled && text.length > 0;
const listCount = filtered.length + (showAdd ? 1 : 0);
const listCount = visible.length + (showAdd ? 1 : 0);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
const pick = (it: ComboboxItem) => {
setText(it.label);
// آیتم با شناسه‌ی خالی یعنی «انتخاب نشده» (گزینه‌ی «همه‌ی …»)؛ فیلد به
// placeholder برمی‌گردد تا متن/دکمه‌ی × برای مقدار پاک‌شده باقی نماند.
const nextText = it.id ? it.label : "";
setText(nextText);
setOpen(false);
setHighlight(-1);
onChange?.(it);
onInputChange?.(it.label);
onInputChange?.(nextText);
};
const addCurrent = () => {
@@ -127,9 +147,9 @@ export function Combobox({
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
} else if (e.key === "Enter") {
e.preventDefault();
if (highlight >= 0 && highlight < filtered.length) {
pick(filtered[highlight]);
} else if (highlight === filtered.length && showAdd) {
if (highlight >= 0 && highlight < visible.length) {
pick(visible[highlight]);
} else if (highlight === visible.length && showAdd) {
addCurrent();
}
} else if (e.key === "Escape") {
@@ -197,13 +217,14 @@ export function Combobox({
{listCount === 0 && (
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
)}
{filtered.map((it, i) => (
{visible.map((it, i) => (
<button
type="button"
key={it.id + "|" + i}
className={cn(
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
i === highlight && "bg-slate-bg",
pinnedCount > 0 && hasUnpinned && i === pinnedCount - 1 && "border-b border-line",
)}
onMouseDown={(e) => {
e.preventDefault(); // حفظ فوکوس input
@@ -5,18 +5,45 @@
// دسته → متریال‌های همان دسته
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
// متریال → مشخصات فنی موجودِ همان متریال
// هر فیلد دکمه‌ی «×» دارد که فقط همان سطح (و زیرمجموعه‌اش) را پاک می‌کند.
// هر فیلد «×» مخصوص خودش را دارد و گزینه‌ی «همه‌ی …» در ابتدای هر فهرست
// همان سطح (و سطوح پایین‌دست) را پاک می‌کند؛ دکمه‌ی «حذف فیلترها» کل استور را
// یک‌جا به نمای «کل سازمان» برمی‌گرداند.
// وضعیت در Zustand نگه داشته می‌شود تا با جابه‌جایی بین تب‌ها از دست نرود.
// ============================================================
import { useMemo } from "react";
import { Filter } from "lucide-react";
import { Combobox } from "../Combobox";
import { Filter, X } from "lucide-react";
import { Combobox, type ComboboxItem } from "../Combobox";
import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
import { useDashboardFilter } from "../../store/dashboardFilter";
import { faNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
// گزینه‌ی «همه» — انتخابی که همان سطح و سطوح پایین‌دستش را پاک می‌کند.
// شناسه‌ی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox) و هرگز در استور ذخیره نمی‌شود.
const ALL_OPTION_ID = "";
// این شناسه همیشه در ابتدای فهرست می‌ماند و با جستجوی کاربر فیلتر نمی‌شود
const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID];
// برچسب‌های ثابت UI — placeholder هر فیلد و گزینه‌ی «همه»اش یک متن مشترک دارند
const LABELS = {
allCategories: "همه‌ی دسته‌ها",
allMaterials: "همه‌ی متریال‌ها",
allBrands: "همه‌ی برندها",
allSpecs: "همه‌ی مشخصات",
materialFirst: "ابتدا متریال را انتخاب کنید",
clearAll: "حذف فیلترها",
wholeOrg: "نمای کل سازمان",
} as const;
const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label });
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیش‌فرض)»
function specLabel(specs: Record<string, string> | null): string {
const parts = Object.entries(specs || {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیش‌فرض)";
}
export function DashboardFilterBar({ className }: { className?: string }) {
const filterQuery = useDashboardFilterOptions();
const state = useDashboardFilter();
@@ -39,11 +66,41 @@ export function DashboardFilterBar({ className }: { className?: string }) {
const activeCount = [state.categoryId, state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیش‌فرض)»
const specLabel = (specs: Record<string, string> | null) => {
const parts = Object.entries(specs || {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیش‌فرض)";
};
// انتخاب از فهرست؛ گزینه‌ی «همه» همان سطح را پاک می‌کند (آبشار خودکار)
const pick =
(set: (id: string | null, label: string | null) => void) =>
(item: ComboboxItem) =>
item.id === ALL_OPTION_ID ? set(null, null) : set(item.id, item.label);
// فهرست هر فیلد با گزینه‌ی «همه» در ابتدا
const categoryItems = useMemo<ComboboxItem[]>(
() => [
allOption(LABELS.allCategories),
...categories.filter((c) => c.id).map((c) => ({ id: c.id as string, label: c.name })),
],
[categories],
);
const materialItems = useMemo<ComboboxItem[]>(
() => [allOption(LABELS.allMaterials), ...materialOptions.map((m) => ({ id: m.id, label: m.name }))],
[materialOptions],
);
const brandItems = useMemo<ComboboxItem[]>(
() => [
allOption(LABELS.allBrands),
...(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name })),
],
[selectedMaterial],
);
const specItems = useMemo<ComboboxItem[]>(
() => [
allOption(LABELS.allSpecs),
...(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) })),
],
[selectedMaterial],
);
return (
<section
@@ -62,9 +119,15 @@ export function DashboardFilterBar({ className }: { className?: string }) {
<button
type="button"
onClick={state.reset}
className="ms-auto rounded-full bg-red-bg px-2.5 py-1 text-[11px] font-bold text-red transition-opacity hover:opacity-80"
aria-label={`${LABELS.clearAll} (بازگشت به ${LABELS.wholeOrg})`}
title={`${LABELS.clearAll} — ${LABELS.wholeOrg}`}
className="group ms-auto inline-flex items-center gap-1.5 rounded-full border border-red/20 bg-red-bg px-2.5 py-1.5 text-[11px] font-bold text-red shadow-sm transition-all hover:border-red/40 hover:shadow active:scale-[0.98]"
>
حذف فیلترها ({faNum(activeCount)})
<X aria-hidden className="h-3.5 w-3.5 transition-transform group-hover:rotate-90" />
{LABELS.clearAll}
<span className="rounded-full bg-panel/60 px-1.5 leading-4 tabular-nums">
{faNum(activeCount)}
</span>
</button>
)}
</div>
@@ -73,44 +136,48 @@ export function DashboardFilterBar({ className }: { className?: string }) {
{/* ۱ — دسته‌بندی */}
<Combobox
value={state.categoryLabel ?? ""}
placeholder="همه‌ی دسته‌ها"
placeholder={LABELS.allCategories}
allowAdd={false}
items={categories.filter((c) => c.id).map((c) => ({ id: c.id as string, label: c.name }))}
items={categoryItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setCategory(null, null)}
onChange={(item) => state.setCategory(item.id, item.label)}
onChange={pick(state.setCategory)}
/>
{/* ۲ — متریال (وابسته به دسته‌ی انتخاب‌شده) */}
<Combobox
value={state.materialLabel ?? ""}
placeholder="همه‌ی متریال‌ها"
placeholder={LABELS.allMaterials}
allowAdd={false}
disabled={materialOptions.length === 0}
items={materialOptions.map((m) => ({ id: m.id, label: m.name }))}
items={materialItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setMaterial(null, null)}
onChange={(item) => state.setMaterial(item.id, item.label)}
onChange={pick(state.setMaterial)}
/>
{/* ۳ — برند (فقط برندهای همان متریال) */}
<Combobox
value={state.brandLabel ?? ""}
placeholder={selectedMaterial ? "همه‌ی برندها" : "ابتدا متریال را انتخاب کنید"}
placeholder={selectedMaterial ? LABELS.allBrands : LABELS.materialFirst}
allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
items={(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name }))}
items={brandItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setBrand(null, null)}
onChange={(item) => state.setBrand(item.id, item.label)}
onChange={pick(state.setBrand)}
/>
{/* ۴ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
<Combobox
value={state.specsLabel ?? ""}
placeholder={selectedMaterial ? "همه‌ی مشخصات" : "ابتدا متریال را انتخاب کنید"}
placeholder={selectedMaterial ? LABELS.allSpecs : LABELS.materialFirst}
allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
items={(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) }))}
items={specItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setSpecs(null, null)}
onChange={(item) => state.setSpecs(item.id, item.label)}
onChange={pick(state.setSpecs)}
/>
</div>
</section>
@@ -1,8 +1,14 @@
// ============================================================
// ویجت ۱ — ماتریس Drill-Down سه‌سطحی با انتخاب داینامیک و پشتیبانی از متریال
// «breadcrumb یکپارچه»: تنها مسیر پیمایش بالای نمودار، هم‌زمان بازتاب دو منبع
// حالت است — دامنه‌های سراسری پروژه/انبار (کنترل‌های چندانتخابی، همان گره‌های
// میانی) و مسیر drill-down داخلی (متریال ← پروژه). کلیک روی گره‌های عقب‌رونده
// هم سطح نمودار را برمی‌گرداند و هم دامنه‌های عمیق‌تر را آزاد می‌کند؛ فیلتر
// سراسری «چه چیزی» (Zustand) دست‌نخورده می‌ماند. با هر تغییر فیلتر سراسری،
// مسیر در برابر payload تازه اعتبارسنجی می‌شود تا در سطح کهنه گیر نکند.
// ============================================================
import { useEffect, useMemo, useState, useRef } from "react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import {
Bar,
BarChart,
@@ -13,9 +19,18 @@ import {
XAxis,
YAxis,
} from "recharts";
import { ChevronLeft, MousePointerClick, ChevronDown, CheckSquare, Square } from "lucide-react";
import type { XAxisTickContentProps } from "recharts";
import {
CheckSquare,
ChevronDown,
ChevronLeft,
MousePointerClick,
RotateCcw,
Square,
} from "lucide-react";
import { useDrilldown } from "../../hooks/useDashboardData";
import { fmtNum } from "../../lib/formatters";
import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter";
import { faNum, fmtNum } from "../../lib/formatters";
import {
CHART_COLORS,
ChartCard,
@@ -25,7 +40,7 @@ import {
} from "./chartShared";
import { cn } from "../../lib/utils";
type Level = 0 | 1 | 2; // 0=Materials, 1=Projects, 2=Warehouses
type Level = 0 | 1 | 2; // ۰=متریال‌ها، ۱=پروژه‌ها، ۲=انبارها
interface Slice {
id: string;
@@ -35,21 +50,140 @@ interface Slice {
unitLabel: string;
}
// گرهی مسیر drill-down — نام همان لحظه‌ی کلیک ثبت می‌شود تا برچسب breadcrumb
// حتی هنگام بارگذاری مجدد داده‌ها پایدار بماند.
interface DrillNode {
id: string;
name: string;
}
const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"];
// سطح ریشه + برچسب‌های ثابت UI (یک‌جا تعریف می‌شوند تا در JSX تکرار نشوند)
const ROOT_LEVEL: Level = 0;
const LABELS = {
root: "کل سازمان",
allProjects: "همه‌ی پروژه‌ها",
allWarehouses: "همه‌ی انبارها",
widgetTitle:"",
projectScopeHint: "دامنه‌ی پروژه — برای تغییر انتخاب‌ها کلیک کنید",
warehouseScopeHint: "دامنه‌ی انبار — برای تغییر انتخاب‌ها کلیک کنید",
} as const;
// پسوند شمارش گزینه‌های چندانتخابی («… و ۲ پروژه‌ی دیگر»)
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}»`;
}
// برچسب یک دامنه‌ی چندانتخابی: «همه‌ی …» / نام / «نام + پسوند شمارش»
function scopeLabel(
options: { id: string; name: string }[],
selectedIds: string[],
allLabel: string,
othersLabel: (count: number) => string,
): string {
if (selectedIds.length === 0) return allLabel;
const names = options.filter((o) => selectedIds.includes(o.id)).map((o) => o.name);
const first = names[0] ?? allLabel;
return names.length > 1 ? `${first} و ${othersLabel(names.length - 1)}` : first;
}
// ——— چیدمان نمودار: میله‌ها و برچسب‌های محور X ———
// هر ماده یک اسلات عرض ثابت می‌گیرد تا میله‌ها پخش شوند و برچسب‌های فارسی
// بلند روی هم نیفتند؛ نام‌های بلندتر در چند خط شکسته می‌شوند.
const AXIS_SLOT_PX = 200; // عرض سهم هر ماده روی محور (px)
const MIN_CHART_WIDTH = 800; // کف عرض نمودار (px)
const CHART_HEIGHT = 360; // ارتفاع نمودار (px)
const AXIS_HEIGHT = 110; // ارتفاع ناحیه‌ی برچسب‌های محور X (px)
const AXIS_TICK_MARGIN = 15; // فاصله‌ی خط محور تا خط اول برچسب (px)
const LABEL_LINE_CHARS = 20; // سقف نویسه‌ی هر خط برچسب
const LABEL_MAX_LINES = 3; // حداکثر خطوط هر برچسب
const LABEL_LINE_HEIGHT = 18; // فاصله‌ی خطوط برچسب (px)
// شکستن نام بلند به خطوط کوتاه — پایه‌ی تیک محور X
function wrapAxisLabel(text: string): string[] {
const lines: string[] = [];
for (const word of text.split(/\s+/).filter(Boolean)) {
// واژه‌های بسیار بلند هم به تکه‌های قابل نمایش شکسته می‌شوند
const chunks: string[] = [];
for (let i = 0; i < word.length; i += LABEL_LINE_CHARS) {
chunks.push(word.slice(i, i + LABEL_LINE_CHARS));
}
for (const chunk of chunks) {
const last = lines[lines.length - 1];
if (last && `${last} ${chunk}`.length <= LABEL_LINE_CHARS) {
lines[lines.length - 1] = `${last} ${chunk}`;
} else {
lines.push(chunk);
}
}
}
if (lines.length <= LABEL_MAX_LINES) return lines;
const head = lines.slice(0, LABEL_MAX_LINES - 1);
head.push(`${lines.slice(LABEL_MAX_LINES - 1).join(" ").slice(0, LABEL_LINE_CHARS - 1)}…`);
return head;
}
// تیک سفارشی محور X — برچسب چندخطی با فاصله‌ی کافی (بدون هم‌پوشانی).
// فاصله‌ی خط اول از محور از tickMargin خودِ XAxis می‌آید و شروع متن با
// dominantBaseline=hanging تراز می‌شود (مثل تیک پیش‌فرض Recharts).
function AxisTick({ x, y, payload }: XAxisTickContentProps) {
const xPos = typeof x === "number" ? x : Number(x);
const yPos = typeof y === "number" ? y : Number(y);
const lines = wrapAxisLabel(String(payload?.value ?? ""));
return (
<text
x={xPos}
y={yPos}
textAnchor="middle"
dominantBaseline="hanging"
fill="#64748b"
fontSize={11}
fontFamily="Vazirmatn"
>
{lines.map((line, i) => (
<tspan key={i} x={xPos} dy={i === 0 ? 0 : LABEL_LINE_HEIGHT}>
{line}
</tspan>
))}
</text>
);
}
// اثر انگشت فیلتر سراسری (Zustand) — هر تغییر آن یعنی payload تازه‌ای می‌آید و
// اعتبارسنجی مسیر drill-down باید دوباره اجرا شود.
const selectGlobalFilterKey = (s: DashboardFilterState): string =>
JSON.stringify([s.categoryId, s.materialId, s.brandId, s.specsHash]);
// --- MultiSelect Dropdown Component ---
// کنترل چندانتخابی دامنه (پروژه/انبار) — به‌عنوان گرهی چندانتخابیِ breadcrumb
// رندر می‌شود؛ برچسبش بازتاب انتخاب‌های فعلی است و ردیف «همه‌ی …» انتخاب‌ها را
// آزاد می‌کند.
function MultiSelectDropdown({
label,
options,
selectedIds,
onChange,
disabled
disabled,
allLabel,
onClearAll,
title,
}: {
label: string;
options: { id: string; name: string }[];
selectedIds: string[];
onChange: (ids: string[]) => void;
disabled?: boolean;
// با ارائه‌ی این دو، ردیف «همه‌ی …» در ابتدای فهرست ظاهر می‌شود
allLabel?: string;
onClearAll?: () => void;
title?: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -77,6 +211,7 @@ function MultiSelectDropdown({
<button
type="button"
disabled={disabled}
title={title}
onClick={() => setOpen(!open)}
className={cn(
"flex items-center gap-1 rounded-md px-2 py-1 text-[13px] font-extrabold transition-colors",
@@ -87,9 +222,25 @@ function MultiSelectDropdown({
<span>{label}</span>
<ChevronDown className="h-3.5 w-3.5" />
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-1 max-h-60 w-56 overflow-auto rounded-lg border border-line bg-panel p-1 shadow-xl">
{allLabel && onClearAll && (
<>
<button
type="button"
onClick={() => {
onClearAll();
setOpen(false);
}}
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs font-bold text-accent transition-colors hover:bg-slate-bg"
>
<RotateCcw className="h-3.5 w-3.5" />
<span className="truncate">{allLabel}</span>
</button>
<div className="my-1 border-t border-line" />
</>
)}
{options.length === 0 ? (
<div className="p-2 text-center text-xs text-muted">گزینه‌ای یافت نشد</div>
) : (
@@ -119,19 +270,25 @@ function MultiSelectDropdown({
export function DrilldownMatrix() {
const query = useDrilldown();
// Drill-down UI state
const [level, setLevel] = useState<Level>(0);
const [materialId, setMaterialId] = useState<string | null>(null);
const [projectId, setProjectId] = useState<string | null>(null);
// Manual Filter Dropdowns State
// مسیر 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;
// فیلترهای دستی (چندگزینه‌ای پروژه/انبار) — وابسته به payload جاری
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [selectedWarehouseIds, setSelectedWarehouseIds] = useState<string[]>([]);
// عرض واقعی کانتینر نمودار — تا نمودار هرگز در عرض والد فشرده نشود
const plotRef = useRef<HTMLDivElement>(null);
const [plotWidth, setPlotWidth] = useState(0);
const data = query.data;
// Aggregate Dropdown Options dynamically from payload
// گزینه‌های آبشاری از دل payload ساخته می‌شوند
const projectOptions = useMemo(() => {
if (!data) return [];
const map = new Map<string, string>();
@@ -158,16 +315,52 @@ export function DrilldownMatrix() {
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [data, selectedProjectIds]);
// Prevent invalid filter states
// ——— همگام‌سازی با فیلتر سراسری (Zustand) ———
// فیلتر سراسری، payload را زیر پای حالت محلی عوض می‌کند؛ به‌جای پاک‌کردن
// کورکورانه‌ی مسیر، حالت محلی را با داده‌ی تازه اعتبارسنجی می‌کنیم تا نه در
// سطح کهنه گیر کنیم و نه بی‌دلیل به عقب بپریم.
const globalFilterKey = useDashboardFilter(selectGlobalFilterKey);
// ۱) انتخاب‌های وابسته به payload (پروژه/انبار) نباید به شناسه‌هایی اشاره کنند
// که در نتیجه‌ی فیلتر جاری وجود ندارند؛ وگرنه نمودارِ خالی با چیپ‌های فعال می‌بینیم.
useEffect(() => {
if (selectedProjectIds.length > 0) {
const validIds = warehouseOptions.map((o) => o.id);
const filtered = selectedWarehouseIds.filter((id) => validIds.includes(id));
if (filtered.length !== selectedWarehouseIds.length) {
setSelectedWarehouseIds(filtered);
}
if (!data) return;
const validProjectIds = new Set(projectOptions.map((o) => o.id));
setSelectedProjectIds((ids) =>
ids.every((id) => validProjectIds.has(id)) ? ids : ids.filter((id) => validProjectIds.has(id)),
);
const validWarehouseIds = new Set(warehouseOptions.map((o) => o.id));
setSelectedWarehouseIds((ids) =>
ids.every((id) => validWarehouseIds.has(id)) ? ids : ids.filter((id) => validWarehouseIds.has(id)),
);
}, [globalFilterKey, data, projectOptions, warehouseOptions]);
// ۲) اگر متریال یا پروژه‌ی در حال مشاهده از payload حذف شده باشد، فقط تا
// عمیق‌ترین سطح معتبر عقب می‌آییم؛ مسیر معتبر دست‌نخورده می‌ماند.
useEffect(() => {
if (!data || path.length === 0) return;
const material = data.materials.find((m) => m.id === path[0].id);
if (!material) {
setPath([]);
return;
}
}, [selectedProjectIds, warehouseOptions]);
if (path.length > 1 && !material.projects.some((p) => p.id === path[1].id)) {
setPath((prev) => prev.slice(0, 1));
}
}, [globalFilterKey, data, path]);
// ——— اندازه‌گیری عرض کانتینر نمودار ———
// عرض صریح BarChart از همین مقدار و تعداد میله‌ها حساب می‌شود؛ کانتینر
// اسکرول‌دار است، پس با هر تغییر اندازه‌ی پنجره/کارت دوباره اندازه‌گیری می‌شود.
useEffect(() => {
const el = plotRef.current;
if (!el) return;
const measure = () => setPlotWidth(el.clientWidth);
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, []);
// Helper to sum all units and get unit label
const getAggregatedQtyAndUnit = (qtyByUnit: Record<string, number>) => {
@@ -242,72 +435,75 @@ export function DrilldownMatrix() {
const levelTotal = slices.reduce((s, x) => s + x.displayQuantity, 0);
// عرض صریح نمودار: هر ماده یک اسلات ثابت می‌گیرد تا میله‌ها پخش شوند و
// برچسب‌های محور X فاصله‌ی کافی داشته باشند؛ هرگز باریک‌تر از عرض واقعی
// کانتینر (و کف تعیین‌شده) نمی‌شود.
const chartWidth = Math.max(plotWidth, MIN_CHART_WIDTH, slices.length * AXIS_SLOT_PX);
const handleBarClick = (slice: Slice) => {
if (level === 0) {
setMaterialId(slice.id);
setLevel(1);
} else if (level === 1) {
setProjectId(slice.id);
setLevel(2);
// هر کلیک یک سطح عمیق‌تر: متریال ← پروژه ← انبار (عمیق‌تر از این وجود ندارد)
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;
});
};
// پرش به هر گرهی مسیر — هم سطح نمودار را برمیگرداند و هم فیلترهای ریزتر
// (اسکوپ‌های پروژه/انبار) را خودکار آزاد می‌کند تا کاربر مجبور نباشد تیک‌ها را
// دستی بردارد. فیلتر سراسری «چه چیزی» (Zustand) دست‌نخورده می‌ماند.
const jumpToLevel = (target: Level) => {
setPath((prev) => prev.slice(0, target));
if (target === ROOT_LEVEL) {
// بازگشت به «کل سازمان»: هر دو انتخاب پروژه و انبار پاک می‌شوند
setSelectedProjectIds([]);
setSelectedWarehouseIds([]);
return;
}
// گره‌های پایین‌تر: فقط ریزترین انتخاب (انبارها) آزاد می‌شود؛
// اسکوپ پروژه‌ی جاری دست‌نخورده می‌ماند.
setSelectedWarehouseIds([]);
};
// گره‌های drill-down در breadcrumb (متریال، سپس پروژه) — گره‌های دامنه جدا رندر می‌شوند
const drillCrumbs = path.map<{ level: Level; label: string }>((node, index) => ({
level: index === 0 ? 1 : 2,
label: node.name,
}));
// ظاهر مشترک گره‌های کلیکی مسیر (گره‌ی جاری: برجسته؛ گره‌های عقب‌رونده: قابل کلیک)
const crumbClass = (isCurrent: boolean) =>
cn(
"max-w-[12rem] cursor-pointer truncate rounded-md px-2 py-0.5 font-bold transition-colors active:scale-[0.98]",
isCurrent
? "bg-accent-bg text-accent hover:bg-accent-bg/70"
: "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 getOrgTitle = () => {
if (selectedProjectIds.length === 0) return "کل سازمان";
return projectOptions
.filter((o) => selectedProjectIds.includes(o.id))
.map((o) => o.name)
.join(" و ");
// ردیف «همه‌ی پروژه‌ها» در منوی گره: دامنه‌ی پروژه و دامنه‌ی عمیق‌ترش (انبار) آزاد می‌شود
const clearProjectScope = () => {
setSelectedProjectIds([]);
setSelectedWarehouseIds([]);
};
const getWarehouseTitle = () => {
if (selectedWarehouseIds.length === 0) return "همه انبارها";
return warehouseOptions
.filter((o) => selectedWarehouseIds.includes(o.id))
.map((o) => o.name)
.join(" و ");
};
// انبار ریزترین دامنه است: فقط خودش آزاد می‌شود و مسیر drill-down دست‌نخورده می‌ماند
const clearWarehouseScope = () => setSelectedWarehouseIds([]);
const selectedMaterialName =
level > 0 && materialId
? data?.materials.find((m) => m.id === materialId)?.name || ""
: "";
const selectedProjectName =
level > 1 && projectId && materialId
? data?.materials
.find((m) => m.id === materialId)
?.projects.find((p) => p.id === projectId)?.name || ""
: "";
const renderBreadcrumbHeader = () => {
return (
<div className="flex flex-wrap items-center gap-1">
<MultiSelectDropdown
label={getOrgTitle()}
options={projectOptions}
selectedIds={selectedProjectIds}
onChange={(ids) => {
setSelectedProjectIds(ids);
if (level === 2 && ids.length > 0 && projectId && !ids.includes(projectId)) {
setLevel(1);
}
}}
/>
{(selectedProjectIds.length > 0 || selectedWarehouseIds.length > 0) && (
<>
<ChevronLeft className="h-3.5 w-3.5 text-muted" />
<MultiSelectDropdown
label={getWarehouseTitle()}
options={warehouseOptions}
selectedIds={selectedWarehouseIds}
onChange={setSelectedWarehouseIds}
/>
</>
)}
</div>
);
};
const projectScopeLabel = () =>
scopeLabel(projectOptions, selectedProjectIds, LABELS.allProjects, otherProjectsLabel);
const warehouseScopeLabel = () =>
scopeLabel(warehouseOptions, selectedWarehouseIds, LABELS.allWarehouses, otherWarehousesLabel);
const getTooltipPrompt = () => {
if (level === 0) return "برای مشاهده پروژه‌ها کلیک کنید";
@@ -316,81 +512,110 @@ export function DrilldownMatrix() {
};
return (
<ChartCard title={renderBreadcrumbHeader()}>
<nav dir="rtl" className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]">
{/* Navigation Breadcrumb (Level Path) */}
<ChartCard title={LABELS.widgetTitle}>
{/* ——— breadcrumb یکپارچه: تنها مسیر پیمایش بالای نمودار ———
گره‌های دامنه (پروژه/انبار) همان فیلترهای سراسری‌اند و گره‌های بعدی
مسیر drill-down؛ کلیک روی گره‌های عقب‌رونده هم سطح نمودار را برمی‌گرداند
و هم دامنه‌های عمیق‌تر را آزاد می‌کند. */}
<nav
dir="rtl"
aria-label="مسیر پیمایش گزارش"
className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]"
>
{/* ریشه — بازگشت کامل به کل سازمان */}
<button
type="button"
onClick={() => {
setMaterialId(null);
setProjectId(null);
setLevel(0);
}}
className={cn(
"rounded-md px-2 py-0.5 font-bold transition-colors",
level === 0
? "bg-accent-bg text-accent"
: "text-muted hover:bg-slate-bg hover:text-ink"
)}
onClick={() => jumpToLevel(ROOT_LEVEL)}
aria-current={level === ROOT_LEVEL ? "page" : undefined}
title={crumbHint(ROOT_LEVEL, LABELS.root)}
className={crumbClass(level === ROOT_LEVEL)}
>
گزارش کل
{LABELS.root}
</button>
{level >= 1 && selectedMaterialName && (
{/* گره‌ی دامنه‌ی پروژه — فیلتر سراسری چندانتخابی، برچسب بازتاب انتخاب‌ها */}
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<MultiSelectDropdown
label={projectScopeLabel()}
options={projectOptions}
selectedIds={selectedProjectIds}
onChange={handleProjectScopeChange}
allLabel={LABELS.allProjects}
onClearAll={clearProjectScope}
title={LABELS.projectScopeHint}
/>
{/* گره‌ی دامنه‌ی انبار — فقط وقتی دامنه‌ی پروژه/انبار فعال است */}
{(selectedProjectIds.length > 0 || selectedWarehouseIds.length > 0) && (
<>
<ChevronLeft className="h-3.5 w-3.5 text-muted" />
<button
type="button"
onClick={() => {
setProjectId(null);
setLevel(1);
}}
className={cn(
"rounded-md px-2 py-0.5 font-bold transition-colors",
level === 1
? "bg-accent-bg text-accent"
: "text-muted hover:bg-slate-bg hover:text-ink"
)}
>
{selectedMaterialName}
</button>
</>
)}
{level >= 2 && selectedProjectName && (
<>
<ChevronLeft className="h-3.5 w-3.5 text-muted" />
<span className="rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent">
{selectedProjectName}
</span>
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<MultiSelectDropdown
label={warehouseScopeLabel()}
options={warehouseOptions}
selectedIds={selectedWarehouseIds}
onChange={setSelectedWarehouseIds}
allLabel={LABELS.allWarehouses}
onClearAll={clearWarehouseScope}
title={LABELS.warehouseScopeHint}
/>
</>
)}
{/* گره‌های 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" />
<button
type="button"
onClick={() => jumpToLevel(crumb.level)}
aria-current={isCurrent ? "page" : undefined}
title={crumbHint(crumb.level, crumb.label)}
className={crumbClass(isCurrent)}
>
{crumb.label}
</button>
</Fragment>
);
})}
<span className="ms-auto text-[11px] tabular-nums text-muted">
مجموع نمایش داده شده: {fmtNum(levelTotal)}
</span>
</nav>
{/* مواد کاتالوگی با موجودی صفر عمداً رندر می‌شوند (محور = کاتالوگ)؛ پیام
«یافت نشد» فقط وقتی است که هیچ ماده‌ای با فیلترها هم‌خوانی نداشته باشد */}
{query.isLoading ? (
<ChartLoading />
) : !slices.length || levelTotal === 0 ? (
) : !slices.length ? (
<ChartEmpty message="موجودی‌ای با این فیلترها یافت نشد" />
) : (
<>
<div dir="ltr" className="h-64" style={{ width: '100%', overflowX: 'auto', overflowY: 'hidden' }}>
<BarChart
width={Math.max(typeof window !== 'undefined' ? window.innerWidth - 100 : 800, slices.length * 180)}
height={256}
data={slices}
margin={{ top: 26, right: 8, left: 8, bottom: 4 }}
{/* کانتینر اسکرول‌دار — عرض صریح نمودار از chartWidth می‌آید تا
میله‌ها پخش شوند و برچسب‌ها فاصله‌ی کافی داشته باشند */}
<div
ref={plotRef}
dir="ltr"
className="w-full overflow-x-auto overflow-y-hidden"
style={{ width: '100%', height: CHART_HEIGHT }}
>
<BarChart
width={chartWidth}
height={CHART_HEIGHT}
data={slices}
margin={{ top: 26, right: 12, left: 8, bottom: 12 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis
dataKey="name"
tick={{ fontSize: 11, fill: "#64748b", fontFamily: "Vazirmatn" }}
tick={AxisTick}
tickLine={false}
axisLine={{ stroke: "#e5e9f0" }}
interval={0}
height={44}
angle={0}
textAnchor="middle"
height={AXIS_HEIGHT}
tickMargin={AXIS_TICK_MARGIN}
/>
<YAxis
tick={{ fontSize: 11, fill: "#64748b" }}