fix(dashboard): re-apply filter clear/toggle, 3-level drill-down, Persian formatting + wire /dashboard route
This commit is contained in:
@@ -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() {
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
{/* داشبورد تحلیلی MVP (انبار + بازرگانی) — برای PM نیز در دسترس */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequirePmProjects>
|
||||
<MvpDashboardPage />
|
||||
</RequirePmProjects>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/units"
|
||||
element={
|
||||
|
||||
@@ -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 { cn } from "../lib/utils";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
@@ -26,6 +27,9 @@ export interface ComboboxProps {
|
||||
onAdd?: (val: string) => void;
|
||||
onInputChange?: (val: string) => void;
|
||||
onBlur?: () => void;
|
||||
// با ارائهی این تابع، دکمهی «×» داخل فیلد ظاهر میشود تا انتخاب فعلی
|
||||
// (و فیلتر وابسته به آن) بهتنهایی پاک شود.
|
||||
onClear?: () => void;
|
||||
// برای فوکوس برنامهای (مثل بازگشت فوکوس پس از ثبت پیوسته)
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
// با 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<HTMLDivElement>(() => 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 && (
|
||||
<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 && (
|
||||
<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 && (
|
||||
|
||||
@@ -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({
|
||||
<span className="text-[17px] font-extrabold leading-6">{faNum(tickets)}</span>
|
||||
<span className="text-[10.5px] 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>
|
||||
@@ -138,7 +138,7 @@ export function DailyControlPanel() {
|
||||
</span>
|
||||
<span className="truncate text-[12.5px] font-bold">{p.name}</span>
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
@@ -165,7 +165,7 @@ export function DailyControlPanel() {
|
||||
</Badge>
|
||||
<span className="truncate text-[12px] font-bold">{it.material}</span>
|
||||
<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 className="ms-auto shrink-0 text-[10.5px] text-muted" title={it.warehouse}>
|
||||
{fmtDateTime(it.transaction_date)}
|
||||
|
||||
@@ -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<string, string> | null) => {
|
||||
const parts = Object.entries(specs || {});
|
||||
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیشفرض)";
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"rounded-2xl border border-line bg-panel p-3 sm:p-4",
|
||||
className,
|
||||
)}
|
||||
className={cn("rounded-2xl border border-line bg-panel p-3 sm:p-4", className)}
|
||||
aria-label="فیلتر سراسری متریال"
|
||||
>
|
||||
<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>
|
||||
<p className="text-[10.5px] text-muted">دستهبندی ← متریال ← برند ← مشخصات فنی</p>
|
||||
</div>
|
||||
{activeChips.length > 0 && (
|
||||
{activeCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
@@ -73,106 +73,46 @@ export function DashboardFilterBar({ className }: { className?: string }) {
|
||||
{/* ۱ — دستهبندی */}
|
||||
<Combobox
|
||||
value={state.categoryLabel ?? ""}
|
||||
placeholder={state.categoryId ? state.categoryLabel ?? "دستهبندی" : "همهی دستهها"}
|
||||
placeholder="همهی دستهها"
|
||||
allowAdd={false}
|
||||
items={[
|
||||
{ id: CLEAR_ID, label: "همهی دستهها" },
|
||||
...categories.map((c) => ({ 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)}
|
||||
/>
|
||||
|
||||
{/* ۲ — متریال (وابسته به دسته) */}
|
||||
{/* ۲ — متریال (وابسته به دستهی انتخابشده) */}
|
||||
<Combobox
|
||||
value={state.materialLabel ?? ""}
|
||||
placeholder={state.materialId ? state.materialLabel ?? "متریال" : "همهی متریالها"}
|
||||
placeholder="همهی متریالها"
|
||||
allowAdd={false}
|
||||
disabled={materialOptions.length === 0}
|
||||
items={[
|
||||
{ id: CLEAR_ID, label: "همهی متریالها" },
|
||||
...materialOptions.map((m) => ({ 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)}
|
||||
/>
|
||||
|
||||
{/* ۳ — برند (از موجودی واقعیِ همان متریال) */}
|
||||
{/* ۳ — برند (فقط برندهای همان متریال) */}
|
||||
<Combobox
|
||||
value={state.brandLabel ?? ""}
|
||||
placeholder={state.brandId ? state.brandLabel ?? "برند" : "همهی برندها"}
|
||||
placeholder={selectedMaterial ? "همهی برندها" : "ابتدا متریال را انتخاب کنید"}
|
||||
allowAdd={false}
|
||||
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
|
||||
items={[
|
||||
{ id: CLEAR_ID, label: "همهی برندها" },
|
||||
...(selectedMaterial?.brands ?? []).map((b) => ({ 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 از موجودی واقعی) */}
|
||||
{/* ۴ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
|
||||
<Combobox
|
||||
value={state.specsLabel ?? ""}
|
||||
placeholder={state.specsHash ? state.specsLabel ?? "مشخصات" : "همهی مشخصات"}
|
||||
placeholder={selectedMaterial ? "همهی مشخصات" : "ابتدا متریال را انتخاب کنید"}
|
||||
allowAdd={false}
|
||||
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
|
||||
items={[
|
||||
{ id: CLEAR_ID, label: "همهی مشخصات" },
|
||||
...(selectedMaterial?.specs ?? []).map((s) => {
|
||||
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)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Level, string> = {
|
||||
0: "کل سازمان",
|
||||
1: "پروژهها",
|
||||
2: "انبارها",
|
||||
};
|
||||
|
||||
// پالت سطح انبارها — سایههای ایندیگو برای تمایز انبارهای یک پروژه
|
||||
const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"];
|
||||
|
||||
export function DrilldownMatrix() {
|
||||
const query = useDrilldown();
|
||||
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;
|
||||
|
||||
@@ -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<string, number>) => (activeUnit ? q[activeUnit] ?? 0 : 0);
|
||||
|
||||
// برشهای سطح فعال — کاملاً از پاسخ API
|
||||
const slices: Slice[] = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const pick = (q: Record<string, number>) => (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 (
|
||||
<ChartCard
|
||||
title="ماتریس توزیع موجودی — کجا ذخیره شده؟"
|
||||
sub={
|
||||
project
|
||||
? `انبارهای پروژه «${project.name}» — برای بازگشت روی مسیر کلیک کنید`
|
||||
: "سهم هر پروژه از کل سازمان — روی هر ستون کلیک کنید تا انبارهای آن پروژه را ببینید"
|
||||
}
|
||||
actions={
|
||||
data ? <UnitPicker units={data.units} value={activeUnit ?? ""} onChange={setUnit} /> : null
|
||||
}
|
||||
sub={subText}
|
||||
actions={data ? <UnitPicker units={data.units} value={activeUnit ?? ""} onChange={setUnit} totals={data.total.qty_by_unit} /> : null}
|
||||
>
|
||||
{/* مسیر Drill-Down */}
|
||||
{/* مسیر Drill-Down (RTL) */}
|
||||
<nav dir="rtl" className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProject(null)}
|
||||
className={`rounded-md px-2 py-0.5 font-bold transition-colors ${
|
||||
project ? "text-muted hover:bg-slate-bg hover:text-ink" : "bg-accent-bg text-accent"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setProjectId(null);
|
||||
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>
|
||||
{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" />
|
||||
<span className="rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent">
|
||||
{project.name}
|
||||
{selectedProject.name}
|
||||
</span>
|
||||
<span className="text-muted">← انبار</span>
|
||||
<span className="text-muted">← {LEVEL_TITLES[2]}</span>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
</nav>
|
||||
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : !slices.length || levelTotal === 0 ? (
|
||||
) : !slices.length || (levelTotal === 0 && level === 0) ? (
|
||||
<ChartEmpty message="موجودیای با این فیلترها یافت نشد" />
|
||||
) : (
|
||||
<div dir="ltr" className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={slices} margin={{ top: 24, right: 8, left: 8, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: "#64748b", fontFamily: "Vazirmatn" }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e9f0" }}
|
||||
interval={0}
|
||||
height={44}
|
||||
angle={slices.length > 4 ? -18 : 0}
|
||||
textAnchor={slices.length > 4 ? "end" : "middle"}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||
tickFormatter={faAxisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={56}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
|
||||
content={
|
||||
<ChartTooltip
|
||||
labelFormatter={(l) => String(l)}
|
||||
valueFormatter={(v) => `${faNum(v.toLocaleString("en-US"))} ${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 });
|
||||
<>
|
||||
<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 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 11, fill: "#64748b", fontFamily: "Vazirmatn" }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e9f0" }}
|
||||
interval={0}
|
||||
height={44}
|
||||
angle={slices.length > 4 ? -18 : 0}
|
||||
textAnchor={slices.length > 4 ? "end" : "middle"}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||
tickFormatter={faAxisTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={56}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
|
||||
content={
|
||||
<ChartTooltip
|
||||
valueFormatter={(v) => `${fmtNum(v)} ${unitLabel}`}
|
||||
/>
|
||||
}
|
||||
}}
|
||||
className={project ? "" : "cursor-pointer"}
|
||||
>
|
||||
{slices.map((s) => (
|
||||
<Cell
|
||||
key={s.id ?? s.name}
|
||||
fill={project ? CHART_COLORS.accent : "#818cf8"}
|
||||
fillOpacity={project ? 1 : 0.9}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
name="موجودی"
|
||||
radius={[6, 6, 0, 0]}
|
||||
maxBarSize={level === 0 ? 110 : 64}
|
||||
isAnimationActive={false}
|
||||
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) : "";
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{slices.map((s, i) => (
|
||||
<Cell
|
||||
key={s.id}
|
||||
fill={
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -95,6 +95,7 @@ export function FlowChart() {
|
||||
}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ direction: "rtl", width: "100%" }}
|
||||
verticalAlign="top"
|
||||
height={28}
|
||||
iconType="circle"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { Truck } from "lucide-react";
|
||||
import { useInTransit } from "../../hooks/useDashboardData";
|
||||
import type { InTransitRow } from "../../lib/types";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { faNum, fmtNum } from "../../lib/formatters";
|
||||
import { cn } from "../../lib/utils";
|
||||
import {
|
||||
Table,
|
||||
@@ -90,13 +90,13 @@ export function InTransitGrid() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-end font-bold tabular-nums">
|
||||
{faNum(ordered.toLocaleString("en-US"))}
|
||||
{fmtNum(ordered)}
|
||||
<div className="text-[10px] font-normal text-muted">
|
||||
{r.unit?.name_fa || r.unit?.code}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-end font-bold tabular-nums">
|
||||
{faNum(received.toLocaleString("en-US"))}
|
||||
{fmtNum(received)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -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={
|
||||
<ChartTooltip
|
||||
labelFormatter={(l) => 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() {
|
||||
<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="ms-auto text-[15px] font-extrabold tabular-nums">
|
||||
{faNum(s.value.toLocaleString("en-US"))}
|
||||
{fmtNum(s.value)}
|
||||
</span>
|
||||
</div>
|
||||
{s.conversion != null && (
|
||||
|
||||
@@ -88,6 +88,7 @@ export function ProjectDensityChart() {
|
||||
}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ direction: "rtl", width: "100%" }}
|
||||
verticalAlign="top"
|
||||
height={26}
|
||||
iconType="circle"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { faNum, fmtNum } from "../../lib/formatters";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
// پالت معنایی نمودارها — همراستا با توکنهای @theme در globals.css
|
||||
@@ -136,35 +136,46 @@ export function ChartLoading() {
|
||||
);
|
||||
}
|
||||
|
||||
// ——— چیپ انتخاب واحد (وقتی مجموعهای با واحدهای ناسازگار نمایش داده میشود) ———
|
||||
// ——— چیپ انتخاب واحد — با مجموع هر واحد تا تغییرِ نمودار فوراً دیده شود ———
|
||||
export function UnitPicker({
|
||||
units,
|
||||
value,
|
||||
onChange,
|
||||
totals,
|
||||
}: {
|
||||
units: Record<string, string>;
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
totals?: Record<string, number>;
|
||||
}) {
|
||||
const codes = Object.keys(units);
|
||||
if (codes.length <= 1) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{codes.map((code) => (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
onClick={() => onChange(code)}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-0.5 text-[11px] font-semibold transition-colors",
|
||||
value === code
|
||||
? "bg-accent text-white"
|
||||
: "bg-slate-bg text-muted hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{units[code] || code}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{codes.map((code) => {
|
||||
const total = totals?.[code];
|
||||
return (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
onClick={() => onChange(code)}
|
||||
title={"نمایش موجودی به " + (units[code] || code)}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-1 text-[11px] font-semibold transition-colors",
|
||||
value === code
|
||||
? "bg-accent text-white"
|
||||
: "bg-slate-bg text-muted hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user