Fix drill-down logic and structure in dashboard

This commit is contained in:
fatemeh sane
2026-10-08 15:28:53 +03:30
parent 7bb432e715
commit 34b987398a
3 changed files with 427 additions and 184 deletions
+45 -17
View File
@@ -176,14 +176,17 @@ async def inventory_drilldown(
brand_id=None, brand_id=None,
specs_hash=None, specs_hash=None,
) -> dict: ) -> dict:
"""Stock quantity as a three-level tree; each aggregate keyed by unit code """Stock quantity as a three-level tree: Material -> Project -> Warehouse."""
so incompatible units never merge into one number."""
stmt = ( stmt = (
select( select(
Project.id, Material.id.label("mid"),
Project.name, Material.name.label("mname"),
Warehouse.id, Brand.name.label("bname"),
Warehouse.name, InventoryItem.specs,
Project.id.label("pid"),
Project.name.label("pname"),
Warehouse.id.label("wid"),
Warehouse.name.label("wname"),
Unit.code, Unit.code,
Unit.name_fa, Unit.name_fa,
func.sum(InventoryItem.quantity).label("qty"), func.sum(InventoryItem.quantity).label("qty"),
@@ -194,9 +197,8 @@ async def inventory_drilldown(
.join(Project, Project.id == Warehouse.project_id) .join(Project, Project.id == Warehouse.project_id)
.join(Material, Material.id == InventoryItem.material_id) .join(Material, Material.id == InventoryItem.material_id)
.join(Unit, Unit.id == InventoryItem.unit_id) .join(Unit, Unit.id == InventoryItem.unit_id)
.where(InventoryItem.quantity > 0) .outerjoin(Brand, Brand.id == InventoryItem.brand_id)
.group_by(Project.id, Project.name, Warehouse.id, Warehouse.name, Unit.code, Unit.name_fa) .group_by(Material.id, Material.name, Brand.name, InventoryItem.specs, Project.id, Project.name, Warehouse.id, Warehouse.name, Unit.code, Unit.name_fa)
.order_by(Project.name, Warehouse.name)
) )
stmt = _stock_filters( stmt = _stock_filters(
stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
@@ -207,33 +209,59 @@ async def inventory_drilldown(
units_legend: dict[str, str] = {} units_legend: dict[str, str] = {}
total_by_unit: dict[str, float] = {} total_by_unit: dict[str, float] = {}
total_rows = 0 total_rows = 0
projects_tree: dict[uuid.UUID, dict] = {}
for pid, pname, wid, wname, ucode, uname_fa, qty, row_count in rows: materials_tree: dict[str, dict] = {}
for mid, mname, bname, specs, pid, pname, wid, wname, ucode, uname_fa, qty, row_count in rows:
q = _qty(qty) q = _qty(qty)
units_legend[ucode] = uname_fa units_legend[ucode] = uname_fa
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4) total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
total_rows += row_count total_rows += row_count
proj = projects_tree.setdefault(
# Build full material name
parts = [mname]
if bname:
parts.append(bname)
if specs:
parts.extend(str(v) for v in specs.values() if v)
full_name = " ".join(parts)
# Use full_name + unit as unique key for material level, or just full_name
mat_key = f"{mid}_{full_name}"
mat = materials_tree.setdefault(
mat_key,
{"id": mat_key, "name": full_name, "qty_by_unit": {}, "rows": 0, "_projects": {}},
)
mat["qty_by_unit"][ucode] = round(mat["qty_by_unit"].get(ucode, 0.0) + q, 4)
mat["rows"] += row_count
proj = mat["_projects"].setdefault(
pid, pid,
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}}, {"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}},
) )
proj["qty_by_unit"][ucode] = round(proj["qty_by_unit"].get(ucode, 0.0) + q, 4) proj["qty_by_unit"][ucode] = round(proj["qty_by_unit"].get(ucode, 0.0) + q, 4)
proj["rows"] += row_count proj["rows"] += row_count
wh = proj["_warehouses"].setdefault(wid, {"id": str(wid), "name": wname, "qty_by_unit": {}, "rows": 0}) wh = proj["_warehouses"].setdefault(wid, {"id": str(wid), "name": wname, "qty_by_unit": {}, "rows": 0})
wh["qty_by_unit"][ucode] = round(wh["qty_by_unit"].get(ucode, 0.0) + q, 4) wh["qty_by_unit"][ucode] = round(wh["qty_by_unit"].get(ucode, 0.0) + q, 4)
wh["rows"] += row_count wh["rows"] += row_count
projects = [] materials = []
for proj in projects_tree.values(): for mat in materials_tree.values():
projects_list = []
for proj in mat.pop("_projects").values():
warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"]) warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"])
projects.append({**proj, "warehouses": warehouses}) projects_list.append({**proj, "warehouses": warehouses})
projects.sort(key=lambda p: p["name"]) projects_list.sort(key=lambda p: p["name"])
materials.append({**mat, "projects": projects_list})
materials.sort(key=lambda m: m["name"])
return { return {
"data": { "data": {
"units": units_legend, "units": units_legend,
"total": {"qty_by_unit": total_by_unit, "rows": total_rows}, "total": {"qty_by_unit": total_by_unit, "rows": total_rows},
"projects": projects, "materials": materials,
} }
} }
@@ -1,26 +1,19 @@
// ============================================================ // ============================================================
// ویجت ۱ — ماتریس Drill-Down سه‌سطحی (سبک Power BI) // ویجت ۱ — ماتریس Drill-Down سه‌سطحی با انتخاب داینامیک و پشتیبانی از متریال
// سطح ۱ «کل سازمان» → یک ستون: تجمیع موجودی همه‌ی انبارهای سازمان
// سطح ۲ «پروژه‌ها» → با کلیک روی سطح ۱: سهم هر پروژه
// سطح ۳ «انبارها» → با کلیک روی هر پروژه: انبارهای همان پروژه
// همه‌ی سطوح از پاسخ /dashboard/inventory/drilldown ساخته می‌شوند —
// هیچ مقدار/نام/وضعیتی در فرانت‌اند هارد‌کد نشده است. اگر پروژه‌ی
// انتخاب‌شده با تغییر فیلترها از داده حذف شود، سطح خودکار به بالا برمی‌گردد.
// ============================================================ // ============================================================
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState, useRef } from "react";
import { import {
Bar, Bar,
BarChart, BarChart,
CartesianGrid, CartesianGrid,
Cell, Cell,
LabelList, LabelList,
ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { ChevronLeft, MousePointerClick } from "lucide-react"; import { ChevronLeft, MousePointerClick, ChevronDown, CheckSquare, Square } from "lucide-react";
import { useDrilldown } from "../../hooks/useDashboardData"; import { useDrilldown } from "../../hooks/useDashboardData";
import { fmtNum } from "../../lib/formatters"; import { fmtNum } from "../../lib/formatters";
import { import {
@@ -28,131 +21,321 @@ import {
ChartCard, ChartCard,
ChartEmpty, ChartEmpty,
ChartLoading, ChartLoading,
ChartTooltip,
UnitPicker,
faAxisTick, faAxisTick,
} from "./chartShared"; } from "./chartShared";
import { cn } from "../../lib/utils";
type Level = 0 | 1 | 2; // 0=سازمان، 1=پروژه‌ها، 2=انبارها type Level = 0 | 1 | 2; // 0=Materials, 1=Projects, 2=Warehouses
interface Slice { interface Slice {
id: string; id: string;
name: string; name: string;
value: number; displayQuantity: number;
rows: number; rows: number;
unitLabel: string;
} }
const LEVEL_TITLES: Record<Level, string> = { const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"];
0: "کل سازمان",
1: "پروژه‌ها", // --- MultiSelect Dropdown Component ---
2: "انبارها", function MultiSelectDropdown({
label,
options,
selectedIds,
onChange,
disabled
}: {
label: string;
options: { id: string; name: string }[];
selectedIds: string[];
onChange: (ids: string[]) => void;
disabled?: boolean;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const toggleItem = (id: string) => {
if (selectedIds.includes(id)) {
onChange(selectedIds.filter((x) => x !== id));
} else {
onChange([...selectedIds, id]);
}
}; };
// پالت سطح انبارها — سایه‌های ایندیگو برای تمایز انبارهای یک پروژه return (
const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"]; <div className="relative inline-block" ref={ref}>
<button
type="button"
disabled={disabled}
onClick={() => setOpen(!open)}
className={cn(
"flex items-center gap-1 rounded-md px-2 py-1 text-[13px] font-extrabold transition-colors",
disabled ? "cursor-not-allowed opacity-50" : "hover:bg-slate-bg hover:text-accent cursor-pointer",
selectedIds.length > 0 ? "text-accent" : "text-ink"
)}
>
<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">
{options.length === 0 ? (
<div className="p-2 text-center text-xs text-muted">گزینه‌ای یافت نشد</div>
) : (
options.map((opt) => {
const isSelected = selectedIds.includes(opt.id);
return (
<div
key={opt.id}
onClick={() => 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"
>
{isSelected ? (
<CheckSquare className="h-4 w-4 text-accent" />
) : (
<Square className="h-4 w-4 text-muted" />
)}
<span className="truncate">{opt.name}</span>
</div>
);
})
)}
</div>
)}
</div>
);
}
export function DrilldownMatrix() { export function DrilldownMatrix() {
const query = useDrilldown(); const query = useDrilldown();
const [unit, setUnit] = useState<string | null>(null);
// Drill-down UI state
const [level, setLevel] = useState<Level>(0); const [level, setLevel] = useState<Level>(0);
const [materialId, setMaterialId] = useState<string | null>(null);
const [projectId, setProjectId] = useState<string | null>(null); const [projectId, setProjectId] = useState<string | null>(null);
// Manual Filter Dropdowns State
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [selectedWarehouseIds, setSelectedWarehouseIds] = useState<string[]>([]);
const data = query.data; const data = query.data;
// واحد فعال: انتخاب کاربر یا بزرگ‌ترین مجموع (پایدار بین رندرها) // Aggregate Dropdown Options dynamically from payload
const activeUnit = useMemo(() => { const projectOptions = useMemo(() => {
if (unit && data?.units?.[unit]) return unit; if (!data) return [];
const entries = Object.entries(data?.total?.qty_by_unit ?? {}); const map = new Map<string, string>();
if (!entries.length) return null; data.materials.forEach((m) => {
return entries.sort((a, b) => b[1] - a[1])[0][0]; m.projects.forEach((p) => {
}, [unit, data]); map.set(p.id, p.name);
});
});
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [data]);
const selectedProject = useMemo( const warehouseOptions = useMemo(() => {
() => (projectId && data ? data.projects.find((p) => p.id === projectId) ?? null : null), if (!data) return [];
[data, projectId], const map = new Map<string, string>();
); data.materials.forEach((m) => {
m.projects.forEach((p) => {
if (selectedProjectIds.length === 0 || selectedProjectIds.includes(p.id)) {
p.warehouses.forEach((w) => {
map.set(w.id, w.name);
});
}
});
});
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [data, selectedProjectIds]);
// کلمپِ خودکار: اگر پروژه‌ی انتخابی با فیلترهای جدید از داده خارج شد، // Prevent invalid filter states
// به سطح پروژه‌ها برگرد (بدون هیچ مقدار ثابت).
useEffect(() => { useEffect(() => {
if (level === 2 && projectId && data && !selectedProject) { if (selectedProjectIds.length > 0) {
setProjectId(null); const validIds = warehouseOptions.map((o) => o.id);
setLevel(1); const filtered = selectedWarehouseIds.filter((id) => validIds.includes(id));
if (filtered.length !== selectedWarehouseIds.length) {
setSelectedWarehouseIds(filtered);
} }
if (data && data.projects.length === 0 && level !== 0) {
setProjectId(null);
setLevel(0);
} }
}, [data, level, projectId, selectedProject]); }, [selectedProjectIds, warehouseOptions]);
const pick = (q: Record<string, number>) => (activeUnit ? q[activeUnit] ?? 0 : 0); // Helper to sum all units and get unit label
const getAggregatedQtyAndUnit = (qtyByUnit: Record<string, number>) => {
let sum = 0;
const unitCodes = Object.keys(qtyByUnit);
unitCodes.forEach(code => sum += qtyByUnit[code]);
const unitLabel = unitCodes.map(code => data?.units[code] || code).join(" و ");
return { qty: sum, unitLabel };
};
// برش‌های سطح فعال — کاملاً از پاسخ API // Core Aggregation Logic
const slices: Slice[] = useMemo(() => { const slices: Slice[] = useMemo(() => {
if (!data) return []; if (!data) return [];
if (level === 0) { const res: Slice[] = [];
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 matchesProject = (pid: string) =>
const levelTotal = slices.reduce((s, x) => s + x.value, 0); selectedProjectIds.length === 0 || selectedProjectIds.includes(pid);
const total = data ? pick(data.total.qty_by_unit) : 0; const matchesWarehouse = (wid: string) =>
selectedWarehouseIds.length === 0 || selectedWarehouseIds.includes(wid);
if (level === 0) {
data.materials.forEach((mat) => {
const mergedQtyByUnit: Record<string, number> = {};
let rows = 0;
mat.projects.forEach((proj) => {
if (!matchesProject(proj.id)) return;
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: mat.id, name: mat.name, displayQuantity: qty, rows, unitLabel });
});
} 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) {
const mat = data.materials.find((m) => m.id === materialId);
if (mat) {
const proj = mat.projects.find((p) => p.id === projectId);
if (proj && matchesProject(proj.id)) {
proj.warehouses.forEach((wh) => {
if (!matchesWarehouse(wh.id)) return;
const { qty, unitLabel } = getAggregatedQtyAndUnit(wh.qty_by_unit);
if (qty >= 0) res.push({ id: wh.id, name: wh.name, displayQuantity: qty, rows: wh.rows, unitLabel });
});
}
}
}
return res.sort((a, b) => b.displayQuantity - a.displayQuantity);
}, [data, level, materialId, projectId, selectedProjectIds, selectedWarehouseIds]);
const levelTotal = slices.reduce((s, x) => s + x.displayQuantity, 0);
const handleBarClick = (slice: Slice) => { const handleBarClick = (slice: Slice) => {
if (level === 0 && data && data.projects.length > 0) { if (level === 0) {
setMaterialId(slice.id);
setLevel(1); setLevel(1);
} else if (level === 1) { } else if (level === 1) {
setProjectId(slice.id); setProjectId(slice.id);
setLevel(2); setLevel(2);
} }
// سطح ۳ برگ است — کلیک دیگری لازم نیست
}; };
const subText = const getOrgTitle = () => {
level === 0 if (selectedProjectIds.length === 0) return "کل سازمان";
? "تجمیع موجودی همه‌ی انبارهای سازمان برای فیلترهای فعال — برای مشاهده‌ی سهم پروژه‌ها کلیک کنید" return projectOptions
: level === 1 .filter((o) => selectedProjectIds.includes(o.id))
? "سهم هر پروژه از موجودی — برای مشاهده‌ی انبارهای هر پروژه روی ستون آن کلیک کنید" .map((o) => o.name)
: `انبارهای پروژه «${selectedProject?.name ?? ""}» — برای بازگشت روی مسیر کلیک کنید`; .join(" و ");
};
const getWarehouseTitle = () => {
if (selectedWarehouseIds.length === 0) return "همه انبارها";
return warehouseOptions
.filter((o) => selectedWarehouseIds.includes(o.id))
.map((o) => o.name)
.join(" و ");
};
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 getTooltipPrompt = () => {
if (level === 0) return "برای مشاهده پروژه‌ها کلیک کنید";
if (level === 1) return "برای مشاهده انبارها کلیک کنید";
return "";
};
return ( return (
<ChartCard <ChartCard title={renderBreadcrumbHeader()}>
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]"> <nav dir="rtl" className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]">
{/* Navigation Breadcrumb (Level Path) */}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setMaterialId(null);
setProjectId(null); setProjectId(null);
setLevel(0); setLevel(0);
}} }}
className={ className={cn(
"rounded-md px-2 py-0.5 font-bold transition-colors",
level === 0 level === 0
? "rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent" ? "bg-accent-bg text-accent"
: "rounded-md px-2 py-0.5 font-bold text-muted transition-colors hover:bg-slate-bg hover:text-ink" : "text-muted hover:bg-slate-bg hover:text-ink"
} )}
> >
{LEVEL_TITLES[0]} گزارش کل
</button> </button>
{level >= 1 && ( {level >= 1 && selectedMaterialName && (
<> <>
<ChevronLeft className="h-3.5 w-3.5 text-muted" /> <ChevronLeft className="h-3.5 w-3.5 text-muted" />
<button <button
@@ -161,42 +344,43 @@ export function DrilldownMatrix() {
setProjectId(null); setProjectId(null);
setLevel(1); setLevel(1);
}} }}
className={ className={cn(
"rounded-md px-2 py-0.5 font-bold transition-colors",
level === 1 level === 1
? "rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent" ? "bg-accent-bg text-accent"
: "rounded-md px-2 py-0.5 font-bold text-muted transition-colors hover:bg-slate-bg hover:text-ink" : "text-muted hover:bg-slate-bg hover:text-ink"
} )}
> >
{LEVEL_TITLES[1]} {selectedMaterialName}
</button> </button>
</> </>
)} )}
{level >= 2 && selectedProject && ( {level >= 2 && selectedProjectName && (
<> <>
<ChevronLeft className="h-3.5 w-3.5 text-muted" /> <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"> <span className="rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent">
{selectedProject.name} {selectedProjectName}
</span> </span>
<span className="text-muted">← {LEVEL_TITLES[2]}</span>
</> </>
)} )}
<span className="ms-auto text-[11px] tabular-nums text-muted"> <span className="ms-auto text-[11px] tabular-nums text-muted">
جمع سطح فعال: {fmtNum(levelTotal)} {unitLabel} مجموع نمایش داده شده: {fmtNum(levelTotal)}
{level > 0 && levelTotal !== total && (
<span className="ms-1">از {fmtNum(total)} {unitLabel} کل سازمان</span>
)}
</span> </span>
</nav> </nav>
{query.isLoading ? ( {query.isLoading ? (
<ChartLoading /> <ChartLoading />
) : !slices.length || (levelTotal === 0 && level === 0) ? ( ) : !slices.length || levelTotal === 0 ? (
<ChartEmpty message="موجودی‌ای با این فیلترها یافت نشد" /> <ChartEmpty message="موجودی‌ای با این فیلترها یافت نشد" />
) : ( ) : (
<> <>
<div dir="ltr" className="h-64 w-full"> <div dir="ltr" className="h-64" style={{ width: '100%', overflowX: 'auto', overflowY: 'hidden' }}>
<ResponsiveContainer width="100%" height="100%"> <BarChart
<BarChart data={slices} margin={{ top: 26, right: 8, left: 8, bottom: 4 }}> 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 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis <XAxis
dataKey="name" dataKey="name"
@@ -205,8 +389,8 @@ export function DrilldownMatrix() {
axisLine={{ stroke: "#e5e9f0" }} axisLine={{ stroke: "#e5e9f0" }}
interval={0} interval={0}
height={44} height={44}
angle={slices.length > 4 ? -18 : 0} angle={0}
textAnchor={slices.length > 4 ? "end" : "middle"} textAnchor="middle"
/> />
<YAxis <YAxis
tick={{ fontSize: 11, fill: "#64748b" }} tick={{ fontSize: 11, fill: "#64748b" }}
@@ -217,14 +401,38 @@ export function DrilldownMatrix() {
/> />
<Tooltip <Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }} cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
content={ content={(props: any) => {
<ChartTooltip const { active, payload, label } = props;
valueFormatter={(v) => `${fmtNum(v)} ${unitLabel}`} 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="value" dataKey="displayQuantity"
name="موجودی" name="موجودی"
radius={[6, 6, 0, 0]} radius={[6, 6, 0, 0]}
maxBarSize={level === 0 ? 110 : 64} maxBarSize={level === 0 ? 110 : 64}
@@ -233,10 +441,10 @@ export function DrilldownMatrix() {
const payload = (entry as { payload?: Slice })?.payload; const payload = (entry as { payload?: Slice })?.payload;
if (payload) handleBarClick(payload); if (payload) handleBarClick(payload);
}} }}
className={level < 2 ? "cursor-pointer" : ""} className={level < 2 ? "cursor-pointer transition-opacity hover:opacity-80" : ""}
> >
<LabelList <LabelList
dataKey="value" dataKey="displayQuantity"
position="top" position="top"
fill="#0f172a" fill="#0f172a"
fontSize={11} fontSize={11}
@@ -260,12 +468,11 @@ export function DrilldownMatrix() {
))} ))}
</Bar> </Bar>
</BarChart> </BarChart>
</ResponsiveContainer>
</div> </div>
{level < 2 && ( {level < 2 && (
<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" />
{level === 0 ? "برای دیدن پروژه‌ها روی ستون کلیک کنید" : "برای دیدن انبارهای هر پروژه روی ستون آن کلیک کنید"} {getTooltipPrompt()}
</p> </p>
)} )}
</> </>
+9 -1
View File
@@ -261,10 +261,18 @@ export interface DrilldownProject {
warehouses: DrilldownWarehouse[]; warehouses: DrilldownWarehouse[];
} }
export interface DrilldownMaterial {
id: string;
name: string;
qty_by_unit: QtyByUnit;
rows: number;
projects: DrilldownProject[];
}
export interface DrilldownData { export interface DrilldownData {
units: Record<string, string>; units: Record<string, string>;
total: { qty_by_unit: QtyByUnit; rows: number }; total: { qty_by_unit: QtyByUnit; rows: number };
projects: DrilldownProject[]; materials: DrilldownMaterial[];
} }
// — جدول موجودی راکد — // — جدول موجودی راکد —