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(() =>
|
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={
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user