From d84637769a6af471011d3f1d7803aacca12aca27 Mon Sep 17 00:00:00 2001 From: fatemeh sane Date: Thu, 8 Oct 2026 11:18:47 +0330 Subject: [PATCH] fix(dashboard): re-apply filter clear/toggle, 3-level drill-down, Persian formatting + wire /dashboard route --- frontend/src/App.tsx | 15 + frontend/src/components/Combobox.tsx | 46 ++- .../dashboard/DailyControlPanel.tsx | 8 +- .../dashboard/DashboardFilterBar.tsx | 136 +++------ .../components/dashboard/DrilldownMatrix.tsx | 284 ++++++++++++------ .../src/components/dashboard/FlowChart.tsx | 1 + .../components/dashboard/InTransitGrid.tsx | 6 +- .../dashboard/ProcurementFunnel.tsx | 8 +- .../dashboard/ProjectDensityChart.tsx | 1 + .../src/components/dashboard/chartShared.tsx | 47 +-- 10 files changed, 333 insertions(+), 219 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6275b03..1024684 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -46,6 +46,10 @@ const AdminPanelPage = lazy(() => const RequesterHome = lazy(() => import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })), ); +// داشبورد تحلیلی MVP (انبار + بازرگانی) — ویجت‌های Power-BI-style +const MvpDashboardPage = lazy(() => + import("./pages/Dashboard/MvpDashboardPage").then((m) => ({ default: m.MvpDashboardPage })), +); function Loading() { return ( @@ -386,6 +390,17 @@ export default function App() { } /> + {/* داشبورد تحلیلی MVP (انبار + بازرگانی) — برای PM نیز در دسترس */} + + + + + + } + /> void; onInputChange?: (val: string) => void; onBlur?: () => void; + // با ارائه‌ی این تابع، دکمه‌ی «×» داخل فیلد ظاهر می‌شود تا انتخاب فعلی + // (و فیلتر وابسته به آن) به‌تنهایی پاک شود. + onClear?: () => void; // برای فوکوس برنامه‌ای (مثل بازگشت فوکوس پس از ثبت پیوسته) inputRef?: RefObject; // با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند — @@ -56,12 +60,17 @@ export function Combobox({ onAdd, onInputChange, onBlur, + onClear, inputRef, openOnFocus = true, }: ComboboxProps) { const [text, setText] = useState(value ?? ""); const [open, setOpen] = useState(false); const [highlight, setHighlight] = useState(-1); + // کلیکِ روی اینپوتِ در حالت فوکوس‌نشده، اول onFocus را صدا می‌زند و منو باز + // می‌شود؛ این نشانگر می‌گوید «همین لحظه با فوکوس باز شد» تا همان کلیک + // دوباره منو را نبندد. کلیک‌های بعدی = تاگل باز/بسته. + const justOpenedByFocus = useRef(false); useEffect(() => { setText(value ?? ""); @@ -82,6 +91,8 @@ export function Combobox({ const showAdd = allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q); + const showClear = Boolean(onClear) && !disabled && text.length > 0; + const listCount = filtered.length + (showAdd ? 1 : 0); const ref = useClickOutside(() => setOpen(false)); @@ -133,6 +144,7 @@ export function Combobox({ ref={inputRef} className={cn( "flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50", + showClear && "pe-8", inputClassName, )} value={text} @@ -146,12 +158,40 @@ export function Combobox({ onInputChange?.(e.target.value); }} onFocus={() => { - if (openOnFocus) setOpen(true); + if (openOnFocus && !open) { + setOpen(true); + justOpenedByFocus.current = true; + } + }} + onClick={() => { + // اولین کلیک بعد از فوکوس = همان بازشدن؛ کلیک‌های بعدی تاگل می‌کنند + if (justOpenedByFocus.current) { + justOpenedByFocus.current = false; + return; + } + setOpen((o) => !o); }} - onClick={() => setOpen(true)} onBlur={onBlur} onKeyDown={onKeyDown} /> + {showClear && ( + + )} {open && (
{listCount === 0 && ( diff --git a/frontend/src/components/dashboard/DailyControlPanel.tsx b/frontend/src/components/dashboard/DailyControlPanel.tsx index a3f5a4b..a11d28a 100644 --- a/frontend/src/components/dashboard/DailyControlPanel.tsx +++ b/frontend/src/components/dashboard/DailyControlPanel.tsx @@ -14,7 +14,7 @@ import { type LucideIcon, } from "lucide-react"; import { useDailyControl } from "../../hooks/useDashboardData"; -import { faNum, fmtDateTime } from "../../lib/formatters"; +import { faNum, fmtDateTime, fmtNum } from "../../lib/formatters"; import { isoToJalaliStr } from "../../lib/jalali"; import { cn } from "../../lib/utils"; import { Badge } from "../ui/badge"; @@ -66,7 +66,7 @@ function KpiCard({ {faNum(tickets)} بلیت · - {faNum(qty.toLocaleString("en-US"))} + {fmtNum(qty)}
@@ -138,7 +138,7 @@ export function DailyControlPanel() { {p.name} - {faNum(p.tickets)} بلیت · {faNum(p.qty.toLocaleString("en-US"))} + {fmtNum(p.tickets)} بلیت · {fmtNum(p.qty)} ))} @@ -165,7 +165,7 @@ export function DailyControlPanel() { {it.material} - {faNum(it.quantity.toLocaleString("en-US"))} {it.unit_code} + {fmtNum(it.quantity)} {it.unit_code} {fmtDateTime(it.transaction_date)} diff --git a/frontend/src/components/dashboard/DashboardFilterBar.tsx b/frontend/src/components/dashboard/DashboardFilterBar.tsx index 338dd7d..129b24f 100644 --- a/frontend/src/components/dashboard/DashboardFilterBar.tsx +++ b/frontend/src/components/dashboard/DashboardFilterBar.tsx @@ -1,19 +1,22 @@ // ============================================================ // فیلتر سراسری «چه چیزی» — آبشار: دسته‌بندی → متریال → برند → مشخصات // داده از /dashboard/filters می‌آید (بدون هیچ لیست ساختگی) و انتخاب -// هر سطح، سطح‌های پایین‌دست را بازنشانی می‌کند. وضعیت در Zustand -// نگه داشته می‌شود تا با جابه‌جایی بین تب انبار/بازرگانی از دست نرود. +// هر سطح، سطح‌های پایین‌دست را بازنشانی می‌کند: +// دسته → متریال‌های همان دسته +// متریال → برندهای همان متریال (کاتالوگ + موجودی) +// متریال → مشخصات فنی موجودِ همان متریال +// هر فیلد دکمه‌ی «×» دارد که فقط همان سطح (و زیرمجموعه‌اش) را پاک می‌کند. +// وضعیت در Zustand نگه داشته می‌شود تا با جابه‌جایی بین تب‌ها از دست نرود. // ============================================================ import { useMemo } from "react"; -import { Filter, X } from "lucide-react"; +import { Filter } from "lucide-react"; import { Combobox } from "../Combobox"; import { useDashboardFilterOptions } from "../../hooks/useDashboardData"; import { useDashboardFilter } from "../../store/dashboardFilter"; +import { faNum } from "../../lib/formatters"; import { cn } from "../../lib/utils"; -const CLEAR_ID = "__clear__"; - export function DashboardFilterBar({ className }: { className?: string }) { const filterQuery = useDashboardFilterOptions(); const state = useDashboardFilter(); @@ -34,19 +37,17 @@ export function DashboardFilterBar({ className }: { className?: string }) { [materialOptions, state.materialId], ); - const activeChips = [ - state.categoryLabel && { key: "category", label: state.categoryLabel }, - state.materialLabel && { key: "material", label: state.materialLabel }, - state.brandLabel && { key: "brand", label: state.brandLabel }, - state.specsLabel && { key: "specs", label: state.specsLabel }, - ].filter(Boolean) as { key: string; label: string }[]; + const activeCount = [state.categoryId, state.materialId, state.brandId, state.specsHash].filter(Boolean).length; + + // برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیش‌فرض)» + const specLabel = (specs: Record | null) => { + const parts = Object.entries(specs || {}); + return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیش‌فرض)"; + }; return (
@@ -57,14 +58,13 @@ export function DashboardFilterBar({ className }: { className?: string }) {

فیلتر جنس

دسته‌بندی ← متریال ← برند ← مشخصات فنی

- {activeChips.length > 0 && ( + {activeCount > 0 && ( )} @@ -73,106 +73,46 @@ export function DashboardFilterBar({ className }: { className?: string }) { {/* ۱ — دسته‌بندی */} ({ id: c.id ?? CLEAR_ID, label: c.name })), - ]} - onChange={(item) => { - if (item.id === CLEAR_ID) { - state.setCategory(null, null); - } else if (item.id === state.categoryId) { - state.setCategory(null, null); // کلیک دوباره = پاک کردن - } else { - state.setCategory(item.id, item.label); - } - }} + items={categories.filter((c) => c.id).map((c) => ({ id: c.id as string, label: c.name }))} + onClear={() => state.setCategory(null, null)} + onChange={(item) => state.setCategory(item.id, item.label)} /> - {/* ۲ — متریال (وابسته به دسته) */} + {/* ۲ — متریال (وابسته به دسته‌ی انتخاب‌شده) */} ({ id: m.id, label: m.name })), - ]} - onChange={(item) => { - if (item.id === CLEAR_ID) { - state.setMaterial(null, null); - } else if (item.id === state.materialId) { - state.setMaterial(null, null); - } else { - state.setMaterial(item.id, item.label); - } - }} + items={materialOptions.map((m) => ({ id: m.id, label: m.name }))} + onClear={() => state.setMaterial(null, null)} + onChange={(item) => state.setMaterial(item.id, item.label)} /> - {/* ۳ — برند (از موجودی واقعیِ همان متریال) */} + {/* ۳ — برند (فقط برندهای همان متریال) */} ({ id: b.id, label: b.name })), - ]} - onChange={(item) => { - if (item.id === CLEAR_ID) { - state.setBrand(null, null); - } else if (item.id === state.brandId) { - state.setBrand(null, null); - } else { - state.setBrand(item.id, item.label); - } - }} + items={(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name }))} + onClear={() => state.setBrand(null, null)} + onChange={(item) => state.setBrand(item.id, item.label)} /> - {/* ۴ — مشخصات فنی (specs_hash از موجودی واقعی) */} + {/* ۴ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */} { - const parts = Object.entries(s.specs || {}); - const label = parts.length - ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") - : "بدون مشخصه (پیش‌فرض)"; - return { id: s.hash, label }; - }), - ]} - onChange={(item) => { - if (item.id === CLEAR_ID) { - state.setSpecs(null, null); - } else if (item.id === state.specsHash) { - state.setSpecs(null, null); - } else { - state.setSpecs(item.id, item.label); - } - }} + items={(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) }))} + onClear={() => state.setSpecs(null, null)} + onChange={(item) => state.setSpecs(item.id, item.label)} /> - - {/* چیپ‌های فیلتر فعال — بازخورد فوری انتخاب‌ها */} - {activeChips.length > 0 && ( -
- {activeChips.map((chip) => ( - - {chip.label} - - ))} -
- )}
); } diff --git a/frontend/src/components/dashboard/DrilldownMatrix.tsx b/frontend/src/components/dashboard/DrilldownMatrix.tsx index 693d20e..3e520d8 100644 --- a/frontend/src/components/dashboard/DrilldownMatrix.tsx +++ b/frontend/src/components/dashboard/DrilldownMatrix.tsx @@ -1,25 +1,28 @@ // ============================================================ -// ویجت ۱ — ماتریس Drill-Down (کل سازمان → پروژه → انبار) -// نمودار ستونی تعاملی: کلیک روی هر ستون پروژه، به انبارهای همان -// پروژه رفته و مسیر (breadcrumb) به‌روز می‌شود — سبک Power BI. -// مقادیر هرگز بین واحدهای ناسازگار مخلوط نمی‌شوند: واحد فعال با -// چیپ‌های بالای نمودار انتخاب می‌شود. +// ویجت ۱ — ماتریس Drill-Down سه‌سطحی (سبک Power BI) +// سطح ۱ «کل سازمان» → یک ستون: تجمیع موجودی همه‌ی انبارهای سازمان +// سطح ۲ «پروژه‌ها» → با کلیک روی سطح ۱: سهم هر پروژه +// سطح ۳ «انبارها» → با کلیک روی هر پروژه: انبارهای همان پروژه +// همه‌ی سطوح از پاسخ /dashboard/inventory/drilldown ساخته می‌شوند — +// هیچ مقدار/نام/وضعیتی در فرانت‌اند هارد‌کد نشده است. اگر پروژه‌ی +// انتخاب‌شده با تغییر فیلترها از داده حذف شود، سطح خودکار به بالا برمی‌گردد. // ============================================================ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Bar, BarChart, CartesianGrid, Cell, + LabelList, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; -import { ChevronLeft } from "lucide-react"; +import { ChevronLeft, MousePointerClick } from "lucide-react"; import { useDrilldown } from "../../hooks/useDashboardData"; -import { faNum } from "../../lib/formatters"; +import { fmtNum } from "../../lib/formatters"; import { CHART_COLORS, ChartCard, @@ -30,17 +33,29 @@ import { faAxisTick, } from "./chartShared"; +type Level = 0 | 1 | 2; // 0=سازمان، 1=پروژه‌ها، 2=انبارها + interface Slice { - id: string | null; + id: string; name: string; value: number; rows: number; } +const LEVEL_TITLES: Record = { + 0: "کل سازمان", + 1: "پروژه‌ها", + 2: "انبارها", +}; + +// پالت سطح انبارها — سایه‌های ایندیگو برای تمایز انبارهای یک پروژه +const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"]; + export function DrilldownMatrix() { const query = useDrilldown(); const [unit, setUnit] = useState(null); - const [project, setProject] = useState<{ id: string; name: string } | null>(null); + const [level, setLevel] = useState(0); + const [projectId, setProjectId] = useState(null); const data = query.data; @@ -52,117 +67,208 @@ export function DrilldownMatrix() { return entries.sort((a, b) => b[1] - a[1])[0][0]; }, [unit, data]); + const selectedProject = useMemo( + () => (projectId && data ? data.projects.find((p) => p.id === projectId) ?? null : null), + [data, projectId], + ); + + // کلمپِ خودکار: اگر پروژه‌ی انتخابی با فیلترهای جدید از داده خارج شد، + // به سطح پروژه‌ها برگرد (بدون هیچ مقدار ثابت). + useEffect(() => { + if (level === 2 && projectId && data && !selectedProject) { + setProjectId(null); + setLevel(1); + } + if (data && data.projects.length === 0 && level !== 0) { + setProjectId(null); + setLevel(0); + } + }, [data, level, projectId, selectedProject]); + + const pick = (q: Record) => (activeUnit ? q[activeUnit] ?? 0 : 0); + + // برش‌های سطح فعال — کاملاً از پاسخ API const slices: Slice[] = useMemo(() => { if (!data) return []; - const pick = (q: Record) => (activeUnit ? q[activeUnit] ?? 0 : 0); - if (project) { - const p = data.projects.find((x) => x.id === project.id); - return (p?.warehouses ?? []).map((w) => ({ id: w.id, name: w.name, value: pick(w.qty_by_unit), rows: w.rows })); + if (level === 0) { + return [{ id: "__org__", name: "کل سازمان", value: pick(data.total.qty_by_unit), rows: data.total.rows }]; } - return data.projects.map((p) => ({ id: p.id, name: p.name, value: pick(p.qty_by_unit), rows: p.rows })); - }, [data, project, activeUnit]); + 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 levelTotal = slices.reduce((s, x) => s + x.value, 0); + const total = data ? pick(data.total.qty_by_unit) : 0; + + const handleBarClick = (slice: Slice) => { + if (level === 0 && data && data.projects.length > 0) { + setLevel(1); + } else if (level === 1) { + setProjectId(slice.id); + setLevel(2); + } + // سطح ۳ برگ است — کلیک دیگری لازم نیست + }; + + const subText = + level === 0 + ? "تجمیع موجودی همه‌ی انبارهای سازمان برای فیلترهای فعال — برای مشاهده‌ی سهم پروژه‌ها کلیک کنید" + : level === 1 + ? "سهم هر پروژه از موجودی — برای مشاهده‌ی انبارهای هر پروژه روی ستون آن کلیک کنید" + : `انبارهای پروژه «${selectedProject?.name ?? ""}» — برای بازگشت روی مسیر کلیک کنید`; return ( : null - } + sub={subText} + actions={data ? : null} > - {/* مسیر Drill-Down */} + {/* مسیر Drill-Down (RTL) */} {query.isLoading ? ( - ) : !slices.length || levelTotal === 0 ? ( + ) : !slices.length || (levelTotal === 0 && level === 0) ? ( ) : ( -
- - - - 4 ? -18 : 0} - textAnchor={slices.length > 4 ? "end" : "middle"} - /> - - String(l)} - valueFormatter={(v) => `${faNum(v.toLocaleString("en-US"))} ${unitLabel}`} - /> - } - /> - { - // Recharts ورودی ستون را در آرگومان اول می‌دهد - const payload = (entry as { payload?: Slice })?.payload; - if (!project && payload?.id) { - setProject({ id: payload.id, name: payload.name }); + <> +
+ + + + 4 ? -18 : 0} + textAnchor={slices.length > 4 ? "end" : "middle"} + /> + + `${fmtNum(v)} ${unitLabel}`} + /> } - }} - className={project ? "" : "cursor-pointer"} - > - {slices.map((s) => ( - + { + const payload = (entry as { payload?: Slice })?.payload; + if (payload) handleBarClick(payload); + }} + className={level < 2 ? "cursor-pointer" : ""} + > + { + const n = Number(v); + return Number.isFinite(n) && n > 0 ? fmtNum(n) : ""; + }} /> - ))} - - - -
+ {slices.map((s, i) => ( + + ))} +
+
+
+
+ {level < 2 && ( +

+ + {level === 0 ? "برای دیدن پروژه‌ها روی ستون کلیک کنید" : "برای دیدن انبارهای هر پروژه روی ستون آن کلیک کنید"} +

+ )} + )}
); diff --git a/frontend/src/components/dashboard/FlowChart.tsx b/frontend/src/components/dashboard/FlowChart.tsx index 4a67da7..f978879 100644 --- a/frontend/src/components/dashboard/FlowChart.tsx +++ b/frontend/src/components/dashboard/FlowChart.tsx @@ -95,6 +95,7 @@ export function FlowChart() { } /> - {faNum(ordered.toLocaleString("en-US"))} + {fmtNum(ordered)}
{r.unit?.name_fa || r.unit?.code}
- {faNum(received.toLocaleString("en-US"))} + {fmtNum(received)}
diff --git a/frontend/src/components/dashboard/ProcurementFunnel.tsx b/frontend/src/components/dashboard/ProcurementFunnel.tsx index 15d7a1a..aefc48e 100644 --- a/frontend/src/components/dashboard/ProcurementFunnel.tsx +++ b/frontend/src/components/dashboard/ProcurementFunnel.tsx @@ -7,7 +7,7 @@ import { useMemo } from "react"; import { Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from "recharts"; import { useQuoteStats } from "../../hooks/useDashboardData"; -import { faNum } from "../../lib/formatters"; +import { faNum, fmtNum } from "../../lib/formatters"; import { CHART_COLORS, ChartCard, ChartEmpty, ChartLoading, ChartTooltip } from "./chartShared"; interface Step { @@ -66,7 +66,7 @@ export function ProcurementFunnel() { content={ String(l)} - valueFormatter={(v) => `${faNum(v.toLocaleString("en-US"))} درخواست`} + valueFormatter={(v) => `${fmtNum(v)} درخواست`} /> } /> @@ -81,7 +81,7 @@ export function ProcurementFunnel() { fontWeight={700} formatter={(v: unknown) => { const n = Number(v); - return Number.isFinite(n) ? faNum(n.toLocaleString("en-US")) : ""; + return Number.isFinite(n) ? fmtNum(n) : ""; }} /> {/* نام مرحله داخل نوار */} @@ -106,7 +106,7 @@ export function ProcurementFunnel() { {s.name} - {faNum(s.value.toLocaleString("en-US"))} + {fmtNum(s.value)}
{s.conversion != null && ( diff --git a/frontend/src/components/dashboard/ProjectDensityChart.tsx b/frontend/src/components/dashboard/ProjectDensityChart.tsx index e75d977..8d1620f 100644 --- a/frontend/src/components/dashboard/ProjectDensityChart.tsx +++ b/frontend/src/components/dashboard/ProjectDensityChart.tsx @@ -88,6 +88,7 @@ export function ProjectDensityChart() { } /> ; value: string; onChange: (code: string) => void; + totals?: Record; }) { const codes = Object.keys(units); if (codes.length <= 1) return null; return ( -
- {codes.map((code) => ( - - ))} +
+ {codes.map((code) => { + const total = totals?.[code]; + return ( + + ); + })}
); }