feat(dashboard): implement floating waterfall inventory bridge with step-line balance
This commit is contained in:
@@ -96,13 +96,11 @@ async def dashboard_scope(
|
|||||||
|
|
||||||
|
|
||||||
def _what_filters(
|
def _what_filters(
|
||||||
category_id: uuid.UUID | None,
|
|
||||||
material_id: uuid.UUID | None,
|
material_id: uuid.UUID | None,
|
||||||
brand_id: uuid.UUID | None,
|
brand_id: uuid.UUID | None,
|
||||||
specs_hash: str | None,
|
specs_hash: str | None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
"category_id": category_id,
|
|
||||||
"material_id": material_id,
|
"material_id": material_id,
|
||||||
"brand_id": brand_id,
|
"brand_id": brand_id,
|
||||||
"specs_hash": specs_hash,
|
"specs_hash": specs_hash,
|
||||||
@@ -111,7 +109,7 @@ def _what_filters(
|
|||||||
|
|
||||||
@router.get("/filters")
|
@router.get("/filters")
|
||||||
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
|
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
|
||||||
"""Cascading options for the global filter bar (Category→Material→Brand→Specs)."""
|
"""Options for the global filter bar (Material→Brand→Specs)."""
|
||||||
return await dashboard_service.filter_options(db)
|
return await dashboard_service.filter_options(db)
|
||||||
|
|
||||||
|
|
||||||
@@ -119,7 +117,6 @@ async def dashboard_filters(db: AsyncSession = Depends(get_db)):
|
|||||||
async def inventory_drilldown(
|
async def inventory_drilldown(
|
||||||
scope: DashboardScope = Depends(dashboard_scope),
|
scope: DashboardScope = Depends(dashboard_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
category_id: uuid.UUID | None = Query(default=None),
|
|
||||||
material_id: uuid.UUID | None = Query(default=None),
|
material_id: uuid.UUID | None = Query(default=None),
|
||||||
brand_id: uuid.UUID | None = Query(default=None),
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
specs_hash: str | None = Query(default=None),
|
specs_hash: str | None = Query(default=None),
|
||||||
@@ -129,7 +126,7 @@ async def inventory_drilldown(
|
|||||||
db,
|
db,
|
||||||
project_ids=scope.project_ids,
|
project_ids=scope.project_ids,
|
||||||
project_id=scope.project_id,
|
project_id=scope.project_id,
|
||||||
**_what_filters(category_id, material_id, brand_id, specs_hash),
|
**_what_filters(material_id, brand_id, specs_hash),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -139,7 +136,6 @@ async def dead_stock(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"),
|
idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"),
|
||||||
limit: int = Query(default=200, ge=1, le=1000),
|
limit: int = Query(default=200, ge=1, le=1000),
|
||||||
category_id: uuid.UUID | None = Query(default=None),
|
|
||||||
material_id: uuid.UUID | None = Query(default=None),
|
material_id: uuid.UUID | None = Query(default=None),
|
||||||
brand_id: uuid.UUID | None = Query(default=None),
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
specs_hash: str | None = Query(default=None),
|
specs_hash: str | None = Query(default=None),
|
||||||
@@ -151,7 +147,7 @@ async def dead_stock(
|
|||||||
project_id=scope.project_id,
|
project_id=scope.project_id,
|
||||||
idle_days=idle_days,
|
idle_days=idle_days,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
**_what_filters(category_id, material_id, brand_id, specs_hash),
|
**_what_filters(material_id, brand_id, specs_hash),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -162,7 +158,6 @@ async def inventory_flow(
|
|||||||
date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیشفرض ۳۰ روز اخیر)"),
|
date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیشفرض ۳۰ روز اخیر)"),
|
||||||
date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"),
|
date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"),
|
||||||
warehouse_id: uuid.UUID | None = Query(default=None),
|
warehouse_id: uuid.UUID | None = Query(default=None),
|
||||||
category_id: uuid.UUID | None = Query(default=None),
|
|
||||||
material_id: uuid.UUID | None = Query(default=None),
|
material_id: uuid.UUID | None = Query(default=None),
|
||||||
brand_id: uuid.UUID | None = Query(default=None),
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
specs_hash: str | None = Query(default=None),
|
specs_hash: str | None = Query(default=None),
|
||||||
@@ -182,7 +177,7 @@ async def inventory_flow(
|
|||||||
date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
||||||
date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
||||||
warehouse_id=warehouse_id,
|
warehouse_id=warehouse_id,
|
||||||
**_what_filters(category_id, material_id, brand_id, specs_hash),
|
**_what_filters(material_id, brand_id, specs_hash),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Read-only service. Every function receives the resolved scope (PM → the list
|
Read-only service. Every function receives the resolved scope (PM → the list
|
||||||
of projects they manage; office roles → project_ids=None = whole
|
of projects they manage; office roles → project_ids=None = whole
|
||||||
organization) plus an optional explicit project filter, and the optional
|
organization) plus an optional explicit project filter, and the optional
|
||||||
«What» filters (category_id / material_id / brand_id / specs_hash).
|
«What» filters (material_id / brand_id / specs_hash).
|
||||||
|
|
||||||
Quantities are NEVER mixed across units: inventory rows live in the
|
Quantities are NEVER mixed across units: inventory rows live in the
|
||||||
material's canonical unit, so stock aggregates carry their unit code and the
|
material's canonical unit, so stock aggregates carry their unit code and the
|
||||||
@@ -21,7 +21,7 @@ from sqlalchemy import case, func, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
from app.models.catalog import Brand, BrandMaterial, Material, MaterialCategory, Unit
|
from app.models.catalog import Brand, BrandMaterial, Material, Unit
|
||||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||||
from app.models.procurement import (
|
from app.models.procurement import (
|
||||||
ItemDecision,
|
ItemDecision,
|
||||||
@@ -44,10 +44,8 @@ OUTBOUND_TYPES = ("WITHDRAWAL", "DAMAGE", "WASTE", "LOSS")
|
|||||||
WASTE_TYPES = ("DAMAGE", "WASTE", "LOSS")
|
WASTE_TYPES = ("DAMAGE", "WASTE", "LOSS")
|
||||||
|
|
||||||
|
|
||||||
def _stock_filters(stmt, *, category_id=None, material_id=None, brand_id=None, specs_hash=None):
|
def _stock_filters(stmt, *, material_id=None, brand_id=None, specs_hash=None):
|
||||||
"""«What» filters for a statement that already joins inventory→material."""
|
"""«What» filters for a statement that already joins inventory→material."""
|
||||||
if category_id is not None:
|
|
||||||
stmt = stmt.where(Material.category_id == category_id)
|
|
||||||
if material_id is not None:
|
if material_id is not None:
|
||||||
stmt = stmt.where(InventoryItem.material_id == material_id)
|
stmt = stmt.where(InventoryItem.material_id == material_id)
|
||||||
if brand_id is not None:
|
if brand_id is not None:
|
||||||
@@ -75,23 +73,15 @@ def _qty(value) -> float:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async def filter_options(db: AsyncSession) -> dict:
|
async def filter_options(db: AsyncSession) -> dict:
|
||||||
"""Cascading options for the global filter bar.
|
"""Options for the global filter bar (Material → Brand → Specs).
|
||||||
|
|
||||||
Categories/materials come from the catalog (active only). Brand options
|
Materials come from the catalog (active only). Brand options per material
|
||||||
per material are the UNION of the catalog links (brand_materials — every
|
are the UNION of the catalog links (brand_materials — every brand defined
|
||||||
brand defined for that material) and the distinct brands actually present
|
for that material) and the distinct brands actually present in stock, so
|
||||||
in stock, so the cascade is complete even for materials whose current
|
the cascade is complete even for materials whose current stock rows carry
|
||||||
stock rows carry no brand yet. Spec options come from stock rows
|
no brand yet. Spec options come from stock rows (inventory.specs_hash),
|
||||||
(inventory.specs_hash), since specs are a stock-level dimension.
|
since specs are a stock-level dimension.
|
||||||
Materials without a category land in the synthetic «بدون دسته» bucket.
|
|
||||||
"""
|
"""
|
||||||
categories = (
|
|
||||||
await db.execute(
|
|
||||||
select(MaterialCategory)
|
|
||||||
.where(MaterialCategory.is_active.is_(True))
|
|
||||||
.order_by(MaterialCategory.name)
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
materials = (
|
materials = (
|
||||||
await db.execute(select(Material).where(Material.status == "active").order_by(Material.name))
|
await db.execute(select(Material).where(Material.status == "active").order_by(Material.name))
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -149,18 +139,8 @@ async def filter_options(db: AsyncSession) -> dict:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
buckets: dict[uuid.UUID | None, list] = {c.id: [] for c in categories}
|
# فهرست تخت متریالها — سرآغاز آبشار سهفیلتری (متریال → برند → مشخصات)
|
||||||
buckets.setdefault(None, [])
|
return {"data": {"materials": [material_entry(m) for m in materials]}}
|
||||||
for m in materials:
|
|
||||||
buckets.setdefault(m.category_id, []).append(material_entry(m))
|
|
||||||
|
|
||||||
out = [
|
|
||||||
{"id": str(c.id), "name": c.name, "slug": c.slug, "materials": buckets.get(c.id, [])}
|
|
||||||
for c in categories
|
|
||||||
]
|
|
||||||
if buckets.get(None):
|
|
||||||
out.append({"id": None, "name": "بدون دسته", "slug": None, "materials": buckets[None]})
|
|
||||||
return {"data": {"categories": out}}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -172,7 +152,6 @@ async def inventory_drilldown(
|
|||||||
*,
|
*,
|
||||||
project_ids,
|
project_ids,
|
||||||
project_id,
|
project_id,
|
||||||
category_id=None,
|
|
||||||
material_id=None,
|
material_id=None,
|
||||||
brand_id=None,
|
brand_id=None,
|
||||||
specs_hash=None,
|
specs_hash=None,
|
||||||
@@ -251,8 +230,6 @@ async def inventory_drilldown(
|
|||||||
.where(Material.status == "active")
|
.where(Material.status == "active")
|
||||||
)
|
)
|
||||||
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود میکنند
|
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود میکنند
|
||||||
if category_id is not None:
|
|
||||||
stmt = stmt.where(Material.category_id == category_id)
|
|
||||||
if material_id is not None:
|
if material_id is not None:
|
||||||
stmt = stmt.where(Material.id == material_id)
|
stmt = stmt.where(Material.id == material_id)
|
||||||
# برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان
|
# برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان
|
||||||
@@ -347,7 +324,6 @@ async def dead_stock(
|
|||||||
project_ids,
|
project_ids,
|
||||||
project_id,
|
project_id,
|
||||||
idle_days: int = 60,
|
idle_days: int = 60,
|
||||||
category_id=None,
|
|
||||||
material_id=None,
|
material_id=None,
|
||||||
brand_id=None,
|
brand_id=None,
|
||||||
specs_hash=None,
|
specs_hash=None,
|
||||||
@@ -392,7 +368,7 @@ async def dead_stock(
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
stmt = _stock_filters(
|
stmt = _stock_filters(
|
||||||
stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
|
stmt, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
|
||||||
)
|
)
|
||||||
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
|
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
|
||||||
rows = (await db.execute(stmt)).all()
|
rows = (await db.execute(stmt)).all()
|
||||||
@@ -427,7 +403,6 @@ async def daily_flow(
|
|||||||
date_from: datetime,
|
date_from: datetime,
|
||||||
date_to: datetime,
|
date_to: datetime,
|
||||||
warehouse_id=None,
|
warehouse_id=None,
|
||||||
category_id=None,
|
|
||||||
material_id=None,
|
material_id=None,
|
||||||
brand_id=None,
|
brand_id=None,
|
||||||
specs_hash=None,
|
specs_hash=None,
|
||||||
@@ -454,7 +429,7 @@ async def daily_flow(
|
|||||||
scope_conditions.append(Warehouse.project_id.in_(project_ids))
|
scope_conditions.append(Warehouse.project_id.in_(project_ids))
|
||||||
if warehouse_id is not None:
|
if warehouse_id is not None:
|
||||||
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
|
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
|
||||||
what_conditions = [Material.category_id == category_id] if category_id is not None else []
|
what_conditions = []
|
||||||
if material_id is not None:
|
if material_id is not None:
|
||||||
what_conditions.append(InventoryItem.material_id == material_id)
|
what_conditions.append(InventoryItem.material_id == material_id)
|
||||||
if brand_id is not None:
|
if brand_id is not None:
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// فیلتر سراسری «چه چیزی» — آبشار: دستهبندی → متریال → برند → مشخصات
|
// فیلتر سراسری «چه چیزی» — آبشار: متریال → برند → مشخصات فنی
|
||||||
// داده از /dashboard/filters میآید (بدون هیچ لیست ساختگی) و انتخاب
|
// داده از /dashboard/filters میآید (بدون هیچ لیست ساختگی) و انتخاب
|
||||||
// هر سطح، سطحهای پاییندست را بازنشانی میکند:
|
// هر سطح، سطحهای پاییندست را بازنشانی میکند:
|
||||||
// دسته → متریالهای همان دسته
|
|
||||||
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
|
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
|
||||||
// متریال → مشخصات فنی موجودِ همان متریال
|
// متریال → مشخصات فنی موجودِ همان متریال
|
||||||
// هر فیلد «×» مخصوص خودش را دارد و گزینهی «همهی …» در ابتدای هر فهرست
|
// هر فیلد «×» مخصوص خودش را دارد و گزینهی «همهی …» در ابتدای هر فهرست
|
||||||
// همان سطح (و سطوح پاییندست) را پاک میکند؛ دکمهی «حذف فیلترها» کل استور را
|
// همان سطح (و سطوح پاییندست) را پاک میکند؛ دکمهی «حذف فیلترها» کل استور را
|
||||||
// یکجا به نمای «کل سازمان» برمیگرداند.
|
// یکجا به نمای «کل سازمان» برمیگرداند.
|
||||||
|
// سه فیلتر در یک ردیفِ وسطچین با عرض یکسان مینشینند (موبایل: تکستونی).
|
||||||
// وضعیت در Zustand نگه داشته میشود تا با جابهجایی بین تبها از دست نرود.
|
// وضعیت در Zustand نگه داشته میشود تا با جابهجایی بین تبها از دست نرود.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
@@ -27,13 +27,13 @@ const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID];
|
|||||||
|
|
||||||
// برچسبهای ثابت UI — placeholder هر فیلد و گزینهی «همه»اش یک متن مشترک دارند
|
// برچسبهای ثابت UI — placeholder هر فیلد و گزینهی «همه»اش یک متن مشترک دارند
|
||||||
const LABELS = {
|
const LABELS = {
|
||||||
allCategories: "همهی دستهها",
|
|
||||||
allMaterials: "همهی متریالها",
|
allMaterials: "همهی متریالها",
|
||||||
allBrands: "همهی برندها",
|
allBrands: "همهی برندها",
|
||||||
allSpecs: "همهی مشخصات",
|
allSpecs: "همهی مشخصات",
|
||||||
materialFirst: "ابتدا متریال را انتخاب کنید",
|
materialFirst: "ابتدا متریال را انتخاب کنید",
|
||||||
clearAll: "حذف فیلترها",
|
clearAll: "حذف فیلترها",
|
||||||
wholeOrg: "نمای کل سازمان",
|
wholeOrg: "نمای کل سازمان",
|
||||||
|
cascade: "متریال ← برند ← مشخصات فنی",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label });
|
const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label });
|
||||||
@@ -48,23 +48,21 @@ export function DashboardFilterBar({ className }: { className?: string }) {
|
|||||||
const filterQuery = useDashboardFilterOptions();
|
const filterQuery = useDashboardFilterOptions();
|
||||||
const state = useDashboardFilter();
|
const state = useDashboardFilter();
|
||||||
|
|
||||||
const categories = filterQuery.data?.categories ?? [];
|
// فهرست متریالها مستقیماً از کاتالوگ سراسری میآید؛ فقط یکتاسازی شناسهها
|
||||||
|
// انجام میشود تا هیچ گزینهی تکراری در فهرست نباشد.
|
||||||
// متریالهای سطح فعلی: اگر دسته انتخاب شده باشد فقط متریالهای همان دسته
|
const materials = useMemo(() => {
|
||||||
const materialOptions = useMemo(() => {
|
|
||||||
const cat = categories.find((c) => (c.id ?? "") === (state.categoryId ?? ""));
|
|
||||||
const list = cat ? cat.materials : categories.flatMap((c) => c.materials);
|
|
||||||
// حذف متریال تکراری وقتی «همهی دستهها» فعال است
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return list.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true)));
|
return (filterQuery.data?.materials ?? []).filter((m) =>
|
||||||
}, [categories, state.categoryId]);
|
seen.has(m.id) ? false : (seen.add(m.id), true),
|
||||||
|
);
|
||||||
|
}, [filterQuery.data]);
|
||||||
|
|
||||||
const selectedMaterial = useMemo(
|
const selectedMaterial = useMemo(
|
||||||
() => materialOptions.find((m) => m.id === state.materialId) ?? null,
|
() => materials.find((m) => m.id === state.materialId) ?? null,
|
||||||
[materialOptions, state.materialId],
|
[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 =
|
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);
|
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[]>(
|
const materialItems = useMemo<ComboboxItem[]>(
|
||||||
() => [allOption(LABELS.allMaterials), ...materialOptions.map((m) => ({ id: m.id, label: m.name }))],
|
() => [allOption(LABELS.allMaterials), ...materials.map((m) => ({ id: m.id, label: m.name }))],
|
||||||
[materialOptions],
|
[materials],
|
||||||
);
|
);
|
||||||
|
|
||||||
const brandItems = useMemo<ComboboxItem[]>(
|
const brandItems = useMemo<ComboboxItem[]>(
|
||||||
@@ -113,7 +103,7 @@ export function DashboardFilterBar({ className }: { className?: string }) {
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-[13px] font-extrabold leading-5">فیلتر جنس</h2>
|
<h2 className="text-[13px] font-extrabold leading-5">فیلتر جنس</h2>
|
||||||
<p className="text-[10.5px] text-muted">دستهبندی ← متریال ← برند ← مشخصات فنی</p>
|
<p className="text-[10.5px] text-muted">{LABELS.cascade}</p>
|
||||||
</div>
|
</div>
|
||||||
{activeCount > 0 && (
|
{activeCount > 0 && (
|
||||||
<button
|
<button
|
||||||
@@ -132,31 +122,21 @@ export function DashboardFilterBar({ className }: { className?: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
{/* سه فیلتر با عرض یکسان، وسطچین و متقارن؛ روی موبایل تکستونی میشود */}
|
||||||
{/* ۱ — دستهبندی */}
|
<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.categoryLabel ?? ""}
|
|
||||||
placeholder={LABELS.allCategories}
|
|
||||||
allowAdd={false}
|
|
||||||
items={categoryItems}
|
|
||||||
pinnedIds={ALL_PINNED_IDS}
|
|
||||||
onClear={() => state.setCategory(null, null)}
|
|
||||||
onChange={pick(state.setCategory)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ۲ — متریال (وابسته به دستهی انتخابشده) */}
|
|
||||||
<Combobox
|
<Combobox
|
||||||
value={state.materialLabel ?? ""}
|
value={state.materialLabel ?? ""}
|
||||||
placeholder={LABELS.allMaterials}
|
placeholder={LABELS.allMaterials}
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
disabled={materialOptions.length === 0}
|
disabled={materials.length === 0}
|
||||||
items={materialItems}
|
items={materialItems}
|
||||||
pinnedIds={ALL_PINNED_IDS}
|
pinnedIds={ALL_PINNED_IDS}
|
||||||
onClear={() => state.setMaterial(null, null)}
|
onClear={() => state.setMaterial(null, null)}
|
||||||
onChange={pick(state.setMaterial)}
|
onChange={pick(state.setMaterial)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ۳ — برند (فقط برندهای همان متریال) */}
|
{/* ۲ — برند (فقط برندهای همان متریال) */}
|
||||||
<Combobox
|
<Combobox
|
||||||
value={state.brandLabel ?? ""}
|
value={state.brandLabel ?? ""}
|
||||||
placeholder={selectedMaterial ? LABELS.allBrands : LABELS.materialFirst}
|
placeholder={selectedMaterial ? LABELS.allBrands : LABELS.materialFirst}
|
||||||
@@ -168,7 +148,7 @@ export function DashboardFilterBar({ className }: { className?: string }) {
|
|||||||
onChange={pick(state.setBrand)}
|
onChange={pick(state.setBrand)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ۴ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
|
{/* ۳ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
|
||||||
<Combobox
|
<Combobox
|
||||||
value={state.specsLabel ?? ""}
|
value={state.specsLabel ?? ""}
|
||||||
placeholder={selectedMaterial ? LABELS.allSpecs : LABELS.materialFirst}
|
placeholder={selectedMaterial ? LABELS.allSpecs : LABELS.materialFirst}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useDrilldown } from "../../hooks/useDashboardData";
|
import { useDrilldown } from "../../hooks/useDashboardData";
|
||||||
import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter";
|
import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter";
|
||||||
|
import type { DrilldownProject } from "../../lib/types";
|
||||||
import { faNum, fmtNum } from "../../lib/formatters";
|
import { faNum, fmtNum } from "../../lib/formatters";
|
||||||
import {
|
import {
|
||||||
ChartCard,
|
ChartCard,
|
||||||
@@ -49,7 +50,8 @@ interface DrillNode {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// یک گروه استک (پروژه در سطح ۰، انبار در سطح ۱) — نام و رنگ از payload مشتق میشوند
|
// یک گروه استک (پروژه در نمای کلی، انبار در نمای drill-down) — نام و رنگ از
|
||||||
|
// payload مشتق میشوند
|
||||||
interface StackGroup {
|
interface StackGroup {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -85,15 +87,57 @@ const LABELS = {
|
|||||||
totalLabel: "مجموع نمایش داده شده",
|
totalLabel: "مجموع نمایش داده شده",
|
||||||
itemsLabel: "اقلام نمایش داده شده",
|
itemsLabel: "اقلام نمایش داده شده",
|
||||||
tooltipTotal: "مجموع",
|
tooltipTotal: "مجموع",
|
||||||
|
tooltipBreakdown: "تفکیک بر اساس",
|
||||||
emptyChart: "موجودیای با این فیلترها یافت نشد",
|
emptyChart: "موجودیای با این فیلترها یافت نشد",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// فرادادهی سطوح — برچسبهای ثابت UI برای محور و گروهها (نامها از payload میآیند)
|
// فرادادهی سطوح — برچسب محور X هر سطح (نامها از payload میآیند)
|
||||||
const LEVEL_META: Record<Level, { axisTitle: string; groupTitle: string }> = {
|
const LEVEL_META: Record<Level, { axisTitle: string }> = {
|
||||||
0: { axisTitle: "متریالها", groupTitle: "پروژهها" },
|
0: { axisTitle: "متریالها" },
|
||||||
1: { axisTitle: "پروژهها", groupTitle: "انبارها" },
|
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 + سقف عرض میلهها بهازای هر سطح
|
// شناسهی استک در Recharts + سقف عرض میلهها بهازای هر سطح
|
||||||
const STACK_ID = "dashboard-stack";
|
const STACK_ID = "dashboard-stack";
|
||||||
const MAX_BAR_SIZE: Record<Level, number> = { 0: 110, 1: 64 };
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// تولتیپ استک — نام گروه، کمیت خام با واحد، و سهم درصدی از میله
|
// تولتیپ سفارشی — کارت شیشهای مینیمال با سلسلهمراتب روشن:
|
||||||
function StackTooltip({
|
// سربرگ (موجودیت میله + نوع تفکیک جاری)، بدنه (بهازای هر سگمنت: نقطهی رنگ
|
||||||
|
// داینامیک، نام کامل، کمیت و سهم درصدی) و پاصفحه (جمع میله).
|
||||||
|
// عرض کارت با محتوا رشد میکند (w-max) و فقط با سقف واکنشگرا محدود میشود؛
|
||||||
|
// متنهای بلند (عنوان/نام سگمنت) در همان سقف میشکنند تا هرگز از کادر بیرون
|
||||||
|
// نزنند، و گروههای عددی با whitespace-nowrap یکتکه میمانند تا اعداد و متن
|
||||||
|
// در حالت RTL بههم نریزند.
|
||||||
|
function CustomTooltip({
|
||||||
active,
|
active,
|
||||||
label,
|
label,
|
||||||
payload,
|
payload,
|
||||||
stacks,
|
stacks,
|
||||||
|
segmentKind,
|
||||||
}: {
|
}: {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
label?: string | number;
|
label?: string | number;
|
||||||
payload?: readonly { payload?: StackedBar }[];
|
payload?: readonly { payload?: StackedBar }[];
|
||||||
stacks: StackGroup[];
|
stacks: StackGroup[];
|
||||||
|
segmentKind: SegmentKind;
|
||||||
}) {
|
}) {
|
||||||
if (!active || !payload?.length) return null;
|
if (!active || !payload?.length) return null;
|
||||||
const bar = payload[0]?.payload;
|
const bar = payload[0]?.payload;
|
||||||
@@ -249,25 +301,54 @@ function StackTooltip({
|
|||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
const total = rows.reduce((sum, row) => sum + row.value, 0);
|
const total = rows.reduce((sum, row) => sum + row.value, 0);
|
||||||
return (
|
return (
|
||||||
<div dir="rtl" className="rounded-xl border border-line bg-panel/95 px-3 py-2 shadow-lg backdrop-blur-sm">
|
<div
|
||||||
<div className="mb-1 text-[11px] font-bold text-muted">{String(label ?? bar.label)}</div>
|
dir="rtl"
|
||||||
<div className="flex flex-col gap-1">
|
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) => (
|
{rows.map((row) => (
|
||||||
<div key={row.stack.key} className="flex items-center gap-2 text-[12px] leading-5">
|
<div key={row.stack.key} className="flex w-full items-center justify-between gap-6">
|
||||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: row.stack.color }} />
|
<div className="flex items-center gap-2">
|
||||||
<span className="max-w-[10rem] truncate text-muted">{row.stack.name}</span>
|
<span
|
||||||
<span className="ms-auto font-bold tabular-nums text-ink">
|
aria-hidden
|
||||||
{fmtNum(row.value)} {bar.unitLabel}
|
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
</span>
|
style={{ background: row.stack.color }}
|
||||||
<span className="w-10 text-end text-[11px] tabular-nums text-accent">
|
/>
|
||||||
{faNum(Math.round((row.value / total) * 100))}٪
|
<span className="whitespace-normal text-[11px] font-medium text-slate-700 sm:text-xs">
|
||||||
</span>
|
{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>
|
</div>
|
||||||
<div className="mt-1 border-t border-line pt-1 text-[11px] text-muted">
|
|
||||||
{LABELS.tooltipTotal}{" "}
|
<div className="my-2 border-b border-slate-100" />
|
||||||
<span className="font-bold tabular-nums text-ink">
|
|
||||||
|
{/* جمع میله — «مجموع: ۸۰۰ متر» کنار هم و از سمت راست */}
|
||||||
|
<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}
|
{fmtNum(total)} {bar.unitLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,7 +359,7 @@ function StackTooltip({
|
|||||||
// اثر انگشت فیلتر سراسری (Zustand) — هر تغییر آن یعنی payload تازهای میآید و
|
// اثر انگشت فیلتر سراسری (Zustand) — هر تغییر آن یعنی payload تازهای میآید و
|
||||||
// اعتبارسنجی متریال انتخابی باید دوباره اجرا شود.
|
// اعتبارسنجی متریال انتخابی باید دوباره اجرا شود.
|
||||||
const selectGlobalFilterKey = (s: DashboardFilterState): string =>
|
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 ---
|
// --- MultiSelect Dropdown Component ---
|
||||||
// کنترل چندانتخابی دامنه (پروژه/انبار) — بهعنوان گرهی چندانتخابیِ breadcrumb
|
// کنترل چندانتخابی دامنه (پروژه/انبار) — بهعنوان گرهی چندانتخابیِ breadcrumb
|
||||||
@@ -406,6 +487,10 @@ export function DrilldownMatrix() {
|
|||||||
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
|
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
|
||||||
const [selectedWarehouseIds, setSelectedWarehouseIds] = useState<string[]>([]);
|
const [selectedWarehouseIds, setSelectedWarehouseIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// تفکیک جاری نمودار: نمای کلی بدون فیلتر پروژه → پروژهها؛ نمای drill-down یا
|
||||||
|
// نمای دارای فیلتر پروژه → انبارها
|
||||||
|
const segmentKind = segmentKindFor(level, selectedProjectIds.length > 0);
|
||||||
|
|
||||||
// عرض واقعی کانتینر نمودار — تا نمودار هرگز در عرض والد فشرده نشود
|
// عرض واقعی کانتینر نمودار — تا نمودار هرگز در عرض والد فشرده نشود
|
||||||
const plotRef = useRef<HTMLDivElement>(null);
|
const plotRef = useRef<HTMLDivElement>(null);
|
||||||
const [plotWidth, setPlotWidth] = useState(0);
|
const [plotWidth, setPlotWidth] = useState(0);
|
||||||
@@ -482,8 +567,8 @@ export function DrilldownMatrix() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ——— دادهی نمودار ۱۰۰٪ استکشده ———
|
// ——— دادهی نمودار ۱۰۰٪ استکشده ———
|
||||||
// همهی محورها، گروهها، واحدها و رنگها از payload مشتق میشوند؛ هیچ نگاشت
|
// محورها، سگمنتها (پروژه/انبار بر اساس segmentKind)، نامهای کامل، واحدها
|
||||||
// دستی (متریال/پروژه/رنگ) در کد وجود ندارد.
|
// و رنگها همه از payload مشتق میشوند؛ هیچ نگاشت دستی در کد وجود ندارد.
|
||||||
const { bars, stacks, unitLabels } = useMemo(() => {
|
const { bars, stacks, unitLabels } = useMemo(() => {
|
||||||
const empty = {
|
const empty = {
|
||||||
bars: [] as StackedBar[],
|
bars: [] as StackedBar[],
|
||||||
@@ -532,29 +617,38 @@ export function DrilldownMatrix() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (level === ROOT_LEVEL) {
|
// میلهها و سگمنتها با یک الگوی واحد ساخته میشوند: میله = متریال (نمای
|
||||||
// میله = متریال، گروه = پروژه
|
// کلی) یا پروژه (نمای drill-down)، و سگمنت بر اساس تفکیک زمینهآگاه
|
||||||
data.materials.forEach((mat) => {
|
// (segmentKind) پروژه یا انبار است.
|
||||||
const bar = ensureBar(mat.id, mat.name, mat.qty_by_unit);
|
const addBar = (
|
||||||
mat.projects.forEach((proj) => {
|
barId: string,
|
||||||
if (!matchesProject(proj.id)) return;
|
barLabel: string,
|
||||||
proj.warehouses.forEach((wh) => {
|
ownUnits: Record<string, number>,
|
||||||
if (!matchesWarehouse(wh.id)) return;
|
projects: DrilldownProject[],
|
||||||
addGroup(bar, proj.id, proj.name, wh.qty_by_unit);
|
) => {
|
||||||
});
|
const bar = ensureBar(barId, barLabel, ownUnits);
|
||||||
});
|
projects.forEach((proj) => {
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// میله = پروژهی متریال انتخابشده، گروه = انبار
|
|
||||||
const mat = data.materials.find((m) => m.id === materialId);
|
|
||||||
mat?.projects.forEach((proj) => {
|
|
||||||
if (!matchesProject(proj.id)) return;
|
if (!matchesProject(proj.id)) return;
|
||||||
const bar = ensureBar(proj.id, proj.name, proj.qty_by_unit);
|
|
||||||
proj.warehouses.forEach((wh) => {
|
proj.warehouses.forEach((wh) => {
|
||||||
if (!matchesWarehouse(wh.id)) return;
|
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>();
|
const groupNames = new Map<string, string>();
|
||||||
@@ -597,24 +691,29 @@ export function DrilldownMatrix() {
|
|||||||
stackTotals.set(groupId, (stackTotals.get(groupId) ?? 0) + value);
|
stackTotals.set(groupId, (stackTotals.get(groupId) ?? 0) + value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const stacks: StackGroup[] = [...stackTotals.entries()]
|
const orderedKeys = [...stackTotals.entries()]
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
b[1] - a[1] ||
|
b[1] - a[1] ||
|
||||||
(groupNames.get(a[0]) ?? "").localeCompare(groupNames.get(b[0]) ?? "", "fa"),
|
(groupNames.get(a[0]) ?? "").localeCompare(groupNames.get(b[0]) ?? "", "fa"),
|
||||||
)
|
)
|
||||||
.map(([key], index) => ({
|
.map(([key]) => key);
|
||||||
key,
|
// نامهای تکراری (انبار همنامِ همان پروژه) پیش از ساخت راهنما تفکیک میشوند
|
||||||
name: groupNames.get(key) ?? key,
|
const uniqueNames = disambiguateNames(
|
||||||
color: groupColor(index),
|
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 {
|
return {
|
||||||
bars,
|
bars,
|
||||||
stacks,
|
stacks,
|
||||||
unitLabels: [...new Set(bars.map((b) => b.unitLabel).filter(Boolean))],
|
unitLabels: [...new Set(bars.map((b) => b.unitLabel).filter(Boolean))],
|
||||||
};
|
};
|
||||||
}, [data, level, materialId, selectedProjectIds, selectedWarehouseIds]);
|
}, [data, level, materialId, segmentKind, selectedProjectIds, selectedWarehouseIds]);
|
||||||
|
|
||||||
// عرض صریح نمودار: هر میله یک اسلات ثابت میگیرد تا میلهها پخش شوند و
|
// عرض صریح نمودار: هر میله یک اسلات ثابت میگیرد تا میلهها پخش شوند و
|
||||||
// برچسبهای محور X فاصلهی کافی داشته باشند؛ هرگز باریکتر از عرض واقعی
|
// برچسبهای محور X فاصلهی کافی داشته باشند؛ هرگز باریکتر از عرض واقعی
|
||||||
@@ -779,18 +878,29 @@ export function DrilldownMatrix() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* کانتینر اسکرولدار — عرض صریح نمودار از chartWidth میآید تا
|
{/* کانتینر اسکرولدار — عرض صریح نمودار از chartWidth میآید تا
|
||||||
میلهها پخش شوند و برچسبها فاصلهی کافی داشته باشند */}
|
میلهها پخش شوند و برچسبهای فاصلهی کافی داشته باشند.
|
||||||
|
ریشهی حلقهی مشکی فوکوس: Recharts لایههای z-index را بهصورت
|
||||||
|
<g tabindex="-1" class="recharts-zIndex-layer_*"> رندر میکند
|
||||||
|
(ZIndexPortal.js) و آن g کل ناحیهی نمودار را میپوشاند؛ پس با
|
||||||
|
کلیک، فوکوس روی همین g مینشیند و مرورگر حلقهی پیشفرض را دور کل
|
||||||
|
نمودار میکشد. چون outline ارثبری نمیشود، مهار آن فقط با
|
||||||
|
سلکتور فرزند (g/سایر عناصر داخل نمودار) ممکن است. */}
|
||||||
<div
|
<div
|
||||||
ref={plotRef}
|
ref={plotRef}
|
||||||
dir="ltr"
|
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 }}
|
style={{ width: '100%', height: CHART_HEIGHT }}
|
||||||
>
|
>
|
||||||
|
{/* بدون لایهی دسترسپذیری، Recharts روی خود SVG دیگر tabIndex
|
||||||
|
نمیگذارد؛ ولی لایههای z-index داخلیاش (g[tabindex="-1"]) هنوز
|
||||||
|
فوکوسپذیرند و همانها منبع حلقهی فوکوساند — که با سلکتورهای
|
||||||
|
فرزند روی wrapper مهار شدهاند. */}
|
||||||
<BarChart
|
<BarChart
|
||||||
width={chartWidth}
|
width={chartWidth}
|
||||||
height={CHART_HEIGHT}
|
height={CHART_HEIGHT}
|
||||||
data={bars}
|
data={bars}
|
||||||
stackOffset="expand"
|
stackOffset="expand"
|
||||||
|
accessibilityLayer={false}
|
||||||
margin={{ top: 26, right: 12, left: 8, bottom: 12 }}
|
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} />
|
||||||
@@ -814,7 +924,9 @@ export function DrilldownMatrix() {
|
|||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
|
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
|
||||||
content={(props) => <StackTooltip {...props} stacks={stacks} />}
|
content={(props) => (
|
||||||
|
<CustomTooltip {...props} stacks={stacks} segmentKind={segmentKind} />
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{/* هر گروه استک یک Bar است؛ رنگ از ترتیب گروه و گوشههای گرد فقط
|
{/* هر گروه استک یک Bar است؛ رنگ از ترتیب گروه و گوشههای گرد فقط
|
||||||
برای بالاترین گروه (آخرین Bar) اعمال میشود */}
|
برای بالاترین گروه (آخرین Bar) اعمال میشود */}
|
||||||
@@ -837,19 +949,22 @@ export function DrilldownMatrix() {
|
|||||||
</BarChart>
|
</BarChart>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* راهنمای رنگها — از همان گروههای payload ساخته میشود */}
|
{/* راهنمای رنگها — از همان سگمنتهای payload ساخته میشود و نام
|
||||||
|
انبارها بهصورت کامل «پروژه - انبار» نمایش داده میشود */}
|
||||||
{stacks.length > 0 && (
|
{stacks.length > 0 && (
|
||||||
<div
|
<div
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted"
|
className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted"
|
||||||
>
|
>
|
||||||
<span className="font-bold">
|
<span className="font-bold">
|
||||||
{LEVEL_META[level].axisTitle} × {LEVEL_META[level].groupTitle}:
|
{LEVEL_META[level].axisTitle} × {SEGMENT_META[segmentKind].plural}:
|
||||||
</span>
|
</span>
|
||||||
{stacks.map((stack) => (
|
{stacks.map((stack) => (
|
||||||
<span key={stack.key} className="inline-flex items-center gap-1">
|
<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="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>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// ویجت ۳ — سری زمانی جریان روزانه
|
// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور)
|
||||||
// ستونهای روزانه: ورود (سبز) / برگشت (کهربایی) / خروج که لایهی
|
// هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز»
|
||||||
// قرمزِ «ضایعات، اتلاف، مفقودی» داخل همان ستون خروج سته —
|
// کشیده میشود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
|
||||||
// بهعلاوهی خط «مانده» (بالانس تجمعی). تاریخها شمسی، اعداد فارسی.
|
// بهصورت سگمنتهای [پایین، بالا] روی مسیر واقعی مانده مینشینند، پس ستون
|
||||||
|
// دقیقاً پلهی خط مانده را میسازد (نه از خط صفر).
|
||||||
|
// خط مانده پلهای (stepAfter) و تولتیپ تحلیلی کامل روز.
|
||||||
|
// همهی مقادیر (شامل ارتفاع پایه و اجزا) از دادهی سریزمانی API محاسبه میشوند.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -19,57 +22,234 @@ import {
|
|||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useDailyFlow } from "../../hooks/useDashboardData";
|
import { useDailyFlow } from "../../hooks/useDashboardData";
|
||||||
import { isoToJalaliStr } from "../../lib/jalali";
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
import {
|
import { fmtNum } from "../../lib/formatters";
|
||||||
CHART_COLORS,
|
import { ChartCard, ChartEmpty, ChartLoading, faAxisTick } from "./chartShared";
|
||||||
ChartCard,
|
|
||||||
ChartEmpty,
|
|
||||||
ChartLoading,
|
|
||||||
ChartTooltip,
|
|
||||||
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;
|
date: string;
|
||||||
ورود: number;
|
balance: number; // خط پلهای مانده = مانده پایان روز
|
||||||
برگشت: number;
|
opening: number;
|
||||||
"خروج سایت": number;
|
closing: number;
|
||||||
"ضایعات و اتلاف": number;
|
net: number;
|
||||||
مانده: number;
|
inbound: number;
|
||||||
_total: 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() {
|
export function FlowChart() {
|
||||||
const query = useDailyFlow();
|
const query = useDailyFlow();
|
||||||
|
|
||||||
const rows: Row[] = useMemo(
|
// ——— ساخت مسیر واقعی مانده: هر جزء روی مقدار جاری سوار میشود ———
|
||||||
() =>
|
const rows: FlowRow[] = useMemo(() => {
|
||||||
(query.data?.days ?? []).map((d) => ({
|
const days = query.data?.days ?? [];
|
||||||
date: d.date,
|
let running = query.data?.opening_balance ?? 0;
|
||||||
ورود: d.inbound,
|
return days.map((d) => {
|
||||||
برگشت: d.returns,
|
const opening = running;
|
||||||
"خروج سایت": Math.max(0, d.outbound - d.waste),
|
const closing = d.balance;
|
||||||
"ضایعات و اتلاف": d.waste,
|
const waste = Math.max(0, d.waste);
|
||||||
مانده: d.balance,
|
// ضایعات بخشی از خروج است؛ برای ستون جدا، خالص خروج گرفته میشود
|
||||||
_total: d.inbound + d.outbound + d.returns,
|
const siteOut = Math.max(0, d.outbound - waste);
|
||||||
})),
|
const net = round4(closing - opening);
|
||||||
[query.data],
|
// «سایر حرکات» = باقیماندهی تراز (انتقال/شمارش/یافتشده)؛ معمولاً صفر
|
||||||
);
|
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 (
|
return (
|
||||||
<ChartCard
|
<ChartCard title={LABELS.title} sub={LABELS.sub}>
|
||||||
title="جریان روزانه موجودی"
|
|
||||||
sub="ورود، خروج (با لایهی قرمز ضایعات)، برگشت از سایت و ماندهی تجمعی — ۳۰ روز اخیر"
|
|
||||||
>
|
|
||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<ChartLoading />
|
<ChartLoading />
|
||||||
) : !hasAnyData ? (
|
) : !hasMovement ? (
|
||||||
<ChartEmpty message="در این بازه جابهجایی موجودی ثبت نشده است" />
|
<ChartEmpty message={LABELS.empty} />
|
||||||
) : (
|
) : (
|
||||||
<div dir="ltr" className="h-72 w-full">
|
<div dir="ltr" className="h-72 w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<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} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
@@ -80,6 +260,7 @@ export function FlowChart() {
|
|||||||
minTickGap={18}
|
minTickGap={18}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
|
domain={[domainMin, domainMax]}
|
||||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||||
tickFormatter={faAxisTick}
|
tickFormatter={faAxisTick}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
@@ -88,11 +269,7 @@ export function FlowChart() {
|
|||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
||||||
content={
|
content={<FlowTooltip />}
|
||||||
<ChartTooltip
|
|
||||||
labelFormatter={(l) => isoToJalaliStr(String(l)) ?? String(l)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Legend
|
<Legend
|
||||||
wrapperStyle={{ direction: "rtl", width: "100%" }}
|
wrapperStyle={{ direction: "rtl", width: "100%" }}
|
||||||
@@ -106,17 +283,62 @@ export function FlowChart() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{/* ستونهای کنار هم: ورود و برگشت */}
|
{/* سگمنتهای شناور آبشاری — هر جزء دقیقاً روی مسیر مانده مینشیند */}
|
||||||
<Bar dataKey="ورود" fill={CHART_COLORS.inbound} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
<Bar
|
||||||
<Bar dataKey="برگشت" fill={CHART_COLORS.returns} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
dataKey="segInbound"
|
||||||
{/* ستون خروج: دو لایهی سته — بخش قرمز ضایعات داخل ستون خروج */}
|
name={LABELS.inbound}
|
||||||
<Bar dataKey="خروج سایت" stackId="out" fill={CHART_COLORS.outbound} radius={[0, 0, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
fill={FLOW_COLORS.inbound}
|
||||||
<Bar dataKey="ضایعات و اتلاف" stackId="out" fill={CHART_COLORS.waste} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
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
|
<Line
|
||||||
type="monotone"
|
type="stepAfter"
|
||||||
dataKey="مانده"
|
dataKey="balance"
|
||||||
stroke={CHART_COLORS.balance}
|
name={LABELS.balance}
|
||||||
strokeWidth={1.5}
|
stroke={FLOW_COLORS.balance}
|
||||||
|
strokeWidth={1.75}
|
||||||
strokeDasharray="5 3"
|
strokeDasharray="5 3"
|
||||||
dot={false}
|
dot={false}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ import type {
|
|||||||
// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد
|
// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد
|
||||||
// (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد)
|
// (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد)
|
||||||
export type DashboardFilterParams = {
|
export type DashboardFilterParams = {
|
||||||
category_id?: string | null;
|
|
||||||
material_id?: string | null;
|
material_id?: string | null;
|
||||||
brand_id?: string | null;
|
brand_id?: string | null;
|
||||||
specs_hash?: string | null;
|
specs_hash?: string | null;
|
||||||
|
|||||||
@@ -232,15 +232,8 @@ export interface FilterMaterialOption {
|
|||||||
specs: FilterSpecOption[];
|
specs: FilterSpecOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterCategoryOption {
|
|
||||||
id: string | null; // null = «بدون دسته»
|
|
||||||
name: string;
|
|
||||||
slug: string | null;
|
|
||||||
materials: FilterMaterialOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardFilterOptions {
|
export interface DashboardFilterOptions {
|
||||||
categories: FilterCategoryOption[];
|
materials: FilterMaterialOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// — ماتریس Drill-Down (کل سازمان → پروژه → انبار) —
|
// — ماتریس Drill-Down (کل سازمان → پروژه → انبار) —
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// Zustand store — فیلتر سراسری «چه چیزی» داشبورد
|
// Zustand store — فیلتر سراسری «چه چیزی» داشبورد
|
||||||
// دستهبندی → متریال → برند → مشخصات فنی
|
// متریال → برند → مشخصات فنی
|
||||||
// بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ میشود.
|
// بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ میشود.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
export interface DashboardFilterState {
|
export interface DashboardFilterState {
|
||||||
categoryId: string | null; // null = همهی دستهها
|
materialId: string | null; // null = همهی متریالها
|
||||||
materialId: string | null;
|
|
||||||
brandId: string | null;
|
brandId: string | null;
|
||||||
specsHash: string | null;
|
specsHash: string | null;
|
||||||
// برچسبهای انتخابشده برای نمایش چیپ فعال
|
// برچسبهای انتخابشده برای نمایش چیپ فعال
|
||||||
categoryLabel: string | null;
|
|
||||||
materialLabel: string | null;
|
materialLabel: string | null;
|
||||||
brandLabel: string | null;
|
brandLabel: string | null;
|
||||||
specsLabel: string | null;
|
specsLabel: string | null;
|
||||||
|
|
||||||
setCategory: (id: string | null, label: string | null) => void;
|
|
||||||
setMaterial: (id: string | null, label: string | null) => void;
|
setMaterial: (id: string | null, label: string | null) => void;
|
||||||
setBrand: (id: string | null, label: string | null) => void;
|
setBrand: (id: string | null, label: string | null) => void;
|
||||||
setSpecs: (hash: string | null, label: string | null) => void;
|
setSpecs: (hash: string | null, label: string | null) => void;
|
||||||
@@ -25,11 +22,9 @@ export interface DashboardFilterState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL = {
|
const INITIAL = {
|
||||||
categoryId: null,
|
|
||||||
materialId: null,
|
materialId: null,
|
||||||
brandId: null,
|
brandId: null,
|
||||||
specsHash: null,
|
specsHash: null,
|
||||||
categoryLabel: null,
|
|
||||||
materialLabel: null,
|
materialLabel: null,
|
||||||
brandLabel: null,
|
brandLabel: null,
|
||||||
specsLabel: null,
|
specsLabel: null,
|
||||||
@@ -37,18 +32,7 @@ const INITIAL = {
|
|||||||
|
|
||||||
export const useDashboardFilter = create<DashboardFilterState>((set) => ({
|
export const useDashboardFilter = create<DashboardFilterState>((set) => ({
|
||||||
...INITIAL,
|
...INITIAL,
|
||||||
// آبشاری: تغییر هر سطح، سطحهای پاییندست را پاک میکند
|
// آبشاری: تغییر هر سطح، سطحهای پاییندست را پاک میکند (متریال → برند → مشخصات)
|
||||||
setCategory: (id, label) =>
|
|
||||||
set({
|
|
||||||
categoryId: id,
|
|
||||||
categoryLabel: label,
|
|
||||||
materialId: null,
|
|
||||||
materialLabel: null,
|
|
||||||
brandId: null,
|
|
||||||
brandLabel: null,
|
|
||||||
specsHash: null,
|
|
||||||
specsLabel: null,
|
|
||||||
}),
|
|
||||||
setMaterial: (id, label) =>
|
setMaterial: (id, label) =>
|
||||||
set({
|
set({
|
||||||
materialId: id,
|
materialId: id,
|
||||||
@@ -72,7 +56,6 @@ export const useDashboardFilter = create<DashboardFilterState>((set) => ({
|
|||||||
// پارامترهای آماده برای endpointهای داشبورد
|
// پارامترهای آماده برای endpointهای داشبورد
|
||||||
export function dashboardFilterParams(s: DashboardFilterState) {
|
export function dashboardFilterParams(s: DashboardFilterState) {
|
||||||
return {
|
return {
|
||||||
category_id: s.categoryId,
|
|
||||||
material_id: s.materialId,
|
material_id: s.materialId,
|
||||||
brand_id: s.brandId,
|
brand_id: s.brandId,
|
||||||
specs_hash: s.specsHash,
|
specs_hash: s.specsHash,
|
||||||
|
|||||||
Reference in New Issue
Block a user