update dashboard
This commit is contained in:
@@ -100,12 +100,14 @@ def _what_filters(
|
||||
brand_id: uuid.UUID | None,
|
||||
specs_hash: str | None,
|
||||
spec_id: str | None = None,
|
||||
material_family: str | None = None,
|
||||
) -> dict:
|
||||
"""دامنهی «چه چیزی» را به پارامترهای سرویس تبدیل میکند.
|
||||
|
||||
شناسهی ماده اگر UUID معتبر باشد → دامنهی همان ماده (همهی مشخصات آن).
|
||||
اگر مقدار ارسالی UUID نباشد (یعنی شناسهی مشخصه فرستاده شده) یا spec_id
|
||||
صریح بیاید → دامنه به همان مشخصه محدود میشود (specs_hash).
|
||||
• شناسهی ماده اگر UUID معتبر باشد → دامنهی همان ماده (همهی مشخصات آن).
|
||||
• اگر مقدار ارسالی UUID نباشد (یعنی شناسهی مشخصه فرستاده شده) یا spec_id
|
||||
صریح بیاید → دامنه به همان مشخصه محدود میشود (specs_hash).
|
||||
• material_family → همهی ردیفهای کاتالوگِ همان خانواده (سرویس حل میکند).
|
||||
"""
|
||||
resolved_material: uuid.UUID | None = None
|
||||
resolved_specs = specs_hash or spec_id
|
||||
@@ -119,6 +121,7 @@ def _what_filters(
|
||||
"material_id": resolved_material,
|
||||
"brand_id": brand_id,
|
||||
"specs_hash": resolved_specs,
|
||||
"material_family": (material_family or "").strip() or None,
|
||||
}
|
||||
|
||||
|
||||
@@ -136,13 +139,14 @@ async def inventory_drilldown(
|
||||
brand_id: uuid.UUID | None = Query(default=None),
|
||||
specs_hash: str | None = Query(default=None),
|
||||
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||
material_family: str | None = Query(default=None, description="خانوادهی متریال (نام پایه)"),
|
||||
):
|
||||
"""Drill-down matrix: کل سازمان → پروژه → انبار (per-unit aggregates)."""
|
||||
return await dashboard_service.inventory_drilldown(
|
||||
db,
|
||||
project_ids=scope.project_ids,
|
||||
project_id=scope.project_id,
|
||||
**_what_filters(material_id, brand_id, specs_hash, spec_id),
|
||||
**_what_filters(material_id, brand_id, specs_hash, spec_id, material_family),
|
||||
)
|
||||
|
||||
|
||||
@@ -150,12 +154,13 @@ async def inventory_drilldown(
|
||||
async def dead_stock(
|
||||
scope: DashboardScope = Depends(dashboard_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"),
|
||||
idle_days: int = Query(default=0, ge=0, le=365, description="آستانه رکود (روز) — ۰ یعنی همه"),
|
||||
limit: int = Query(default=200, ge=1, le=1000),
|
||||
material_id: str | None = Query(default=None, description="UUID ماده یا شناسهی مشخصه"),
|
||||
brand_id: uuid.UUID | None = Query(default=None),
|
||||
specs_hash: str | None = Query(default=None),
|
||||
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||
material_family: str | None = Query(default=None, description="خانوادهی متریال (نام پایه)"),
|
||||
):
|
||||
"""Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازهی رکود."""
|
||||
return await dashboard_service.dead_stock(
|
||||
@@ -164,7 +169,7 @@ async def dead_stock(
|
||||
project_id=scope.project_id,
|
||||
idle_days=idle_days,
|
||||
limit=limit,
|
||||
**_what_filters(material_id, brand_id, specs_hash, spec_id),
|
||||
**_what_filters(material_id, brand_id, specs_hash, spec_id, material_family),
|
||||
)
|
||||
|
||||
|
||||
@@ -179,6 +184,7 @@ async def inventory_flow(
|
||||
brand_id: uuid.UUID | None = Query(default=None),
|
||||
specs_hash: str | None = Query(default=None),
|
||||
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||
material_family: str | None = Query(default=None, description="خانوادهی متریال (نام پایه)"),
|
||||
):
|
||||
"""Daily flow series: ورود / خروج (با لایهی ضایعات) / برگشت / مانده."""
|
||||
today = datetime.now(dashboard_service.TEHRAN).date()
|
||||
@@ -195,7 +201,7 @@ async def inventory_flow(
|
||||
date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
||||
date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
||||
warehouse_id=warehouse_id,
|
||||
**_what_filters(material_id, brand_id, specs_hash, spec_id),
|
||||
**_what_filters(material_id, brand_id, specs_hash, spec_id, material_family),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,14 @@ from app.models.procurement import (
|
||||
Quote,
|
||||
QuoteProjects,
|
||||
)
|
||||
from app.services.material_naming import (
|
||||
material_entry_labels,
|
||||
material_family_key,
|
||||
spec_label_from_specs,
|
||||
)
|
||||
|
||||
# برچسب مشخصهی خالی (ردیفهای دارای specs_hash پیشفرض)
|
||||
DEFAULT_SPEC_LABEL = "بدون مشخصه (پیشفرض)"
|
||||
|
||||
# Iran has been on a fixed UTC+03:30 (no DST) since 2022 — «امروز» in the
|
||||
# daily widgets means today at this offset.
|
||||
@@ -43,6 +51,21 @@ TEHRAN = timezone(timedelta(hours=3, minutes=30))
|
||||
OUTBOUND_TYPES = ("WITHDRAWAL", "DAMAGE", "WASTE", "LOSS")
|
||||
WASTE_TYPES = ("DAMAGE", "WASTE", "LOSS")
|
||||
|
||||
# دستههای عملیاتی «کنترل روزانه» — تنها تراکنشهایی که در فید امروز دیده میشوند
|
||||
OPS_CATEGORIES = ("outbound", "returns", "waste", "purchases")
|
||||
OPS_TYPES = ("WITHDRAWAL", "RETURN", "DAMAGE", "WASTE", "LOSS", "PURCHASE")
|
||||
|
||||
|
||||
def _ops_category(tx_type: str) -> str:
|
||||
"""نوع تراکنش دفتر → دستهی عملیاتی (خروج/برگشت/ضایعات/پذیرش)."""
|
||||
if tx_type == "WITHDRAWAL":
|
||||
return "outbound"
|
||||
if tx_type == "RETURN":
|
||||
return "returns"
|
||||
if tx_type == "PURCHASE":
|
||||
return "purchases"
|
||||
return "waste" # DAMAGE / WASTE / LOSS
|
||||
|
||||
|
||||
def _stock_filters(stmt, *, material_id=None, brand_id=None, specs_hash=None):
|
||||
"""«What» filters for a statement that already joins inventory→material."""
|
||||
@@ -68,6 +91,21 @@ def _qty(value) -> float:
|
||||
return round(float(value), 4) if value is not None else 0.0
|
||||
|
||||
|
||||
async def _family_material_ids(db: AsyncSession, family: str) -> list[uuid.UUID]:
|
||||
"""شناسهی همهی ردیفهای کاتالوگِ یک خانواده.
|
||||
|
||||
خانواده از نام پایهی ماده مشتق میشود (material_naming)؛ بنابراین انتخاب
|
||||
«لوله پلیاتیلن» همهی سایزهایش را با هم فیلتر میکند — بدون هاردکد نام.
|
||||
"""
|
||||
name = (family or "").strip()
|
||||
if not name:
|
||||
return []
|
||||
rows = (
|
||||
await db.execute(select(Material.id, Material.name).where(Material.status == "active"))
|
||||
).all()
|
||||
return [material_id for material_id, material_name in rows if material_family_key(material_name) == name]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# «What» filter cascade — Category → Material → Brand → Specs
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -125,22 +163,78 @@ async def filter_options(db: AsyncSession) -> dict:
|
||||
unit = units.get(m.canonical_unit_id)
|
||||
brands = brands_by_material.get(m.id, {})
|
||||
spec_map = specs_by_material.get(m.id, {})
|
||||
labels = material_entry_labels(m.name, None)
|
||||
return {
|
||||
"id": str(m.id),
|
||||
"name": m.name,
|
||||
# خانواده vs واریانت — تفکیک عمومی از روی نام کاتالوگ
|
||||
"family_id": labels["family_id"],
|
||||
"family_name": labels["family_name"],
|
||||
"variant_label": labels["variant_label"],
|
||||
"unit": {"id": unit.id, "code": unit.code, "name_fa": unit.name_fa} if unit else None,
|
||||
"brands": [
|
||||
{"id": str(bid), "name": bname}
|
||||
for bid, bname in sorted(brands.items(), key=lambda kv: kv[1] or "")
|
||||
],
|
||||
"specs": [
|
||||
{"hash": h, "specs": sp}
|
||||
{
|
||||
"hash": h,
|
||||
"specs": sp,
|
||||
"label": spec_label_from_specs(sp) or DEFAULT_SPEC_LABEL,
|
||||
}
|
||||
for h, sp in sorted(spec_map.items())
|
||||
],
|
||||
}
|
||||
|
||||
# فهرست تخت متریالها — سرآغاز آبشار سهفیلتری (متریال → برند → مشخصات)
|
||||
return {"data": {"materials": [material_entry(m) for m in materials]}}
|
||||
entries = [material_entry(m) for m in materials]
|
||||
|
||||
# خانوادهها: گروهبندی ردیفهای کاتالوگ بر اساس نام پایه (بدون هاردکد نام)
|
||||
family_map: dict[str, dict] = {}
|
||||
for entry in entries:
|
||||
family = family_map.setdefault(
|
||||
entry["family_id"],
|
||||
{
|
||||
"id": entry["family_id"],
|
||||
"name": entry["family_name"],
|
||||
"unit": entry["unit"],
|
||||
"brands": {},
|
||||
"specs": {},
|
||||
"materials": [],
|
||||
},
|
||||
)
|
||||
if family["unit"] is None:
|
||||
family["unit"] = entry["unit"]
|
||||
family["materials"].append(
|
||||
{
|
||||
"id": entry["id"],
|
||||
"name": entry["name"],
|
||||
"variant_label": entry["variant_label"],
|
||||
}
|
||||
)
|
||||
for brand in entry["brands"]:
|
||||
family["brands"][brand["id"]] = brand["name"]
|
||||
for spec in entry["specs"]:
|
||||
family["specs"][spec["hash"]] = spec["label"]
|
||||
|
||||
families = [
|
||||
{
|
||||
"id": family["id"],
|
||||
"name": family["name"],
|
||||
"unit": family["unit"],
|
||||
"brands": [
|
||||
{"id": bid, "name": bname}
|
||||
for bid, bname in sorted(family["brands"].items(), key=lambda kv: kv[1] or "")
|
||||
],
|
||||
"specs": [
|
||||
{"hash": spec_hash, "label": label}
|
||||
for spec_hash, label in sorted(family["specs"].items(), key=lambda kv: kv[1] or "")
|
||||
],
|
||||
"materials": family["materials"],
|
||||
}
|
||||
for family in sorted(family_map.values(), key=lambda item: item["name"])
|
||||
]
|
||||
|
||||
return {"data": {"materials": entries, "families": families}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -155,6 +249,7 @@ async def inventory_drilldown(
|
||||
material_id=None,
|
||||
brand_id=None,
|
||||
specs_hash=None,
|
||||
material_family=None,
|
||||
) -> dict:
|
||||
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse.
|
||||
|
||||
@@ -212,6 +307,7 @@ async def inventory_drilldown(
|
||||
Material.name.label("mname"),
|
||||
Brand.name.label("bname"),
|
||||
stock_sq.c.specs,
|
||||
stock_sq.c.specs_hash,
|
||||
stock_sq.c.pid,
|
||||
stock_sq.c.pname,
|
||||
stock_sq.c.wid,
|
||||
@@ -232,6 +328,8 @@ async def inventory_drilldown(
|
||||
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود میکنند
|
||||
if material_id is not None:
|
||||
stmt = stmt.where(Material.id == material_id)
|
||||
if material_family:
|
||||
stmt = stmt.where(Material.id.in_(await _family_material_ids(db, material_family)))
|
||||
# برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان
|
||||
# موجودی روی محور میمانند (رفتار قبلی دستنخورده)
|
||||
if brand_id is not None:
|
||||
@@ -247,7 +345,7 @@ async def inventory_drilldown(
|
||||
|
||||
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:
|
||||
for mid, mname, bname, specs, specs_hash, pid, pname, wid, wname, ucode, uname_fa, qty, row_count, canon_ucode, canon_uname_fa in rows:
|
||||
q = _qty(qty)
|
||||
|
||||
# افسانهی واحدها و جمع کل؛ مواد بدون ردیف موجودی با واحد کاتالوگی و صفر
|
||||
@@ -269,9 +367,25 @@ async def inventory_drilldown(
|
||||
# Use full_name + unit as unique key for material level, or just full_name
|
||||
mat_key = f"{mid}_{full_name}"
|
||||
|
||||
# برچسبهای محور X: خانواده (نام پایه) و مشخصه/واریانت — مشتق از نام کاتالوگ
|
||||
labels = material_entry_labels(mname, specs)
|
||||
mat = materials_tree.setdefault(
|
||||
mat_key,
|
||||
{"id": mat_key, "name": full_name, "qty_by_unit": {}, "rows": 0, "_projects": {}},
|
||||
{
|
||||
"id": mat_key,
|
||||
"name": full_name,
|
||||
"material_id": str(mid),
|
||||
"specs_hash": specs_hash or "",
|
||||
"family_id": labels["family_id"],
|
||||
"family_name": labels["family_name"],
|
||||
"variant_label": labels["variant_label"],
|
||||
"spec_label": labels["spec_label"],
|
||||
"spec_detail": labels["spec_detail"],
|
||||
"brand_name": bname,
|
||||
"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)
|
||||
@@ -327,6 +441,7 @@ async def dead_stock(
|
||||
material_id=None,
|
||||
brand_id=None,
|
||||
specs_hash=None,
|
||||
material_family=None,
|
||||
limit: int = 200,
|
||||
) -> dict:
|
||||
"""Stock rows with quantity > 0 and NO ledger activity within `idle_days`.
|
||||
@@ -370,6 +485,10 @@ async def dead_stock(
|
||||
stmt = _stock_filters(
|
||||
stmt, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
|
||||
)
|
||||
if material_family:
|
||||
stmt = stmt.where(
|
||||
InventoryItem.material_id.in_(await _family_material_ids(db, material_family))
|
||||
)
|
||||
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
|
||||
@@ -406,6 +525,7 @@ async def daily_flow(
|
||||
material_id=None,
|
||||
brand_id=None,
|
||||
specs_hash=None,
|
||||
material_family=None,
|
||||
) -> dict:
|
||||
"""Per-day buckets from the signed ledger.
|
||||
|
||||
@@ -440,6 +560,10 @@ async def daily_flow(
|
||||
if warehouse_id is not None:
|
||||
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
|
||||
what_conditions = []
|
||||
family_ids: list[uuid.UUID] = []
|
||||
if material_family:
|
||||
family_ids = await _family_material_ids(db, material_family)
|
||||
what_conditions.append(InventoryItem.material_id.in_(family_ids))
|
||||
if material_id is not None:
|
||||
what_conditions.append(InventoryItem.material_id == material_id)
|
||||
if brand_id is not None:
|
||||
@@ -525,13 +649,14 @@ async def daily_flow(
|
||||
})
|
||||
|
||||
unit = None
|
||||
if material_id is not None:
|
||||
unit_material_id = material_id or (family_ids[0] if family_ids else None)
|
||||
if unit_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)
|
||||
.where(Material.id == unit_material_id)
|
||||
)
|
||||
).first()
|
||||
if unit_row is not None:
|
||||
@@ -571,8 +696,14 @@ async def daily_control(
|
||||
day: date_cls,
|
||||
warehouse_id=None,
|
||||
) -> dict:
|
||||
"""One day (Tehran calendar): outbound / return / waste / purchase ticket
|
||||
counts + quantities, the exact performer names, and the operation detail."""
|
||||
"""One day (Tehran calendar): operational counts per category, the people
|
||||
involved (warehouse clerk + site recipient) and the transaction feed.
|
||||
|
||||
Quantities are never exposed as a unitless total: every category carries a
|
||||
per-unit breakdown (`units`) plus the number of distinct stock items
|
||||
(`items`), so the UI can render «۱۲۲ عدد» for single-unit days and
|
||||
«چندواحدی (۳ قلم کالا)» for mixed-unit days.
|
||||
"""
|
||||
day_start = datetime.combine(day, time.min, tzinfo=TEHRAN)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
|
||||
@@ -584,44 +715,88 @@ async def daily_control(
|
||||
if warehouse_id is not None:
|
||||
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
|
||||
|
||||
count_rows = (
|
||||
# ۱) شمارش/کمیت هر دسته، تفکیکشده بر اساس واحد + تعداد قلم کالای متمایز
|
||||
category_unit_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
InventoryTransaction.transaction_type,
|
||||
Unit.code,
|
||||
Unit.name_fa,
|
||||
func.count().label("tickets"),
|
||||
func.count(func.distinct(InventoryItem.id)).label("items"),
|
||||
func.sum(func.abs(InventoryTransaction.quantity)).label("qty"),
|
||||
)
|
||||
.select_from(InventoryTransaction)
|
||||
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
|
||||
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||
.where(
|
||||
InventoryTransaction.transaction_date >= day_start,
|
||||
InventoryTransaction.transaction_date < day_end,
|
||||
InventoryTransaction.transaction_type.in_(OPS_TYPES),
|
||||
*scope_conditions,
|
||||
)
|
||||
.group_by(InventoryTransaction.transaction_type)
|
||||
)
|
||||
).all()
|
||||
by_type = {str(tx_type): {"tickets": tickets, "qty": _qty(qty)} for tx_type, tickets, qty in count_rows}
|
||||
|
||||
performers_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
InventoryTransaction.performed_by,
|
||||
func.count().label("tickets"),
|
||||
func.sum(func.abs(InventoryTransaction.quantity)).label("qty"),
|
||||
)
|
||||
.select_from(InventoryTransaction)
|
||||
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||
.where(
|
||||
InventoryTransaction.transaction_date >= day_start,
|
||||
InventoryTransaction.transaction_date < day_end,
|
||||
*scope_conditions,
|
||||
)
|
||||
.group_by(InventoryTransaction.performed_by)
|
||||
.order_by(func.count().desc())
|
||||
.group_by(InventoryTransaction.transaction_type, Unit.code, Unit.name_fa)
|
||||
)
|
||||
).all()
|
||||
|
||||
categories: dict[str, dict] = {
|
||||
key: {"tickets": 0, "items": 0, "qty": 0.0, "units": []} for key in OPS_CATEGORIES
|
||||
}
|
||||
for tx_type, unit_code, unit_name_fa, tickets, items, qty in category_unit_rows:
|
||||
bucket = categories[_ops_category(str(tx_type))]
|
||||
bucket["tickets"] += tickets
|
||||
bucket["items"] += items
|
||||
bucket["qty"] = round(bucket["qty"] + _qty(qty), 4)
|
||||
bucket["units"].append({"code": unit_code, "name_fa": unit_name_fa, "qty": _qty(qty)})
|
||||
|
||||
# ۲) اشخاص درگیر: ثبتکنندهی انبار (performed_by) و تحویلگیرنده (delivered_to)
|
||||
async def _people(field, role: str) -> list[dict]:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
field,
|
||||
InventoryTransaction.transaction_type,
|
||||
func.count(),
|
||||
func.sum(func.abs(InventoryTransaction.quantity)),
|
||||
)
|
||||
.select_from(InventoryTransaction)
|
||||
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||
.where(
|
||||
InventoryTransaction.transaction_date >= day_start,
|
||||
InventoryTransaction.transaction_date < day_end,
|
||||
InventoryTransaction.transaction_type.in_(OPS_TYPES),
|
||||
field.isnot(None),
|
||||
*scope_conditions,
|
||||
)
|
||||
.group_by(field, InventoryTransaction.transaction_type)
|
||||
)
|
||||
).all()
|
||||
people: dict[str, dict] = {}
|
||||
for name, tx_type, count, qty in rows:
|
||||
clean = (name or "").strip()
|
||||
if not clean:
|
||||
continue
|
||||
person = people.setdefault(
|
||||
clean,
|
||||
{
|
||||
"name": clean,
|
||||
"role": role,
|
||||
"tickets": 0,
|
||||
"qty": 0.0,
|
||||
"categories": {key: 0 for key in OPS_CATEGORIES},
|
||||
},
|
||||
)
|
||||
person["tickets"] += count
|
||||
person["qty"] = round(person["qty"] + _qty(qty), 4)
|
||||
person["categories"][_ops_category(str(tx_type))] += count
|
||||
return list(people.values())
|
||||
|
||||
people = await _people(InventoryTransaction.performed_by, "performer")
|
||||
people += await _people(InventoryTransaction.delivered_to, "recipient")
|
||||
people.sort(key=lambda p: (p["role"] != "performer", -p["tickets"], p["name"]))
|
||||
|
||||
# ۳) ریز عملیات امروز (همراه با انبار، برند، واحد فارسی و تحویلگیرنده)
|
||||
detail_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
@@ -630,18 +805,19 @@ async def daily_control(
|
||||
Material.name,
|
||||
InventoryItem.specs,
|
||||
Unit.code,
|
||||
Unit.name_fa,
|
||||
Brand.name,
|
||||
)
|
||||
.select_from(InventoryTransaction)
|
||||
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
|
||||
.join(Material, Material.id == InventoryItem.material_id)
|
||||
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
|
||||
.where(
|
||||
InventoryTransaction.transaction_date >= day_start,
|
||||
InventoryTransaction.transaction_date < day_end,
|
||||
InventoryTransaction.transaction_type.in_(
|
||||
("WITHDRAWAL", "RETURN", "DAMAGE", "WASTE", "LOSS", "PURCHASE")
|
||||
),
|
||||
InventoryTransaction.transaction_type.in_(OPS_TYPES),
|
||||
*scope_conditions,
|
||||
)
|
||||
.order_by(InventoryTransaction.transaction_date.desc())
|
||||
@@ -649,15 +825,17 @@ async def daily_control(
|
||||
)
|
||||
).all()
|
||||
|
||||
zero = {"tickets": 0, "qty": 0.0}
|
||||
items = []
|
||||
for tx, wh_name, mat_name, specs, ucode in detail_rows:
|
||||
for tx, wh_name, mat_name, specs, unit_code, unit_name_fa, brand_name in detail_rows:
|
||||
items.append({
|
||||
"id": str(tx.id),
|
||||
"transaction_type": str(tx.transaction_type),
|
||||
"quantity": _qty(tx.quantity),
|
||||
"unit_code": ucode,
|
||||
"unit_code": unit_code,
|
||||
"unit_name_fa": unit_name_fa,
|
||||
"performed_by": tx.performed_by,
|
||||
"delivered_to": tx.delivered_to,
|
||||
"brand": brand_name,
|
||||
"note": tx.note,
|
||||
"warehouse": wh_name,
|
||||
"material": mat_name,
|
||||
@@ -668,18 +846,20 @@ async def daily_control(
|
||||
return {
|
||||
"data": {
|
||||
"date": day.isoformat(),
|
||||
"outbound": by_type.get("WITHDRAWAL", zero),
|
||||
"returns": by_type.get("RETURN", zero),
|
||||
"waste": {
|
||||
"tickets": sum(by_type.get(t, zero)["tickets"] for t in WASTE_TYPES),
|
||||
"qty": round(sum(by_type.get(t, zero)["qty"] for t in WASTE_TYPES), 4),
|
||||
},
|
||||
"purchases": by_type.get("PURCHASE", zero),
|
||||
"performers": [
|
||||
{"name": name or "نامشخص", "tickets": tickets, "qty": _qty(qty)}
|
||||
for name, tickets, qty in performers_rows
|
||||
],
|
||||
"outbound": categories["outbound"],
|
||||
"returns": categories["returns"],
|
||||
"waste": categories["waste"],
|
||||
"purchases": categories["purchases"],
|
||||
# همیشه آرایهاند (هرگز null/حذفشده) تا مصرفکنندهها امن بمانند
|
||||
"people": people,
|
||||
"items": items,
|
||||
# سازگاری عقبرو: باندلهای فرانت که هنوز فقط performers را میخوانند
|
||||
# با API جدید کرش نکنند (تا زمان انتشار باندل تازه)
|
||||
"performers": [
|
||||
{"name": person["name"], "tickets": person["tickets"], "qty": person["qty"]}
|
||||
for person in people
|
||||
if person["role"] == "performer"
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""نامگذاری متریال — تفکیک «خانواده» از «مشخصه/واریانت» بهصورت عمومی.
|
||||
|
||||
برخی ردیفهای کاتالوگ، سایز/گرید را داخل نام ماده دارند
|
||||
(«بلوک سیمانی ۲۰×۲۰×۴۰»، «لوله پلیاتیلن PE100 سایز ۲۵»). این ماژول با قواعد
|
||||
الگویی (ابعاد NxNxN، کلیدواژههای مشخصه، کدهای گرید لاتین) نام را به
|
||||
«خانواده» و «واریانت» میشکند — بدون هیچ هاردکدِ نام محصول مشخصی.
|
||||
"""
|
||||
import re
|
||||
|
||||
_DIGIT_MAP = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
|
||||
|
||||
# واژههایی که شروع بخش مشخصه را نشان میدهند (ابعاد/درجه/تیپ/مدل/…)
|
||||
_ATTRIBUTE_WORDS = {
|
||||
"سایز",
|
||||
"تیپ",
|
||||
"مدل",
|
||||
"درجه",
|
||||
"ضخامت",
|
||||
"قطر",
|
||||
"ابعاد",
|
||||
"طول",
|
||||
"عرض",
|
||||
"ارتفاع",
|
||||
"رنگ",
|
||||
"وزن",
|
||||
"ظرفیت",
|
||||
"شماره",
|
||||
"کلاس",
|
||||
}
|
||||
|
||||
# ابعاد ترکیبی: ۲۰×۲۰×۴۰ / ۲×۲٫۵ / 60x60 / 2*2.5
|
||||
_DIMENSION_RE = re.compile(r"^\d+(?:[×xX*]\d+(?:[.,٫/]\d+)?)+$")
|
||||
# کد گرید: PE100 / PN10 / DN50 / A3 / SN4 / ST37
|
||||
_GRADE_RE = re.compile(r"^(?:PN|PE|DN|SN|SCH|ST|A|B|C)[-]?\d+$")
|
||||
_NUMBER_RE = re.compile(r"^\d+(?:[.,٫/]\d+)?$")
|
||||
_LATIN_WORD_RE = re.compile(r"^[A-Za-z]{2,6}$")
|
||||
|
||||
STANDARD_VARIANT_LABEL = "استاندارد"
|
||||
|
||||
|
||||
def normalize_digits(text: str) -> str:
|
||||
return (text or "").translate(_DIGIT_MAP)
|
||||
|
||||
|
||||
def _is_spec_start(tokens: list[str], index: int) -> bool:
|
||||
token = tokens[index]
|
||||
normalized = normalize_digits(token)
|
||||
if normalized in _ATTRIBUTE_WORDS: # «سایز»، «تیپ»، «مدل»، …
|
||||
return True
|
||||
if _DIMENSION_RE.match(normalized): # ۲۰×۲۰×۴۰
|
||||
return True
|
||||
if _GRADE_RE.match(token): # PE100 / PN10 / A3
|
||||
return True
|
||||
# کد لاتین کوتاه که پشتش عدد میآید: «IPE ۱۴»
|
||||
if (
|
||||
_LATIN_WORD_RE.match(token)
|
||||
and index + 1 < len(tokens)
|
||||
and _NUMBER_RE.match(normalize_digits(tokens[index + 1]))
|
||||
):
|
||||
return True
|
||||
# عددِ تنها در انتهای نام (بدون کلیدواژه): «لوله مسی ۱۵»
|
||||
if _NUMBER_RE.match(normalized) and index == len(tokens) - 1 and index >= 1:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def split_material_name(name: str) -> tuple[str, str | None]:
|
||||
"""«لوله پلیاتیلن PE100 سایز ۲۵» → («لوله پلیاتیلن», «PE100 سایز ۲۵»)."""
|
||||
tokens = [token for token in (name or "").split() if token]
|
||||
for index in range(1, len(tokens)):
|
||||
if _is_spec_start(tokens, index):
|
||||
family = " ".join(tokens[:index]).strip()
|
||||
variant = " ".join(tokens[index:]).strip()
|
||||
if family and variant:
|
||||
return family, variant
|
||||
return " ".join(tokens).strip(), None
|
||||
|
||||
|
||||
def material_family(name: str) -> str:
|
||||
"""نام خانواده (بدون واریانت)."""
|
||||
family, _ = split_material_name(name)
|
||||
return " ".join(family.split())
|
||||
|
||||
|
||||
def material_family_key(name: str) -> str:
|
||||
"""شناسهی پایدار خانواده — همان نام نرمالشده (بدون UUID اختصاصی)."""
|
||||
return material_family(name)
|
||||
|
||||
|
||||
def variant_label(name: str) -> str:
|
||||
"""برچسب واریانت؛ در نبود واریانت، «استاندارد»."""
|
||||
_, variant = split_material_name(name)
|
||||
return variant or STANDARD_VARIANT_LABEL
|
||||
|
||||
|
||||
def spec_label_from_specs(specs: dict | None) -> str | None:
|
||||
"""برچسب کوتاه مشخصات از JSON موجودی (فقط مقدارها، نه «کلید: مقدار»)."""
|
||||
if not specs:
|
||||
return None
|
||||
values = [str(value).strip() for value in specs.values() if str(value).strip()]
|
||||
return "، ".join(values) if values else None
|
||||
|
||||
|
||||
def material_entry_labels(material_name: str, specs: dict | None) -> dict:
|
||||
"""برچسبهای نمایشی یک ردیف کاتالوگ: خانواده، واریانت، مشخصه.
|
||||
|
||||
`spec_label` (برچسب محور X) = مقدار مشخصات JSON اگر موجود باشد، وگرنه
|
||||
واریانتِ استخراجشده از نام، وگرنه «استاندارد».
|
||||
"""
|
||||
family = material_family(material_name)
|
||||
variant = variant_label(material_name)
|
||||
spec_from_json = spec_label_from_specs(specs)
|
||||
# برچسب محور X: واریانت (سایز/گرید) ترجیح دارد؛ در نبود آن مقدار مشخصات
|
||||
axis_label = variant if variant != STANDARD_VARIANT_LABEL else (spec_from_json or STANDARD_VARIANT_LABEL)
|
||||
return {
|
||||
"family_id": material_family_key(material_name),
|
||||
"family_name": family,
|
||||
"variant_label": variant,
|
||||
"spec_label": axis_label,
|
||||
"spec_detail": spec_from_json,
|
||||
}
|
||||
@@ -1,176 +1,597 @@
|
||||
// ============================================================
|
||||
// ویجت ۴ — کنترل روزانه (امروز)
|
||||
// کارتهای KPI: بلیتهای خروج امروز، برگشت از سایت، ضایعات و پذیرش
|
||||
// + فهرست دقیق نام تحویلدهندهها/انجامدهندهها + ریز عملیات امروز.
|
||||
// ویجت ۴ — کنترل روزانه (عملیات امروز)
|
||||
// • کارتهای KPI: «حواله» (خروج) / «برگشتی» (برگشت) / «ثبت» (ضایعات) /
|
||||
// «رسید» (پذیرش خرید). کمیتها هرگز بدون واحد جمع نمیشوند: با یک واحد،
|
||||
// «۱۲۲ عدد» و با چند واحد «چندواحدی (۳ قلم کالا)» نمایش داده میشود.
|
||||
// • کارتها و اشخاص، فیلتر فید «ریز عملیات امروز» را روشن/خاموش میکنند.
|
||||
// • هر ردیف فید: نام کامل متریال + مشخصات، کمیت علامتدار با واحد فارسی،
|
||||
// و متادیتای عملیاتی (انبار/برند/ثبتکننده/تحویلگیرنده) + ساعت تهران.
|
||||
// • هیچ متن کاربردی بریده نمیشود: برش/خطچین/عرض محدود در کارتها نیست؛
|
||||
// نامها و متادیتا کامل و چندخطی دیده میشوند.
|
||||
// هر ۶۰ ثانیه خودکار تازهسازی میشود (useDailyControl).
|
||||
// ============================================================
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpFromLine,
|
||||
PackageOpen,
|
||||
TriangleAlert,
|
||||
Users,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useDailyControl } from "../../hooks/useDashboardData";
|
||||
import { faNum, fmtDateTime, fmtNum } from "../../lib/formatters";
|
||||
import { faNum, fmtNum } from "../../lib/formatters";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
|
||||
import type {
|
||||
DailyControlCategoryStats,
|
||||
DailyControlData,
|
||||
DailyControlItem,
|
||||
DailyControlPerson,
|
||||
DailyControlPersonRole,
|
||||
} from "../../lib/types";
|
||||
|
||||
const TYPE_FA: Record<string, string> = {
|
||||
WITHDRAWAL: "برداشت از انبار",
|
||||
RETURN: "برگشت از سایت",
|
||||
DAMAGE: "آسیبدیده",
|
||||
WASTE: "اتلاف",
|
||||
LOSS: "مفقودی",
|
||||
PURCHASE: "پذیرش خرید",
|
||||
TRANSFER_IN: "انتقال ورودی",
|
||||
TRANSFER_OUT: "انتقال خروجی",
|
||||
FOUND: "پیدا شده",
|
||||
STOCK_COUNT: "انبارگردانی",
|
||||
// دستههای عملیاتی امروز — عنوان، واحد شمارش فارسی، آیکون و رنگبندی
|
||||
type OpsCategory = "outbound" | "returns" | "waste" | "purchases";
|
||||
|
||||
const OPS_CATEGORY_ORDER: OpsCategory[] = ["outbound", "returns", "waste", "purchases"];
|
||||
|
||||
const CATEGORY_META: Record<
|
||||
OpsCategory,
|
||||
{ label: string; countNoun: string; icon: LucideIcon; tone: string }
|
||||
> = {
|
||||
outbound: {
|
||||
label: "خروج به سایت",
|
||||
countNoun: "حواله",
|
||||
icon: ArrowUpFromLine,
|
||||
tone: "bg-blue-bg text-blue-700",
|
||||
},
|
||||
returns: {
|
||||
label: "برگشت از سایت",
|
||||
countNoun: "برگشتی",
|
||||
icon: ArrowDownToLine,
|
||||
tone: "bg-amber-bg text-amber",
|
||||
},
|
||||
waste: {
|
||||
label: "ضایعات / اتلاف / مفقودی",
|
||||
countNoun: "ثبت",
|
||||
icon: TriangleAlert,
|
||||
tone: "bg-red-bg text-red",
|
||||
},
|
||||
purchases: {
|
||||
label: "پذیرش خرید (ورود)",
|
||||
countNoun: "رسید",
|
||||
icon: PackageOpen,
|
||||
tone: "bg-green-bg text-green",
|
||||
},
|
||||
};
|
||||
|
||||
const TYPE_STYLE: Record<string, string> = {
|
||||
WITHDRAWAL: "bg-blue-bg text-[#1d4ed8]",
|
||||
RETURN: "bg-amber-bg text-amber",
|
||||
DAMAGE: "bg-red-bg text-red",
|
||||
WASTE: "bg-red-bg text-red",
|
||||
LOSS: "bg-red-bg text-red",
|
||||
PURCHASE: "bg-green-bg text-green",
|
||||
// نوع تراکنش → برچسب نشان و دستهی عملیاتی
|
||||
const TYPE_META: Record<string, { label: string; category: OpsCategory }> = {
|
||||
WITHDRAWAL: { label: "برداشت از انبار", category: "outbound" },
|
||||
RETURN: { label: "برگشت از سایت", category: "returns" },
|
||||
DAMAGE: { label: "آسیبدیده", category: "waste" },
|
||||
WASTE: { label: "اتلاف", category: "waste" },
|
||||
LOSS: { label: "مفقودی", category: "waste" },
|
||||
PURCHASE: { label: "پذیرش خرید", category: "purchases" },
|
||||
};
|
||||
|
||||
const PERSON_ROLE_META: Record<DailyControlPersonRole, { label: string; tone: string }> = {
|
||||
performer: { label: "ثبتکننده انبار", tone: "bg-blue-bg text-blue-700" },
|
||||
recipient: { label: "تحویلگیرنده کارگاه", tone: "bg-amber-bg text-amber" },
|
||||
};
|
||||
|
||||
// اسکرولبار نازک و ظریف برای فهرستهای داخلی (کلاسها بهصورت literal)
|
||||
const SCROLLBAR_CLASSES =
|
||||
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300/70";
|
||||
|
||||
const LABELS = {
|
||||
peopleTitle: "اشخاص درگیر در عملیات امروز",
|
||||
peopleSub: "(ثبتکننده / تحویلگیرنده)",
|
||||
feedTitle: "ریز عملیات امروز",
|
||||
warehouse: "انبار",
|
||||
brand: "برند",
|
||||
performer: "ثبتکننده",
|
||||
recipient: "تحویلگیرنده",
|
||||
transaction: "تراکنش",
|
||||
noActivity: "ثبتی نشده",
|
||||
multiUnit: "چندواحدی",
|
||||
itemUnit: "قلم کالا",
|
||||
noPeople: "امروز عملیاتی ثبت نشده است",
|
||||
emptyFeed: "هنوز جابهجاییای امروز ثبت نشده است",
|
||||
emptyFiltered: "عملیاتی با این فیلتر امروز ثبت نشده است",
|
||||
noSpecs: "بدون مشخصه",
|
||||
} as const;
|
||||
|
||||
// برچسب مشخصات فنی: «کلید: مقدار، …» یا خالی
|
||||
function specLabel(specs: Record<string, string>): string {
|
||||
const parts = Object.entries(specs ?? {});
|
||||
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
|
||||
}
|
||||
|
||||
// کمیت با علامت (+/−) و ارقام فارسی
|
||||
const signedQty = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`;
|
||||
|
||||
// فقط ساعت تهران (ارقام فارسی) — تاریخ در کل ویجت امروز است و تکرار نمیشود
|
||||
function timeOnly(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const text = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: "Asia/Tehran",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
}).format(date);
|
||||
return faNum(text);
|
||||
}
|
||||
|
||||
// متریک دوم کارت: تکواحدی → «۱۲۲ عدد» / چندواحدی → «چندواحدی (۳ قلم کالا)»
|
||||
function secondaryMetric(stats: DailyControlCategoryStats): string {
|
||||
if (!stats || stats.tickets === 0) return LABELS.noActivity;
|
||||
const units = stats.units ?? [];
|
||||
if (units.length === 1) {
|
||||
const unit = units[0];
|
||||
return `${fmtNum(unit.qty)} ${unit.name_fa ?? unit.code}`;
|
||||
}
|
||||
return `${LABELS.multiUnit} (${faNum(stats.items)} ${LABELS.itemUnit})`;
|
||||
}
|
||||
|
||||
// تفکیک فعالیت هر شخص: «۲ حواله · ۱ رسید»
|
||||
function personBreakdown(person: DailyControlPerson): string {
|
||||
return OPS_CATEGORY_ORDER.filter((category) => person.categories[category] > 0)
|
||||
.map((category) => `${faNum(person.categories[category])} ${CATEGORY_META[category].countNoun}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
// ——— نرمالسازی payload ———
|
||||
// هر آرایه/عدد/متن با پیشفرض امن ساخته میشود تا هیچ مصرفکنندهای هرگز
|
||||
// `.length` یا `.map` را روی undefined اجرا نکند (پاسخ خالی/قدیمی/ناقص هم امن است).
|
||||
const toNumber = (value: unknown): number => {
|
||||
const parsed = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
const toArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []);
|
||||
const toText = (value: unknown): string => (typeof value === "string" ? value : "");
|
||||
const toNullableText = (value: unknown): string | null =>
|
||||
typeof value === "string" ? value : null;
|
||||
|
||||
const EMPTY_PERSON_CATEGORIES: DailyControlPerson["categories"] = {
|
||||
outbound: 0,
|
||||
returns: 0,
|
||||
waste: 0,
|
||||
purchases: 0,
|
||||
};
|
||||
|
||||
function normalizeUnits(raw: unknown): DailyControlCategoryStats["units"] {
|
||||
return toArray<Partial<DailyControlCategoryStats["units"][number]>>(raw).map((unit) => ({
|
||||
code: toText(unit?.code),
|
||||
name_fa: toNullableText(unit?.name_fa),
|
||||
qty: toNumber(unit?.qty),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeStats(raw: unknown): DailyControlCategoryStats {
|
||||
const stats = (raw ?? {}) as Partial<DailyControlCategoryStats>;
|
||||
return {
|
||||
tickets: toNumber(stats.tickets),
|
||||
items: toNumber(stats.items),
|
||||
qty: toNumber(stats.qty),
|
||||
// همیشه آرایه — منبع کرش قبلی وقتی API فیلد units را نداشت
|
||||
units: normalizeUnits(stats.units),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePerson(raw: unknown): DailyControlPerson | null {
|
||||
const person = (raw ?? {}) as Partial<DailyControlPerson>;
|
||||
const name = toText(person.name).trim();
|
||||
if (!name) return null;
|
||||
const categories = (person.categories ?? EMPTY_PERSON_CATEGORIES) as Partial<
|
||||
DailyControlPerson["categories"]
|
||||
>;
|
||||
return {
|
||||
name,
|
||||
role: person.role === "recipient" ? "recipient" : "performer",
|
||||
tickets: toNumber(person.tickets),
|
||||
categories: {
|
||||
outbound: toNumber(categories.outbound),
|
||||
returns: toNumber(categories.returns),
|
||||
waste: toNumber(categories.waste),
|
||||
purchases: toNumber(categories.purchases),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeItem(raw: unknown): DailyControlItem | null {
|
||||
const item = (raw ?? {}) as Partial<DailyControlItem>;
|
||||
const id = toText(item.id);
|
||||
if (!id) return null; // ردیف بدون شناسه قابل رندر نیست
|
||||
const specs =
|
||||
item.specs && typeof item.specs === "object" && !Array.isArray(item.specs)
|
||||
? (item.specs as Record<string, string>)
|
||||
: {};
|
||||
return {
|
||||
id,
|
||||
transaction_type: toText(item.transaction_type),
|
||||
quantity: toNumber(item.quantity),
|
||||
unit_code: toText(item.unit_code),
|
||||
unit_name_fa: toNullableText(item.unit_name_fa),
|
||||
performed_by: toNullableText(item.performed_by),
|
||||
delivered_to: toNullableText(item.delivered_to),
|
||||
brand: toNullableText(item.brand),
|
||||
note: toNullableText(item.note),
|
||||
warehouse: toText(item.warehouse),
|
||||
material: toText(item.material),
|
||||
specs,
|
||||
transaction_date: toText(item.transaction_date),
|
||||
};
|
||||
}
|
||||
|
||||
interface DailyControlView {
|
||||
date: string;
|
||||
categories: Record<OpsCategory, DailyControlCategoryStats>;
|
||||
people: DailyControlPerson[];
|
||||
items: DailyControlItem[];
|
||||
}
|
||||
|
||||
// پاسخ API (نسخهی جدید یا قدیمی) → مدل نمایشیِ کاملاً تضمینشده
|
||||
export function normalizeDailyControl(raw: unknown): DailyControlView {
|
||||
const data = (raw ?? {}) as Partial<DailyControlData> & { performers?: unknown };
|
||||
|
||||
let people = toArray<unknown>(data.people)
|
||||
.map(normalizePerson)
|
||||
.filter((person): person is DailyControlPerson => person !== null);
|
||||
|
||||
// سازگاری با payload قدیمی که فقط performers دارد (بدون نقش/تفکیک دسته)
|
||||
if (people.length === 0) {
|
||||
people = toArray<{ name?: unknown; tickets?: unknown }>(data.performers)
|
||||
.map((person) =>
|
||||
normalizePerson({
|
||||
name: person?.name,
|
||||
role: "performer",
|
||||
tickets: person?.tickets,
|
||||
categories: EMPTY_PERSON_CATEGORIES,
|
||||
}),
|
||||
)
|
||||
.filter((person): person is DailyControlPerson => person !== null);
|
||||
}
|
||||
|
||||
return {
|
||||
date: toText(data.date),
|
||||
categories: {
|
||||
outbound: normalizeStats(data.outbound),
|
||||
returns: normalizeStats(data.returns),
|
||||
waste: normalizeStats(data.waste),
|
||||
purchases: normalizeStats(data.purchases),
|
||||
},
|
||||
people,
|
||||
items: toArray<unknown>(data.items)
|
||||
.map(normalizeItem)
|
||||
.filter((item): item is DailyControlItem => item !== null),
|
||||
};
|
||||
}
|
||||
|
||||
type FeedFilter =
|
||||
| { kind: "category"; value: OpsCategory }
|
||||
| { kind: "person"; name: string; role: DailyControlPersonRole };
|
||||
|
||||
// ——— کارت KPI ———
|
||||
// نوار فشرده: عنوان و آیکون / متریک اصلی / زیرنویس. در ستونهای باریک عنوان
|
||||
// بهطور طبیعی میشکند (بدون برش) و هیچ متنی پنهان نمیشود.
|
||||
function KpiCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
tickets,
|
||||
qty,
|
||||
tone,
|
||||
category,
|
||||
stats,
|
||||
active,
|
||||
onToggle,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
tickets: number;
|
||||
qty: number;
|
||||
tone: string;
|
||||
category: OpsCategory;
|
||||
stats: DailyControlCategoryStats;
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const meta = CATEGORY_META[category];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2.5">
|
||||
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-lg", tone)}>
|
||||
<Icon className="h-4.5 w-4.5" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-pressed={active}
|
||||
title={meta.label}
|
||||
className={cn(
|
||||
"flex flex-col justify-between gap-1.5 rounded-xl border border-slate-100 bg-slate-50/40 p-3 text-start transition-all",
|
||||
active ? "border-indigo-300 ring-1 ring-indigo-200" : "hover:border-slate-200",
|
||||
)}
|
||||
>
|
||||
{/* عنوان + آیکون */}
|
||||
<span className="flex items-start justify-between gap-1.5">
|
||||
<span className="min-w-0 break-words text-[11px] font-medium leading-snug text-slate-500">
|
||||
{meta.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg",
|
||||
meta.tone,
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden className="h-4 w-4" />
|
||||
</span>
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] text-muted">{label}</div>
|
||||
<div className="flex items-baseline gap-1.5 tabular-nums">
|
||||
<span className="text-[17px] font-extrabold leading-6">{faNum(tickets)}</span>
|
||||
<span className="text-[10.5px] text-muted">بلیت</span>
|
||||
<span className="text-[11px] text-muted">·</span>
|
||||
<span className="text-[12px] font-bold">{fmtNum(qty)}</span>
|
||||
|
||||
{/* متریک اصلی */}
|
||||
<span className="text-sm font-bold tabular-nums text-slate-800 md:text-base">
|
||||
{faNum(stats.tickets)} {meta.countNoun}
|
||||
</span>
|
||||
|
||||
{/* زیرنویس */}
|
||||
<span className="text-[10px] leading-normal text-slate-400">
|
||||
{secondaryMetric(stats)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ——— یک ردیف فید عملیات ———
|
||||
// نام کامل متریال و همهی متادیتا (بدون برش متن)؛ کمیت و ساعت در ستون
|
||||
// جداگانه و nowrap میمانند تا هرگز با متنهای بلند قاطی نشوند.
|
||||
function FeedRow({ item }: { item: DailyControlItem }) {
|
||||
const type = TYPE_META[item.transaction_type] ?? {
|
||||
label: item.transaction_type,
|
||||
category: "outbound" as OpsCategory,
|
||||
};
|
||||
const specs = specLabel(item.specs);
|
||||
const unitName = item.unit_name_fa ?? item.unit_code;
|
||||
return (
|
||||
<li className="flex shrink-0 flex-col gap-2 rounded-xl border border-slate-100 bg-slate-50/30 p-3.5 transition-all hover:bg-white hover:shadow-xs">
|
||||
{/* ردیف ۱: نشان نوع + نام کامل متریال/مشخصات و کمیت */}
|
||||
<div className="flex w-full items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium",
|
||||
CATEGORY_META[type.category].tone,
|
||||
)}
|
||||
>
|
||||
{type.label}
|
||||
</Badge>
|
||||
<span className="min-w-0 break-words text-xs font-bold leading-snug text-slate-800 sm:text-sm">
|
||||
{item.material}
|
||||
{specs && <span className="font-normal text-slate-400"> — {specs}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
dir="ltr"
|
||||
className="shrink-0 whitespace-nowrap text-xs font-bold tabular-nums text-slate-800 sm:text-sm"
|
||||
>
|
||||
{signedQty(item.quantity)} {unitName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ردیف ۲: متادیتای کامل + ساعت */}
|
||||
<div className="flex w-full items-center justify-between gap-3 border-t border-slate-100/80 pt-2">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
|
||||
<span>
|
||||
{LABELS.warehouse}: {item.warehouse}
|
||||
</span>
|
||||
{item.brand && (
|
||||
<>
|
||||
<span aria-hidden className="text-slate-300">
|
||||
•
|
||||
</span>
|
||||
<span>
|
||||
{LABELS.brand}: {item.brand}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{item.performed_by && (
|
||||
<>
|
||||
<span aria-hidden className="text-slate-300">
|
||||
•
|
||||
</span>
|
||||
<span>
|
||||
{LABELS.performer}: {item.performed_by}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{item.delivered_to && (
|
||||
<>
|
||||
<span aria-hidden className="text-slate-300">
|
||||
•
|
||||
</span>
|
||||
<span>
|
||||
{LABELS.recipient}: {item.delivered_to}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 whitespace-nowrap text-[11px] font-medium tabular-nums text-slate-400">
|
||||
{timeOnly(item.transaction_date)}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function DailyControlPanel() {
|
||||
const query = useDailyControl();
|
||||
const d = query.data;
|
||||
// مدل نمایشیِ نرمالشده: هیچ آرایهای undefined نیست (پاسخ خالی/قدیمی هم امن است)
|
||||
const view = useMemo(
|
||||
() => (query.data ? normalizeDailyControl(query.data) : null),
|
||||
[query.data],
|
||||
);
|
||||
const [filter, setFilter] = useState<FeedFilter | null>(null);
|
||||
|
||||
const items = useMemo(() => view?.items ?? [], [view]);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
if (!filter) return items;
|
||||
if (filter.kind === "category") {
|
||||
return items.filter(
|
||||
(item) => TYPE_META[item.transaction_type]?.category === filter.value,
|
||||
);
|
||||
}
|
||||
return items.filter((item) =>
|
||||
filter.role === "performer"
|
||||
? item.performed_by === filter.name
|
||||
: item.delivered_to === filter.name,
|
||||
);
|
||||
}, [items, filter]);
|
||||
|
||||
const toggleCategory = (category: OpsCategory) =>
|
||||
setFilter((prev) =>
|
||||
prev?.kind === "category" && prev.value === category
|
||||
? null
|
||||
: { kind: "category", value: category },
|
||||
);
|
||||
|
||||
const togglePerson = (person: DailyControlPerson) =>
|
||||
setFilter((prev) =>
|
||||
prev?.kind === "person" && prev.name === person.name && prev.role === person.role
|
||||
? null
|
||||
: { kind: "person", name: person.name, role: person.role },
|
||||
);
|
||||
|
||||
const filterLabel =
|
||||
filter?.kind === "category"
|
||||
? CATEGORY_META[filter.value].label
|
||||
: filter?.kind === "person"
|
||||
? `${filter.name} (${PERSON_ROLE_META[filter.role].label})`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-2xl !border-gray-100/80 [&>header]:shrink-0 [&>header_h2]:text-gray-900 [&>header_h2]:!font-bold [&>header_p]:!text-xs [&>header_p]:!text-gray-400"
|
||||
title="کنترل روزانه — عملیات امروز"
|
||||
sub={d ? `تاریخ ${isoToJalaliStr(d.date) ?? d.date} — هر دقیقه بهروزرسانی میشود` : "هر دقیقه بهروزرسانی میشود"}
|
||||
sub={
|
||||
view?.date
|
||||
? `تاریخ ${isoToJalaliStr(view.date) ?? view.date} — هر دقیقه بهروزرسانی میشود`
|
||||
: "هر دقیقه بهروزرسانی میشود"
|
||||
}
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : !d ? (
|
||||
) : query.isError || !view ? (
|
||||
<ChartEmpty message="دادهی امروز در دسترس نیست" />
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* KPI کارتها */}
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
<KpiCard
|
||||
icon={ArrowUpFromLine}
|
||||
label="خروج به سایت (برداشت)"
|
||||
tickets={d.outbound.tickets}
|
||||
qty={d.outbound.qty}
|
||||
tone="bg-blue-bg text-[#1d4ed8]"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={ArrowDownToLine}
|
||||
label="برگشت از سایت"
|
||||
tickets={d.returns.tickets}
|
||||
qty={d.returns.qty}
|
||||
tone="bg-amber-bg text-amber"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={TriangleAlert}
|
||||
label="ضایعات / اتلاف / مفقودی"
|
||||
tickets={d.waste.tickets}
|
||||
qty={d.waste.qty}
|
||||
tone="bg-red-bg text-red"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={PackageOpen}
|
||||
label="پذیرش خرید (ورود)"
|
||||
tickets={d.purchases.tickets}
|
||||
qty={d.purchases.qty}
|
||||
tone="bg-green-bg text-green"
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* نوار فشردهی KPI — همیشه چهار ستون در دسکتاپ؛ قابل کلیک برای فیلتر فید */}
|
||||
<div className="grid shrink-0 grid-cols-2 gap-2.5 md:grid-cols-4">
|
||||
{OPS_CATEGORY_ORDER.map((category) => (
|
||||
<KpiCard
|
||||
key={category}
|
||||
category={category}
|
||||
stats={view.categories[category]}
|
||||
active={filter?.kind === "category" && filter.value === category}
|
||||
onToggle={() => toggleCategory(category)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-5">
|
||||
{/* نام دقیق انجامدهندهها */}
|
||||
<div className="rounded-xl border border-line p-3 lg:col-span-2">
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-[12.5px] font-extrabold">
|
||||
<Users className="h-4 w-4 text-muted" />
|
||||
تحویلدهندهها / انجامدهندههای امروز
|
||||
</h3>
|
||||
{d.performers.length === 0 ? (
|
||||
<p className="py-4 text-center text-[12px] text-muted">امروز عملیاتی ثبت نشده است</p>
|
||||
<div className="mt-3 grid min-h-0 flex-1 grid-cols-1 gap-3.5 md:grid-cols-12 md:grid-rows-1 md:items-stretch">
|
||||
{/* اشخاص درگیر در عملیات امروز — کاشی دوردیفی */}
|
||||
<div className="flex h-[420px] min-h-0 w-full min-w-0 shrink-0 flex-col rounded-xl border border-slate-100 bg-white p-4 md:col-span-5 lg:h-auto lg:max-h-[420px]">
|
||||
<div className="mb-3 shrink-0">
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-bold text-slate-800">
|
||||
<Users aria-hidden className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
{LABELS.peopleTitle}
|
||||
</h3>
|
||||
<p className="mt-0.5 whitespace-nowrap text-[11px] text-slate-400">
|
||||
{LABELS.peopleSub}
|
||||
</p>
|
||||
</div>
|
||||
{view.people.length === 0 ? (
|
||||
<p className="py-4 text-center text-[12px] text-muted">{LABELS.noPeople}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-line">
|
||||
{d.performers.map((p, i) => (
|
||||
<li key={i} className="flex items-center gap-2 py-1.5">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-accent-bg text-[11px] font-extrabold text-accent">
|
||||
{faNum(i + 1)}
|
||||
</span>
|
||||
<span className="truncate text-[12.5px] font-bold">{p.name}</span>
|
||||
<span className="ms-auto shrink-0 text-[11px] tabular-nums text-muted">
|
||||
{fmtNum(p.tickets)} بلیت · {fmtNum(p.qty)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
<ul
|
||||
className={cn(
|
||||
"custom-scrollbar flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto pe-1",
|
||||
SCROLLBAR_CLASSES,
|
||||
)}
|
||||
>
|
||||
{view.people.map((person, index) => {
|
||||
const active =
|
||||
filter?.kind === "person" &&
|
||||
filter.name === person.name &&
|
||||
filter.role === person.role;
|
||||
const breakdown = personBreakdown(person);
|
||||
return (
|
||||
<li key={`${person.role}|${person.name}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePerson(person)}
|
||||
aria-pressed={active}
|
||||
title={person.name}
|
||||
className={cn(
|
||||
"flex w-full shrink-0 flex-col gap-2.5 rounded-xl border p-3 text-right transition-all",
|
||||
active
|
||||
? "border-blue-300 bg-blue-50/40 ring-1 ring-blue-200"
|
||||
: "border-slate-100 bg-slate-50/50 hover:border-slate-200 hover:bg-slate-50",
|
||||
)}
|
||||
>
|
||||
{/* ردیف ۱: هویت */}
|
||||
<span className="flex w-full items-center gap-2">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-100/80 text-xs font-bold tabular-nums text-blue-700">
|
||||
{faNum(index + 1)}
|
||||
</span>
|
||||
<span className="whitespace-nowrap text-xs font-bold text-slate-800 sm:text-sm">
|
||||
{person.name}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* ردیف ۲: نقش + آمار */}
|
||||
<span className="flex w-full flex-wrap items-center justify-between gap-2 border-t border-slate-100/80 pt-2">
|
||||
<span className="w-fit shrink-0 whitespace-nowrap rounded-md bg-blue-50 px-2 py-0.5 text-[10px] font-medium text-blue-600">
|
||||
{PERSON_ROLE_META[person.role].label}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[11px] tabular-nums">
|
||||
<span className="font-bold text-slate-700">
|
||||
{faNum(person.tickets)} {LABELS.transaction}
|
||||
</span>
|
||||
{breakdown && (
|
||||
<span className="font-medium text-slate-400">({breakdown})</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ریز عملیات امروز */}
|
||||
<div className="rounded-xl border border-line p-3 lg:col-span-3">
|
||||
<h3 className="mb-2 text-[12.5px] font-extrabold">ریز عملیات امروز</h3>
|
||||
{d.items.length === 0 ? (
|
||||
<p className="py-4 text-center text-[12px] text-muted">هنوز جابهجاییای امروز ثبت نشده است</p>
|
||||
<div className="flex h-[420px] min-h-0 min-w-0 flex-1 flex-col rounded-xl border border-slate-100 bg-white p-4 md:col-span-7 lg:h-auto lg:max-h-[420px]">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-bold text-slate-800">{LABELS.feedTitle}</h3>
|
||||
{filter && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter(null)}
|
||||
className="ms-auto inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-indigo-100 bg-indigo-50 px-2.5 py-1 text-xs font-medium text-indigo-700 transition-colors hover:bg-indigo-100/70"
|
||||
aria-label={`حذف فیلتر ${filterLabel}`}
|
||||
title="حذف فیلتر"
|
||||
>
|
||||
{filterLabel}
|
||||
<X aria-hidden className="h-3 w-3 shrink-0" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{visibleItems.length === 0 ? (
|
||||
<p className="py-4 text-center text-[12px] text-muted">
|
||||
{items.length === 0 ? LABELS.emptyFeed : LABELS.emptyFiltered}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="max-h-56 flex flex-col divide-y divide-line overflow-y-auto pe-1">
|
||||
{d.items.map((it) => (
|
||||
<li key={it.id} className="flex items-center gap-2 py-1.5">
|
||||
<Badge
|
||||
className={cn(
|
||||
"shrink-0 px-2 py-0 text-[10px]",
|
||||
TYPE_STYLE[it.transaction_type] ?? "bg-slate-bg text-muted",
|
||||
)}
|
||||
>
|
||||
{TYPE_FA[it.transaction_type] ?? it.transaction_type}
|
||||
</Badge>
|
||||
<span className="truncate text-[12px] font-bold">{it.material}</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted">
|
||||
{fmtNum(it.quantity)} {it.unit_code}
|
||||
</span>
|
||||
<span className="ms-auto shrink-0 text-[10.5px] text-muted" title={it.warehouse}>
|
||||
{fmtDateTime(it.transaction_date)}
|
||||
</span>
|
||||
</li>
|
||||
<ul
|
||||
className={cn(
|
||||
"custom-scrollbar mt-3 flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto pe-1",
|
||||
SCROLLBAR_CLASSES,
|
||||
)}
|
||||
>
|
||||
{visibleItems.map((item) => (
|
||||
<FeedRow key={item.id} item={item} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// ============================================================
|
||||
// فیلتر سراسری «چه چیزی» — آبشار: متریال → برند → مشخصات فنی
|
||||
// داده از /dashboard/filters میآید (بدون هیچ لیست ساختگی) و انتخاب
|
||||
// هر سطح، سطحهای پاییندست را بازنشانی میکند:
|
||||
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
|
||||
// متریال → مشخصات فنی موجودِ همان متریال
|
||||
// هر فیلد «×» مخصوص خودش را دارد و گزینهی «همهی …» در ابتدای هر فهرست
|
||||
// همان سطح (و سطوح پاییندست) را پاک میکند؛ دکمهی «حذف فیلترها» کل استور را
|
||||
// یکجا به نمای «کل سازمان» برمیگرداند.
|
||||
// سه فیلتر در یک ردیفِ وسطچین با عرض یکسان مینشینند (موبایل: تکستونی).
|
||||
// فیلتر سراسری «چه چیزی» — آبشار: متریال (خانواده) → برند → مشخصات فنی
|
||||
// • «متریال» فقط خانوادههای پایه را نشان میدهد (بلوک سیمانی، لوله پلیاتیلن…)
|
||||
// نه ردیفهای دارای سایز/گرید.
|
||||
// • «مشخصات فنی» پس از انتخاب خانواده، واریانتهای همان خانواده (سایز/گرید)
|
||||
// و مشخصههای موجودیِ آن خانواده را فهرست میکند.
|
||||
// • همهی گزینهها از API مشتق میشوند (بدون هیچ لیست یا نام هاردکدشده).
|
||||
// • سه فیلتر وسطچین و متقارناند؛ روی موبایل تکستونی میشوند.
|
||||
// وضعیت در Zustand نگه داشته میشود تا با جابهجایی بین تبها از دست نرود.
|
||||
// ============================================================
|
||||
|
||||
@@ -19,18 +17,19 @@ import { useDashboardFilter } from "../../store/dashboardFilter";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
// گزینهی «همه» — انتخابی که همان سطح و سطوح پاییندستش را پاک میکند.
|
||||
// شناسهی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox) و هرگز در استور ذخیره نمیشود.
|
||||
// گزینهی «همه» — شناسهی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox)
|
||||
const ALL_OPTION_ID = "";
|
||||
// این شناسه همیشه در ابتدای فهرست میماند و با جستجوی کاربر فیلتر نمیشود
|
||||
const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID];
|
||||
|
||||
// برچسبهای ثابت UI — placeholder هر فیلد و گزینهی «همه»اش یک متن مشترک دارند
|
||||
// پیشوندهای شناسهی گزینههای «مشخصات» — واریانت کاتالوگ یا مشخصهی موجودی
|
||||
const SPEC_VARIANT_PREFIX = "v:";
|
||||
const SPEC_STOCK_PREFIX = "s:";
|
||||
|
||||
const LABELS = {
|
||||
allMaterials: "همهی متریالها",
|
||||
allFamilies: "همهی متریالها",
|
||||
allBrands: "همهی برندها",
|
||||
allSpecs: "همهی مشخصات",
|
||||
materialFirst: "ابتدا متریال را انتخاب کنید",
|
||||
familyFirst: "ابتدا متریال را انتخاب کنید",
|
||||
clearAll: "حذف فیلترها",
|
||||
wholeOrg: "نمای کل سازمان",
|
||||
cascade: "متریال ← برند ← مشخصات فنی",
|
||||
@@ -38,59 +37,80 @@ const LABELS = {
|
||||
|
||||
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 }) {
|
||||
const filterQuery = useDashboardFilterOptions();
|
||||
const state = useDashboardFilter();
|
||||
|
||||
// فهرست متریالها مستقیماً از کاتالوگ سراسری میآید؛ فقط یکتاسازی شناسهها
|
||||
// انجام میشود تا هیچ گزینهی تکراری در فهرست نباشد.
|
||||
const materials = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return (filterQuery.data?.materials ?? []).filter((m) =>
|
||||
seen.has(m.id) ? false : (seen.add(m.id), true),
|
||||
);
|
||||
}, [filterQuery.data]);
|
||||
|
||||
const selectedMaterial = useMemo(
|
||||
() => materials.find((m) => m.id === state.materialId) ?? null,
|
||||
[materials, state.materialId],
|
||||
const families = useMemo(() => filterQuery.data?.families ?? [], [filterQuery.data]);
|
||||
const selectedFamily = useMemo(
|
||||
() => families.find((family) => family.id === state.materialFamilyId) ?? null,
|
||||
[families, state.materialFamilyId],
|
||||
);
|
||||
|
||||
const activeCount = [state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
|
||||
const activeCount = [state.materialFamilyId, state.brandId, state.spec].filter(Boolean).length;
|
||||
|
||||
// انتخاب از فهرست؛ گزینهی «همه» همان سطح را پاک میکند (آبشار خودکار)
|
||||
const pick =
|
||||
(set: (id: string | null, label: string | null) => void) =>
|
||||
(item: ComboboxItem) =>
|
||||
item.id === ALL_OPTION_ID ? set(null, null) : set(item.id, item.label);
|
||||
const pickFamily = (item: ComboboxItem) =>
|
||||
item.id === ALL_OPTION_ID
|
||||
? state.setMaterialFamily(null, null)
|
||||
: state.setMaterialFamily(item.id, item.label);
|
||||
|
||||
// فهرست هر فیلد با گزینهی «همه» در ابتدا
|
||||
const materialItems = useMemo<ComboboxItem[]>(
|
||||
() => [allOption(LABELS.allMaterials), ...materials.map((m) => ({ id: m.id, label: m.name }))],
|
||||
[materials],
|
||||
const pickBrand = (item: ComboboxItem) =>
|
||||
item.id === ALL_OPTION_ID
|
||||
? state.setBrand(null, null)
|
||||
: state.setBrand(item.id, item.label);
|
||||
|
||||
// گزینههای مشخصات: «v:<materialId>» برای واریانت و «s:<hash>» برای مشخصهی موجودی
|
||||
const pickSpec = (item: ComboboxItem) => {
|
||||
if (item.id === ALL_OPTION_ID) {
|
||||
state.setSpec(null);
|
||||
return;
|
||||
}
|
||||
if (item.id.startsWith(SPEC_VARIANT_PREFIX)) {
|
||||
state.setSpec({
|
||||
kind: "variant",
|
||||
id: item.id.slice(SPEC_VARIANT_PREFIX.length),
|
||||
label: item.label,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (item.id.startsWith(SPEC_STOCK_PREFIX)) {
|
||||
state.setSpec({
|
||||
kind: "stockSpec",
|
||||
id: item.id.slice(SPEC_STOCK_PREFIX.length),
|
||||
label: item.label,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const familyItems = useMemo<ComboboxItem[]>(
|
||||
() => [allOption(LABELS.allFamilies), ...families.map((f) => ({ id: f.id, label: f.name }))],
|
||||
[families],
|
||||
);
|
||||
|
||||
const brandItems = useMemo<ComboboxItem[]>(
|
||||
() => [
|
||||
allOption(LABELS.allBrands),
|
||||
...(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name })),
|
||||
...(selectedFamily?.brands ?? []).map((brand) => ({ id: brand.id, label: brand.name })),
|
||||
],
|
||||
[selectedMaterial],
|
||||
[selectedFamily],
|
||||
);
|
||||
|
||||
const specItems = useMemo<ComboboxItem[]>(
|
||||
() => [
|
||||
allOption(LABELS.allSpecs),
|
||||
...(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) })),
|
||||
],
|
||||
[selectedMaterial],
|
||||
);
|
||||
const specItems = useMemo<ComboboxItem[]>(() => {
|
||||
if (!selectedFamily) return [allOption(LABELS.allSpecs)];
|
||||
// واریانتها (سایز/گرید) — یکتاسازی بر اساس برچسب برای پرهیز از تکرار
|
||||
const seen = new Set<string>();
|
||||
const variants = selectedFamily.materials
|
||||
.filter((material) => (seen.has(material.variant_label) ? false : (seen.add(material.variant_label), true)))
|
||||
.map((material) => ({
|
||||
id: `${SPEC_VARIANT_PREFIX}${material.id}`,
|
||||
label: material.variant_label,
|
||||
}));
|
||||
const stockSpecs = selectedFamily.specs.map((spec) => ({
|
||||
id: `${SPEC_STOCK_PREFIX}${spec.hash}`,
|
||||
label: spec.label,
|
||||
}));
|
||||
return [allOption(LABELS.allSpecs), ...variants, ...stockSpecs];
|
||||
}, [selectedFamily]);
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -124,40 +144,40 @@ export function DashboardFilterBar({ className }: { className?: string }) {
|
||||
|
||||
{/* سه فیلتر با عرض یکسان، وسطچین و متقارن؛ روی موبایل تکستونی میشود */}
|
||||
<div className="mx-auto grid w-full max-w-4xl grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
|
||||
{/* ۱ — متریال (سرآغاز آبشار) */}
|
||||
{/* ۱ — متریال (خانوادهی پایه) */}
|
||||
<Combobox
|
||||
value={state.materialLabel ?? ""}
|
||||
placeholder={LABELS.allMaterials}
|
||||
value={state.materialFamilyLabel ?? ""}
|
||||
placeholder={LABELS.allFamilies}
|
||||
allowAdd={false}
|
||||
disabled={materials.length === 0}
|
||||
items={materialItems}
|
||||
disabled={families.length === 0}
|
||||
items={familyItems}
|
||||
pinnedIds={ALL_PINNED_IDS}
|
||||
onClear={() => state.setMaterial(null, null)}
|
||||
onChange={pick(state.setMaterial)}
|
||||
onClear={() => state.setMaterialFamily(null, null)}
|
||||
onChange={pickFamily}
|
||||
/>
|
||||
|
||||
{/* ۲ — برند (فقط برندهای همان متریال) */}
|
||||
{/* ۲ — برند (برندهای همان خانواده) */}
|
||||
<Combobox
|
||||
value={state.brandLabel ?? ""}
|
||||
placeholder={selectedMaterial ? LABELS.allBrands : LABELS.materialFirst}
|
||||
placeholder={selectedFamily ? LABELS.allBrands : LABELS.familyFirst}
|
||||
allowAdd={false}
|
||||
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
|
||||
disabled={!selectedFamily || selectedFamily.brands.length === 0}
|
||||
items={brandItems}
|
||||
pinnedIds={ALL_PINNED_IDS}
|
||||
onClear={() => state.setBrand(null, null)}
|
||||
onChange={pick(state.setBrand)}
|
||||
onChange={pickBrand}
|
||||
/>
|
||||
|
||||
{/* ۳ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
|
||||
{/* ۳ — مشخصات فنی (واریانتها + مشخصههای موجودی همان خانواده) */}
|
||||
<Combobox
|
||||
value={state.specsLabel ?? ""}
|
||||
placeholder={selectedMaterial ? LABELS.allSpecs : LABELS.materialFirst}
|
||||
value={state.spec?.label ?? ""}
|
||||
placeholder={selectedFamily ? LABELS.allSpecs : LABELS.familyFirst}
|
||||
allowAdd={false}
|
||||
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
|
||||
disabled={!selectedFamily}
|
||||
items={specItems}
|
||||
pinnedIds={ALL_PINNED_IDS}
|
||||
onClear={() => state.setSpecs(null, null)}
|
||||
onChange={pick(state.setSpecs)}
|
||||
onClear={() => state.setSpec(null)}
|
||||
onChange={pickSpec}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,41 +1,231 @@
|
||||
// ============================================================
|
||||
// ویجت ۲ — جدول موجودی راکد (سرمایهی خوابیده)
|
||||
// ردیفهایی با موجودی مثبت که هیچ تراکنشی در بازهی رکود نداشتهاند.
|
||||
// آستانهی ۶۰/۹۰ روز با چیپ انتخاب میشود؛ روزهای رکود رنگی است.
|
||||
// ویجت ۲ — موجودی راکد (سرمایهی خوابیده) — فقطخواندنی
|
||||
// داشبورد برای پایش و تحلیل است، نه ویرایش: هیچ استپر/آستانهی ردیفی یا
|
||||
// پنهانسازی وجود ندارد. همهی ردیفها با دادهی خام API رندر میشوند و
|
||||
// مرتبسازی سهحالته (صعودی ← نزولی ← پیشفرض) روی هدر سه ستون کلیدی است.
|
||||
// ============================================================
|
||||
|
||||
import { useState } from "react";
|
||||
import { Snowflake } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Snowflake } from "lucide-react";
|
||||
import { useDeadStock } from "../../hooks/useDashboardData";
|
||||
import { fmtNum, faNum } from "../../lib/formatters";
|
||||
import { faNum, fmtNum } from "../../lib/formatters";
|
||||
import { isoToJalaliStr } from "../../lib/jalali";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../ui/table";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
|
||||
import type { DeadStockItem } from "../../lib/types";
|
||||
|
||||
// صفر = بدون هیچ آستانهای؛ همهی ردیفهای موجودی برگردانده میشوند
|
||||
const ALL_ITEMS_IDLE_DAYS = 0;
|
||||
const MAX_LIST_HEIGHT = "max-h-[450px]";
|
||||
|
||||
// اسکرولبار ظریف (کلاسها بهصورت literal برای Tailwind)
|
||||
const SCROLLBAR_CLASSES =
|
||||
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300/70";
|
||||
|
||||
// شبکهی مشترک ستونها: هویت + سه ستون متریک با عرض ثابت.
|
||||
// هدر و همهی ردیفها از همین قالب استفاده میکنند تا ستونها در کل لیست
|
||||
// دقیقاً روی هم بیفتند؛ در موبایل هویت بالا و متریکها در سه ستون مساوی
|
||||
// زیر آن میآیند (sm:contents پوستهی متریک را از چیدمان حذف میکند).
|
||||
const ROW_GRID =
|
||||
"grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_repeat(3,7rem)] sm:items-center sm:gap-4 lg:gap-6";
|
||||
const HEADER_GRID =
|
||||
"grid grid-cols-3 items-center gap-4 px-3.5 pb-2 sm:grid-cols-[minmax(0,1fr)_repeat(3,7rem)] sm:gap-4 lg:gap-6";
|
||||
const METRICS_GRID = "grid grid-cols-3 gap-4 sm:contents";
|
||||
const METRIC_CELL = "flex min-w-0 flex-col items-center justify-center gap-0.5 text-center";
|
||||
const METRIC_LABEL = "whitespace-nowrap text-[9.5px] font-bold leading-4 text-gray-400";
|
||||
const METRIC_VALUE = "whitespace-nowrap text-sm font-bold leading-5 tabular-nums";
|
||||
|
||||
const LABELS = {
|
||||
sub: "همهی اقلام راکد — برای مرتبسازی روی عنوان ستونها کلیک کنید",
|
||||
qty: "مقدار راکد",
|
||||
days: "روزهای رکود",
|
||||
dayUnit: "روز",
|
||||
lastActivity: "آخرین فعالیت",
|
||||
brand: "برند",
|
||||
warehouse: "انبار",
|
||||
project: "پروژه",
|
||||
sortHint: "ترتیب: صعودی ← نزولی ← پیشفرض",
|
||||
empty: "موجودی راکدی یافت نشد",
|
||||
noDate: "—",
|
||||
} as const;
|
||||
|
||||
// ——— مرتبسازی سهحالته ———
|
||||
type SortKey = "quantity" | "days" | "activity";
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
interface SortState {
|
||||
key: SortKey;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
const SORT_FIELDS: { key: SortKey; label: string }[] = [
|
||||
{ key: "quantity", label: LABELS.qty },
|
||||
{ key: "days", label: LABELS.days },
|
||||
{ key: "activity", label: LABELS.lastActivity },
|
||||
];
|
||||
|
||||
// برچسب مشخصات: «کلید: مقدار، …»
|
||||
function specLabel(specs: Record<string, string>): string {
|
||||
const parts = Object.entries(specs || {});
|
||||
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
|
||||
}
|
||||
|
||||
function idleSeverity(days: number | null): string {
|
||||
if (days == null) return "text-muted";
|
||||
if (days >= 90) return "text-red";
|
||||
if (days >= 60) return "text-amber";
|
||||
return "text-muted";
|
||||
// شدت رکود نسبت به بیشترین رکود مجموعه (کاملاً دادهمحور)
|
||||
function severityClass(days: number, maxDays: number): string {
|
||||
const ratio = maxDays > 0 ? days / maxDays : 0;
|
||||
if (ratio > 0.66) return "text-red";
|
||||
if (ratio > 0.33) return "text-amber";
|
||||
return "text-green";
|
||||
}
|
||||
|
||||
// مقایسهی امن: ردیفهای بدون تاریخ همیشه در انتها میآیند
|
||||
function compareActivity(a: DeadStockItem, b: DeadStockItem, direction: 1 | -1): number {
|
||||
const time = (value: string | null): number | null => {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
const at = time(a.last_activity);
|
||||
const bt = time(b.last_activity);
|
||||
if (at === null && bt === null) return 0;
|
||||
if (at === null) return 1;
|
||||
if (bt === null) return -1;
|
||||
return (at - bt) * direction;
|
||||
}
|
||||
|
||||
export function sortItems(items: DeadStockItem[], sort: SortState | null): DeadStockItem[] {
|
||||
// بیترتیب = ترتیب پیشفرض دیتابیس (بدون هیچ تغییر)
|
||||
if (!sort) return items;
|
||||
const direction = sort.direction === "asc" ? 1 : -1;
|
||||
const list = [...items];
|
||||
list.sort((a, b) => {
|
||||
if (sort.key === "quantity") return (a.quantity - b.quantity) * direction;
|
||||
if (sort.key === "days") return ((a.idle_days ?? 0) - (b.idle_days ?? 0)) * direction;
|
||||
return compareActivity(a, b, direction);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
// چرخهی سهحالتهی هر ستون: صعودی ← نزولی ← برگشت به پیشفرض دیتابیس
|
||||
export function nextSortState(prev: SortState | null, key: SortKey): SortState | null {
|
||||
if (!prev || prev.key !== key) return { key, direction: "asc" };
|
||||
if (prev.direction === "asc") return { key, direction: "desc" };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ——— هدر ستون تعاملی ———
|
||||
// برچسب متریک خودِ دکمه است. نشانگر مرتبسازی همیشه دیده میشود (نه فقط با
|
||||
// هاور) تا از نگاه اول مشخص باشد ستون قابل مرتبسازی است: حالت غیرفعال با
|
||||
// فلش دوگانهی خاکستری ملایم و حالت فعال با فلش جهتدار و رنگ برند.
|
||||
function SortHeader({
|
||||
field,
|
||||
sort,
|
||||
onCycle,
|
||||
}: {
|
||||
field: { key: SortKey; label: string };
|
||||
sort: SortState | null;
|
||||
onCycle: (key: SortKey) => void;
|
||||
}) {
|
||||
const active = sort?.key === field.key;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCycle(field.key)}
|
||||
aria-pressed={active}
|
||||
title={LABELS.sortHint}
|
||||
className={cn(
|
||||
"group inline-flex w-full min-w-0 cursor-pointer select-none items-center justify-center gap-1 rounded-md px-1 py-1 text-[10px] font-bold transition-colors",
|
||||
active ? "text-indigo-600" : "text-gray-400 hover:text-gray-600",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{field.label}</span>
|
||||
{active ? (
|
||||
sort.direction === "asc" ? (
|
||||
<ArrowUp aria-hidden className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ArrowDown aria-hidden className="h-3 w-3 shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown
|
||||
aria-hidden
|
||||
className="h-3 w-3 shrink-0 text-gray-300 transition-colors group-hover:text-gray-400"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ——— یک ردیف فقطخواندنی ———
|
||||
function DeadStockRow({ item, maxDays }: { item: DeadStockItem; maxDays: number }) {
|
||||
const specs = specLabel(item.specs);
|
||||
const unitName = item.unit?.name_fa || item.unit?.code || "";
|
||||
const days = item.idle_days ?? 0;
|
||||
return (
|
||||
<li className="rounded-xl border border-gray-100 bg-white p-3.5 shadow-xs transition-colors hover:border-gray-200">
|
||||
<div className={ROW_GRID}>
|
||||
{/* هویت */}
|
||||
<div className="flex min-w-0 flex-col gap-1 text-start">
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{item.material}
|
||||
{specs && <span className="font-medium text-gray-400"> — {specs}</span>}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-gray-400">
|
||||
{item.brand && (
|
||||
<span>
|
||||
{LABELS.brand}: {item.brand}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{LABELS.warehouse}: {item.warehouse}
|
||||
</span>
|
||||
<span>
|
||||
{LABELS.project}: {item.project}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* سه متریک فقطخواندنی — در sm بهصورت contents داخل شبکهی ردیف
|
||||
مینشینند تا دقیقاً زیر ستونهای هدر قرار بگیرند */}
|
||||
<div className={METRICS_GRID}>
|
||||
<div className={METRIC_CELL}>
|
||||
<span className={METRIC_LABEL}>{LABELS.qty}</span>
|
||||
<span dir="ltr" className={cn(METRIC_VALUE, "text-gray-800")}>
|
||||
{fmtNum(item.quantity)} {unitName}
|
||||
</span>
|
||||
</div>
|
||||
<div className={METRIC_CELL}>
|
||||
<span className={METRIC_LABEL}>{LABELS.days}</span>
|
||||
<span className={cn(METRIC_VALUE, severityClass(days, maxDays))}>
|
||||
{item.idle_days == null ? LABELS.noDate : `${faNum(days)} ${LABELS.dayUnit}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={METRIC_CELL}>
|
||||
<span className={METRIC_LABEL}>{LABELS.lastActivity}</span>
|
||||
<span className={cn(METRIC_VALUE, "text-gray-800")}>
|
||||
{item.last_activity
|
||||
? isoToJalaliStr(item.last_activity) ?? LABELS.noDate
|
||||
: LABELS.noDate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeadStockTable() {
|
||||
const [idleDays, setIdleDays] = useState<60 | 90>(60);
|
||||
const query = useDeadStock(idleDays);
|
||||
const query = useDeadStock(ALL_ITEMS_IDLE_DAYS);
|
||||
const items = query.data?.items ?? [];
|
||||
const [sort, setSort] = useState<SortState | null>(null);
|
||||
|
||||
const maxDays = useMemo(
|
||||
() => Math.max(1, ...items.map((item) => item.idle_days ?? 0)),
|
||||
[items],
|
||||
);
|
||||
|
||||
const sortedItems = useMemo(() => sortItems(items, sort), [items, sort]);
|
||||
|
||||
const cycleSort = (key: SortKey) => setSort((prev) => nextSortState(prev, key));
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
@@ -45,72 +235,43 @@ export function DeadStockTable() {
|
||||
موجودی راکد — سرمایهی خوابیده
|
||||
</span>
|
||||
}
|
||||
sub="اقلامی با موجودی مثبت که هیچ تراکنشی در بازهی انتخابی نداشتهاند"
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
{([60, 90] as const).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => setIdleDays(d)}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-0.5 text-[11px] font-bold transition-colors",
|
||||
idleDays === d ? "bg-accent text-white" : "bg-slate-bg text-muted hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{faNum(d)} روز و بیشتر
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
sub={LABELS.sub}
|
||||
>
|
||||
{query.isLoading ? (
|
||||
<ChartLoading />
|
||||
) : items.length === 0 ? (
|
||||
<ChartEmpty message={`موجودی راکدی با آستانهی ${faNum(idleDays)} روز وجود ندارد`} />
|
||||
<ChartEmpty message={LABELS.empty} />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>متریال و مشخصات</TableHead>
|
||||
<TableHead>برند</TableHead>
|
||||
<TableHead>انبار / پروژه</TableHead>
|
||||
<TableHead className="text-end">مقدار راکد</TableHead>
|
||||
<TableHead className="text-end">روزهای رکود</TableHead>
|
||||
<TableHead>آخرین فعالیت</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((it) => {
|
||||
const specs = specLabel(it.specs);
|
||||
return (
|
||||
<TableRow key={it.inventory_id}>
|
||||
<TableCell>
|
||||
<div className="text-[13px] font-bold">{it.material}</div>
|
||||
{specs && <div className="text-[11px] text-muted">{specs}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-[12px] text-muted">{it.brand || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-[12px]">{it.warehouse}</div>
|
||||
<div className="text-[10.5px] text-muted">{it.project}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-end font-bold tabular-nums">
|
||||
{fmtNum(it.quantity)}{" "}
|
||||
<span className="text-[10.5px] font-normal text-muted">
|
||||
{it.unit?.name_fa || it.unit?.code}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className={cn("text-end font-extrabold tabular-nums", idleSeverity(it.idle_days))}>
|
||||
{it.idle_days == null ? "—" : faNum(it.idle_days)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] text-muted">
|
||||
{it.last_activity ? isoToJalaliStr(it.last_activity) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div
|
||||
dir="rtl"
|
||||
className={cn(
|
||||
"overflow-y-auto scroll-smooth pe-1",
|
||||
MAX_LIST_HEIGHT,
|
||||
SCROLLBAR_CLASSES,
|
||||
)}
|
||||
>
|
||||
{/* هدر و ردیفها در یک اسکرولکانتینر مشترکاند؛ پس عرض محتوا و گاتر
|
||||
اسکرولبار برای هر دو یکسان است و ستونهای هدر دقیقاً روی ستونهای
|
||||
هر ردیف مینشینند. هدر sticky است تا هنگام پیمایش دیده شود. */}
|
||||
<div
|
||||
className={cn(
|
||||
HEADER_GRID,
|
||||
"sticky top-0 z-10 border-b border-gray-100 bg-white",
|
||||
)}
|
||||
>
|
||||
{/* ستون هویت در دسکتاپ؛ در موبایل فضای خالی لازم نیست */}
|
||||
<span aria-hidden className="hidden sm:block" />
|
||||
{SORT_FIELDS.map((field) => (
|
||||
<SortHeader key={field.key} field={field} sort={sort} onCycle={cycleSort} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-2.5 pt-2">
|
||||
{sortedItems.map((item) => (
|
||||
<DeadStockRow key={item.inventory_id} item={item} maxDays={maxDays} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
|
||||
@@ -25,13 +25,11 @@ import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
MousePointerClick,
|
||||
Package,
|
||||
RotateCcw,
|
||||
Square,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useDrilldown } from "../../hooks/useDashboardData";
|
||||
import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter";
|
||||
import { useDashboardFilter } from "../../store/dashboardFilter";
|
||||
import type { DrilldownProject } from "../../lib/types";
|
||||
import { faNum, fmtNum } from "../../lib/formatters";
|
||||
import {
|
||||
@@ -63,11 +61,21 @@ interface StackGroup {
|
||||
// مینشیند؛ چون dataKey هر <Bar> مستقیماً از سطح پراپرتیهای داده خوانده میشود.
|
||||
interface StackedBar {
|
||||
id: string;
|
||||
label: string;
|
||||
label: string; // نام کامل (تولتیپ)
|
||||
// برچسبهای سلسلهمراتبی محور X (از payload)
|
||||
axisLabel: string; // Tier 1: مشخصه/واریانت کوتاه
|
||||
familyId: string;
|
||||
familyName: string;
|
||||
specLabel: string;
|
||||
specDetail: string | null;
|
||||
variantLabel: string;
|
||||
specsHash: string;
|
||||
materialRowId: string;
|
||||
brandName: string | null;
|
||||
unitLabel: string;
|
||||
total: number;
|
||||
segments: Record<string, number>;
|
||||
[segmentKey: string]: number | string | Record<string, number>;
|
||||
[segmentKey: string]: number | string | null | Record<string, number>;
|
||||
}
|
||||
|
||||
// سطح ریشه + برچسبهای ثابت UI (یکجا تعریف میشوند تا در JSX تکرار نشوند)
|
||||
@@ -83,11 +91,14 @@ const LABELS = {
|
||||
warehouseScopeHint: "دامنهی انبار — برای تغییر انتخابها کلیک کنید",
|
||||
drillHint: "برای مشاهدهی سهم پروژهها و انبارها روی میلهها کلیک کنید",
|
||||
lockedHint: "فیلتر انبار فعال است؛ برای drill-down ابتدا آن را بردارید",
|
||||
removeMaterial: "حذف متریال انتخابشده و بازگشت به نمای متریالها",
|
||||
removeSpec: "حذف مشخصه و بازگشت به نمای همین خانواده",
|
||||
totalLabel: "مجموع نمایش داده شده",
|
||||
itemsLabel: "اقلام نمایش داده شده",
|
||||
tooltipTotal: "مجموع",
|
||||
tooltipBreakdown: "تفکیک بر اساس",
|
||||
family: "خانواده",
|
||||
spec: "مشخصه",
|
||||
standard: "استاندارد",
|
||||
emptyChart: "موجودیای با این فیلترها یافت نشد",
|
||||
} as const;
|
||||
|
||||
@@ -184,9 +195,17 @@ function scopeLabel(
|
||||
// بلند روی هم نیفتند؛ نامهای بلندتر در چند خط شکسته میشوند.
|
||||
const AXIS_SLOT_PX = 200; // عرض سهم هر میله روی محور (px)
|
||||
const MIN_CHART_WIDTH = 800; // کف عرض نمودار (px)
|
||||
const CHART_HEIGHT = 360; // ارتفاع نمودار (px)
|
||||
const AXIS_HEIGHT = 110; // ارتفاع ناحیهی برچسبهای محور X (px)
|
||||
const AXIS_TICK_MARGIN = 15; // فاصلهی خط محور تا خط اول برچسب (px)
|
||||
const CHART_HEIGHT = 360; // ارتفاع ناحیهی نمودار (px)
|
||||
const AXIS_HEIGHT = 62; // ناحیهی برچسب Tier 1 (مشخصه) — px
|
||||
const AXIS_TICK_MARGIN = 10; // فاصلهی خط محور تا خط اول برچسب (px)
|
||||
// هندسهی محور سلسلهمراتبی (Tier 2/3) — همتراز با layout خودِ BarChart
|
||||
const CHART_MARGIN_LEFT = 8;
|
||||
const CHART_MARGIN_RIGHT = 12;
|
||||
const CHART_MARGIN_TOP = 26;
|
||||
const CHART_MARGIN_BOTTOM = 12;
|
||||
const Y_AXIS_WIDTH = 56;
|
||||
const FAMILY_BAR_HEIGHT = 32; // خط سقف خانواده + نام آن (px)
|
||||
const SCROLLBAR_ALLOWANCE = 18; // فضای اسکرولبار افقی
|
||||
const LABEL_LINE_CHARS = 20; // سقف نویسهی هر خط برچسب
|
||||
const LABEL_MAX_LINES = 3; // حداکثر خطوط هر برچسب
|
||||
const LABEL_LINE_HEIGHT = 18; // فاصلهی خطوط برچسب (px)
|
||||
@@ -281,13 +300,11 @@ function numericValues(qtyByUnit: Record<string, number>): Record<string, number
|
||||
// در حالت RTL بههم نریزند.
|
||||
function CustomTooltip({
|
||||
active,
|
||||
label,
|
||||
payload,
|
||||
stacks,
|
||||
segmentKind,
|
||||
}: {
|
||||
active?: boolean;
|
||||
label?: string | number;
|
||||
payload?: readonly { payload?: StackedBar }[];
|
||||
stacks: StackGroup[];
|
||||
segmentKind: SegmentKind;
|
||||
@@ -307,11 +324,25 @@ function CustomTooltip({
|
||||
>
|
||||
{/* سربرگ: موجودیت اصلی میله (متریال در نمای کلی، پروژه در نمای drill-down) */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="whitespace-normal text-xs font-bold text-slate-800">
|
||||
{String(label ?? bar.label)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500 sm:text-xs">
|
||||
{LABELS.tooltipBreakdown} {SEGMENT_META[segmentKind].plural}
|
||||
<span className="whitespace-normal text-xs font-bold text-slate-800">{bar.label}</span>
|
||||
<span className="flex flex-wrap items-center gap-1.5 text-[10px] text-slate-500 sm:text-xs">
|
||||
<span>{SEGMENT_META[segmentKind].plural}</span>
|
||||
{bar.familyName && (
|
||||
<>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span>
|
||||
{LABELS.family}: {bar.familyName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{bar.specLabel && (
|
||||
<>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span>
|
||||
{LABELS.spec}: {bar.specLabel}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -356,11 +387,6 @@ function CustomTooltip({
|
||||
);
|
||||
}
|
||||
|
||||
// اثر انگشت فیلتر سراسری (Zustand) — هر تغییر آن یعنی payload تازهای میآید و
|
||||
// اعتبارسنجی متریال انتخابی باید دوباره اجرا شود.
|
||||
const selectGlobalFilterKey = (s: DashboardFilterState): string =>
|
||||
JSON.stringify([s.materialId, s.brandId, s.specsHash]);
|
||||
|
||||
// --- MultiSelect Dropdown Component ---
|
||||
// کنترل چندانتخابی دامنه (پروژه/انبار) — بهعنوان گرهی چندانتخابیِ breadcrumb
|
||||
// رندر میشود؛ برچسبش بازتاب انتخابهای فعلی است و ردیف «همهی …» انتخابها را
|
||||
@@ -528,7 +554,13 @@ export function DrilldownMatrix() {
|
||||
// فیلتر سراسری، payload را زیر پای حالت محلی عوض میکند؛ بهجای پاککردن
|
||||
// کورکورانهی انتخابها، حالت محلی را با دادهی تازه اعتبارسنجی میکنیم تا نه
|
||||
// در سطح کهنه گیر کنیم و نه بیدلیل به عقب بپریم.
|
||||
const globalFilterKey = useDashboardFilter(selectGlobalFilterKey);
|
||||
const filter = useDashboardFilter();
|
||||
// اثر انگشت فیلتر سراسری (خانواده + برند + مشخصه) — با هر تغییر، payload نو
|
||||
const globalFilterKey = JSON.stringify([
|
||||
filter.materialFamilyId,
|
||||
filter.brandId,
|
||||
filter.spec?.id ?? null,
|
||||
]);
|
||||
|
||||
// ۱) انتخابهای وابسته به payload (پروژه/انبار) نباید به شناسههایی اشاره کنند
|
||||
// که در نتیجهی فیلتر جاری وجود ندارند؛ وگرنه نمودارِ خالی با چیپهای فعال میبینیم.
|
||||
@@ -582,6 +614,35 @@ export function DrilldownMatrix() {
|
||||
const matchesWarehouse = (wid: string) =>
|
||||
selectedWarehouseIds.length === 0 || selectedWarehouseIds.includes(wid);
|
||||
|
||||
// برچسبهای سلسلهمراتبی هر میله (خانواده/مشخصه/واریانت) — فقط در نمای کلی
|
||||
const materialMeta = new Map<
|
||||
string,
|
||||
{
|
||||
familyId: string;
|
||||
familyName: string;
|
||||
specLabel: string;
|
||||
specDetail: string | null;
|
||||
variantLabel: string;
|
||||
specsHash: string;
|
||||
materialRowId: string;
|
||||
brandName: string | null;
|
||||
}
|
||||
>();
|
||||
if (level === ROOT_LEVEL) {
|
||||
data.materials.forEach((mat) => {
|
||||
materialMeta.set(mat.id, {
|
||||
familyId: mat.family_id ?? mat.name,
|
||||
familyName: mat.family_name ?? mat.name,
|
||||
specLabel: mat.spec_label ?? mat.variant_label ?? LABELS.standard,
|
||||
specDetail: mat.spec_detail ?? null,
|
||||
variantLabel: mat.variant_label ?? LABELS.standard,
|
||||
specsHash: mat.specs_hash ?? "",
|
||||
materialRowId: mat.material_id ?? "",
|
||||
brandName: mat.brand_name ?? null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface GroupAcc {
|
||||
name: string;
|
||||
byUnit: Record<string, number>;
|
||||
@@ -670,9 +731,19 @@ export function DrilldownMatrix() {
|
||||
total += value;
|
||||
groupNames.set(groupId, group.name);
|
||||
}
|
||||
const meta = materialMeta.get(bar.id);
|
||||
bars.push({
|
||||
id: bar.id,
|
||||
label: bar.label,
|
||||
axisLabel: meta ? meta.specLabel : bar.label,
|
||||
familyId: meta?.familyId ?? "",
|
||||
familyName: meta?.familyName ?? "",
|
||||
specLabel: meta?.specLabel ?? "",
|
||||
specDetail: meta?.specDetail ?? null,
|
||||
variantLabel: meta?.variantLabel ?? "",
|
||||
specsHash: meta?.specsHash ?? "",
|
||||
materialRowId: meta?.materialRowId ?? "",
|
||||
brandName: meta?.brandName ?? null,
|
||||
unitLabel: unitCode ? data.units[unitCode] || unitCode : "",
|
||||
total,
|
||||
segments,
|
||||
@@ -681,7 +752,40 @@ export function DrilldownMatrix() {
|
||||
...segments,
|
||||
});
|
||||
}
|
||||
bars.sort((a, b) => b.total - a.total);
|
||||
// Tier1: در یک خانواده، برچسب تکراری با برند تفکیک میشود
|
||||
const labelCounts = new Map<string, number>();
|
||||
bars.forEach((bar) => {
|
||||
if (!bar.familyId) return;
|
||||
const key = `${bar.familyId}|${bar.axisLabel}`;
|
||||
labelCounts.set(key, (labelCounts.get(key) ?? 0) + 1);
|
||||
});
|
||||
bars.forEach((bar) => {
|
||||
if (!bar.familyId) return;
|
||||
const key = `${bar.familyId}|${bar.axisLabel}`;
|
||||
if ((labelCounts.get(key) ?? 0) > 1) {
|
||||
const suffix = bar.specDetail ?? bar.brandName;
|
||||
if (suffix) bar.axisLabel = `${bar.axisLabel} · ${suffix}`;
|
||||
}
|
||||
});
|
||||
|
||||
if (level === ROOT_LEVEL) {
|
||||
// گروهبندی پیوسته: خانوادهها بر اساس مجموع موجودی، واریانتها داخل هر خانواده
|
||||
const familyTotals = new Map<string, number>();
|
||||
bars.forEach((bar) => {
|
||||
familyTotals.set(bar.familyId, (familyTotals.get(bar.familyId) ?? 0) + bar.total);
|
||||
});
|
||||
bars.sort((a, b) => {
|
||||
if (a.familyId !== b.familyId) {
|
||||
return (
|
||||
(familyTotals.get(b.familyId) ?? 0) - (familyTotals.get(a.familyId) ?? 0) ||
|
||||
a.familyName.localeCompare(b.familyName, "fa")
|
||||
);
|
||||
}
|
||||
return a.axisLabel.localeCompare(b.axisLabel, "fa", { numeric: true });
|
||||
});
|
||||
} else {
|
||||
bars.sort((a, b) => b.total - a.total);
|
||||
}
|
||||
|
||||
// فهرست استکها = اجتماع گروههای حاضر؛ ترتیب (و بنابراین رنگها) از مجموع
|
||||
// کمیتها مشتق میشود و در حالت تساوی با نام، پایدار میماند.
|
||||
@@ -720,6 +824,28 @@ export function DrilldownMatrix() {
|
||||
// کانتینر (و کف تعیینشده) نمیشود.
|
||||
const chartWidth = Math.max(plotWidth, MIN_CHART_WIDTH, bars.length * AXIS_SLOT_PX);
|
||||
|
||||
// ——— هندسهی محور سلسلهمراتبی (Tier 2/3) ———
|
||||
// گروههای پیوستهی خانواده از ترتیب میلهها مشتق میشوند
|
||||
const familyGroups = useMemo(() => {
|
||||
const groups: { familyId: string; familyName: string; start: number; count: number }[] = [];
|
||||
if (level !== ROOT_LEVEL) return groups;
|
||||
bars.forEach((bar, index) => {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.familyId === bar.familyId) last.count += 1;
|
||||
else
|
||||
groups.push({ familyId: bar.familyId, familyName: bar.familyName, start: index, count: 1 });
|
||||
});
|
||||
return groups;
|
||||
}, [bars, level]);
|
||||
|
||||
const plotLeft = CHART_MARGIN_LEFT + Y_AXIS_WIDTH;
|
||||
const bandWidth =
|
||||
bars.length > 0 ? Math.max(0, chartWidth - plotLeft - CHART_MARGIN_RIGHT) / bars.length : 0;
|
||||
const plotAreaHeight =
|
||||
CHART_HEIGHT +
|
||||
(level === ROOT_LEVEL && familyGroups.length > 0 ? FAMILY_BAR_HEIGHT : 0) +
|
||||
SCROLLBAR_ALLOWANCE;
|
||||
|
||||
// ——— قفل هوشمند drill-down ———
|
||||
// اگر کاربر انبار(های) مشخصی را در دامنهی سراسری فیلتر کرده باشد، عمیقتر
|
||||
// رفتن روی نمودار بیمعناست؛ کلیک بیاثر و نشانگر پیشفرض میشود.
|
||||
@@ -732,18 +858,37 @@ export function DrilldownMatrix() {
|
||||
if (level !== ROOT_LEVEL) return;
|
||||
const bar = (entry as { payload?: StackedBar })?.payload;
|
||||
if (!bar) return;
|
||||
// همگامسازی دوطرفه: انتخاب میله → فیلتر سراسری (خانواده + مشخصه/واریانت)
|
||||
if (bar.familyId) {
|
||||
filter.setMaterialFamily(bar.familyId, bar.familyName);
|
||||
if (bar.specsHash && bar.specsHash !== "default") {
|
||||
filter.setSpec({ kind: "stockSpec", id: bar.specsHash, label: bar.specLabel });
|
||||
} else if (bar.materialRowId) {
|
||||
filter.setSpec({ kind: "variant", id: bar.materialRowId, label: bar.variantLabel });
|
||||
}
|
||||
}
|
||||
setSelectedMaterial({ id: bar.id, name: bar.label });
|
||||
};
|
||||
|
||||
// بازگشت کامل به «کل سازمان» — متریال انتخابی و هر دو دامنه پاک میشوند
|
||||
// بازگشت کامل به «کل سازمان» — فیلتر سراسری، دامنهها و مسیر drill پاک میشوند
|
||||
const resetToRoot = () => {
|
||||
setSelectedMaterial(null);
|
||||
setSelectedProjectIds([]);
|
||||
setSelectedWarehouseIds([]);
|
||||
filter.reset();
|
||||
};
|
||||
|
||||
// حذف تگ متریال — فقط حالت محلی نمودار صفر میشود و دامنههای سراسری دستنخورده میمانند
|
||||
const clearMaterial = () => setSelectedMaterial(null);
|
||||
// یک پله بالا: حذف مشخصه (خانواده میماند) و بازگشت نمودار به سطح خانواده
|
||||
const clearSpecAndDrill = () => {
|
||||
filter.setSpec(null);
|
||||
setSelectedMaterial(null);
|
||||
};
|
||||
|
||||
// انتخاب خانواده از برچسب محور → متریال = خانواده، مشخصات = «همه»
|
||||
const selectFamilyGroup = (familyId: string, familyName: string) => {
|
||||
filter.setMaterialFamily(familyId, familyName);
|
||||
setSelectedMaterial(null);
|
||||
};
|
||||
|
||||
// دامنهی پروژه فقط دامنهی انبارِ زیرمجموعهاش را آزاد میکند
|
||||
const handleProjectScopeChange = (ids: string[]) => {
|
||||
@@ -835,34 +980,33 @@ export function DrilldownMatrix() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* تگ متریال انتخابشده (سطح ۱) — بدنه و ✕ هر دو انتخاب را حذف میکنند */}
|
||||
{selectedMaterial && (
|
||||
{/* گرهی خانوادهی متریال (از فیلتر سراسری) — کلیک: حذف مشخصه و یک پله بالا */}
|
||||
{filter.materialFamilyId && (
|
||||
<>
|
||||
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<span className="inline-flex min-w-0 items-center gap-1 rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent">
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearMaterial}
|
||||
title={crumbHint(DRILL_LEVEL, selectedMaterial.name)}
|
||||
className="inline-flex min-w-0 cursor-pointer items-center gap-1 transition-opacity hover:opacity-80"
|
||||
>
|
||||
<Package aria-hidden className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="max-w-[10rem] truncate">{selectedMaterial.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
// ✕ نباید به handler بدنهی تگ برسد
|
||||
event.stopPropagation();
|
||||
clearMaterial();
|
||||
}}
|
||||
aria-label={LABELS.removeMaterial}
|
||||
title={LABELS.removeMaterial}
|
||||
className="inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full text-accent/70 transition-colors hover:bg-accent/10 hover:text-accent"
|
||||
>
|
||||
<X aria-hidden className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSpecAndDrill}
|
||||
title={`${LABELS.family}: ${filter.materialFamilyLabel ?? filter.materialFamilyId}`}
|
||||
className={crumbClass(!filter.spec)}
|
||||
>
|
||||
{filter.materialFamilyLabel ?? filter.materialFamilyId}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* گرهی مشخصه (از فیلتر سراسری) — کلیک: حذف مشخصه و بازگشت به خانواده */}
|
||||
{filter.spec && (
|
||||
<>
|
||||
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSpecAndDrill}
|
||||
title={LABELS.removeSpec}
|
||||
className={crumbClass(true)}
|
||||
>
|
||||
{filter.spec.label}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -889,8 +1033,26 @@ export function DrilldownMatrix() {
|
||||
ref={plotRef}
|
||||
dir="ltr"
|
||||
className="w-full overflow-x-auto overflow-y-hidden outline-none focus:outline-none [&_svg]:!outline-none [&_svg:focus]:!outline-none [&_svg:active]:!outline-none [&_svg:focus-visible]:!outline-none [&_.recharts-surface]:!outline-none [&_g]:!outline-none [&_g:focus]:!outline-none [&_g:focus-visible]:!outline-none [&_*]:!outline-none"
|
||||
style={{ width: '100%', height: CHART_HEIGHT }}
|
||||
style={{ width: '100%', height: plotAreaHeight }}
|
||||
>
|
||||
<div className="relative" style={{ width: chartWidth }}>
|
||||
{/* جداکنندههای عمودی نقطهچین بین خانوادهها (فقط نمای کلی) */}
|
||||
{level === ROOT_LEVEL &&
|
||||
familyGroups.slice(1).map((group) => (
|
||||
<span
|
||||
key={`divider-${group.familyId}`}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute border-s border-dashed border-slate-200"
|
||||
style={{
|
||||
left: plotLeft + bandWidth * group.start,
|
||||
top: CHART_MARGIN_TOP,
|
||||
height: Math.max(
|
||||
0,
|
||||
CHART_HEIGHT - AXIS_HEIGHT - CHART_MARGIN_TOP - CHART_MARGIN_BOTTOM,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* بدون لایهی دسترسپذیری، Recharts روی خود SVG دیگر tabIndex
|
||||
نمیگذارد؛ ولی لایههای z-index داخلیاش (g[tabindex="-1"]) هنوز
|
||||
فوکوسپذیرند و همانها منبع حلقهی فوکوساند — که با سلکتورهای
|
||||
@@ -901,11 +1063,16 @@ export function DrilldownMatrix() {
|
||||
data={bars}
|
||||
stackOffset="expand"
|
||||
accessibilityLayer={false}
|
||||
margin={{ top: 26, right: 12, left: 8, bottom: 12 }}
|
||||
margin={{
|
||||
top: CHART_MARGIN_TOP,
|
||||
right: CHART_MARGIN_RIGHT,
|
||||
left: CHART_MARGIN_LEFT,
|
||||
bottom: CHART_MARGIN_BOTTOM,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
dataKey="axisLabel"
|
||||
tick={AxisTick}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: "#e5e9f0" }}
|
||||
@@ -918,7 +1085,7 @@ export function DrilldownMatrix() {
|
||||
tickFormatter={percentTick}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={56}
|
||||
width={Y_AXIS_WIDTH}
|
||||
domain={[0, 1]}
|
||||
tickCount={5}
|
||||
/>
|
||||
@@ -947,6 +1114,36 @@ export function DrilldownMatrix() {
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
|
||||
{/* Tier 2/3: خط سقف خانواده + نام آن، وسطچین زیر همان بازه */}
|
||||
{level === ROOT_LEVEL && familyGroups.length > 0 && (
|
||||
<div className="relative" style={{ width: chartWidth, height: FAMILY_BAR_HEIGHT }}>
|
||||
{familyGroups.map((group) => {
|
||||
const left = plotLeft + bandWidth * group.start;
|
||||
const width = bandWidth * group.count;
|
||||
return (
|
||||
<div key={group.familyId} className="absolute top-0" style={{ left, width }}>
|
||||
<div className="relative mx-1 h-2">
|
||||
<span aria-hidden className="absolute inset-x-0 top-0 border-t border-slate-300" />
|
||||
<span aria-hidden className="absolute start-0 top-0 h-2 border-s border-slate-300" />
|
||||
<span aria-hidden className="absolute end-0 top-0 h-2 border-e border-slate-300" />
|
||||
</div>
|
||||
<div className="flex justify-center px-1 pt-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectFamilyGroup(group.familyId, group.familyName)}
|
||||
title={`${LABELS.family}: ${group.familyName}`}
|
||||
className="max-w-full cursor-pointer truncate text-[11.5px] font-extrabold text-slate-700 transition-colors hover:text-accent"
|
||||
>
|
||||
{group.familyName}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* راهنمای رنگها — از همان سگمنتهای payload ساخته میشود و نام
|
||||
|
||||
@@ -324,18 +324,18 @@ function dayGroups(day: FlowDay): CatMap {
|
||||
|
||||
export function FlowChart() {
|
||||
const filter = useDashboardFilter();
|
||||
// بدون متریال، کوئری اصلاً اجرا نمیشود (جلوگیری از تجمیع واحدهای ناسازگار)
|
||||
const query = useDailyFlow(undefined, undefined, Boolean(filter.materialId));
|
||||
// بدون خانوادهی متریال، کوئری اصلاً اجرا نمیشود (تجمیع واحدهای ناسازگار ممنوع)
|
||||
const query = useDailyFlow(undefined, undefined, Boolean(filter.materialFamilyId));
|
||||
const options = useDashboardFilterOptions();
|
||||
|
||||
const materials = options.data?.materials ?? [];
|
||||
const selectedMaterial = materials.find((m) => m.id === filter.materialId) ?? null;
|
||||
const materialName = selectedMaterial?.name ?? filter.materialLabel ?? "";
|
||||
const families = options.data?.families ?? [];
|
||||
const selectedFamily = families.find((family) => family.id === filter.materialFamilyId) ?? null;
|
||||
const materialName = selectedFamily?.name ?? filter.materialFamilyLabel ?? "";
|
||||
const unitLabel =
|
||||
query.data?.unit?.name_fa ??
|
||||
query.data?.unit?.code ??
|
||||
selectedMaterial?.unit?.name_fa ??
|
||||
selectedMaterial?.unit?.code ??
|
||||
selectedFamily?.unit?.name_fa ??
|
||||
selectedFamily?.unit?.code ??
|
||||
"";
|
||||
|
||||
// ——— همسازماندهی روزها + سگمنتهای شناور ———
|
||||
@@ -461,9 +461,12 @@ export function FlowChart() {
|
||||
const hasMultipleShades = visibleCategories.some((category) => groupCounts[category] > 1);
|
||||
|
||||
// ——— دروازهی تکمتریالی: بدون متریال، نمودار رندر نمیشود ———
|
||||
if (!filter.materialId) {
|
||||
if (!filter.materialFamilyId) {
|
||||
return (
|
||||
<ChartCard title={LABELS.title}>
|
||||
<ChartCard
|
||||
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden [&>header]:shrink-0"
|
||||
title={LABELS.title}
|
||||
>
|
||||
{options.isError ? (
|
||||
<FlowError />
|
||||
) : options.isLoading ? (
|
||||
@@ -471,31 +474,30 @@ export function FlowChart() {
|
||||
) : (
|
||||
<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"
|
||||
className="flex min-h-0 w-full flex-1 flex-col items-center justify-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 && (
|
||||
{families.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 ?? "";
|
||||
{families.map((family) => {
|
||||
const familyUnit = family.unit?.name_fa ?? family.unit?.code ?? "";
|
||||
return (
|
||||
<button
|
||||
key={material.id}
|
||||
key={family.id}
|
||||
type="button"
|
||||
onClick={() => filter.setMaterial(material.id, material.name)}
|
||||
title={material.name}
|
||||
onClick={() => filter.setMaterialFamily(family.id, family.name)}
|
||||
title={family.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="truncate">{family.name}</span>
|
||||
{familyUnit && (
|
||||
<span className="shrink-0 rounded-full bg-slate-bg px-1.5 text-[10px] font-medium text-muted">
|
||||
{materialUnit}
|
||||
{familyUnit}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -512,6 +514,7 @@ export function FlowChart() {
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden [&>header]:shrink-0"
|
||||
title={LABELS.title}
|
||||
sub={`${materialName}${unitLabel ? ` — ${LABELS.unit}: ${unitLabel}` : ""}`}
|
||||
>
|
||||
@@ -522,9 +525,9 @@ export function FlowChart() {
|
||||
) : !hasMovement ? (
|
||||
<ChartEmpty message={LABELS.empty} />
|
||||
) : (
|
||||
<div dir="rtl" className="flex flex-col gap-1">
|
||||
<div dir="rtl" className="flex min-h-0 flex-1 flex-col gap-1">
|
||||
{/* راهنمای رنگ دستهها (بهجای Legend پیشفرض؛ سریهای تنالیته معنا ندارند) */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
|
||||
<div className="flex shrink-0 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
|
||||
@@ -539,7 +542,7 @@ export function FlowChart() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div dir="ltr" className="h-72 w-full">
|
||||
<div dir="ltr" className="min-h-0 w-full grow basis-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={rows}
|
||||
|
||||
@@ -46,6 +46,9 @@ import type {
|
||||
// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد
|
||||
// (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد)
|
||||
export type DashboardFilterParams = {
|
||||
// خانوادهی متریال (نام پایه) — سرور به همهی واریانتهای خانواده map میکند
|
||||
material_family?: string | null;
|
||||
// واریانت مشخص (ردیف کاتالوگ) برای محدودکردن به یک سایز/گرید
|
||||
material_id?: string | null;
|
||||
brand_id?: string | null;
|
||||
specs_hash?: string | null;
|
||||
|
||||
+71
-17
@@ -222,18 +222,35 @@ export interface ConfirmedReceipt {
|
||||
export interface FilterSpecOption {
|
||||
hash: string;
|
||||
specs: Record<string, string>;
|
||||
// برچسب کوتاه مشخصه (مقدارها) — برای محور X و فهرست کشویی
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface FilterMaterialOption {
|
||||
id: string;
|
||||
name: string;
|
||||
// تفکیک «خانواده» (نام پایه) از «واریانت» (سایز/گرید) — از API
|
||||
family_id?: string;
|
||||
family_name?: string;
|
||||
variant_label?: string;
|
||||
unit: { id: number; code: string; name_fa: string | null } | null;
|
||||
brands: { id: string; name: string }[];
|
||||
specs: FilterSpecOption[];
|
||||
}
|
||||
|
||||
// خانوادهی متریال: چند ردیف کاتالوگ با یک نام پایه (سایزها/گریدها زیرمجموعه)
|
||||
export interface FilterFamilyOption {
|
||||
id: string;
|
||||
name: string;
|
||||
unit: { id: number; code: string; name_fa: string | null } | null;
|
||||
brands: { id: string; name: string }[];
|
||||
specs: { hash: string; label: string }[];
|
||||
materials: { id: string; name: string; variant_label: string }[];
|
||||
}
|
||||
|
||||
export interface DashboardFilterOptions {
|
||||
materials: FilterMaterialOption[];
|
||||
families: FilterFamilyOption[];
|
||||
}
|
||||
|
||||
// — ماتریس Drill-Down (کل سازمان → پروژه → انبار) —
|
||||
@@ -257,6 +274,15 @@ export interface DrilldownProject {
|
||||
export interface DrilldownMaterial {
|
||||
id: string;
|
||||
name: string;
|
||||
// ردیف کاتالوگ + برچسبهای محور (خانواده/واریانت/مشخصه) و برند
|
||||
material_id?: string;
|
||||
specs_hash?: string;
|
||||
family_id?: string;
|
||||
family_name?: string;
|
||||
variant_label?: string;
|
||||
spec_label?: string;
|
||||
spec_detail?: string | null;
|
||||
brand_name?: string | null;
|
||||
qty_by_unit: QtyByUnit;
|
||||
rows: number;
|
||||
projects: DrilldownProject[];
|
||||
@@ -315,25 +341,53 @@ export interface FlowData {
|
||||
}
|
||||
|
||||
// — کنترل روزانه —
|
||||
export interface DailyControlUnitStat {
|
||||
code: string;
|
||||
name_fa: string | null;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
// آمار هر دستهی عملیاتی: تعداد تراکنش، تعداد قلم کالای متمایز و تفکیک واحدها
|
||||
export interface DailyControlCategoryStats {
|
||||
tickets: number;
|
||||
items: number;
|
||||
qty: number;
|
||||
units: DailyControlUnitStat[];
|
||||
}
|
||||
|
||||
export type DailyControlPersonRole = "performer" | "recipient";
|
||||
|
||||
export interface DailyControlPerson {
|
||||
name: string;
|
||||
role: DailyControlPersonRole;
|
||||
tickets: number;
|
||||
categories: { outbound: number; returns: number; waste: number; purchases: number };
|
||||
}
|
||||
|
||||
export interface DailyControlItem {
|
||||
id: string;
|
||||
transaction_type: string;
|
||||
quantity: number; // علامتدار مطابق دفتر
|
||||
unit_code: string;
|
||||
unit_name_fa: string | null;
|
||||
performed_by: string | null;
|
||||
delivered_to: string | null;
|
||||
brand: string | null;
|
||||
note: string | null;
|
||||
warehouse: string;
|
||||
material: string;
|
||||
specs: Record<string, string>;
|
||||
transaction_date: string;
|
||||
}
|
||||
|
||||
export interface DailyControlData {
|
||||
date: string;
|
||||
outbound: { tickets: number; qty: number };
|
||||
returns: { tickets: number; qty: number };
|
||||
waste: { tickets: number; qty: number };
|
||||
purchases: { tickets: number; qty: number };
|
||||
performers: { name: string; tickets: number; qty: number }[];
|
||||
items: {
|
||||
id: string;
|
||||
transaction_type: string;
|
||||
quantity: number;
|
||||
unit_code: string;
|
||||
performed_by: string | null;
|
||||
note: string | null;
|
||||
warehouse: string;
|
||||
material: string;
|
||||
specs: Record<string, string>;
|
||||
transaction_date: string;
|
||||
}[];
|
||||
outbound: DailyControlCategoryStats;
|
||||
returns: DailyControlCategoryStats;
|
||||
waste: DailyControlCategoryStats;
|
||||
purchases: DailyControlCategoryStats;
|
||||
people: DailyControlPerson[];
|
||||
items: DailyControlItem[];
|
||||
}
|
||||
|
||||
// — بازرگانی: قیف تدارکات + KPI زمان تأمین (/quotes/stats) —
|
||||
|
||||
@@ -69,7 +69,7 @@ export function MvpDashboardPage() {
|
||||
{/* ردیف اول: ماتریس Drill-Down (تمام عرض — ستون فقرات نما) */}
|
||||
<DrilldownMatrix />
|
||||
{/* ردیف دوم: سری زمانی + کنترل روزانه */}
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<div className="grid h-auto min-w-0 items-stretch gap-3 lg:h-[550px] xl:grid-cols-2">
|
||||
<FlowChart />
|
||||
<DailyControlPanel />
|
||||
</div>
|
||||
|
||||
@@ -1,63 +1,68 @@
|
||||
// ============================================================
|
||||
// Zustand store — فیلتر سراسری «چه چیزی» داشبورد
|
||||
// متریال → برند → مشخصات فنی
|
||||
// متریال (خانواده) → برند → مشخصات فنی
|
||||
// «مشخصات» یکی از دو چیز است: واریانتِ کاتالوگ (سایز/گرید) یا مشخصهی موجودی.
|
||||
// بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ میشود.
|
||||
// ============================================================
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface DashboardFilterState {
|
||||
materialId: string | null; // null = همهی متریالها
|
||||
brandId: string | null;
|
||||
specsHash: string | null;
|
||||
// برچسبهای انتخابشده برای نمایش چیپ فعال
|
||||
materialLabel: string | null;
|
||||
brandLabel: string | null;
|
||||
specsLabel: string | null;
|
||||
// انتخاب مشخصه: واریانت (ردیف کاتالوگ) یا مشخصهی موجودی (specs_hash)
|
||||
export type SpecSelection =
|
||||
| { kind: "variant"; id: string; label: string }
|
||||
| { kind: "stockSpec"; id: string; label: string };
|
||||
|
||||
setMaterial: (id: string | null, label: string | null) => void;
|
||||
export interface DashboardFilterState {
|
||||
// شناسه/نام خانواده (نام پایه) — نه ردیف تکیِ کاتالوگ
|
||||
materialFamilyId: string | null;
|
||||
materialFamilyLabel: string | null;
|
||||
brandId: string | null;
|
||||
brandLabel: string | null;
|
||||
spec: SpecSelection | null;
|
||||
|
||||
setMaterialFamily: (id: string | null, label: string | null) => void;
|
||||
setBrand: (id: string | null, label: string | null) => void;
|
||||
setSpecs: (hash: string | null, label: string | null) => void;
|
||||
setSpec: (selection: SpecSelection | null) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const INITIAL = {
|
||||
materialId: null,
|
||||
materialFamilyId: null,
|
||||
materialFamilyLabel: null,
|
||||
brandId: null,
|
||||
specsHash: null,
|
||||
materialLabel: null,
|
||||
brandLabel: null,
|
||||
specsLabel: null,
|
||||
spec: null,
|
||||
};
|
||||
|
||||
export const useDashboardFilter = create<DashboardFilterState>((set) => ({
|
||||
...INITIAL,
|
||||
// آبشاری: تغییر هر سطح، سطحهای پاییندست را پاک میکند (متریال → برند → مشخصات)
|
||||
setMaterial: (id, label) =>
|
||||
// آبشاری: تغییر هر سطح، سطحهای پاییندست را پاک میکند
|
||||
setMaterialFamily: (id, label) =>
|
||||
set({
|
||||
materialId: id,
|
||||
materialLabel: label,
|
||||
materialFamilyId: id,
|
||||
materialFamilyLabel: label,
|
||||
brandId: null,
|
||||
brandLabel: null,
|
||||
specsHash: null,
|
||||
specsLabel: null,
|
||||
spec: null,
|
||||
}),
|
||||
setBrand: (id, label) =>
|
||||
set({
|
||||
brandId: id,
|
||||
brandLabel: label,
|
||||
specsHash: null,
|
||||
specsLabel: null,
|
||||
spec: null,
|
||||
}),
|
||||
setSpecs: (hash, label) => set({ specsHash: hash, specsLabel: label }),
|
||||
setSpec: (selection) => set({ spec: selection }),
|
||||
reset: () => set({ ...INITIAL }),
|
||||
}));
|
||||
|
||||
// پارامترهای آماده برای endpointهای داشبورد
|
||||
export function dashboardFilterParams(s: DashboardFilterState) {
|
||||
return {
|
||||
material_id: s.materialId,
|
||||
material_family: s.materialFamilyId,
|
||||
// واریانت = ردیف کاتالوگ همان سایز/گرید
|
||||
material_id: s.spec?.kind === "variant" ? s.spec.id : null,
|
||||
brand_id: s.brandId,
|
||||
specs_hash: s.specsHash,
|
||||
// مشخصهی موجودی
|
||||
specs_hash: s.spec?.kind === "stockSpec" ? s.spec.id : null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user