Fix drill-down logic and structure in dashboard
This commit is contained in:
@@ -176,14 +176,17 @@ async def inventory_drilldown(
|
||||
brand_id=None,
|
||||
specs_hash=None,
|
||||
) -> dict:
|
||||
"""Stock quantity as a three-level tree; each aggregate keyed by unit code
|
||||
so incompatible units never merge into one number."""
|
||||
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse."""
|
||||
stmt = (
|
||||
select(
|
||||
Project.id,
|
||||
Project.name,
|
||||
Warehouse.id,
|
||||
Warehouse.name,
|
||||
Material.id.label("mid"),
|
||||
Material.name.label("mname"),
|
||||
Brand.name.label("bname"),
|
||||
InventoryItem.specs,
|
||||
Project.id.label("pid"),
|
||||
Project.name.label("pname"),
|
||||
Warehouse.id.label("wid"),
|
||||
Warehouse.name.label("wname"),
|
||||
Unit.code,
|
||||
Unit.name_fa,
|
||||
func.sum(InventoryItem.quantity).label("qty"),
|
||||
@@ -194,9 +197,8 @@ async def inventory_drilldown(
|
||||
.join(Project, Project.id == Warehouse.project_id)
|
||||
.join(Material, Material.id == InventoryItem.material_id)
|
||||
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||
.where(InventoryItem.quantity > 0)
|
||||
.group_by(Project.id, Project.name, Warehouse.id, Warehouse.name, Unit.code, Unit.name_fa)
|
||||
.order_by(Project.name, Warehouse.name)
|
||||
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
|
||||
.group_by(Material.id, Material.name, Brand.name, InventoryItem.specs, Project.id, Project.name, Warehouse.id, Warehouse.name, Unit.code, Unit.name_fa)
|
||||
)
|
||||
stmt = _stock_filters(
|
||||
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] = {}
|
||||
total_by_unit: dict[str, float] = {}
|
||||
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)
|
||||
units_legend[ucode] = uname_fa
|
||||
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
|
||||
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,
|
||||
{"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["rows"] += row_count
|
||||
|
||||
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["rows"] += row_count
|
||||
|
||||
projects = []
|
||||
for proj in projects_tree.values():
|
||||
materials = []
|
||||
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"])
|
||||
projects.append({**proj, "warehouses": warehouses})
|
||||
projects.sort(key=lambda p: p["name"])
|
||||
projects_list.append({**proj, "warehouses": warehouses})
|
||||
projects_list.sort(key=lambda p: p["name"])
|
||||
materials.append({**mat, "projects": projects_list})
|
||||
|
||||
materials.sort(key=lambda m: m["name"])
|
||||
|
||||
return {
|
||||
"data": {
|
||||
"units": units_legend,
|
||||
"total": {"qty_by_unit": total_by_unit, "rows": total_rows},
|
||||
"projects": projects,
|
||||
"materials": materials,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
// ============================================================
|
||||
// ویجت ۱ — ماتریس Drill-Down سهسطحی (سبک Power BI)
|
||||
// سطح ۱ «کل سازمان» → یک ستون: تجمیع موجودی همهی انبارهای سازمان
|
||||
// سطح ۲ «پروژهها» → با کلیک روی سطح ۱: سهم هر پروژه
|
||||
// سطح ۳ «انبارها» → با کلیک روی هر پروژه: انبارهای همان پروژه
|
||||
// همهی سطوح از پاسخ /dashboard/inventory/drilldown ساخته میشوند —
|
||||
// هیچ مقدار/نام/وضعیتی در فرانتاند هاردکد نشده است. اگر پروژهی
|
||||
// انتخابشده با تغییر فیلترها از داده حذف شود، سطح خودکار به بالا برمیگردد.
|
||||
// ویجت ۱ — ماتریس Drill-Down سهسطحی با انتخاب داینامیک و پشتیبانی از متریال
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
LabelList,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { ChevronLeft, MousePointerClick } from "lucide-react";
|
||||
import { ChevronLeft, MousePointerClick, ChevronDown, CheckSquare, Square } from "lucide-react";
|
||||
import { useDrilldown } from "../../hooks/useDashboardData";
|
||||
import { fmtNum } from "../../lib/formatters";
|
||||
import {
|
||||
@@ -28,131 +21,321 @@ import {
|
||||
ChartCard,
|
||||
ChartEmpty,
|
||||
ChartLoading,
|
||||
ChartTooltip,
|
||||
UnitPicker,
|
||||
faAxisTick,
|
||||
} 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 {
|
||||
id: string;
|
||||
name: string;
|
||||
value: number;
|
||||
displayQuantity: number;
|
||||
rows: number;
|
||||
unitLabel: string;
|
||||
}
|
||||
|
||||
const LEVEL_TITLES: Record<Level, string> = {
|
||||
0: "کل سازمان",
|
||||
1: "پروژهها",
|
||||
2: "انبارها",
|
||||
};
|
||||
|
||||
// پالت سطح انبارها — سایههای ایندیگو برای تمایز انبارهای یک پروژه
|
||||
const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"];
|
||||
|
||||
// --- MultiSelect Dropdown Component ---
|
||||
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 (
|
||||
<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() {
|
||||
const query = useDrilldown();
|
||||
const [unit, setUnit] = useState<string | null>(null);
|
||||
|
||||
// Drill-down UI state
|
||||
const [level, setLevel] = useState<Level>(0);
|
||||
const [materialId, setMaterialId] = 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 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]);
|
||||
// Aggregate Dropdown Options dynamically from payload
|
||||
const projectOptions = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const map = new Map<string, string>();
|
||||
data.materials.forEach((m) => {
|
||||
m.projects.forEach((p) => {
|
||||
map.set(p.id, p.name);
|
||||
});
|
||||
});
|
||||
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||
}, [data]);
|
||||
|
||||
const selectedProject = useMemo(
|
||||
() => (projectId && data ? data.projects.find((p) => p.id === projectId) ?? null : null),
|
||||
[data, projectId],
|
||||
);
|
||||
const warehouseOptions = useMemo(() => {
|
||||
if (!data) return [];
|
||||
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(() => {
|
||||
if (level === 2 && projectId && data && !selectedProject) {
|
||||
setProjectId(null);
|
||||
setLevel(1);
|
||||
if (selectedProjectIds.length > 0) {
|
||||
const validIds = warehouseOptions.map((o) => o.id);
|
||||
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(() => {
|
||||
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 res: Slice[] = [];
|
||||
|
||||
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 matchesProject = (pid: string) =>
|
||||
selectedProjectIds.length === 0 || selectedProjectIds.includes(pid);
|
||||
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) => {
|
||||
if (level === 0 && data && data.projects.length > 0) {
|
||||
if (level === 0) {
|
||||
setMaterialId(slice.id);
|
||||
setLevel(1);
|
||||
} else if (level === 1) {
|
||||
setProjectId(slice.id);
|
||||
setLevel(2);
|
||||
}
|
||||
// سطح ۳ برگ است — کلیک دیگری لازم نیست
|
||||
};
|
||||
|
||||
const subText =
|
||||
level === 0
|
||||
? "تجمیع موجودی همهی انبارهای سازمان برای فیلترهای فعال — برای مشاهدهی سهم پروژهها کلیک کنید"
|
||||
: level === 1
|
||||
? "سهم هر پروژه از موجودی — برای مشاهدهی انبارهای هر پروژه روی ستون آن کلیک کنید"
|
||||
: `انبارهای پروژه «${selectedProject?.name ?? ""}» — برای بازگشت روی مسیر کلیک کنید`;
|
||||
const getOrgTitle = () => {
|
||||
if (selectedProjectIds.length === 0) return "کل سازمان";
|
||||
return projectOptions
|
||||
.filter((o) => selectedProjectIds.includes(o.id))
|
||||
.map((o) => o.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 (
|
||||
<ChartCard
|
||||
title="ماتریس توزیع موجودی — کجا ذخیره شده؟"
|
||||
sub={subText}
|
||||
actions={data ? <UnitPicker units={data.units} value={activeUnit ?? ""} onChange={setUnit} totals={data.total.qty_by_unit} /> : null}
|
||||
>
|
||||
{/* مسیر Drill-Down (RTL) */}
|
||||
<ChartCard title={renderBreadcrumbHeader()}>
|
||||
<nav dir="rtl" className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]">
|
||||
{/* Navigation Breadcrumb (Level Path) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMaterialId(null);
|
||||
setProjectId(null);
|
||||
setLevel(0);
|
||||
}}
|
||||
className={
|
||||
className={cn(
|
||||
"rounded-md px-2 py-0.5 font-bold transition-colors",
|
||||
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"
|
||||
}
|
||||
? "bg-accent-bg text-accent"
|
||||
: "text-muted hover:bg-slate-bg hover:text-ink"
|
||||
)}
|
||||
>
|
||||
{LEVEL_TITLES[0]}
|
||||
گزارش کل
|
||||
</button>
|
||||
{level >= 1 && (
|
||||
{level >= 1 && selectedMaterialName && (
|
||||
<>
|
||||
<ChevronLeft className="h-3.5 w-3.5 text-muted" />
|
||||
<button
|
||||
@@ -161,42 +344,43 @@ export function DrilldownMatrix() {
|
||||
setProjectId(null);
|
||||
setLevel(1);
|
||||
}}
|
||||
className={
|
||||
className={cn(
|
||||
"rounded-md px-2 py-0.5 font-bold transition-colors",
|
||||
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"
|
||||
}
|
||||
? "bg-accent-bg text-accent"
|
||||
: "text-muted hover:bg-slate-bg hover:text-ink"
|
||||
)}
|
||||
>
|
||||
{LEVEL_TITLES[1]}
|
||||
{selectedMaterialName}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{level >= 2 && selectedProject && (
|
||||
{level >= 2 && selectedProjectName && (
|
||||
<>
|
||||
<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}
|
||||
{selectedProjectName}
|
||||
</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>
|
||||
)}
|
||||
مجموع نمایش داده شده: {fmtNum(levelTotal)}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : !slices.length || (levelTotal === 0 && level === 0) ? (
|
||||
) : !slices.length || levelTotal === 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 }}>
|
||||
<div dir="ltr" className="h-64" style={{ width: '100%', overflowX: 'auto', overflowY: 'hidden' }}>
|
||||
<BarChart
|
||||
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} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
@@ -205,8 +389,8 @@ export function DrilldownMatrix() {
|
||||
axisLine={{ stroke: "#e5e9f0" }}
|
||||
interval={0}
|
||||
height={44}
|
||||
angle={slices.length > 4 ? -18 : 0}
|
||||
textAnchor={slices.length > 4 ? "end" : "middle"}
|
||||
angle={0}
|
||||
textAnchor="middle"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||
@@ -217,14 +401,38 @@ export function DrilldownMatrix() {
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
|
||||
content={
|
||||
<ChartTooltip
|
||||
valueFormatter={(v) => `${fmtNum(v)} ${unitLabel}`}
|
||||
/>
|
||||
}
|
||||
content={(props: any) => {
|
||||
const { active, payload, label } = props;
|
||||
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
|
||||
dataKey="value"
|
||||
dataKey="displayQuantity"
|
||||
name="موجودی"
|
||||
radius={[6, 6, 0, 0]}
|
||||
maxBarSize={level === 0 ? 110 : 64}
|
||||
@@ -233,10 +441,10 @@ export function DrilldownMatrix() {
|
||||
const payload = (entry as { payload?: Slice })?.payload;
|
||||
if (payload) handleBarClick(payload);
|
||||
}}
|
||||
className={level < 2 ? "cursor-pointer" : ""}
|
||||
className={level < 2 ? "cursor-pointer transition-opacity hover:opacity-80" : ""}
|
||||
>
|
||||
<LabelList
|
||||
dataKey="value"
|
||||
dataKey="displayQuantity"
|
||||
position="top"
|
||||
fill="#0f172a"
|
||||
fontSize={11}
|
||||
@@ -260,12 +468,11 @@ export function DrilldownMatrix() {
|
||||
))}
|
||||
</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 ? "برای دیدن پروژهها روی ستون کلیک کنید" : "برای دیدن انبارهای هر پروژه روی ستون آن کلیک کنید"}
|
||||
{getTooltipPrompt()}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -261,10 +261,18 @@ export interface DrilldownProject {
|
||||
warehouses: DrilldownWarehouse[];
|
||||
}
|
||||
|
||||
export interface DrilldownMaterial {
|
||||
id: string;
|
||||
name: string;
|
||||
qty_by_unit: QtyByUnit;
|
||||
rows: number;
|
||||
projects: DrilldownProject[];
|
||||
}
|
||||
|
||||
export interface DrilldownData {
|
||||
units: Record<string, string>;
|
||||
total: { qty_by_unit: QtyByUnit; rows: number };
|
||||
projects: DrilldownProject[];
|
||||
materials: DrilldownMaterial[];
|
||||
}
|
||||
|
||||
// — جدول موجودی راکد —
|
||||
|
||||
Reference in New Issue
Block a user