feat(dashboard): implement floating waterfall inventory bridge with step-line balance

This commit is contained in:
fatemeh sane
2026-10-15 15:21:08 +03:30
parent eedb821e3b
commit 692343423c
8 changed files with 492 additions and 230 deletions
@@ -1,13 +1,13 @@
// ============================================================
// فیلتر سراسری «چه چیزی» — آبشار: دسته‌بندی → متریال → برند → مشخصات
// فیلتر سراسری «چه چیزی» — آبشار: متریال → برند → مشخصات فنی
// داده از /dashboard/filters می‌آید (بدون هیچ لیست ساختگی) و انتخاب
// هر سطح، سطح‌های پایین‌دست را بازنشانی می‌کند:
// دسته → متریال‌های همان دسته
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
// متریال → مشخصات فنی موجودِ همان متریال
// هر فیلد «×» مخصوص خودش را دارد و گزینه‌ی «همه‌ی …» در ابتدای هر فهرست
// همان سطح (و سطوح پایین‌دست) را پاک می‌کند؛ دکمه‌ی «حذف فیلترها» کل استور را
// یک‌جا به نمای «کل سازمان» برمی‌گرداند.
// سه فیلتر در یک ردیفِ وسط‌چین با عرض یکسان می‌نشینند (موبایل: تک‌ستونی).
// وضعیت در Zustand نگه داشته می‌شود تا با جابه‌جایی بین تب‌ها از دست نرود.
// ============================================================
@@ -27,13 +27,13 @@ const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID];
// برچسب‌های ثابت UI — placeholder هر فیلد و گزینه‌ی «همه»اش یک متن مشترک دارند
const LABELS = {
allCategories: "همه‌ی دسته‌ها",
allMaterials: "همه‌ی متریال‌ها",
allBrands: "همه‌ی برندها",
allSpecs: "همه‌ی مشخصات",
materialFirst: "ابتدا متریال را انتخاب کنید",
clearAll: "حذف فیلترها",
wholeOrg: "نمای کل سازمان",
cascade: "متریال ← برند ← مشخصات فنی",
} as const;
const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label });
@@ -48,23 +48,21 @@ export function DashboardFilterBar({ className }: { className?: string }) {
const filterQuery = useDashboardFilterOptions();
const state = useDashboardFilter();
const categories = filterQuery.data?.categories ?? [];
// متریال‌های سطح فعلی: اگر دسته انتخاب شده باشد فقط متریال‌های همان دسته
const materialOptions = useMemo(() => {
const cat = categories.find((c) => (c.id ?? "") === (state.categoryId ?? ""));
const list = cat ? cat.materials : categories.flatMap((c) => c.materials);
// حذف متریال تکراری وقتی «همه‌ی دسته‌ها» فعال است
// فهرست متریال‌ها مستقیماً از کاتالوگ سراسری می‌آید؛ فقط یکتاسازی شناسه‌ها
// انجام می‌شود تا هیچ گزینه‌ی تکراری در فهرست نباشد.
const materials = useMemo(() => {
const seen = new Set<string>();
return list.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true)));
}, [categories, state.categoryId]);
return (filterQuery.data?.materials ?? []).filter((m) =>
seen.has(m.id) ? false : (seen.add(m.id), true),
);
}, [filterQuery.data]);
const selectedMaterial = useMemo(
() => materialOptions.find((m) => m.id === state.materialId) ?? null,
[materialOptions, state.materialId],
() => materials.find((m) => m.id === state.materialId) ?? null,
[materials, state.materialId],
);
const activeCount = [state.categoryId, state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
const activeCount = [state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
// انتخاب از فهرست؛ گزینه‌ی «همه» همان سطح را پاک می‌کند (آبشار خودکار)
const pick =
@@ -73,17 +71,9 @@ export function DashboardFilterBar({ className }: { className?: string }) {
item.id === ALL_OPTION_ID ? set(null, null) : set(item.id, item.label);
// فهرست هر فیلد با گزینه‌ی «همه» در ابتدا
const categoryItems = useMemo<ComboboxItem[]>(
() => [
allOption(LABELS.allCategories),
...categories.filter((c) => c.id).map((c) => ({ id: c.id as string, label: c.name })),
],
[categories],
);
const materialItems = useMemo<ComboboxItem[]>(
() => [allOption(LABELS.allMaterials), ...materialOptions.map((m) => ({ id: m.id, label: m.name }))],
[materialOptions],
() => [allOption(LABELS.allMaterials), ...materials.map((m) => ({ id: m.id, label: m.name }))],
[materials],
);
const brandItems = useMemo<ComboboxItem[]>(
@@ -113,7 +103,7 @@ export function DashboardFilterBar({ className }: { className?: string }) {
</span>
<div>
<h2 className="text-[13px] font-extrabold leading-5">فیلتر جنس</h2>
<p className="text-[10.5px] text-muted">دسته‌بندی ← متریال ← برند ← مشخصات فنی</p>
<p className="text-[10.5px] text-muted">{LABELS.cascade}</p>
</div>
{activeCount > 0 && (
<button
@@ -132,31 +122,21 @@ export function DashboardFilterBar({ className }: { className?: string }) {
)}
</div>
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
{/* ۱ — دسته‌بندی */}
<Combobox
value={state.categoryLabel ?? ""}
placeholder={LABELS.allCategories}
allowAdd={false}
items={categoryItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setCategory(null, null)}
onChange={pick(state.setCategory)}
/>
{/* ۲ — متریال (وابسته به دسته‌ی انتخاب‌شده) */}
{/* سه فیلتر با عرض یکسان، وسط‌چین و متقارن؛ روی موبایل تک‌ستونی می‌شود */}
<div className="mx-auto grid w-full max-w-4xl grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
{/* ۱ — متریال (سرآغاز آبشار) */}
<Combobox
value={state.materialLabel ?? ""}
placeholder={LABELS.allMaterials}
allowAdd={false}
disabled={materialOptions.length === 0}
disabled={materials.length === 0}
items={materialItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setMaterial(null, null)}
onChange={pick(state.setMaterial)}
/>
{/* ۳ — برند (فقط برندهای همان متریال) */}
{/* ۲ — برند (فقط برندهای همان متریال) */}
<Combobox
value={state.brandLabel ?? ""}
placeholder={selectedMaterial ? LABELS.allBrands : LABELS.materialFirst}
@@ -168,7 +148,7 @@ export function DashboardFilterBar({ className }: { className?: string }) {
onChange={pick(state.setBrand)}
/>
{/* ۴ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
{/* ۳ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
<Combobox
value={state.specsLabel ?? ""}
placeholder={selectedMaterial ? LABELS.allSpecs : LABELS.materialFirst}
@@ -32,6 +32,7 @@ import {
} from "lucide-react";
import { useDrilldown } from "../../hooks/useDashboardData";
import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter";
import type { DrilldownProject } from "../../lib/types";
import { faNum, fmtNum } from "../../lib/formatters";
import {
ChartCard,
@@ -49,7 +50,8 @@ interface DrillNode {
name: string;
}
// یک گروه استک (پروژه در سطح ۰، انبار در سطح ۱) — نام و رنگ از payload مشتق می‌شوند
// یک گروه استک (پروژه در نمای کلی، انبار در نمای drill-down) — نام و رنگ از
// payload مشتق می‌شوند
interface StackGroup {
key: string;
name: string;
@@ -85,15 +87,57 @@ const LABELS = {
totalLabel: "مجموع نمایش داده شده",
itemsLabel: "اقلام نمایش داده شده",
tooltipTotal: "مجموع",
tooltipBreakdown: "تفکیک بر اساس",
emptyChart: "موجودی‌ای با این فیلترها یافت نشد",
} as const;
// فراداده‌ی سطوح — برچسب‌های ثابت UI برای محور و گروه‌ها (نام‌ها از payload می‌آیند)
const LEVEL_META: Record<Level, { axisTitle: string; groupTitle: string }> = {
0: { axisTitle: "متریال‌ها", groupTitle: "پروژه‌ها" },
1: { axisTitle: "پروژه‌ها", groupTitle: "انبارها" },
// فراداده‌ی سطوح — برچسب محور X هر سطح (نام‌ها از payload می‌آیند)
const LEVEL_META: Record<Level, { axisTitle: string }> = {
0: { axisTitle: "متریال‌ها" },
1: { axisTitle: "پروژه‌ها" },
};
// ——— تفکیک سگمنت‌ها (استک‌ها) ———
// نوع تفکیک زمینه‌آگاه است: نمای کلی بدون فیلتر پروژه → پروژه‌ها؛ نمای
// drill-down متریال یا نمای دارای فیلتر پروژه → انبارها (تفکیک ریزتر).
type SegmentKind = "project" | "warehouse";
const SEGMENT_META: Record<SegmentKind, { label: string; plural: string }> = {
project: { label: "پروژه", plural: "پروژه‌ها" },
warehouse: { label: "انبار", plural: "انبارها" },
};
function segmentKindFor(level: Level, projectFilterActive: boolean): SegmentKind {
return level === DRILL_LEVEL || projectFilterActive ? "warehouse" : "project";
}
// نام کامل سگمنت انبار — انبارهای هم‌نام در پروژه‌های مختلف با پیشوند پروژه از
// هم تفکیک می‌شوند («مجتمع مسکونی آرام - انبار شمالی»)
const SEGMENT_NAME_SEPARATOR = " - ";
const warehouseSegmentName = (projectName: string, warehouseName: string): string =>
`${projectName}${SEGMENT_NAME_SEPARATOR}${warehouseName}`;
// انبار هم‌نام در «همان پروژه» هم ممکن است (دیتابیس محدودیت یکتایی روی
// (project_id, name) ندارد)؛ فقط در همان حالتِ برخورد، یک شماره‌ی ترتیب پایدار
// به انتهای نام اضافه می‌شود تا راهنما هیچ‌وقت دو برچسب یکسان نداشته باشد.
function disambiguateNames(entries: { key: string; name: string }[]): Map<string, string> {
const keysByName = new Map<string, string[]>();
entries.forEach((entry) => {
const keys = keysByName.get(entry.name) ?? [];
keys.push(entry.key);
keysByName.set(entry.name, keys);
});
const names = new Map<string, string>();
entries.forEach((entry) => {
const twins = keysByName.get(entry.name) ?? [];
names.set(
entry.key,
twins.length > 1 ? `${entry.name} (${faNum(twins.indexOf(entry.key) + 1)})` : entry.name,
);
});
return names;
}
// شناسه‌ی استک در Recharts + سقف عرض میله‌ها به‌ازای هر سطح
const STACK_ID = "dashboard-stack";
const MAX_BAR_SIZE: Record<Level, number> = { 0: 110, 1: 64 };
@@ -228,17 +272,25 @@ function numericValues(qtyByUnit: Record<string, number>): Record<string, number
return out;
}
// تولتیپ استک — نام گروه، کمیت خام با واحد، و سهم درصدی از میله
function StackTooltip({
// تولتیپ سفارشی — کارت شیشه‌ای مینیمال با سلسله‌مراتب روشن:
// سربرگ (موجودیت میله + نوع تفکیک جاری)، بدنه (به‌ازای هر سگمنت: نقطه‌ی رنگ
// داینامیک، نام کامل، کمیت و سهم درصدی) و پاصفحه (جمع میله).
// عرض کارت با محتوا رشد می‌کند (w-max) و فقط با سقف واکنش‌گرا محدود می‌شود؛
// متن‌های بلند (عنوان/نام سگمنت) در همان سقف می‌شکنند تا هرگز از کادر بیرون
// نزنند، و گروه‌های عددی با whitespace-nowrap یک‌تکه می‌مانند تا اعداد و متن
// در حالت RTL به‌هم نریزند.
function CustomTooltip({
active,
label,
payload,
stacks,
segmentKind,
}: {
active?: boolean;
label?: string | number;
payload?: readonly { payload?: StackedBar }[];
stacks: StackGroup[];
segmentKind: SegmentKind;
}) {
if (!active || !payload?.length) return null;
const bar = payload[0]?.payload;
@@ -249,25 +301,54 @@ function StackTooltip({
if (rows.length === 0) return null;
const total = rows.reduce((sum, row) => sum + row.value, 0);
return (
<div dir="rtl" className="rounded-xl border border-line bg-panel/95 px-3 py-2 shadow-lg backdrop-blur-sm">
<div className="mb-1 text-[11px] font-bold text-muted">{String(label ?? bar.label)}</div>
<div className="flex flex-col gap-1">
<div
dir="rtl"
className="w-max max-w-[85vw] whitespace-normal rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md sm:max-w-[320px] sm:whitespace-nowrap"
>
{/* سربرگ: موجودیت اصلی میله (متریال در نمای کلی، پروژه در نمای drill-down) */}
<div className="flex flex-col gap-0.5">
<span className="whitespace-normal text-xs font-bold text-slate-800">
{String(label ?? bar.label)}
</span>
<span className="text-[10px] text-slate-500 sm:text-xs">
{LABELS.tooltipBreakdown} {SEGMENT_META[segmentKind].plural}
</span>
</div>
<div className="my-2 border-b border-slate-100" />
{/* ردیف هر سگمنت: نام سمت راست، کمیت و درصد چسبیده به هم سمت چپ */}
<div className="flex flex-col gap-2">
{rows.map((row) => (
<div key={row.stack.key} className="flex items-center gap-2 text-[12px] leading-5">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: row.stack.color }} />
<span className="max-w-[10rem] truncate text-muted">{row.stack.name}</span>
<span className="ms-auto font-bold tabular-nums text-ink">
{fmtNum(row.value)} {bar.unitLabel}
</span>
<span className="w-10 text-end text-[11px] tabular-nums text-accent">
{faNum(Math.round((row.value / total) * 100))}٪
</span>
<div key={row.stack.key} className="flex w-full items-center justify-between gap-6">
<div className="flex items-center gap-2">
<span
aria-hidden
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ background: row.stack.color }}
/>
<span className="whitespace-normal text-[11px] font-medium text-slate-700 sm:text-xs">
{row.stack.name}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
{fmtNum(row.value)} {bar.unitLabel}
</span>
<span className="whitespace-nowrap text-[11px] tabular-nums text-slate-700 sm:text-xs">
{faNum(Math.round((row.value / total) * 100))}٪
</span>
</div>
</div>
))}
</div>
<div className="mt-1 border-t border-line pt-1 text-[11px] text-muted">
{LABELS.tooltipTotal}{" "}
<span className="font-bold tabular-nums text-ink">
<div className="my-2 border-b border-slate-100" />
{/* جمع میله — «مجموع: ۸۰۰ متر» کنار هم و از سمت راست */}
<div className="flex w-full items-center justify-start gap-1.5 text-xs font-semibold text-slate-600">
<span>{LABELS.tooltipTotal}:</span>
<span className="whitespace-nowrap tabular-nums">
{fmtNum(total)} {bar.unitLabel}
</span>
</div>
@@ -278,7 +359,7 @@ function StackTooltip({
// اثر انگشت فیلتر سراسری (Zustand) — هر تغییر آن یعنی payload تازه‌ای می‌آید و
// اعتبارسنجی متریال انتخابی باید دوباره اجرا شود.
const selectGlobalFilterKey = (s: DashboardFilterState): string =>
JSON.stringify([s.categoryId, s.materialId, s.brandId, s.specsHash]);
JSON.stringify([s.materialId, s.brandId, s.specsHash]);
// --- MultiSelect Dropdown Component ---
// کنترل چند‌انتخابی دامنه (پروژه/انبار) — به‌عنوان گرهی چند‌انتخابیِ breadcrumb
@@ -406,6 +487,10 @@ export function DrilldownMatrix() {
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [selectedWarehouseIds, setSelectedWarehouseIds] = useState<string[]>([]);
// تفکیک جاری نمودار: نمای کلی بدون فیلتر پروژه → پروژه‌ها؛ نمای drill-down یا
// نمای دارای فیلتر پروژه → انبارها
const segmentKind = segmentKindFor(level, selectedProjectIds.length > 0);
// عرض واقعی کانتینر نمودار — تا نمودار هرگز در عرض والد فشرده نشود
const plotRef = useRef<HTMLDivElement>(null);
const [plotWidth, setPlotWidth] = useState(0);
@@ -482,8 +567,8 @@ export function DrilldownMatrix() {
}, []);
// ——— داده‌ی نمودار ۱۰۰٪ استک‌شده ———
// همه‌ی محورها، گروه‌ها، واحدها و رنگ‌ها از payload مشتق می‌شوند؛ هیچ نگاشت
// دستی (متریال/پروژه/رنگ) در کد وجود ندارد.
// محورها، سگمنت‌ها (پروژه/انبار بر اساس segmentKind)، نام‌های کامل، واحدها
// و رنگ‌ها همه از payload مشتق می‌شوند؛ هیچ نگاشت دستی در کد وجود ندارد.
const { bars, stacks, unitLabels } = useMemo(() => {
const empty = {
bars: [] as StackedBar[],
@@ -532,29 +617,38 @@ export function DrilldownMatrix() {
}
};
if (level === ROOT_LEVEL) {
// میله = متریال، گروه = پروژه
data.materials.forEach((mat) => {
const bar = ensureBar(mat.id, mat.name, mat.qty_by_unit);
mat.projects.forEach((proj) => {
if (!matchesProject(proj.id)) return;
proj.warehouses.forEach((wh) => {
if (!matchesWarehouse(wh.id)) return;
addGroup(bar, proj.id, proj.name, wh.qty_by_unit);
});
});
});
} else {
// میله = پروژه‌ی متریال انتخاب‌شده، گروه = انبار
const mat = data.materials.find((m) => m.id === materialId);
mat?.projects.forEach((proj) => {
// میله‌ها و سگمنت‌ها با یک الگوی واحد ساخته می‌شوند: میله = متریال (نمای
// کلی) یا پروژه (نمای drill-down)، و سگمنت بر اساس تفکیک زمینه‌آگاه
// (segmentKind) پروژه یا انبار است.
const addBar = (
barId: string,
barLabel: string,
ownUnits: Record<string, number>,
projects: DrilldownProject[],
) => {
const bar = ensureBar(barId, barLabel, ownUnits);
projects.forEach((proj) => {
if (!matchesProject(proj.id)) return;
const bar = ensureBar(proj.id, proj.name, proj.qty_by_unit);
proj.warehouses.forEach((wh) => {
if (!matchesWarehouse(wh.id)) return;
addGroup(bar, wh.id, wh.name, wh.qty_by_unit);
if (segmentKind === "project") {
addGroup(bar, proj.id, proj.name, wh.qty_by_unit);
} else {
// سگمنت انبار: نام کامل «پروژه - انبار» تا انبارهای هم‌نام در
// پروژه‌های مختلف در راهنما و تولتیپ قابل تفکیک باشند
addGroup(bar, wh.id, warehouseSegmentName(proj.name, wh.name), wh.qty_by_unit);
}
});
});
};
if (level === ROOT_LEVEL) {
// میله = متریال، سگمنت = پروژه (نمای کلی) یا انبار (وقتی فیلتر پروژه فعال است)
data.materials.forEach((mat) => addBar(mat.id, mat.name, mat.qty_by_unit, mat.projects));
} else {
// میله = پروژه‌ی متریال انتخاب‌شده، سگمنت = انبار
const mat = data.materials.find((m) => m.id === materialId);
mat?.projects.forEach((proj) => addBar(proj.id, proj.name, proj.qty_by_unit, [proj]));
}
const groupNames = new Map<string, string>();
@@ -597,24 +691,29 @@ export function DrilldownMatrix() {
stackTotals.set(groupId, (stackTotals.get(groupId) ?? 0) + value);
}
}
const stacks: StackGroup[] = [...stackTotals.entries()]
const orderedKeys = [...stackTotals.entries()]
.sort(
(a, b) =>
b[1] - a[1] ||
(groupNames.get(a[0]) ?? "").localeCompare(groupNames.get(b[0]) ?? "", "fa"),
)
.map(([key], index) => ({
key,
name: groupNames.get(key) ?? key,
color: groupColor(index),
}));
.map(([key]) => key);
// نام‌های تکراری (انبار هم‌نامِ همان پروژه) پیش از ساخت راهنما تفکیک می‌شوند
const uniqueNames = disambiguateNames(
orderedKeys.map((key) => ({ key, name: groupNames.get(key) ?? key })),
);
const stacks: StackGroup[] = orderedKeys.map((key, index) => ({
key,
name: uniqueNames.get(key) ?? groupNames.get(key) ?? key,
color: groupColor(index),
}));
return {
bars,
stacks,
unitLabels: [...new Set(bars.map((b) => b.unitLabel).filter(Boolean))],
};
}, [data, level, materialId, selectedProjectIds, selectedWarehouseIds]);
}, [data, level, materialId, segmentKind, selectedProjectIds, selectedWarehouseIds]);
// عرض صریح نمودار: هر میله یک اسلات ثابت می‌گیرد تا میله‌ها پخش شوند و
// برچسب‌های محور X فاصله‌ی کافی داشته باشند؛ هرگز باریک‌تر از عرض واقعی
@@ -779,18 +878,29 @@ export function DrilldownMatrix() {
) : (
<>
{/* کانتینر اسکرول‌دار — عرض صریح نمودار از chartWidth می‌آید تا
میله‌ها پخش شوند و برچسب‌ها فاصله‌ی کافی داشته باشند */}
میله‌ها پخش شوند و برچسب‌های فاصله‌ی کافی داشته باشند.
ریشه‌ی حلقه‌ی مشکی فوکوس: Recharts لایه‌های z-index را به‌صورت
<g tabindex="-1" class="recharts-zIndex-layer_*"> رندر می‌کند
(ZIndexPortal.js) و آن g کل ناحیه‌ی نمودار را می‌پوشاند؛ پس با
کلیک، فوکوس روی همین g می‌نشیند و مرورگر حلقه‌ی پیش‌فرض را دور کل
نمودار می‌کشد. چون outline ارث‌بری نمی‌شود، مهار آن فقط با
سلکتور فرزند (g/سایر عناصر داخل نمودار) ممکن است. */}
<div
ref={plotRef}
dir="ltr"
className="w-full overflow-x-auto overflow-y-hidden"
className="w-full overflow-x-auto overflow-y-hidden outline-none focus:outline-none [&_svg]:!outline-none [&_svg:focus]:!outline-none [&_svg:active]:!outline-none [&_svg:focus-visible]:!outline-none [&_.recharts-surface]:!outline-none [&_g]:!outline-none [&_g:focus]:!outline-none [&_g:focus-visible]:!outline-none [&_*]:!outline-none"
style={{ width: '100%', height: CHART_HEIGHT }}
>
{/* بدون لایه‌ی دسترس‌پذیری، Recharts روی خود SVG دیگر tabIndex
نمی‌گذارد؛ ولی لایه‌های z-index داخلی‌اش (g[tabindex="-1"]) هنوز
فوکوس‌پذیرند و همان‌ها منبع حلقه‌ی فوکوس‌اند — که با سلکتورهای
فرزند روی wrapper مهار شده‌اند. */}
<BarChart
width={chartWidth}
height={CHART_HEIGHT}
data={bars}
stackOffset="expand"
accessibilityLayer={false}
margin={{ top: 26, right: 12, left: 8, bottom: 12 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
@@ -814,7 +924,9 @@ export function DrilldownMatrix() {
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
content={(props) => <StackTooltip {...props} stacks={stacks} />}
content={(props) => (
<CustomTooltip {...props} stacks={stacks} segmentKind={segmentKind} />
)}
/>
{/* هر گروه استک یک Bar است؛ رنگ از ترتیب گروه و گوشه‌های گرد فقط
برای بالاترین گروه (آخرین Bar) اعمال می‌شود */}
@@ -837,19 +949,22 @@ export function DrilldownMatrix() {
</BarChart>
</div>
{/* راهنمای رنگ‌ها — از همان گروه‌های payload ساخته می‌شود */}
{/* راهنمای رنگ‌ها — از همان سگمنت‌های payload ساخته می‌شود و نام
انبارها به‌صورت کامل «پروژه - انبار» نمایش داده می‌شود */}
{stacks.length > 0 && (
<div
dir="rtl"
className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted"
>
<span className="font-bold">
{LEVEL_META[level].axisTitle} × {LEVEL_META[level].groupTitle}:
{LEVEL_META[level].axisTitle} × {SEGMENT_META[segmentKind].plural}:
</span>
{stacks.map((stack) => (
<span key={stack.key} className="inline-flex items-center gap-1">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: stack.color }} />
<span className="max-w-[14rem] truncate">{stack.name}</span>
<span className="max-w-[16rem] truncate" title={stack.name}>
{stack.name}
</span>
</span>
))}
</div>
+277 -55
View File
@@ -1,8 +1,11 @@
// ============================================================
// ویجت ۳ — سری زمانی جریان روزانه
// ستون‌های روزانه: ورود (سبز) / برگشت (کهربایی) / خروج که لایه‌ی
// قرمزِ «ضایعات، اتلاف، مفقودی» داخل همان ستون خروج سته —
// به‌علاوه‌ی خط «مانده» (بالانس تجمعی). تاریخ‌ها شمسی، اعداد فارسی.
// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور)
// هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز»
// کشیده میشود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
// بهصورت سگمنتهای [پایین، بالا] روی مسیر واقعی مانده مینشینند، پس ستون
// دقیقاً پلهی خط مانده را میسازد (نه از خط صفر).
// خط مانده پلهای (stepAfter) و تولتیپ تحلیلی کامل روز.
// همهی مقادیر (شامل ارتفاع پایه و اجزا) از دادهی سریزمانی API محاسبه میشوند.
// ============================================================
import { useMemo } from "react";
@@ -19,57 +22,234 @@ import {
} from "recharts";
import { useDailyFlow } from "../../hooks/useDashboardData";
import { isoToJalaliStr } from "../../lib/jalali";
import {
CHART_COLORS,
ChartCard,
ChartEmpty,
ChartLoading,
ChartTooltip,
faAxisTick,
} from "./chartShared";
import { fmtNum } from "../../lib/formatters";
import { ChartCard, ChartEmpty, ChartLoading, faAxisTick } from "./chartShared";
interface Row {
// ——— پالت معنایی همین نمودار (مقادیر پالت Tailwind؛ سایر ویجتها دستنخورده) ———
const FLOW_COLORS = {
inbound: "#059669", // ورود به انبار — زمردی
returns: "#0d9488", // برگشت از سایت — تیل
siteOut: "#f59e0b", // خروج به سایت — کهربایی
waste: "#dc2626", // ضایعات و اتلاف — قرمز
other: "#94a3b8", // سایر حرکات (انتقال/شمارش) — خاکستری
balance: "#0f172a", // خط مانده — سرمهای
} as const;
// برچسبهای ثابت UI (یکجا، بدون تکرار در JSX)
const LABELS = {
title: "جریان روزانه موجودی",
sub: "ستونهای شناور روی خط مانده — ورود، برگشت، خروج، ضایعات و سایر حرکات",
inbound: "ورود به انبار",
returns: "برگشت از سایت",
siteOut: "خروج به سایت",
waste: "ضایعات و اتلاف",
other: "سایر حرکات (انتقال/شمارش)",
balance: "مانده",
opening: "مانده ابتدای روز",
net: "تغییر خالص روز",
closing: "مانده پایان روز",
empty: "در این بازه جابه‌جایی موجودی ثبت نشده است",
} as const;
// سگمنت شناور: [پایین، بالا] — همان چیزی که <Bar> به‌عنوان مقدار محدوده می‌خواند
type Range = [number, number];
// یک ردیف زمانی — سگمنتها به‌صورت پراپرتی‌های مسطح می‌نشینند تا dataKey هر
// <Bar> مقدارش را مستقیماً از سطح خود شیء بخواند (بدون تودرتو شدن).
interface FlowRow {
date: string;
ورود: number;
برگشت: number;
"خروج سایت": number;
"ضایعات و اتلاف": number;
مانده: number;
_total: number;
balance: number; // خط پله‌ای مانده = مانده پایان روز
opening: number;
closing: number;
net: number;
inbound: number;
returns: number;
siteOut: number;
waste: number;
other: number;
hasMovement: boolean;
segInbound: Range;
segReturns: Range;
segSiteOut: Range;
segWaste: Range;
segOther: Range;
}
// گرد کردن تا ۴ رقم اعشار — مثل بقیه‌ی تجمیع‌های داشبورد
const round4 = (v: number) => Math.round(v * 10000) / 10000;
// حاشیه‌ی دامنه‌ی محور Y (نسبت به بازه‌ی داده) تا لبه‌ها به قاب نچسبند
const DOMAIN_PAD_RATIO = 0.04;
const MAX_BAR_SIZE = 20; // حداکثر عرض ستون (px) — عرض واقعی از باند محور می‌آید
const BAR_CATEGORY_GAP = "18%";
// همه‌ی سگمنت‌ها یک ستون مشترک می‌سازند (نه چند ستون باریک کنار هم)؛ چون
// مقدار هر سگمنت [پایین، بالا] است، Recharts آن را بدون انبارش (stacking)
// دقیقاً در همان ارتفاع شناور رسم می‌کند.
const FLOW_STACK_ID = "flow-waterfall";
// عدد با علامت برای «تغییر خالص» — ارقام فارسی
const signed = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`;
// تولتیپ تحلیلی روز — مانده ابتدا/پایان، اجزای روز، تغییر خالص
function FlowTooltip({
active,
payload,
}: {
active?: boolean;
payload?: readonly { payload?: FlowRow }[];
}) {
if (!active || !payload?.length) return null;
const row = payload[0]?.payload;
if (!row) return null;
/** rows: برچسب + مقدار + کلاس رنگ متن (رنگها از پالت Tailwind) */
const lines: { label: string; value: number; className: string; signed?: boolean }[] = [
{ label: LABELS.opening, value: row.opening, className: "text-slate-600" },
{ label: LABELS.inbound, value: row.inbound, className: "text-slate-700" },
{ label: LABELS.returns, value: row.returns, className: "text-slate-700" },
{ label: LABELS.siteOut, value: -row.siteOut, className: "text-slate-700" },
{ label: LABELS.waste, value: -row.waste, className: "text-slate-700" },
];
if (row.other !== 0) {
lines.push({ label: LABELS.other, value: row.other, className: "text-slate-700" });
}
const netTone =
row.net > 0 ? "text-emerald-600" : row.net < 0 ? "text-red-600" : "text-slate-600";
return (
<div
dir="rtl"
className="min-w-max rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md"
>
<div className="whitespace-nowrap text-xs font-bold text-slate-800">
{isoToJalaliStr(row.date) ?? row.date}
</div>
<div className="my-2 border-b border-slate-100" />
<div className="flex flex-col gap-1.5">
{lines.map((line) => (
<div key={line.label} className="flex items-center justify-between gap-6">
<span className="whitespace-nowrap text-[11px] text-slate-500 sm:text-xs">
{line.label}
</span>
<span
dir="ltr"
className={`whitespace-nowrap text-[11px] font-medium tabular-nums sm:text-xs ${line.className}`}
>
{line.value === 0 ? fmtNum(0) : signed(line.value)}
</span>
</div>
))}
<div className="my-0.5 border-b border-slate-100" />
<div className="flex items-center justify-between gap-6">
<span className="whitespace-nowrap text-[11px] font-semibold text-slate-600 sm:text-xs">
{LABELS.net}
</span>
<span
dir="ltr"
className={`whitespace-nowrap text-[11px] font-bold tabular-nums sm:text-xs ${netTone}`}
>
{signed(row.net)}
</span>
</div>
<div className="flex items-center justify-between gap-6">
<span className="whitespace-nowrap text-[11px] font-semibold text-slate-600 sm:text-xs">
{LABELS.closing}
</span>
<span
dir="ltr"
className="whitespace-nowrap text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs"
>
{fmtNum(row.closing)}
</span>
</div>
</div>
</div>
);
}
export function FlowChart() {
const query = useDailyFlow();
const rows: Row[] = useMemo(
() =>
(query.data?.days ?? []).map((d) => ({
date: d.date,
ورود: d.inbound,
برگشت: d.returns,
"خروج سایت": Math.max(0, d.outbound - d.waste),
"ضایعات و اتلاف": d.waste,
مانده: d.balance,
_total: d.inbound + d.outbound + d.returns,
})),
[query.data],
);
// ——— ساخت مسیر واقعی مانده: هر جزء روی مقدار جاری سوار می‌شود ———
const rows: FlowRow[] = useMemo(() => {
const days = query.data?.days ?? [];
let running = query.data?.opening_balance ?? 0;
return days.map((d) => {
const opening = running;
const closing = d.balance;
const waste = Math.max(0, d.waste);
// ضایعات بخشی از خروج است؛ برای ستون جدا، خالص خروج گرفته می‌شود
const siteOut = Math.max(0, d.outbound - waste);
const net = round4(closing - opening);
// «سایر حرکات» = باقی‌مانده‌ی تراز (انتقال/شمارش/یافت‌شده)؛ معمولاً صفر
const other = round4(net - (d.inbound + d.returns - siteOut - waste));
const hasAnyData = rows.some((r) => r._total > 0);
let cursor = opening;
// هر سگمنت = [کمترین، بیشترین] از مقادیر قبل و بعد از اعمال همان جزء
const segment = (delta: number): Range => {
const from = cursor;
cursor = round4(cursor + delta);
return [Math.min(from, cursor), Math.max(from, cursor)];
};
const segInbound = segment(d.inbound);
const segReturns = segment(d.returns);
const segSiteOut = segment(-siteOut);
const segWaste = segment(-waste);
const segOther = segment(other);
running = closing;
return {
date: d.date,
balance: closing,
opening,
closing,
net,
inbound: d.inbound,
returns: d.returns,
siteOut,
waste,
other,
hasMovement: d.inbound + d.returns + d.outbound + waste + Math.abs(other) > 0,
segInbound,
segReturns,
segSiteOut,
segWaste,
segOther,
};
});
}, [query.data]);
// ——— دامنه‌ی محور Y از خود داده (نه از صفر) تا ستون‌ها و خط در کادر جا شوند ———
const [domainMin, domainMax] = useMemo(() => {
const values: number[] = [];
rows.forEach((r) => {
values.push(r.balance, r.opening, r.closing);
[r.segInbound, r.segReturns, r.segSiteOut, r.segWaste, r.segOther].forEach(([lo, hi]) => {
values.push(lo, hi);
});
});
if (values.length === 0) return [0, 1] as const;
const lo = Math.min(...values);
const hi = Math.max(...values);
const pad = (hi - lo) * DOMAIN_PAD_RATIO || 1;
return [Math.floor(lo - pad), Math.ceil(hi + pad)] as const;
}, [rows]);
const hasMovement = rows.some((r) => r.hasMovement);
const hasOther = rows.some((r) => r.other !== 0);
return (
<ChartCard
title="جریان روزانه موجودی"
sub="ورود، خروج (با لایه‌ی قرمز ضایعات)، برگشت از سایت و مانده‌ی تجمعی — ۳۰ روز اخیر"
>
<ChartCard title={LABELS.title} sub={LABELS.sub}>
{query.isLoading ? (
<ChartLoading />
) : !hasAnyData ? (
<ChartEmpty message="در این بازه جابه‌جایی موجودی ثبت نشده است" />
) : !hasMovement ? (
<ChartEmpty message={LABELS.empty} />
) : (
<div dir="ltr" className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={rows} margin={{ top: 12, right: 8, left: 8, bottom: 4 }}>
<ComposedChart
data={rows}
barCategoryGap={BAR_CATEGORY_GAP}
margin={{ top: 12, right: 12, left: 8, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis
dataKey="date"
@@ -80,6 +260,7 @@ export function FlowChart() {
minTickGap={18}
/>
<YAxis
domain={[domainMin, domainMax]}
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick}
tickLine={false}
@@ -88,11 +269,7 @@ export function FlowChart() {
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
content={
<ChartTooltip
labelFormatter={(l) => isoToJalaliStr(String(l)) ?? String(l)}
/>
}
content={<FlowTooltip />}
/>
<Legend
wrapperStyle={{ direction: "rtl", width: "100%" }}
@@ -106,17 +283,62 @@ export function FlowChart() {
</span>
)}
/>
{/* ستون‌های کنار هم: ورود و برگشت */}
<Bar dataKey="ورود" fill={CHART_COLORS.inbound} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
<Bar dataKey="برگشت" fill={CHART_COLORS.returns} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
{/* ستون خروج: دو لایه‌ی سته — بخش قرمز ضایعات داخل ستون خروج */}
<Bar dataKey="خروج سایت" stackId="out" fill={CHART_COLORS.outbound} radius={[0, 0, 0, 0]} maxBarSize={18} isAnimationActive={false} />
<Bar dataKey="ضایعات و اتلاف" stackId="out" fill={CHART_COLORS.waste} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
{/* سگمنت‌های شناور آبشاری — هر جزء دقیقاً روی مسیر مانده می‌نشیند */}
<Bar
dataKey="segInbound"
name={LABELS.inbound}
fill={FLOW_COLORS.inbound}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
<Bar
dataKey="segReturns"
name={LABELS.returns}
fill={FLOW_COLORS.returns}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
<Bar
dataKey="segSiteOut"
name={LABELS.siteOut}
fill={FLOW_COLORS.siteOut}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
<Bar
dataKey="segWaste"
name={LABELS.waste}
fill={FLOW_COLORS.waste}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
{/* فقط وقتی حرکتی خارج از چهار سبد ثبت شده باشد نمایش داده می‌شود */}
{hasOther && (
<Bar
dataKey="segOther"
name={LABELS.other}
fill={FLOW_COLORS.other}
stackId={FLOW_STACK_ID}
radius={2}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
)}
{/* خط پله‌ای مانده — هم‌تراز هندسی با سرِ ستون‌های شناور */}
<Line
type="monotone"
dataKey="مانده"
stroke={CHART_COLORS.balance}
strokeWidth={1.5}
type="stepAfter"
dataKey="balance"
name={LABELS.balance}
stroke={FLOW_COLORS.balance}
strokeWidth={1.75}
strokeDasharray="5 3"
dot={false}
isAnimationActive={false}
-1
View File
@@ -46,7 +46,6 @@ import type {
// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد
// (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد)
export type DashboardFilterParams = {
category_id?: string | null;
material_id?: string | null;
brand_id?: string | null;
specs_hash?: string | null;
+1 -8
View File
@@ -232,15 +232,8 @@ export interface FilterMaterialOption {
specs: FilterSpecOption[];
}
export interface FilterCategoryOption {
id: string | null; // null = «بدون دسته»
name: string;
slug: string | null;
materials: FilterMaterialOption[];
}
export interface DashboardFilterOptions {
categories: FilterCategoryOption[];
materials: FilterMaterialOption[];
}
// — ماتریس Drill-Down (کل سازمان → پروژه → انبار) —
+3 -20
View File
@@ -1,23 +1,20 @@
// ============================================================
// Zustand store — فیلتر سراسری «چه چیزی» داشبورد
// دسته‌بندی → متریال → برند → مشخصات فنی
// متریال → برند → مشخصات فنی
// بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ می‌شود.
// ============================================================
import { create } from "zustand";
export interface DashboardFilterState {
categoryId: string | null; // null = همه‌ی دسته‌ها
materialId: string | null;
materialId: string | null; // null = همه‌ی متریال‌ها
brandId: string | null;
specsHash: string | null;
// برچسب‌های انتخاب‌شده برای نمایش چیپ فعال
categoryLabel: string | null;
materialLabel: string | null;
brandLabel: string | null;
specsLabel: string | null;
setCategory: (id: string | null, label: string | null) => void;
setMaterial: (id: string | null, label: string | null) => void;
setBrand: (id: string | null, label: string | null) => void;
setSpecs: (hash: string | null, label: string | null) => void;
@@ -25,11 +22,9 @@ export interface DashboardFilterState {
}
const INITIAL = {
categoryId: null,
materialId: null,
brandId: null,
specsHash: null,
categoryLabel: null,
materialLabel: null,
brandLabel: null,
specsLabel: null,
@@ -37,18 +32,7 @@ const INITIAL = {
export const useDashboardFilter = create<DashboardFilterState>((set) => ({
...INITIAL,
// آبشاری: تغییر هر سطح، سطح‌های پایین‌دست را پاک می‌کند
setCategory: (id, label) =>
set({
categoryId: id,
categoryLabel: label,
materialId: null,
materialLabel: null,
brandId: null,
brandLabel: null,
specsHash: null,
specsLabel: null,
}),
// آبشاری: تغییر هر سطح، سطح‌های پایین‌دست را پاک می‌کند (متریال → برند → مشخصات)
setMaterial: (id, label) =>
set({
materialId: id,
@@ -72,7 +56,6 @@ export const useDashboardFilter = create<DashboardFilterState>((set) => ({
// پارامترهای آماده برای endpointهای داشبورد
export function dashboardFilterParams(s: DashboardFilterState) {
return {
category_id: s.categoryId,
material_id: s.materialId,
brand_id: s.brandId,
specs_hash: s.specsHash,