Compare commits
5
Commits
7bb432e715
...
dashboard
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41f93acec5 | ||
|
|
692343423c | ||
|
|
eedb821e3b | ||
|
|
3d16c435d0 | ||
|
|
34b987398a |
@@ -96,22 +96,35 @@ async def dashboard_scope(
|
|||||||
|
|
||||||
|
|
||||||
def _what_filters(
|
def _what_filters(
|
||||||
category_id: uuid.UUID | None,
|
material_id: str | None,
|
||||||
material_id: uuid.UUID | None,
|
|
||||||
brand_id: uuid.UUID | None,
|
brand_id: uuid.UUID | None,
|
||||||
specs_hash: str | None,
|
specs_hash: str | None,
|
||||||
|
spec_id: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
"""دامنهی «چه چیزی» را به پارامترهای سرویس تبدیل میکند.
|
||||||
|
|
||||||
|
شناسهی ماده اگر UUID معتبر باشد → دامنهی همان ماده (همهی مشخصات آن).
|
||||||
|
اگر مقدار ارسالی UUID نباشد (یعنی شناسهی مشخصه فرستاده شده) یا spec_id
|
||||||
|
صریح بیاید → دامنه به همان مشخصه محدود میشود (specs_hash).
|
||||||
|
"""
|
||||||
|
resolved_material: uuid.UUID | None = None
|
||||||
|
resolved_specs = specs_hash or spec_id
|
||||||
|
if material_id:
|
||||||
|
try:
|
||||||
|
resolved_material = uuid.UUID(str(material_id))
|
||||||
|
except (ValueError, AttributeError, TypeError):
|
||||||
|
# شناسهی مشخصه (نه UUID ماده) — بهعنوان دامنهی مشخصه استفاده میشود
|
||||||
|
resolved_specs = resolved_specs or str(material_id)
|
||||||
return {
|
return {
|
||||||
"category_id": category_id,
|
"material_id": resolved_material,
|
||||||
"material_id": material_id,
|
|
||||||
"brand_id": brand_id,
|
"brand_id": brand_id,
|
||||||
"specs_hash": specs_hash,
|
"specs_hash": resolved_specs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@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,17 +132,17 @@ 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: str | None = Query(default=None, description="UUID ماده یا شناسهی مشخصه"),
|
||||||
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),
|
||||||
|
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||||
):
|
):
|
||||||
"""Drill-down matrix: کل سازمان → پروژه → انبار (per-unit aggregates)."""
|
"""Drill-down matrix: کل سازمان → پروژه → انبار (per-unit aggregates)."""
|
||||||
return await dashboard_service.inventory_drilldown(
|
return await dashboard_service.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, spec_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -139,10 +152,10 @@ 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: str | None = Query(default=None, description="UUID ماده یا شناسهی مشخصه"),
|
||||||
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),
|
||||||
|
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||||
):
|
):
|
||||||
"""Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازهی رکود."""
|
"""Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازهی رکود."""
|
||||||
return await dashboard_service.dead_stock(
|
return await dashboard_service.dead_stock(
|
||||||
@@ -151,7 +164,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, spec_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -161,11 +174,11 @@ async def inventory_flow(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
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, description="اختیاری — بدون آن همهی انبارها"),
|
||||||
category_id: uuid.UUID | None = Query(default=None),
|
material_id: str | None = Query(default=None, description="UUID ماده یا شناسهی مشخصه"),
|
||||||
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),
|
||||||
|
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||||
):
|
):
|
||||||
"""Daily flow series: ورود / خروج (با لایهی ضایعات) / برگشت / مانده."""
|
"""Daily flow series: ورود / خروج (با لایهی ضایعات) / برگشت / مانده."""
|
||||||
today = datetime.now(dashboard_service.TEHRAN).date()
|
today = datetime.now(dashboard_service.TEHRAN).date()
|
||||||
@@ -182,7 +195,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, spec_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -19,8 +19,9 @@ from datetime import datetime, time, timedelta, timezone
|
|||||||
|
|
||||||
from sqlalchemy import case, func, select
|
from sqlalchemy import case, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
from app.models.catalog import Brand, BrandMaterial, Material, MaterialCategory, Unit
|
from app.models.catalog import Brand, BrandMaterial, Material, 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,
|
||||||
@@ -43,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:
|
||||||
@@ -74,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()
|
||||||
@@ -148,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}}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -171,69 +152,164 @@ 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,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Stock quantity as a three-level tree; each aggregate keyed by unit code
|
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse.
|
||||||
so incompatible units never merge into one number."""
|
|
||||||
stmt = (
|
The x-axis mirrors the *catalog*, not the ledger: the statement starts from
|
||||||
|
``materials`` and LEFT OUTER JOINs the per-(brand, specs, project,
|
||||||
|
warehouse, unit) stock aggregation. Active catalog materials without any
|
||||||
|
ledger rows therefore stay on the chart with ``COALESCE``d zero quantities
|
||||||
|
(labelled with their canonical unit) instead of disappearing in an inner
|
||||||
|
join.
|
||||||
|
"""
|
||||||
|
# تجمیع دفتر انبار بهازای (ماده، برند، مشخصات، پروژه، انبار، واحد) — سمت
|
||||||
|
# راست LEFT JOIN؛ محدودیت دامنه (پروژههای مجاز) همینجا اعمال میشود تا
|
||||||
|
# مواد بدون موجودیِ داخل دامنه هم از دست نروند.
|
||||||
|
stock = (
|
||||||
select(
|
select(
|
||||||
Project.id,
|
InventoryItem.material_id.label("material_id"),
|
||||||
Project.name,
|
InventoryItem.brand_id.label("brand_id"),
|
||||||
Warehouse.id,
|
InventoryItem.specs_hash.label("specs_hash"),
|
||||||
Warehouse.name,
|
InventoryItem.specs.label("specs"),
|
||||||
Unit.code,
|
Project.id.label("pid"),
|
||||||
Unit.name_fa,
|
Project.name.label("pname"),
|
||||||
|
Warehouse.id.label("wid"),
|
||||||
|
Warehouse.name.label("wname"),
|
||||||
|
Unit.code.label("ucode"),
|
||||||
|
Unit.name_fa.label("uname_fa"),
|
||||||
func.sum(InventoryItem.quantity).label("qty"),
|
func.sum(InventoryItem.quantity).label("qty"),
|
||||||
func.count().label("rows"),
|
func.count().label("rows"),
|
||||||
)
|
)
|
||||||
.select_from(InventoryItem)
|
.select_from(InventoryItem)
|
||||||
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||||
.join(Project, Project.id == Warehouse.project_id)
|
.join(Project, Project.id == Warehouse.project_id)
|
||||||
.join(Material, Material.id == InventoryItem.material_id)
|
|
||||||
.join(Unit, Unit.id == InventoryItem.unit_id)
|
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||||
.where(InventoryItem.quantity > 0)
|
.group_by(
|
||||||
.group_by(Project.id, Project.name, Warehouse.id, Warehouse.name, Unit.code, Unit.name_fa)
|
InventoryItem.material_id,
|
||||||
.order_by(Project.name, Warehouse.name)
|
InventoryItem.brand_id,
|
||||||
|
InventoryItem.specs_hash,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Project.id,
|
||||||
|
Project.name,
|
||||||
|
Warehouse.id,
|
||||||
|
Warehouse.name,
|
||||||
|
Unit.code,
|
||||||
|
Unit.name_fa,
|
||||||
)
|
)
|
||||||
stmt = _stock_filters(
|
|
||||||
stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
|
|
||||||
)
|
)
|
||||||
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
|
stock = _scope_filters(stock, project_ids=project_ids, project_id=project_id)
|
||||||
|
stock_sq = stock.subquery()
|
||||||
|
|
||||||
|
# واحد کاتالوگی ماده — برچسب واحدِ ردیفهای صفر («۰ عدد»)
|
||||||
|
canon_unit = aliased(Unit)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
Material.id.label("mid"),
|
||||||
|
Material.name.label("mname"),
|
||||||
|
Brand.name.label("bname"),
|
||||||
|
stock_sq.c.specs,
|
||||||
|
stock_sq.c.pid,
|
||||||
|
stock_sq.c.pname,
|
||||||
|
stock_sq.c.wid,
|
||||||
|
stock_sq.c.wname,
|
||||||
|
stock_sq.c.ucode,
|
||||||
|
stock_sq.c.uname_fa,
|
||||||
|
func.coalesce(stock_sq.c.qty, 0).label("qty"),
|
||||||
|
func.coalesce(stock_sq.c.rows, 0).label("rows"),
|
||||||
|
canon_unit.code.label("canon_ucode"),
|
||||||
|
canon_unit.name_fa.label("canon_uname_fa"),
|
||||||
|
)
|
||||||
|
.select_from(Material)
|
||||||
|
.outerjoin(stock_sq, stock_sq.c.material_id == Material.id)
|
||||||
|
.outerjoin(Brand, Brand.id == stock_sq.c.brand_id)
|
||||||
|
.outerjoin(canon_unit, canon_unit.id == Material.canonical_unit_id)
|
||||||
|
.where(Material.status == "active")
|
||||||
|
)
|
||||||
|
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود میکنند
|
||||||
|
if material_id is not None:
|
||||||
|
stmt = stmt.where(Material.id == material_id)
|
||||||
|
# برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان
|
||||||
|
# موجودی روی محور میمانند (رفتار قبلی دستنخورده)
|
||||||
|
if brand_id is not None:
|
||||||
|
stmt = stmt.where(stock_sq.c.brand_id == brand_id)
|
||||||
|
if specs_hash:
|
||||||
|
stmt = stmt.where(stock_sq.c.specs_hash == specs_hash)
|
||||||
|
|
||||||
rows = (await db.execute(stmt)).all()
|
rows = (await db.execute(stmt)).all()
|
||||||
|
|
||||||
units_legend: dict[str, str] = {}
|
units_legend: dict[str, str] = {}
|
||||||
total_by_unit: dict[str, float] = {}
|
total_by_unit: dict[str, float] = {}
|
||||||
total_rows = 0
|
total_rows = 0
|
||||||
projects_tree: dict[uuid.UUID, dict] = {}
|
|
||||||
for pid, pname, wid, wname, ucode, uname_fa, qty, row_count in rows:
|
materials_tree: dict[str, dict] = {}
|
||||||
|
|
||||||
|
for mid, mname, bname, specs, pid, pname, wid, wname, ucode, uname_fa, qty, row_count, canon_ucode, canon_uname_fa in rows:
|
||||||
q = _qty(qty)
|
q = _qty(qty)
|
||||||
|
|
||||||
|
# افسانهی واحدها و جمع کل؛ مواد بدون ردیف موجودی با واحد کاتالوگی و صفر
|
||||||
|
if ucode is not None:
|
||||||
units_legend[ucode] = uname_fa
|
units_legend[ucode] = uname_fa
|
||||||
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
|
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
|
||||||
|
elif canon_ucode is not None:
|
||||||
|
units_legend.setdefault(canon_ucode, canon_uname_fa)
|
||||||
|
total_by_unit.setdefault(canon_ucode, 0.0)
|
||||||
total_rows += row_count
|
total_rows += row_count
|
||||||
proj = projects_tree.setdefault(
|
|
||||||
|
# Build full material name
|
||||||
|
parts = [mname]
|
||||||
|
if bname:
|
||||||
|
parts.append(bname)
|
||||||
|
if specs:
|
||||||
|
parts.extend(str(v) for v in specs.values() if v)
|
||||||
|
full_name = " ".join(parts)
|
||||||
|
# Use full_name + unit as unique key for material level, or just full_name
|
||||||
|
mat_key = f"{mid}_{full_name}"
|
||||||
|
|
||||||
|
mat = materials_tree.setdefault(
|
||||||
|
mat_key,
|
||||||
|
{"id": mat_key, "name": full_name, "qty_by_unit": {}, "rows": 0, "_projects": {}},
|
||||||
|
)
|
||||||
|
if ucode is not None:
|
||||||
|
mat["qty_by_unit"][ucode] = round(mat["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
||||||
|
elif canon_ucode is not None:
|
||||||
|
mat["qty_by_unit"].setdefault(canon_ucode, 0.0)
|
||||||
|
mat["rows"] += row_count
|
||||||
|
|
||||||
|
# مواد بدون هیچ ردیف موجودی: فقط روی محور میمانند (بدون پروژه/انبار)
|
||||||
|
if pid is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
proj = mat["_projects"].setdefault(
|
||||||
pid,
|
pid,
|
||||||
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}},
|
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}},
|
||||||
)
|
)
|
||||||
proj["qty_by_unit"][ucode] = round(proj["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
proj["qty_by_unit"][ucode] = round(proj["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
||||||
proj["rows"] += row_count
|
proj["rows"] += row_count
|
||||||
|
|
||||||
wh = proj["_warehouses"].setdefault(wid, {"id": str(wid), "name": wname, "qty_by_unit": {}, "rows": 0})
|
wh = proj["_warehouses"].setdefault(wid, {"id": str(wid), "name": wname, "qty_by_unit": {}, "rows": 0})
|
||||||
wh["qty_by_unit"][ucode] = round(wh["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
wh["qty_by_unit"][ucode] = round(wh["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
||||||
wh["rows"] += row_count
|
wh["rows"] += row_count
|
||||||
|
|
||||||
projects = []
|
materials = []
|
||||||
for proj in projects_tree.values():
|
for mat in materials_tree.values():
|
||||||
|
projects_list = []
|
||||||
|
for proj in mat.pop("_projects").values():
|
||||||
warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"])
|
warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"])
|
||||||
projects.append({**proj, "warehouses": warehouses})
|
projects_list.append({**proj, "warehouses": warehouses})
|
||||||
projects.sort(key=lambda p: p["name"])
|
projects_list.sort(key=lambda p: p["name"])
|
||||||
|
materials.append({**mat, "projects": projects_list})
|
||||||
|
|
||||||
|
materials.sort(key=lambda m: m["name"])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"data": {
|
"data": {
|
||||||
"units": units_legend,
|
"units": units_legend,
|
||||||
"total": {"qty_by_unit": total_by_unit, "rows": total_rows},
|
"total": {"qty_by_unit": total_by_unit, "rows": total_rows},
|
||||||
"projects": projects,
|
"materials": materials,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,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,
|
||||||
@@ -293,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()
|
||||||
@@ -328,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,
|
||||||
@@ -339,14 +413,24 @@ async def daily_flow(
|
|||||||
(DAMAGE/WASTE/LOSS, reported separately for the stacked red segment).
|
(DAMAGE/WASTE/LOSS, reported separately for the stacked red segment).
|
||||||
balance = running sum of EVERY signed movement (transfers/found/stock
|
balance = running sum of EVERY signed movement (transfers/found/stock
|
||||||
counts included), seeded with the opening balance before `date_from`.
|
counts included), seeded with the opening balance before `date_from`.
|
||||||
|
|
||||||
|
Each day also carries a `breakdown` list — the ledger rows behind that
|
||||||
|
day's aggregates, grouped by (category, transaction type, specs, brand,
|
||||||
|
warehouse) — so the chart can subdivide categories into tonal shades and
|
||||||
|
the tooltip can itemize them. When a single material is selected, the
|
||||||
|
payload also carries that material's canonical `unit`.
|
||||||
"""
|
"""
|
||||||
def _with_joins(sel):
|
def _with_joins(sel, *, include_brand: bool = False):
|
||||||
return (
|
# ترتیب joinها مهم است: برند فقط بعد از join شدن inventory قابل ارجاع است
|
||||||
|
stmt = (
|
||||||
sel.select_from(InventoryTransaction)
|
sel.select_from(InventoryTransaction)
|
||||||
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||||
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
|
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
|
||||||
.join(Material, Material.id == InventoryItem.material_id)
|
.join(Material, Material.id == InventoryItem.material_id)
|
||||||
)
|
)
|
||||||
|
if include_brand:
|
||||||
|
stmt = stmt.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
|
||||||
|
return stmt
|
||||||
|
|
||||||
scope_conditions = []
|
scope_conditions = []
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
@@ -355,7 +439,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:
|
||||||
@@ -363,18 +447,38 @@ async def daily_flow(
|
|||||||
if specs_hash:
|
if specs_hash:
|
||||||
what_conditions.append(InventoryItem.specs_hash == specs_hash)
|
what_conditions.append(InventoryItem.specs_hash == specs_hash)
|
||||||
|
|
||||||
day_expr = func.date_trunc("day", InventoryTransaction.transaction_date).label("day")
|
# «روز» بر مبنای تقویم تهران است؛ بدون این تبدیل، تراکنشهای بامداد
|
||||||
bucket_rows = (
|
# (۰۰:۰۰ تا ۰۳:۳۰ تهران) به روز قبلِ UTC میافتادند.
|
||||||
|
day_expr = func.date_trunc(
|
||||||
|
"day", func.timezone("Asia/Tehran", InventoryTransaction.transaction_date)
|
||||||
|
).label("day")
|
||||||
|
# ریزتراکنشها بهازای (نوع، مشخصات، برند، انبار) گروه میشوند تا هم تجمیع
|
||||||
|
# روزانه و هم شکست چندبعدی نمودار از یک کوئری واحد بیایند (بدون تکرار رکورد).
|
||||||
|
detail_rows = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
_with_joins(
|
_with_joins(
|
||||||
select(day_expr, InventoryTransaction.transaction_type, func.sum(InventoryTransaction.quantity))
|
select(
|
||||||
|
day_expr,
|
||||||
|
InventoryTransaction.transaction_type,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Brand.name,
|
||||||
|
Warehouse.name,
|
||||||
|
func.sum(InventoryTransaction.quantity),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
InventoryTransaction.transaction_date >= date_from,
|
InventoryTransaction.transaction_date >= date_from,
|
||||||
InventoryTransaction.transaction_date < date_to,
|
InventoryTransaction.transaction_date < date_to,
|
||||||
*scope_conditions,
|
*scope_conditions,
|
||||||
*what_conditions,
|
*what_conditions,
|
||||||
)
|
)
|
||||||
.group_by(day_expr, InventoryTransaction.transaction_type)
|
.group_by(
|
||||||
|
day_expr,
|
||||||
|
InventoryTransaction.transaction_type,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Brand.name,
|
||||||
|
Warehouse.name,
|
||||||
|
),
|
||||||
|
include_brand=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
@@ -391,10 +495,47 @@ async def daily_flow(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def category_of(tx_type: str) -> str:
|
||||||
|
if tx_type == "PURCHASE":
|
||||||
|
return "inflow"
|
||||||
|
if tx_type == "RETURN":
|
||||||
|
return "returns"
|
||||||
|
if tx_type == "WITHDRAWAL":
|
||||||
|
return "siteOut"
|
||||||
|
if tx_type in WASTE_TYPES:
|
||||||
|
return "waste"
|
||||||
|
# انتقالها/شمارش/یافتشده — خارج از چهار سبد نمایشی، علامتدار
|
||||||
|
return "other"
|
||||||
|
|
||||||
signed_by_day: dict[date_cls, dict[str, float]] = {}
|
signed_by_day: dict[date_cls, dict[str, float]] = {}
|
||||||
for day, tx_type, signed in bucket_rows:
|
breakdown_by_day: dict[date_cls, list[dict]] = {}
|
||||||
|
for day, tx_type, specs, brand_name, warehouse_name, signed in detail_rows:
|
||||||
day_key = day.date() if isinstance(day, datetime) else day
|
day_key = day.date() if isinstance(day, datetime) else day
|
||||||
signed_by_day.setdefault(day_key, {})[str(tx_type)] = _qty(signed)
|
tx_type = str(tx_type)
|
||||||
|
qty = _qty(signed)
|
||||||
|
day_types = signed_by_day.setdefault(day_key, {})
|
||||||
|
day_types[tx_type] = round(day_types.get(tx_type, 0.0) + qty, 4)
|
||||||
|
breakdown_by_day.setdefault(day_key, []).append({
|
||||||
|
"category": category_of(tx_type),
|
||||||
|
"transaction_type": tx_type,
|
||||||
|
"specs": specs or None,
|
||||||
|
"brand": brand_name or None,
|
||||||
|
"warehouse": warehouse_name,
|
||||||
|
"quantity": qty,
|
||||||
|
})
|
||||||
|
|
||||||
|
unit = None
|
||||||
|
if material_id is not None:
|
||||||
|
unit_row = (
|
||||||
|
await db.execute(
|
||||||
|
select(Unit.code, Unit.name_fa)
|
||||||
|
.select_from(Material)
|
||||||
|
.join(Unit, Unit.id == Material.canonical_unit_id)
|
||||||
|
.where(Material.id == material_id)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if unit_row is not None:
|
||||||
|
unit = {"code": unit_row[0], "name_fa": unit_row[1]}
|
||||||
|
|
||||||
days = []
|
days = []
|
||||||
running = opening
|
running = opening
|
||||||
@@ -411,10 +552,11 @@ async def daily_flow(
|
|||||||
"waste": waste,
|
"waste": waste,
|
||||||
"returns": round(types.get("RETURN", 0.0), 4) or 0.0,
|
"returns": round(types.get("RETURN", 0.0), 4) or 0.0,
|
||||||
"balance": running,
|
"balance": running,
|
||||||
|
"breakdown": breakdown_by_day.get(current, []),
|
||||||
})
|
})
|
||||||
current += timedelta(days=1)
|
current += timedelta(days=1)
|
||||||
|
|
||||||
return {"data": {"opening_balance": opening, "days": days}}
|
return {"data": {"opening_balance": opening, "unit": unit, "days": days}}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export interface ComboboxProps {
|
|||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (item: ComboboxItem) => void;
|
onChange?: (item: ComboboxItem) => void;
|
||||||
items: ComboboxSource; // آرایه از string یا {id, name}
|
items: ComboboxSource; // آرایه از string یا {id, name}
|
||||||
|
// شناسهی آیتمهایی که همیشه در ابتدای فهرست میمانند و با جستجوی کاربر
|
||||||
|
// فیلتر نمیشوند (مثل گزینهی «همهی …» در فیلترهای آبشاری داشبورد)
|
||||||
|
pinnedIds?: string[];
|
||||||
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
||||||
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
|
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
|
||||||
addSuffix?: string; // متن بعد از +
|
addSuffix?: string; // متن بعد از +
|
||||||
@@ -50,6 +53,7 @@ export function Combobox({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
items,
|
items,
|
||||||
|
pinnedIds,
|
||||||
allowAdd = true,
|
allowAdd = true,
|
||||||
addPrefix,
|
addPrefix,
|
||||||
addSuffix = "به عنوان گزینه جدید",
|
addSuffix = "به عنوان گزینه جدید",
|
||||||
@@ -88,21 +92,37 @@ export function Combobox({
|
|||||||
return all.filter((x) => normalize(x.label).includes(q));
|
return all.filter((x) => normalize(x.label).includes(q));
|
||||||
}, [all, q]);
|
}, [all, q]);
|
||||||
|
|
||||||
|
// آیتمهای pinشده همیشه در ابتدای فهرست میمانند و با جستجو حذف نمیشوند؛
|
||||||
|
// ناوبری با کلیدها هم روی همین فهرست نهایی (visible) انجام میشود.
|
||||||
|
const { visible, pinnedCount, hasUnpinned } = useMemo(() => {
|
||||||
|
const pin = pinnedIds ?? [];
|
||||||
|
if (pin.length === 0) return { visible: filtered, pinnedCount: 0, hasUnpinned: false };
|
||||||
|
const pinned = pin
|
||||||
|
.map((id) => all.find((x) => x.id === id))
|
||||||
|
.filter((x): x is ComboboxItem => x !== undefined);
|
||||||
|
const pinnedSet = new Set(pinned.map((x) => x.id));
|
||||||
|
const rest = filtered.filter((x) => !pinnedSet.has(x.id));
|
||||||
|
return { visible: [...pinned, ...rest], pinnedCount: pinned.length, hasUnpinned: rest.length > 0 };
|
||||||
|
}, [all, filtered, pinnedIds]);
|
||||||
|
|
||||||
const showAdd =
|
const showAdd =
|
||||||
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q);
|
allowAdd && q.length > 0 && !visible.some((x) => normalize(x.label) === q);
|
||||||
|
|
||||||
const showClear = Boolean(onClear) && !disabled && text.length > 0;
|
const showClear = Boolean(onClear) && !disabled && text.length > 0;
|
||||||
|
|
||||||
const listCount = filtered.length + (showAdd ? 1 : 0);
|
const listCount = visible.length + (showAdd ? 1 : 0);
|
||||||
|
|
||||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||||
|
|
||||||
const pick = (it: ComboboxItem) => {
|
const pick = (it: ComboboxItem) => {
|
||||||
setText(it.label);
|
// آیتم با شناسهی خالی یعنی «انتخاب نشده» (گزینهی «همهی …»)؛ فیلد به
|
||||||
|
// placeholder برمیگردد تا متن/دکمهی × برای مقدار پاکشده باقی نماند.
|
||||||
|
const nextText = it.id ? it.label : "";
|
||||||
|
setText(nextText);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setHighlight(-1);
|
setHighlight(-1);
|
||||||
onChange?.(it);
|
onChange?.(it);
|
||||||
onInputChange?.(it.label);
|
onInputChange?.(nextText);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addCurrent = () => {
|
const addCurrent = () => {
|
||||||
@@ -127,9 +147,9 @@ export function Combobox({
|
|||||||
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
|
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (highlight >= 0 && highlight < filtered.length) {
|
if (highlight >= 0 && highlight < visible.length) {
|
||||||
pick(filtered[highlight]);
|
pick(visible[highlight]);
|
||||||
} else if (highlight === filtered.length && showAdd) {
|
} else if (highlight === visible.length && showAdd) {
|
||||||
addCurrent();
|
addCurrent();
|
||||||
}
|
}
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
@@ -197,13 +217,14 @@ export function Combobox({
|
|||||||
{listCount === 0 && (
|
{listCount === 0 && (
|
||||||
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
|
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
|
||||||
)}
|
)}
|
||||||
{filtered.map((it, i) => (
|
{visible.map((it, i) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={it.id + "|" + i}
|
key={it.id + "|" + i}
|
||||||
className={cn(
|
className={cn(
|
||||||
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
|
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
|
||||||
i === highlight && "bg-slate-bg",
|
i === highlight && "bg-slate-bg",
|
||||||
|
pinnedCount > 0 && hasUnpinned && i === pinnedCount - 1 && "border-b border-line",
|
||||||
)}
|
)}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault(); // حفظ فوکوس input
|
e.preventDefault(); // حفظ فوکوس input
|
||||||
|
|||||||
@@ -1,49 +1,96 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// فیلتر سراسری «چه چیزی» — آبشار: دستهبندی → متریال → برند → مشخصات
|
// فیلتر سراسری «چه چیزی» — آبشار: متریال → برند → مشخصات فنی
|
||||||
// داده از /dashboard/filters میآید (بدون هیچ لیست ساختگی) و انتخاب
|
// داده از /dashboard/filters میآید (بدون هیچ لیست ساختگی) و انتخاب
|
||||||
// هر سطح، سطحهای پاییندست را بازنشانی میکند:
|
// هر سطح، سطحهای پاییندست را بازنشانی میکند:
|
||||||
// دسته → متریالهای همان دسته
|
|
||||||
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
|
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
|
||||||
// متریال → مشخصات فنی موجودِ همان متریال
|
// متریال → مشخصات فنی موجودِ همان متریال
|
||||||
// هر فیلد دکمهی «×» دارد که فقط همان سطح (و زیرمجموعهاش) را پاک میکند.
|
// هر فیلد «×» مخصوص خودش را دارد و گزینهی «همهی …» در ابتدای هر فهرست
|
||||||
|
// همان سطح (و سطوح پاییندست) را پاک میکند؛ دکمهی «حذف فیلترها» کل استور را
|
||||||
|
// یکجا به نمای «کل سازمان» برمیگرداند.
|
||||||
|
// سه فیلتر در یک ردیفِ وسطچین با عرض یکسان مینشینند (موبایل: تکستونی).
|
||||||
// وضعیت در Zustand نگه داشته میشود تا با جابهجایی بین تبها از دست نرود.
|
// وضعیت در Zustand نگه داشته میشود تا با جابهجایی بین تبها از دست نرود.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Filter } from "lucide-react";
|
import { Filter, X } from "lucide-react";
|
||||||
import { Combobox } from "../Combobox";
|
import { Combobox, type ComboboxItem } from "../Combobox";
|
||||||
import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
|
import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
|
||||||
import { useDashboardFilter } from "../../store/dashboardFilter";
|
import { useDashboardFilter } from "../../store/dashboardFilter";
|
||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
// گزینهی «همه» — انتخابی که همان سطح و سطوح پاییندستش را پاک میکند.
|
||||||
|
// شناسهی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox) و هرگز در استور ذخیره نمیشود.
|
||||||
|
const ALL_OPTION_ID = "";
|
||||||
|
// این شناسه همیشه در ابتدای فهرست میماند و با جستجوی کاربر فیلتر نمیشود
|
||||||
|
const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID];
|
||||||
|
|
||||||
|
// برچسبهای ثابت UI — placeholder هر فیلد و گزینهی «همه»اش یک متن مشترک دارند
|
||||||
|
const LABELS = {
|
||||||
|
allMaterials: "همهی متریالها",
|
||||||
|
allBrands: "همهی برندها",
|
||||||
|
allSpecs: "همهی مشخصات",
|
||||||
|
materialFirst: "ابتدا متریال را انتخاب کنید",
|
||||||
|
clearAll: "حذف فیلترها",
|
||||||
|
wholeOrg: "نمای کل سازمان",
|
||||||
|
cascade: "متریال ← برند ← مشخصات فنی",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label });
|
||||||
|
|
||||||
|
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیشفرض)»
|
||||||
|
function specLabel(specs: Record<string, string> | null): string {
|
||||||
|
const parts = Object.entries(specs || {});
|
||||||
|
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیشفرض)";
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardFilterBar({ className }: { className?: string }) {
|
export function DashboardFilterBar({ className }: { className?: string }) {
|
||||||
const filterQuery = useDashboardFilterOptions();
|
const filterQuery = useDashboardFilterOptions();
|
||||||
const state = useDashboardFilter();
|
const state = useDashboardFilter();
|
||||||
|
|
||||||
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 specLabel = (specs: Record<string, string> | null) => {
|
const pick =
|
||||||
const parts = Object.entries(specs || {});
|
(set: (id: string | null, label: string | null) => void) =>
|
||||||
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیشفرض)";
|
(item: ComboboxItem) =>
|
||||||
};
|
item.id === ALL_OPTION_ID ? set(null, null) : set(item.id, item.label);
|
||||||
|
|
||||||
|
// فهرست هر فیلد با گزینهی «همه» در ابتدا
|
||||||
|
const materialItems = useMemo<ComboboxItem[]>(
|
||||||
|
() => [allOption(LABELS.allMaterials), ...materials.map((m) => ({ id: m.id, label: m.name }))],
|
||||||
|
[materials],
|
||||||
|
);
|
||||||
|
|
||||||
|
const brandItems = useMemo<ComboboxItem[]>(
|
||||||
|
() => [
|
||||||
|
allOption(LABELS.allBrands),
|
||||||
|
...(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name })),
|
||||||
|
],
|
||||||
|
[selectedMaterial],
|
||||||
|
);
|
||||||
|
|
||||||
|
const specItems = useMemo<ComboboxItem[]>(
|
||||||
|
() => [
|
||||||
|
allOption(LABELS.allSpecs),
|
||||||
|
...(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) })),
|
||||||
|
],
|
||||||
|
[selectedMaterial],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -56,61 +103,61 @@ 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
|
||||||
type="button"
|
type="button"
|
||||||
onClick={state.reset}
|
onClick={state.reset}
|
||||||
className="ms-auto rounded-full bg-red-bg px-2.5 py-1 text-[11px] font-bold text-red transition-opacity hover:opacity-80"
|
aria-label={`${LABELS.clearAll} (بازگشت به ${LABELS.wholeOrg})`}
|
||||||
|
title={`${LABELS.clearAll} — ${LABELS.wholeOrg}`}
|
||||||
|
className="group ms-auto inline-flex items-center gap-1.5 rounded-full border border-red/20 bg-red-bg px-2.5 py-1.5 text-[11px] font-bold text-red shadow-sm transition-all hover:border-red/40 hover:shadow active:scale-[0.98]"
|
||||||
>
|
>
|
||||||
حذف فیلترها ({faNum(activeCount)})
|
<X aria-hidden className="h-3.5 w-3.5 transition-transform group-hover:rotate-90" />
|
||||||
|
{LABELS.clearAll}
|
||||||
|
<span className="rounded-full bg-panel/60 px-1.5 leading-4 tabular-nums">
|
||||||
|
{faNum(activeCount)}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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="همهی دستهها"
|
|
||||||
allowAdd={false}
|
|
||||||
items={categories.filter((c) => c.id).map((c) => ({ id: c.id as string, label: c.name }))}
|
|
||||||
onClear={() => state.setCategory(null, null)}
|
|
||||||
onChange={(item) => state.setCategory(item.id, item.label)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ۲ — متریال (وابسته به دستهی انتخابشده) */}
|
|
||||||
<Combobox
|
<Combobox
|
||||||
value={state.materialLabel ?? ""}
|
value={state.materialLabel ?? ""}
|
||||||
placeholder="همهی متریالها"
|
placeholder={LABELS.allMaterials}
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
disabled={materialOptions.length === 0}
|
disabled={materials.length === 0}
|
||||||
items={materialOptions.map((m) => ({ id: m.id, label: m.name }))}
|
items={materialItems}
|
||||||
|
pinnedIds={ALL_PINNED_IDS}
|
||||||
onClear={() => state.setMaterial(null, null)}
|
onClear={() => state.setMaterial(null, null)}
|
||||||
onChange={(item) => state.setMaterial(item.id, item.label)}
|
onChange={pick(state.setMaterial)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ۳ — برند (فقط برندهای همان متریال) */}
|
{/* ۲ — برند (فقط برندهای همان متریال) */}
|
||||||
<Combobox
|
<Combobox
|
||||||
value={state.brandLabel ?? ""}
|
value={state.brandLabel ?? ""}
|
||||||
placeholder={selectedMaterial ? "همهی برندها" : "ابتدا متریال را انتخاب کنید"}
|
placeholder={selectedMaterial ? LABELS.allBrands : LABELS.materialFirst}
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
|
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
|
||||||
items={(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name }))}
|
items={brandItems}
|
||||||
|
pinnedIds={ALL_PINNED_IDS}
|
||||||
onClear={() => state.setBrand(null, null)}
|
onClear={() => state.setBrand(null, null)}
|
||||||
onChange={(item) => state.setBrand(item.id, item.label)}
|
onChange={pick(state.setBrand)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ۴ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
|
{/* ۳ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
|
||||||
<Combobox
|
<Combobox
|
||||||
value={state.specsLabel ?? ""}
|
value={state.specsLabel ?? ""}
|
||||||
placeholder={selectedMaterial ? "همهی مشخصات" : "ابتدا متریال را انتخاب کنید"}
|
placeholder={selectedMaterial ? LABELS.allSpecs : LABELS.materialFirst}
|
||||||
allowAdd={false}
|
allowAdd={false}
|
||||||
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
|
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
|
||||||
items={(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) }))}
|
items={specItems}
|
||||||
|
pinnedIds={ALL_PINNED_IDS}
|
||||||
onClear={() => state.setSpecs(null, null)}
|
onClear={() => state.setSpecs(null, null)}
|
||||||
onChange={(item) => state.setSpecs(item.id, item.label)}
|
onChange={pick(state.setSpecs)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,16 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// ویجت ۳ — سری زمانی جریان روزانه
|
// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور، تکمتریالی)
|
||||||
// ستونهای روزانه: ورود (سبز) / برگشت (کهربایی) / خروج که لایهی
|
// هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز»
|
||||||
// قرمزِ «ضایعات، اتلاف، مفقودی» داخل همان ستون خروج سته —
|
// کشیده میشود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
|
||||||
// بهعلاوهی خط «مانده» (بالانس تجمعی). تاریخها شمسی، اعداد فارسی.
|
// روی مسیر واقعی مانده مینشینند، پس ستون دقیقاً پلهی خط stepAfter را میسازد.
|
||||||
|
//
|
||||||
|
// قواعد کسبوکار:
|
||||||
|
// • جمعزدن چند متریال با واحدهای متفاوت (متر + کیلوگرم + عدد) بیمعناست؛ پس
|
||||||
|
// بدون انتخاب متریال، نمودار رندر نمیشود و بهجایش انتخاب سریع متریال میآید.
|
||||||
|
// • هر دسته (ورود/برگشت/خروج/ضایعات/سایر) میتواند چند «گروه» (مشخصات + برند)
|
||||||
|
// داشته باشد؛ هر گروه یک پلهی روشنتر از خانوادهی رنگ همان دسته میگیرد و
|
||||||
|
// سگمنتها یک ستون مشترک با جداکنندهی سفید ۱px میسازند.
|
||||||
|
// • همهی موجودیتها (متریال/مشخصات/برند/انبار/واحد) از payload مشتق میشوند.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -10,66 +18,534 @@ import {
|
|||||||
Bar,
|
Bar,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
Legend,
|
|
||||||
Line,
|
Line,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useDailyFlow } from "../../hooks/useDashboardData";
|
import { Package } from "lucide-react";
|
||||||
|
import { useDailyFlow, useDashboardFilterOptions } from "../../hooks/useDashboardData";
|
||||||
|
import { useDashboardFilter } from "../../store/dashboardFilter";
|
||||||
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,
|
import type { FlowBreakdownItem, FlowCategory, FlowDay } from "../../lib/types";
|
||||||
ChartEmpty,
|
|
||||||
ChartLoading,
|
|
||||||
ChartTooltip,
|
|
||||||
faAxisTick,
|
|
||||||
} from "./chartShared";
|
|
||||||
|
|
||||||
interface Row {
|
// ——— چیدمان ———
|
||||||
|
const FLOW_STACK_ID = "flow-waterfall";
|
||||||
|
const MAX_BAR_SIZE = 20;
|
||||||
|
const BAR_CATEGORY_GAP = "18%";
|
||||||
|
const DOMAIN_PAD_RATIO = 0.1; // ۱۰٪ سرصفحه بالا/پایین دامنه
|
||||||
|
const OTHER_RESIDUAL_KEY = "__adjust__";
|
||||||
|
|
||||||
|
const CATEGORY_ORDER: FlowCategory[] = ["inflow", "returns", "siteOut", "waste", "other"];
|
||||||
|
|
||||||
|
// خانوادهی رنگی هر دسته (HSL پایه) + جهت علامت در مسیر مانده
|
||||||
|
const CATEGORY_META: Record<
|
||||||
|
FlowCategory,
|
||||||
|
{ label: string; sign: 1 | -1; base: { h: number; s: number; l: number } }
|
||||||
|
> = {
|
||||||
|
inflow: { label: "ورود به انبار", sign: 1, base: { h: 160, s: 84, l: 39 } }, // زمردی
|
||||||
|
returns: { label: "برگشت از سایت", sign: 1, base: { h: 175, s: 77, l: 41 } }, // تیل
|
||||||
|
siteOut: { label: "خروج به سایت", sign: -1, base: { h: 35, s: 92, l: 47 } }, // کهربایی
|
||||||
|
waste: { label: "ضایعات و اتلاف", sign: -1, base: { h: 0, s: 72, l: 51 } }, // قرمز
|
||||||
|
other: { label: "سایر حرکات", sign: 1, base: { h: 215, s: 16, l: 55 } }, // خاکستری
|
||||||
|
};
|
||||||
|
|
||||||
|
// تنالیتهی گروههای یک دسته: هر گروه (مشخصات+برند) یک پله روشنتر
|
||||||
|
const SHADE_STEP_L = 9;
|
||||||
|
const SHADE_MAX_L = 78;
|
||||||
|
function categoryColor(category: FlowCategory, shadeIndex: number): string {
|
||||||
|
const { h, s, l } = CATEGORY_META[category].base;
|
||||||
|
const lightness = Math.min(SHADE_MAX_L, l + shadeIndex * SHADE_STEP_L);
|
||||||
|
return `hsl(${h} ${s}% ${lightness}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABELS = {
|
||||||
|
title: "جریان روزانه موجودی",
|
||||||
|
needMaterial:
|
||||||
|
"برای مشاهدهی جریان روزانه و تحلیل دقیق تغییرات موجودی، لطفاً یک متریال را از فیلترهای بالا انتخاب کنید.",
|
||||||
|
quickPick: "انتخاب سریع متریال",
|
||||||
|
empty: "در این بازه جابهجایی موجودی ثبت نشده است",
|
||||||
|
opening: "مانده ابتدای روز",
|
||||||
|
net: "تغییر خالص روز",
|
||||||
|
closing: "مانده پایان روز",
|
||||||
|
unit: "واحد",
|
||||||
|
brand: "برند",
|
||||||
|
warehouse: "انبار",
|
||||||
|
noSpecs: "بدون مشخصه",
|
||||||
|
aggregate: "کل دسته",
|
||||||
|
adjustment: "تعدیل حساب",
|
||||||
|
shadeHint: "طیف روشنتر هر رنگ = برند/مشخصات دیگر در همان روز",
|
||||||
|
error: "دریافت دادهی جریان روزانه ناموفق بود",
|
||||||
|
errorHint: "اتصال به سرور را بررسی کنید؛ اگر ادامه داشت با مدیر سیستم تماس بگیرید.",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ——— مدل داده ———
|
||||||
|
type Range = [number, number]; // [پایین، بالا] — سگمنت شناور <Bar>
|
||||||
|
|
||||||
|
interface CatItem {
|
||||||
|
key: string; // هویت گروه: مشخصات|برند
|
||||||
|
specLabel: string;
|
||||||
|
brand: string | null;
|
||||||
|
warehouse: string;
|
||||||
|
quantity: number; // چهار دسته: قدر مطلق — «سایر»: علامتدار
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatGroup {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
brand: string | null;
|
||||||
|
quantity: number; // مجموع گروه (برای «سایر» علامتدار)
|
||||||
|
items: CatItem[]; // ریزردیفها برای تولتیپ
|
||||||
|
}
|
||||||
|
|
||||||
|
type CatMap = Record<FlowCategory, CatGroup[]>;
|
||||||
|
|
||||||
|
interface FlowRow {
|
||||||
date: string;
|
date: string;
|
||||||
ورود: number;
|
opening: number;
|
||||||
برگشت: number;
|
closing: number;
|
||||||
"خروج سایت": number;
|
net: number;
|
||||||
"ضایعات و اتلاف": number;
|
balance: number;
|
||||||
مانده: number;
|
groups: CatMap; // متادیتای تولتیپ (dataKey نیست)
|
||||||
_total: number;
|
hasMovement: boolean;
|
||||||
|
// سگمنتها با کلیدهای مسطح seg_<category>_<shadeIndex>
|
||||||
|
[segmentKey: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const round4 = (v: number) => Math.round(v * 10000) / 10000;
|
||||||
|
const signed = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`;
|
||||||
|
|
||||||
|
// خطای شبکه/سرور باید صریح دیده شود — هرگز نباید «دادهای ثبت نشده» تلقّی شود
|
||||||
|
function FlowError() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="flex h-40 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-red/30 bg-red-bg/40 px-4 text-center"
|
||||||
|
>
|
||||||
|
<span className="text-[12px] font-bold text-red">{LABELS.error}</span>
|
||||||
|
<span className="max-w-md text-[11px] leading-5 text-muted">{LABELS.errorHint}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// برچسب مشخصات فنی: «کلید: مقدار، …» یا «بدون مشخصه»
|
||||||
|
function specLabel(specs: Record<string, string> | null): string {
|
||||||
|
const parts = Object.entries(specs ?? {});
|
||||||
|
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : LABELS.noSpecs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— تولتیپ تحلیلی RTL ———
|
||||||
|
function FlowTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
unitLabel,
|
||||||
|
materialName,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: readonly { payload?: FlowRow }[];
|
||||||
|
unitLabel: string;
|
||||||
|
materialName: string;
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const row = payload[0]?.payload;
|
||||||
|
if (!row) return null;
|
||||||
|
const withUnit = (value: string) => (unitLabel ? `${value} ${unitLabel}` : value);
|
||||||
|
const netTone =
|
||||||
|
row.net > 0 ? "text-emerald-600" : row.net < 0 ? "text-red-600" : "text-slate-600";
|
||||||
|
return (
|
||||||
|
<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-[380px] sm:whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{/* سربرگ: تاریخ + متریال + نشان واحد */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs font-bold text-slate-800">
|
||||||
|
{isoToJalaliStr(row.date) ?? row.date}
|
||||||
|
</span>
|
||||||
|
{materialName && <span className="text-[11px] text-slate-500">{materialName}</span>}
|
||||||
|
{unitLabel && (
|
||||||
|
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold text-slate-500">
|
||||||
|
{unitLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-2 border-b border-slate-100" />
|
||||||
|
|
||||||
|
{/* مانده ابتدای روز */}
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="text-[11px] text-slate-500 sm:text-xs">{LABELS.opening}</span>
|
||||||
|
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
|
||||||
|
{withUnit(fmtNum(row.opening))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-2 border-b border-slate-100" />
|
||||||
|
|
||||||
|
{/* دستهها + ریزردیف مشخصات/برند/انبار */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{CATEGORY_ORDER.map((category) => {
|
||||||
|
const groups = row.groups[category] ?? [];
|
||||||
|
if (groups.length === 0) return null;
|
||||||
|
const total = round4(
|
||||||
|
groups.reduce((sum, group) => sum + group.quantity, 0) *
|
||||||
|
CATEGORY_META[category].sign,
|
||||||
|
);
|
||||||
|
if (total === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={category} className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="flex items-center gap-2 text-[11px] font-semibold text-slate-700 sm:text-xs">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
|
style={{ background: categoryColor(category, 0) }}
|
||||||
|
/>
|
||||||
|
{CATEGORY_META[category].label}
|
||||||
|
</span>
|
||||||
|
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
|
||||||
|
{withUnit(signed(total))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.key} className="flex flex-col">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`${group.key}|${item.warehouse}|${item.specLabel}`}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-0.5 border-s border-slate-100 ps-3 text-[10.5px] text-slate-500 sm:text-[11px]"
|
||||||
|
>
|
||||||
|
<span className="flex flex-wrap items-center gap-1">
|
||||||
|
<span aria-hidden>↳</span>
|
||||||
|
<span>{item.specLabel}</span>
|
||||||
|
{item.brand && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-300">|</span>
|
||||||
|
<span>
|
||||||
|
{LABELS.brand}: {item.brand}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.warehouse && (
|
||||||
|
<span className="text-slate-400">
|
||||||
|
({LABELS.warehouse}: {item.warehouse})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span dir="ltr" className="font-medium tabular-nums text-slate-600">
|
||||||
|
{withUnit(signed(round4(item.quantity * CATEGORY_META[category].sign)))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-2 border-b border-slate-100" />
|
||||||
|
|
||||||
|
{/* جمعبندی */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="text-[11px] font-semibold text-slate-600 sm:text-xs">{LABELS.net}</span>
|
||||||
|
<span dir="ltr" className={`text-[11px] font-bold tabular-nums sm:text-xs ${netTone}`}>
|
||||||
|
{withUnit(signed(row.net))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="text-[11px] font-semibold text-slate-600 sm:text-xs">{LABELS.closing}</span>
|
||||||
|
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
|
||||||
|
{withUnit(fmtNum(row.closing))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— نرمالسازی یک روز: گروهبندی ریزردیفها بر اساس (دسته، مشخصات+برند) ———
|
||||||
|
function dayGroups(day: FlowDay): CatMap {
|
||||||
|
const groups: CatMap = { inflow: [], returns: [], siteOut: [], waste: [], other: [] };
|
||||||
|
const insert = (category: FlowCategory, item: CatItem) => {
|
||||||
|
const existing = groups[category].find((group) => group.key === item.key);
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity = round4(existing.quantity + item.quantity);
|
||||||
|
existing.items.push(item);
|
||||||
|
} else {
|
||||||
|
groups[category].push({
|
||||||
|
key: item.key,
|
||||||
|
label: item.specLabel,
|
||||||
|
brand: item.brand,
|
||||||
|
quantity: item.quantity,
|
||||||
|
items: [item],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const breakdown: FlowBreakdownItem[] = day.breakdown ?? [];
|
||||||
|
if (breakdown.length > 0) {
|
||||||
|
breakdown.forEach((item) => {
|
||||||
|
// چهار سبد نمایشی همیشه مثبت نشان داده میشوند؛ «سایر» علامت دفتر را نگه میدارد
|
||||||
|
const quantity = item.category === "other" ? item.quantity : Math.abs(item.quantity);
|
||||||
|
if (quantity === 0) return;
|
||||||
|
insert(item.category, {
|
||||||
|
key: `${JSON.stringify(item.specs ?? {})}|${item.brand ?? ""}`,
|
||||||
|
specLabel: specLabel(item.specs),
|
||||||
|
brand: item.brand,
|
||||||
|
warehouse: item.warehouse,
|
||||||
|
quantity: round4(quantity),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// سازگاری عقبرو: اگر شکست چندبعدی در payload نباشد، یک گروهِ کلِ دسته ساخته میشود
|
||||||
|
const aggregates: [FlowCategory, number][] = [
|
||||||
|
["inflow", day.inbound],
|
||||||
|
["returns", day.returns],
|
||||||
|
["siteOut", Math.max(0, day.outbound - day.waste)],
|
||||||
|
["waste", day.waste],
|
||||||
|
];
|
||||||
|
aggregates.forEach(([category, quantity]) => {
|
||||||
|
if (quantity > 0) {
|
||||||
|
insert(category, {
|
||||||
|
key: `${category}-all`,
|
||||||
|
specLabel: LABELS.aggregate,
|
||||||
|
brand: null,
|
||||||
|
warehouse: "",
|
||||||
|
quantity: round4(quantity),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FlowChart() {
|
export function FlowChart() {
|
||||||
const query = useDailyFlow();
|
const filter = useDashboardFilter();
|
||||||
|
// بدون متریال، کوئری اصلاً اجرا نمیشود (جلوگیری از تجمیع واحدهای ناسازگار)
|
||||||
|
const query = useDailyFlow(undefined, undefined, Boolean(filter.materialId));
|
||||||
|
const options = useDashboardFilterOptions();
|
||||||
|
|
||||||
const rows: Row[] = useMemo(
|
const materials = options.data?.materials ?? [];
|
||||||
() =>
|
const selectedMaterial = materials.find((m) => m.id === filter.materialId) ?? null;
|
||||||
(query.data?.days ?? []).map((d) => ({
|
const materialName = selectedMaterial?.name ?? filter.materialLabel ?? "";
|
||||||
date: d.date,
|
const unitLabel =
|
||||||
ورود: d.inbound,
|
query.data?.unit?.name_fa ??
|
||||||
برگشت: d.returns,
|
query.data?.unit?.code ??
|
||||||
"خروج سایت": Math.max(0, d.outbound - d.waste),
|
selectedMaterial?.unit?.name_fa ??
|
||||||
"ضایعات و اتلاف": d.waste,
|
selectedMaterial?.unit?.code ??
|
||||||
مانده: d.balance,
|
"";
|
||||||
_total: d.inbound + d.outbound + d.returns,
|
|
||||||
})),
|
// ——— همسازماندهی روزها + سگمنتهای شناور ———
|
||||||
[query.data],
|
const { rows, groupCounts, hasMovement } = useMemo(() => {
|
||||||
|
const days = query.data?.days ?? [];
|
||||||
|
let opening = query.data?.opening_balance ?? 0;
|
||||||
|
|
||||||
|
// گذر اول: نرمالسازی + تعدیل دقیق حساب (ستون باید همیشه روی خط بنشیند)
|
||||||
|
const normalized = days.map((day) => {
|
||||||
|
const closing = day.balance;
|
||||||
|
const net = round4(closing - opening);
|
||||||
|
const groups = dayGroups(day);
|
||||||
|
const categorized = round4(
|
||||||
|
CATEGORY_ORDER.reduce((sum, category) => {
|
||||||
|
const categoryTotal = groups[category].reduce(
|
||||||
|
(groupSum, group) => groupSum + group.quantity,
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
|
return sum + categoryTotal * CATEGORY_META[category].sign;
|
||||||
|
}, 0),
|
||||||
|
);
|
||||||
|
const residual = round4(net - categorized);
|
||||||
|
if (residual !== 0) {
|
||||||
|
groups.other.push({
|
||||||
|
key: OTHER_RESIDUAL_KEY,
|
||||||
|
label: LABELS.adjustment,
|
||||||
|
brand: null,
|
||||||
|
quantity: residual,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const normalizedDay = { day, opening, closing, net, groups };
|
||||||
|
opening = closing;
|
||||||
|
return normalizedDay;
|
||||||
|
});
|
||||||
|
|
||||||
const hasAnyData = rows.some((r) => r._total > 0);
|
// ترتیب گروهها (و در نتیجه پلهی رنگ) در کل بازه ثابت میماند
|
||||||
|
const totalByCategory = new Map<FlowCategory, Map<string, number>>();
|
||||||
|
normalized.forEach(({ groups }) => {
|
||||||
|
CATEGORY_ORDER.forEach((category) => {
|
||||||
|
const perCategory = totalByCategory.get(category) ?? new Map<string, number>();
|
||||||
|
groups[category].forEach((group) => {
|
||||||
|
perCategory.set(group.key, round4((perCategory.get(group.key) ?? 0) + Math.abs(group.quantity)));
|
||||||
|
});
|
||||||
|
totalByCategory.set(category, perCategory);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const orderByCategory = new Map<FlowCategory, string[]>();
|
||||||
|
const groupCounts: Record<FlowCategory, number> = {
|
||||||
|
inflow: 0,
|
||||||
|
returns: 0,
|
||||||
|
siteOut: 0,
|
||||||
|
waste: 0,
|
||||||
|
other: 0,
|
||||||
|
};
|
||||||
|
CATEGORY_ORDER.forEach((category) => {
|
||||||
|
const orderedKeys = [...(totalByCategory.get(category) ?? new Map<string, number>()).entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([key]) => key);
|
||||||
|
orderByCategory.set(category, orderedKeys);
|
||||||
|
groupCounts[category] = orderedKeys.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// گذر دوم: هر گروه یک سگمنت [پایین، بالا] روی مسیر مانده میگیرد
|
||||||
|
const rows: FlowRow[] = normalized.map(({ day, opening: dayOpening, closing, net, groups }) => {
|
||||||
|
const row: FlowRow = {
|
||||||
|
date: day.date,
|
||||||
|
opening: dayOpening,
|
||||||
|
closing,
|
||||||
|
net,
|
||||||
|
balance: closing,
|
||||||
|
groups,
|
||||||
|
hasMovement: false,
|
||||||
|
};
|
||||||
|
let cursor = dayOpening;
|
||||||
|
CATEGORY_ORDER.forEach((category) => {
|
||||||
|
const orderedKeys = orderByCategory.get(category) ?? [];
|
||||||
|
const sign = CATEGORY_META[category].sign;
|
||||||
|
orderedKeys.forEach((key, index) => {
|
||||||
|
const group = groups[category].find((item) => item.key === key);
|
||||||
|
let range: Range = [cursor, cursor];
|
||||||
|
if (group && group.quantity !== 0) {
|
||||||
|
const from = cursor;
|
||||||
|
cursor = round4(cursor + group.quantity * sign);
|
||||||
|
range = [Math.min(from, cursor), Math.max(from, cursor)];
|
||||||
|
row.hasMovement = true;
|
||||||
|
}
|
||||||
|
row[`seg_${category}_${index}`] = range;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
groupCounts,
|
||||||
|
hasMovement: rows.some((row) => row.hasMovement || row.net !== 0),
|
||||||
|
};
|
||||||
|
}, [query.data]);
|
||||||
|
|
||||||
|
// ——— دامنهی محور Y با ۱۰٪ سرصفحه ———
|
||||||
|
const [domainMin, domainMax] = useMemo(() => {
|
||||||
|
const values: number[] = [];
|
||||||
|
rows.forEach((row) => {
|
||||||
|
values.push(row.balance, row.opening, row.closing);
|
||||||
|
CATEGORY_ORDER.forEach((category) => {
|
||||||
|
const count = groupCounts[category] ?? 0;
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const range = row[`seg_${category}_${index}`] as Range | undefined;
|
||||||
|
if (range) values.push(range[0], range[1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
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, groupCounts]);
|
||||||
|
|
||||||
|
const visibleCategories = CATEGORY_ORDER.filter((category) => (groupCounts[category] ?? 0) > 0);
|
||||||
|
const hasMultipleShades = visibleCategories.some((category) => groupCounts[category] > 1);
|
||||||
|
|
||||||
|
// ——— دروازهی تکمتریالی: بدون متریال، نمودار رندر نمیشود ———
|
||||||
|
if (!filter.materialId) {
|
||||||
|
return (
|
||||||
|
<ChartCard title={LABELS.title}>
|
||||||
|
{options.isError ? (
|
||||||
|
<FlowError />
|
||||||
|
) : options.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-line bg-slate-bg/40 px-4 py-7 text-center"
|
||||||
|
>
|
||||||
|
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-accent-bg text-accent">
|
||||||
|
<Package aria-hidden className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<p className="max-w-md text-[12.5px] leading-6 text-muted">{LABELS.needMaterial}</p>
|
||||||
|
{materials.length > 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<span className="text-[11px] font-bold text-slate-500">{LABELS.quickPick}</span>
|
||||||
|
<div className="flex max-w-xl flex-wrap justify-center gap-2">
|
||||||
|
{materials.map((material) => {
|
||||||
|
const materialUnit =
|
||||||
|
material.unit?.name_fa ?? material.unit?.code ?? "";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={material.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => filter.setMaterial(material.id, material.name)}
|
||||||
|
title={material.name}
|
||||||
|
className="inline-flex max-w-[16rem] cursor-pointer items-center gap-1.5 rounded-full border border-line bg-panel px-3 py-1.5 text-[11.5px] font-semibold text-ink shadow-sm transition-colors hover:border-accent hover:text-accent"
|
||||||
|
>
|
||||||
|
<span className="truncate">{material.name}</span>
|
||||||
|
{materialUnit && (
|
||||||
|
<span className="shrink-0 rounded-full bg-slate-bg px-1.5 text-[10px] font-medium text-muted">
|
||||||
|
{materialUnit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="جریان روزانه موجودی"
|
title={LABELS.title}
|
||||||
sub="ورود، خروج (با لایهی قرمز ضایعات)، برگشت از سایت و ماندهی تجمعی — ۳۰ روز اخیر"
|
sub={`${materialName}${unitLabel ? ` — ${LABELS.unit}: ${unitLabel}` : ""}`}
|
||||||
>
|
>
|
||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<ChartLoading />
|
<ChartLoading />
|
||||||
) : !hasAnyData ? (
|
) : query.isError ? (
|
||||||
<ChartEmpty message="در این بازه جابهجایی موجودی ثبت نشده است" />
|
<FlowError />
|
||||||
|
) : !hasMovement ? (
|
||||||
|
<ChartEmpty message={LABELS.empty} />
|
||||||
) : (
|
) : (
|
||||||
|
<div dir="rtl" className="flex flex-col gap-1">
|
||||||
|
{/* راهنمای رنگ دستهها (بهجای Legend پیشفرض؛ سریهای تنالیته معنا ندارند) */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
|
||||||
|
{visibleCategories.map((category) => (
|
||||||
|
<span key={category} className="inline-flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{ background: categoryColor(category, 0) }}
|
||||||
|
/>
|
||||||
|
{CATEGORY_META[category].label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{hasMultipleShades && (
|
||||||
|
<span className="text-[10px] text-slate-400">{LABELS.shadeHint}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<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,43 +556,50 @@ 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}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
width={56}
|
width={unitLabel ? 70 : 56}
|
||||||
|
label={
|
||||||
|
unitLabel
|
||||||
|
? {
|
||||||
|
value: unitLabel,
|
||||||
|
angle: -90,
|
||||||
|
position: "insideLeft",
|
||||||
|
style: { fontSize: 10, fill: "#64748b" },
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
||||||
content={
|
content={<FlowTooltip unitLabel={unitLabel} materialName={materialName} />}
|
||||||
<ChartTooltip
|
|
||||||
labelFormatter={(l) => isoToJalaliStr(String(l)) ?? String(l)}
|
|
||||||
/>
|
/>
|
||||||
}
|
{/* سگمنتهای شناور: هر گروه (مشخصات+برند) یک پله از خانوادهی رنگ
|
||||||
|
دسته، با جداکنندهی سفید ۱px و یک ستون مشترک */}
|
||||||
|
{CATEGORY_ORDER.map((category) =>
|
||||||
|
Array.from({ length: groupCounts[category] ?? 0 }, (_, index) => (
|
||||||
|
<Bar
|
||||||
|
key={`${category}-${index}`}
|
||||||
|
dataKey={`seg_${category}_${index}`}
|
||||||
|
fill={categoryColor(category, index)}
|
||||||
|
stroke="#ffffff"
|
||||||
|
strokeWidth={1}
|
||||||
|
stackId={FLOW_STACK_ID}
|
||||||
|
maxBarSize={MAX_BAR_SIZE}
|
||||||
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
<Legend
|
)),
|
||||||
wrapperStyle={{ direction: "rtl", width: "100%" }}
|
|
||||||
verticalAlign="top"
|
|
||||||
height={28}
|
|
||||||
iconType="circle"
|
|
||||||
iconSize={8}
|
|
||||||
formatter={(value: unknown) => (
|
|
||||||
<span dir="rtl" className="text-[11px] text-muted">
|
|
||||||
{String(value)}
|
|
||||||
</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} />
|
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="stepAfter"
|
||||||
dataKey="مانده"
|
dataKey="balance"
|
||||||
stroke={CHART_COLORS.balance}
|
name={LABELS.closing}
|
||||||
strokeWidth={1.5}
|
stroke="#0f172a"
|
||||||
|
strokeWidth={1.75}
|
||||||
strokeDasharray="5 3"
|
strokeDasharray="5 3"
|
||||||
dot={false}
|
dot={false}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
@@ -124,6 +607,7 @@ export function FlowChart() {
|
|||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,11 +43,13 @@ export function useDeadStock(idleDays: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ویجت ۳ — سری زمانی جریان روزانه
|
// ویجت ۳ — سری زمانی جریان روزانه
|
||||||
export function useDailyFlow(dateFrom?: string, dateTo?: string) {
|
// فقط با متریال مشخص اجرا میشود: جمع چند متریال با واحدهای متفاوت بیمعناست.
|
||||||
|
export function useDailyFlow(dateFrom?: string, dateTo?: string, enabled = true) {
|
||||||
const params = useFilterParams();
|
const params = useFilterParams();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["dashboard", "flow", dateFrom, dateTo, params],
|
queryKey: ["dashboard", "flow", dateFrom, dateTo, params],
|
||||||
queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }),
|
queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }),
|
||||||
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import type {
|
|||||||
DailyControlData,
|
DailyControlData,
|
||||||
DeadStockItem,
|
DeadStockItem,
|
||||||
DrilldownData,
|
DrilldownData,
|
||||||
FlowDay,
|
FlowData,
|
||||||
InTransitRow,
|
InTransitRow,
|
||||||
InventoryRow,
|
InventoryRow,
|
||||||
InventoryTransaction,
|
InventoryTransaction,
|
||||||
@@ -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;
|
||||||
@@ -1929,8 +1928,8 @@ export const apiV26 = {
|
|||||||
getData<DrilldownData>(`/dashboard/inventory/drilldown?${new URLSearchParams(cleanParams(params))}`),
|
getData<DrilldownData>(`/dashboard/inventory/drilldown?${new URLSearchParams(cleanParams(params))}`),
|
||||||
deadStock: async (params: DashboardFilterParams & { idle_days?: number } = {}): Promise<{ idle_days_threshold: number; items: DeadStockItem[] }> =>
|
deadStock: async (params: DashboardFilterParams & { idle_days?: number } = {}): Promise<{ idle_days_threshold: number; items: DeadStockItem[] }> =>
|
||||||
getData<{ idle_days_threshold: number; items: DeadStockItem[] }>(`/dashboard/inventory/dead-stock?${new URLSearchParams(cleanParams(params))}`),
|
getData<{ idle_days_threshold: number; items: DeadStockItem[] }>(`/dashboard/inventory/dead-stock?${new URLSearchParams(cleanParams(params))}`),
|
||||||
flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<{ opening_balance: number; days: FlowDay[] }> =>
|
flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<FlowData> =>
|
||||||
getData<{ opening_balance: number; days: FlowDay[] }>(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`),
|
getData<FlowData>(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`),
|
||||||
dailyControl: async (params: { date?: string; warehouse_id?: string } = {}): Promise<DailyControlData> =>
|
dailyControl: async (params: { date?: string; warehouse_id?: string } = {}): Promise<DailyControlData> =>
|
||||||
getData<DailyControlData>(`/dashboard/daily-control?${new URLSearchParams(cleanParams(params))}`),
|
getData<DailyControlData>(`/dashboard/daily-control?${new URLSearchParams(cleanParams(params))}`),
|
||||||
workload: async (): Promise<{ projects: WorkloadProject[] }> =>
|
workload: async (): Promise<{ projects: WorkloadProject[] }> =>
|
||||||
|
|||||||
@@ -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 (کل سازمان → پروژه → انبار) —
|
||||||
@@ -261,10 +254,18 @@ export interface DrilldownProject {
|
|||||||
warehouses: DrilldownWarehouse[];
|
warehouses: DrilldownWarehouse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DrilldownMaterial {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
qty_by_unit: QtyByUnit;
|
||||||
|
rows: number;
|
||||||
|
projects: DrilldownProject[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DrilldownData {
|
export interface DrilldownData {
|
||||||
units: Record<string, string>;
|
units: Record<string, string>;
|
||||||
total: { qty_by_unit: QtyByUnit; rows: number };
|
total: { qty_by_unit: QtyByUnit; rows: number };
|
||||||
projects: DrilldownProject[];
|
materials: DrilldownMaterial[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// — جدول موجودی راکد —
|
// — جدول موجودی راکد —
|
||||||
@@ -282,6 +283,20 @@ export interface DeadStockItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// — سری زمانی جریان روزانه —
|
// — سری زمانی جریان روزانه —
|
||||||
|
// دستههای نمایشی جریان: ورود، برگشت، خروج به سایت، ضایعات، و سایر حرکات
|
||||||
|
// (انتقال/شمارش/یافتشده که در چهار سبد نمایشی جا نمیگیرند)
|
||||||
|
export type FlowCategory = "inflow" | "returns" | "siteOut" | "waste" | "other";
|
||||||
|
|
||||||
|
// یک ریزردیف دفتر: نوع تراکنش + مشخصات + برند + انبار — پایهی شکست چندبعدی
|
||||||
|
export interface FlowBreakdownItem {
|
||||||
|
category: FlowCategory;
|
||||||
|
transaction_type: string;
|
||||||
|
specs: Record<string, string> | null;
|
||||||
|
brand: string | null;
|
||||||
|
warehouse: string;
|
||||||
|
quantity: number; // علامتدار مطابق دفتر (مثبت/منفی)
|
||||||
|
}
|
||||||
|
|
||||||
export interface FlowDay {
|
export interface FlowDay {
|
||||||
date: string; // ISO (YYYY-MM-DD)
|
date: string; // ISO (YYYY-MM-DD)
|
||||||
inbound: number;
|
inbound: number;
|
||||||
@@ -289,6 +304,14 @@ export interface FlowDay {
|
|||||||
waste: number;
|
waste: number;
|
||||||
returns: number;
|
returns: number;
|
||||||
balance: number;
|
balance: number;
|
||||||
|
breakdown?: FlowBreakdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowData {
|
||||||
|
opening_balance: number;
|
||||||
|
// واحد کاتالوگی متریال انتخابشده (فقط وقتی یک متریال فیلتر شده باشد)
|
||||||
|
unit: { code: string; name_fa: string | null } | null;
|
||||||
|
days: FlowDay[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// — کنترل روزانه —
|
// — کنترل روزانه —
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// Zustand store — فیلتر سراسری «چه چیزی» داشبورد
|
// Zustand store — فیلتر سراسری «چه چیزی» داشبورد
|
||||||
// دستهبندی → متریال → برند → مشخصات فنی
|
// متریال → برند → مشخصات فنی
|
||||||
// بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ میشود.
|
// بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ میشود.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
export interface DashboardFilterState {
|
export interface DashboardFilterState {
|
||||||
categoryId: string | null; // null = همهی دستهها
|
materialId: string | null; // null = همهی متریالها
|
||||||
materialId: string | null;
|
|
||||||
brandId: string | null;
|
brandId: string | null;
|
||||||
specsHash: string | null;
|
specsHash: string | null;
|
||||||
// برچسبهای انتخابشده برای نمایش چیپ فعال
|
// برچسبهای انتخابشده برای نمایش چیپ فعال
|
||||||
categoryLabel: string | null;
|
|
||||||
materialLabel: string | null;
|
materialLabel: string | null;
|
||||||
brandLabel: string | null;
|
brandLabel: string | null;
|
||||||
specsLabel: string | null;
|
specsLabel: string | null;
|
||||||
|
|
||||||
setCategory: (id: string | null, label: string | null) => void;
|
|
||||||
setMaterial: (id: string | null, label: string | null) => void;
|
setMaterial: (id: string | null, label: string | null) => void;
|
||||||
setBrand: (id: string | null, label: string | null) => void;
|
setBrand: (id: string | null, label: string | null) => void;
|
||||||
setSpecs: (hash: string | null, label: string | null) => void;
|
setSpecs: (hash: string | null, label: string | null) => void;
|
||||||
@@ -25,11 +22,9 @@ export interface DashboardFilterState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL = {
|
const INITIAL = {
|
||||||
categoryId: null,
|
|
||||||
materialId: null,
|
materialId: null,
|
||||||
brandId: null,
|
brandId: null,
|
||||||
specsHash: null,
|
specsHash: null,
|
||||||
categoryLabel: null,
|
|
||||||
materialLabel: null,
|
materialLabel: null,
|
||||||
brandLabel: null,
|
brandLabel: null,
|
||||||
specsLabel: null,
|
specsLabel: null,
|
||||||
@@ -37,18 +32,7 @@ const INITIAL = {
|
|||||||
|
|
||||||
export const useDashboardFilter = create<DashboardFilterState>((set) => ({
|
export const useDashboardFilter = create<DashboardFilterState>((set) => ({
|
||||||
...INITIAL,
|
...INITIAL,
|
||||||
// آبشاری: تغییر هر سطح، سطحهای پاییندست را پاک میکند
|
// آبشاری: تغییر هر سطح، سطحهای پاییندست را پاک میکند (متریال → برند → مشخصات)
|
||||||
setCategory: (id, label) =>
|
|
||||||
set({
|
|
||||||
categoryId: id,
|
|
||||||
categoryLabel: label,
|
|
||||||
materialId: null,
|
|
||||||
materialLabel: null,
|
|
||||||
brandId: null,
|
|
||||||
brandLabel: null,
|
|
||||||
specsHash: null,
|
|
||||||
specsLabel: null,
|
|
||||||
}),
|
|
||||||
setMaterial: (id, label) =>
|
setMaterial: (id, label) =>
|
||||||
set({
|
set({
|
||||||
materialId: id,
|
materialId: id,
|
||||||
@@ -72,7 +56,6 @@ export const useDashboardFilter = create<DashboardFilterState>((set) => ({
|
|||||||
// پارامترهای آماده برای endpointهای داشبورد
|
// پارامترهای آماده برای endpointهای داشبورد
|
||||||
export function dashboardFilterParams(s: DashboardFilterState) {
|
export function dashboardFilterParams(s: DashboardFilterState) {
|
||||||
return {
|
return {
|
||||||
category_id: s.categoryId,
|
|
||||||
material_id: s.materialId,
|
material_id: s.materialId,
|
||||||
brand_id: s.brandId,
|
brand_id: s.brandId,
|
||||||
specs_hash: s.specsHash,
|
specs_hash: s.specsHash,
|
||||||
|
|||||||
Reference in New Issue
Block a user