Compare commits
4
Commits
d38cf38bd9
...
7bb432e715
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bb432e715 | ||
|
|
b82c039265 | ||
|
|
d84637769a | ||
|
|
b0776da061 |
@@ -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)")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
"""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(
|
||||||
|
category_id: uuid.UUID | None,
|
||||||
|
material_id: uuid.UUID | None,
|
||||||
|
brand_id: uuid.UUID | None,
|
||||||
|
specs_hash: str | None,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"category_id": category_id,
|
||||||
|
"material_id": material_id,
|
||||||
|
"brand_id": brand_id,
|
||||||
|
"specs_hash": specs_hash,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/filters")
|
||||||
|
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Cascading options for the global filter bar (Category→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),
|
||||||
|
category_id: uuid.UUID | None = Query(default=None),
|
||||||
|
material_id: uuid.UUID | None = Query(default=None),
|
||||||
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
|
specs_hash: str | None = Query(default=None),
|
||||||
|
):
|
||||||
|
"""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(category_id, material_id, brand_id, specs_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inventory/dead-stock")
|
||||||
|
async def dead_stock(
|
||||||
|
scope: DashboardScope = Depends(dashboard_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"),
|
||||||
|
limit: int = Query(default=200, ge=1, le=1000),
|
||||||
|
category_id: uuid.UUID | None = Query(default=None),
|
||||||
|
material_id: uuid.UUID | None = Query(default=None),
|
||||||
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
|
specs_hash: str | None = Query(default=None),
|
||||||
|
):
|
||||||
|
"""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(category_id, material_id, brand_id, specs_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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),
|
||||||
|
category_id: uuid.UUID | None = Query(default=None),
|
||||||
|
material_id: uuid.UUID | None = Query(default=None),
|
||||||
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
|
specs_hash: str | None = Query(default=None),
|
||||||
|
):
|
||||||
|
"""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(category_id, material_id, brand_id, specs_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,658 @@
|
|||||||
|
"""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 (category_id / 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 app.models.catalog import Brand, BrandMaterial, Material, MaterialCategory, Unit
|
||||||
|
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||||
|
from app.models.procurement import (
|
||||||
|
ItemDecision,
|
||||||
|
ItemReceipt,
|
||||||
|
LineItem,
|
||||||
|
Project,
|
||||||
|
Quote,
|
||||||
|
QuoteProjects,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
|
||||||
|
def _stock_filters(stmt, *, category_id=None, material_id=None, brand_id=None, specs_hash=None):
|
||||||
|
"""«What» filters for a statement that already joins inventory→material."""
|
||||||
|
if category_id is not None:
|
||||||
|
stmt = stmt.where(Material.category_id == category_id)
|
||||||
|
if material_id is not None:
|
||||||
|
stmt = stmt.where(InventoryItem.material_id == material_id)
|
||||||
|
if brand_id is not None:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# «What» filter cascade — Category → Material → Brand → Specs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def filter_options(db: AsyncSession) -> dict:
|
||||||
|
"""Cascading options for the global filter bar.
|
||||||
|
|
||||||
|
Categories/materials come from the catalog (active only). Brand options
|
||||||
|
per material are the UNION of the catalog links (brand_materials — every
|
||||||
|
brand defined for that material) and the distinct brands actually present
|
||||||
|
in stock, so the cascade is complete even for materials whose current
|
||||||
|
stock rows carry no brand yet. Spec options come from stock rows
|
||||||
|
(inventory.specs_hash), since specs are a stock-level dimension.
|
||||||
|
Materials without a category land in the synthetic «بدون دسته» bucket.
|
||||||
|
"""
|
||||||
|
categories = (
|
||||||
|
await db.execute(
|
||||||
|
select(MaterialCategory)
|
||||||
|
.where(MaterialCategory.is_active.is_(True))
|
||||||
|
.order_by(MaterialCategory.name)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
materials = (
|
||||||
|
await db.execute(select(Material).where(Material.status == "active").order_by(Material.name))
|
||||||
|
).scalars().all()
|
||||||
|
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, {})
|
||||||
|
return {
|
||||||
|
"id": str(m.id),
|
||||||
|
"name": m.name,
|
||||||
|
"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}
|
||||||
|
for h, sp in sorted(spec_map.items())
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
buckets: dict[uuid.UUID | None, list] = {c.id: [] for c in categories}
|
||||||
|
buckets.setdefault(None, [])
|
||||||
|
for m in materials:
|
||||||
|
buckets.setdefault(m.category_id, []).append(material_entry(m))
|
||||||
|
|
||||||
|
out = [
|
||||||
|
{"id": str(c.id), "name": c.name, "slug": c.slug, "materials": buckets.get(c.id, [])}
|
||||||
|
for c in categories
|
||||||
|
]
|
||||||
|
if buckets.get(None):
|
||||||
|
out.append({"id": None, "name": "بدون دسته", "slug": None, "materials": buckets[None]})
|
||||||
|
return {"data": {"categories": out}}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Widget 1 — Drill-down matrix (organization → project → warehouse)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def inventory_drilldown(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
project_ids,
|
||||||
|
project_id,
|
||||||
|
category_id=None,
|
||||||
|
material_id=None,
|
||||||
|
brand_id=None,
|
||||||
|
specs_hash=None,
|
||||||
|
) -> dict:
|
||||||
|
"""Stock quantity as a three-level tree; each aggregate keyed by unit code
|
||||||
|
so incompatible units never merge into one number."""
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
Project.id,
|
||||||
|
Project.name,
|
||||||
|
Warehouse.id,
|
||||||
|
Warehouse.name,
|
||||||
|
Unit.code,
|
||||||
|
Unit.name_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(Material, Material.id == InventoryItem.material_id)
|
||||||
|
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||||
|
.where(InventoryItem.quantity > 0)
|
||||||
|
.group_by(Project.id, Project.name, Warehouse.id, Warehouse.name, Unit.code, Unit.name_fa)
|
||||||
|
.order_by(Project.name, Warehouse.name)
|
||||||
|
)
|
||||||
|
stmt = _stock_filters(
|
||||||
|
stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
|
||||||
|
)
|
||||||
|
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
|
||||||
|
rows = (await db.execute(stmt)).all()
|
||||||
|
|
||||||
|
units_legend: dict[str, str] = {}
|
||||||
|
total_by_unit: dict[str, float] = {}
|
||||||
|
total_rows = 0
|
||||||
|
projects_tree: dict[uuid.UUID, dict] = {}
|
||||||
|
for pid, pname, wid, wname, ucode, uname_fa, qty, row_count in rows:
|
||||||
|
q = _qty(qty)
|
||||||
|
units_legend[ucode] = uname_fa
|
||||||
|
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
|
||||||
|
total_rows += row_count
|
||||||
|
proj = projects_tree.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
|
||||||
|
|
||||||
|
projects = []
|
||||||
|
for proj in projects_tree.values():
|
||||||
|
warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"])
|
||||||
|
projects.append({**proj, "warehouses": warehouses})
|
||||||
|
projects.sort(key=lambda p: p["name"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"units": units_legend,
|
||||||
|
"total": {"qty_by_unit": total_by_unit, "rows": total_rows},
|
||||||
|
"projects": projects,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Widget 2 — Dead stock (dormant capital)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def dead_stock(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
project_ids,
|
||||||
|
project_id,
|
||||||
|
idle_days: int = 60,
|
||||||
|
category_id=None,
|
||||||
|
material_id=None,
|
||||||
|
brand_id=None,
|
||||||
|
specs_hash=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, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
|
||||||
|
)
|
||||||
|
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
|
||||||
|
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,
|
||||||
|
category_id=None,
|
||||||
|
material_id=None,
|
||||||
|
brand_id=None,
|
||||||
|
specs_hash=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`.
|
||||||
|
"""
|
||||||
|
def _with_joins(sel):
|
||||||
|
return (
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
|
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 = [Material.category_id == category_id] if category_id is not None else []
|
||||||
|
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)
|
||||||
|
|
||||||
|
day_expr = func.date_trunc("day", InventoryTransaction.transaction_date).label("day")
|
||||||
|
bucket_rows = (
|
||||||
|
await db.execute(
|
||||||
|
_with_joins(
|
||||||
|
select(day_expr, InventoryTransaction.transaction_type, 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)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
signed_by_day: dict[date_cls, dict[str, float]] = {}
|
||||||
|
for day, tx_type, signed in bucket_rows:
|
||||||
|
day_key = day.date() if isinstance(day, datetime) else day
|
||||||
|
signed_by_day.setdefault(day_key, {})[str(tx_type)] = _qty(signed)
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
current += timedelta(days=1)
|
||||||
|
|
||||||
|
return {"data": {"opening_balance": opening, "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): outbound / return / waste / purchase ticket
|
||||||
|
counts + quantities, the exact performer names, and the operation detail."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
count_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
InventoryTransaction.transaction_type,
|
||||||
|
func.count().label("tickets"),
|
||||||
|
func.sum(func.abs(InventoryTransaction.quantity)).label("qty"),
|
||||||
|
)
|
||||||
|
.select_from(InventoryTransaction)
|
||||||
|
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||||
|
.where(
|
||||||
|
InventoryTransaction.transaction_date >= day_start,
|
||||||
|
InventoryTransaction.transaction_date < day_end,
|
||||||
|
*scope_conditions,
|
||||||
|
)
|
||||||
|
.group_by(InventoryTransaction.transaction_type)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
by_type = {str(tx_type): {"tickets": tickets, "qty": _qty(qty)} for tx_type, tickets, qty in count_rows}
|
||||||
|
|
||||||
|
performers_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
InventoryTransaction.performed_by,
|
||||||
|
func.count().label("tickets"),
|
||||||
|
func.sum(func.abs(InventoryTransaction.quantity)).label("qty"),
|
||||||
|
)
|
||||||
|
.select_from(InventoryTransaction)
|
||||||
|
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||||
|
.where(
|
||||||
|
InventoryTransaction.transaction_date >= day_start,
|
||||||
|
InventoryTransaction.transaction_date < day_end,
|
||||||
|
*scope_conditions,
|
||||||
|
)
|
||||||
|
.group_by(InventoryTransaction.performed_by)
|
||||||
|
.order_by(func.count().desc())
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
detail_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
InventoryTransaction,
|
||||||
|
Warehouse.name,
|
||||||
|
Material.name,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Unit.code,
|
||||||
|
)
|
||||||
|
.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)
|
||||||
|
.where(
|
||||||
|
InventoryTransaction.transaction_date >= day_start,
|
||||||
|
InventoryTransaction.transaction_date < day_end,
|
||||||
|
InventoryTransaction.transaction_type.in_(
|
||||||
|
("WITHDRAWAL", "RETURN", "DAMAGE", "WASTE", "LOSS", "PURCHASE")
|
||||||
|
),
|
||||||
|
*scope_conditions,
|
||||||
|
)
|
||||||
|
.order_by(InventoryTransaction.transaction_date.desc())
|
||||||
|
.limit(200)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
zero = {"tickets": 0, "qty": 0.0}
|
||||||
|
items = []
|
||||||
|
for tx, wh_name, mat_name, specs, ucode in detail_rows:
|
||||||
|
items.append({
|
||||||
|
"id": str(tx.id),
|
||||||
|
"transaction_type": str(tx.transaction_type),
|
||||||
|
"quantity": _qty(tx.quantity),
|
||||||
|
"unit_code": ucode,
|
||||||
|
"performed_by": tx.performed_by,
|
||||||
|
"note": tx.note,
|
||||||
|
"warehouse": wh_name,
|
||||||
|
"material": mat_name,
|
||||||
|
"specs": specs or {},
|
||||||
|
"transaction_date": tx.transaction_date.isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"date": day.isoformat(),
|
||||||
|
"outbound": by_type.get("WITHDRAWAL", zero),
|
||||||
|
"returns": by_type.get("RETURN", zero),
|
||||||
|
"waste": {
|
||||||
|
"tickets": sum(by_type.get(t, zero)["tickets"] for t in WASTE_TYPES),
|
||||||
|
"qty": round(sum(by_type.get(t, zero)["qty"] for t in WASTE_TYPES), 4),
|
||||||
|
},
|
||||||
|
"purchases": by_type.get("PURCHASE", zero),
|
||||||
|
"performers": [
|
||||||
|
{"name": name or "نامشخص", "tickets": tickets, "qty": _qty(qty)}
|
||||||
|
for name, tickets, qty in performers_rows
|
||||||
|
],
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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}}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+390
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -26,6 +27,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) منو را باز نمیکند —
|
||||||
@@ -56,12 +60,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 ?? "");
|
||||||
@@ -82,6 +91,8 @@ export function Combobox({
|
|||||||
const showAdd =
|
const showAdd =
|
||||||
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q);
|
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q);
|
||||||
|
|
||||||
|
const showClear = Boolean(onClear) && !disabled && text.length > 0;
|
||||||
|
|
||||||
const listCount = filtered.length + (showAdd ? 1 : 0);
|
const listCount = filtered.length + (showAdd ? 1 : 0);
|
||||||
|
|
||||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||||
@@ -133,6 +144,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,12 +158,40 @@ 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 && (
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۴ — کنترل روزانه (امروز)
|
||||||
|
// کارتهای KPI: بلیتهای خروج امروز، برگشت از سایت، ضایعات و پذیرش
|
||||||
|
// + فهرست دقیق نام تحویلدهندهها/انجامدهندهها + ریز عملیات امروز.
|
||||||
|
// هر ۶۰ ثانیه خودکار تازهسازی میشود (useDailyControl).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowDownToLine,
|
||||||
|
ArrowUpFromLine,
|
||||||
|
PackageOpen,
|
||||||
|
TriangleAlert,
|
||||||
|
Users,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useDailyControl } from "../../hooks/useDashboardData";
|
||||||
|
import { faNum, fmtDateTime, 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";
|
||||||
|
|
||||||
|
const TYPE_FA: Record<string, string> = {
|
||||||
|
WITHDRAWAL: "برداشت از انبار",
|
||||||
|
RETURN: "برگشت از سایت",
|
||||||
|
DAMAGE: "آسیبدیده",
|
||||||
|
WASTE: "اتلاف",
|
||||||
|
LOSS: "مفقودی",
|
||||||
|
PURCHASE: "پذیرش خرید",
|
||||||
|
TRANSFER_IN: "انتقال ورودی",
|
||||||
|
TRANSFER_OUT: "انتقال خروجی",
|
||||||
|
FOUND: "پیدا شده",
|
||||||
|
STOCK_COUNT: "انبارگردانی",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_STYLE: Record<string, string> = {
|
||||||
|
WITHDRAWAL: "bg-blue-bg text-[#1d4ed8]",
|
||||||
|
RETURN: "bg-amber-bg text-amber",
|
||||||
|
DAMAGE: "bg-red-bg text-red",
|
||||||
|
WASTE: "bg-red-bg text-red",
|
||||||
|
LOSS: "bg-red-bg text-red",
|
||||||
|
PURCHASE: "bg-green-bg text-green",
|
||||||
|
};
|
||||||
|
|
||||||
|
function KpiCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
tickets,
|
||||||
|
qty,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
tickets: number;
|
||||||
|
qty: number;
|
||||||
|
tone: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2.5">
|
||||||
|
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-lg", tone)}>
|
||||||
|
<Icon className="h-4.5 w-4.5" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[11px] text-muted">{label}</div>
|
||||||
|
<div className="flex items-baseline gap-1.5 tabular-nums">
|
||||||
|
<span className="text-[17px] font-extrabold leading-6">{faNum(tickets)}</span>
|
||||||
|
<span className="text-[10.5px] text-muted">بلیت</span>
|
||||||
|
<span className="text-[11px] text-muted">·</span>
|
||||||
|
<span className="text-[12px] font-bold">{fmtNum(qty)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DailyControlPanel() {
|
||||||
|
const query = useDailyControl();
|
||||||
|
const d = query.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title="کنترل روزانه — عملیات امروز"
|
||||||
|
sub={d ? `تاریخ ${isoToJalaliStr(d.date) ?? d.date} — هر دقیقه بهروزرسانی میشود` : "هر دقیقه بهروزرسانی میشود"}
|
||||||
|
>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : !d ? (
|
||||||
|
<ChartEmpty message="دادهی امروز در دسترس نیست" />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* KPI کارتها */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||||
|
<KpiCard
|
||||||
|
icon={ArrowUpFromLine}
|
||||||
|
label="خروج به سایت (برداشت)"
|
||||||
|
tickets={d.outbound.tickets}
|
||||||
|
qty={d.outbound.qty}
|
||||||
|
tone="bg-blue-bg text-[#1d4ed8]"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={ArrowDownToLine}
|
||||||
|
label="برگشت از سایت"
|
||||||
|
tickets={d.returns.tickets}
|
||||||
|
qty={d.returns.qty}
|
||||||
|
tone="bg-amber-bg text-amber"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={TriangleAlert}
|
||||||
|
label="ضایعات / اتلاف / مفقودی"
|
||||||
|
tickets={d.waste.tickets}
|
||||||
|
qty={d.waste.qty}
|
||||||
|
tone="bg-red-bg text-red"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={PackageOpen}
|
||||||
|
label="پذیرش خرید (ورود)"
|
||||||
|
tickets={d.purchases.tickets}
|
||||||
|
qty={d.purchases.qty}
|
||||||
|
tone="bg-green-bg text-green"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 lg:grid-cols-5">
|
||||||
|
{/* نام دقیق انجامدهندهها */}
|
||||||
|
<div className="rounded-xl border border-line p-3 lg:col-span-2">
|
||||||
|
<h3 className="mb-2 flex items-center gap-1.5 text-[12.5px] font-extrabold">
|
||||||
|
<Users className="h-4 w-4 text-muted" />
|
||||||
|
تحویلدهندهها / انجامدهندههای امروز
|
||||||
|
</h3>
|
||||||
|
{d.performers.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-[12px] text-muted">امروز عملیاتی ثبت نشده است</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-line">
|
||||||
|
{d.performers.map((p, i) => (
|
||||||
|
<li key={i} className="flex items-center gap-2 py-1.5">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-accent-bg text-[11px] font-extrabold text-accent">
|
||||||
|
{faNum(i + 1)}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-[12.5px] font-bold">{p.name}</span>
|
||||||
|
<span className="ms-auto shrink-0 text-[11px] tabular-nums text-muted">
|
||||||
|
{fmtNum(p.tickets)} بلیت · {fmtNum(p.qty)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ریز عملیات امروز */}
|
||||||
|
<div className="rounded-xl border border-line p-3 lg:col-span-3">
|
||||||
|
<h3 className="mb-2 text-[12.5px] font-extrabold">ریز عملیات امروز</h3>
|
||||||
|
{d.items.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-[12px] text-muted">هنوز جابهجاییای امروز ثبت نشده است</p>
|
||||||
|
) : (
|
||||||
|
<ul className="max-h-56 flex flex-col divide-y divide-line overflow-y-auto pe-1">
|
||||||
|
{d.items.map((it) => (
|
||||||
|
<li key={it.id} className="flex items-center gap-2 py-1.5">
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 px-2 py-0 text-[10px]",
|
||||||
|
TYPE_STYLE[it.transaction_type] ?? "bg-slate-bg text-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{TYPE_FA[it.transaction_type] ?? it.transaction_type}
|
||||||
|
</Badge>
|
||||||
|
<span className="truncate text-[12px] font-bold">{it.material}</span>
|
||||||
|
<span className="shrink-0 text-[11px] tabular-nums text-muted">
|
||||||
|
{fmtNum(it.quantity)} {it.unit_code}
|
||||||
|
</span>
|
||||||
|
<span className="ms-auto shrink-0 text-[10.5px] text-muted" title={it.warehouse}>
|
||||||
|
{fmtDateTime(it.transaction_date)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// ============================================================
|
||||||
|
// فیلتر سراسری «چه چیزی» — آبشار: دستهبندی → متریال → برند → مشخصات
|
||||||
|
// داده از /dashboard/filters میآید (بدون هیچ لیست ساختگی) و انتخاب
|
||||||
|
// هر سطح، سطحهای پاییندست را بازنشانی میکند:
|
||||||
|
// دسته → متریالهای همان دسته
|
||||||
|
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
|
||||||
|
// متریال → مشخصات فنی موجودِ همان متریال
|
||||||
|
// هر فیلد دکمهی «×» دارد که فقط همان سطح (و زیرمجموعهاش) را پاک میکند.
|
||||||
|
// وضعیت در Zustand نگه داشته میشود تا با جابهجایی بین تبها از دست نرود.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Filter } from "lucide-react";
|
||||||
|
import { Combobox } from "../Combobox";
|
||||||
|
import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
|
||||||
|
import { useDashboardFilter } from "../../store/dashboardFilter";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
export function DashboardFilterBar({ className }: { className?: string }) {
|
||||||
|
const filterQuery = useDashboardFilterOptions();
|
||||||
|
const state = useDashboardFilter();
|
||||||
|
|
||||||
|
const categories = filterQuery.data?.categories ?? [];
|
||||||
|
|
||||||
|
// متریالهای سطح فعلی: اگر دسته انتخاب شده باشد فقط متریالهای همان دسته
|
||||||
|
const materialOptions = useMemo(() => {
|
||||||
|
const cat = categories.find((c) => (c.id ?? "") === (state.categoryId ?? ""));
|
||||||
|
const list = cat ? cat.materials : categories.flatMap((c) => c.materials);
|
||||||
|
// حذف متریال تکراری وقتی «همهی دستهها» فعال است
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return list.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true)));
|
||||||
|
}, [categories, state.categoryId]);
|
||||||
|
|
||||||
|
const selectedMaterial = useMemo(
|
||||||
|
() => materialOptions.find((m) => m.id === state.materialId) ?? null,
|
||||||
|
[materialOptions, state.materialId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeCount = [state.categoryId, state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
|
||||||
|
|
||||||
|
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیشفرض)»
|
||||||
|
const specLabel = (specs: Record<string, string> | null) => {
|
||||||
|
const parts = Object.entries(specs || {});
|
||||||
|
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیشفرض)";
|
||||||
|
};
|
||||||
|
|
||||||
|
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">دستهبندی ← متریال ← برند ← مشخصات فنی</p>
|
||||||
|
</div>
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={state.reset}
|
||||||
|
className="ms-auto rounded-full bg-red-bg px-2.5 py-1 text-[11px] font-bold text-red transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
حذف فیلترها ({faNum(activeCount)})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||||
|
{/* ۱ — دستهبندی */}
|
||||||
|
<Combobox
|
||||||
|
value={state.categoryLabel ?? ""}
|
||||||
|
placeholder="همهی دستهها"
|
||||||
|
allowAdd={false}
|
||||||
|
items={categories.filter((c) => c.id).map((c) => ({ id: c.id as string, label: c.name }))}
|
||||||
|
onClear={() => state.setCategory(null, null)}
|
||||||
|
onChange={(item) => state.setCategory(item.id, item.label)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ۲ — متریال (وابسته به دستهی انتخابشده) */}
|
||||||
|
<Combobox
|
||||||
|
value={state.materialLabel ?? ""}
|
||||||
|
placeholder="همهی متریالها"
|
||||||
|
allowAdd={false}
|
||||||
|
disabled={materialOptions.length === 0}
|
||||||
|
items={materialOptions.map((m) => ({ id: m.id, label: m.name }))}
|
||||||
|
onClear={() => state.setMaterial(null, null)}
|
||||||
|
onChange={(item) => state.setMaterial(item.id, item.label)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ۳ — برند (فقط برندهای همان متریال) */}
|
||||||
|
<Combobox
|
||||||
|
value={state.brandLabel ?? ""}
|
||||||
|
placeholder={selectedMaterial ? "همهی برندها" : "ابتدا متریال را انتخاب کنید"}
|
||||||
|
allowAdd={false}
|
||||||
|
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
|
||||||
|
items={(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name }))}
|
||||||
|
onClear={() => state.setBrand(null, null)}
|
||||||
|
onChange={(item) => state.setBrand(item.id, item.label)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ۴ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
|
||||||
|
<Combobox
|
||||||
|
value={state.specsLabel ?? ""}
|
||||||
|
placeholder={selectedMaterial ? "همهی مشخصات" : "ابتدا متریال را انتخاب کنید"}
|
||||||
|
allowAdd={false}
|
||||||
|
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
|
||||||
|
items={(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) }))}
|
||||||
|
onClear={() => state.setSpecs(null, null)}
|
||||||
|
onChange={(item) => state.setSpecs(item.id, item.label)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۲ — جدول موجودی راکد (سرمایهی خوابیده)
|
||||||
|
// ردیفهایی با موجودی مثبت که هیچ تراکنشی در بازهی رکود نداشتهاند.
|
||||||
|
// آستانهی ۶۰/۹۰ روز با چیپ انتخاب میشود؛ روزهای رکود رنگی است.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Snowflake } from "lucide-react";
|
||||||
|
import { useDeadStock } from "../../hooks/useDashboardData";
|
||||||
|
import { fmtNum, faNum } from "../../lib/formatters";
|
||||||
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../ui/table";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
|
||||||
|
|
||||||
|
function specLabel(specs: Record<string, string>): string {
|
||||||
|
const parts = Object.entries(specs || {});
|
||||||
|
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function idleSeverity(days: number | null): string {
|
||||||
|
if (days == null) return "text-muted";
|
||||||
|
if (days >= 90) return "text-red";
|
||||||
|
if (days >= 60) return "text-amber";
|
||||||
|
return "text-muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeadStockTable() {
|
||||||
|
const [idleDays, setIdleDays] = useState<60 | 90>(60);
|
||||||
|
const query = useDeadStock(idleDays);
|
||||||
|
const items = query.data?.items ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Snowflake className="h-4 w-4 text-[#0ea5e9]" />
|
||||||
|
موجودی راکد — سرمایهی خوابیده
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
sub="اقلامی با موجودی مثبت که هیچ تراکنشی در بازهی انتخابی نداشتهاند"
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{([60, 90] as const).map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIdleDays(d)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2.5 py-0.5 text-[11px] font-bold transition-colors",
|
||||||
|
idleDays === d ? "bg-accent text-white" : "bg-slate-bg text-muted hover:text-ink",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{faNum(d)} روز و بیشتر
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<ChartEmpty message={`موجودی راکدی با آستانهی ${faNum(idleDays)} روز وجود ندارد`} />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead>متریال و مشخصات</TableHead>
|
||||||
|
<TableHead>برند</TableHead>
|
||||||
|
<TableHead>انبار / پروژه</TableHead>
|
||||||
|
<TableHead className="text-end">مقدار راکد</TableHead>
|
||||||
|
<TableHead className="text-end">روزهای رکود</TableHead>
|
||||||
|
<TableHead>آخرین فعالیت</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((it) => {
|
||||||
|
const specs = specLabel(it.specs);
|
||||||
|
return (
|
||||||
|
<TableRow key={it.inventory_id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-[13px] font-bold">{it.material}</div>
|
||||||
|
{specs && <div className="text-[11px] text-muted">{specs}</div>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-[12px] text-muted">{it.brand || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-[12px]">{it.warehouse}</div>
|
||||||
|
<div className="text-[10.5px] text-muted">{it.project}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-end font-bold tabular-nums">
|
||||||
|
{fmtNum(it.quantity)}{" "}
|
||||||
|
<span className="text-[10.5px] font-normal text-muted">
|
||||||
|
{it.unit?.name_fa || it.unit?.code}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={cn("text-end font-extrabold tabular-nums", idleSeverity(it.idle_days))}>
|
||||||
|
{it.idle_days == null ? "—" : faNum(it.idle_days)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-[11px] text-muted">
|
||||||
|
{it.last_activity ? isoToJalaliStr(it.last_activity) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۱ — ماتریس Drill-Down سهسطحی (سبک Power BI)
|
||||||
|
// سطح ۱ «کل سازمان» → یک ستون: تجمیع موجودی همهی انبارهای سازمان
|
||||||
|
// سطح ۲ «پروژهها» → با کلیک روی سطح ۱: سهم هر پروژه
|
||||||
|
// سطح ۳ «انبارها» → با کلیک روی هر پروژه: انبارهای همان پروژه
|
||||||
|
// همهی سطوح از پاسخ /dashboard/inventory/drilldown ساخته میشوند —
|
||||||
|
// هیچ مقدار/نام/وضعیتی در فرانتاند هاردکد نشده است. اگر پروژهی
|
||||||
|
// انتخابشده با تغییر فیلترها از داده حذف شود، سطح خودکار به بالا برمیگردد.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
LabelList,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { ChevronLeft, MousePointerClick } from "lucide-react";
|
||||||
|
import { useDrilldown } from "../../hooks/useDashboardData";
|
||||||
|
import { fmtNum } from "../../lib/formatters";
|
||||||
|
import {
|
||||||
|
CHART_COLORS,
|
||||||
|
ChartCard,
|
||||||
|
ChartEmpty,
|
||||||
|
ChartLoading,
|
||||||
|
ChartTooltip,
|
||||||
|
UnitPicker,
|
||||||
|
faAxisTick,
|
||||||
|
} from "./chartShared";
|
||||||
|
|
||||||
|
type Level = 0 | 1 | 2; // 0=سازمان، 1=پروژهها، 2=انبارها
|
||||||
|
|
||||||
|
interface Slice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
rows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_TITLES: Record<Level, string> = {
|
||||||
|
0: "کل سازمان",
|
||||||
|
1: "پروژهها",
|
||||||
|
2: "انبارها",
|
||||||
|
};
|
||||||
|
|
||||||
|
// پالت سطح انبارها — سایههای ایندیگو برای تمایز انبارهای یک پروژه
|
||||||
|
const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"];
|
||||||
|
|
||||||
|
export function DrilldownMatrix() {
|
||||||
|
const query = useDrilldown();
|
||||||
|
const [unit, setUnit] = useState<string | null>(null);
|
||||||
|
const [level, setLevel] = useState<Level>(0);
|
||||||
|
const [projectId, setProjectId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const data = query.data;
|
||||||
|
|
||||||
|
// واحد فعال: انتخاب کاربر یا بزرگترین مجموع (پایدار بین رندرها)
|
||||||
|
const activeUnit = useMemo(() => {
|
||||||
|
if (unit && data?.units?.[unit]) return unit;
|
||||||
|
const entries = Object.entries(data?.total?.qty_by_unit ?? {});
|
||||||
|
if (!entries.length) return null;
|
||||||
|
return entries.sort((a, b) => b[1] - a[1])[0][0];
|
||||||
|
}, [unit, data]);
|
||||||
|
|
||||||
|
const selectedProject = useMemo(
|
||||||
|
() => (projectId && data ? data.projects.find((p) => p.id === projectId) ?? null : null),
|
||||||
|
[data, projectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// کلمپِ خودکار: اگر پروژهی انتخابی با فیلترهای جدید از داده خارج شد،
|
||||||
|
// به سطح پروژهها برگرد (بدون هیچ مقدار ثابت).
|
||||||
|
useEffect(() => {
|
||||||
|
if (level === 2 && projectId && data && !selectedProject) {
|
||||||
|
setProjectId(null);
|
||||||
|
setLevel(1);
|
||||||
|
}
|
||||||
|
if (data && data.projects.length === 0 && level !== 0) {
|
||||||
|
setProjectId(null);
|
||||||
|
setLevel(0);
|
||||||
|
}
|
||||||
|
}, [data, level, projectId, selectedProject]);
|
||||||
|
|
||||||
|
const pick = (q: Record<string, number>) => (activeUnit ? q[activeUnit] ?? 0 : 0);
|
||||||
|
|
||||||
|
// برشهای سطح فعال — کاملاً از پاسخ API
|
||||||
|
const slices: Slice[] = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
if (level === 0) {
|
||||||
|
return [{ id: "__org__", name: "کل سازمان", value: pick(data.total.qty_by_unit), rows: data.total.rows }];
|
||||||
|
}
|
||||||
|
if (level === 1) {
|
||||||
|
return data.projects.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
value: pick(p.qty_by_unit),
|
||||||
|
rows: p.rows,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return (selectedProject?.warehouses ?? []).map((w) => ({
|
||||||
|
id: w.id,
|
||||||
|
name: w.name,
|
||||||
|
value: pick(w.qty_by_unit),
|
||||||
|
rows: w.rows,
|
||||||
|
}));
|
||||||
|
}, [data, level, selectedProject, activeUnit]);
|
||||||
|
|
||||||
|
const unitLabel = activeUnit ? data?.units?.[activeUnit] || activeUnit : "";
|
||||||
|
const levelTotal = slices.reduce((s, x) => s + x.value, 0);
|
||||||
|
const total = data ? pick(data.total.qty_by_unit) : 0;
|
||||||
|
|
||||||
|
const handleBarClick = (slice: Slice) => {
|
||||||
|
if (level === 0 && data && data.projects.length > 0) {
|
||||||
|
setLevel(1);
|
||||||
|
} else if (level === 1) {
|
||||||
|
setProjectId(slice.id);
|
||||||
|
setLevel(2);
|
||||||
|
}
|
||||||
|
// سطح ۳ برگ است — کلیک دیگری لازم نیست
|
||||||
|
};
|
||||||
|
|
||||||
|
const subText =
|
||||||
|
level === 0
|
||||||
|
? "تجمیع موجودی همهی انبارهای سازمان برای فیلترهای فعال — برای مشاهدهی سهم پروژهها کلیک کنید"
|
||||||
|
: level === 1
|
||||||
|
? "سهم هر پروژه از موجودی — برای مشاهدهی انبارهای هر پروژه روی ستون آن کلیک کنید"
|
||||||
|
: `انبارهای پروژه «${selectedProject?.name ?? ""}» — برای بازگشت روی مسیر کلیک کنید`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title="ماتریس توزیع موجودی — کجا ذخیره شده؟"
|
||||||
|
sub={subText}
|
||||||
|
actions={data ? <UnitPicker units={data.units} value={activeUnit ?? ""} onChange={setUnit} totals={data.total.qty_by_unit} /> : null}
|
||||||
|
>
|
||||||
|
{/* مسیر Drill-Down (RTL) */}
|
||||||
|
<nav dir="rtl" className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setProjectId(null);
|
||||||
|
setLevel(0);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
level === 0
|
||||||
|
? "rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent"
|
||||||
|
: "rounded-md px-2 py-0.5 font-bold text-muted transition-colors hover:bg-slate-bg hover:text-ink"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{LEVEL_TITLES[0]}
|
||||||
|
</button>
|
||||||
|
{level >= 1 && (
|
||||||
|
<>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5 text-muted" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setProjectId(null);
|
||||||
|
setLevel(1);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
level === 1
|
||||||
|
? "rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent"
|
||||||
|
: "rounded-md px-2 py-0.5 font-bold text-muted transition-colors hover:bg-slate-bg hover:text-ink"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{LEVEL_TITLES[1]}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{level >= 2 && selectedProject && (
|
||||||
|
<>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5 text-muted" />
|
||||||
|
<span className="rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent">
|
||||||
|
{selectedProject.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted">← {LEVEL_TITLES[2]}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="ms-auto text-[11px] tabular-nums text-muted">
|
||||||
|
جمع سطح فعال: {fmtNum(levelTotal)} {unitLabel}
|
||||||
|
{level > 0 && levelTotal !== total && (
|
||||||
|
<span className="ms-1">از {fmtNum(total)} {unitLabel} کل سازمان</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : !slices.length || (levelTotal === 0 && level === 0) ? (
|
||||||
|
<ChartEmpty message="موجودیای با این فیلترها یافت نشد" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div dir="ltr" className="h-64 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={slices} margin={{ top: 26, right: 8, left: 8, bottom: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fontSize: 11, fill: "#64748b", fontFamily: "Vazirmatn" }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e5e9f0" }}
|
||||||
|
interval={0}
|
||||||
|
height={44}
|
||||||
|
angle={slices.length > 4 ? -18 : 0}
|
||||||
|
textAnchor={slices.length > 4 ? "end" : "middle"}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||||
|
tickFormatter={faAxisTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={56}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
|
||||||
|
content={
|
||||||
|
<ChartTooltip
|
||||||
|
valueFormatter={(v) => `${fmtNum(v)} ${unitLabel}`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="value"
|
||||||
|
name="موجودی"
|
||||||
|
radius={[6, 6, 0, 0]}
|
||||||
|
maxBarSize={level === 0 ? 110 : 64}
|
||||||
|
isAnimationActive={false}
|
||||||
|
onClick={(entry: unknown) => {
|
||||||
|
const payload = (entry as { payload?: Slice })?.payload;
|
||||||
|
if (payload) handleBarClick(payload);
|
||||||
|
}}
|
||||||
|
className={level < 2 ? "cursor-pointer" : ""}
|
||||||
|
>
|
||||||
|
<LabelList
|
||||||
|
dataKey="value"
|
||||||
|
position="top"
|
||||||
|
fill="#0f172a"
|
||||||
|
fontSize={11}
|
||||||
|
fontWeight={700}
|
||||||
|
formatter={(v: unknown) => {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) && n > 0 ? fmtNum(n) : "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{slices.map((s, i) => (
|
||||||
|
<Cell
|
||||||
|
key={s.id}
|
||||||
|
fill={
|
||||||
|
level === 0
|
||||||
|
? CHART_COLORS.accent
|
||||||
|
: level === 1
|
||||||
|
? "#818cf8"
|
||||||
|
: WAREHOUSE_PALETTE[i % WAREHOUSE_PALETTE.length]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
{level < 2 && (
|
||||||
|
<p className="mt-1 flex items-center justify-center gap-1 text-[10.5px] text-muted">
|
||||||
|
<MousePointerClick className="h-3.5 w-3.5" />
|
||||||
|
{level === 0 ? "برای دیدن پروژهها روی ستون کلیک کنید" : "برای دیدن انبارهای هر پروژه روی ستون آن کلیک کنید"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۳ — سری زمانی جریان روزانه
|
||||||
|
// ستونهای روزانه: ورود (سبز) / برگشت (کهربایی) / خروج که لایهی
|
||||||
|
// قرمزِ «ضایعات، اتلاف، مفقودی» داخل همان ستون خروج سته —
|
||||||
|
// بهعلاوهی خط «مانده» (بالانس تجمعی). تاریخها شمسی، اعداد فارسی.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
Legend,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { useDailyFlow } from "../../hooks/useDashboardData";
|
||||||
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
|
import {
|
||||||
|
CHART_COLORS,
|
||||||
|
ChartCard,
|
||||||
|
ChartEmpty,
|
||||||
|
ChartLoading,
|
||||||
|
ChartTooltip,
|
||||||
|
faAxisTick,
|
||||||
|
} from "./chartShared";
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
date: string;
|
||||||
|
ورود: number;
|
||||||
|
برگشت: number;
|
||||||
|
"خروج سایت": number;
|
||||||
|
"ضایعات و اتلاف": number;
|
||||||
|
مانده: number;
|
||||||
|
_total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlowChart() {
|
||||||
|
const query = useDailyFlow();
|
||||||
|
|
||||||
|
const rows: Row[] = useMemo(
|
||||||
|
() =>
|
||||||
|
(query.data?.days ?? []).map((d) => ({
|
||||||
|
date: d.date,
|
||||||
|
ورود: d.inbound,
|
||||||
|
برگشت: d.returns,
|
||||||
|
"خروج سایت": Math.max(0, d.outbound - d.waste),
|
||||||
|
"ضایعات و اتلاف": d.waste,
|
||||||
|
مانده: d.balance,
|
||||||
|
_total: d.inbound + d.outbound + d.returns,
|
||||||
|
})),
|
||||||
|
[query.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAnyData = rows.some((r) => r._total > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title="جریان روزانه موجودی"
|
||||||
|
sub="ورود، خروج (با لایهی قرمز ضایعات)، برگشت از سایت و ماندهی تجمعی — ۳۰ روز اخیر"
|
||||||
|
>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : !hasAnyData ? (
|
||||||
|
<ChartEmpty message="در این بازه جابهجایی موجودی ثبت نشده است" />
|
||||||
|
) : (
|
||||||
|
<div dir="ltr" className="h-72 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart data={rows} margin={{ top: 12, right: 8, left: 8, bottom: 4 }}>
|
||||||
|
<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
|
||||||
|
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||||
|
tickFormatter={faAxisTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={56}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
||||||
|
content={
|
||||||
|
<ChartTooltip
|
||||||
|
labelFormatter={(l) => isoToJalaliStr(String(l)) ?? String(l)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ direction: "rtl", width: "100%" }}
|
||||||
|
verticalAlign="top"
|
||||||
|
height={28}
|
||||||
|
iconType="circle"
|
||||||
|
iconSize={8}
|
||||||
|
formatter={(value: unknown) => (
|
||||||
|
<span dir="rtl" className="text-[11px] text-muted">
|
||||||
|
{String(value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* ستونهای کنار هم: ورود و برگشت */}
|
||||||
|
<Bar dataKey="ورود" fill={CHART_COLORS.inbound} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
||||||
|
<Bar dataKey="برگشت" fill={CHART_COLORS.returns} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
||||||
|
{/* ستون خروج: دو لایهی سته — بخش قرمز ضایعات داخل ستون خروج */}
|
||||||
|
<Bar dataKey="خروج سایت" stackId="out" fill={CHART_COLORS.outbound} radius={[0, 0, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
||||||
|
<Bar dataKey="ضایعات و اتلاف" stackId="out" fill={CHART_COLORS.waste} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="مانده"
|
||||||
|
stroke={CHART_COLORS.balance}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeDasharray="5 3"
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</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");
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// ============================================================
|
||||||
|
// هوکهای 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) {
|
||||||
|
const params = useFilterParams();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "flow", dateFrom, dateTo, params],
|
||||||
|
queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ویجت ۴ — کنترل روزانه (امروز) — هر ۶۰ ثانیه تازهسازی میشود
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
+47
-6
@@ -19,23 +19,39 @@ import { useAuthStore } from "../store/auth";
|
|||||||
import type {
|
import type {
|
||||||
Attachment,
|
Attachment,
|
||||||
ConfirmedReceipt,
|
ConfirmedReceipt,
|
||||||
|
DashboardFilterOptions,
|
||||||
|
DailyControlData,
|
||||||
|
DeadStockItem,
|
||||||
|
DrilldownData,
|
||||||
|
FlowDay,
|
||||||
|
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 = {
|
||||||
|
category_id?: 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 +1903,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 +1919,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<{ opening_balance: number; days: FlowDay[] }> =>
|
||||||
|
getData<{ opening_balance: number; days: FlowDay[] }>(`/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: {
|
||||||
|
|||||||
+132
-1
@@ -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,132 @@ 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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterMaterialOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
unit: { id: number; code: string; name_fa: string | null } | null;
|
||||||
|
brands: { id: string; name: string }[];
|
||||||
|
specs: FilterSpecOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterCategoryOption {
|
||||||
|
id: string | null; // null = «بدون دسته»
|
||||||
|
name: string;
|
||||||
|
slug: string | null;
|
||||||
|
materials: FilterMaterialOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardFilterOptions {
|
||||||
|
categories: FilterCategoryOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// — ماتریس 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 DrilldownData {
|
||||||
|
units: Record<string, string>;
|
||||||
|
total: { qty_by_unit: QtyByUnit; rows: number };
|
||||||
|
projects: DrilldownProject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// — جدول موجودی راکد —
|
||||||
|
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 interface FlowDay {
|
||||||
|
date: string; // ISO (YYYY-MM-DD)
|
||||||
|
inbound: number;
|
||||||
|
outbound: number;
|
||||||
|
waste: number;
|
||||||
|
returns: number;
|
||||||
|
balance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// — کنترل روزانه —
|
||||||
|
export interface DailyControlData {
|
||||||
|
date: string;
|
||||||
|
outbound: { tickets: number; qty: number };
|
||||||
|
returns: { tickets: number; qty: number };
|
||||||
|
waste: { tickets: number; qty: number };
|
||||||
|
purchases: { tickets: number; qty: number };
|
||||||
|
performers: { name: string; tickets: number; qty: number }[];
|
||||||
|
items: {
|
||||||
|
id: string;
|
||||||
|
transaction_type: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_code: string;
|
||||||
|
performed_by: string | null;
|
||||||
|
note: string | null;
|
||||||
|
warehouse: string;
|
||||||
|
material: string;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
transaction_date: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// — بازرگانی: قیف تدارکات + 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 +387,3 @@ export interface ProjectTransferReceiveIn {
|
|||||||
received_at?: string | null;
|
received_at?: string | null;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 gap-3 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Zustand store — فیلتر سراسری «چه چیزی» داشبورد
|
||||||
|
// دستهبندی → متریال → برند → مشخصات فنی
|
||||||
|
// بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ میشود.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export interface DashboardFilterState {
|
||||||
|
categoryId: string | null; // null = همهی دستهها
|
||||||
|
materialId: string | null;
|
||||||
|
brandId: string | null;
|
||||||
|
specsHash: string | null;
|
||||||
|
// برچسبهای انتخابشده برای نمایش چیپ فعال
|
||||||
|
categoryLabel: string | null;
|
||||||
|
materialLabel: string | null;
|
||||||
|
brandLabel: string | null;
|
||||||
|
specsLabel: string | null;
|
||||||
|
|
||||||
|
setCategory: (id: string | null, label: string | null) => void;
|
||||||
|
setMaterial: (id: string | null, label: string | null) => void;
|
||||||
|
setBrand: (id: string | null, label: string | null) => void;
|
||||||
|
setSpecs: (hash: string | null, label: string | null) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL = {
|
||||||
|
categoryId: null,
|
||||||
|
materialId: null,
|
||||||
|
brandId: null,
|
||||||
|
specsHash: null,
|
||||||
|
categoryLabel: null,
|
||||||
|
materialLabel: null,
|
||||||
|
brandLabel: null,
|
||||||
|
specsLabel: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDashboardFilter = create<DashboardFilterState>((set) => ({
|
||||||
|
...INITIAL,
|
||||||
|
// آبشاری: تغییر هر سطح، سطحهای پاییندست را پاک میکند
|
||||||
|
setCategory: (id, label) =>
|
||||||
|
set({
|
||||||
|
categoryId: id,
|
||||||
|
categoryLabel: label,
|
||||||
|
materialId: null,
|
||||||
|
materialLabel: null,
|
||||||
|
brandId: null,
|
||||||
|
brandLabel: null,
|
||||||
|
specsHash: null,
|
||||||
|
specsLabel: null,
|
||||||
|
}),
|
||||||
|
setMaterial: (id, label) =>
|
||||||
|
set({
|
||||||
|
materialId: id,
|
||||||
|
materialLabel: label,
|
||||||
|
brandId: null,
|
||||||
|
brandLabel: null,
|
||||||
|
specsHash: null,
|
||||||
|
specsLabel: null,
|
||||||
|
}),
|
||||||
|
setBrand: (id, label) =>
|
||||||
|
set({
|
||||||
|
brandId: id,
|
||||||
|
brandLabel: label,
|
||||||
|
specsHash: null,
|
||||||
|
specsLabel: null,
|
||||||
|
}),
|
||||||
|
setSpecs: (hash, label) => set({ specsHash: hash, specsLabel: label }),
|
||||||
|
reset: () => set({ ...INITIAL }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// پارامترهای آماده برای endpointهای داشبورد
|
||||||
|
export function dashboardFilterParams(s: DashboardFilterState) {
|
||||||
|
return {
|
||||||
|
category_id: s.categoryId,
|
||||||
|
material_id: s.materialId,
|
||||||
|
brand_id: s.brandId,
|
||||||
|
specs_hash: s.specsHash,
|
||||||
|
};
|
||||||
|
}
|
||||||
+22
-3
@@ -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)
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user