Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
692343423c | ||
|
|
eedb821e3b | ||
|
|
3d16c435d0 | ||
|
|
34b987398a |
@@ -96,13 +96,11 @@ async def dashboard_scope(
|
|||||||
|
|
||||||
|
|
||||||
def _what_filters(
|
def _what_filters(
|
||||||
category_id: uuid.UUID | None,
|
|
||||||
material_id: uuid.UUID | None,
|
material_id: uuid.UUID | None,
|
||||||
brand_id: uuid.UUID | None,
|
brand_id: uuid.UUID | None,
|
||||||
specs_hash: str | None,
|
specs_hash: str | None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
"category_id": category_id,
|
|
||||||
"material_id": material_id,
|
"material_id": material_id,
|
||||||
"brand_id": brand_id,
|
"brand_id": brand_id,
|
||||||
"specs_hash": specs_hash,
|
"specs_hash": specs_hash,
|
||||||
@@ -111,7 +109,7 @@ def _what_filters(
|
|||||||
|
|
||||||
@router.get("/filters")
|
@router.get("/filters")
|
||||||
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
|
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
|
||||||
"""Cascading options for the global filter bar (Category→Material→Brand→Specs)."""
|
"""Options for the global filter bar (Material→Brand→Specs)."""
|
||||||
return await dashboard_service.filter_options(db)
|
return await dashboard_service.filter_options(db)
|
||||||
|
|
||||||
|
|
||||||
@@ -119,7 +117,6 @@ async def dashboard_filters(db: AsyncSession = Depends(get_db)):
|
|||||||
async def inventory_drilldown(
|
async def inventory_drilldown(
|
||||||
scope: DashboardScope = Depends(dashboard_scope),
|
scope: DashboardScope = Depends(dashboard_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
category_id: uuid.UUID | None = Query(default=None),
|
|
||||||
material_id: uuid.UUID | None = Query(default=None),
|
material_id: uuid.UUID | None = Query(default=None),
|
||||||
brand_id: uuid.UUID | None = Query(default=None),
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
specs_hash: str | None = Query(default=None),
|
specs_hash: str | None = Query(default=None),
|
||||||
@@ -129,7 +126,7 @@ async def inventory_drilldown(
|
|||||||
db,
|
db,
|
||||||
project_ids=scope.project_ids,
|
project_ids=scope.project_ids,
|
||||||
project_id=scope.project_id,
|
project_id=scope.project_id,
|
||||||
**_what_filters(category_id, material_id, brand_id, specs_hash),
|
**_what_filters(material_id, brand_id, specs_hash),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -139,7 +136,6 @@ async def dead_stock(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"),
|
idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"),
|
||||||
limit: int = Query(default=200, ge=1, le=1000),
|
limit: int = Query(default=200, ge=1, le=1000),
|
||||||
category_id: uuid.UUID | None = Query(default=None),
|
|
||||||
material_id: uuid.UUID | None = Query(default=None),
|
material_id: uuid.UUID | None = Query(default=None),
|
||||||
brand_id: uuid.UUID | None = Query(default=None),
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
specs_hash: str | None = Query(default=None),
|
specs_hash: str | None = Query(default=None),
|
||||||
@@ -151,7 +147,7 @@ async def dead_stock(
|
|||||||
project_id=scope.project_id,
|
project_id=scope.project_id,
|
||||||
idle_days=idle_days,
|
idle_days=idle_days,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
**_what_filters(category_id, material_id, brand_id, specs_hash),
|
**_what_filters(material_id, brand_id, specs_hash),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -162,7 +158,6 @@ async def inventory_flow(
|
|||||||
date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیشفرض ۳۰ روز اخیر)"),
|
date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیشفرض ۳۰ روز اخیر)"),
|
||||||
date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"),
|
date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"),
|
||||||
warehouse_id: uuid.UUID | None = Query(default=None),
|
warehouse_id: uuid.UUID | None = Query(default=None),
|
||||||
category_id: uuid.UUID | None = Query(default=None),
|
|
||||||
material_id: uuid.UUID | None = Query(default=None),
|
material_id: uuid.UUID | None = Query(default=None),
|
||||||
brand_id: uuid.UUID | None = Query(default=None),
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
specs_hash: str | None = Query(default=None),
|
specs_hash: str | None = Query(default=None),
|
||||||
@@ -182,7 +177,7 @@ async def inventory_flow(
|
|||||||
date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
||||||
date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
||||||
warehouse_id=warehouse_id,
|
warehouse_id=warehouse_id,
|
||||||
**_what_filters(category_id, material_id, brand_id, specs_hash),
|
**_what_filters(material_id, brand_id, specs_hash),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Read-only service. Every function receives the resolved scope (PM → the list
|
Read-only service. Every function receives the resolved scope (PM → the list
|
||||||
of projects they manage; office roles → project_ids=None = whole
|
of projects they manage; office roles → project_ids=None = whole
|
||||||
organization) plus an optional explicit project filter, and the optional
|
organization) plus an optional explicit project filter, and the optional
|
||||||
«What» filters (category_id / material_id / brand_id / specs_hash).
|
«What» filters (material_id / brand_id / specs_hash).
|
||||||
|
|
||||||
Quantities are NEVER mixed across units: inventory rows live in the
|
Quantities are NEVER mixed across units: inventory rows live in the
|
||||||
material's canonical unit, so stock aggregates carry their unit code and the
|
material's canonical unit, so stock aggregates carry their unit code and the
|
||||||
@@ -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,
|
||||||
@@ -355,7 +429,7 @@ async def daily_flow(
|
|||||||
scope_conditions.append(Warehouse.project_id.in_(project_ids))
|
scope_conditions.append(Warehouse.project_id.in_(project_ids))
|
||||||
if warehouse_id is not None:
|
if warehouse_id is not None:
|
||||||
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
|
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
|
||||||
what_conditions = [Material.category_id == category_id] if category_id is not None else []
|
what_conditions = []
|
||||||
if material_id is not None:
|
if material_id is not None:
|
||||||
what_conditions.append(InventoryItem.material_id == material_id)
|
what_conditions.append(InventoryItem.material_id == material_id)
|
||||||
if brand_id is not None:
|
if brand_id is not None:
|
||||||
|
|||||||
@@ -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,11 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// ویجت ۳ — سری زمانی جریان روزانه
|
// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور)
|
||||||
// ستونهای روزانه: ورود (سبز) / برگشت (کهربایی) / خروج که لایهی
|
// هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز»
|
||||||
// قرمزِ «ضایعات، اتلاف، مفقودی» داخل همان ستون خروج سته —
|
// کشیده میشود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
|
||||||
// بهعلاوهی خط «مانده» (بالانس تجمعی). تاریخها شمسی، اعداد فارسی.
|
// بهصورت سگمنتهای [پایین، بالا] روی مسیر واقعی مانده مینشینند، پس ستون
|
||||||
|
// دقیقاً پلهی خط مانده را میسازد (نه از خط صفر).
|
||||||
|
// خط مانده پلهای (stepAfter) و تولتیپ تحلیلی کامل روز.
|
||||||
|
// همهی مقادیر (شامل ارتفاع پایه و اجزا) از دادهی سریزمانی API محاسبه میشوند.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -19,57 +22,234 @@ import {
|
|||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useDailyFlow } from "../../hooks/useDashboardData";
|
import { useDailyFlow } from "../../hooks/useDashboardData";
|
||||||
import { isoToJalaliStr } from "../../lib/jalali";
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
import {
|
import { fmtNum } from "../../lib/formatters";
|
||||||
CHART_COLORS,
|
import { ChartCard, ChartEmpty, ChartLoading, faAxisTick } from "./chartShared";
|
||||||
ChartCard,
|
|
||||||
ChartEmpty,
|
|
||||||
ChartLoading,
|
|
||||||
ChartTooltip,
|
|
||||||
faAxisTick,
|
|
||||||
} from "./chartShared";
|
|
||||||
|
|
||||||
interface Row {
|
// ——— پالت معنایی همین نمودار (مقادیر پالت Tailwind؛ سایر ویجتها دستنخورده) ———
|
||||||
|
const FLOW_COLORS = {
|
||||||
|
inbound: "#059669", // ورود به انبار — زمردی
|
||||||
|
returns: "#0d9488", // برگشت از سایت — تیل
|
||||||
|
siteOut: "#f59e0b", // خروج به سایت — کهربایی
|
||||||
|
waste: "#dc2626", // ضایعات و اتلاف — قرمز
|
||||||
|
other: "#94a3b8", // سایر حرکات (انتقال/شمارش) — خاکستری
|
||||||
|
balance: "#0f172a", // خط مانده — سرمهای
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// برچسبهای ثابت UI (یکجا، بدون تکرار در JSX)
|
||||||
|
const LABELS = {
|
||||||
|
title: "جریان روزانه موجودی",
|
||||||
|
sub: "ستونهای شناور روی خط مانده — ورود، برگشت، خروج، ضایعات و سایر حرکات",
|
||||||
|
inbound: "ورود به انبار",
|
||||||
|
returns: "برگشت از سایت",
|
||||||
|
siteOut: "خروج به سایت",
|
||||||
|
waste: "ضایعات و اتلاف",
|
||||||
|
other: "سایر حرکات (انتقال/شمارش)",
|
||||||
|
balance: "مانده",
|
||||||
|
opening: "مانده ابتدای روز",
|
||||||
|
net: "تغییر خالص روز",
|
||||||
|
closing: "مانده پایان روز",
|
||||||
|
empty: "در این بازه جابهجایی موجودی ثبت نشده است",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// سگمنت شناور: [پایین، بالا] — همان چیزی که <Bar> بهعنوان مقدار محدوده میخواند
|
||||||
|
type Range = [number, number];
|
||||||
|
|
||||||
|
// یک ردیف زمانی — سگمنتها بهصورت پراپرتیهای مسطح مینشینند تا dataKey هر
|
||||||
|
// <Bar> مقدارش را مستقیماً از سطح خود شیء بخواند (بدون تودرتو شدن).
|
||||||
|
interface FlowRow {
|
||||||
date: string;
|
date: string;
|
||||||
ورود: number;
|
balance: number; // خط پلهای مانده = مانده پایان روز
|
||||||
برگشت: number;
|
opening: number;
|
||||||
"خروج سایت": number;
|
closing: number;
|
||||||
"ضایعات و اتلاف": number;
|
net: number;
|
||||||
مانده: number;
|
inbound: number;
|
||||||
_total: number;
|
returns: number;
|
||||||
|
siteOut: number;
|
||||||
|
waste: number;
|
||||||
|
other: number;
|
||||||
|
hasMovement: boolean;
|
||||||
|
segInbound: Range;
|
||||||
|
segReturns: Range;
|
||||||
|
segSiteOut: Range;
|
||||||
|
segWaste: Range;
|
||||||
|
segOther: Range;
|
||||||
|
}
|
||||||
|
|
||||||
|
// گرد کردن تا ۴ رقم اعشار — مثل بقیهی تجمیعهای داشبورد
|
||||||
|
const round4 = (v: number) => Math.round(v * 10000) / 10000;
|
||||||
|
|
||||||
|
// حاشیهی دامنهی محور Y (نسبت به بازهی داده) تا لبهها به قاب نچسبند
|
||||||
|
const DOMAIN_PAD_RATIO = 0.04;
|
||||||
|
const MAX_BAR_SIZE = 20; // حداکثر عرض ستون (px) — عرض واقعی از باند محور میآید
|
||||||
|
const BAR_CATEGORY_GAP = "18%";
|
||||||
|
// همهی سگمنتها یک ستون مشترک میسازند (نه چند ستون باریک کنار هم)؛ چون
|
||||||
|
// مقدار هر سگمنت [پایین، بالا] است، Recharts آن را بدون انبارش (stacking)
|
||||||
|
// دقیقاً در همان ارتفاع شناور رسم میکند.
|
||||||
|
const FLOW_STACK_ID = "flow-waterfall";
|
||||||
|
|
||||||
|
// عدد با علامت برای «تغییر خالص» — ارقام فارسی
|
||||||
|
const signed = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`;
|
||||||
|
|
||||||
|
// تولتیپ تحلیلی روز — مانده ابتدا/پایان، اجزای روز، تغییر خالص
|
||||||
|
function FlowTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: readonly { payload?: FlowRow }[];
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const row = payload[0]?.payload;
|
||||||
|
if (!row) return null;
|
||||||
|
/** rows: برچسب + مقدار + کلاس رنگ متن (رنگها از پالت Tailwind) */
|
||||||
|
const lines: { label: string; value: number; className: string; signed?: boolean }[] = [
|
||||||
|
{ label: LABELS.opening, value: row.opening, className: "text-slate-600" },
|
||||||
|
{ label: LABELS.inbound, value: row.inbound, className: "text-slate-700" },
|
||||||
|
{ label: LABELS.returns, value: row.returns, className: "text-slate-700" },
|
||||||
|
{ label: LABELS.siteOut, value: -row.siteOut, className: "text-slate-700" },
|
||||||
|
{ label: LABELS.waste, value: -row.waste, className: "text-slate-700" },
|
||||||
|
];
|
||||||
|
if (row.other !== 0) {
|
||||||
|
lines.push({ label: LABELS.other, value: row.other, className: "text-slate-700" });
|
||||||
|
}
|
||||||
|
const netTone =
|
||||||
|
row.net > 0 ? "text-emerald-600" : row.net < 0 ? "text-red-600" : "text-slate-600";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="min-w-max rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<div className="whitespace-nowrap text-xs font-bold text-slate-800">
|
||||||
|
{isoToJalaliStr(row.date) ?? row.date}
|
||||||
|
</div>
|
||||||
|
<div className="my-2 border-b border-slate-100" />
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{lines.map((line) => (
|
||||||
|
<div key={line.label} className="flex items-center justify-between gap-6">
|
||||||
|
<span className="whitespace-nowrap text-[11px] text-slate-500 sm:text-xs">
|
||||||
|
{line.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
dir="ltr"
|
||||||
|
className={`whitespace-nowrap text-[11px] font-medium tabular-nums sm:text-xs ${line.className}`}
|
||||||
|
>
|
||||||
|
{line.value === 0 ? fmtNum(0) : signed(line.value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="my-0.5 border-b border-slate-100" />
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="whitespace-nowrap text-[11px] font-semibold text-slate-600 sm:text-xs">
|
||||||
|
{LABELS.net}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
dir="ltr"
|
||||||
|
className={`whitespace-nowrap text-[11px] font-bold tabular-nums sm:text-xs ${netTone}`}
|
||||||
|
>
|
||||||
|
{signed(row.net)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="whitespace-nowrap text-[11px] font-semibold text-slate-600 sm:text-xs">
|
||||||
|
{LABELS.closing}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
dir="ltr"
|
||||||
|
className="whitespace-nowrap text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs"
|
||||||
|
>
|
||||||
|
{fmtNum(row.closing)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FlowChart() {
|
export function FlowChart() {
|
||||||
const query = useDailyFlow();
|
const query = useDailyFlow();
|
||||||
|
|
||||||
const rows: Row[] = useMemo(
|
// ——— ساخت مسیر واقعی مانده: هر جزء روی مقدار جاری سوار میشود ———
|
||||||
() =>
|
const rows: FlowRow[] = useMemo(() => {
|
||||||
(query.data?.days ?? []).map((d) => ({
|
const days = query.data?.days ?? [];
|
||||||
date: d.date,
|
let running = query.data?.opening_balance ?? 0;
|
||||||
ورود: d.inbound,
|
return days.map((d) => {
|
||||||
برگشت: d.returns,
|
const opening = running;
|
||||||
"خروج سایت": Math.max(0, d.outbound - d.waste),
|
const closing = d.balance;
|
||||||
"ضایعات و اتلاف": d.waste,
|
const waste = Math.max(0, d.waste);
|
||||||
مانده: d.balance,
|
// ضایعات بخشی از خروج است؛ برای ستون جدا، خالص خروج گرفته میشود
|
||||||
_total: d.inbound + d.outbound + d.returns,
|
const siteOut = Math.max(0, d.outbound - waste);
|
||||||
})),
|
const net = round4(closing - opening);
|
||||||
[query.data],
|
// «سایر حرکات» = باقیماندهی تراز (انتقال/شمارش/یافتشده)؛ معمولاً صفر
|
||||||
);
|
const other = round4(net - (d.inbound + d.returns - siteOut - waste));
|
||||||
|
|
||||||
const hasAnyData = rows.some((r) => r._total > 0);
|
let cursor = opening;
|
||||||
|
// هر سگمنت = [کمترین، بیشترین] از مقادیر قبل و بعد از اعمال همان جزء
|
||||||
|
const segment = (delta: number): Range => {
|
||||||
|
const from = cursor;
|
||||||
|
cursor = round4(cursor + delta);
|
||||||
|
return [Math.min(from, cursor), Math.max(from, cursor)];
|
||||||
|
};
|
||||||
|
const segInbound = segment(d.inbound);
|
||||||
|
const segReturns = segment(d.returns);
|
||||||
|
const segSiteOut = segment(-siteOut);
|
||||||
|
const segWaste = segment(-waste);
|
||||||
|
const segOther = segment(other);
|
||||||
|
|
||||||
|
running = closing;
|
||||||
|
return {
|
||||||
|
date: d.date,
|
||||||
|
balance: closing,
|
||||||
|
opening,
|
||||||
|
closing,
|
||||||
|
net,
|
||||||
|
inbound: d.inbound,
|
||||||
|
returns: d.returns,
|
||||||
|
siteOut,
|
||||||
|
waste,
|
||||||
|
other,
|
||||||
|
hasMovement: d.inbound + d.returns + d.outbound + waste + Math.abs(other) > 0,
|
||||||
|
segInbound,
|
||||||
|
segReturns,
|
||||||
|
segSiteOut,
|
||||||
|
segWaste,
|
||||||
|
segOther,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [query.data]);
|
||||||
|
|
||||||
|
// ——— دامنهی محور Y از خود داده (نه از صفر) تا ستونها و خط در کادر جا شوند ———
|
||||||
|
const [domainMin, domainMax] = useMemo(() => {
|
||||||
|
const values: number[] = [];
|
||||||
|
rows.forEach((r) => {
|
||||||
|
values.push(r.balance, r.opening, r.closing);
|
||||||
|
[r.segInbound, r.segReturns, r.segSiteOut, r.segWaste, r.segOther].forEach(([lo, hi]) => {
|
||||||
|
values.push(lo, hi);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (values.length === 0) return [0, 1] as const;
|
||||||
|
const lo = Math.min(...values);
|
||||||
|
const hi = Math.max(...values);
|
||||||
|
const pad = (hi - lo) * DOMAIN_PAD_RATIO || 1;
|
||||||
|
return [Math.floor(lo - pad), Math.ceil(hi + pad)] as const;
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const hasMovement = rows.some((r) => r.hasMovement);
|
||||||
|
const hasOther = rows.some((r) => r.other !== 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartCard
|
<ChartCard title={LABELS.title} sub={LABELS.sub}>
|
||||||
title="جریان روزانه موجودی"
|
|
||||||
sub="ورود، خروج (با لایهی قرمز ضایعات)، برگشت از سایت و ماندهی تجمعی — ۳۰ روز اخیر"
|
|
||||||
>
|
|
||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<ChartLoading />
|
<ChartLoading />
|
||||||
) : !hasAnyData ? (
|
) : !hasMovement ? (
|
||||||
<ChartEmpty message="در این بازه جابهجایی موجودی ثبت نشده است" />
|
<ChartEmpty message={LABELS.empty} />
|
||||||
) : (
|
) : (
|
||||||
<div dir="ltr" className="h-72 w-full">
|
<div dir="ltr" className="h-72 w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ComposedChart data={rows} margin={{ top: 12, right: 8, left: 8, bottom: 4 }}>
|
<ComposedChart
|
||||||
|
data={rows}
|
||||||
|
barCategoryGap={BAR_CATEGORY_GAP}
|
||||||
|
margin={{ top: 12, right: 12, left: 8, bottom: 4 }}
|
||||||
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
@@ -80,6 +260,7 @@ export function FlowChart() {
|
|||||||
minTickGap={18}
|
minTickGap={18}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
|
domain={[domainMin, domainMax]}
|
||||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||||
tickFormatter={faAxisTick}
|
tickFormatter={faAxisTick}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
@@ -88,11 +269,7 @@ export function FlowChart() {
|
|||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
||||||
content={
|
content={<FlowTooltip />}
|
||||||
<ChartTooltip
|
|
||||||
labelFormatter={(l) => isoToJalaliStr(String(l)) ?? String(l)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Legend
|
<Legend
|
||||||
wrapperStyle={{ direction: "rtl", width: "100%" }}
|
wrapperStyle={{ direction: "rtl", width: "100%" }}
|
||||||
@@ -106,17 +283,62 @@ export function FlowChart() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{/* ستونهای کنار هم: ورود و برگشت */}
|
{/* سگمنتهای شناور آبشاری — هر جزء دقیقاً روی مسیر مانده مینشیند */}
|
||||||
<Bar dataKey="ورود" fill={CHART_COLORS.inbound} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
<Bar
|
||||||
<Bar dataKey="برگشت" fill={CHART_COLORS.returns} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
dataKey="segInbound"
|
||||||
{/* ستون خروج: دو لایهی سته — بخش قرمز ضایعات داخل ستون خروج */}
|
name={LABELS.inbound}
|
||||||
<Bar dataKey="خروج سایت" stackId="out" fill={CHART_COLORS.outbound} radius={[0, 0, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
fill={FLOW_COLORS.inbound}
|
||||||
<Bar dataKey="ضایعات و اتلاف" stackId="out" fill={CHART_COLORS.waste} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
stackId={FLOW_STACK_ID}
|
||||||
|
radius={2}
|
||||||
|
maxBarSize={MAX_BAR_SIZE}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="segReturns"
|
||||||
|
name={LABELS.returns}
|
||||||
|
fill={FLOW_COLORS.returns}
|
||||||
|
stackId={FLOW_STACK_ID}
|
||||||
|
radius={2}
|
||||||
|
maxBarSize={MAX_BAR_SIZE}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="segSiteOut"
|
||||||
|
name={LABELS.siteOut}
|
||||||
|
fill={FLOW_COLORS.siteOut}
|
||||||
|
stackId={FLOW_STACK_ID}
|
||||||
|
radius={2}
|
||||||
|
maxBarSize={MAX_BAR_SIZE}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="segWaste"
|
||||||
|
name={LABELS.waste}
|
||||||
|
fill={FLOW_COLORS.waste}
|
||||||
|
stackId={FLOW_STACK_ID}
|
||||||
|
radius={2}
|
||||||
|
maxBarSize={MAX_BAR_SIZE}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
{/* فقط وقتی حرکتی خارج از چهار سبد ثبت شده باشد نمایش داده میشود */}
|
||||||
|
{hasOther && (
|
||||||
|
<Bar
|
||||||
|
dataKey="segOther"
|
||||||
|
name={LABELS.other}
|
||||||
|
fill={FLOW_COLORS.other}
|
||||||
|
stackId={FLOW_STACK_ID}
|
||||||
|
radius={2}
|
||||||
|
maxBarSize={MAX_BAR_SIZE}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* خط پلهای مانده — همتراز هندسی با سرِ ستونهای شناور */}
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="stepAfter"
|
||||||
dataKey="مانده"
|
dataKey="balance"
|
||||||
stroke={CHART_COLORS.balance}
|
name={LABELS.balance}
|
||||||
strokeWidth={1.5}
|
stroke={FLOW_COLORS.balance}
|
||||||
|
strokeWidth={1.75}
|
||||||
strokeDasharray="5 3"
|
strokeDasharray="5 3"
|
||||||
dot={false}
|
dot={false}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ import type {
|
|||||||
// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد
|
// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد
|
||||||
// (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد)
|
// (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد)
|
||||||
export type DashboardFilterParams = {
|
export type DashboardFilterParams = {
|
||||||
category_id?: string | null;
|
|
||||||
material_id?: string | null;
|
material_id?: string | null;
|
||||||
brand_id?: string | null;
|
brand_id?: string | null;
|
||||||
specs_hash?: string | null;
|
specs_hash?: string | null;
|
||||||
|
|||||||
@@ -232,15 +232,8 @@ export interface FilterMaterialOption {
|
|||||||
specs: FilterSpecOption[];
|
specs: FilterSpecOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterCategoryOption {
|
|
||||||
id: string | null; // null = «بدون دسته»
|
|
||||||
name: string;
|
|
||||||
slug: string | null;
|
|
||||||
materials: FilterMaterialOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardFilterOptions {
|
export interface DashboardFilterOptions {
|
||||||
categories: FilterCategoryOption[];
|
materials: FilterMaterialOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// — ماتریس Drill-Down (کل سازمان → پروژه → انبار) —
|
// — ماتریس Drill-Down (کل سازمان → پروژه → انبار) —
|
||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// — جدول موجودی راکد —
|
// — جدول موجودی راکد —
|
||||||
|
|||||||
@@ -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