chore: restore MVP dashboard components from backup
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
// ============================================================
|
||||
// ویجت ۱ — ماتریس Drill-Down (کل سازمان → پروژه → انبار)
|
||||
// نمودار ستونی تعاملی: کلیک روی هر ستون پروژه، به انبارهای همان
|
||||
// پروژه رفته و مسیر (breadcrumb) بهروز میشود — سبک Power BI.
|
||||
// مقادیر هرگز بین واحدهای ناسازگار مخلوط نمیشوند: واحد فعال با
|
||||
// چیپهای بالای نمودار انتخاب میشود.
|
||||
// ============================================================
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useDrilldown } from "../../hooks/useDashboardData";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import {
|
||||
CHART_COLORS,
|
||||
ChartCard,
|
||||
ChartEmpty,
|
||||
ChartLoading,
|
||||
ChartTooltip,
|
||||
UnitPicker,
|
||||
faAxisTick,
|
||||
} from "./chartShared";
|
||||
|
||||
interface Slice {
|
||||
id: string | null;
|
||||
name: string;
|
||||
value: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export function DrilldownMatrix() {
|
||||
const query = useDrilldown();
|
||||
const [unit, setUnit] = useState<string | null>(null);
|
||||
const [project, setProject] = useState<{ id: string; name: 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 slices: Slice[] = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const pick = (q: Record<string, number>) => (activeUnit ? q[activeUnit] ?? 0 : 0);
|
||||
if (project) {
|
||||
const p = data.projects.find((x) => x.id === project.id);
|
||||
return (p?.warehouses ?? []).map((w) => ({ id: w.id, name: w.name, value: pick(w.qty_by_unit), rows: w.rows }));
|
||||
}
|
||||
return data.projects.map((p) => ({ id: p.id, name: p.name, value: pick(p.qty_by_unit), rows: p.rows }));
|
||||
}, [data, project, activeUnit]);
|
||||
|
||||
const unitLabel = activeUnit ? data?.units?.[activeUnit] || activeUnit : "";
|
||||
const levelTotal = slices.reduce((s, x) => s + x.value, 0);
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="ماتریس توزیع موجودی — کجا ذخیره شده؟"
|
||||
sub={
|
||||
project
|
||||
? `انبارهای پروژه «${project.name}» — برای بازگشت روی مسیر کلیک کنید`
|
||||
: "سهم هر پروژه از کل سازمان — روی هر ستون کلیک کنید تا انبارهای آن پروژه را ببینید"
|
||||
}
|
||||
actions={
|
||||
data ? <UnitPicker units={data.units} value={activeUnit ?? ""} onChange={setUnit} /> : null
|
||||
}
|
||||
>
|
||||
{/* مسیر Drill-Down */}
|
||||
<nav dir="rtl" className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProject(null)}
|
||||
className={`rounded-md px-2 py-0.5 font-bold transition-colors ${
|
||||
project ? "text-muted hover:bg-slate-bg hover:text-ink" : "bg-accent-bg text-accent"
|
||||
}`}
|
||||
>
|
||||
کل سازمان
|
||||
</button>
|
||||
{project && (
|
||||
<>
|
||||
<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">
|
||||
{project.name}
|
||||
</span>
|
||||
<span className="text-muted">← انبار</span>
|
||||
</>
|
||||
)}
|
||||
<span className="ms-auto text-[11px] tabular-nums text-muted">
|
||||
جمع سطح فعال: {faNum(levelTotal.toLocaleString("en-US"))} {unitLabel}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : !slices.length || levelTotal === 0 ? (
|
||||
<ChartEmpty message="موجودیای با این فیلترها یافت نشد" />
|
||||
) : (
|
||||
<div dir="ltr" className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={slices} margin={{ top: 24, 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
|
||||
labelFormatter={(l) => String(l)}
|
||||
valueFormatter={(v) => `${faNum(v.toLocaleString("en-US"))} ${unitLabel}`}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
name="موجودی"
|
||||
radius={[6, 6, 0, 0]}
|
||||
maxBarSize={64}
|
||||
isAnimationActive={false}
|
||||
onClick={(entry: unknown) => {
|
||||
// Recharts ورودی ستون را در آرگومان اول میدهد
|
||||
const payload = (entry as { payload?: Slice })?.payload;
|
||||
if (!project && payload?.id) {
|
||||
setProject({ id: payload.id, name: payload.name });
|
||||
}
|
||||
}}
|
||||
className={project ? "" : "cursor-pointer"}
|
||||
>
|
||||
{slices.map((s) => (
|
||||
<Cell
|
||||
key={s.id ?? s.name}
|
||||
fill={project ? CHART_COLORS.accent : "#818cf8"}
|
||||
fillOpacity={project ? 1 : 0.9}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user