Fix drill-down logic and structure in dashboard
This commit is contained in:
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// — جدول موجودی راکد —
|
// — جدول موجودی راکد —
|
||||||
|
|||||||
Reference in New Issue
Block a user