fix(dashboard): re-apply filter clear/toggle, 3-level drill-down, Persian formatting + wire /dashboard route

This commit is contained in:
fatemeh sane
2026-10-08 11:18:47 +03:30
parent b0776da061
commit d84637769a
10 changed files with 333 additions and 219 deletions
+15
View File
@@ -46,6 +46,10 @@ const AdminPanelPage = lazy(() =>
const RequesterHome = lazy(() => const RequesterHome = lazy(() =>
import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })), 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() { function Loading() {
return ( return (
@@ -386,6 +390,17 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
{/* داشبورد تحلیلی MVP (انبار + بازرگانی) — برای PM نیز در دسترس */}
<Route
path="/dashboard"
element={
<RequireAuth>
<RequirePmProjects>
<MvpDashboardPage />
</RequirePmProjects>
</RequireAuth>
}
/>
<Route <Route
path="/units" path="/units"
element={ element={
+43 -3
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, type RefObject } from "react"; import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { X } from "lucide-react";
import { normalize } from "../lib/normalize"; import { normalize } from "../lib/normalize";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside"; import { useClickOutside } from "./useClickOutside";
@@ -26,6 +27,9 @@ export interface ComboboxProps {
onAdd?: (val: string) => void; onAdd?: (val: string) => void;
onInputChange?: (val: string) => void; onInputChange?: (val: string) => void;
onBlur?: () => void; onBlur?: () => void;
// با ارائه‌ی این تابع، دکمه‌ی «×» داخل فیلد ظاهر می‌شود تا انتخاب فعلی
// (و فیلتر وابسته به آن) به‌تنهایی پاک شود.
onClear?: () => void;
// برای فوکوس برنامه‌ای (مثل بازگشت فوکوس پس از ثبت پیوسته) // برای فوکوس برنامه‌ای (مثل بازگشت فوکوس پس از ثبت پیوسته)
inputRef?: RefObject<HTMLInputElement | null>; inputRef?: RefObject<HTMLInputElement | null>;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند — // با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
@@ -56,12 +60,17 @@ export function Combobox({
onAdd, onAdd,
onInputChange, onInputChange,
onBlur, onBlur,
onClear,
inputRef, inputRef,
openOnFocus = true, openOnFocus = true,
}: ComboboxProps) { }: ComboboxProps) {
const [text, setText] = useState(value ?? ""); const [text, setText] = useState(value ?? "");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(-1); const [highlight, setHighlight] = useState(-1);
// کلیکِ روی اینپوتِ در حالت فوکوس‌نشده، اول onFocus را صدا می‌زند و منو باز
// می‌شود؛ این نشانگر می‌گوید «همین لحظه با فوکوس باز شد» تا همان کلیک
// دوباره منو را نبندد. کلیک‌های بعدی = تاگل باز/بسته.
const justOpenedByFocus = useRef(false);
useEffect(() => { useEffect(() => {
setText(value ?? ""); setText(value ?? "");
@@ -82,6 +91,8 @@ export function Combobox({
const showAdd = const showAdd =
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q); 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 listCount = filtered.length + (showAdd ? 1 : 0);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false)); const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
@@ -133,6 +144,7 @@ export function Combobox({
ref={inputRef} ref={inputRef}
className={cn( 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", "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, inputClassName,
)} )}
value={text} value={text}
@@ -146,12 +158,40 @@ export function Combobox({
onInputChange?.(e.target.value); onInputChange?.(e.target.value);
}} }}
onFocus={() => { 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} onBlur={onBlur}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
{showClear && (
<button
type="button"
aria-label="پاک کردن"
title="پاک کردن"
className="absolute end-2 top-1/2 z-10 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full text-muted transition-colors hover:bg-slate-bg hover:text-ink"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
setText("");
setHighlight(-1);
setOpen(true);
onClear?.();
}}
>
<X className="h-3.5 w-3.5" />
</button>
)}
{open && ( {open && (
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg"> <div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
{listCount === 0 && ( {listCount === 0 && (
@@ -14,7 +14,7 @@ import {
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { useDailyControl } from "../../hooks/useDashboardData"; import { useDailyControl } from "../../hooks/useDashboardData";
import { faNum, fmtDateTime } from "../../lib/formatters"; import { faNum, fmtDateTime, fmtNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali"; import { isoToJalaliStr } from "../../lib/jalali";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { Badge } from "../ui/badge"; import { Badge } from "../ui/badge";
@@ -66,7 +66,7 @@ function KpiCard({
<span className="text-[17px] font-extrabold leading-6">{faNum(tickets)}</span> <span className="text-[17px] font-extrabold leading-6">{faNum(tickets)}</span>
<span className="text-[10.5px] text-muted">بلیت</span> <span className="text-[10.5px] text-muted">بلیت</span>
<span className="text-[11px] text-muted">·</span> <span className="text-[11px] text-muted">·</span>
<span className="text-[12px] font-bold">{faNum(qty.toLocaleString("en-US"))}</span> <span className="text-[12px] font-bold">{fmtNum(qty)}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -138,7 +138,7 @@ export function DailyControlPanel() {
</span> </span>
<span className="truncate text-[12.5px] font-bold">{p.name}</span> <span className="truncate text-[12.5px] font-bold">{p.name}</span>
<span className="ms-auto shrink-0 text-[11px] tabular-nums text-muted"> <span className="ms-auto shrink-0 text-[11px] tabular-nums text-muted">
{faNum(p.tickets)} بلیت · {faNum(p.qty.toLocaleString("en-US"))} {fmtNum(p.tickets)} بلیت · {fmtNum(p.qty)}
</span> </span>
</li> </li>
))} ))}
@@ -165,7 +165,7 @@ export function DailyControlPanel() {
</Badge> </Badge>
<span className="truncate text-[12px] font-bold">{it.material}</span> <span className="truncate text-[12px] font-bold">{it.material}</span>
<span className="shrink-0 text-[11px] tabular-nums text-muted"> <span className="shrink-0 text-[11px] tabular-nums text-muted">
{faNum(it.quantity.toLocaleString("en-US"))} {it.unit_code} {fmtNum(it.quantity)} {it.unit_code}
</span> </span>
<span className="ms-auto shrink-0 text-[10.5px] text-muted" title={it.warehouse}> <span className="ms-auto shrink-0 text-[10.5px] text-muted" title={it.warehouse}>
{fmtDateTime(it.transaction_date)} {fmtDateTime(it.transaction_date)}
@@ -1,19 +1,22 @@
// ============================================================ // ============================================================
// فیلتر سراسری «چه چیزی» — آبشار: دسته‌بندی → متریال → برند → مشخصات // فیلتر سراسری «چه چیزی» — آبشار: دسته‌بندی → متریال → برند → مشخصات
// داده از /dashboard/filters می‌آید (بدون هیچ لیست ساختگی) و انتخاب // داده از /dashboard/filters می‌آید (بدون هیچ لیست ساختگی) و انتخاب
// هر سطح، سطح‌های پایین‌دست را بازنشانی می‌کند. وضعیت در Zustand // هر سطح، سطح‌های پایین‌دست را بازنشانی می‌کند:
// نگه داشته می‌شود تا با جابه‌جایی بین تب انبار/بازرگانی از دست نرود. // دسته → متریال‌های همان دسته
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
// متریال → مشخصات فنی موجودِ همان متریال
// هر فیلد دکمه‌ی «×» دارد که فقط همان سطح (و زیرمجموعه‌اش) را پاک می‌کند.
// وضعیت در Zustand نگه داشته می‌شود تا با جابه‌جایی بین تب‌ها از دست نرود.
// ============================================================ // ============================================================
import { useMemo } from "react"; import { useMemo } from "react";
import { Filter, X } from "lucide-react"; import { Filter } from "lucide-react";
import { Combobox } from "../Combobox"; import { Combobox } from "../Combobox";
import { useDashboardFilterOptions } from "../../hooks/useDashboardData"; import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
import { useDashboardFilter } from "../../store/dashboardFilter"; import { useDashboardFilter } from "../../store/dashboardFilter";
import { faNum } from "../../lib/formatters";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
const CLEAR_ID = "__clear__";
export function DashboardFilterBar({ className }: { className?: string }) { export function DashboardFilterBar({ className }: { className?: string }) {
const filterQuery = useDashboardFilterOptions(); const filterQuery = useDashboardFilterOptions();
const state = useDashboardFilter(); const state = useDashboardFilter();
@@ -34,19 +37,17 @@ export function DashboardFilterBar({ className }: { className?: string }) {
[materialOptions, state.materialId], [materialOptions, state.materialId],
); );
const activeChips = [ const activeCount = [state.categoryId, state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
state.categoryLabel && { key: "category", label: state.categoryLabel },
state.materialLabel && { key: "material", label: state.materialLabel }, // برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیش‌فرض)»
state.brandLabel && { key: "brand", label: state.brandLabel }, const specLabel = (specs: Record<string, string> | null) => {
state.specsLabel && { key: "specs", label: state.specsLabel }, const parts = Object.entries(specs || {});
].filter(Boolean) as { key: string; label: string }[]; return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیش‌فرض)";
};
return ( return (
<section <section
className={cn( className={cn("rounded-2xl border border-line bg-panel p-3 sm:p-4", className)}
"rounded-2xl border border-line bg-panel p-3 sm:p-4",
className,
)}
aria-label="فیلتر سراسری متریال" aria-label="فیلتر سراسری متریال"
> >
<div className="mb-2.5 flex items-center gap-2"> <div className="mb-2.5 flex items-center gap-2">
@@ -57,14 +58,13 @@ export function DashboardFilterBar({ className }: { className?: string }) {
<h2 className="text-[13px] font-extrabold leading-5">فیلتر جنس</h2> <h2 className="text-[13px] font-extrabold leading-5">فیلتر جنس</h2>
<p className="text-[10.5px] text-muted">دسته‌بندی ← متریال ← برند ← مشخصات فنی</p> <p className="text-[10.5px] text-muted">دسته‌بندی ← متریال ← برند ← مشخصات فنی</p>
</div> </div>
{activeChips.length > 0 && ( {activeCount > 0 && (
<button <button
type="button" type="button"
onClick={state.reset} onClick={state.reset}
className="ms-auto inline-flex items-center gap-1 rounded-full bg-red-bg px-2.5 py-1 text-[11px] font-bold text-red transition-opacity hover:opacity-80" className="ms-auto rounded-full bg-red-bg px-2.5 py-1 text-[11px] font-bold text-red transition-opacity hover:opacity-80"
> >
<X className="h-3 w-3" /> حذف فیلترها ({faNum(activeCount)})
حذف همه‌ی فیلترها
</button> </button>
)} )}
</div> </div>
@@ -73,106 +73,46 @@ export function DashboardFilterBar({ className }: { className?: string }) {
{/* ۱ — دسته‌بندی */} {/* ۱ — دسته‌بندی */}
<Combobox <Combobox
value={state.categoryLabel ?? ""} value={state.categoryLabel ?? ""}
placeholder={state.categoryId ? state.categoryLabel ?? "دسته‌بندی" : "همه‌ی دسته‌ها"} placeholder="همه‌ی دسته‌ها"
allowAdd={false} allowAdd={false}
items={[ items={categories.filter((c) => c.id).map((c) => ({ id: c.id as string, label: c.name }))}
{ id: CLEAR_ID, label: "همه‌ی دسته‌ها" }, onClear={() => state.setCategory(null, null)}
...categories.map((c) => ({ id: c.id ?? CLEAR_ID, label: c.name })), onChange={(item) => state.setCategory(item.id, item.label)}
]}
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);
}
}}
/> />
{/* ۲ — متریال (وابسته به دسته) */} {/* ۲ — متریال (وابسته به دسته‌ی انتخاب‌شده) */}
<Combobox <Combobox
value={state.materialLabel ?? ""} value={state.materialLabel ?? ""}
placeholder={state.materialId ? state.materialLabel ?? "متریال" : "همه‌ی متریال‌ها"} placeholder="همه‌ی متریال‌ها"
allowAdd={false} allowAdd={false}
disabled={materialOptions.length === 0} disabled={materialOptions.length === 0}
items={[ items={materialOptions.map((m) => ({ id: m.id, label: m.name }))}
{ id: CLEAR_ID, label: "همه‌ی متریال‌ها" }, onClear={() => state.setMaterial(null, null)}
...materialOptions.map((m) => ({ id: m.id, label: m.name })), onChange={(item) => state.setMaterial(item.id, item.label)}
]}
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);
}
}}
/> />
{/* ۳ — برند (از موجودی واقعیِ همان متریال) */} {/* ۳ — برند (فقط برندهای همان متریال) */}
<Combobox <Combobox
value={state.brandLabel ?? ""} value={state.brandLabel ?? ""}
placeholder={state.brandId ? state.brandLabel ?? "برند" : "همه‌ی برندها"} placeholder={selectedMaterial ? "همه‌ی برندها" : "ابتدا متریال را انتخاب کنید"}
allowAdd={false} allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.brands.length === 0} disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
items={[ items={(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name }))}
{ id: CLEAR_ID, label: "همه‌ی برندها" }, onClear={() => state.setBrand(null, null)}
...(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name })), onChange={(item) => state.setBrand(item.id, item.label)}
]}
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);
}
}}
/> />
{/* ۴ — مشخصات فنی (specs_hash از موجودی واقعی) */} {/* ۴ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
<Combobox <Combobox
value={state.specsLabel ?? ""} value={state.specsLabel ?? ""}
placeholder={state.specsHash ? state.specsLabel ?? "مشخصات" : "همه‌ی مشخصات"} placeholder={selectedMaterial ? "همه‌ی مشخصات" : "ابتدا متریال را انتخاب کنید"}
allowAdd={false} allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.specs.length === 0} disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
items={[ items={(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) }))}
{ id: CLEAR_ID, label: "همه‌ی مشخصات" }, onClear={() => state.setSpecs(null, null)}
...(selectedMaterial?.specs ?? []).map((s) => { onChange={(item) => state.setSpecs(item.id, item.label)}
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);
}
}}
/> />
</div> </div>
{/* چیپ‌های فیلتر فعال — بازخورد فوری انتخاب‌ها */}
{activeChips.length > 0 && (
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
{activeChips.map((chip) => (
<span
key={chip.key}
className="inline-flex items-center gap-1 rounded-full bg-accent-bg px-2.5 py-0.5 text-[11px] font-bold text-accent"
>
{chip.label}
</span>
))}
</div>
)}
</section> </section>
); );
} }
@@ -1,25 +1,28 @@
// ============================================================ // ============================================================
// ویجت ۱ — ماتریس Drill-Down (کل سازمان → پروژه → انبار) // ویجت ۱ — ماتریس Drill-Down سه‌سطحی (سبک Power BI)
// نمودار ستونی تعاملی: کلیک روی هر ستون پروژه، به انبارهای همان // سطح ۱ «کل سازمان» → یک ستون: تجمیع موجودی همه‌ی انبارهای سازمان
// پروژه رفته و مسیر (breadcrumb) به‌روز می‌شود — سبک Power BI. // سطح ۲ «پروژه‌ها» → با کلیک روی سطح ۱: سهم هر پروژه
// مقادیر هرگز بین واحدهای ناسازگار مخلوط نمی‌شوند: واحد فعال با // سطح ۳ «انبارها» → با کلیک روی هر پروژه: انبارهای همان پروژه
// چیپ‌های بالای نمودار انتخاب می‌شود. // همه‌ی سطوح از پاسخ /dashboard/inventory/drilldown ساخته می‌شوند —
// هیچ مقدار/نام/وضعیتی در فرانت‌اند هارد‌کد نشده است. اگر پروژه‌ی
// انتخاب‌شده با تغییر فیلترها از داده حذف شود، سطح خودکار به بالا برمی‌گردد.
// ============================================================ // ============================================================
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
Bar, Bar,
BarChart, BarChart,
CartesianGrid, CartesianGrid,
Cell, Cell,
LabelList,
ResponsiveContainer, ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import { ChevronLeft } from "lucide-react"; import { ChevronLeft, MousePointerClick } from "lucide-react";
import { useDrilldown } from "../../hooks/useDashboardData"; import { useDrilldown } from "../../hooks/useDashboardData";
import { faNum } from "../../lib/formatters"; import { fmtNum } from "../../lib/formatters";
import { import {
CHART_COLORS, CHART_COLORS,
ChartCard, ChartCard,
@@ -30,17 +33,29 @@ import {
faAxisTick, faAxisTick,
} from "./chartShared"; } from "./chartShared";
type Level = 0 | 1 | 2; // 0=سازمان، 1=پروژه‌ها، 2=انبارها
interface Slice { interface Slice {
id: string | null; id: string;
name: string; name: string;
value: number; value: number;
rows: number; rows: number;
} }
const LEVEL_TITLES: Record<Level, string> = {
0: "کل سازمان",
1: "پروژه‌ها",
2: "انبارها",
};
// پالت سطح انبارها — سایه‌های ایندیگو برای تمایز انبارهای یک پروژه
const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"];
export function DrilldownMatrix() { export function DrilldownMatrix() {
const query = useDrilldown(); const query = useDrilldown();
const [unit, setUnit] = useState<string | null>(null); const [unit, setUnit] = useState<string | null>(null);
const [project, setProject] = useState<{ id: string; name: string } | null>(null); const [level, setLevel] = useState<Level>(0);
const [projectId, setProjectId] = useState<string | null>(null);
const data = query.data; const data = query.data;
@@ -52,117 +67,208 @@ export function DrilldownMatrix() {
return entries.sort((a, b) => b[1] - a[1])[0][0]; return entries.sort((a, b) => b[1] - a[1])[0][0];
}, [unit, data]); }, [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<string, number>) => (activeUnit ? q[activeUnit] ?? 0 : 0);
// برش‌های سطح فعال — کاملاً از پاسخ API
const slices: Slice[] = useMemo(() => { const slices: Slice[] = useMemo(() => {
if (!data) return []; if (!data) return [];
const pick = (q: Record<string, number>) => (activeUnit ? q[activeUnit] ?? 0 : 0); if (level === 0) {
if (project) { return [{ id: "__org__", name: "کل سازمان", value: pick(data.total.qty_by_unit), rows: data.total.rows }];
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 }));
} }
return data.projects.map((p) => ({ id: p.id, name: p.name, value: pick(p.qty_by_unit), rows: p.rows })); if (level === 1) {
}, [data, project, activeUnit]); 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 unitLabel = activeUnit ? data?.units?.[activeUnit] || activeUnit : "";
const levelTotal = slices.reduce((s, x) => s + x.value, 0); 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 ( return (
<ChartCard <ChartCard
title="ماتریس توزیع موجودی — کجا ذخیره شده؟" title="ماتریس توزیع موجودی — کجا ذخیره شده؟"
sub={ sub={subText}
project actions={data ? <UnitPicker units={data.units} value={activeUnit ?? ""} onChange={setUnit} totals={data.total.qty_by_unit} /> : null}
? `انبارهای پروژه «${project.name}» — برای بازگشت روی مسیر کلیک کنید`
: "سهم هر پروژه از کل سازمان — روی هر ستون کلیک کنید تا انبارهای آن پروژه را ببینید"
}
actions={
data ? <UnitPicker units={data.units} value={activeUnit ?? ""} onChange={setUnit} /> : null
}
> >
{/* مسیر Drill-Down */} {/* مسیر 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]">
<button <button
type="button" type="button"
onClick={() => setProject(null)} onClick={() => {
className={`rounded-md px-2 py-0.5 font-bold transition-colors ${ setProjectId(null);
project ? "text-muted hover:bg-slate-bg hover:text-ink" : "bg-accent-bg text-accent" setLevel(0);
}`} }}
className={
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"
}
> >
کل سازمان {LEVEL_TITLES[0]}
</button> </button>
{project && ( {level >= 1 && (
<>
<ChevronLeft className="h-3.5 w-3.5 text-muted" />
<button
type="button"
onClick={() => {
setProjectId(null);
setLevel(1);
}}
className={
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"
}
>
{LEVEL_TITLES[1]}
</button>
</>
)}
{level >= 2 && selectedProject && (
<> <>
<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">
{project.name} {selectedProject.name}
</span> </span>
<span className="text-muted">← انبار</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">
جمع سطح فعال: {faNum(levelTotal.toLocaleString("en-US"))} {unitLabel} جمع سطح فعال: {fmtNum(levelTotal)} {unitLabel}
{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 ? ( ) : !slices.length || (levelTotal === 0 && level === 0) ? (
<ChartEmpty message="موجودی‌ای با این فیلترها یافت نشد" /> <ChartEmpty message="موجودی‌ای با این فیلترها یافت نشد" />
) : ( ) : (
<div dir="ltr" className="h-64 w-full"> <>
<ResponsiveContainer width="100%" height="100%"> <div dir="ltr" className="h-64 w-full">
<BarChart data={slices} margin={{ top: 24, right: 8, left: 8, bottom: 4 }}> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} /> <BarChart data={slices} margin={{ top: 26, right: 8, left: 8, bottom: 4 }}>
<XAxis <CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
dataKey="name" <XAxis
tick={{ fontSize: 11, fill: "#64748b", fontFamily: "Vazirmatn" }} dataKey="name"
tickLine={false} tick={{ fontSize: 11, fill: "#64748b", fontFamily: "Vazirmatn" }}
axisLine={{ stroke: "#e5e9f0" }} tickLine={false}
interval={0} axisLine={{ stroke: "#e5e9f0" }}
height={44} interval={0}
angle={slices.length > 4 ? -18 : 0} height={44}
textAnchor={slices.length > 4 ? "end" : "middle"} angle={slices.length > 4 ? -18 : 0}
/> textAnchor={slices.length > 4 ? "end" : "middle"}
<YAxis />
tick={{ fontSize: 11, fill: "#64748b" }} <YAxis
tickFormatter={faAxisTick} tick={{ fontSize: 11, fill: "#64748b" }}
tickLine={false} tickFormatter={faAxisTick}
axisLine={false} tickLine={false}
width={56} axisLine={false}
/> width={56}
<Tooltip />
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }} <Tooltip
content={ cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
<ChartTooltip content={
labelFormatter={(l) => String(l)} <ChartTooltip
valueFormatter={(v) => `${faNum(v.toLocaleString("en-US"))} ${unitLabel}`} valueFormatter={(v) => `${fmtNum(v)} ${unitLabel}`}
/> />
}
/>
<Bar
dataKey="value"
name="موجودی"
radius={[6, 6, 0, 0]}
maxBarSize={64}
isAnimationActive={false}
onClick={(entry: unknown) => {
// Recharts ورودی ستون را در آرگومان اول می‌دهد
const payload = (entry as { payload?: Slice })?.payload;
if (!project && payload?.id) {
setProject({ id: payload.id, name: payload.name });
} }
}} />
className={project ? "" : "cursor-pointer"} <Bar
> dataKey="value"
{slices.map((s) => ( name="موجودی"
<Cell radius={[6, 6, 0, 0]}
key={s.id ?? s.name} maxBarSize={level === 0 ? 110 : 64}
fill={project ? CHART_COLORS.accent : "#818cf8"} isAnimationActive={false}
fillOpacity={project ? 1 : 0.9} onClick={(entry: unknown) => {
const payload = (entry as { payload?: Slice })?.payload;
if (payload) handleBarClick(payload);
}}
className={level < 2 ? "cursor-pointer" : ""}
>
<LabelList
dataKey="value"
position="top"
fill="#0f172a"
fontSize={11}
fontWeight={700}
formatter={(v: unknown) => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? fmtNum(n) : "";
}}
/> />
))} {slices.map((s, i) => (
</Bar> <Cell
</BarChart> key={s.id}
</ResponsiveContainer> fill={
</div> level === 0
? CHART_COLORS.accent
: level === 1
? "#818cf8"
: WAREHOUSE_PALETTE[i % WAREHOUSE_PALETTE.length]
}
/>
))}
</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 ? "برای دیدن پروژه‌ها روی ستون کلیک کنید" : "برای دیدن انبارهای هر پروژه روی ستون آن کلیک کنید"}
</p>
)}
</>
)} )}
</ChartCard> </ChartCard>
); );
@@ -95,6 +95,7 @@ export function FlowChart() {
} }
/> />
<Legend <Legend
wrapperStyle={{ direction: "rtl", width: "100%" }}
verticalAlign="top" verticalAlign="top"
height={28} height={28}
iconType="circle" iconType="circle"
@@ -8,7 +8,7 @@
import { Truck } from "lucide-react"; import { Truck } from "lucide-react";
import { useInTransit } from "../../hooks/useDashboardData"; import { useInTransit } from "../../hooks/useDashboardData";
import type { InTransitRow } from "../../lib/types"; import type { InTransitRow } from "../../lib/types";
import { faNum } from "../../lib/formatters"; import { faNum, fmtNum } from "../../lib/formatters";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
import { import {
Table, Table,
@@ -90,13 +90,13 @@ export function InTransitGrid() {
)} )}
</TableCell> </TableCell>
<TableCell className="text-end font-bold tabular-nums"> <TableCell className="text-end font-bold tabular-nums">
{faNum(ordered.toLocaleString("en-US"))} {fmtNum(ordered)}
<div className="text-[10px] font-normal text-muted"> <div className="text-[10px] font-normal text-muted">
{r.unit?.name_fa || r.unit?.code} {r.unit?.name_fa || r.unit?.code}
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-end font-bold tabular-nums"> <TableCell className="text-end font-bold tabular-nums">
{faNum(received.toLocaleString("en-US"))} {fmtNum(received)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -7,7 +7,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from "recharts"; import { Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from "recharts";
import { useQuoteStats } from "../../hooks/useDashboardData"; 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"; import { CHART_COLORS, ChartCard, ChartEmpty, ChartLoading, ChartTooltip } from "./chartShared";
interface Step { interface Step {
@@ -66,7 +66,7 @@ export function ProcurementFunnel() {
content={ content={
<ChartTooltip <ChartTooltip
labelFormatter={(l) => String(l)} labelFormatter={(l) => String(l)}
valueFormatter={(v) => `${faNum(v.toLocaleString("en-US"))} درخواست`} valueFormatter={(v) => `${fmtNum(v)} درخواست`}
/> />
} }
/> />
@@ -81,7 +81,7 @@ export function ProcurementFunnel() {
fontWeight={700} fontWeight={700}
formatter={(v: unknown) => { formatter={(v: unknown) => {
const n = Number(v); 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() {
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: s.fill }} /> <span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: s.fill }} />
<span className="text-[12px] font-bold">{s.name}</span> <span className="text-[12px] font-bold">{s.name}</span>
<span className="ms-auto text-[15px] font-extrabold tabular-nums"> <span className="ms-auto text-[15px] font-extrabold tabular-nums">
{faNum(s.value.toLocaleString("en-US"))} {fmtNum(s.value)}
</span> </span>
</div> </div>
{s.conversion != null && ( {s.conversion != null && (
@@ -88,6 +88,7 @@ export function ProjectDensityChart() {
} }
/> />
<Legend <Legend
wrapperStyle={{ direction: "rtl", width: "100%" }}
verticalAlign="top" verticalAlign="top"
height={26} height={26}
iconType="circle" iconType="circle"
@@ -7,7 +7,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { groupCommas } from "../../lib/numutil"; import { groupCommas } from "../../lib/numutil";
import { isoToJalaliStr } from "../../lib/jalali"; import { isoToJalaliStr } from "../../lib/jalali";
import { faNum } from "../../lib/formatters"; import { faNum, fmtNum } from "../../lib/formatters";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
// پالت معنایی نمودارها — هم‌راستا با توکن‌های @theme در globals.css // پالت معنایی نمودارها — هم‌راستا با توکن‌های @theme در globals.css
@@ -136,35 +136,46 @@ export function ChartLoading() {
); );
} }
// ——— چیپ انتخاب واحد (وقتی مجموعه‌ای با واحدهای ناسازگار نمایش داده می‌شود) ——— // ——— چیپ انتخاب واحد — با مجموع هر واحد تا تغییرِ نمودار فوراً دیده شود ———
export function UnitPicker({ export function UnitPicker({
units, units,
value, value,
onChange, onChange,
totals,
}: { }: {
units: Record<string, string>; units: Record<string, string>;
value: string; value: string;
onChange: (code: string) => void; onChange: (code: string) => void;
totals?: Record<string, number>;
}) { }) {
const codes = Object.keys(units); const codes = Object.keys(units);
if (codes.length <= 1) return null; if (codes.length <= 1) return null;
return ( return (
<div className="flex items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
{codes.map((code) => ( {codes.map((code) => {
<button const total = totals?.[code];
key={code} return (
type="button" <button
onClick={() => onChange(code)} key={code}
className={cn( type="button"
"rounded-full px-2.5 py-0.5 text-[11px] font-semibold transition-colors", onClick={() => onChange(code)}
value === code title={"نمایش موجودی به " + (units[code] || code)}
? "bg-accent text-white" className={cn(
: "bg-slate-bg text-muted hover:text-ink", "rounded-full px-2.5 py-1 text-[11px] font-semibold transition-colors",
)} value === code
> ? "bg-accent text-white"
{units[code] || code} : "bg-slate-bg text-muted hover:text-ink",
</button> )}
))} >
{units[code] || code}
{total != null && (
<span className={cn("ms-1 tabular-nums", value === code ? "text-white/80" : "text-muted/80")}>
{fmtNum(total)}
</span>
)}
</button>
);
})}
</div> </div>
); );
} }