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