update dashboard logic

This commit is contained in:
fatemeh sane
2026-10-13 21:49:22 +03:30
parent 34b987398a
commit 3d16c435d0
4 changed files with 566 additions and 182 deletions
+86 -15
View File
@@ -19,6 +19,7 @@ from datetime import datetime, time, timedelta, timezone
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.models.catalog import Brand, BrandMaterial, Material, MaterialCategory, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
@@ -176,34 +177,91 @@ async def inventory_drilldown(
brand_id=None,
specs_hash=None,
) -> dict:
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse."""
stmt = (
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse.
The x-axis mirrors the *catalog*, not the ledger: the statement starts from
``materials`` and LEFT OUTER JOINs the per-(brand, specs, project,
warehouse, unit) stock aggregation. Active catalog materials without any
ledger rows therefore stay on the chart with ``COALESCE``d zero quantities
(labelled with their canonical unit) instead of disappearing in an inner
join.
"""
# تجمیع دفتر انبار به‌ازای (ماده، برند، مشخصات، پروژه، انبار، واحد) — سمت
# راست LEFT JOIN؛ محدودیت دامنه (پروژه‌های مجاز) همین‌جا اعمال می‌شود تا
# مواد بدون موجودیِ داخل دامنه هم از دست نروند.
stock = (
select(
Material.id.label("mid"),
Material.name.label("mname"),
Brand.name.label("bname"),
InventoryItem.specs,
InventoryItem.material_id.label("material_id"),
InventoryItem.brand_id.label("brand_id"),
InventoryItem.specs_hash.label("specs_hash"),
InventoryItem.specs.label("specs"),
Project.id.label("pid"),
Project.name.label("pname"),
Warehouse.id.label("wid"),
Warehouse.name.label("wname"),
Unit.code,
Unit.name_fa,
Unit.code.label("ucode"),
Unit.name_fa.label("uname_fa"),
func.sum(InventoryItem.quantity).label("qty"),
func.count().label("rows"),
)
.select_from(InventoryItem)
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
.join(Project, Project.id == Warehouse.project_id)
.join(Material, Material.id == InventoryItem.material_id)
.join(Unit, Unit.id == InventoryItem.unit_id)
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
.group_by(Material.id, Material.name, Brand.name, InventoryItem.specs, Project.id, Project.name, Warehouse.id, Warehouse.name, Unit.code, Unit.name_fa)
.group_by(
InventoryItem.material_id,
InventoryItem.brand_id,
InventoryItem.specs_hash,
InventoryItem.specs,
Project.id,
Project.name,
Warehouse.id,
Warehouse.name,
Unit.code,
Unit.name_fa,
)
stmt = _stock_filters(
stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
)
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
stock = _scope_filters(stock, project_ids=project_ids, project_id=project_id)
stock_sq = stock.subquery()
# واحد کاتالوگی ماده — برچسب واحدِ ردیف‌های صفر («۰ عدد»)
canon_unit = aliased(Unit)
stmt = (
select(
Material.id.label("mid"),
Material.name.label("mname"),
Brand.name.label("bname"),
stock_sq.c.specs,
stock_sq.c.pid,
stock_sq.c.pname,
stock_sq.c.wid,
stock_sq.c.wname,
stock_sq.c.ucode,
stock_sq.c.uname_fa,
func.coalesce(stock_sq.c.qty, 0).label("qty"),
func.coalesce(stock_sq.c.rows, 0).label("rows"),
canon_unit.code.label("canon_ucode"),
canon_unit.name_fa.label("canon_uname_fa"),
)
.select_from(Material)
.outerjoin(stock_sq, stock_sq.c.material_id == Material.id)
.outerjoin(Brand, Brand.id == stock_sq.c.brand_id)
.outerjoin(canon_unit, canon_unit.id == Material.canonical_unit_id)
.where(Material.status == "active")
)
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود می‌کنند
if category_id is not None:
stmt = stmt.where(Material.category_id == category_id)
if material_id is not None:
stmt = stmt.where(Material.id == material_id)
# برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان
# موجودی روی محور می‌مانند (رفتار قبلی دست‌نخورده)
if brand_id is not None:
stmt = stmt.where(stock_sq.c.brand_id == brand_id)
if specs_hash:
stmt = stmt.where(stock_sq.c.specs_hash == specs_hash)
rows = (await db.execute(stmt)).all()
units_legend: dict[str, str] = {}
@@ -212,10 +270,16 @@ async def inventory_drilldown(
materials_tree: dict[str, dict] = {}
for mid, mname, bname, specs, pid, pname, wid, wname, ucode, uname_fa, qty, row_count in rows:
for mid, mname, bname, specs, pid, pname, wid, wname, ucode, uname_fa, qty, row_count, canon_ucode, canon_uname_fa in rows:
q = _qty(qty)
# افسانه‌ی واحدها و جمع کل؛ مواد بدون ردیف موجودی با واحد کاتالوگی و صفر
if ucode is not None:
units_legend[ucode] = uname_fa
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
elif canon_ucode is not None:
units_legend.setdefault(canon_ucode, canon_uname_fa)
total_by_unit.setdefault(canon_ucode, 0.0)
total_rows += row_count
# Build full material name
@@ -232,9 +296,16 @@ async def inventory_drilldown(
mat_key,
{"id": mat_key, "name": full_name, "qty_by_unit": {}, "rows": 0, "_projects": {}},
)
if ucode is not None:
mat["qty_by_unit"][ucode] = round(mat["qty_by_unit"].get(ucode, 0.0) + q, 4)
elif canon_ucode is not None:
mat["qty_by_unit"].setdefault(canon_ucode, 0.0)
mat["rows"] += row_count
# مواد بدون هیچ ردیف موجودی: فقط روی محور می‌مانند (بدون پروژه/انبار)
if pid is None:
continue
proj = mat["_projects"].setdefault(
pid,
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}},
+29 -8
View File
@@ -17,6 +17,9 @@ export interface ComboboxProps {
value?: string;
onChange?: (item: ComboboxItem) => void;
items: ComboboxSource; // آرایه از string یا {id, name}
// شناسه‌ی آیتم‌هایی که همیشه در ابتدای فهرست می‌مانند و با جستجوی کاربر
// فیلتر نمی‌شوند (مثل گزینه‌ی «همه‌ی …» در فیلترهای آبشاری داشبورد)
pinnedIds?: string[];
allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید"
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
addSuffix?: string; // متن بعد از +
@@ -50,6 +53,7 @@ export function Combobox({
value,
onChange,
items,
pinnedIds,
allowAdd = true,
addPrefix,
addSuffix = "به عنوان گزینه جدید",
@@ -88,21 +92,37 @@ export function Combobox({
return all.filter((x) => normalize(x.label).includes(q));
}, [all, q]);
// آیتم‌های pin‌شده همیشه در ابتدای فهرست می‌مانند و با جستجو حذف نمی‌شوند؛
// ناوبری با کلیدها هم روی همین فهرست نهایی (visible) انجام می‌شود.
const { visible, pinnedCount, hasUnpinned } = useMemo(() => {
const pin = pinnedIds ?? [];
if (pin.length === 0) return { visible: filtered, pinnedCount: 0, hasUnpinned: false };
const pinned = pin
.map((id) => all.find((x) => x.id === id))
.filter((x): x is ComboboxItem => x !== undefined);
const pinnedSet = new Set(pinned.map((x) => x.id));
const rest = filtered.filter((x) => !pinnedSet.has(x.id));
return { visible: [...pinned, ...rest], pinnedCount: pinned.length, hasUnpinned: rest.length > 0 };
}, [all, filtered, pinnedIds]);
const showAdd =
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q);
allowAdd && q.length > 0 && !visible.some((x) => normalize(x.label) === q);
const showClear = Boolean(onClear) && !disabled && text.length > 0;
const listCount = filtered.length + (showAdd ? 1 : 0);
const listCount = visible.length + (showAdd ? 1 : 0);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
const pick = (it: ComboboxItem) => {
setText(it.label);
// آیتم با شناسه‌ی خالی یعنی «انتخاب نشده» (گزینه‌ی «همه‌ی …»)؛ فیلد به
// placeholder برمی‌گردد تا متن/دکمه‌ی × برای مقدار پاک‌شده باقی نماند.
const nextText = it.id ? it.label : "";
setText(nextText);
setOpen(false);
setHighlight(-1);
onChange?.(it);
onInputChange?.(it.label);
onInputChange?.(nextText);
};
const addCurrent = () => {
@@ -127,9 +147,9 @@ export function Combobox({
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
} else if (e.key === "Enter") {
e.preventDefault();
if (highlight >= 0 && highlight < filtered.length) {
pick(filtered[highlight]);
} else if (highlight === filtered.length && showAdd) {
if (highlight >= 0 && highlight < visible.length) {
pick(visible[highlight]);
} else if (highlight === visible.length && showAdd) {
addCurrent();
}
} else if (e.key === "Escape") {
@@ -197,13 +217,14 @@ export function Combobox({
{listCount === 0 && (
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
)}
{filtered.map((it, i) => (
{visible.map((it, i) => (
<button
type="button"
key={it.id + "|" + i}
className={cn(
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
i === highlight && "bg-slate-bg",
pinnedCount > 0 && hasUnpinned && i === pinnedCount - 1 && "border-b border-line",
)}
onMouseDown={(e) => {
e.preventDefault(); // حفظ فوکوس input
@@ -5,18 +5,45 @@
// دسته → متریال‌های همان دسته
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
// متریال → مشخصات فنی موجودِ همان متریال
// هر فیلد دکمه‌ی «×» دارد که فقط همان سطح (و زیرمجموعه‌اش) را پاک می‌کند.
// هر فیلد «×» مخصوص خودش را دارد و گزینه‌ی «همه‌ی …» در ابتدای هر فهرست
// همان سطح (و سطوح پایین‌دست) را پاک می‌کند؛ دکمه‌ی «حذف فیلترها» کل استور را
// یک‌جا به نمای «کل سازمان» برمی‌گرداند.
// وضعیت در Zustand نگه داشته می‌شود تا با جابه‌جایی بین تب‌ها از دست نرود.
// ============================================================
import { useMemo } from "react";
import { Filter } from "lucide-react";
import { Combobox } from "../Combobox";
import { Filter, X } from "lucide-react";
import { Combobox, type ComboboxItem } from "../Combobox";
import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
import { useDashboardFilter } from "../../store/dashboardFilter";
import { faNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
// گزینه‌ی «همه» — انتخابی که همان سطح و سطوح پایین‌دستش را پاک می‌کند.
// شناسه‌ی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox) و هرگز در استور ذخیره نمی‌شود.
const ALL_OPTION_ID = "";
// این شناسه همیشه در ابتدای فهرست می‌ماند و با جستجوی کاربر فیلتر نمی‌شود
const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID];
// برچسب‌های ثابت UI — placeholder هر فیلد و گزینه‌ی «همه»اش یک متن مشترک دارند
const LABELS = {
allCategories: "همه‌ی دسته‌ها",
allMaterials: "همه‌ی متریال‌ها",
allBrands: "همه‌ی برندها",
allSpecs: "همه‌ی مشخصات",
materialFirst: "ابتدا متریال را انتخاب کنید",
clearAll: "حذف فیلترها",
wholeOrg: "نمای کل سازمان",
} as const;
const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label });
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیش‌فرض)»
function specLabel(specs: Record<string, string> | null): string {
const parts = Object.entries(specs || {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیش‌فرض)";
}
export function DashboardFilterBar({ className }: { className?: string }) {
const filterQuery = useDashboardFilterOptions();
const state = useDashboardFilter();
@@ -39,11 +66,41 @@ export function DashboardFilterBar({ className }: { className?: 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("، ") : "بدون مشخصه (پیش‌فرض)";
};
// انتخاب از فهرست؛ گزینه‌ی «همه» همان سطح را پاک می‌کند (آبشار خودکار)
const pick =
(set: (id: string | null, label: string | null) => void) =>
(item: ComboboxItem) =>
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],
);
const brandItems = useMemo<ComboboxItem[]>(
() => [
allOption(LABELS.allBrands),
...(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name })),
],
[selectedMaterial],
);
const specItems = useMemo<ComboboxItem[]>(
() => [
allOption(LABELS.allSpecs),
...(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) })),
],
[selectedMaterial],
);
return (
<section
@@ -62,9 +119,15 @@ export function DashboardFilterBar({ className }: { className?: string }) {
<button
type="button"
onClick={state.reset}
className="ms-auto rounded-full bg-red-bg px-2.5 py-1 text-[11px] font-bold text-red transition-opacity hover:opacity-80"
aria-label={`${LABELS.clearAll} (بازگشت به ${LABELS.wholeOrg})`}
title={`${LABELS.clearAll} — ${LABELS.wholeOrg}`}
className="group ms-auto inline-flex items-center gap-1.5 rounded-full border border-red/20 bg-red-bg px-2.5 py-1.5 text-[11px] font-bold text-red shadow-sm transition-all hover:border-red/40 hover:shadow active:scale-[0.98]"
>
حذف فیلترها ({faNum(activeCount)})
<X aria-hidden className="h-3.5 w-3.5 transition-transform group-hover:rotate-90" />
{LABELS.clearAll}
<span className="rounded-full bg-panel/60 px-1.5 leading-4 tabular-nums">
{faNum(activeCount)}
</span>
</button>
)}
</div>
@@ -73,44 +136,48 @@ export function DashboardFilterBar({ className }: { className?: string }) {
{/* ۱ — دسته‌بندی */}
<Combobox
value={state.categoryLabel ?? ""}
placeholder="همه‌ی دسته‌ها"
placeholder={LABELS.allCategories}
allowAdd={false}
items={categories.filter((c) => c.id).map((c) => ({ id: c.id as string, label: c.name }))}
items={categoryItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setCategory(null, null)}
onChange={(item) => state.setCategory(item.id, item.label)}
onChange={pick(state.setCategory)}
/>
{/* ۲ — متریال (وابسته به دسته‌ی انتخاب‌شده) */}
<Combobox
value={state.materialLabel ?? ""}
placeholder="همه‌ی متریال‌ها"
placeholder={LABELS.allMaterials}
allowAdd={false}
disabled={materialOptions.length === 0}
items={materialOptions.map((m) => ({ id: m.id, label: m.name }))}
items={materialItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setMaterial(null, null)}
onChange={(item) => state.setMaterial(item.id, item.label)}
onChange={pick(state.setMaterial)}
/>
{/* ۳ — برند (فقط برندهای همان متریال) */}
<Combobox
value={state.brandLabel ?? ""}
placeholder={selectedMaterial ? "همه‌ی برندها" : "ابتدا متریال را انتخاب کنید"}
placeholder={selectedMaterial ? LABELS.allBrands : LABELS.materialFirst}
allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
items={(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name }))}
items={brandItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setBrand(null, null)}
onChange={(item) => state.setBrand(item.id, item.label)}
onChange={pick(state.setBrand)}
/>
{/* ۴ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
<Combobox
value={state.specsLabel ?? ""}
placeholder={selectedMaterial ? "همه‌ی مشخصات" : "ابتدا متریال را انتخاب کنید"}
placeholder={selectedMaterial ? LABELS.allSpecs : LABELS.materialFirst}
allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
items={(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) }))}
items={specItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setSpecs(null, null)}
onChange={(item) => state.setSpecs(item.id, item.label)}
onChange={pick(state.setSpecs)}
/>
</div>
</section>
@@ -1,8 +1,14 @@
// ============================================================
// ویجت ۱ — ماتریس Drill-Down سه‌سطحی با انتخاب داینامیک و پشتیبانی از متریال
// «breadcrumb یکپارچه»: تنها مسیر پیمایش بالای نمودار، هم‌زمان بازتاب دو منبع
// حالت است — دامنه‌های سراسری پروژه/انبار (کنترل‌های چندانتخابی، همان گره‌های
// میانی) و مسیر drill-down داخلی (متریال ← پروژه). کلیک روی گره‌های عقب‌رونده
// هم سطح نمودار را برمی‌گرداند و هم دامنه‌های عمیق‌تر را آزاد می‌کند؛ فیلتر
// سراسری «چه چیزی» (Zustand) دست‌نخورده می‌ماند. با هر تغییر فیلتر سراسری،
// مسیر در برابر payload تازه اعتبارسنجی می‌شود تا در سطح کهنه گیر نکند.
// ============================================================
import { useEffect, useMemo, useState, useRef } from "react";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import {
Bar,
BarChart,
@@ -13,9 +19,18 @@ import {
XAxis,
YAxis,
} from "recharts";
import { ChevronLeft, MousePointerClick, ChevronDown, CheckSquare, Square } from "lucide-react";
import type { XAxisTickContentProps } from "recharts";
import {
CheckSquare,
ChevronDown,
ChevronLeft,
MousePointerClick,
RotateCcw,
Square,
} from "lucide-react";
import { useDrilldown } from "../../hooks/useDashboardData";
import { fmtNum } from "../../lib/formatters";
import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter";
import { faNum, fmtNum } from "../../lib/formatters";
import {
CHART_COLORS,
ChartCard,
@@ -25,7 +40,7 @@ import {
} from "./chartShared";
import { cn } from "../../lib/utils";
type Level = 0 | 1 | 2; // 0=Materials, 1=Projects, 2=Warehouses
type Level = 0 | 1 | 2; // ۰=متریال‌ها، ۱=پروژه‌ها، ۲=انبارها
interface Slice {
id: string;
@@ -35,21 +50,140 @@ interface Slice {
unitLabel: string;
}
// گرهی مسیر drill-down — نام همان لحظه‌ی کلیک ثبت می‌شود تا برچسب breadcrumb
// حتی هنگام بارگذاری مجدد داده‌ها پایدار بماند.
interface DrillNode {
id: string;
name: string;
}
const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"];
// سطح ریشه + برچسب‌های ثابت UI (یک‌جا تعریف می‌شوند تا در JSX تکرار نشوند)
const ROOT_LEVEL: Level = 0;
const LABELS = {
root: "کل سازمان",
allProjects: "همه‌ی پروژه‌ها",
allWarehouses: "همه‌ی انبارها",
widgetTitle:"",
projectScopeHint: "دامنه‌ی پروژه — برای تغییر انتخاب‌ها کلیک کنید",
warehouseScopeHint: "دامنه‌ی انبار — برای تغییر انتخاب‌ها کلیک کنید",
} as const;
// پسوند شمارش گزینه‌های چندانتخابی («… و ۲ پروژه‌ی دیگر»)
const otherProjectsLabel = (count: number) => `${faNum(count)} پروژه‌ی دیگر`;
const otherWarehousesLabel = (count: number) => `${faNum(count)} انبار دیگر`;
function crumbHint(level: Level, label: string): string {
if (level === ROOT_LEVEL) return `بازگشت به ${LABELS.root} — پاک‌کردن دامنه‌ها و مسیر`;
if (level === 1) return `مشاهده‌ی پروژه‌های «${label}»`;
return `مشاهده‌ی انبارهای «${label}»`;
}
// برچسب یک دامنه‌ی چندانتخابی: «همه‌ی …» / نام / «نام + پسوند شمارش»
function scopeLabel(
options: { id: string; name: string }[],
selectedIds: string[],
allLabel: string,
othersLabel: (count: number) => string,
): string {
if (selectedIds.length === 0) return allLabel;
const names = options.filter((o) => selectedIds.includes(o.id)).map((o) => o.name);
const first = names[0] ?? allLabel;
return names.length > 1 ? `${first} و ${othersLabel(names.length - 1)}` : first;
}
// ——— چیدمان نمودار: میله‌ها و برچسب‌های محور X ———
// هر ماده یک اسلات عرض ثابت می‌گیرد تا میله‌ها پخش شوند و برچسب‌های فارسی
// بلند روی هم نیفتند؛ نام‌های بلندتر در چند خط شکسته می‌شوند.
const AXIS_SLOT_PX = 200; // عرض سهم هر ماده روی محور (px)
const MIN_CHART_WIDTH = 800; // کف عرض نمودار (px)
const CHART_HEIGHT = 360; // ارتفاع نمودار (px)
const AXIS_HEIGHT = 110; // ارتفاع ناحیه‌ی برچسب‌های محور X (px)
const AXIS_TICK_MARGIN = 15; // فاصله‌ی خط محور تا خط اول برچسب (px)
const LABEL_LINE_CHARS = 20; // سقف نویسه‌ی هر خط برچسب
const LABEL_MAX_LINES = 3; // حداکثر خطوط هر برچسب
const LABEL_LINE_HEIGHT = 18; // فاصله‌ی خطوط برچسب (px)
// شکستن نام بلند به خطوط کوتاه — پایه‌ی تیک محور X
function wrapAxisLabel(text: string): string[] {
const lines: string[] = [];
for (const word of text.split(/\s+/).filter(Boolean)) {
// واژه‌های بسیار بلند هم به تکه‌های قابل نمایش شکسته می‌شوند
const chunks: string[] = [];
for (let i = 0; i < word.length; i += LABEL_LINE_CHARS) {
chunks.push(word.slice(i, i + LABEL_LINE_CHARS));
}
for (const chunk of chunks) {
const last = lines[lines.length - 1];
if (last && `${last} ${chunk}`.length <= LABEL_LINE_CHARS) {
lines[lines.length - 1] = `${last} ${chunk}`;
} else {
lines.push(chunk);
}
}
}
if (lines.length <= LABEL_MAX_LINES) return lines;
const head = lines.slice(0, LABEL_MAX_LINES - 1);
head.push(`${lines.slice(LABEL_MAX_LINES - 1).join(" ").slice(0, LABEL_LINE_CHARS - 1)}…`);
return head;
}
// تیک سفارشی محور X — برچسب چندخطی با فاصله‌ی کافی (بدون هم‌پوشانی).
// فاصله‌ی خط اول از محور از tickMargin خودِ XAxis می‌آید و شروع متن با
// dominantBaseline=hanging تراز می‌شود (مثل تیک پیش‌فرض Recharts).
function AxisTick({ x, y, payload }: XAxisTickContentProps) {
const xPos = typeof x === "number" ? x : Number(x);
const yPos = typeof y === "number" ? y : Number(y);
const lines = wrapAxisLabel(String(payload?.value ?? ""));
return (
<text
x={xPos}
y={yPos}
textAnchor="middle"
dominantBaseline="hanging"
fill="#64748b"
fontSize={11}
fontFamily="Vazirmatn"
>
{lines.map((line, i) => (
<tspan key={i} x={xPos} dy={i === 0 ? 0 : LABEL_LINE_HEIGHT}>
{line}
</tspan>
))}
</text>
);
}
// اثر انگشت فیلتر سراسری (Zustand) — هر تغییر آن یعنی payload تازه‌ای می‌آید و
// اعتبارسنجی مسیر drill-down باید دوباره اجرا شود.
const selectGlobalFilterKey = (s: DashboardFilterState): string =>
JSON.stringify([s.categoryId, s.materialId, s.brandId, s.specsHash]);
// --- MultiSelect Dropdown Component ---
// کنترل چندانتخابی دامنه (پروژه/انبار) — به‌عنوان گرهی چندانتخابیِ breadcrumb
// رندر می‌شود؛ برچسبش بازتاب انتخاب‌های فعلی است و ردیف «همه‌ی …» انتخاب‌ها را
// آزاد می‌کند.
function MultiSelectDropdown({
label,
options,
selectedIds,
onChange,
disabled
disabled,
allLabel,
onClearAll,
title,
}: {
label: string;
options: { id: string; name: string }[];
selectedIds: string[];
onChange: (ids: string[]) => void;
disabled?: boolean;
// با ارائه‌ی این دو، ردیف «همه‌ی …» در ابتدای فهرست ظاهر می‌شود
allLabel?: string;
onClearAll?: () => void;
title?: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
@@ -77,6 +211,7 @@ function MultiSelectDropdown({
<button
type="button"
disabled={disabled}
title={title}
onClick={() => setOpen(!open)}
className={cn(
"flex items-center gap-1 rounded-md px-2 py-1 text-[13px] font-extrabold transition-colors",
@@ -90,6 +225,22 @@ function MultiSelectDropdown({
{open && (
<div className="absolute right-0 top-full z-50 mt-1 max-h-60 w-56 overflow-auto rounded-lg border border-line bg-panel p-1 shadow-xl">
{allLabel && onClearAll && (
<>
<button
type="button"
onClick={() => {
onClearAll();
setOpen(false);
}}
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs font-bold text-accent transition-colors hover:bg-slate-bg"
>
<RotateCcw className="h-3.5 w-3.5" />
<span className="truncate">{allLabel}</span>
</button>
<div className="my-1 border-t border-line" />
</>
)}
{options.length === 0 ? (
<div className="p-2 text-center text-xs text-muted">گزینه‌ای یافت نشد</div>
) : (
@@ -120,18 +271,24 @@ function MultiSelectDropdown({
export function DrilldownMatrix() {
const query = useDrilldown();
// Drill-down UI state
const [level, setLevel] = useState<Level>(0);
const [materialId, setMaterialId] = useState<string | null>(null);
const [projectId, setProjectId] = useState<string | null>(null);
// مسیر drill-down محلی — تنها منبع حقیقت برای سطح و گره‌های پیمایش‌شده؛
// سطح از طول مسیر مشتق می‌شود تا هرگز با شناسه‌های گره ناهماهنگ نشود.
const [path, setPath] = useState<DrillNode[]>([]);
const level: Level = path.length === 0 ? 0 : path.length === 1 ? 1 : 2;
const materialId = path[0]?.id ?? null;
const projectId = path[1]?.id ?? null;
// Manual Filter Dropdowns State
// فیلترهای دستی (چندگزینه‌ای پروژه/انبار) — وابسته به payload جاری
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [selectedWarehouseIds, setSelectedWarehouseIds] = useState<string[]>([]);
// عرض واقعی کانتینر نمودار — تا نمودار هرگز در عرض والد فشرده نشود
const plotRef = useRef<HTMLDivElement>(null);
const [plotWidth, setPlotWidth] = useState(0);
const data = query.data;
// Aggregate Dropdown Options dynamically from payload
// گزینه‌های آبشاری از دل payload ساخته می‌شوند
const projectOptions = useMemo(() => {
if (!data) return [];
const map = new Map<string, string>();
@@ -158,16 +315,52 @@ export function DrilldownMatrix() {
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [data, selectedProjectIds]);
// Prevent invalid filter states
// ——— همگام‌سازی با فیلتر سراسری (Zustand) ———
// فیلتر سراسری، payload را زیر پای حالت محلی عوض می‌کند؛ به‌جای پاک‌کردن
// کورکورانه‌ی مسیر، حالت محلی را با داده‌ی تازه اعتبارسنجی می‌کنیم تا نه در
// سطح کهنه گیر کنیم و نه بی‌دلیل به عقب بپریم.
const globalFilterKey = useDashboardFilter(selectGlobalFilterKey);
// ۱) انتخاب‌های وابسته به payload (پروژه/انبار) نباید به شناسه‌هایی اشاره کنند
// که در نتیجه‌ی فیلتر جاری وجود ندارند؛ وگرنه نمودارِ خالی با چیپ‌های فعال می‌بینیم.
useEffect(() => {
if (selectedProjectIds.length > 0) {
const validIds = warehouseOptions.map((o) => o.id);
const filtered = selectedWarehouseIds.filter((id) => validIds.includes(id));
if (filtered.length !== selectedWarehouseIds.length) {
setSelectedWarehouseIds(filtered);
if (!data) return;
const validProjectIds = new Set(projectOptions.map((o) => o.id));
setSelectedProjectIds((ids) =>
ids.every((id) => validProjectIds.has(id)) ? ids : ids.filter((id) => validProjectIds.has(id)),
);
const validWarehouseIds = new Set(warehouseOptions.map((o) => o.id));
setSelectedWarehouseIds((ids) =>
ids.every((id) => validWarehouseIds.has(id)) ? ids : ids.filter((id) => validWarehouseIds.has(id)),
);
}, [globalFilterKey, data, projectOptions, warehouseOptions]);
// ۲) اگر متریال یا پروژه‌ی در حال مشاهده از payload حذف شده باشد، فقط تا
// عمیق‌ترین سطح معتبر عقب می‌آییم؛ مسیر معتبر دست‌نخورده می‌ماند.
useEffect(() => {
if (!data || path.length === 0) return;
const material = data.materials.find((m) => m.id === path[0].id);
if (!material) {
setPath([]);
return;
}
if (path.length > 1 && !material.projects.some((p) => p.id === path[1].id)) {
setPath((prev) => prev.slice(0, 1));
}
}, [selectedProjectIds, warehouseOptions]);
}, [globalFilterKey, data, path]);
// ——— اندازه‌گیری عرض کانتینر نمودار ———
// عرض صریح BarChart از همین مقدار و تعداد میله‌ها حساب می‌شود؛ کانتینر
// اسکرول‌دار است، پس با هر تغییر اندازه‌ی پنجره/کارت دوباره اندازه‌گیری می‌شود.
useEffect(() => {
const el = plotRef.current;
if (!el) return;
const measure = () => setPlotWidth(el.clientWidth);
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, []);
// Helper to sum all units and get unit label
const getAggregatedQtyAndUnit = (qtyByUnit: Record<string, number>) => {
@@ -242,73 +435,76 @@ export function DrilldownMatrix() {
const levelTotal = slices.reduce((s, x) => s + x.displayQuantity, 0);
// عرض صریح نمودار: هر ماده یک اسلات ثابت می‌گیرد تا میله‌ها پخش شوند و
// برچسب‌های محور X فاصله‌ی کافی داشته باشند؛ هرگز باریک‌تر از عرض واقعی
// کانتینر (و کف تعیین‌شده) نمی‌شود.
const chartWidth = Math.max(plotWidth, MIN_CHART_WIDTH, slices.length * AXIS_SLOT_PX);
const handleBarClick = (slice: Slice) => {
if (level === 0) {
setMaterialId(slice.id);
setLevel(1);
} else if (level === 1) {
setProjectId(slice.id);
setLevel(2);
// هر کلیک یک سطح عمیق‌تر: متریال ← پروژه ← انبار (عمیق‌تر از این وجود ندارد)
setPath((prev) => {
if (prev.length === 0) return [{ id: slice.id, name: slice.name }];
if (prev.length === 1) return [...prev, { id: slice.id, name: slice.name }];
return prev;
});
};
// پرش به هر گرهی مسیر — هم سطح نمودار را برمیگرداند و هم فیلترهای ریزتر
// (اسکوپ‌های پروژه/انبار) را خودکار آزاد می‌کند تا کاربر مجبور نباشد تیک‌ها را
// دستی بردارد. فیلتر سراسری «چه چیزی» (Zustand) دست‌نخورده می‌ماند.
const jumpToLevel = (target: Level) => {
setPath((prev) => prev.slice(0, target));
if (target === ROOT_LEVEL) {
// بازگشت به «کل سازمان»: هر دو انتخاب پروژه و انبار پاک می‌شوند
setSelectedProjectIds([]);
setSelectedWarehouseIds([]);
return;
}
// گره‌های پایین‌تر: فقط ریزترین انتخاب (انبارها) آزاد می‌شود؛
// اسکوپ پروژه‌ی جاری دست‌نخورده می‌ماند.
setSelectedWarehouseIds([]);
};
const getOrgTitle = () => {
if (selectedProjectIds.length === 0) return "کل سازمان";
return projectOptions
.filter((o) => selectedProjectIds.includes(o.id))
.map((o) => o.name)
.join(" و ");
};
// گره‌های drill-down در breadcrumb (متریال، سپس پروژه) — گره‌های دامنه جدا رندر می‌شوند
const drillCrumbs = path.map<{ level: Level; label: string }>((node, index) => ({
level: index === 0 ? 1 : 2,
label: node.name,
}));
const getWarehouseTitle = () => {
if (selectedWarehouseIds.length === 0) return "همه انبارها";
return warehouseOptions
.filter((o) => selectedWarehouseIds.includes(o.id))
.map((o) => o.name)
.join(" و ");
};
const selectedMaterialName =
level > 0 && materialId
? data?.materials.find((m) => m.id === materialId)?.name || ""
: "";
const selectedProjectName =
level > 1 && projectId && materialId
? data?.materials
.find((m) => m.id === materialId)
?.projects.find((p) => p.id === projectId)?.name || ""
: "";
const renderBreadcrumbHeader = () => {
return (
<div className="flex flex-wrap items-center gap-1">
<MultiSelectDropdown
label={getOrgTitle()}
options={projectOptions}
selectedIds={selectedProjectIds}
onChange={(ids) => {
setSelectedProjectIds(ids);
if (level === 2 && ids.length > 0 && projectId && !ids.includes(projectId)) {
setLevel(1);
}
}}
/>
{(selectedProjectIds.length > 0 || selectedWarehouseIds.length > 0) && (
<>
<ChevronLeft className="h-3.5 w-3.5 text-muted" />
<MultiSelectDropdown
label={getWarehouseTitle()}
options={warehouseOptions}
selectedIds={selectedWarehouseIds}
onChange={setSelectedWarehouseIds}
/>
</>
)}
</div>
// ظاهر مشترک گره‌های کلیکی مسیر (گره‌ی جاری: برجسته؛ گره‌های عقب‌رونده: قابل کلیک)
const crumbClass = (isCurrent: boolean) =>
cn(
"max-w-[12rem] cursor-pointer truncate rounded-md px-2 py-0.5 font-bold transition-colors active:scale-[0.98]",
isCurrent
? "bg-accent-bg text-accent hover:bg-accent-bg/70"
: "text-muted hover:bg-slate-bg hover:text-ink",
);
// ——— دامنه‌های سراسری (پروژه/انبار) ———
// این دو دامنه دیگر کنترل جدایی در هدر کارت ندارند؛ همان گره‌های میانیِ
// breadcrumb‌اند و برچسبشان از همین انتخاب‌ها می‌آید (همگام‌سازی دوطرفه).
const handleProjectScopeChange = (ids: string[]) => {
setSelectedProjectIds(ids);
// اگر پروژه‌ی در حال مشاهده از دامنه‌ی انتخاب خارج شود، به سطح پروژه‌ها برمی‌گردیم
if (ids.length > 0 && projectId && !ids.includes(projectId)) {
setPath((prev) => (prev.length > 1 ? prev.slice(0, 1) : prev));
}
};
// ردیف «همه‌ی پروژه‌ها» در منوی گره: دامنه‌ی پروژه و دامنه‌ی عمیق‌ترش (انبار) آزاد می‌شود
const clearProjectScope = () => {
setSelectedProjectIds([]);
setSelectedWarehouseIds([]);
};
// انبار ریزترین دامنه است: فقط خودش آزاد می‌شود و مسیر drill-down دست‌نخورده می‌ماند
const clearWarehouseScope = () => setSelectedWarehouseIds([]);
const projectScopeLabel = () =>
scopeLabel(projectOptions, selectedProjectIds, LABELS.allProjects, otherProjectsLabel);
const warehouseScopeLabel = () =>
scopeLabel(warehouseOptions, selectedWarehouseIds, LABELS.allWarehouses, otherWarehousesLabel);
const getTooltipPrompt = () => {
if (level === 0) return "برای مشاهده پروژه‌ها کلیک کنید";
if (level === 1) return "برای مشاهده انبارها کلیک کنید";
@@ -316,81 +512,110 @@ export function DrilldownMatrix() {
};
return (
<ChartCard title={renderBreadcrumbHeader()}>
<nav dir="rtl" className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]">
{/* Navigation Breadcrumb (Level Path) */}
<ChartCard title={LABELS.widgetTitle}>
{/* ——— breadcrumb یکپارچه: تنها مسیر پیمایش بالای نمودار ———
گره‌های دامنه (پروژه/انبار) همان فیلترهای سراسری‌اند و گره‌های بعدی
مسیر drill-down؛ کلیک روی گره‌های عقب‌رونده هم سطح نمودار را برمی‌گرداند
و هم دامنه‌های عمیق‌تر را آزاد می‌کند. */}
<nav
dir="rtl"
aria-label="مسیر پیمایش گزارش"
className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]"
>
{/* ریشه — بازگشت کامل به کل سازمان */}
<button
type="button"
onClick={() => {
setMaterialId(null);
setProjectId(null);
setLevel(0);
}}
className={cn(
"rounded-md px-2 py-0.5 font-bold transition-colors",
level === 0
? "bg-accent-bg text-accent"
: "text-muted hover:bg-slate-bg hover:text-ink"
)}
onClick={() => jumpToLevel(ROOT_LEVEL)}
aria-current={level === ROOT_LEVEL ? "page" : undefined}
title={crumbHint(ROOT_LEVEL, LABELS.root)}
className={crumbClass(level === ROOT_LEVEL)}
>
گزارش کل
{LABELS.root}
</button>
{level >= 1 && selectedMaterialName && (
{/* گره‌ی دامنه‌ی پروژه — فیلتر سراسری چندانتخابی، برچسب بازتاب انتخاب‌ها */}
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<MultiSelectDropdown
label={projectScopeLabel()}
options={projectOptions}
selectedIds={selectedProjectIds}
onChange={handleProjectScopeChange}
allLabel={LABELS.allProjects}
onClearAll={clearProjectScope}
title={LABELS.projectScopeHint}
/>
{/* گره‌ی دامنه‌ی انبار — فقط وقتی دامنه‌ی پروژه/انبار فعال است */}
{(selectedProjectIds.length > 0 || selectedWarehouseIds.length > 0) && (
<>
<ChevronLeft className="h-3.5 w-3.5 text-muted" />
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<MultiSelectDropdown
label={warehouseScopeLabel()}
options={warehouseOptions}
selectedIds={selectedWarehouseIds}
onChange={setSelectedWarehouseIds}
allLabel={LABELS.allWarehouses}
onClearAll={clearWarehouseScope}
title={LABELS.warehouseScopeHint}
/>
</>
)}
{/* گره‌های drill-down — هر گره همان سطح را بازیابی می‌کند */}
{drillCrumbs.map((crumb) => {
const isCurrent = crumb.level === level;
return (
<Fragment key={crumb.level}>
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<button
type="button"
onClick={() => {
setProjectId(null);
setLevel(1);
}}
className={cn(
"rounded-md px-2 py-0.5 font-bold transition-colors",
level === 1
? "bg-accent-bg text-accent"
: "text-muted hover:bg-slate-bg hover:text-ink"
)}
onClick={() => jumpToLevel(crumb.level)}
aria-current={isCurrent ? "page" : undefined}
title={crumbHint(crumb.level, crumb.label)}
className={crumbClass(isCurrent)}
>
{selectedMaterialName}
{crumb.label}
</button>
</>
)}
{level >= 2 && selectedProjectName && (
<>
<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">
{selectedProjectName}
</span>
</>
)}
</Fragment>
);
})}
<span className="ms-auto text-[11px] tabular-nums text-muted">
مجموع نمایش داده شده: {fmtNum(levelTotal)}
</span>
</nav>
{/* مواد کاتالوگی با موجودی صفر عمداً رندر می‌شوند (محور = کاتالوگ)؛ پیام
«یافت نشد» فقط وقتی است که هیچ ماده‌ای با فیلترها هم‌خوانی نداشته باشد */}
{query.isLoading ? (
<ChartLoading />
) : !slices.length || levelTotal === 0 ? (
) : !slices.length ? (
<ChartEmpty message="موجودی‌ای با این فیلترها یافت نشد" />
) : (
<>
<div dir="ltr" className="h-64" style={{ width: '100%', overflowX: 'auto', overflowY: 'hidden' }}>
{/* کانتینر اسکرول‌دار — عرض صریح نمودار از chartWidth می‌آید تا
میله‌ها پخش شوند و برچسب‌ها فاصله‌ی کافی داشته باشند */}
<div
ref={plotRef}
dir="ltr"
className="w-full overflow-x-auto overflow-y-hidden"
style={{ width: '100%', height: CHART_HEIGHT }}
>
<BarChart
width={Math.max(typeof window !== 'undefined' ? window.innerWidth - 100 : 800, slices.length * 180)}
height={256}
width={chartWidth}
height={CHART_HEIGHT}
data={slices}
margin={{ top: 26, right: 8, left: 8, bottom: 4 }}
margin={{ top: 26, right: 12, left: 8, bottom: 12 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis
dataKey="name"
tick={{ fontSize: 11, fill: "#64748b", fontFamily: "Vazirmatn" }}
tick={AxisTick}
tickLine={false}
axisLine={{ stroke: "#e5e9f0" }}
interval={0}
height={44}
angle={0}
textAnchor="middle"
height={AXIS_HEIGHT}
tickMargin={AXIS_TICK_MARGIN}
/>
<YAxis
tick={{ fontSize: 11, fill: "#64748b" }}