276 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|