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(() =>
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={
+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 { 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>
);
}