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,
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>
)}
</>
+9 -1
View File
@@ -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[];
}
// — جدول موجودی راکد —