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

This commit is contained in:
fatemeh sane
2026-10-15 15:21:08 +03:30
parent eedb821e3b
commit 692343423c
8 changed files with 492 additions and 230 deletions
+4 -9
View File
@@ -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),
) )
+14 -39
View File
@@ -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>
+277 -55
View File
@@ -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}
-1
View File
@@ -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;
+1 -8
View File
@@ -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 (کل سازمان → پروژه → انبار) —
+3 -20
View File
@@ -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,