Files
ERP/frontend/src/components/dashboard/DrilldownMatrix.tsx
T

276 lines
10 KiB
TypeScript

// ============================================================
// ویجت ۱ — ماتریس Drill-Down سه‌سطحی (سبک Power BI)
// سطح ۱ «کل سازمان» → یک ستون: تجمیع موجودی همه‌ی انبارهای سازمان
// سطح ۲ «پروژه‌ها» → با کلیک روی سطح ۱: سهم هر پروژه
// سطح ۳ «انبارها» → با کلیک روی هر پروژه: انبارهای همان پروژه
// همه‌ی سطوح از پاسخ /dashboard/inventory/drilldown ساخته می‌شوند —
// هیچ مقدار/نام/وضعیتی در فرانت‌اند هارد‌کد نشده است. اگر پروژه‌ی
// انتخاب‌شده با تغییر فیلترها از داده حذف شود، سطح خودکار به بالا برمی‌گردد.
// ============================================================
import { useEffect, useMemo, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
LabelList,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { ChevronLeft, MousePointerClick } from "lucide-react";
import { useDrilldown } from "../../hooks/useDashboardData";
import { fmtNum } from "../../lib/formatters";
import {
CHART_COLORS,
ChartCard,
ChartEmpty,
ChartLoading,
ChartTooltip,
UnitPicker,
faAxisTick,
} from "./chartShared";
type Level = 0 | 1 | 2; // 0=سازمان، 1=پروژه‌ها، 2=انبارها
interface Slice {
id: string;
name: string;
value: number;
rows: number;
}
const LEVEL_TITLES: Record<Level, string> = {
0: "کل سازمان",
1: "پروژه‌ها",
2: "انبارها",
};
// پالت سطح انبارها — سایه‌های ایندیگو برای تمایز انبارهای یک پروژه
const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"];
export function DrilldownMatrix() {
const query = useDrilldown();
const [unit, setUnit] = useState<string | null>(null);
const [level, setLevel] = useState<Level>(0);
const [projectId, setProjectId] = useState<string | null>(null);
const data = query.data;
// واحد فعال: انتخاب کاربر یا بزرگ‌ترین مجموع (پایدار بین رندرها)
const activeUnit = useMemo(() => {
if (unit && data?.units?.[unit]) return unit;
const entries = Object.entries(data?.total?.qty_by_unit ?? {});
if (!entries.length) return null;
return entries.sort((a, b) => b[1] - a[1])[0][0];
}, [unit, data]);
const selectedProject = useMemo(
() => (projectId && data ? data.projects.find((p) => p.id === projectId) ?? null : null),
[data, projectId],
);
// کلمپِ خودکار: اگر پروژه‌ی انتخابی با فیلترهای جدید از داده خارج شد،
// به سطح پروژه‌ها برگرد (بدون هیچ مقدار ثابت).
useEffect(() => {
if (level === 2 && projectId && data && !selectedProject) {
setProjectId(null);
setLevel(1);
}
if (data && data.projects.length === 0 && level !== 0) {
setProjectId(null);
setLevel(0);
}
}, [data, level, projectId, selectedProject]);
const pick = (q: Record<string, number>) => (activeUnit ? q[activeUnit] ?? 0 : 0);
// برش‌های سطح فعال — کاملاً از پاسخ API
const slices: Slice[] = useMemo(() => {
if (!data) return [];
if (level === 0) {
return [{ id: "__org__", name: "کل سازمان", value: pick(data.total.qty_by_unit), rows: data.total.rows }];
}
if (level === 1) {
return data.projects.map((p) => ({
id: p.id,
name: p.name,
value: pick(p.qty_by_unit),
rows: p.rows,
}));
}
return (selectedProject?.warehouses ?? []).map((w) => ({
id: w.id,
name: w.name,
value: pick(w.qty_by_unit),
rows: w.rows,
}));
}, [data, level, selectedProject, activeUnit]);
const unitLabel = activeUnit ? data?.units?.[activeUnit] || activeUnit : "";
const levelTotal = slices.reduce((s, x) => s + x.value, 0);
const total = data ? pick(data.total.qty_by_unit) : 0;
const handleBarClick = (slice: Slice) => {
if (level === 0 && data && data.projects.length > 0) {
setLevel(1);
} else if (level === 1) {
setProjectId(slice.id);
setLevel(2);
}
// سطح ۳ برگ است — کلیک دیگری لازم نیست
};
const subText =
level === 0
? "تجمیع موجودی همه‌ی انبارهای سازمان برای فیلترهای فعال — برای مشاهده‌ی سهم پروژه‌ها کلیک کنید"
: level === 1
? "سهم هر پروژه از موجودی — برای مشاهده‌ی انبارهای هر پروژه روی ستون آن کلیک کنید"
: `انبارهای پروژه «${selectedProject?.name ?? ""}» — برای بازگشت روی مسیر کلیک کنید`;
return (
<ChartCard
title="ماتریس توزیع موجودی — کجا ذخیره شده؟"
sub={subText}
actions={data ? <UnitPicker units={data.units} value={activeUnit ?? ""} onChange={setUnit} totals={data.total.qty_by_unit} /> : null}
>
{/* مسیر Drill-Down (RTL) */}
<nav dir="rtl" className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]">
<button
type="button"
onClick={() => {
setProjectId(null);
setLevel(0);
}}
className={
level === 0
? "rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent"
: "rounded-md px-2 py-0.5 font-bold text-muted transition-colors hover:bg-slate-bg hover:text-ink"
}
>
{LEVEL_TITLES[0]}
</button>
{level >= 1 && (
<>
<ChevronLeft className="h-3.5 w-3.5 text-muted" />
<button
type="button"
onClick={() => {
setProjectId(null);
setLevel(1);
}}
className={
level === 1
? "rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent"
: "rounded-md px-2 py-0.5 font-bold text-muted transition-colors hover:bg-slate-bg hover:text-ink"
}
>
{LEVEL_TITLES[1]}
</button>
</>
)}
{level >= 2 && selectedProject && (
<>
<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">
{selectedProject.name}
</span>
<span className="text-muted">← {LEVEL_TITLES[2]}</span>
</>
)}
<span className="ms-auto text-[11px] tabular-nums text-muted">
جمع سطح فعال: {fmtNum(levelTotal)} {unitLabel}
{level > 0 && levelTotal !== total && (
<span className="ms-1">از {fmtNum(total)} {unitLabel} کل سازمان</span>
)}
</span>
</nav>
{query.isLoading ? (
<ChartLoading />
) : !slices.length || (levelTotal === 0 && level === 0) ? (
<ChartEmpty message="موجودی‌ای با این فیلترها یافت نشد" />
) : (
<>
<div dir="ltr" className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={slices} margin={{ top: 26, right: 8, left: 8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis
dataKey="name"
tick={{ fontSize: 11, fill: "#64748b", fontFamily: "Vazirmatn" }}
tickLine={false}
axisLine={{ stroke: "#e5e9f0" }}
interval={0}
height={44}
angle={slices.length > 4 ? -18 : 0}
textAnchor={slices.length > 4 ? "end" : "middle"}
/>
<YAxis
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick}
tickLine={false}
axisLine={false}
width={56}
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
content={
<ChartTooltip
valueFormatter={(v) => `${fmtNum(v)} ${unitLabel}`}
/>
}
/>
<Bar
dataKey="value"
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" : ""}
>
<LabelList
dataKey="value"
position="top"
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>
</ResponsiveContainer>
</div>
{level < 2 && (
<p className="mt-1 flex items-center justify-center gap-1 text-[10.5px] text-muted">
<MousePointerClick className="h-3.5 w-3.5" />
{level === 0 ? "برای دیدن پروژه‌ها روی ستون کلیک کنید" : "برای دیدن انبارهای هر پروژه روی ستون آن کلیک کنید"}
</p>
)}
</>
)}
</ChartCard>
);
}