33 changed files with 6041 additions and 49 deletions
+71 -3
View File
@@ -384,11 +384,12 @@ async def ensure_v26(engine: AsyncEngine) -> None:
"ON CONFLICT (from_unit, to_unit) DO NOTHING", "ON CONFLICT (from_unit, to_unit) DO NOTHING",
c, c,
) )
# v2.5/v2.6 category decoupling from materials: drop trigger, function, and column. # v2.5/v2.6 category decoupling from materials: drop the old supplier-derived
# trigger/function. The `materials.category_id` COLUMN is NOT dropped here
# anymore — v2.8 re-introduces it (topic-backfilled) for the dashboard's
# Category→Material filter cascade (see ensure_dashboard_schema below).
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials") await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()") await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions") await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
await run_statement(""" await run_statement("""
@@ -558,3 +559,70 @@ async def ensure_project_transfers_table(engine: AsyncEngine) -> None:
await conn.execute(text(stmt)) await conn.execute(text(stmt))
logger.info("project_transfers table ensured") logger.info("project_transfers table ensured")
# ---------------------------------------------------------------------------
# v2.8 — MVP dashboard schema: materials.category_id + reporting indexes
# The dashboard's «What» filter cascade (Category → Material → Brand → Specs)
# needs a real materials.category_id link. v2.6 had deliberately dropped the
# old supplier-derived column; this is a fresh, topic-backfilled link
# (nullable — a material may stay uncategorized). The rest is B-tree index
# coverage for the aggregation queries in services/dashboard_service.py.
# Runs AFTER ensure_v26 so the 8 fixed categories always exist for the
# backfill. Every statement is idempotent.
# ---------------------------------------------------------------------------
_DASHBOARD_SCHEMA_DDL = [
# Fresh column (inline FK names the constraint materials_category_id_fkey,
# matching the DO-block guard below for volumes that predate v2.8).
"""
ALTER TABLE materials ADD COLUMN IF NOT EXISTS category_id UUID REFERENCES material_categories(id) ON DELETE SET NULL
""",
# Backfill from the legacy Persian topic text (NULL stays NULL — the UI
# groups those under «بدون دسته»).
"""
UPDATE materials SET category_id = mc.id
FROM material_categories mc
WHERE materials.topic = mc.name AND materials.category_id IS NULL
""",
# FK for the (rare) volume that already had the column without it.
"""
DO $$ BEGIN
ALTER TABLE materials ADD CONSTRAINT materials_category_id_fkey
FOREIGN KEY (category_id) REFERENCES material_categories(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL; END $$
""",
"CREATE INDEX IF NOT EXISTS idx_materials_category ON materials(category_id)",
# Drill-down / PM scoping: every dashboard query filters through it.
"CREATE INDEX IF NOT EXISTS idx_warehouses_project ON warehouses(project_id)",
# Daily flow + daily control (date-range aggregation).
"CREATE INDEX IF NOT EXISTS idx_inventory_tx_date ON inventory_transactions(transaction_date)",
# Type slices (withdrawal / return / waste) inside a date range.
"CREATE INDEX IF NOT EXISTS idx_inventory_tx_type_date ON inventory_transactions(transaction_type, transaction_date)",
# Funnel cumulative counts + lead-time average (partial — tiny & hot).
"CREATE INDEX IF NOT EXISTS idx_quotes_accepted ON quotes(accepted_date) WHERE accepted_date IS NOT NULL",
"CREATE INDEX IF NOT EXISTS idx_quotes_arrived ON quotes(arrived_date) WHERE arrived_date IS NOT NULL",
# Funnel per-status counts (existing composite leads with supplier_id).
"CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status)",
# Project-density workload grouping.
"CREATE INDEX IF NOT EXISTS idx_item_decisions_project ON item_decisions(project_id)",
# Receipt-level arrival reporting.
"CREATE INDEX IF NOT EXISTS idx_item_receipts_received ON item_receipts(received_at)",
# «What»-filtered stock scans across warehouses (existing composites lead
# with warehouse_id, useless for a bare material/brand filter).
"CREATE INDEX IF NOT EXISTS idx_inventory_material_brand ON inventory(material_id, brand_id)",
]
async def ensure_dashboard_schema(engine: AsyncEngine) -> None:
"""v2.8: materials.category_id (+ backfill) + dashboard reporting indexes.
Statement-per-transaction like ensure_v26: one failure can never block
the rest of the upgrade. Safe to call on every startup.
"""
for ddl in _DASHBOARD_SCHEMA_DDL:
try:
async with engine.begin() as conn:
await conn.execute(text(ddl))
except Exception: # pragma: no cover - pre-existing data conflicts
first_line = ddl.splitlines()[0][:100] if ddl.splitlines() else ddl[:100]
logger.exception("dashboard schema statement skipped (continuing): %s", first_line)
logger.info("dashboard schema ensured (materials.category_id + reporting indexes)")
+2
View File
@@ -28,6 +28,7 @@ from app.db.init_db import (
ensure_attachment_table, ensure_attachment_table,
ensure_audit_table, ensure_audit_table,
ensure_bot_drafts_table, ensure_bot_drafts_table,
ensure_dashboard_schema,
ensure_drop_category_tables, ensure_drop_category_tables,
ensure_dashboard_tables, ensure_dashboard_tables,
ensure_material_topic_column, ensure_material_topic_column,
@@ -109,6 +110,7 @@ async def lifespan(_app: FastAPI):
await ensure_quote_projects(engine) await ensure_quote_projects(engine)
await ensure_v26(engine) await ensure_v26(engine)
await ensure_rbac_schema(engine) await ensure_rbac_schema(engine)
await ensure_dashboard_schema(engine)
await ensure_audit_table(engine) await ensure_audit_table(engine)
await ensure_drop_category_tables(engine) await ensure_drop_category_tables(engine)
await ensure_bot_drafts_table(engine) await ensure_bot_drafts_table(engine)
+7 -1
View File
@@ -130,13 +130,18 @@ class CategoryAttribute(Base):
class Material(Base): class Material(Base):
__tablename__ = "materials" __tablename__ = "materials"
# v2.6/v2.7: name is GLOBALLY unique; decoupled from categories. # v2.6/v2.7: name is GLOBALLY unique; v2.8 re-links materials to the
# category taxonomy for the dashboard filter cascade (nullable — backfill
# from the legacy `topic` text runs in init_db.ensure_dashboard_schema).
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),) __table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
__mapper_args__ = {"eager_defaults": True} __mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk() id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False) name: Mapped[str] = mapped_column(Text, nullable=False)
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
category_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("material_categories.id", ondelete="SET NULL")
)
canonical_unit_id: Mapped[int] = mapped_column( canonical_unit_id: Mapped[int] = mapped_column(
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
) )
@@ -147,6 +152,7 @@ class Material(Base):
) )
canonical_unit: Mapped["Unit"] = relationship(lazy="raise") canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship( attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
back_populates="material", lazy="raise", passive_deletes=True back_populates="material", lazy="raise", passive_deletes=True
) )
+2
View File
@@ -4,6 +4,7 @@ from app.routers import (
auth, auth,
brand_materials, brand_materials,
brands, brands,
dashboard,
drafts, drafts,
files, files,
inventory, inventory,
@@ -53,6 +54,7 @@ ROUTERS = [
inventory.router, inventory.router,
pm.router, pm.router,
project_transfers.router, project_transfers.router,
dashboard.router,
users.router, users.router,
drafts.router, drafts.router,
] ]
+236
View File
@@ -0,0 +1,236 @@
"""MVP dashboard router — read-only aggregations for the warehouse +
procurement dashboard widgets consumed by the frontend `apiV26.dashboard`
client (frontend/src/lib/api.ts).
Scope (same resolution pattern as the inventory / pm routers):
- role 'pm' → limited to the projects they manage (projects.pm_user_id, 1:N);
an explicit ?project_id must be one of them.
- role 'admin' / 'procurement_manager' → whole organization; ?project_id is
an optional filter.
- Dev fallback via X-User-Role / X-Project-Id headers, ONLY when
PROCUREMENT_DEV_LOGIN_ENABLED=true (read-only endpoints, same gate as the
inventory router).
Endpoints:
- GET /dashboard/filters — «What» cascade options
- GET /dashboard/inventory/drilldown — org → project → warehouse tree
- GET /dashboard/inventory/dead-stock — dormant stock table
- GET /dashboard/inventory/flow — daily inbound/outbound/returns/balance
- GET /dashboard/daily-control — today's tickets + performers
- GET /dashboard/procurement/workload — workload per project (density)
Every response is the standard `{"data": …}` envelope.
"""
import uuid
from dataclasses import dataclass
from datetime import date as date_cls
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user
from app.config import settings
from app.db.engine import get_db
from app.services import dashboard_service
from app.utils.errors import (
ForbiddenError,
UnauthorizedError,
ValidationError,
)
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@dataclass
class DashboardScope:
role: str # "pm" | "office"
project_ids: list | None # PM: managed project ids; office: None = all
project_id: uuid.UUID | None # explicit ?project_id filter (validated for PM)
async def dashboard_scope(
request: Request,
project_id: uuid.UUID | None = Query(default=None, description="فیلتر پروژه (PM: فقط پروژه‌های خودش)"),
db: AsyncSession = Depends(get_db),
) -> DashboardScope:
"""Resolve the dashboard caller — Casdoor JWT first, dev headers as fallback."""
if request.headers.get("authorization"):
user: UserContext | None = await get_optional_user(request, db)
if user is not None:
if user.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
if user.role == "pm":
allowed = list(user.project_ids or [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return DashboardScope(role="pm", project_ids=allowed, project_id=project_id)
if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
return DashboardScope(role="pm", project_ids=allowed, project_id=None)
if user.role in ("admin", "procurement_manager"):
return DashboardScope(role="office", project_ids=None, project_id=project_id)
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا دفتر تدارکات است")
if not settings.DEV_LOGIN_ENABLED or not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
role = (request.headers.get("x-user-role") or "").strip().lower()
if role == "procurement_manager" or role == "admin":
role = "office"
elif role == "pm":
role = "pm"
else:
raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role")
if role == "pm":
raw = request.headers.get("x-project-id")
if not raw:
raise ValidationError("هدر X-Project-Id (پروژه مسئول) برای نقش PM الزامی است", field="X-Project-Id")
try:
if project_id is None:
project_id = uuid.UUID(str(raw))
except ValueError:
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
return DashboardScope(role="pm", project_ids=[project_id], project_id=project_id)
return DashboardScope(role="office", project_ids=None, project_id=project_id)
def _what_filters(
material_id: str | None,
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).
• material_family → همه‌ی ردیف‌های کاتالوگِ همان خانواده (سرویس حل می‌کند).
"""
resolved_material: uuid.UUID | None = None
resolved_specs = specs_hash or spec_id
if material_id:
try:
resolved_material = uuid.UUID(str(material_id))
except (ValueError, AttributeError, TypeError):
# شناسه‌ی مشخصه (نه UUID ماده) — به‌عنوان دامنه‌ی مشخصه استفاده می‌شود
resolved_specs = resolved_specs or str(material_id)
return {
"material_id": resolved_material,
"brand_id": brand_id,
"specs_hash": resolved_specs,
"material_family": (material_family or "").strip() or None,
}
@router.get("/filters")
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
"""Options for the global filter bar (Material→Brand→Specs)."""
return await dashboard_service.filter_options(db)
@router.get("/inventory/drilldown")
async def inventory_drilldown(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
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="خانواده‌ی متریال (نام پایه)"),
):
"""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, material_family),
)
@router.get("/inventory/dead-stock")
async def dead_stock(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
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(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
idle_days=idle_days,
limit=limit,
**_what_filters(material_id, brand_id, specs_hash, spec_id, material_family),
)
@router.get("/inventory/flow")
async def inventory_flow(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیش‌فرض ۳۰ روز اخیر)"),
date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"),
warehouse_id: uuid.UUID | None = Query(default=None, description="اختیاری — بدون آن همه‌ی انبارها"),
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="خانواده‌ی متریال (نام پایه)"),
):
"""Daily flow series: ورود / خروج (با لایه‌ی ضایعات) / برگشت / مانده."""
today = datetime.now(dashboard_service.TEHRAN).date()
date_from = date_from or (today - timedelta(days=29))
date_to = date_to or (today + timedelta(days=1))
if date_to <= date_from:
raise ValidationError("بازه‌ی تاریخ نامعتبر است — «تا» باید بعد از «از» باشد", field="date_to")
if (date_to - date_from).days > 180:
raise ValidationError("بازه‌ی تاریخ حداکثر ۱۸۰ روز است", field="date_to")
return await dashboard_service.daily_flow(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
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, material_family),
)
@router.get("/daily-control")
async def daily_control(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
date: date_cls | None = Query(default=None, description="روز موردنظر (ISO، پیش‌فرض امروز)"),
warehouse_id: uuid.UUID | None = Query(default=None),
):
"""Daily control: today's outbound/return tickets + exact performer names."""
day = date or datetime.now(dashboard_service.TEHRAN).date()
return await dashboard_service.daily_control(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
day=day,
warehouse_id=warehouse_id,
)
@router.get("/procurement/workload")
async def procurement_workload(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
):
"""Project density: توزیع بار تدارکات (پیشنهادهای باز + خطوط خرید جاری) روی پروژه‌ها."""
return await dashboard_service.procurement_workload(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
)
+35 -3
View File
@@ -38,13 +38,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user from app.auth import UserContext, get_optional_user
from app.config import settings from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import BrandMaterial, Material, Unit from app.models.catalog import AttributeDefinition, Brand, BrandMaterial, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
ItemReceipt, ItemReceipt,
ItemReceiptAllocation, ItemReceiptAllocation,
LineItem, LineItem,
LineItemSpec,
Project, Project,
Quote, Quote,
Supplier, Supplier,
@@ -182,16 +183,24 @@ def arrival_state(ordered: Decimal, received: Decimal | None) -> str:
async def pm_on_the_way( async def pm_on_the_way(
ctx: ArrivalScope = Depends(arrivals_scope), ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
material_id: uuid.UUID | None = Query(default=None, description="فیلتر متریال (داشبورد بازرگانی)"),
brand_id: uuid.UUID | None = Query(default=None, description="فیلتر برند (داشبورد بازرگانی)"),
): ):
stmt = ( stmt = (
select(ItemDecision, LineItem, Quote, Material, Supplier, Project) select(ItemDecision, LineItem, Quote, Material, Supplier, Project, BrandMaterial, Brand)
.join(LineItem, LineItem.id == ItemDecision.line_item_id) .join(LineItem, LineItem.id == ItemDecision.line_item_id)
.join(Quote, Quote.id == LineItem.quote_id) .join(Quote, Quote.id == LineItem.quote_id)
.join(Material, Material.id == LineItem.material_id) .join(Material, Material.id == LineItem.material_id)
.join(Supplier, Supplier.id == Quote.supplier_id) .join(Supplier, Supplier.id == Quote.supplier_id)
.outerjoin(Project, Project.id == ItemDecision.project_id) .outerjoin(Project, Project.id == ItemDecision.project_id)
.outerjoin(BrandMaterial, BrandMaterial.id == LineItem.brand_material_id)
.outerjoin(Brand, Brand.id == BrandMaterial.brand_id)
.where(ItemDecision.status == "chosen") .where(ItemDecision.status == "chosen")
) )
if material_id is not None:
stmt = stmt.where(Material.id == material_id)
if brand_id is not None:
stmt = stmt.where(BrandMaterial.brand_id == brand_id)
if ctx.role == "pm": if ctx.role == "pm":
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else []) allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if not allowed: if not allowed:
@@ -211,8 +220,29 @@ async def pm_on_the_way(
# unit / category lookups for display # unit / category lookups for display
unit_ids = {d.LineItem.unit_id for d in decisions} unit_ids = {d.LineItem.unit_id for d in decisions}
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()} units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
# frozen spec snapshot per line item (key = attribute label)
li_ids = [d.LineItem.id for d in decisions]
specs_by_line: dict = {}
if li_ids:
spec_rows = (
await db.execute(
select(LineItemSpec, AttributeDefinition)
.join(AttributeDefinition, AttributeDefinition.id == LineItemSpec.attribute_definition_id)
.where(LineItemSpec.line_item_id.in_(li_ids))
)
).all()
for spec, ad in spec_rows:
value = spec.value_string
if value is None and spec.value_number is not None:
value = str(spec.value_number)
if value is None and spec.value_boolean is not None:
value = str(spec.value_boolean)
if value is None and spec.value_date is not None:
value = spec.value_date.isoformat()
if value is not None:
specs_by_line.setdefault(spec.line_item_id, {})[ad.label or ad.attribute_key] = value
out = [] out = []
for d, li, q, m, sup, proj in decisions: for d, li, q, m, sup, proj, bm, br in decisions:
received = received_map.get(d.id) received = received_map.get(d.id)
out.append({ out.append({
"item_decision_id": str(d.id), "item_decision_id": str(d.id),
@@ -224,6 +254,8 @@ async def pm_on_the_way(
"project": {"id": str(proj.id), "name": proj.name} if proj else None, "project": {"id": str(proj.id), "name": proj.name} if proj else None,
"decided_at": to_iso(d.decided_at), "decided_at": to_iso(d.decided_at),
"material": {"id": str(m.id), "name": m.name}, "material": {"id": str(m.id), "name": m.name},
"brand": {"id": str(br.id), "name": br.name} if br else None,
"specs": specs_by_line.get(li.id, {}),
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code, "unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None, "name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
"ordered_qty": str(li.quantity), "ordered_qty": str(li.quantity),
-19
View File
@@ -122,25 +122,6 @@ async def delete_supplier_material(
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}} return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
@router.get("/materials/{material_id}/suppliers")
async def list_material_suppliers(
material_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
async with db.begin():
await unlink_supplier_material(db, supplier_id, material_id)
return {
"data": {
"supplier_id": str(supplier_id),
"material_id": str(material_id),
"deleted": True,
}
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GET /materials/{material_id}/suppliers # GET /materials/{material_id}/suppliers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+980
View File
@@ -0,0 +1,980 @@
"""Aggregation queries behind the MVP dashboard (warehouse + procurement).
Read-only service. Every function receives the resolved scope (PM → the list
of projects they manage; office roles → project_ids=None = whole
organization) plus an optional explicit project filter, and the optional
«What» filters (material_id / brand_id / specs_hash).
Quantities are NEVER mixed across units: inventory rows live in the
material's canonical unit, so stock aggregates carry their unit code and the
UI picks one unit at a time. Numeric results leave the service chart-ready
(floats); identifiers and labels are plain strings.
Response shapes mirror frontend/src/lib/types.ts (v2.8 dashboard DTOs)
exactly — this module is the server side of that contract.
"""
import uuid
from datetime import date as date_cls
from datetime import datetime, time, timedelta, timezone
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from app.models.catalog import Brand, BrandMaterial, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import (
ItemDecision,
ItemReceipt,
LineItem,
Project,
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.
TEHRAN = timezone(timedelta(hours=3, minutes=30))
# Flow buckets: outbound = site consumption + shrinkage; waste is reported
# separately so the UI can stack it as a distinct red segment inside the
# outbound bar. Transfers are excluded from the buckets (they are internal
# moves) but DO count toward the running balance.
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."""
if material_id is not None:
stmt = stmt.where(InventoryItem.material_id == material_id)
if brand_id is not None:
stmt = stmt.where(InventoryItem.brand_id == brand_id)
if specs_hash:
stmt = stmt.where(InventoryItem.specs_hash == specs_hash)
return stmt
def _scope_filters(stmt, *, project_ids, project_id):
"""warehouse.project_id restriction for the resolved dashboard scope."""
if project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id)
elif project_ids is not None:
stmt = stmt.where(Warehouse.project_id.in_(project_ids))
return stmt
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
# ---------------------------------------------------------------------------
async def filter_options(db: AsyncSession) -> dict:
"""Options for the global filter bar (Material → Brand → Specs).
Materials come from the catalog (active only). Brand options per material
are the UNION of the catalog links (brand_materials — every brand defined
for that material) and the distinct brands actually present in stock, so
the cascade is complete even for materials whose current stock rows carry
no brand yet. Spec options come from stock rows (inventory.specs_hash),
since specs are a stock-level dimension.
"""
materials = (
await db.execute(select(Material).where(Material.status == "active").order_by(Material.name))
).scalars().all()
units = {u.id: u for u in (await db.execute(select(Unit))).scalars()}
inv_rows = (
await db.execute(
select(
InventoryItem.material_id,
InventoryItem.brand_id,
InventoryItem.specs_hash,
InventoryItem.specs,
Brand.name,
)
.select_from(InventoryItem)
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
.distinct()
)
).all()
# برندهای کاتالوگی هر متریال (brand_materials) — مستقل از موجودی فعلی
catalog_brand_rows = (
await db.execute(
select(BrandMaterial.material_id, Brand.id, Brand.name)
.select_from(BrandMaterial)
.join(Brand, Brand.id == BrandMaterial.brand_id)
.where(BrandMaterial.is_active.is_(True))
)
).all()
brands_by_material: dict[uuid.UUID, dict] = {}
specs_by_material: dict[uuid.UUID, dict[str, dict]] = {}
for material_id, brand_id, brand_name in catalog_brand_rows:
brands_by_material.setdefault(material_id, {})[brand_id] = brand_name
for material_id, brand_id, specs_hash, specs, brand_name in inv_rows:
if brand_id is not None:
brands_by_material.setdefault(material_id, {}).setdefault(brand_id, brand_name)
if specs_hash:
specs_by_material.setdefault(material_id, {})[specs_hash] = specs or {}
def material_entry(m: Material) -> 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,
"label": spec_label_from_specs(sp) or DEFAULT_SPEC_LABEL,
}
for h, sp in sorted(spec_map.items())
],
}
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}}
# ---------------------------------------------------------------------------
# Widget 1 — Drill-down matrix (organization → project → warehouse)
# ---------------------------------------------------------------------------
async def inventory_drilldown(
db: AsyncSession,
*,
project_ids,
project_id,
material_id=None,
brand_id=None,
specs_hash=None,
material_family=None,
) -> dict:
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse.
The x-axis mirrors the *catalog*, not the ledger: the statement starts from
``materials`` and LEFT OUTER JOINs the per-(brand, specs, project,
warehouse, unit) stock aggregation. Active catalog materials without any
ledger rows therefore stay on the chart with ``COALESCE``d zero quantities
(labelled with their canonical unit) instead of disappearing in an inner
join.
"""
# تجمیع دفتر انبار به‌ازای (ماده، برند، مشخصات، پروژه، انبار، واحد) — سمت
# راست LEFT JOIN؛ محدودیت دامنه (پروژه‌های مجاز) همین‌جا اعمال می‌شود تا
# مواد بدون موجودیِ داخل دامنه هم از دست نروند.
stock = (
select(
InventoryItem.material_id.label("material_id"),
InventoryItem.brand_id.label("brand_id"),
InventoryItem.specs_hash.label("specs_hash"),
InventoryItem.specs.label("specs"),
Project.id.label("pid"),
Project.name.label("pname"),
Warehouse.id.label("wid"),
Warehouse.name.label("wname"),
Unit.code.label("ucode"),
Unit.name_fa.label("uname_fa"),
func.sum(InventoryItem.quantity).label("qty"),
func.count().label("rows"),
)
.select_from(InventoryItem)
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
.join(Project, Project.id == Warehouse.project_id)
.join(Unit, Unit.id == InventoryItem.unit_id)
.group_by(
InventoryItem.material_id,
InventoryItem.brand_id,
InventoryItem.specs_hash,
InventoryItem.specs,
Project.id,
Project.name,
Warehouse.id,
Warehouse.name,
Unit.code,
Unit.name_fa,
)
)
stock = _scope_filters(stock, project_ids=project_ids, project_id=project_id)
stock_sq = stock.subquery()
# واحد کاتالوگی ماده — برچسب واحدِ ردیف‌های صفر («۰ عدد»)
canon_unit = aliased(Unit)
stmt = (
select(
Material.id.label("mid"),
Material.name.label("mname"),
Brand.name.label("bname"),
stock_sq.c.specs,
stock_sq.c.specs_hash,
stock_sq.c.pid,
stock_sq.c.pname,
stock_sq.c.wid,
stock_sq.c.wname,
stock_sq.c.ucode,
stock_sq.c.uname_fa,
func.coalesce(stock_sq.c.qty, 0).label("qty"),
func.coalesce(stock_sq.c.rows, 0).label("rows"),
canon_unit.code.label("canon_ucode"),
canon_unit.name_fa.label("canon_uname_fa"),
)
.select_from(Material)
.outerjoin(stock_sq, stock_sq.c.material_id == Material.id)
.outerjoin(Brand, Brand.id == stock_sq.c.brand_id)
.outerjoin(canon_unit, canon_unit.id == Material.canonical_unit_id)
.where(Material.status == "active")
)
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود می‌کنند
if material_id is not None:
stmt = stmt.where(Material.id == material_id)
if material_family:
stmt = stmt.where(Material.id.in_(await _family_material_ids(db, material_family)))
# برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان
# موجودی روی محور می‌مانند (رفتار قبلی دست‌نخورده)
if brand_id is not None:
stmt = stmt.where(stock_sq.c.brand_id == brand_id)
if specs_hash:
stmt = stmt.where(stock_sq.c.specs_hash == specs_hash)
rows = (await db.execute(stmt)).all()
units_legend: dict[str, str] = {}
total_by_unit: dict[str, float] = {}
total_rows = 0
materials_tree: dict[str, dict] = {}
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)
# افسانه‌ی واحدها و جمع کل؛ مواد بدون ردیف موجودی با واحد کاتالوگی و صفر
if ucode is not None:
units_legend[ucode] = uname_fa
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
elif canon_ucode is not None:
units_legend.setdefault(canon_ucode, canon_uname_fa)
total_by_unit.setdefault(canon_ucode, 0.0)
total_rows += row_count
# Build full material name
parts = [mname]
if bname:
parts.append(bname)
if specs:
parts.extend(str(v) for v in specs.values() if v)
full_name = " ".join(parts)
# Use full_name + unit as unique key for material level, or just full_name
mat_key = f"{mid}_{full_name}"
# برچسب‌های محور X: خانواده (نام پایه) و مشخصه/واریانت — مشتق از نام کاتالوگ
labels = material_entry_labels(mname, specs)
mat = materials_tree.setdefault(
mat_key,
{
"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)
elif canon_ucode is not None:
mat["qty_by_unit"].setdefault(canon_ucode, 0.0)
mat["rows"] += row_count
# مواد بدون هیچ ردیف موجودی: فقط روی محور می‌مانند (بدون پروژه/انبار)
if pid is None:
continue
proj = mat["_projects"].setdefault(
pid,
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}},
)
proj["qty_by_unit"][ucode] = round(proj["qty_by_unit"].get(ucode, 0.0) + q, 4)
proj["rows"] += row_count
wh = proj["_warehouses"].setdefault(wid, {"id": str(wid), "name": wname, "qty_by_unit": {}, "rows": 0})
wh["qty_by_unit"][ucode] = round(wh["qty_by_unit"].get(ucode, 0.0) + q, 4)
wh["rows"] += row_count
materials = []
for mat in materials_tree.values():
projects_list = []
for proj in mat.pop("_projects").values():
warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"])
projects_list.append({**proj, "warehouses": warehouses})
projects_list.sort(key=lambda p: p["name"])
materials.append({**mat, "projects": projects_list})
materials.sort(key=lambda m: m["name"])
return {
"data": {
"units": units_legend,
"total": {"qty_by_unit": total_by_unit, "rows": total_rows},
"materials": materials,
}
}
# ---------------------------------------------------------------------------
# Widget 2 — Dead stock (dormant capital)
# ---------------------------------------------------------------------------
async def dead_stock(
db: AsyncSession,
*,
project_ids,
project_id,
idle_days: int = 60,
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`.
Last activity = MAX(inventory_transactions.transaction_date) per row —
served by idx_inventory_tx_inventory_date — falling back to
inventory.updated_at for rows that never posted a transaction.
"""
last_tx = (
select(func.max(InventoryTransaction.transaction_date))
.where(InventoryTransaction.inventory_id == InventoryItem.id)
.correlate(InventoryItem)
.scalar_subquery()
)
last_activity = func.coalesce(last_tx, InventoryItem.updated_at)
cutoff = datetime.now(TEHRAN) - timedelta(days=idle_days)
stmt = (
select(
InventoryItem.id,
InventoryItem.quantity,
InventoryItem.specs,
Material.name,
Brand.name,
Warehouse.name,
Project.name,
Unit.code,
Unit.name_fa,
last_activity.label("last_activity"),
)
.select_from(InventoryItem)
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
.join(Project, Project.id == Warehouse.project_id)
.join(Material, Material.id == InventoryItem.material_id)
.join(Unit, Unit.id == InventoryItem.unit_id)
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
.where(InventoryItem.quantity > 0, last_activity <= cutoff)
.order_by(last_activity.asc())
.limit(limit)
)
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()
now = datetime.now(TEHRAN)
items = []
for inv_id, quantity, specs, mat_name, brand_name, wh_name, proj_name, ucode, uname_fa, last in rows:
items.append({
"inventory_id": str(inv_id),
"material": mat_name,
"brand": brand_name,
"specs": specs or {},
"warehouse": wh_name,
"project": proj_name,
"quantity": _qty(quantity),
"unit": {"code": ucode, "name_fa": uname_fa},
"last_activity": last.isoformat() if last else None,
"idle_days": (now - last).days if last else None,
})
return {"data": {"idle_days_threshold": idle_days, "items": items}}
# ---------------------------------------------------------------------------
# Widget 3 — Daily time-series flow (inbound / outbound / returns / balance)
# ---------------------------------------------------------------------------
async def daily_flow(
db: AsyncSession,
*,
project_ids,
project_id,
date_from: datetime,
date_to: datetime,
warehouse_id=None,
material_id=None,
brand_id=None,
specs_hash=None,
material_family=None,
) -> dict:
"""Per-day buckets from the signed ledger.
inbound = PURCHASE · returns = RETURN · outbound = WITHDRAWAL + waste
(DAMAGE/WASTE/LOSS, reported separately for the stacked red segment).
balance = running sum of EVERY signed movement (transfers/found/stock
counts included), seeded with the opening balance before `date_from`.
Each day also carries a `breakdown` list — the ledger rows behind that
day's aggregates, grouped by (category, transaction type, specs, brand,
warehouse) — so the chart can subdivide categories into tonal shades and
the tooltip can itemize them. When a single material is selected, the
payload also carries that material's canonical `unit`.
"""
def _with_joins(sel, *, include_brand: bool = False):
# ترتیب join‌ها مهم است: برند فقط بعد از join شدن inventory قابل ارجاع است
stmt = (
sel.select_from(InventoryTransaction)
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
.join(Material, Material.id == InventoryItem.material_id)
)
if include_brand:
stmt = stmt.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
return stmt
scope_conditions = []
if project_id is not None:
scope_conditions.append(Warehouse.project_id == project_id)
elif project_ids is not None:
scope_conditions.append(Warehouse.project_id.in_(project_ids))
if warehouse_id is not None:
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
what_conditions = []
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:
what_conditions.append(InventoryItem.brand_id == brand_id)
if specs_hash:
what_conditions.append(InventoryItem.specs_hash == specs_hash)
# «روز» بر مبنای تقویم تهران است؛ بدون این تبدیل، تراکنش‌های بامداد
# (۰۰:۰۰ تا ۰۳:۳۰ تهران) به روز قبلِ UTC می‌افتادند.
day_expr = func.date_trunc(
"day", func.timezone("Asia/Tehran", InventoryTransaction.transaction_date)
).label("day")
# ریزتراکنش‌ها به‌ازای (نوع، مشخصات، برند، انبار) گروه می‌شوند تا هم تجمیع
# روزانه و هم شکست چندبعدی نمودار از یک کوئری واحد بیایند (بدون تکرار رکورد).
detail_rows = (
await db.execute(
_with_joins(
select(
day_expr,
InventoryTransaction.transaction_type,
InventoryItem.specs,
Brand.name,
Warehouse.name,
func.sum(InventoryTransaction.quantity),
)
.where(
InventoryTransaction.transaction_date >= date_from,
InventoryTransaction.transaction_date < date_to,
*scope_conditions,
*what_conditions,
)
.group_by(
day_expr,
InventoryTransaction.transaction_type,
InventoryItem.specs,
Brand.name,
Warehouse.name,
),
include_brand=True,
)
)
).all()
opening = _qty(
await db.scalar(
_with_joins(
select(func.coalesce(func.sum(InventoryTransaction.quantity), 0.0)).where(
InventoryTransaction.transaction_date < date_from,
*scope_conditions,
*what_conditions,
)
)
)
)
def category_of(tx_type: str) -> str:
if tx_type == "PURCHASE":
return "inflow"
if tx_type == "RETURN":
return "returns"
if tx_type == "WITHDRAWAL":
return "siteOut"
if tx_type in WASTE_TYPES:
return "waste"
# انتقال‌ها/شمارش/یافت‌شده — خارج از چهار سبد نمایشی، علامت‌دار
return "other"
signed_by_day: dict[date_cls, dict[str, float]] = {}
breakdown_by_day: dict[date_cls, list[dict]] = {}
for day, tx_type, specs, brand_name, warehouse_name, signed in detail_rows:
day_key = day.date() if isinstance(day, datetime) else day
tx_type = str(tx_type)
qty = _qty(signed)
day_types = signed_by_day.setdefault(day_key, {})
day_types[tx_type] = round(day_types.get(tx_type, 0.0) + qty, 4)
breakdown_by_day.setdefault(day_key, []).append({
"category": category_of(tx_type),
"transaction_type": tx_type,
"specs": specs or None,
"brand": brand_name or None,
"warehouse": warehouse_name,
"quantity": qty,
})
unit = None
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 == unit_material_id)
)
).first()
if unit_row is not None:
unit = {"code": unit_row[0], "name_fa": unit_row[1]}
days = []
running = opening
current = date_from.date()
while current < date_to.date():
types = signed_by_day.get(current, {})
waste = round(-sum(types.get(t, 0.0) for t in WASTE_TYPES), 4) or 0.0
outbound = round(waste - types.get("WITHDRAWAL", 0.0), 4) or 0.0
running = round(running + sum(types.values()), 4)
days.append({
"date": current.isoformat(),
"inbound": round(types.get("PURCHASE", 0.0), 4) or 0.0,
"outbound": outbound,
"waste": waste,
"returns": round(types.get("RETURN", 0.0), 4) or 0.0,
"balance": running,
"breakdown": breakdown_by_day.get(current, []),
})
current += timedelta(days=1)
return {"data": {"opening_balance": opening, "unit": unit, "days": days}}
# ---------------------------------------------------------------------------
# Widget 4 — Daily control (today's site operations)
# ---------------------------------------------------------------------------
async def daily_control(
db: AsyncSession,
*,
project_ids,
project_id,
day: date_cls,
warehouse_id=None,
) -> dict:
"""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)
scope_conditions = []
if project_id is not None:
scope_conditions.append(Warehouse.project_id == project_id)
elif project_ids is not None:
scope_conditions.append(Warehouse.project_id.in_(project_ids))
if warehouse_id is not None:
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
# ۱) شمارش/کمیت هر دسته، تفکیک‌شده بر اساس واحد + تعداد قلم کالای متمایز
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, 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(
InventoryTransaction,
Warehouse.name,
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_(OPS_TYPES),
*scope_conditions,
)
.order_by(InventoryTransaction.transaction_date.desc())
.limit(200)
)
).all()
items = []
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": 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,
"specs": specs or {},
"transaction_date": tx.transaction_date.isoformat(),
})
return {
"data": {
"date": day.isoformat(),
"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"
],
}
}
# ---------------------------------------------------------------------------
# Widget 8 — Procurement workload per project (project density)
# ---------------------------------------------------------------------------
async def procurement_workload(db: AsyncSession, *, project_ids, project_id) -> dict:
"""Per project: offered quotes still open + chosen purchase lines (open
vs fully received). Chosen lines attribute to item_decisions.project_id
(the purchased destination); offered quotes to any linked project."""
received_sq = (
select(ItemReceipt.item_decision_id.label("did"), func.sum(ItemReceipt.received_qty).label("received_qty"))
.group_by(ItemReceipt.item_decision_id)
.subquery()
)
order_conditions = [ItemDecision.status == "chosen"]
if project_id is not None:
order_conditions.append(ItemDecision.project_id == project_id)
elif project_ids is not None:
order_conditions.append(ItemDecision.project_id.in_(project_ids))
order_rows = (
await db.execute(
select(
Project.id,
Project.name,
func.count().label("chosen_items"),
func.sum(LineItem.quantity).label("ordered_qty"),
func.coalesce(func.sum(received_sq.c.received_qty), 0).label("received_qty"),
func.sum(
case(
(func.coalesce(received_sq.c.received_qty, 0) < LineItem.quantity, 1),
else_=0,
)
).label("open_items"),
)
.select_from(ItemDecision)
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
.join(Quote, Quote.id == LineItem.quote_id)
.outerjoin(Project, Project.id == ItemDecision.project_id)
.outerjoin(received_sq, received_sq.c.did == ItemDecision.id)
.where(*order_conditions)
.group_by(Project.id, Project.name)
)
).all()
offered_by_project: dict[uuid.UUID | None, set] = {}
offered_primary = (
await db.execute(
select(Quote.id, Quote.project_id).where(
Quote.status == "offered", Quote.project_id.is_not(None)
)
)
).all()
for quote_id, pid in offered_primary:
offered_by_project.setdefault(pid, set()).add(quote_id)
offered_junction = (
await db.execute(
select(QuoteProjects.quote_id, QuoteProjects.project_id)
.join(Quote, Quote.id == QuoteProjects.quote_id)
.where(Quote.status == "offered")
)
).all()
for quote_id, pid in offered_junction:
offered_by_project.setdefault(pid, set()).add(quote_id)
project_ids_needed = {row[0] for row in order_rows if row[0] is not None}
project_ids_needed.update(pid for pid in offered_by_project if pid is not None)
names: dict[uuid.UUID, str] = {}
if project_ids_needed:
names = {
p.id: p.name
for p in (
await db.execute(select(Project).where(Project.id.in_(project_ids_needed)))
).scalars()
}
projects = []
for pid, _pname, chosen, ordered, received, open_items in order_rows:
if pid is None:
offered = 0
else:
offered = len(offered_by_project.pop(pid, set()))
if project_id is not None and pid != project_id:
offered = 0
elif project_ids is not None and pid not in project_ids:
offered = 0
projects.append({
"id": str(pid) if pid else None,
"name": names.get(pid, "بدون پروژه") if pid else "بدون پروژه",
"offered_quotes": offered,
"chosen_items": chosen,
"open_orders": open_items,
"ordered_qty": _qty(ordered),
"received_qty": _qty(received),
})
# offered-only projects that have no chosen lines yet
for pid, quote_ids in offered_by_project.items():
if not quote_ids:
continue
if project_id is not None and pid != project_id:
continue
if project_ids is not None and (pid is None or pid not in project_ids):
continue
projects.append({
"id": str(pid) if pid else None,
"name": names.get(pid, "بدون پروژه") if pid else "بدون پروژه",
"offered_quotes": len(quote_ids),
"chosen_items": 0,
"open_orders": 0,
"ordered_qty": 0.0,
"received_qty": 0.0,
})
projects.sort(key=lambda p: (-p["open_orders"], -p["offered_quotes"], p["name"]))
return {"data": {"projects": projects}}
+121
View File
@@ -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,
}
+16
View File
@@ -1100,6 +1100,17 @@ async def quote_stats(
*conditions, *conditions,
) )
) )
# Procurement funnel (cumulative, not the current-state counts above):
# a quote counts as accepted/arrived if it EVER reached that stage.
funnel_row = (
await db.execute(
select(
func.count().label("offered_total"),
func.count().filter(Quote.accepted_date.is_not(None)).label("accepted_total"),
func.count().filter(Quote.arrived_date.is_not(None)).label("arrived_total"),
).where(*conditions)
)
).one()
return { return {
"total": sum(counts.values()), "total": sum(counts.values()),
"offered": counts["offered"], "offered": counts["offered"],
@@ -1108,6 +1119,11 @@ async def quote_stats(
"rejected": counts["rejected"], "rejected": counts["rejected"],
"expired": counts["expired"], "expired": counts["expired"],
"average_lead_time_days": int(round(float(avg))) if avg is not None else None, "average_lead_time_days": int(round(float(avg))) if avg is not None else None,
"funnel": {
"offered_total": funnel_row.offered_total,
"accepted_total": funnel_row.accepted_total,
"arrived_total": funnel_row.arrived_total,
},
} }
+83
View File
@@ -262,8 +262,11 @@ async def run() -> None:
material_by_name: dict[str, Material] = {} material_by_name: dict[str, Material] = {}
brand_material_by_material: dict[str, object] = {} brand_material_by_material: dict[str, object] = {}
for name, cat_slug, unit_code, brand_name in MATERIALS: for name, cat_slug, unit_code, brand_name in MATERIALS:
cat = await get_category(db, cat_slug)
material = Material( material = Material(
name=name, name=name,
topic=cat.name,
category_id=cat.id, # v2.8: dashboard Category→Material cascade
canonical_unit_id=unit_ids[unit_code], canonical_unit_id=unit_ids[unit_code],
status="active", status="active",
) )
@@ -441,6 +444,86 @@ async def run() -> None:
)) ))
await db.flush() await db.flush()
# ---- MVP dashboard demo ledger -----------------------------------
# Multi-day history so every widget has real data to show:
# - two old PURCHASEs 70/85 days ago → dead stock rows (60-90d)
# - withdrawals/returns/waste over the last ~12 days → flow series
# - tickets dated TODAY (performed_by names) → daily control
# Quantities are hand-checked to keep every row >= 0.
async def ensure_inv(mat_idx: int, project_idx: int, wh_idx: int) -> InventoryItem:
mat = material_by_name[MATERIALS[mat_idx][0]]
wh = warehouse_rows[project_idx][wh_idx]
inv = await db.scalar(select(InventoryItem).where(
InventoryItem.warehouse_id == wh.id,
InventoryItem.material_id == mat.id,
InventoryItem.specs_hash == "default",
))
if inv is None:
inv = InventoryItem(
warehouse_id=wh.id, material_id=mat.id, brand_id=None,
quantity=Decimal("0"), unit_id=mat.canonical_unit_id,
specs={}, specs_hash="default",
)
db.add(inv)
await db.flush()
return inv
async def add_tx(inv: InventoryItem, wh: Warehouse, tx_type: str, qty: int,
days_ago: int, performed_by: str, note: str | None = None) -> None:
inv.quantity += Decimal(str(qty))
db.add(InventoryTransaction(
inventory_id=inv.id,
warehouse_id=wh.id,
transaction_type=tx_type,
quantity=Decimal(str(qty)),
performed_by=performed_by,
note=note or "تاریخچه دمو داشبورد",
transaction_date=d(days_ago),
))
wh_aram, wh_sepehr, wh_shargh = warehouse_rows[0], warehouse_rows[1], warehouse_rows[2]
# dead stock: cable + switches bought long ago, untouched since
cable = await ensure_inv(0, 0, 0)
await add_tx(cable, wh_aram[0], "PURCHASE", 800, 70, "حسین موسوی", "خرید اولیه — دمو")
keys = await ensure_inv(1, 0, 0)
await add_tx(keys, wh_aram[0], "PURCHASE", 200, 85, "حسین موسوی", "خرید اولیه — دمو")
# cement (انبار مرکزی آرام is project0 — cement lives in شرق اصلی)
cement = await ensure_inv(4, 2, 0)
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -80, 10, "رضا محمدی")
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -60, 8, "رضا محمدی")
await add_tx(cement, wh_shargh[0], "RETURN", 20, 7, "علی رضایی", "برگشت از سایت — مازاد")
await add_tx(cement, wh_shargh[0], "DAMAGE", -10, 5, "کاربر تست")
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -50, 1, "رضا محمدی")
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -30, 0, "رضا محمدی")
await add_tx(cement, wh_shargh[0], "RETURN", 12, 0, "علی رضایی", "برگشت از سایت — امروز")
blocks = await ensure_inv(5, 2, 1)
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -400, 12, "رضا محمدی")
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -300, 9, "کاربر تست")
await add_tx(blocks, wh_shargh[1], "LOSS", -15, 6, "کاربر تست")
await add_tx(blocks, wh_shargh[1], "WASTE", -25, 4, "رضا محمدی")
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -250, 0, "حسین موسوی")
pe16_a = await ensure_inv(6, 1, 0)
await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -100, 6, "محمد محقق")
await add_tx(pe16_a, wh_sepehr[0], "RETURN", 25, 3, "محمد محقق", "برگشت — برش اضافه")
await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -80, 0, "محمد محقق")
pe16_b = await ensure_inv(6, 1, 1)
await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -150, 11, "محمد محقق")
await add_tx(pe16_b, wh_sepehr[1], "WASTE", -20, 7, "محمد محقق")
await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -120, 2, "محمد محقق")
pe25 = await ensure_inv(7, 1, 0)
await add_tx(pe25, wh_sepehr[0], "WITHDRAWAL", -60, 9, "محمد محقق")
await add_tx(pe25, wh_sepehr[0], "RETURN", 10, 5, "محمد محقق")
await add_tx(pe25, wh_sepehr[0], "DAMAGE", -8, 3, "محمد محقق")
await db.flush()
await db.commit() await db.commit()
# summary # summary
+390
View File
@@ -28,6 +28,7 @@
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-hook-form": "^7.85.0", "react-hook-form": "^7.85.0",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
@@ -1366,6 +1367,32 @@
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.13.0.tgz",
"integrity": "sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
@@ -1714,6 +1741,18 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -2003,6 +2042,69 @@
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.13.3", "version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@@ -2033,6 +2135,12 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "6.0.5", "version": "6.0.5",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
@@ -2127,6 +2235,133 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2155,6 +2390,24 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types",
"tests/browser-compat"
]
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2201,6 +2454,25 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/immer": {
"version": "11.1.21",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.21.tgz",
"integrity": "sha512-bZSadeCPGdaY5/qRLgr6mDSH1ra21rD3rqpiWk7+QTr4ayYPTE9qZYdLh/NdPhMe0wSrTvHD/Lk8q5EwhUs0aw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2645,6 +2917,36 @@
"react": "^16.8.0 || ^17 || ^18 || ^19" "react": "^16.8.0 || ^17 || ^18 || ^19"
} }
}, },
"node_modules/react-is": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz",
"integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -2752,6 +3054,57 @@
} }
} }
}, },
"node_modules/recharts": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz",
"integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
@@ -2884,6 +3237,12 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -2970,6 +3329,37 @@
} }
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.2.1", "version": "8.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
+1
View File
@@ -30,6 +30,7 @@
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-hook-form": "^7.85.0", "react-hook-form": "^7.85.0",
"react-router-dom": "^7.18.2", "react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
+15
View File
@@ -46,6 +46,10 @@ const AdminPanelPage = lazy(() =>
const RequesterHome = lazy(() => const RequesterHome = lazy(() =>
import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })), import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })),
); );
// داشبورد تحلیلی MVP (انبار + بازرگانی) — ویجت‌های Power-BI-style
const MvpDashboardPage = lazy(() =>
import("./pages/Dashboard/MvpDashboardPage").then((m) => ({ default: m.MvpDashboardPage })),
);
function Loading() { function Loading() {
return ( return (
@@ -386,6 +390,17 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
{/* داشبورد تحلیلی MVP (انبار + بازرگانی) — برای PM نیز در دسترس */}
<Route
path="/dashboard"
element={
<RequireAuth>
<RequirePmProjects>
<MvpDashboardPage />
</RequirePmProjects>
</RequireAuth>
}
/>
<Route <Route
path="/units" path="/units"
element={ element={
+73 -12
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, type RefObject } from "react"; import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { X } from "lucide-react";
import { normalize } from "../lib/normalize"; import { normalize } from "../lib/normalize";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside"; import { useClickOutside } from "./useClickOutside";
@@ -16,6 +17,9 @@ export interface ComboboxProps {
value?: string; value?: string;
onChange?: (item: ComboboxItem) => void; onChange?: (item: ComboboxItem) => void;
items: ComboboxSource; // آرایه از string یا {id, name} items: ComboboxSource; // آرایه از string یا {id, name}
// شناسه‌ی آیتم‌هایی که همیشه در ابتدای فهرست می‌مانند و با جستجوی کاربر
// فیلتر نمی‌شوند (مثل گزینه‌ی «همه‌ی …» در فیلترهای آبشاری داشبورد)
pinnedIds?: string[];
allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید" allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید"
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
addSuffix?: string; // متن بعد از + addSuffix?: string; // متن بعد از +
@@ -26,6 +30,9 @@ export interface ComboboxProps {
onAdd?: (val: string) => void; onAdd?: (val: string) => void;
onInputChange?: (val: string) => void; onInputChange?: (val: string) => void;
onBlur?: () => void; onBlur?: () => void;
// با ارائه‌ی این تابع، دکمه‌ی «×» داخل فیلد ظاهر می‌شود تا انتخاب فعلی
// (و فیلتر وابسته به آن) به‌تنهایی پاک شود.
onClear?: () => void;
// برای فوکوس برنامه‌ای (مثل بازگشت فوکوس پس از ثبت پیوسته) // برای فوکوس برنامه‌ای (مثل بازگشت فوکوس پس از ثبت پیوسته)
inputRef?: RefObject<HTMLInputElement | null>; inputRef?: RefObject<HTMLInputElement | null>;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند — // با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
@@ -46,6 +53,7 @@ export function Combobox({
value, value,
onChange, onChange,
items, items,
pinnedIds,
allowAdd = true, allowAdd = true,
addPrefix, addPrefix,
addSuffix = "به عنوان گزینه جدید", addSuffix = "به عنوان گزینه جدید",
@@ -56,12 +64,17 @@ export function Combobox({
onAdd, onAdd,
onInputChange, onInputChange,
onBlur, onBlur,
onClear,
inputRef, inputRef,
openOnFocus = true, openOnFocus = true,
}: ComboboxProps) { }: ComboboxProps) {
const [text, setText] = useState(value ?? ""); const [text, setText] = useState(value ?? "");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(-1); const [highlight, setHighlight] = useState(-1);
// کلیکِ روی اینپوتِ در حالت فوکوس‌نشده، اول onFocus را صدا می‌زند و منو باز
// می‌شود؛ این نشانگر می‌گوید «همین لحظه با فوکوس باز شد» تا همان کلیک
// دوباره منو را نبندد. کلیک‌های بعدی = تاگل باز/بسته.
const justOpenedByFocus = useRef(false);
useEffect(() => { useEffect(() => {
setText(value ?? ""); setText(value ?? "");
@@ -79,19 +92,37 @@ export function Combobox({
return all.filter((x) => normalize(x.label).includes(q)); return all.filter((x) => normalize(x.label).includes(q));
}, [all, q]); }, [all, q]);
const showAdd = // آیتم‌های pin‌شده همیشه در ابتدای فهرست می‌مانند و با جستجو حذف نمی‌شوند؛
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q); // ناوبری با کلیدها هم روی همین فهرست نهایی (visible) انجام می‌شود.
const { visible, pinnedCount, hasUnpinned } = useMemo(() => {
const pin = pinnedIds ?? [];
if (pin.length === 0) return { visible: filtered, pinnedCount: 0, hasUnpinned: false };
const pinned = pin
.map((id) => all.find((x) => x.id === id))
.filter((x): x is ComboboxItem => x !== undefined);
const pinnedSet = new Set(pinned.map((x) => x.id));
const rest = filtered.filter((x) => !pinnedSet.has(x.id));
return { visible: [...pinned, ...rest], pinnedCount: pinned.length, hasUnpinned: rest.length > 0 };
}, [all, filtered, pinnedIds]);
const listCount = filtered.length + (showAdd ? 1 : 0); const showAdd =
allowAdd && q.length > 0 && !visible.some((x) => normalize(x.label) === q);
const showClear = Boolean(onClear) && !disabled && text.length > 0;
const listCount = visible.length + (showAdd ? 1 : 0);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false)); const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
const pick = (it: ComboboxItem) => { const pick = (it: ComboboxItem) => {
setText(it.label); // آیتم با شناسه‌ی خالی یعنی «انتخاب نشده» (گزینه‌ی «همه‌ی …»)؛ فیلد به
// placeholder برمی‌گردد تا متن/دکمه‌ی × برای مقدار پاک‌شده باقی نماند.
const nextText = it.id ? it.label : "";
setText(nextText);
setOpen(false); setOpen(false);
setHighlight(-1); setHighlight(-1);
onChange?.(it); onChange?.(it);
onInputChange?.(it.label); onInputChange?.(nextText);
}; };
const addCurrent = () => { const addCurrent = () => {
@@ -116,9 +147,9 @@ export function Combobox({
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1)); setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
if (highlight >= 0 && highlight < filtered.length) { if (highlight >= 0 && highlight < visible.length) {
pick(filtered[highlight]); pick(visible[highlight]);
} else if (highlight === filtered.length && showAdd) { } else if (highlight === visible.length && showAdd) {
addCurrent(); addCurrent();
} }
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
@@ -133,6 +164,7 @@ export function Combobox({
ref={inputRef} ref={inputRef}
className={cn( className={cn(
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50", "flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
showClear && "pe-8",
inputClassName, inputClassName,
)} )}
value={text} value={text}
@@ -146,24 +178,53 @@ export function Combobox({
onInputChange?.(e.target.value); onInputChange?.(e.target.value);
}} }}
onFocus={() => { onFocus={() => {
if (openOnFocus) setOpen(true); if (openOnFocus && !open) {
setOpen(true);
justOpenedByFocus.current = true;
}
}}
onClick={() => {
// اولین کلیک بعد از فوکوس = همان بازشدن؛ کلیک‌های بعدی تاگل می‌کنند
if (justOpenedByFocus.current) {
justOpenedByFocus.current = false;
return;
}
setOpen((o) => !o);
}} }}
onClick={() => setOpen(true)}
onBlur={onBlur} onBlur={onBlur}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
{showClear && (
<button
type="button"
aria-label="پاک کردن"
title="پاک کردن"
className="absolute end-2 top-1/2 z-10 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full text-muted transition-colors hover:bg-slate-bg hover:text-ink"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
setText("");
setHighlight(-1);
setOpen(true);
onClear?.();
}}
>
<X className="h-3.5 w-3.5" />
</button>
)}
{open && ( {open && (
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg"> <div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
{listCount === 0 && ( {listCount === 0 && (
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div> <div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
)} )}
{filtered.map((it, i) => ( {visible.map((it, i) => (
<button <button
type="button" type="button"
key={it.id + "|" + i} key={it.id + "|" + i}
className={cn( className={cn(
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg", "block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
i === highlight && "bg-slate-bg", i === highlight && "bg-slate-bg",
pinnedCount > 0 && hasUnpinned && i === pinnedCount - 1 && "border-b border-line",
)} )}
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); // حفظ فوکوس input e.preventDefault(); // حفظ فوکوس input
@@ -0,0 +1,604 @@
// ============================================================
// ویجت ۴ — کنترل روزانه (عملیات امروز)
// • کارت‌های 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, 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";
// دسته‌های عملیاتی امروز — عنوان، واحد شمارش فارسی، آیکون و رنگ‌بندی
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_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({
category,
stats,
active,
onToggle,
}: {
category: OpsCategory;
stats: DailyControlCategoryStats;
active: boolean;
onToggle: () => void;
}) {
const meta = CATEGORY_META[category];
const Icon = meta.icon;
return (
<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>
{/* متریک اصلی */}
<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 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();
// مدل نمایشیِ نرمال‌شده: هیچ آرایه‌ای 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={
view?.date
? `تاریخ ${isoToJalaliStr(view.date) ?? view.date} — هر دقیقه به‌روزرسانی می‌شود`
: "هر دقیقه به‌روزرسانی می‌شود"
}
>
{query.isLoading ? (
<ChartLoading />
) : query.isError || !view ? (
<ChartEmpty message="داده‌ی امروز در دسترس نیست" />
) : (
<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="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={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="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={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>
)}
</div>
</div>
</div>
)}
</ChartCard>
);
}
@@ -0,0 +1,185 @@
// ============================================================
// فیلتر سراسری «چه چیزی» — آبشار: متریال (خانواده) → برند → مشخصات فنی
// • «متریال» فقط خانواده‌های پایه را نشان می‌دهد (بلوک سیمانی، لوله پلی‌اتیلن…)
// نه ردیف‌های دارای سایز/گرید.
// • «مشخصات فنی» پس از انتخاب خانواده، واریانت‌های همان خانواده (سایز/گرید)
// و مشخصه‌های موجودیِ آن خانواده را فهرست می‌کند.
// • همه‌ی گزینه‌ها از API مشتق می‌شوند (بدون هیچ لیست یا نام هاردکدشده).
// • سه فیلتر وسط‌چین و متقارن‌اند؛ روی موبایل تک‌ستونی می‌شوند.
// وضعیت در Zustand نگه داشته می‌شود تا با جابه‌جایی بین تب‌ها از دست نرود.
// ============================================================
import { useMemo } from "react";
import { Filter, X } from "lucide-react";
import { Combobox, type ComboboxItem } from "../Combobox";
import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
import { useDashboardFilter } from "../../store/dashboardFilter";
import { faNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
// گزینه‌ی «همه» — شناسه‌ی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox)
const ALL_OPTION_ID = "";
const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID];
// پیشوندهای شناسه‌ی گزینه‌های «مشخصات» — واریانت کاتالوگ یا مشخصه‌ی موجودی
const SPEC_VARIANT_PREFIX = "v:";
const SPEC_STOCK_PREFIX = "s:";
const LABELS = {
allFamilies: "همه‌ی متریال‌ها",
allBrands: "همه‌ی برندها",
allSpecs: "همه‌ی مشخصات",
familyFirst: "ابتدا متریال را انتخاب کنید",
clearAll: "حذف فیلترها",
wholeOrg: "نمای کل سازمان",
cascade: "متریال ← برند ← مشخصات فنی",
} as const;
const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label });
export function DashboardFilterBar({ className }: { className?: string }) {
const filterQuery = useDashboardFilterOptions();
const state = useDashboardFilter();
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.materialFamilyId, state.brandId, state.spec].filter(Boolean).length;
const pickFamily = (item: ComboboxItem) =>
item.id === ALL_OPTION_ID
? state.setMaterialFamily(null, null)
: state.setMaterialFamily(item.id, item.label);
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),
...(selectedFamily?.brands ?? []).map((brand) => ({ id: brand.id, label: brand.name })),
],
[selectedFamily],
);
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
className={cn("rounded-2xl border border-line bg-panel p-3 sm:p-4", className)}
aria-label="فیلتر سراسری متریال"
>
<div className="mb-2.5 flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-bg text-accent">
<Filter className="h-3.5 w-3.5" />
</span>
<div>
<h2 className="text-[13px] font-extrabold leading-5">فیلتر جنس</h2>
<p className="text-[10.5px] text-muted">{LABELS.cascade}</p>
</div>
{activeCount > 0 && (
<button
type="button"
onClick={state.reset}
aria-label={`${LABELS.clearAll} (بازگشت به ${LABELS.wholeOrg})`}
title={`${LABELS.clearAll} — ${LABELS.wholeOrg}`}
className="group ms-auto inline-flex items-center gap-1.5 rounded-full border border-red/20 bg-red-bg px-2.5 py-1.5 text-[11px] font-bold text-red shadow-sm transition-all hover:border-red/40 hover:shadow active:scale-[0.98]"
>
<X aria-hidden className="h-3.5 w-3.5 transition-transform group-hover:rotate-90" />
{LABELS.clearAll}
<span className="rounded-full bg-panel/60 px-1.5 leading-4 tabular-nums">
{faNum(activeCount)}
</span>
</button>
)}
</div>
{/* سه فیلتر با عرض یکسان، وسط‌چین و متقارن؛ روی موبایل تک‌ستونی می‌شود */}
<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.materialFamilyLabel ?? ""}
placeholder={LABELS.allFamilies}
allowAdd={false}
disabled={families.length === 0}
items={familyItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setMaterialFamily(null, null)}
onChange={pickFamily}
/>
{/* ۲ — برند (برندهای همان خانواده) */}
<Combobox
value={state.brandLabel ?? ""}
placeholder={selectedFamily ? LABELS.allBrands : LABELS.familyFirst}
allowAdd={false}
disabled={!selectedFamily || selectedFamily.brands.length === 0}
items={brandItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setBrand(null, null)}
onChange={pickBrand}
/>
{/* ۳ — مشخصات فنی (واریانت‌ها + مشخصه‌های موجودی همان خانواده) */}
<Combobox
value={state.spec?.label ?? ""}
placeholder={selectedFamily ? LABELS.allSpecs : LABELS.familyFirst}
allowAdd={false}
disabled={!selectedFamily}
items={specItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setSpec(null)}
onChange={pickSpec}
/>
</div>
</section>
);
}
@@ -0,0 +1,278 @@
// ============================================================
// ویجت ۲ — موجودی راکد (سرمایه‌ی خوابیده) — فقط‌خواندنی
// داشبورد برای پایش و تحلیل است، نه ویرایش: هیچ استپر/آستانه‌ی ردیفی یا
// پنهان‌سازی وجود ندارد. همه‌ی ردیف‌ها با داده‌ی خام API رندر می‌شوند و
// مرتب‌سازی سه‌حالته (صعودی ← نزولی ← پیش‌فرض) روی هدر سه ستون کلیدی است.
// ============================================================
import { useMemo, useState } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown, Snowflake } from "lucide-react";
import { useDeadStock } from "../../hooks/useDashboardData";
import { faNum, fmtNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
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 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 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
title={
<span className="inline-flex items-center gap-1.5">
<Snowflake className="h-4 w-4 text-[#0ea5e9]" />
موجودی راکد — سرمایه‌ی خوابیده
</span>
}
sub={LABELS.sub}
>
{query.isLoading ? (
<ChartLoading />
) : items.length === 0 ? (
<ChartEmpty message={LABELS.empty} />
) : (
<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>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,617 @@
// ============================================================
// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور، تک‌متریالی)
// هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز»
// کشیده می‌شود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
// روی مسیر واقعی مانده می‌نشینند، پس ستون دقیقاً پله‌ی خط stepAfter را می‌سازد.
//
// قواعد کسب‌وکار:
// • جمع‌زدن چند متریال با واحدهای متفاوت (متر + کیلوگرم + عدد) بی‌معناست؛ پس
// بدون انتخاب متریال، نمودار رندر نمی‌شود و به‌جایش انتخاب سریع متریال می‌آید.
// • هر دسته (ورود/برگشت/خروج/ضایعات/سایر) می‌تواند چند «گروه» (مشخصات + برند)
// داشته باشد؛ هر گروه یک پله‌ی روشن‌تر از خانواده‌ی رنگ همان دسته می‌گیرد و
// سگمنت‌ها یک ستون مشترک با جداکننده‌ی سفید ۱px می‌سازند.
// • همه‌ی موجودیت‌ها (متریال/مشخصات/برند/انبار/واحد) از payload مشتق می‌شوند.
// ============================================================
import { useMemo } from "react";
import {
Bar,
CartesianGrid,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Package } from "lucide-react";
import { useDailyFlow, useDashboardFilterOptions } from "../../hooks/useDashboardData";
import { useDashboardFilter } from "../../store/dashboardFilter";
import { isoToJalaliStr } from "../../lib/jalali";
import { fmtNum } from "../../lib/formatters";
import { ChartCard, ChartEmpty, ChartLoading, faAxisTick } from "./chartShared";
import type { FlowBreakdownItem, FlowCategory, FlowDay } from "../../lib/types";
// ——— چیدمان ———
const FLOW_STACK_ID = "flow-waterfall";
const MAX_BAR_SIZE = 20;
const BAR_CATEGORY_GAP = "18%";
const DOMAIN_PAD_RATIO = 0.1; // ۱۰٪ سرصفحه بالا/پایین دامنه
const OTHER_RESIDUAL_KEY = "__adjust__";
const CATEGORY_ORDER: FlowCategory[] = ["inflow", "returns", "siteOut", "waste", "other"];
// خانواده‌ی رنگی هر دسته (HSL پایه) + جهت علامت در مسیر مانده
const CATEGORY_META: Record<
FlowCategory,
{ label: string; sign: 1 | -1; base: { h: number; s: number; l: number } }
> = {
inflow: { label: "ورود به انبار", sign: 1, base: { h: 160, s: 84, l: 39 } }, // زمردی
returns: { label: "برگشت از سایت", sign: 1, base: { h: 175, s: 77, l: 41 } }, // تیل
siteOut: { label: "خروج به سایت", sign: -1, base: { h: 35, s: 92, l: 47 } }, // کهربایی
waste: { label: "ضایعات و اتلاف", sign: -1, base: { h: 0, s: 72, l: 51 } }, // قرمز
other: { label: "سایر حرکات", sign: 1, base: { h: 215, s: 16, l: 55 } }, // خاکستری
};
// تنالیته‌ی گروه‌های یک دسته: هر گروه (مشخصات+برند) یک پله روشن‌تر
const SHADE_STEP_L = 9;
const SHADE_MAX_L = 78;
function categoryColor(category: FlowCategory, shadeIndex: number): string {
const { h, s, l } = CATEGORY_META[category].base;
const lightness = Math.min(SHADE_MAX_L, l + shadeIndex * SHADE_STEP_L);
return `hsl(${h} ${s}% ${lightness}%)`;
}
const LABELS = {
title: "جریان روزانه موجودی",
needMaterial:
"برای مشاهده‌ی جریان روزانه و تحلیل دقیق تغییرات موجودی، لطفاً یک متریال را از فیلترهای بالا انتخاب کنید.",
quickPick: "انتخاب سریع متریال",
empty: "در این بازه جابه‌جایی موجودی ثبت نشده است",
opening: "مانده ابتدای روز",
net: "تغییر خالص روز",
closing: "مانده پایان روز",
unit: "واحد",
brand: "برند",
warehouse: "انبار",
noSpecs: "بدون مشخصه",
aggregate: "کل دسته",
adjustment: "تعدیل حساب",
shadeHint: "طیف روشن‌تر هر رنگ = برند/مشخصات دیگر در همان روز",
error: "دریافت داده‌ی جریان روزانه ناموفق بود",
errorHint: "اتصال به سرور را بررسی کنید؛ اگر ادامه داشت با مدیر سیستم تماس بگیرید.",
} as const;
// ——— مدل داده ———
type Range = [number, number]; // [پایین، بالا] — سگمنت شناور <Bar>
interface CatItem {
key: string; // هویت گروه: مشخصات|برند
specLabel: string;
brand: string | null;
warehouse: string;
quantity: number; // چهار دسته: قدر مطلق — «سایر»: علامت‌دار
}
interface CatGroup {
key: string;
label: string;
brand: string | null;
quantity: number; // مجموع گروه (برای «سایر» علامت‌دار)
items: CatItem[]; // ریزردیف‌ها برای تولتیپ
}
type CatMap = Record<FlowCategory, CatGroup[]>;
interface FlowRow {
date: string;
opening: number;
closing: number;
net: number;
balance: number;
groups: CatMap; // متادیتای تولتیپ (dataKey نیست)
hasMovement: boolean;
// سگمنت‌ها با کلیدهای مسطح seg_<category>_<shadeIndex>
[segmentKey: string]: unknown;
}
const round4 = (v: number) => Math.round(v * 10000) / 10000;
const signed = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`;
// خطای شبکه/سرور باید صریح دیده شود — هرگز نباید «داده‌ای ثبت نشده» تلقّی شود
function FlowError() {
return (
<div
dir="rtl"
className="flex h-40 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-red/30 bg-red-bg/40 px-4 text-center"
>
<span className="text-[12px] font-bold text-red">{LABELS.error}</span>
<span className="max-w-md text-[11px] leading-5 text-muted">{LABELS.errorHint}</span>
</div>
);
}
// برچسب مشخصات فنی: «کلید: مقدار، …» یا «بدون مشخصه»
function specLabel(specs: Record<string, string> | null): string {
const parts = Object.entries(specs ?? {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : LABELS.noSpecs;
}
// ——— تولتیپ تحلیلی RTL ———
function FlowTooltip({
active,
payload,
unitLabel,
materialName,
}: {
active?: boolean;
payload?: readonly { payload?: FlowRow }[];
unitLabel: string;
materialName: string;
}) {
if (!active || !payload?.length) return null;
const row = payload[0]?.payload;
if (!row) return null;
const withUnit = (value: string) => (unitLabel ? `${value} ${unitLabel}` : value);
const netTone =
row.net > 0 ? "text-emerald-600" : row.net < 0 ? "text-red-600" : "text-slate-600";
return (
<div
dir="rtl"
className="w-max max-w-[85vw] whitespace-normal rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md sm:max-w-[380px] sm:whitespace-nowrap"
>
{/* سربرگ: تاریخ + متریال + نشان واحد */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-bold text-slate-800">
{isoToJalaliStr(row.date) ?? row.date}
</span>
{materialName && <span className="text-[11px] text-slate-500">{materialName}</span>}
{unitLabel && (
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold text-slate-500">
{unitLabel}
</span>
)}
</div>
<div className="my-2 border-b border-slate-100" />
{/* مانده ابتدای روز */}
<div className="flex items-center justify-between gap-6">
<span className="text-[11px] text-slate-500 sm:text-xs">{LABELS.opening}</span>
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
{withUnit(fmtNum(row.opening))}
</span>
</div>
<div className="my-2 border-b border-slate-100" />
{/* دسته‌ها + ریزردیف مشخصات/برند/انبار */}
<div className="flex flex-col gap-2">
{CATEGORY_ORDER.map((category) => {
const groups = row.groups[category] ?? [];
if (groups.length === 0) return null;
const total = round4(
groups.reduce((sum, group) => sum + group.quantity, 0) *
CATEGORY_META[category].sign,
);
if (total === 0) return null;
return (
<div key={category} className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-6">
<span className="flex items-center gap-2 text-[11px] font-semibold text-slate-700 sm:text-xs">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ background: categoryColor(category, 0) }}
/>
{CATEGORY_META[category].label}
</span>
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
{withUnit(signed(total))}
</span>
</div>
{groups.map((group) => (
<div key={group.key} className="flex flex-col">
{group.items.map((item) => (
<div
key={`${group.key}|${item.warehouse}|${item.specLabel}`}
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-0.5 border-s border-slate-100 ps-3 text-[10.5px] text-slate-500 sm:text-[11px]"
>
<span className="flex flex-wrap items-center gap-1">
<span aria-hidden>↳</span>
<span>{item.specLabel}</span>
{item.brand && (
<>
<span className="text-slate-300">|</span>
<span>
{LABELS.brand}: {item.brand}
</span>
</>
)}
{item.warehouse && (
<span className="text-slate-400">
({LABELS.warehouse}: {item.warehouse})
</span>
)}
</span>
<span dir="ltr" className="font-medium tabular-nums text-slate-600">
{withUnit(signed(round4(item.quantity * CATEGORY_META[category].sign)))}
</span>
</div>
))}
</div>
))}
</div>
);
})}
</div>
<div className="my-2 border-b border-slate-100" />
{/* جمع‌بندی */}
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-6">
<span className="text-[11px] font-semibold text-slate-600 sm:text-xs">{LABELS.net}</span>
<span dir="ltr" className={`text-[11px] font-bold tabular-nums sm:text-xs ${netTone}`}>
{withUnit(signed(row.net))}
</span>
</div>
<div className="flex items-center justify-between gap-6">
<span className="text-[11px] font-semibold text-slate-600 sm:text-xs">{LABELS.closing}</span>
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
{withUnit(fmtNum(row.closing))}
</span>
</div>
</div>
</div>
);
}
// ——— نرمال‌سازی یک روز: گروه‌بندی ریزردیف‌ها بر اساس (دسته، مشخصات+برند) ———
function dayGroups(day: FlowDay): CatMap {
const groups: CatMap = { inflow: [], returns: [], siteOut: [], waste: [], other: [] };
const insert = (category: FlowCategory, item: CatItem) => {
const existing = groups[category].find((group) => group.key === item.key);
if (existing) {
existing.quantity = round4(existing.quantity + item.quantity);
existing.items.push(item);
} else {
groups[category].push({
key: item.key,
label: item.specLabel,
brand: item.brand,
quantity: item.quantity,
items: [item],
});
}
};
const breakdown: FlowBreakdownItem[] = day.breakdown ?? [];
if (breakdown.length > 0) {
breakdown.forEach((item) => {
// چهار سبد نمایشی همیشه مثبت نشان داده می‌شوند؛ «سایر» علامت دفتر را نگه می‌دارد
const quantity = item.category === "other" ? item.quantity : Math.abs(item.quantity);
if (quantity === 0) return;
insert(item.category, {
key: `${JSON.stringify(item.specs ?? {})}|${item.brand ?? ""}`,
specLabel: specLabel(item.specs),
brand: item.brand,
warehouse: item.warehouse,
quantity: round4(quantity),
});
});
} else {
// سازگاری عقب‌رو: اگر شکست چندبعدی در payload نباشد، یک گروهِ کلِ دسته ساخته می‌شود
const aggregates: [FlowCategory, number][] = [
["inflow", day.inbound],
["returns", day.returns],
["siteOut", Math.max(0, day.outbound - day.waste)],
["waste", day.waste],
];
aggregates.forEach(([category, quantity]) => {
if (quantity > 0) {
insert(category, {
key: `${category}-all`,
specLabel: LABELS.aggregate,
brand: null,
warehouse: "",
quantity: round4(quantity),
});
}
});
}
return groups;
}
export function FlowChart() {
const filter = useDashboardFilter();
// بدون خانواده‌ی متریال، کوئری اصلاً اجرا نمی‌شود (تجمیع واحدهای ناسازگار ممنوع)
const query = useDailyFlow(undefined, undefined, Boolean(filter.materialFamilyId));
const options = useDashboardFilterOptions();
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 ??
selectedFamily?.unit?.name_fa ??
selectedFamily?.unit?.code ??
"";
// ——— هم‌سازمان‌دهی روزها + سگمنت‌های شناور ———
const { rows, groupCounts, hasMovement } = useMemo(() => {
const days = query.data?.days ?? [];
let opening = query.data?.opening_balance ?? 0;
// گذر اول: نرمال‌سازی + تعدیل دقیق حساب (ستون باید همیشه روی خط بنشیند)
const normalized = days.map((day) => {
const closing = day.balance;
const net = round4(closing - opening);
const groups = dayGroups(day);
const categorized = round4(
CATEGORY_ORDER.reduce((sum, category) => {
const categoryTotal = groups[category].reduce(
(groupSum, group) => groupSum + group.quantity,
0,
);
return sum + categoryTotal * CATEGORY_META[category].sign;
}, 0),
);
const residual = round4(net - categorized);
if (residual !== 0) {
groups.other.push({
key: OTHER_RESIDUAL_KEY,
label: LABELS.adjustment,
brand: null,
quantity: residual,
items: [],
});
}
const normalizedDay = { day, opening, closing, net, groups };
opening = closing;
return normalizedDay;
});
// ترتیب گروه‌ها (و در نتیجه پله‌ی رنگ) در کل بازه ثابت می‌ماند
const totalByCategory = new Map<FlowCategory, Map<string, number>>();
normalized.forEach(({ groups }) => {
CATEGORY_ORDER.forEach((category) => {
const perCategory = totalByCategory.get(category) ?? new Map<string, number>();
groups[category].forEach((group) => {
perCategory.set(group.key, round4((perCategory.get(group.key) ?? 0) + Math.abs(group.quantity)));
});
totalByCategory.set(category, perCategory);
});
});
const orderByCategory = new Map<FlowCategory, string[]>();
const groupCounts: Record<FlowCategory, number> = {
inflow: 0,
returns: 0,
siteOut: 0,
waste: 0,
other: 0,
};
CATEGORY_ORDER.forEach((category) => {
const orderedKeys = [...(totalByCategory.get(category) ?? new Map<string, number>()).entries()]
.sort((a, b) => b[1] - a[1])
.map(([key]) => key);
orderByCategory.set(category, orderedKeys);
groupCounts[category] = orderedKeys.length;
});
// گذر دوم: هر گروه یک سگمنت [پایین، بالا] روی مسیر مانده می‌گیرد
const rows: FlowRow[] = normalized.map(({ day, opening: dayOpening, closing, net, groups }) => {
const row: FlowRow = {
date: day.date,
opening: dayOpening,
closing,
net,
balance: closing,
groups,
hasMovement: false,
};
let cursor = dayOpening;
CATEGORY_ORDER.forEach((category) => {
const orderedKeys = orderByCategory.get(category) ?? [];
const sign = CATEGORY_META[category].sign;
orderedKeys.forEach((key, index) => {
const group = groups[category].find((item) => item.key === key);
let range: Range = [cursor, cursor];
if (group && group.quantity !== 0) {
const from = cursor;
cursor = round4(cursor + group.quantity * sign);
range = [Math.min(from, cursor), Math.max(from, cursor)];
row.hasMovement = true;
}
row[`seg_${category}_${index}`] = range;
});
});
return row;
});
return {
rows,
groupCounts,
hasMovement: rows.some((row) => row.hasMovement || row.net !== 0),
};
}, [query.data]);
// ——— دامنه‌ی محور Y با ۱۰٪ سرصفحه ———
const [domainMin, domainMax] = useMemo(() => {
const values: number[] = [];
rows.forEach((row) => {
values.push(row.balance, row.opening, row.closing);
CATEGORY_ORDER.forEach((category) => {
const count = groupCounts[category] ?? 0;
for (let index = 0; index < count; index += 1) {
const range = row[`seg_${category}_${index}`] as Range | undefined;
if (range) values.push(range[0], range[1]);
}
});
});
if (values.length === 0) return [0, 1] as const;
const lo = Math.min(...values);
const hi = Math.max(...values);
const pad = (hi - lo) * DOMAIN_PAD_RATIO || 1;
return [Math.floor(lo - pad), Math.ceil(hi + pad)] as const;
}, [rows, groupCounts]);
const visibleCategories = CATEGORY_ORDER.filter((category) => (groupCounts[category] ?? 0) > 0);
const hasMultipleShades = visibleCategories.some((category) => groupCounts[category] > 1);
// ——— دروازه‌ی تک‌متریالی: بدون متریال، نمودار رندر نمی‌شود ———
if (!filter.materialFamilyId) {
return (
<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 ? (
<ChartLoading />
) : (
<div
dir="rtl"
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>
{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">
{families.map((family) => {
const familyUnit = family.unit?.name_fa ?? family.unit?.code ?? "";
return (
<button
key={family.id}
type="button"
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">{family.name}</span>
{familyUnit && (
<span className="shrink-0 rounded-full bg-slate-bg px-1.5 text-[10px] font-medium text-muted">
{familyUnit}
</span>
)}
</button>
);
})}
</div>
</div>
)}
</div>
)}
</ChartCard>
);
}
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}` : ""}`}
>
{query.isLoading ? (
<ChartLoading />
) : query.isError ? (
<FlowError />
) : !hasMovement ? (
<ChartEmpty message={LABELS.empty} />
) : (
<div dir="rtl" className="flex min-h-0 flex-1 flex-col gap-1">
{/* راهنمای رنگ دسته‌ها (به‌جای Legend پیش‌فرض؛ سری‌های تنالیته معنا ندارند) */}
<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
className="h-2 w-2 rounded-full"
style={{ background: categoryColor(category, 0) }}
/>
{CATEGORY_META[category].label}
</span>
))}
{hasMultipleShades && (
<span className="text-[10px] text-slate-400">{LABELS.shadeHint}</span>
)}
</div>
<div dir="ltr" className="min-h-0 w-full grow basis-64">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={rows}
barCategoryGap={BAR_CATEGORY_GAP}
margin={{ top: 12, right: 12, left: 8, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis
dataKey="date"
tickFormatter={(v: string) => isoToJalaliStr(v) ?? v}
tick={{ fontSize: 10.5, fill: "#64748b", fontFamily: "Vazirmatn" }}
tickLine={false}
axisLine={{ stroke: "#e5e9f0" }}
minTickGap={18}
/>
<YAxis
domain={[domainMin, domainMax]}
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick}
tickLine={false}
axisLine={false}
width={unitLabel ? 70 : 56}
label={
unitLabel
? {
value: unitLabel,
angle: -90,
position: "insideLeft",
style: { fontSize: 10, fill: "#64748b" },
}
: undefined
}
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
content={<FlowTooltip unitLabel={unitLabel} materialName={materialName} />}
/>
{/* سگمنت‌های شناور: هر گروه (مشخصات+برند) یک پله از خانواده‌ی رنگ
دسته، با جداکننده‌ی سفید ۱px و یک ستون مشترک */}
{CATEGORY_ORDER.map((category) =>
Array.from({ length: groupCounts[category] ?? 0 }, (_, index) => (
<Bar
key={`${category}-${index}`}
dataKey={`seg_${category}_${index}`}
fill={categoryColor(category, index)}
stroke="#ffffff"
strokeWidth={1}
stackId={FLOW_STACK_ID}
maxBarSize={MAX_BAR_SIZE}
isAnimationActive={false}
/>
)),
)}
{/* خط پله‌ای مانده — هم‌تراز هندسی با سرِ ستون‌های شناور */}
<Line
type="stepAfter"
dataKey="balance"
name={LABELS.closing}
stroke="#0f172a"
strokeWidth={1.75}
strokeDasharray="5 3"
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
)}
</ChartCard>
);
}
@@ -0,0 +1,125 @@
// ============================================================
// ویجت ۶ — شبکه‌ی وضعیت سفارش‌های در راه
// جدول کمی سفارش‌های باز: متریال و تأمین‌کننده | سفارش‌شده | رسیده
// | وضعیت (در راه / تحویل جزئی / تحویل کامل) + نوار پیشرفت.
// داده از /pm/on-the-way (با برچسب برند و مشخصات و فیلتر سراسری).
// ============================================================
import { Truck } from "lucide-react";
import { useInTransit } from "../../hooks/useDashboardData";
import type { InTransitRow } from "../../lib/types";
import { faNum, fmtNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table";
import { Badge } from "../ui/badge";
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
const STATE_META: Record<
InTransitRow["state"],
{ label: string; cls: string; bar: string }
> = {
on_the_way: { label: "در راه", cls: "bg-amber-bg text-amber", bar: "bg-amber" },
partially_received: { label: "تحویل جزئی", cls: "bg-blue-bg text-[#1d4ed8]", bar: "bg-[#2563eb]" },
arrived: { label: "تحویل کامل", cls: "bg-green-bg text-green", bar: "bg-green" },
};
function specLabel(specs: Record<string, string>): string {
const parts = Object.entries(specs || {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
}
function num(v: string): number {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
export function InTransitGrid() {
const query = useInTransit();
const rows = query.data ?? [];
return (
<ChartCard
title={
<span className="inline-flex items-center gap-1.5">
<Truck className="h-4 w-4 text-muted" />
سفارش‌های در راه — وضعیت تحویل
</span>
}
sub="اقلام پذیرفته‌شده‌ی خرید تا رسیدن کامل به انبار"
>
{query.isLoading ? (
<ChartLoading />
) : rows.length === 0 ? (
<ChartEmpty message="سفارش بازی در جریان نیست" />
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>متریال / تأمین‌کننده</TableHead>
<TableHead className="text-end">سفارش‌شده</TableHead>
<TableHead className="text-end">رسیده تاکنون</TableHead>
<TableHead className="min-w-[140px]">پیشرفت</TableHead>
<TableHead>وضعیت</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => {
const ordered = num(r.ordered_qty);
const received = num(r.received_qty);
const pct = ordered > 0 ? Math.min(100, Math.round((received / ordered) * 100)) : 0;
const meta = STATE_META[r.state] ?? STATE_META.on_the_way;
const specs = specLabel(r.specs);
return (
<TableRow key={r.item_decision_id}>
<TableCell>
<div className="text-[13px] font-bold">{r.material.name}</div>
<div className="text-[11px] text-muted">
{r.supplier.name}
{r.brand ? ` · ${r.brand.name}` : ""}
</div>
{specs && <div className="text-[10.5px] text-muted">{specs}</div>}
{r.project && (
<div className="mt-0.5 text-[10.5px] text-accent">{r.project.name}</div>
)}
</TableCell>
<TableCell className="text-end font-bold tabular-nums">
{fmtNum(ordered)}
<div className="text-[10px] font-normal text-muted">
{r.unit?.name_fa || r.unit?.code}
</div>
</TableCell>
<TableCell className="text-end font-bold tabular-nums">
{fmtNum(received)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-bg">
<div
className={cn("h-full rounded-full", meta.bar)}
style={{ width: `${pct}%` }}
/>
</div>
<span className="shrink-0 text-[10.5px] tabular-nums text-muted">
{faNum(pct)}٪
</span>
</div>
</TableCell>
<TableCell>
<Badge className={cn("px-2 py-0.5 text-[10.5px]", meta.cls)}>{meta.label}</Badge>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</ChartCard>
);
}
@@ -0,0 +1,42 @@
// ============================================================
// ویجت ۵ — KPI زمان تأمین (Lead Time)
// میانگین روزهای «پذیرش رسمی سفارش → رسیدن فیزیکی به انبار»
// از /quotes/stats (میانگین arrived_date − accepted_date).
// ============================================================
import { Timer } from "lucide-react";
import { useQuoteStats } from "../../hooks/useDashboardData";
import { faNum } from "../../lib/formatters";
import { ChartCard, ChartLoading } from "./chartShared";
export function LeadTimeKpiCard() {
const query = useQuoteStats();
const days = query.data?.average_lead_time_days;
return (
<ChartCard title="میانگین زمان تأمین" sub="از پذیرش رسمی سفارش تا رسیدن فیزیکی به انبار">
{query.isLoading ? (
<ChartLoading />
) : (
<div className="flex items-center gap-4">
<span className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-accent-bg text-accent">
<Timer className="h-7 w-7" />
</span>
<div>
<div className="flex items-baseline gap-1.5">
<span className="text-[38px] font-extrabold leading-none tabular-nums text-ink">
{days == null ? "—" : faNum(days)}
</span>
<span className="text-[14px] font-bold text-muted">روز</span>
</div>
<p className="mt-1 text-[11px] text-muted">
{days == null
? "هنوز سفارشی به مرحله‌ی رسیدن نرسیده است"
: `بر پایه‌ی سفارش‌های رسیده‌شده به انبار`}
</p>
</div>
</div>
)}
</ChartCard>
);
}
@@ -0,0 +1,124 @@
// ============================================================
// ویجت ۷ — قیف تدارکات
// تبدیل تجمعی: کل درخواست‌ها (پیشنهادشده) → پذیرفته‌شده → رسیده به انبار
// از /quotes/stats.funnel (شمارش تجمعی بر پایه‌ی تاریخ‌های واقعی).
// ============================================================
import { useMemo } from "react";
import { Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from "recharts";
import { useQuoteStats } from "../../hooks/useDashboardData";
import { faNum, fmtNum } from "../../lib/formatters";
import { CHART_COLORS, ChartCard, ChartEmpty, ChartLoading, ChartTooltip } from "./chartShared";
interface Step {
name: string;
value: number;
fill: string;
conversion: number | null;
}
export function ProcurementFunnel() {
const query = useQuoteStats();
const steps: Step[] = useMemo(() => {
const f = query.data?.funnel;
if (!f) return [];
const offered = f.offered_total;
return [
{
name: "کل درخواست‌ها (پیشنهادشده)",
value: f.offered_total,
fill: CHART_COLORS.accent,
conversion: null,
},
{
name: "پذیرفته‌شده",
value: f.accepted_total,
fill: CHART_COLORS.blue,
conversion: offered > 0 ? Math.round((f.accepted_total / offered) * 100) : 0,
},
{
name: "رسیده به انبار",
value: f.arrived_total,
fill: CHART_COLORS.green,
conversion: offered > 0 ? Math.round((f.arrived_total / offered) * 100) : 0,
},
];
}, [query.data]);
const total = steps[0]?.value ?? 0;
return (
<ChartCard
title="قیف تدارکات"
sub="تبدیل درخواست‌ها به سفارش پذیرفته‌شده و رسیدن کالا به انبار"
>
{query.isLoading ? (
<ChartLoading />
) : total === 0 ? (
<ChartEmpty message="هنوز درخواستی ثبت نشده است" />
) : (
<div className="grid items-center gap-3 sm:grid-cols-5">
<div dir="ltr" className="h-56 sm:col-span-3">
<ResponsiveContainer width="100%" height="100%">
<FunnelChart>
<Tooltip
content={
<ChartTooltip
labelFormatter={(l) => String(l)}
valueFormatter={(v) => `${fmtNum(v)} درخواست`}
/>
}
/>
<Funnel dataKey="value" data={steps} isAnimationActive={false}>
{/* مقدار هر مرحله — با dataKey صریح تا Recharts مقدار درست بدهد */}
<LabelList
dataKey="value"
position="right"
fill="#0f172a"
stroke="none"
fontSize={12}
fontWeight={700}
formatter={(v: unknown) => {
const n = Number(v);
return Number.isFinite(n) ? fmtNum(n) : "";
}}
/>
{/* نام مرحله داخل نوار */}
<LabelList
dataKey="name"
position="left"
fill="#ffffff"
stroke="none"
fontSize={11}
formatter={(v: unknown) => (v == null ? "" : String(v))}
/>
</Funnel>
</FunnelChart>
</ResponsiveContainer>
</div>
{/* مراحل قیف + درصد تبدیل */}
<ul className="flex flex-col gap-2 sm:col-span-2">
{steps.map((s) => (
<li key={s.name} className="rounded-xl border border-line px-3 py-2">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: s.fill }} />
<span className="text-[12px] font-bold">{s.name}</span>
<span className="ms-auto text-[15px] font-extrabold tabular-nums">
{fmtNum(s.value)}
</span>
</div>
{s.conversion != null && (
<div className="mt-0.5 text-[10.5px] text-muted">
{faNum(s.conversion)}٪ از کل درخواست‌ها
</div>
)}
</li>
))}
</ul>
</div>
)}
</ChartCard>
);
}
@@ -0,0 +1,123 @@
// ============================================================
// ویجت ۸ — تراکم پروژه‌ها (Project Density)
// نمودار میله‌ای افقی: توزیع بار تدارکات روی پروژه‌های فعال —
// سفارش‌های باز (سته‌شده با پیشنهادهای باز).
// ============================================================
import { useMemo } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useProcurementWorkload } from "../../hooks/useDashboardData";
import { faNum } from "../../lib/formatters";
import {
CHART_COLORS,
ChartCard,
ChartEmpty,
ChartLoading,
ChartTooltip,
faAxisTick,
} from "./chartShared";
export function ProjectDensityChart() {
const query = useProcurementWorkload();
const rows = useMemo(() => {
const projects = query.data?.projects ?? [];
return projects
.filter((p) => p.open_orders > 0 || p.offered_quotes > 0 || p.chosen_items > 0)
.map((p) => ({
name: p.name,
"سفارش باز (در راه)": p.open_orders,
"پیشنهاد باز": p.offered_quotes,
_ordered: p.ordered_qty,
_received: p.received_qty,
}))
.sort((a, b) => (b["سفارش باز (در راه)"] + b["پیشنهاد باز"]) - (a["سفارش باز (در راه)"] + a["پیشنهاد باز"]));
}, [query.data]);
return (
<ChartCard
title="تراکم بار تدارکات روی پروژه‌ها"
sub="تعداد سفارش‌های باز و پیشنهادهای در جریان به تفکیک پروژه"
>
{query.isLoading ? (
<ChartLoading />
) : rows.length === 0 ? (
<ChartEmpty message="بار تدارکاتی فعالی روی پروژه‌ها وجود ندارد" />
) : (
<div dir="ltr" className="w-full" style={{ height: Math.max(180, rows.length * 56 + 60) }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={rows}
layout="vertical"
margin={{ top: 8, right: 16, left: 8, bottom: 4 }}
barCategoryGap="28%"
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" horizontal={false} />
<XAxis
type="number"
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<YAxis
type="category"
dataKey="name"
width={150}
tick={{ fontSize: 11, fill: "#0f172a", fontFamily: "Vazirmatn" }}
tickLine={false}
axisLine={false}
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
content={
<ChartTooltip
labelFormatter={(l) => String(l)}
valueFormatter={(v) => `${faNum(v)} مورد`}
/>
}
/>
<Legend
wrapperStyle={{ direction: "rtl", width: "100%" }}
verticalAlign="top"
height={26}
iconType="circle"
iconSize={8}
formatter={(value: unknown) => (
<span dir="rtl" className="text-[11px] text-muted">
{String(value)}
</span>
)}
/>
<Bar
dataKey="سفارش باز (در راه)"
stackId="load"
fill={CHART_COLORS.blue}
maxBarSize={22}
isAnimationActive={false}
/>
<Bar
dataKey="پیشنهاد باز"
stackId="load"
fill={CHART_COLORS.amber}
radius={[0, 4, 4, 0]}
maxBarSize={22}
isAnimationActive={false}
/>
</BarChart>
</ResponsiveContainer>
</div>
)}
</ChartCard>
);
}
@@ -0,0 +1,181 @@
// ============================================================
// ابزار مشترک نمودارها — اعداد فارسی، تاریخ شمسی، تولتیپ RTL
// همه‌ی نمودارهای داشبورد از این قالب استفاده می‌کنند تا خروجی
// Recharts فارسی و هماهنگ با دیزاین‌سیستم (Tailwind @theme) باشد.
// ============================================================
import type { ReactNode } from "react";
import { groupCommas } from "../../lib/numutil";
import { isoToJalaliStr } from "../../lib/jalali";
import { faNum, fmtNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
// پالت معنایی نمودارها — هم‌راستا با توکن‌های @theme در globals.css
export const CHART_COLORS = {
inbound: "#16a34a", // ورود = سبز
outbound: "#2563eb", // خروج = آبی
waste: "#dc2626", // ضایعات/اتلاف/مفقودی = قرمز (لایه‌ی داخل ستون خروج)
returns: "#d97706", // برگشت از سایت = کهربایی
balance: "#0f172a", // مانده = خط سرمه‌ای نقطه‌چین
accent: "#4f46e5", // ایندیگو — رنگ اصلی سیستم
amber: "#d97706",
green: "#16a34a",
red: "#dc2626",
blue: "#2563eb",
} as const;
// عدد → رقم فارسی با جداکننده‌ی هزارگان (تیک محورها)
export function faAxisTick(value: unknown): string {
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return "";
if (Number.isInteger(n) && Math.abs(n) < 1000) return faNum(n);
return groupCommas(n, true);
}
// ISO (YYYY-MM-DD) → تاریخ شمسی کوتاه برای محور X
export function jaAxisDate(iso: unknown): string {
const j = isoToJalaliStr(typeof iso === "string" ? iso : String(iso ?? ""));
return j || String(iso ?? "");
}
// ——— تولتیپ سفارشی RTL — اعداد فارسی و نام‌های فارسی سری‌ها ———
interface TooltipEntry {
name?: string | number;
value?: string | number;
color?: string;
dataKey?: string | number;
payload?: Record<string, unknown>;
}
interface TooltipProps {
active?: boolean;
payload?: TooltipEntry[];
label?: string | number;
labelFormatter?: (label: string | number) => ReactNode;
valueFormatter?: (value: number, name: string) => string;
}
export function ChartTooltip({ active, payload, label, labelFormatter, valueFormatter }: TooltipProps) {
if (!active || !payload || payload.length === 0) return null;
// ردیف‌های صفر را از تولتیپ حذف می‌کنیم — شلوغیِ بی‌معنا (اصل Power BI)
const rows = payload.filter((p) => {
const v = typeof p.value === "number" ? p.value : Number(p.value);
return Number.isFinite(v) && v !== 0;
});
return (
<div
dir="rtl"
className="rounded-xl border border-line bg-panel/95 px-3 py-2 shadow-lg backdrop-blur-sm"
>
{label !== undefined && label !== "" && (
<div className="mb-1 text-[11px] font-bold text-muted">
{labelFormatter ? labelFormatter(label) : String(label)}
</div>
)}
<div className="flex flex-col gap-1">
{rows.map((p, i) => {
const v = typeof p.value === "number" ? p.value : Number(p.value);
const name = String(p.name ?? p.dataKey ?? "");
return (
<div key={i} className="flex items-center gap-2 text-[12px] leading-5">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: p.color }} />
<span className="text-muted">{name}</span>
<span className="ms-auto font-bold tabular-nums text-ink">
{valueFormatter ? valueFormatter(v, name) : groupCommas(v, true)}
</span>
</div>
);
})}
</div>
</div>
);
}
// ——— کارت نگه‌دارنده‌ی ویجت — عنوان + توضیح + اکشن ———
export function ChartCard({
title,
sub,
actions,
children,
className,
}: {
title: ReactNode;
sub?: string;
actions?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section className={cn("rounded-2xl border border-line bg-panel p-4", className)}>
<header className="mb-3 flex flex-wrap items-center gap-2">
<div className="min-w-0">
<h2 className="text-[14px] font-extrabold leading-6">{title}</h2>
{sub && <p className="text-[11px] text-muted">{sub}</p>}
</div>
{actions && <div className="ms-auto flex items-center gap-1.5">{actions}</div>}
</header>
{children}
</section>
);
}
// ——— حالت خالی/خطای ویجت ———
export function ChartEmpty({ message = "داده‌ای برای نمایش وجود ندارد" }: { message?: string }) {
return (
<div className="flex h-40 items-center justify-center rounded-xl border border-dashed border-line text-[12px] text-muted">
{message}
</div>
);
}
export function ChartLoading() {
return (
<div className="flex h-40 items-center justify-center text-[12px] text-muted">
در حال بارگذاری...
</div>
);
}
// ——— چیپ انتخاب واحد — با مجموع هر واحد تا تغییرِ نمودار فوراً دیده شود ———
export function UnitPicker({
units,
value,
onChange,
totals,
}: {
units: Record<string, string>;
value: string;
onChange: (code: string) => void;
totals?: Record<string, number>;
}) {
const codes = Object.keys(units);
if (codes.length <= 1) return null;
return (
<div className="flex flex-wrap items-center gap-1">
{codes.map((code) => {
const total = totals?.[code];
return (
<button
key={code}
type="button"
onClick={() => onChange(code)}
title={"نمایش موجودی به " + (units[code] || code)}
className={cn(
"rounded-full px-2.5 py-1 text-[11px] font-semibold transition-colors",
value === code
? "bg-accent text-white"
: "bg-slate-bg text-muted hover:text-ink",
)}
>
{units[code] || code}
{total != null && (
<span className={cn("ms-1 tabular-nums", value === code ? "text-white/80" : "text-muted/80")}>
{fmtNum(total)}
</span>
)}
</button>
);
})}
</div>
);
}
@@ -101,6 +101,7 @@ export function MobileTabBar() {
if (pathname === "/wizard" || pathname.startsWith("/review")) return null; if (pathname === "/wizard" || pathname.startsWith("/review")) return null;
const onOfficePage = const onOfficePage =
pathname === "/" || pathname === "/" ||
pathname === "/dashboard" ||
pathname === "/projects" || pathname === "/projects" ||
pathname === "/units" || pathname === "/units" ||
pathname.startsWith("/suppliers"); pathname.startsWith("/suppliers");
+92
View File
@@ -0,0 +1,92 @@
// ============================================================
// هوک‌های TanStack Query برای داشبورد MVP
// همه‌ی ویجت‌ها مستقیم به API واقعی وصل‌اند — بدون هیچ داده‌ی ساختگی.
// کلیدهای کوئری شامل پارامترهای فیلتر «چه چیزی» هستند تا با هر
// تغییر فیلتر، فقط ویجت‌های همان تب دوباره خوانده شوند.
// ============================================================
import { useQuery } from "@tanstack/react-query";
import { apiV26 } from "../lib/api";
import type { DashboardFilterParams } from "../lib/api";
import { dashboardFilterParams, useDashboardFilter } from "../store/dashboardFilter";
function useFilterParams(): DashboardFilterParams {
const state = useDashboardFilter();
return dashboardFilterParams(state);
}
// گزینه‌های آبشاری فیلتر سراسری (دسته → متریال → برند → مشخصات)
export function useDashboardFilterOptions() {
return useQuery({
queryKey: ["dashboard", "filters"],
queryFn: () => apiV26.dashboard.filters(),
staleTime: 5 * 60 * 1000,
});
}
// ویجت ۱ — ماتریس Drill-Down (کل سازمان → پروژه → انبار)
export function useDrilldown() {
const params = useFilterParams();
return useQuery({
queryKey: ["dashboard", "drilldown", params],
queryFn: () => apiV26.dashboard.drilldown(params),
});
}
// ویجت ۲ — موجودی راکد (dead stock)
export function useDeadStock(idleDays: number) {
const params = useFilterParams();
return useQuery({
queryKey: ["dashboard", "dead-stock", idleDays, params],
queryFn: () => apiV26.dashboard.deadStock({ ...params, idle_days: idleDays }),
});
}
// ویجت ۳ — سری زمانی جریان روزانه
// فقط با متریال مشخص اجرا میشود: جمع چند متریال با واحدهای متفاوت بی‌معناست.
export function useDailyFlow(dateFrom?: string, dateTo?: string, enabled = true) {
const params = useFilterParams();
return useQuery({
queryKey: ["dashboard", "flow", dateFrom, dateTo, params],
queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }),
enabled,
});
}
// ویجت ۴ — کنترل روزانه (امروز) — هر ۶۰ ثانیه تازه‌سازی می‌شود
export function useDailyControl(date?: string) {
return useQuery({
queryKey: ["dashboard", "daily-control", date],
queryFn: () => apiV26.dashboard.dailyControl(date ? { date } : {}),
refetchInterval: 60 * 1000,
});
}
// ویجت ۵ + ۷ — KPI زمان تأمین و قیف تدارکات (/quotes/stats)
export function useQuoteStats() {
return useQuery({
queryKey: ["dashboard", "quote-stats"],
queryFn: () => apiV26.dashboard.quoteStats(),
staleTime: 60 * 1000,
});
}
// ویجت ۶ — کالاهای در راه (با فیلتر متریال/برند سراسری)
export function useInTransit() {
const state = useDashboardFilter();
const params = dashboardFilterParams(state);
return useQuery({
queryKey: ["dashboard", "in-transit", params],
queryFn: () =>
apiV26.pm.onTheWay(null, { material_id: params.material_id, brand_id: params.brand_id }),
});
}
// ویجت ۸ — بار تدارکات روی پروژه‌ها
export function useProcurementWorkload() {
return useQuery({
queryKey: ["dashboard", "workload"],
queryFn: () => apiV26.dashboard.workload(),
staleTime: 60 * 1000,
});
}
+49 -6
View File
@@ -19,23 +19,41 @@ import { useAuthStore } from "../store/auth";
import type { import type {
Attachment, Attachment,
ConfirmedReceipt, ConfirmedReceipt,
DashboardFilterOptions,
DailyControlData,
DeadStockItem,
DrilldownData,
FlowData,
InTransitRow,
InventoryRow, InventoryRow,
InventoryTransaction, InventoryTransaction,
Material, Material,
PendingArrival,
Project, Project,
Quote, Quote,
QuoteStats,
StandardConversion, StandardConversion,
Store, Store,
Supplier, Supplier,
TransactionType,
Warehouse,
ProjectTransfer, ProjectTransfer,
ProjectTransferCreateIn, ProjectTransferCreateIn,
ProjectTransferUpdateIn,
ProjectTransferReceiveIn, ProjectTransferReceiveIn,
ProjectTransferUpdateIn,
TransactionType,
Warehouse,
WorkloadProject,
} from "./types"; } from "./types";
// پارامترهای مشترک فیلتر «چه چیزی» برای 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;
};
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1"; const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
const PAGE_SIZE = 100; const PAGE_SIZE = 100;
@@ -1887,8 +1905,12 @@ export const apiV26 = {
pm: { pm: {
// پروژه فعال به‌صورت صریح ارسال می‌شود؛ بک‌اند تعلق آن به PM را بررسی می‌کند // پروژه فعال به‌صورت صریح ارسال می‌شود؛ بک‌اند تعلق آن به PM را بررسی می‌کند
onTheWay: async (projectId?: string | null): Promise<PendingArrival[]> => onTheWay: async (projectId?: string | null, params: { material_id?: string | null; brand_id?: string | null } = {}): Promise<InTransitRow[]> => {
getData<PendingArrival[]>(projectId ? `/pm/on-the-way?project_id=${projectId}` : "/pm/on-the-way"), const qs = new URLSearchParams(
cleanParams({ project_id: projectId ?? activeProjectId(), ...params }),
).toString();
return getData<InTransitRow[]>(`/pm/on-the-way${qs ? `?${qs}` : ""}`);
},
confirmReceipt: async (body: { confirmReceipt: async (body: {
item_decision_id: string; received_qty: number; item_decision_id: string; received_qty: number;
allocations: { warehouse_id: string; quantity: number }[]; allocations: { warehouse_id: string; quantity: number }[];
@@ -1899,6 +1921,27 @@ export const apiV26 = {
getData<ConfirmedReceipt[]>(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"), getData<ConfirmedReceipt[]>(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"),
}, },
// ---------- داشبورد MVP (v2.8) — تجمیع‌های سمت سرور، فقط‌خواندنی ----------
// نقش/پروژه سمت بک‌اند از JWT (یا هدرهای dev) حل می‌شود؛ PM به پروژه‌های
// خودش محدود است و ادمین/مدیر تدارکات کل سازمان را می‌بیند.
dashboard: {
filters: async (): Promise<DashboardFilterOptions> =>
getData<DashboardFilterOptions>("/dashboard/filters"),
drilldown: async (params: DashboardFilterParams = {}): Promise<DrilldownData> =>
getData<DrilldownData>(`/dashboard/inventory/drilldown?${new URLSearchParams(cleanParams(params))}`),
deadStock: async (params: DashboardFilterParams & { idle_days?: number } = {}): Promise<{ idle_days_threshold: number; items: DeadStockItem[] }> =>
getData<{ idle_days_threshold: number; items: DeadStockItem[] }>(`/dashboard/inventory/dead-stock?${new URLSearchParams(cleanParams(params))}`),
flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<FlowData> =>
getData<FlowData>(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`),
dailyControl: async (params: { date?: string; warehouse_id?: string } = {}): Promise<DailyControlData> =>
getData<DailyControlData>(`/dashboard/daily-control?${new URLSearchParams(cleanParams(params))}`),
workload: async (): Promise<{ projects: WorkloadProject[] }> =>
getData<{ projects: WorkloadProject[] }>("/dashboard/procurement/workload"),
// KPI زمان تأمین + قیف تدارکات (endpoint آماری موجود /quotes/stats)
quoteStats: async (): Promise<QuoteStats> =>
getData<QuoteStats>("/quotes/stats"),
},
// ساخت موجودیت‌های کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه): // ساخت موجودیت‌های کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):
// متریال/برند/واحد جدید بدون وابستگی به دفتر تدارکات ساخته می‌شوند. // متریال/برند/واحد جدید بدون وابستگی به دفتر تدارکات ساخته می‌شوند.
catalog: { catalog: {
+209 -1
View File
@@ -195,6 +195,12 @@ export interface PendingArrival {
state: "on_the_way" | "partially_received" | "arrived"; state: "on_the_way" | "partially_received" | "arrived";
} }
// v2.6.1 — برند/مشخصه‌های فrozen قلم (اضافه‌شده به /pm/on-the-way برای داشبورد)
export interface InTransitRow extends PendingArrival {
brand: { id: string; name: string } | null;
specs: Record<string, string>;
}
export interface ConfirmedReceipt { export interface ConfirmedReceipt {
receipt_id: string; receipt_id: string;
quote_id: string; quote_id: string;
@@ -208,6 +214,209 @@ export interface ConfirmedReceipt {
allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[]; allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[];
} }
// ============================================================
// v2.8 — داشبورد MVP (انبار + بازرگانی)
// ============================================================
// — فیلتر سراسری «چه چیزی» —
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 (کل سازمان → پروژه → انبار) —
export type QtyByUnit = Record<string, number>;
export interface DrilldownWarehouse {
id: string;
name: string;
qty_by_unit: QtyByUnit;
rows: number;
}
export interface DrilldownProject {
id: string;
name: string;
qty_by_unit: QtyByUnit;
rows: number;
warehouses: DrilldownWarehouse[];
}
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[];
}
export interface DrilldownData {
units: Record<string, string>;
total: { qty_by_unit: QtyByUnit; rows: number };
materials: DrilldownMaterial[];
}
// — جدول موجودی راکد —
export interface DeadStockItem {
inventory_id: string;
material: string;
brand: string | null;
specs: Record<string, string>;
warehouse: string;
project: string;
quantity: number;
unit: { code: string; name_fa: string | null };
last_activity: string | null;
idle_days: number | null;
}
// — سری زمانی جریان روزانه —
// دسته‌های نمایشی جریان: ورود، برگشت، خروج به سایت، ضایعات، و سایر حرکات
// (انتقال/شمارش/یافت‌شده که در چهار سبد نمایشی جا نمی‌گیرند)
export type FlowCategory = "inflow" | "returns" | "siteOut" | "waste" | "other";
// یک ریزردیف دفتر: نوع تراکنش + مشخصات + برند + انبار — پایه‌ی شکست چندبعدی
export interface FlowBreakdownItem {
category: FlowCategory;
transaction_type: string;
specs: Record<string, string> | null;
brand: string | null;
warehouse: string;
quantity: number; // علامت‌دار مطابق دفتر (مثبت/منفی)
}
export interface FlowDay {
date: string; // ISO (YYYY-MM-DD)
inbound: number;
outbound: number;
waste: number;
returns: number;
balance: number;
breakdown?: FlowBreakdownItem[];
}
export interface FlowData {
opening_balance: number;
// واحد کاتالوگی متریال انتخاب‌شده (فقط وقتی یک متریال فیلتر شده باشد)
unit: { code: string; name_fa: string | null } | null;
days: FlowDay[];
}
// — کنترل روزانه —
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: DailyControlCategoryStats;
returns: DailyControlCategoryStats;
waste: DailyControlCategoryStats;
purchases: DailyControlCategoryStats;
people: DailyControlPerson[];
items: DailyControlItem[];
}
// — بازرگانی: قیف تدارکات + KPI زمان تأمین (/quotes/stats) —
export interface QuoteStats {
total: number;
offered: number;
accepted: number;
arrived: number;
rejected: number;
expired: number;
average_lead_time_days: number | null;
funnel: {
offered_total: number;
accepted_total: number;
arrived_total: number;
};
}
// — بار تدارکات روی پروژه‌ها (Project Density) —
export interface WorkloadProject {
id: string | null;
name: string;
offered_quotes: number;
chosen_items: number;
open_orders: number;
ordered_qty: number;
received_qty: number;
}
export interface ProjectTransfer { export interface ProjectTransfer {
id: string; id: string;
from_project: { id: string; name: string }; from_project: { id: string; name: string };
@@ -255,4 +464,3 @@ export interface ProjectTransferReceiveIn {
received_at?: string | null; received_at?: string | null;
note?: string | null; note?: string | null;
} }
+10 -1
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Users, FolderKanban, Ruler, Plus } from "lucide-react"; import { Users, FolderKanban, Ruler, Plus, ChartColumnBig } from "lucide-react";
import { import {
useMaterials, useMaterials,
useProjects, useProjects,
@@ -94,6 +94,15 @@ export function DashboardPage() {
menu={<AppSwitcherMenu activeModule="procurement" />} menu={<AppSwitcherMenu activeModule="procurement" />}
actions={ actions={
<div className="hidden md:flex items-center gap-2"> <div className="hidden md:flex items-center gap-2">
{/* داشبورد تحلیلی — دکمه‌ی مستقل، هم‌سبک با «پروژه‌ها» و «ضرایب تبدیل» */}
<Link
to="/dashboard"
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
>
<ChartColumnBig className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
<span>داشبورد</span>
</Link>
<Link to="/suppliers" className="btn-line-file no-underline"> <Link to="/suppliers" className="btn-line-file no-underline">
<Users className="h-3.5 w-3.5" /> <Users className="h-3.5 w-3.5" />
تأمین‌کننده‌ها تأمین‌کننده‌ها
@@ -0,0 +1,99 @@
// ============================================================
// داشبورد MVP — نمای «انبار» و نمای «بازرگانی و لجستیک»
// فیلتر سراسری «چه چیزی» بالای هر دو نما (Zustand — با جابه‌جایی
// بین تب‌ها حفظ می‌شود). تمام ویجت‌ها مستقیم به API واقعی وصل‌اند.
// ============================================================
import { useState } from "react";
import { BarChart3, Warehouse } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { DashboardFilterBar } from "../../components/dashboard/DashboardFilterBar";
import { DrilldownMatrix } from "../../components/dashboard/DrilldownMatrix";
import { FlowChart } from "../../components/dashboard/FlowChart";
import { DeadStockTable } from "../../components/dashboard/DeadStockTable";
import { DailyControlPanel } from "../../components/dashboard/DailyControlPanel";
import { LeadTimeKpiCard } from "../../components/dashboard/LeadTimeKpiCard";
import { InTransitGrid } from "../../components/dashboard/InTransitGrid";
import { ProcurementFunnel } from "../../components/dashboard/ProcurementFunnel";
import { ProjectDensityChart } from "../../components/dashboard/ProjectDensityChart";
import { cn } from "../../lib/utils";
type Tab = "warehouse" | "procurement";
const TABS: { key: Tab; label: string; icon: typeof Warehouse }[] = [
{ key: "warehouse", label: "داشبورد انبار", icon: Warehouse },
{ key: "procurement", label: "داشبورد بازرگانی و لجستیک", icon: BarChart3 },
];
export function MvpDashboardPage() {
const [tab, setTab] = useState<Tab>("warehouse");
return (
<div className="min-h-screen pb-20 md:pb-6">
<Header
title="داشبورد تحلیلی"
sub="انبار و تدارکات — فیلتر جنس روی همه‌ی ویجت‌ها اعمال می‌شود"
brand
menu={<AppSwitcherMenu activeModule="procurement" />}
actions={
/* تب‌های نما — سگمنت‌کنترل هماهنگ با دیزاین‌سیستم */
<div className="flex items-center gap-1 rounded-xl border border-line bg-slate-bg p-1">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
type="button"
onClick={() => setTab(key)}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[12px] font-bold transition-all",
tab === key
? "bg-panel text-accent shadow-sm"
: "text-muted hover:text-ink",
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
}
/>
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
{/* فیلتر سراسری «چه چیزی» — مشترک بین دو نما */}
<DashboardFilterBar />
{tab === "warehouse" ? (
/* ——— نمای A: انبار ——— */
<div className="flex flex-col gap-3">
{/* ردیف اول: ماتریس Drill-Down (تمام عرض — ستون فقرات نما) */}
<DrilldownMatrix />
{/* ردیف دوم: سری زمانی + کنترل روزانه */}
<div className="grid h-auto min-w-0 items-stretch gap-3 lg:h-[550px] xl:grid-cols-2">
<FlowChart />
<DailyControlPanel />
</div>
{/* ردیف سوم: موجودی راکد */}
<DeadStockTable />
</div>
) : (
/* ——— نمای B: بازرگانی و لجستیک ——— */
<div className="flex flex-col gap-3">
{/* KPI + قیف */}
<div className="grid gap-3 xl:grid-cols-3">
<LeadTimeKpiCard />
<div className="xl:col-span-2">
<ProcurementFunnel />
</div>
</div>
{/* تراکم پروژه‌ها + سفارش‌های در راه */}
<div className="grid gap-3 xl:grid-cols-2">
<ProjectDensityChart />
<InTransitGrid />
</div>
</div>
)}
</PageLayout>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
// ============================================================
// Zustand store — فیلتر سراسری «چه چیزی» داشبورد
// متریال (خانواده) → برند → مشخصات فنی
// «مشخصات» یکی از دو چیز است: واریانتِ کاتالوگ (سایز/گرید) یا مشخصه‌ی موجودی.
// بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ می‌شود.
// ============================================================
import { create } from "zustand";
// انتخاب مشخصه: واریانت (ردیف کاتالوگ) یا مشخصه‌ی موجودی (specs_hash)
export type SpecSelection =
| { kind: "variant"; id: string; label: string }
| { kind: "stockSpec"; id: string; label: string };
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;
setSpec: (selection: SpecSelection | null) => void;
reset: () => void;
}
const INITIAL = {
materialFamilyId: null,
materialFamilyLabel: null,
brandId: null,
brandLabel: null,
spec: null,
};
export const useDashboardFilter = create<DashboardFilterState>((set) => ({
...INITIAL,
// آبشاری: تغییر هر سطح، سطح‌های پایین‌دست را پاک می‌کند
setMaterialFamily: (id, label) =>
set({
materialFamilyId: id,
materialFamilyLabel: label,
brandId: null,
brandLabel: null,
spec: null,
}),
setBrand: (id, label) =>
set({
brandId: id,
brandLabel: label,
spec: null,
}),
setSpec: (selection) => set({ spec: selection }),
reset: () => set({ ...INITIAL }),
}));
// پارامترهای آماده برای endpointهای داشبورد
export function dashboardFilterParams(s: DashboardFilterState) {
return {
material_family: s.materialFamilyId,
// واریانت = ردیف کاتالوگ همان سایز/گرید
material_id: s.spec?.kind === "variant" ? s.spec.id : null,
brand_id: s.brandId,
// مشخصه‌ی موجودی
specs_hash: s.spec?.kind === "stockSpec" ? s.spec.id : null,
};
}
+22 -3
View File
@@ -165,13 +165,15 @@ CREATE TABLE category_attributes (
); );
-- ---------- MATERIALS (GLOBAL catalog entities — v2.6/v2.7) ---------- -- ---------- MATERIALS (GLOBAL catalog entities — v2.6/v2.7) ----------
-- Materials are global, category-free entities. name is GLOBALLY -- Materials are global entities. name is GLOBALLY unique (uq_material_name).
-- unique (uq_material_name). topic is a legacy display column no longer -- topic is a legacy display column; category_id (v2.8) is the dashboard's
-- written by the app. -- Category→Material cascade link — backfilled from topic at API startup
-- (init_db.ensure_dashboard_schema; NULL = «بدون دسته»).
CREATE TABLE materials ( CREATE TABLE materials (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
topic TEXT, -- LEGACY display column topic TEXT, -- LEGACY display column
category_id UUID REFERENCES material_categories(id) ON DELETE SET NULL,
canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT, canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
status material_status NOT NULL DEFAULT 'active', status material_status NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
@@ -481,6 +483,23 @@ CREATE INDEX idx_category_attrs_category ON category_attributes(category_id);
CREATE INDEX idx_category_attrs_attr_def ON category_attributes(attribute_definition_id); CREATE INDEX idx_category_attrs_attr_def ON category_attributes(attribute_definition_id);
CREATE INDEX idx_line_item_specs_attr_def ON line_item_specs(attribute_definition_id); CREATE INDEX idx_line_item_specs_attr_def ON line_item_specs(attribute_definition_id);
-- ============================================================================
-- MVP DASHBOARD INDEXES (v2.8 — aggregation queries in dashboard_service)
-- (Fresh installs; existing volumes get the same set idempotently via
-- init_db.ensure_dashboard_schema.)
-- ============================================================================
CREATE INDEX idx_materials_category ON materials(category_id);
CREATE INDEX idx_warehouses_project ON warehouses(project_id);
CREATE INDEX idx_inventory_tx_date ON inventory_transactions(transaction_date);
CREATE INDEX idx_inventory_tx_type_date ON inventory_transactions(transaction_type, transaction_date);
CREATE INDEX idx_quotes_accepted ON quotes(accepted_date) WHERE accepted_date IS NOT NULL;
CREATE INDEX idx_quotes_arrived ON quotes(arrived_date) WHERE arrived_date IS NOT NULL;
CREATE INDEX idx_quotes_status ON quotes(status);
CREATE INDEX idx_item_decisions_project ON item_decisions(project_id);
CREATE INDEX idx_item_receipts_received ON item_receipts(received_at);
CREATE INDEX idx_inventory_material_brand ON inventory(material_id, brand_id);
-- ============================================================================ -- ============================================================================
-- QUOTE ATTACHMENTS (invoice / proforma files — bytes live in MinIO) -- QUOTE ATTACHMENTS (invoice / proforma files — bytes live in MinIO)
-- ============================================================================ -- ============================================================================