update dashboard logic
This commit is contained in:
@@ -19,6 +19,7 @@ from datetime import datetime, time, timedelta, timezone
|
|||||||
|
|
||||||
from sqlalchemy import case, func, select
|
from sqlalchemy import case, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
from app.models.catalog import Brand, BrandMaterial, Material, MaterialCategory, Unit
|
from app.models.catalog import Brand, BrandMaterial, Material, MaterialCategory, Unit
|
||||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||||
@@ -176,34 +177,91 @@ async def inventory_drilldown(
|
|||||||
brand_id=None,
|
brand_id=None,
|
||||||
specs_hash=None,
|
specs_hash=None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse."""
|
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse.
|
||||||
stmt = (
|
|
||||||
|
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(
|
select(
|
||||||
Material.id.label("mid"),
|
InventoryItem.material_id.label("material_id"),
|
||||||
Material.name.label("mname"),
|
InventoryItem.brand_id.label("brand_id"),
|
||||||
Brand.name.label("bname"),
|
InventoryItem.specs_hash.label("specs_hash"),
|
||||||
InventoryItem.specs,
|
InventoryItem.specs.label("specs"),
|
||||||
Project.id.label("pid"),
|
Project.id.label("pid"),
|
||||||
Project.name.label("pname"),
|
Project.name.label("pname"),
|
||||||
Warehouse.id.label("wid"),
|
Warehouse.id.label("wid"),
|
||||||
Warehouse.name.label("wname"),
|
Warehouse.name.label("wname"),
|
||||||
Unit.code,
|
Unit.code.label("ucode"),
|
||||||
Unit.name_fa,
|
Unit.name_fa.label("uname_fa"),
|
||||||
func.sum(InventoryItem.quantity).label("qty"),
|
func.sum(InventoryItem.quantity).label("qty"),
|
||||||
func.count().label("rows"),
|
func.count().label("rows"),
|
||||||
)
|
)
|
||||||
.select_from(InventoryItem)
|
.select_from(InventoryItem)
|
||||||
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||||
.join(Project, Project.id == Warehouse.project_id)
|
.join(Project, Project.id == Warehouse.project_id)
|
||||||
.join(Material, Material.id == InventoryItem.material_id)
|
|
||||||
.join(Unit, Unit.id == InventoryItem.unit_id)
|
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||||
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
|
.group_by(
|
||||||
.group_by(Material.id, Material.name, Brand.name, InventoryItem.specs, Project.id, Project.name, Warehouse.id, Warehouse.name, Unit.code, Unit.name_fa)
|
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(
|
stock = _scope_filters(stock, project_ids=project_ids, project_id=project_id)
|
||||||
stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
|
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")
|
||||||
)
|
)
|
||||||
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
|
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود میکنند
|
||||||
|
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()
|
rows = (await db.execute(stmt)).all()
|
||||||
|
|
||||||
units_legend: dict[str, str] = {}
|
units_legend: dict[str, str] = {}
|
||||||
@@ -212,10 +270,16 @@ async def inventory_drilldown(
|
|||||||
|
|
||||||
materials_tree: dict[str, dict] = {}
|
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)
|
q = _qty(qty)
|
||||||
units_legend[ucode] = uname_fa
|
|
||||||
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
|
# افسانهی واحدها و جمع کل؛ مواد بدون ردیف موجودی با واحد کاتالوگی و صفر
|
||||||
|
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
|
total_rows += row_count
|
||||||
|
|
||||||
# Build full material name
|
# Build full material name
|
||||||
@@ -232,9 +296,16 @@ async def inventory_drilldown(
|
|||||||
mat_key,
|
mat_key,
|
||||||
{"id": mat_key, "name": full_name, "qty_by_unit": {}, "rows": 0, "_projects": {}},
|
{"id": mat_key, "name": full_name, "qty_by_unit": {}, "rows": 0, "_projects": {}},
|
||||||
)
|
)
|
||||||
mat["qty_by_unit"][ucode] = round(mat["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
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
|
mat["rows"] += row_count
|
||||||
|
|
||||||
|
# مواد بدون هیچ ردیف موجودی: فقط روی محور میمانند (بدون پروژه/انبار)
|
||||||
|
if pid is None:
|
||||||
|
continue
|
||||||
|
|
||||||
proj = mat["_projects"].setdefault(
|
proj = mat["_projects"].setdefault(
|
||||||
pid,
|
pid,
|
||||||
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}},
|
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}},
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export interface ComboboxProps {
|
|||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (item: ComboboxItem) => void;
|
onChange?: (item: ComboboxItem) => void;
|
||||||
items: ComboboxSource; // آرایه از string یا {id, name}
|
items: ComboboxSource; // آرایه از string یا {id, name}
|
||||||
|
// شناسهی آیتمهایی که همیشه در ابتدای فهرست میمانند و با جستجوی کاربر
|
||||||
|
// فیلتر نمیشوند (مثل گزینهی «همهی …» در فیلترهای آبشاری داشبورد)
|
||||||
|
pinnedIds?: string[];
|
||||||
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
||||||
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
|
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
|
||||||
addSuffix?: string; // متن بعد از +
|
addSuffix?: string; // متن بعد از +
|
||||||
@@ -50,6 +53,7 @@ export function Combobox({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
items,
|
items,
|
||||||
|
pinnedIds,
|
||||||
allowAdd = true,
|
allowAdd = true,
|
||||||
addPrefix,
|
addPrefix,
|
||||||
addSuffix = "به عنوان گزینه جدید",
|
addSuffix = "به عنوان گزینه جدید",
|
||||||
@@ -88,21 +92,37 @@ export function Combobox({
|
|||||||
return all.filter((x) => normalize(x.label).includes(q));
|
return all.filter((x) => normalize(x.label).includes(q));
|
||||||
}, [all, 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 =
|
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 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 ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||||
|
|
||||||
const pick = (it: ComboboxItem) => {
|
const pick = (it: ComboboxItem) => {
|
||||||
setText(it.label);
|
// آیتم با شناسهی خالی یعنی «انتخاب نشده» (گزینهی «همهی …»)؛ فیلد به
|
||||||
|
// placeholder برمیگردد تا متن/دکمهی × برای مقدار پاکشده باقی نماند.
|
||||||
|
const nextText = it.id ? it.label : "";
|
||||||
|
setText(nextText);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setHighlight(-1);
|
setHighlight(-1);
|
||||||
onChange?.(it);
|
onChange?.(it);
|
||||||
onInputChange?.(it.label);
|
onInputChange?.(nextText);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addCurrent = () => {
|
const addCurrent = () => {
|
||||||
@@ -127,9 +147,9 @@ export function Combobox({
|
|||||||
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
|
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (highlight >= 0 && highlight < filtered.length) {
|
if (highlight >= 0 && highlight < visible.length) {
|
||||||
pick(filtered[highlight]);
|
pick(visible[highlight]);
|
||||||
} else if (highlight === filtered.length && showAdd) {
|
} else if (highlight === visible.length && showAdd) {
|
||||||
addCurrent();
|
addCurrent();
|
||||||
}
|
}
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
@@ -197,13 +217,14 @@ export function Combobox({
|
|||||||
{listCount === 0 && (
|
{listCount === 0 && (
|
||||||
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
|
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
|
||||||
)}
|
)}
|
||||||
{filtered.map((it, i) => (
|
{visible.map((it, i) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={it.id + "|" + i}
|
key={it.id + "|" + i}
|
||||||
className={cn(
|
className={cn(
|
||||||
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
|
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
|
||||||
i === highlight && "bg-slate-bg",
|
i === highlight && "bg-slate-bg",
|
||||||
|
pinnedCount > 0 && hasUnpinned && i === pinnedCount - 1 && "border-b border-line",
|
||||||
)}
|
)}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault(); // حفظ فوکوس input
|
e.preventDefault(); // حفظ فوکوس input
|
||||||
|
|||||||
@@ -5,18 +5,45 @@
|
|||||||
// دسته → متریالهای همان دسته
|
// دسته → متریالهای همان دسته
|
||||||
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
|
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
|
||||||
// متریال → مشخصات فنی موجودِ همان متریال
|
// متریال → مشخصات فنی موجودِ همان متریال
|
||||||
// هر فیلد دکمهی «×» دارد که فقط همان سطح (و زیرمجموعهاش) را پاک میکند.
|
// هر فیلد «×» مخصوص خودش را دارد و گزینهی «همهی …» در ابتدای هر فهرست
|
||||||
|
// همان سطح (و سطوح پاییندست) را پاک میکند؛ دکمهی «حذف فیلترها» کل استور را
|
||||||
|
// یکجا به نمای «کل سازمان» برمیگرداند.
|
||||||
// وضعیت در Zustand نگه داشته میشود تا با جابهجایی بین تبها از دست نرود.
|
// وضعیت در Zustand نگه داشته میشود تا با جابهجایی بین تبها از دست نرود.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Filter } from "lucide-react";
|
import { Filter, X } from "lucide-react";
|
||||||
import { Combobox } from "../Combobox";
|
import { Combobox, type ComboboxItem } 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 { faNum } from "../../lib/formatters";
|
||||||
import { cn } from "../../lib/utils";
|
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 }) {
|
export function DashboardFilterBar({ className }: { className?: string }) {
|
||||||
const filterQuery = useDashboardFilterOptions();
|
const filterQuery = useDashboardFilterOptions();
|
||||||
const state = useDashboardFilter();
|
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 activeCount = [state.categoryId, state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
|
||||||
|
|
||||||
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیشفرض)»
|
// انتخاب از فهرست؛ گزینهی «همه» همان سطح را پاک میکند (آبشار خودکار)
|
||||||
const specLabel = (specs: Record<string, string> | null) => {
|
const pick =
|
||||||
const parts = Object.entries(specs || {});
|
(set: (id: string | null, label: string | null) => void) =>
|
||||||
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیشفرض)";
|
(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 (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -62,9 +119,15 @@ export function DashboardFilterBar({ className }: { className?: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={state.reset}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -73,44 +136,48 @@ export function DashboardFilterBar({ className }: { className?: string }) {
|
|||||||
{/* ۱ — دستهبندی */}
|
{/* ۱ — دستهبندی */}
|
||||||
<Combobox
|
<Combobox
|
||||||
value={state.categoryLabel ?? ""}
|
value={state.categoryLabel ?? ""}
|
||||||
placeholder="همهی دستهها"
|
placeholder={LABELS.allCategories}
|
||||||
allowAdd={false}
|
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)}
|
onClear={() => state.setCategory(null, null)}
|
||||||
onChange={(item) => state.setCategory(item.id, item.label)}
|
onChange={pick(state.setCategory)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ۲ — متریال (وابسته به دستهی انتخابشده) */}
|
{/* ۲ — متریال (وابسته به دستهی انتخابشده) */}
|
||||||
<Combobox
|
<Combobox
|
||||||
value={state.materialLabel ?? ""}
|
value={state.materialLabel ?? ""}
|
||||||
placeholder="همهی متریالها"
|
placeholder={LABELS.allMaterials}
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
disabled={materialOptions.length === 0}
|
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)}
|
onClear={() => state.setMaterial(null, null)}
|
||||||
onChange={(item) => state.setMaterial(item.id, item.label)}
|
onChange={pick(state.setMaterial)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ۳ — برند (فقط برندهای همان متریال) */}
|
{/* ۳ — برند (فقط برندهای همان متریال) */}
|
||||||
<Combobox
|
<Combobox
|
||||||
value={state.brandLabel ?? ""}
|
value={state.brandLabel ?? ""}
|
||||||
placeholder={selectedMaterial ? "همهی برندها" : "ابتدا متریال را انتخاب کنید"}
|
placeholder={selectedMaterial ? LABELS.allBrands : LABELS.materialFirst}
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
|
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)}
|
onClear={() => state.setBrand(null, null)}
|
||||||
onChange={(item) => state.setBrand(item.id, item.label)}
|
onChange={pick(state.setBrand)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ۴ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
|
{/* ۴ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
|
||||||
<Combobox
|
<Combobox
|
||||||
value={state.specsLabel ?? ""}
|
value={state.specsLabel ?? ""}
|
||||||
placeholder={selectedMaterial ? "همهی مشخصات" : "ابتدا متریال را انتخاب کنید"}
|
placeholder={selectedMaterial ? LABELS.allSpecs : LABELS.materialFirst}
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
|
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)}
|
onClear={() => state.setSpecs(null, null)}
|
||||||
onChange={(item) => state.setSpecs(item.id, item.label)}
|
onChange={pick(state.setSpecs)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// ویجت ۱ — ماتریس Drill-Down سهسطحی با انتخاب داینامیک و پشتیبانی از متریال
|
// ویجت ۱ — ماتریس Drill-Down سهسطحی با انتخاب داینامیک و پشتیبانی از متریال
|
||||||
|
// «breadcrumb یکپارچه»: تنها مسیر پیمایش بالای نمودار، همزمان بازتاب دو منبع
|
||||||
|
// حالت است — دامنههای سراسری پروژه/انبار (کنترلهای چندانتخابی، همان گرههای
|
||||||
|
// میانی) و مسیر drill-down داخلی (متریال ← پروژه). کلیک روی گرههای عقبرونده
|
||||||
|
// هم سطح نمودار را برمیگرداند و هم دامنههای عمیقتر را آزاد میکند؛ فیلتر
|
||||||
|
// سراسری «چه چیزی» (Zustand) دستنخورده میماند. با هر تغییر فیلتر سراسری،
|
||||||
|
// مسیر در برابر payload تازه اعتبارسنجی میشود تا در سطح کهنه گیر نکند.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, useRef } from "react";
|
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -13,9 +19,18 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} 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 { useDrilldown } from "../../hooks/useDashboardData";
|
||||||
import { fmtNum } from "../../lib/formatters";
|
import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter";
|
||||||
|
import { faNum, fmtNum } from "../../lib/formatters";
|
||||||
import {
|
import {
|
||||||
CHART_COLORS,
|
CHART_COLORS,
|
||||||
ChartCard,
|
ChartCard,
|
||||||
@@ -25,7 +40,7 @@ import {
|
|||||||
} from "./chartShared";
|
} from "./chartShared";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
type Level = 0 | 1 | 2; // 0=Materials, 1=Projects, 2=Warehouses
|
type Level = 0 | 1 | 2; // ۰=متریالها، ۱=پروژهها، ۲=انبارها
|
||||||
|
|
||||||
interface Slice {
|
interface Slice {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,21 +50,140 @@ interface Slice {
|
|||||||
unitLabel: string;
|
unitLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// گرهی مسیر drill-down — نام همان لحظهی کلیک ثبت میشود تا برچسب breadcrumb
|
||||||
|
// حتی هنگام بارگذاری مجدد دادهها پایدار بماند.
|
||||||
|
interface DrillNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"];
|
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 ---
|
// --- MultiSelect Dropdown Component ---
|
||||||
|
// کنترل چندانتخابی دامنه (پروژه/انبار) — بهعنوان گرهی چندانتخابیِ breadcrumb
|
||||||
|
// رندر میشود؛ برچسبش بازتاب انتخابهای فعلی است و ردیف «همهی …» انتخابها را
|
||||||
|
// آزاد میکند.
|
||||||
function MultiSelectDropdown({
|
function MultiSelectDropdown({
|
||||||
label,
|
label,
|
||||||
options,
|
options,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
onChange,
|
onChange,
|
||||||
disabled
|
disabled,
|
||||||
|
allLabel,
|
||||||
|
onClearAll,
|
||||||
|
title,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
options: { id: string; name: string }[];
|
options: { id: string; name: string }[];
|
||||||
selectedIds: string[];
|
selectedIds: string[];
|
||||||
onChange: (ids: string[]) => void;
|
onChange: (ids: string[]) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
// با ارائهی این دو، ردیف «همهی …» در ابتدای فهرست ظاهر میشود
|
||||||
|
allLabel?: string;
|
||||||
|
onClearAll?: () => void;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -77,6 +211,7 @@ function MultiSelectDropdown({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => setOpen(!open)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1 rounded-md px-2 py-1 text-[13px] font-extrabold transition-colors",
|
"flex items-center gap-1 rounded-md px-2 py-1 text-[13px] font-extrabold transition-colors",
|
||||||
@@ -90,6 +225,22 @@ function MultiSelectDropdown({
|
|||||||
|
|
||||||
{open && (
|
{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">
|
<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 ? (
|
{options.length === 0 ? (
|
||||||
<div className="p-2 text-center text-xs text-muted">گزینهای یافت نشد</div>
|
<div className="p-2 text-center text-xs text-muted">گزینهای یافت نشد</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -120,18 +271,24 @@ function MultiSelectDropdown({
|
|||||||
export function DrilldownMatrix() {
|
export function DrilldownMatrix() {
|
||||||
const query = useDrilldown();
|
const query = useDrilldown();
|
||||||
|
|
||||||
// Drill-down UI state
|
// مسیر drill-down محلی — تنها منبع حقیقت برای سطح و گرههای پیمایششده؛
|
||||||
const [level, setLevel] = useState<Level>(0);
|
// سطح از طول مسیر مشتق میشود تا هرگز با شناسههای گره ناهماهنگ نشود.
|
||||||
const [materialId, setMaterialId] = useState<string | null>(null);
|
const [path, setPath] = useState<DrillNode[]>([]);
|
||||||
const [projectId, setProjectId] = useState<string | null>(null);
|
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 [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
|
||||||
const [selectedWarehouseIds, setSelectedWarehouseIds] = useState<string[]>([]);
|
const [selectedWarehouseIds, setSelectedWarehouseIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// عرض واقعی کانتینر نمودار — تا نمودار هرگز در عرض والد فشرده نشود
|
||||||
|
const plotRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [plotWidth, setPlotWidth] = useState(0);
|
||||||
|
|
||||||
const data = query.data;
|
const data = query.data;
|
||||||
|
|
||||||
// Aggregate Dropdown Options dynamically from payload
|
// گزینههای آبشاری از دل payload ساخته میشوند
|
||||||
const projectOptions = useMemo(() => {
|
const projectOptions = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -158,16 +315,52 @@ export function DrilldownMatrix() {
|
|||||||
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||||
}, [data, selectedProjectIds]);
|
}, [data, selectedProjectIds]);
|
||||||
|
|
||||||
// Prevent invalid filter states
|
// ——— همگامسازی با فیلتر سراسری (Zustand) ———
|
||||||
|
// فیلتر سراسری، payload را زیر پای حالت محلی عوض میکند؛ بهجای پاککردن
|
||||||
|
// کورکورانهی مسیر، حالت محلی را با دادهی تازه اعتبارسنجی میکنیم تا نه در
|
||||||
|
// سطح کهنه گیر کنیم و نه بیدلیل به عقب بپریم.
|
||||||
|
const globalFilterKey = useDashboardFilter(selectGlobalFilterKey);
|
||||||
|
|
||||||
|
// ۱) انتخابهای وابسته به payload (پروژه/انبار) نباید به شناسههایی اشاره کنند
|
||||||
|
// که در نتیجهی فیلتر جاری وجود ندارند؛ وگرنه نمودارِ خالی با چیپهای فعال میبینیم.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedProjectIds.length > 0) {
|
if (!data) return;
|
||||||
const validIds = warehouseOptions.map((o) => o.id);
|
const validProjectIds = new Set(projectOptions.map((o) => o.id));
|
||||||
const filtered = selectedWarehouseIds.filter((id) => validIds.includes(id));
|
setSelectedProjectIds((ids) =>
|
||||||
if (filtered.length !== selectedWarehouseIds.length) {
|
ids.every((id) => validProjectIds.has(id)) ? ids : ids.filter((id) => validProjectIds.has(id)),
|
||||||
setSelectedWarehouseIds(filtered);
|
);
|
||||||
}
|
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;
|
||||||
}
|
}
|
||||||
}, [selectedProjectIds, warehouseOptions]);
|
if (path.length > 1 && !material.projects.some((p) => p.id === path[1].id)) {
|
||||||
|
setPath((prev) => prev.slice(0, 1));
|
||||||
|
}
|
||||||
|
}, [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
|
// Helper to sum all units and get unit label
|
||||||
const getAggregatedQtyAndUnit = (qtyByUnit: Record<string, number>) => {
|
const getAggregatedQtyAndUnit = (qtyByUnit: Record<string, number>) => {
|
||||||
@@ -242,72 +435,75 @@ export function DrilldownMatrix() {
|
|||||||
|
|
||||||
const levelTotal = slices.reduce((s, x) => s + x.displayQuantity, 0);
|
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) => {
|
const handleBarClick = (slice: Slice) => {
|
||||||
if (level === 0) {
|
// هر کلیک یک سطح عمیقتر: متریال ← پروژه ← انبار (عمیقتر از این وجود ندارد)
|
||||||
setMaterialId(slice.id);
|
setPath((prev) => {
|
||||||
setLevel(1);
|
if (prev.length === 0) return [{ id: slice.id, name: slice.name }];
|
||||||
} else if (level === 1) {
|
if (prev.length === 1) return [...prev, { id: slice.id, name: slice.name }];
|
||||||
setProjectId(slice.id);
|
return prev;
|
||||||
setLevel(2);
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// پرش به هر گرهی مسیر — هم سطح نمودار را برمیگرداند و هم فیلترهای ریزتر
|
||||||
|
// (اسکوپهای پروژه/انبار) را خودکار آزاد میکند تا کاربر مجبور نباشد تیکها را
|
||||||
|
// دستی بردارد. فیلتر سراسری «چه چیزی» (Zustand) دستنخورده میماند.
|
||||||
|
const jumpToLevel = (target: Level) => {
|
||||||
|
setPath((prev) => prev.slice(0, target));
|
||||||
|
if (target === ROOT_LEVEL) {
|
||||||
|
// بازگشت به «کل سازمان»: هر دو انتخاب پروژه و انبار پاک میشوند
|
||||||
|
setSelectedProjectIds([]);
|
||||||
|
setSelectedWarehouseIds([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// گرههای پایینتر: فقط ریزترین انتخاب (انبارها) آزاد میشود؛
|
||||||
|
// اسکوپ پروژهی جاری دستنخورده میماند.
|
||||||
|
setSelectedWarehouseIds([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// گرههای drill-down در breadcrumb (متریال، سپس پروژه) — گرههای دامنه جدا رندر میشوند
|
||||||
|
const drillCrumbs = path.map<{ level: Level; label: string }>((node, index) => ({
|
||||||
|
level: index === 0 ? 1 : 2,
|
||||||
|
label: node.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ظاهر مشترک گرههای کلیکی مسیر (گرهی جاری: برجسته؛ گرههای عقبرونده: قابل کلیک)
|
||||||
|
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 getOrgTitle = () => {
|
// ردیف «همهی پروژهها» در منوی گره: دامنهی پروژه و دامنهی عمیقترش (انبار) آزاد میشود
|
||||||
if (selectedProjectIds.length === 0) return "کل سازمان";
|
const clearProjectScope = () => {
|
||||||
return projectOptions
|
setSelectedProjectIds([]);
|
||||||
.filter((o) => selectedProjectIds.includes(o.id))
|
setSelectedWarehouseIds([]);
|
||||||
.map((o) => o.name)
|
|
||||||
.join(" و ");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getWarehouseTitle = () => {
|
// انبار ریزترین دامنه است: فقط خودش آزاد میشود و مسیر drill-down دستنخورده میماند
|
||||||
if (selectedWarehouseIds.length === 0) return "همه انبارها";
|
const clearWarehouseScope = () => setSelectedWarehouseIds([]);
|
||||||
return warehouseOptions
|
|
||||||
.filter((o) => selectedWarehouseIds.includes(o.id))
|
|
||||||
.map((o) => o.name)
|
|
||||||
.join(" و ");
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedMaterialName =
|
const projectScopeLabel = () =>
|
||||||
level > 0 && materialId
|
scopeLabel(projectOptions, selectedProjectIds, LABELS.allProjects, otherProjectsLabel);
|
||||||
? data?.materials.find((m) => m.id === materialId)?.name || ""
|
const warehouseScopeLabel = () =>
|
||||||
: "";
|
scopeLabel(warehouseOptions, selectedWarehouseIds, LABELS.allWarehouses, otherWarehousesLabel);
|
||||||
|
|
||||||
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 getTooltipPrompt = () => {
|
const getTooltipPrompt = () => {
|
||||||
if (level === 0) return "برای مشاهده پروژهها کلیک کنید";
|
if (level === 0) return "برای مشاهده پروژهها کلیک کنید";
|
||||||
@@ -316,81 +512,110 @@ export function DrilldownMatrix() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartCard title={renderBreadcrumbHeader()}>
|
<ChartCard title={LABELS.widgetTitle}>
|
||||||
<nav dir="rtl" className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]">
|
{/* ——— breadcrumb یکپارچه: تنها مسیر پیمایش بالای نمودار ———
|
||||||
{/* Navigation Breadcrumb (Level Path) */}
|
گرههای دامنه (پروژه/انبار) همان فیلترهای سراسریاند و گرههای بعدی
|
||||||
|
مسیر drill-down؛ کلیک روی گرههای عقبرونده هم سطح نمودار را برمیگرداند
|
||||||
|
و هم دامنههای عمیقتر را آزاد میکند. */}
|
||||||
|
<nav
|
||||||
|
dir="rtl"
|
||||||
|
aria-label="مسیر پیمایش گزارش"
|
||||||
|
className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]"
|
||||||
|
>
|
||||||
|
{/* ریشه — بازگشت کامل به کل سازمان */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => jumpToLevel(ROOT_LEVEL)}
|
||||||
setMaterialId(null);
|
aria-current={level === ROOT_LEVEL ? "page" : undefined}
|
||||||
setProjectId(null);
|
title={crumbHint(ROOT_LEVEL, LABELS.root)}
|
||||||
setLevel(0);
|
className={crumbClass(level === ROOT_LEVEL)}
|
||||||
}}
|
|
||||||
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"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
گزارش کل
|
{LABELS.root}
|
||||||
</button>
|
</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" />
|
||||||
<button
|
<MultiSelectDropdown
|
||||||
type="button"
|
label={warehouseScopeLabel()}
|
||||||
onClick={() => {
|
options={warehouseOptions}
|
||||||
setProjectId(null);
|
selectedIds={selectedWarehouseIds}
|
||||||
setLevel(1);
|
onChange={setSelectedWarehouseIds}
|
||||||
}}
|
allLabel={LABELS.allWarehouses}
|
||||||
className={cn(
|
onClearAll={clearWarehouseScope}
|
||||||
"rounded-md px-2 py-0.5 font-bold transition-colors",
|
title={LABELS.warehouseScopeHint}
|
||||||
level === 1
|
/>
|
||||||
? "bg-accent-bg text-accent"
|
|
||||||
: "text-muted hover:bg-slate-bg hover:text-ink"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{selectedMaterialName}
|
|
||||||
</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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* گرههای 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={() => jumpToLevel(crumb.level)}
|
||||||
|
aria-current={isCurrent ? "page" : undefined}
|
||||||
|
title={crumbHint(crumb.level, crumb.label)}
|
||||||
|
className={crumbClass(isCurrent)}
|
||||||
|
>
|
||||||
|
{crumb.label}
|
||||||
|
</button>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<span className="ms-auto text-[11px] tabular-nums text-muted">
|
<span className="ms-auto text-[11px] tabular-nums text-muted">
|
||||||
مجموع نمایش داده شده: {fmtNum(levelTotal)}
|
مجموع نمایش داده شده: {fmtNum(levelTotal)}
|
||||||
</span>
|
</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* مواد کاتالوگی با موجودی صفر عمداً رندر میشوند (محور = کاتالوگ)؛ پیام
|
||||||
|
«یافت نشد» فقط وقتی است که هیچ مادهای با فیلترها همخوانی نداشته باشد */}
|
||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<ChartLoading />
|
<ChartLoading />
|
||||||
) : !slices.length || levelTotal === 0 ? (
|
) : !slices.length ? (
|
||||||
<ChartEmpty message="موجودیای با این فیلترها یافت نشد" />
|
<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
|
<BarChart
|
||||||
width={Math.max(typeof window !== 'undefined' ? window.innerWidth - 100 : 800, slices.length * 180)}
|
width={chartWidth}
|
||||||
height={256}
|
height={CHART_HEIGHT}
|
||||||
data={slices}
|
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} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="name"
|
dataKey="name"
|
||||||
tick={{ fontSize: 11, fill: "#64748b", fontFamily: "Vazirmatn" }}
|
tick={AxisTick}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={{ stroke: "#e5e9f0" }}
|
axisLine={{ stroke: "#e5e9f0" }}
|
||||||
interval={0}
|
interval={0}
|
||||||
height={44}
|
height={AXIS_HEIGHT}
|
||||||
angle={0}
|
tickMargin={AXIS_TICK_MARGIN}
|
||||||
textAnchor="middle"
|
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||||
|
|||||||
Reference in New Issue
Block a user