Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b2970cdb5 | ||
|
|
41f93acec5 | ||
|
|
692343423c | ||
|
|
eedb821e3b | ||
|
|
3d16c435d0 | ||
|
|
34b987398a | ||
|
|
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("""
|
||||||
@@ -420,9 +421,6 @@ async def ensure_rbac_schema(engine: AsyncEngine) -> None:
|
|||||||
volume — the RBAC model expects these and login 500s without them."""
|
volume — the RBAC model expects these and login 500s without them."""
|
||||||
stmts = (
|
stmts = (
|
||||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT",
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS username TEXT",
|
||||||
# The live user_role enum predates 'supervisor' — CREATE TYPE no-ops on
|
|
||||||
# an existing type, so without this ALTER, assigning the role 500s.
|
|
||||||
"ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'supervisor'",
|
|
||||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS location TEXT DEFAULT ''",
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS location TEXT DEFAULT ''",
|
||||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS project_manager TEXT DEFAULT ''",
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS project_manager TEXT DEFAULT ''",
|
||||||
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_start DATE",
|
"ALTER TABLE projects ADD COLUMN IF NOT EXISTS planned_start DATE",
|
||||||
@@ -561,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,236 @@
|
|||||||
|
"""MVP dashboard router — read-only aggregations for the warehouse +
|
||||||
|
procurement dashboard widgets consumed by the frontend `apiV26.dashboard`
|
||||||
|
client (frontend/src/lib/api.ts).
|
||||||
|
|
||||||
|
Scope (same resolution pattern as the inventory / pm routers):
|
||||||
|
- role 'pm' → limited to the projects they manage (projects.pm_user_id, 1:N);
|
||||||
|
an explicit ?project_id must be one of them.
|
||||||
|
- role 'admin' / 'procurement_manager' → whole organization; ?project_id is
|
||||||
|
an optional filter.
|
||||||
|
- Dev fallback via X-User-Role / X-Project-Id headers, ONLY when
|
||||||
|
PROCUREMENT_DEV_LOGIN_ENABLED=true (read-only endpoints, same gate as the
|
||||||
|
inventory router).
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- GET /dashboard/filters — «What» cascade options
|
||||||
|
- GET /dashboard/inventory/drilldown — org → project → warehouse tree
|
||||||
|
- GET /dashboard/inventory/dead-stock — dormant stock table
|
||||||
|
- GET /dashboard/inventory/flow — daily inbound/outbound/returns/balance
|
||||||
|
- GET /dashboard/daily-control — today's tickets + performers
|
||||||
|
- GET /dashboard/procurement/workload — workload per project (density)
|
||||||
|
|
||||||
|
Every response is the standard `{"data": …}` envelope.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date as date_cls
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import UserContext, get_optional_user
|
||||||
|
from app.config import settings
|
||||||
|
from app.db.engine import get_db
|
||||||
|
from app.services import dashboard_service
|
||||||
|
from app.utils.errors import (
|
||||||
|
ForbiddenError,
|
||||||
|
UnauthorizedError,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DashboardScope:
|
||||||
|
role: str # "pm" | "office"
|
||||||
|
project_ids: list | None # PM: managed project ids; office: None = all
|
||||||
|
project_id: uuid.UUID | None # explicit ?project_id filter (validated for PM)
|
||||||
|
|
||||||
|
|
||||||
|
async def dashboard_scope(
|
||||||
|
request: Request,
|
||||||
|
project_id: uuid.UUID | None = Query(default=None, description="فیلتر پروژه (PM: فقط پروژههای خودش)"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> DashboardScope:
|
||||||
|
"""Resolve the dashboard caller — Casdoor JWT first, dev headers as fallback."""
|
||||||
|
if request.headers.get("authorization"):
|
||||||
|
user: UserContext | None = await get_optional_user(request, db)
|
||||||
|
if user is not None:
|
||||||
|
if user.role is None:
|
||||||
|
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||||
|
if user.role == "pm":
|
||||||
|
allowed = list(user.project_ids or [])
|
||||||
|
if project_id is not None:
|
||||||
|
if project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
return DashboardScope(role="pm", project_ids=allowed, project_id=project_id)
|
||||||
|
if not allowed:
|
||||||
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
|
return DashboardScope(role="pm", project_ids=allowed, project_id=None)
|
||||||
|
if user.role in ("admin", "procurement_manager"):
|
||||||
|
return DashboardScope(role="office", project_ids=None, project_id=project_id)
|
||||||
|
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا دفتر تدارکات است")
|
||||||
|
|
||||||
|
if not settings.DEV_LOGIN_ENABLED or not request.headers.get("x-user-role"):
|
||||||
|
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
|
||||||
|
role = (request.headers.get("x-user-role") or "").strip().lower()
|
||||||
|
if role == "procurement_manager" or role == "admin":
|
||||||
|
role = "office"
|
||||||
|
elif role == "pm":
|
||||||
|
role = "pm"
|
||||||
|
else:
|
||||||
|
raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role")
|
||||||
|
if role == "pm":
|
||||||
|
raw = request.headers.get("x-project-id")
|
||||||
|
if not raw:
|
||||||
|
raise ValidationError("هدر X-Project-Id (پروژه مسئول) برای نقش PM الزامی است", field="X-Project-Id")
|
||||||
|
try:
|
||||||
|
if project_id is None:
|
||||||
|
project_id = uuid.UUID(str(raw))
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
|
||||||
|
return DashboardScope(role="pm", project_ids=[project_id], project_id=project_id)
|
||||||
|
return DashboardScope(role="office", project_ids=None, project_id=project_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _what_filters(
|
||||||
|
material_id: str | None,
|
||||||
|
brand_id: uuid.UUID | None,
|
||||||
|
specs_hash: str | None,
|
||||||
|
spec_id: str | None = None,
|
||||||
|
material_family: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""دامنهی «چه چیزی» را به پارامترهای سرویس تبدیل میکند.
|
||||||
|
|
||||||
|
• شناسهی ماده اگر UUID معتبر باشد → دامنهی همان ماده (همهی مشخصات آن).
|
||||||
|
• اگر مقدار ارسالی UUID نباشد (یعنی شناسهی مشخصه فرستاده شده) یا spec_id
|
||||||
|
صریح بیاید → دامنه به همان مشخصه محدود میشود (specs_hash).
|
||||||
|
• material_family → همهی ردیفهای کاتالوگِ همان خانواده (سرویس حل میکند).
|
||||||
|
"""
|
||||||
|
resolved_material: uuid.UUID | None = None
|
||||||
|
resolved_specs = specs_hash or spec_id
|
||||||
|
if material_id:
|
||||||
|
try:
|
||||||
|
resolved_material = uuid.UUID(str(material_id))
|
||||||
|
except (ValueError, AttributeError, TypeError):
|
||||||
|
# شناسهی مشخصه (نه UUID ماده) — بهعنوان دامنهی مشخصه استفاده میشود
|
||||||
|
resolved_specs = resolved_specs or str(material_id)
|
||||||
|
return {
|
||||||
|
"material_id": resolved_material,
|
||||||
|
"brand_id": brand_id,
|
||||||
|
"specs_hash": resolved_specs,
|
||||||
|
"material_family": (material_family or "").strip() or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/filters")
|
||||||
|
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Options for the global filter bar (Material→Brand→Specs)."""
|
||||||
|
return await dashboard_service.filter_options(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inventory/drilldown")
|
||||||
|
async def inventory_drilldown(
|
||||||
|
scope: DashboardScope = Depends(dashboard_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
material_id: str | None = Query(default=None, description="UUID ماده یا شناسهی مشخصه"),
|
||||||
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
|
specs_hash: str | None = Query(default=None),
|
||||||
|
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||||
|
material_family: str | None = Query(default=None, description="خانوادهی متریال (نام پایه)"),
|
||||||
|
):
|
||||||
|
"""Drill-down matrix: کل سازمان → پروژه → انبار (per-unit aggregates)."""
|
||||||
|
return await dashboard_service.inventory_drilldown(
|
||||||
|
db,
|
||||||
|
project_ids=scope.project_ids,
|
||||||
|
project_id=scope.project_id,
|
||||||
|
**_what_filters(material_id, brand_id, specs_hash, spec_id, material_family),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inventory/dead-stock")
|
||||||
|
async def dead_stock(
|
||||||
|
scope: DashboardScope = Depends(dashboard_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
idle_days: int = Query(default=0, ge=0, le=365, description="آستانه رکود (روز) — ۰ یعنی همه"),
|
||||||
|
limit: int = Query(default=200, ge=1, le=1000),
|
||||||
|
material_id: str | None = Query(default=None, description="UUID ماده یا شناسهی مشخصه"),
|
||||||
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
|
specs_hash: str | None = Query(default=None),
|
||||||
|
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||||
|
material_family: str | None = Query(default=None, description="خانوادهی متریال (نام پایه)"),
|
||||||
|
):
|
||||||
|
"""Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازهی رکود."""
|
||||||
|
return await dashboard_service.dead_stock(
|
||||||
|
db,
|
||||||
|
project_ids=scope.project_ids,
|
||||||
|
project_id=scope.project_id,
|
||||||
|
idle_days=idle_days,
|
||||||
|
limit=limit,
|
||||||
|
**_what_filters(material_id, brand_id, specs_hash, spec_id, material_family),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/inventory/flow")
|
||||||
|
async def inventory_flow(
|
||||||
|
scope: DashboardScope = Depends(dashboard_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیشفرض ۳۰ روز اخیر)"),
|
||||||
|
date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"),
|
||||||
|
warehouse_id: uuid.UUID | None = Query(default=None, description="اختیاری — بدون آن همهی انبارها"),
|
||||||
|
material_id: str | None = Query(default=None, description="UUID ماده یا شناسهی مشخصه"),
|
||||||
|
brand_id: uuid.UUID | None = Query(default=None),
|
||||||
|
specs_hash: str | None = Query(default=None),
|
||||||
|
spec_id: str | None = Query(default=None, description="دامنهی یک مشخصهی خاص"),
|
||||||
|
material_family: str | None = Query(default=None, description="خانوادهی متریال (نام پایه)"),
|
||||||
|
):
|
||||||
|
"""Daily flow series: ورود / خروج (با لایهی ضایعات) / برگشت / مانده."""
|
||||||
|
today = datetime.now(dashboard_service.TEHRAN).date()
|
||||||
|
date_from = date_from or (today - timedelta(days=29))
|
||||||
|
date_to = date_to or (today + timedelta(days=1))
|
||||||
|
if date_to <= date_from:
|
||||||
|
raise ValidationError("بازهی تاریخ نامعتبر است — «تا» باید بعد از «از» باشد", field="date_to")
|
||||||
|
if (date_to - date_from).days > 180:
|
||||||
|
raise ValidationError("بازهی تاریخ حداکثر ۱۸۰ روز است", field="date_to")
|
||||||
|
return await dashboard_service.daily_flow(
|
||||||
|
db,
|
||||||
|
project_ids=scope.project_ids,
|
||||||
|
project_id=scope.project_id,
|
||||||
|
date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
||||||
|
date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
|
||||||
|
warehouse_id=warehouse_id,
|
||||||
|
**_what_filters(material_id, brand_id, specs_hash, spec_id, material_family),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/daily-control")
|
||||||
|
async def daily_control(
|
||||||
|
scope: DashboardScope = Depends(dashboard_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
date: date_cls | None = Query(default=None, description="روز موردنظر (ISO، پیشفرض امروز)"),
|
||||||
|
warehouse_id: uuid.UUID | None = Query(default=None),
|
||||||
|
):
|
||||||
|
"""Daily control: today's outbound/return tickets + exact performer names."""
|
||||||
|
day = date or datetime.now(dashboard_service.TEHRAN).date()
|
||||||
|
return await dashboard_service.daily_control(
|
||||||
|
db,
|
||||||
|
project_ids=scope.project_ids,
|
||||||
|
project_id=scope.project_id,
|
||||||
|
day=day,
|
||||||
|
warehouse_id=warehouse_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/procurement/workload")
|
||||||
|
async def procurement_workload(
|
||||||
|
scope: DashboardScope = Depends(dashboard_scope),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Project density: توزیع بار تدارکات (پیشنهادهای باز + خطوط خرید جاری) روی پروژهها."""
|
||||||
|
return await dashboard_service.procurement_workload(
|
||||||
|
db,
|
||||||
|
project_ids=scope.project_ids,
|
||||||
|
project_id=scope.project_id,
|
||||||
|
)
|
||||||
@@ -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,980 @@
|
|||||||
|
"""Aggregation queries behind the MVP dashboard (warehouse + procurement).
|
||||||
|
|
||||||
|
Read-only service. Every function receives the resolved scope (PM → the list
|
||||||
|
of projects they manage; office roles → project_ids=None = whole
|
||||||
|
organization) plus an optional explicit project filter, and the optional
|
||||||
|
«What» filters (material_id / brand_id / specs_hash).
|
||||||
|
|
||||||
|
Quantities are NEVER mixed across units: inventory rows live in the
|
||||||
|
material's canonical unit, so stock aggregates carry their unit code and the
|
||||||
|
UI picks one unit at a time. Numeric results leave the service chart-ready
|
||||||
|
(floats); identifiers and labels are plain strings.
|
||||||
|
|
||||||
|
Response shapes mirror frontend/src/lib/types.ts (v2.8 dashboard DTOs)
|
||||||
|
exactly — this module is the server side of that contract.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import date as date_cls
|
||||||
|
from datetime import datetime, time, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import case, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import aliased
|
||||||
|
|
||||||
|
from app.models.catalog import Brand, BrandMaterial, Material, Unit
|
||||||
|
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||||
|
from app.models.procurement import (
|
||||||
|
ItemDecision,
|
||||||
|
ItemReceipt,
|
||||||
|
LineItem,
|
||||||
|
Project,
|
||||||
|
Quote,
|
||||||
|
QuoteProjects,
|
||||||
|
)
|
||||||
|
from app.services.material_naming import (
|
||||||
|
material_entry_labels,
|
||||||
|
material_family_key,
|
||||||
|
spec_label_from_specs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# برچسب مشخصهی خالی (ردیفهای دارای specs_hash پیشفرض)
|
||||||
|
DEFAULT_SPEC_LABEL = "بدون مشخصه (پیشفرض)"
|
||||||
|
|
||||||
|
# Iran has been on a fixed UTC+03:30 (no DST) since 2022 — «امروز» in the
|
||||||
|
# daily widgets means today at this offset.
|
||||||
|
TEHRAN = timezone(timedelta(hours=3, minutes=30))
|
||||||
|
|
||||||
|
# Flow buckets: outbound = site consumption + shrinkage; waste is reported
|
||||||
|
# separately so the UI can stack it as a distinct red segment inside the
|
||||||
|
# outbound bar. Transfers are excluded from the buckets (they are internal
|
||||||
|
# moves) but DO count toward the running balance.
|
||||||
|
OUTBOUND_TYPES = ("WITHDRAWAL", "DAMAGE", "WASTE", "LOSS")
|
||||||
|
WASTE_TYPES = ("DAMAGE", "WASTE", "LOSS")
|
||||||
|
|
||||||
|
# دستههای عملیاتی «کنترل روزانه» — تنها تراکنشهایی که در فید امروز دیده میشوند
|
||||||
|
OPS_CATEGORIES = ("outbound", "returns", "waste", "purchases")
|
||||||
|
OPS_TYPES = ("WITHDRAWAL", "RETURN", "DAMAGE", "WASTE", "LOSS", "PURCHASE")
|
||||||
|
|
||||||
|
|
||||||
|
def _ops_category(tx_type: str) -> str:
|
||||||
|
"""نوع تراکنش دفتر → دستهی عملیاتی (خروج/برگشت/ضایعات/پذیرش)."""
|
||||||
|
if tx_type == "WITHDRAWAL":
|
||||||
|
return "outbound"
|
||||||
|
if tx_type == "RETURN":
|
||||||
|
return "returns"
|
||||||
|
if tx_type == "PURCHASE":
|
||||||
|
return "purchases"
|
||||||
|
return "waste" # DAMAGE / WASTE / LOSS
|
||||||
|
|
||||||
|
|
||||||
|
def _stock_filters(stmt, *, material_id=None, brand_id=None, specs_hash=None):
|
||||||
|
"""«What» filters for a statement that already joins inventory→material."""
|
||||||
|
if material_id is not None:
|
||||||
|
stmt = stmt.where(InventoryItem.material_id == material_id)
|
||||||
|
if brand_id is not None:
|
||||||
|
stmt = stmt.where(InventoryItem.brand_id == brand_id)
|
||||||
|
if specs_hash:
|
||||||
|
stmt = stmt.where(InventoryItem.specs_hash == specs_hash)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_filters(stmt, *, project_ids, project_id):
|
||||||
|
"""warehouse.project_id restriction for the resolved dashboard scope."""
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
|
elif project_ids is not None:
|
||||||
|
stmt = stmt.where(Warehouse.project_id.in_(project_ids))
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def _qty(value) -> float:
|
||||||
|
return round(float(value), 4) if value is not None else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
async def _family_material_ids(db: AsyncSession, family: str) -> list[uuid.UUID]:
|
||||||
|
"""شناسهی همهی ردیفهای کاتالوگِ یک خانواده.
|
||||||
|
|
||||||
|
خانواده از نام پایهی ماده مشتق میشود (material_naming)؛ بنابراین انتخاب
|
||||||
|
«لوله پلیاتیلن» همهی سایزهایش را با هم فیلتر میکند — بدون هاردکد نام.
|
||||||
|
"""
|
||||||
|
name = (family or "").strip()
|
||||||
|
if not name:
|
||||||
|
return []
|
||||||
|
rows = (
|
||||||
|
await db.execute(select(Material.id, Material.name).where(Material.status == "active"))
|
||||||
|
).all()
|
||||||
|
return [material_id for material_id, material_name in rows if material_family_key(material_name) == name]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# «What» filter cascade — Category → Material → Brand → Specs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def filter_options(db: AsyncSession) -> dict:
|
||||||
|
"""Options for the global filter bar (Material → Brand → Specs).
|
||||||
|
|
||||||
|
Materials come from the catalog (active only). Brand options per material
|
||||||
|
are the UNION of the catalog links (brand_materials — every brand defined
|
||||||
|
for that material) and the distinct brands actually present in stock, so
|
||||||
|
the cascade is complete even for materials whose current stock rows carry
|
||||||
|
no brand yet. Spec options come from stock rows (inventory.specs_hash),
|
||||||
|
since specs are a stock-level dimension.
|
||||||
|
"""
|
||||||
|
materials = (
|
||||||
|
await db.execute(select(Material).where(Material.status == "active").order_by(Material.name))
|
||||||
|
).scalars().all()
|
||||||
|
units = {u.id: u for u in (await db.execute(select(Unit))).scalars()}
|
||||||
|
|
||||||
|
inv_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
InventoryItem.material_id,
|
||||||
|
InventoryItem.brand_id,
|
||||||
|
InventoryItem.specs_hash,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Brand.name,
|
||||||
|
)
|
||||||
|
.select_from(InventoryItem)
|
||||||
|
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
# برندهای کاتالوگی هر متریال (brand_materials) — مستقل از موجودی فعلی
|
||||||
|
catalog_brand_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(BrandMaterial.material_id, Brand.id, Brand.name)
|
||||||
|
.select_from(BrandMaterial)
|
||||||
|
.join(Brand, Brand.id == BrandMaterial.brand_id)
|
||||||
|
.where(BrandMaterial.is_active.is_(True))
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
brands_by_material: dict[uuid.UUID, dict] = {}
|
||||||
|
specs_by_material: dict[uuid.UUID, dict[str, dict]] = {}
|
||||||
|
for material_id, brand_id, brand_name in catalog_brand_rows:
|
||||||
|
brands_by_material.setdefault(material_id, {})[brand_id] = brand_name
|
||||||
|
for material_id, brand_id, specs_hash, specs, brand_name in inv_rows:
|
||||||
|
if brand_id is not None:
|
||||||
|
brands_by_material.setdefault(material_id, {}).setdefault(brand_id, brand_name)
|
||||||
|
if specs_hash:
|
||||||
|
specs_by_material.setdefault(material_id, {})[specs_hash] = specs or {}
|
||||||
|
|
||||||
|
def material_entry(m: Material) -> dict:
|
||||||
|
unit = units.get(m.canonical_unit_id)
|
||||||
|
brands = brands_by_material.get(m.id, {})
|
||||||
|
spec_map = specs_by_material.get(m.id, {})
|
||||||
|
labels = material_entry_labels(m.name, None)
|
||||||
|
return {
|
||||||
|
"id": str(m.id),
|
||||||
|
"name": m.name,
|
||||||
|
# خانواده vs واریانت — تفکیک عمومی از روی نام کاتالوگ
|
||||||
|
"family_id": labels["family_id"],
|
||||||
|
"family_name": labels["family_name"],
|
||||||
|
"variant_label": labels["variant_label"],
|
||||||
|
"unit": {"id": unit.id, "code": unit.code, "name_fa": unit.name_fa} if unit else None,
|
||||||
|
"brands": [
|
||||||
|
{"id": str(bid), "name": bname}
|
||||||
|
for bid, bname in sorted(brands.items(), key=lambda kv: kv[1] or "")
|
||||||
|
],
|
||||||
|
"specs": [
|
||||||
|
{
|
||||||
|
"hash": h,
|
||||||
|
"specs": sp,
|
||||||
|
"label": spec_label_from_specs(sp) or DEFAULT_SPEC_LABEL,
|
||||||
|
}
|
||||||
|
for h, sp in sorted(spec_map.items())
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = [material_entry(m) for m in materials]
|
||||||
|
|
||||||
|
# خانوادهها: گروهبندی ردیفهای کاتالوگ بر اساس نام پایه (بدون هاردکد نام)
|
||||||
|
family_map: dict[str, dict] = {}
|
||||||
|
for entry in entries:
|
||||||
|
family = family_map.setdefault(
|
||||||
|
entry["family_id"],
|
||||||
|
{
|
||||||
|
"id": entry["family_id"],
|
||||||
|
"name": entry["family_name"],
|
||||||
|
"unit": entry["unit"],
|
||||||
|
"brands": {},
|
||||||
|
"specs": {},
|
||||||
|
"materials": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if family["unit"] is None:
|
||||||
|
family["unit"] = entry["unit"]
|
||||||
|
family["materials"].append(
|
||||||
|
{
|
||||||
|
"id": entry["id"],
|
||||||
|
"name": entry["name"],
|
||||||
|
"variant_label": entry["variant_label"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for brand in entry["brands"]:
|
||||||
|
family["brands"][brand["id"]] = brand["name"]
|
||||||
|
for spec in entry["specs"]:
|
||||||
|
family["specs"][spec["hash"]] = spec["label"]
|
||||||
|
|
||||||
|
families = [
|
||||||
|
{
|
||||||
|
"id": family["id"],
|
||||||
|
"name": family["name"],
|
||||||
|
"unit": family["unit"],
|
||||||
|
"brands": [
|
||||||
|
{"id": bid, "name": bname}
|
||||||
|
for bid, bname in sorted(family["brands"].items(), key=lambda kv: kv[1] or "")
|
||||||
|
],
|
||||||
|
"specs": [
|
||||||
|
{"hash": spec_hash, "label": label}
|
||||||
|
for spec_hash, label in sorted(family["specs"].items(), key=lambda kv: kv[1] or "")
|
||||||
|
],
|
||||||
|
"materials": family["materials"],
|
||||||
|
}
|
||||||
|
for family in sorted(family_map.values(), key=lambda item: item["name"])
|
||||||
|
]
|
||||||
|
|
||||||
|
return {"data": {"materials": entries, "families": families}}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Widget 1 — Drill-down matrix (organization → project → warehouse)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def inventory_drilldown(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
project_ids,
|
||||||
|
project_id,
|
||||||
|
material_id=None,
|
||||||
|
brand_id=None,
|
||||||
|
specs_hash=None,
|
||||||
|
material_family=None,
|
||||||
|
) -> dict:
|
||||||
|
"""Stock quantity as a three-level tree: Material -> Project -> Warehouse.
|
||||||
|
|
||||||
|
The x-axis mirrors the *catalog*, not the ledger: the statement starts from
|
||||||
|
``materials`` and LEFT OUTER JOINs the per-(brand, specs, project,
|
||||||
|
warehouse, unit) stock aggregation. Active catalog materials without any
|
||||||
|
ledger rows therefore stay on the chart with ``COALESCE``d zero quantities
|
||||||
|
(labelled with their canonical unit) instead of disappearing in an inner
|
||||||
|
join.
|
||||||
|
"""
|
||||||
|
# تجمیع دفتر انبار بهازای (ماده، برند، مشخصات، پروژه، انبار، واحد) — سمت
|
||||||
|
# راست LEFT JOIN؛ محدودیت دامنه (پروژههای مجاز) همینجا اعمال میشود تا
|
||||||
|
# مواد بدون موجودیِ داخل دامنه هم از دست نروند.
|
||||||
|
stock = (
|
||||||
|
select(
|
||||||
|
InventoryItem.material_id.label("material_id"),
|
||||||
|
InventoryItem.brand_id.label("brand_id"),
|
||||||
|
InventoryItem.specs_hash.label("specs_hash"),
|
||||||
|
InventoryItem.specs.label("specs"),
|
||||||
|
Project.id.label("pid"),
|
||||||
|
Project.name.label("pname"),
|
||||||
|
Warehouse.id.label("wid"),
|
||||||
|
Warehouse.name.label("wname"),
|
||||||
|
Unit.code.label("ucode"),
|
||||||
|
Unit.name_fa.label("uname_fa"),
|
||||||
|
func.sum(InventoryItem.quantity).label("qty"),
|
||||||
|
func.count().label("rows"),
|
||||||
|
)
|
||||||
|
.select_from(InventoryItem)
|
||||||
|
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||||
|
.join(Project, Project.id == Warehouse.project_id)
|
||||||
|
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||||
|
.group_by(
|
||||||
|
InventoryItem.material_id,
|
||||||
|
InventoryItem.brand_id,
|
||||||
|
InventoryItem.specs_hash,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Project.id,
|
||||||
|
Project.name,
|
||||||
|
Warehouse.id,
|
||||||
|
Warehouse.name,
|
||||||
|
Unit.code,
|
||||||
|
Unit.name_fa,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stock = _scope_filters(stock, project_ids=project_ids, project_id=project_id)
|
||||||
|
stock_sq = stock.subquery()
|
||||||
|
|
||||||
|
# واحد کاتالوگی ماده — برچسب واحدِ ردیفهای صفر («۰ عدد»)
|
||||||
|
canon_unit = aliased(Unit)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
Material.id.label("mid"),
|
||||||
|
Material.name.label("mname"),
|
||||||
|
Brand.name.label("bname"),
|
||||||
|
stock_sq.c.specs,
|
||||||
|
stock_sq.c.specs_hash,
|
||||||
|
stock_sq.c.pid,
|
||||||
|
stock_sq.c.pname,
|
||||||
|
stock_sq.c.wid,
|
||||||
|
stock_sq.c.wname,
|
||||||
|
stock_sq.c.ucode,
|
||||||
|
stock_sq.c.uname_fa,
|
||||||
|
func.coalesce(stock_sq.c.qty, 0).label("qty"),
|
||||||
|
func.coalesce(stock_sq.c.rows, 0).label("rows"),
|
||||||
|
canon_unit.code.label("canon_ucode"),
|
||||||
|
canon_unit.name_fa.label("canon_uname_fa"),
|
||||||
|
)
|
||||||
|
.select_from(Material)
|
||||||
|
.outerjoin(stock_sq, stock_sq.c.material_id == Material.id)
|
||||||
|
.outerjoin(Brand, Brand.id == stock_sq.c.brand_id)
|
||||||
|
.outerjoin(canon_unit, canon_unit.id == Material.canonical_unit_id)
|
||||||
|
.where(Material.status == "active")
|
||||||
|
)
|
||||||
|
# فیلترهای بُعد کاتالوگ، عضویت روی محور را محدود میکنند
|
||||||
|
if material_id is not None:
|
||||||
|
stmt = stmt.where(Material.id == material_id)
|
||||||
|
if material_family:
|
||||||
|
stmt = stmt.where(Material.id.in_(await _family_material_ids(db, material_family)))
|
||||||
|
# برند/مشخصات، ابعاد دفتر انبارند: با فعال بودنشان فقط مواد دارای همان
|
||||||
|
# موجودی روی محور میمانند (رفتار قبلی دستنخورده)
|
||||||
|
if brand_id is not None:
|
||||||
|
stmt = stmt.where(stock_sq.c.brand_id == brand_id)
|
||||||
|
if specs_hash:
|
||||||
|
stmt = stmt.where(stock_sq.c.specs_hash == specs_hash)
|
||||||
|
|
||||||
|
rows = (await db.execute(stmt)).all()
|
||||||
|
|
||||||
|
units_legend: dict[str, str] = {}
|
||||||
|
total_by_unit: dict[str, float] = {}
|
||||||
|
total_rows = 0
|
||||||
|
|
||||||
|
materials_tree: dict[str, dict] = {}
|
||||||
|
|
||||||
|
for mid, mname, bname, specs, specs_hash, pid, pname, wid, wname, ucode, uname_fa, qty, row_count, canon_ucode, canon_uname_fa in rows:
|
||||||
|
q = _qty(qty)
|
||||||
|
|
||||||
|
# افسانهی واحدها و جمع کل؛ مواد بدون ردیف موجودی با واحد کاتالوگی و صفر
|
||||||
|
if ucode is not None:
|
||||||
|
units_legend[ucode] = uname_fa
|
||||||
|
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
|
||||||
|
elif canon_ucode is not None:
|
||||||
|
units_legend.setdefault(canon_ucode, canon_uname_fa)
|
||||||
|
total_by_unit.setdefault(canon_ucode, 0.0)
|
||||||
|
total_rows += row_count
|
||||||
|
|
||||||
|
# Build full material name
|
||||||
|
parts = [mname]
|
||||||
|
if bname:
|
||||||
|
parts.append(bname)
|
||||||
|
if specs:
|
||||||
|
parts.extend(str(v) for v in specs.values() if v)
|
||||||
|
full_name = " ".join(parts)
|
||||||
|
# Use full_name + unit as unique key for material level, or just full_name
|
||||||
|
mat_key = f"{mid}_{full_name}"
|
||||||
|
|
||||||
|
# برچسبهای محور X: خانواده (نام پایه) و مشخصه/واریانت — مشتق از نام کاتالوگ
|
||||||
|
labels = material_entry_labels(mname, specs)
|
||||||
|
mat = materials_tree.setdefault(
|
||||||
|
mat_key,
|
||||||
|
{
|
||||||
|
"id": mat_key,
|
||||||
|
"name": full_name,
|
||||||
|
"material_id": str(mid),
|
||||||
|
"specs_hash": specs_hash or "",
|
||||||
|
"family_id": labels["family_id"],
|
||||||
|
"family_name": labels["family_name"],
|
||||||
|
"variant_label": labels["variant_label"],
|
||||||
|
"spec_label": labels["spec_label"],
|
||||||
|
"spec_detail": labels["spec_detail"],
|
||||||
|
"brand_name": bname,
|
||||||
|
"qty_by_unit": {},
|
||||||
|
"rows": 0,
|
||||||
|
"_projects": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if ucode is not None:
|
||||||
|
mat["qty_by_unit"][ucode] = round(mat["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
||||||
|
elif canon_ucode is not None:
|
||||||
|
mat["qty_by_unit"].setdefault(canon_ucode, 0.0)
|
||||||
|
mat["rows"] += row_count
|
||||||
|
|
||||||
|
# مواد بدون هیچ ردیف موجودی: فقط روی محور میمانند (بدون پروژه/انبار)
|
||||||
|
if pid is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
proj = mat["_projects"].setdefault(
|
||||||
|
pid,
|
||||||
|
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}},
|
||||||
|
)
|
||||||
|
proj["qty_by_unit"][ucode] = round(proj["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
||||||
|
proj["rows"] += row_count
|
||||||
|
|
||||||
|
wh = proj["_warehouses"].setdefault(wid, {"id": str(wid), "name": wname, "qty_by_unit": {}, "rows": 0})
|
||||||
|
wh["qty_by_unit"][ucode] = round(wh["qty_by_unit"].get(ucode, 0.0) + q, 4)
|
||||||
|
wh["rows"] += row_count
|
||||||
|
|
||||||
|
materials = []
|
||||||
|
for mat in materials_tree.values():
|
||||||
|
projects_list = []
|
||||||
|
for proj in mat.pop("_projects").values():
|
||||||
|
warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"])
|
||||||
|
projects_list.append({**proj, "warehouses": warehouses})
|
||||||
|
projects_list.sort(key=lambda p: p["name"])
|
||||||
|
materials.append({**mat, "projects": projects_list})
|
||||||
|
|
||||||
|
materials.sort(key=lambda m: m["name"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"units": units_legend,
|
||||||
|
"total": {"qty_by_unit": total_by_unit, "rows": total_rows},
|
||||||
|
"materials": materials,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Widget 2 — Dead stock (dormant capital)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def dead_stock(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
project_ids,
|
||||||
|
project_id,
|
||||||
|
idle_days: int = 60,
|
||||||
|
material_id=None,
|
||||||
|
brand_id=None,
|
||||||
|
specs_hash=None,
|
||||||
|
material_family=None,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> dict:
|
||||||
|
"""Stock rows with quantity > 0 and NO ledger activity within `idle_days`.
|
||||||
|
|
||||||
|
Last activity = MAX(inventory_transactions.transaction_date) per row —
|
||||||
|
served by idx_inventory_tx_inventory_date — falling back to
|
||||||
|
inventory.updated_at for rows that never posted a transaction.
|
||||||
|
"""
|
||||||
|
last_tx = (
|
||||||
|
select(func.max(InventoryTransaction.transaction_date))
|
||||||
|
.where(InventoryTransaction.inventory_id == InventoryItem.id)
|
||||||
|
.correlate(InventoryItem)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
last_activity = func.coalesce(last_tx, InventoryItem.updated_at)
|
||||||
|
cutoff = datetime.now(TEHRAN) - timedelta(days=idle_days)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
InventoryItem.id,
|
||||||
|
InventoryItem.quantity,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Material.name,
|
||||||
|
Brand.name,
|
||||||
|
Warehouse.name,
|
||||||
|
Project.name,
|
||||||
|
Unit.code,
|
||||||
|
Unit.name_fa,
|
||||||
|
last_activity.label("last_activity"),
|
||||||
|
)
|
||||||
|
.select_from(InventoryItem)
|
||||||
|
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||||
|
.join(Project, Project.id == Warehouse.project_id)
|
||||||
|
.join(Material, Material.id == InventoryItem.material_id)
|
||||||
|
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||||
|
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
|
||||||
|
.where(InventoryItem.quantity > 0, last_activity <= cutoff)
|
||||||
|
.order_by(last_activity.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
stmt = _stock_filters(
|
||||||
|
stmt, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
|
||||||
|
)
|
||||||
|
if material_family:
|
||||||
|
stmt = stmt.where(
|
||||||
|
InventoryItem.material_id.in_(await _family_material_ids(db, material_family))
|
||||||
|
)
|
||||||
|
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
|
||||||
|
rows = (await db.execute(stmt)).all()
|
||||||
|
|
||||||
|
now = datetime.now(TEHRAN)
|
||||||
|
items = []
|
||||||
|
for inv_id, quantity, specs, mat_name, brand_name, wh_name, proj_name, ucode, uname_fa, last in rows:
|
||||||
|
items.append({
|
||||||
|
"inventory_id": str(inv_id),
|
||||||
|
"material": mat_name,
|
||||||
|
"brand": brand_name,
|
||||||
|
"specs": specs or {},
|
||||||
|
"warehouse": wh_name,
|
||||||
|
"project": proj_name,
|
||||||
|
"quantity": _qty(quantity),
|
||||||
|
"unit": {"code": ucode, "name_fa": uname_fa},
|
||||||
|
"last_activity": last.isoformat() if last else None,
|
||||||
|
"idle_days": (now - last).days if last else None,
|
||||||
|
})
|
||||||
|
return {"data": {"idle_days_threshold": idle_days, "items": items}}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Widget 3 — Daily time-series flow (inbound / outbound / returns / balance)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def daily_flow(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
project_ids,
|
||||||
|
project_id,
|
||||||
|
date_from: datetime,
|
||||||
|
date_to: datetime,
|
||||||
|
warehouse_id=None,
|
||||||
|
material_id=None,
|
||||||
|
brand_id=None,
|
||||||
|
specs_hash=None,
|
||||||
|
material_family=None,
|
||||||
|
) -> dict:
|
||||||
|
"""Per-day buckets from the signed ledger.
|
||||||
|
|
||||||
|
inbound = PURCHASE · returns = RETURN · outbound = WITHDRAWAL + waste
|
||||||
|
(DAMAGE/WASTE/LOSS, reported separately for the stacked red segment).
|
||||||
|
balance = running sum of EVERY signed movement (transfers/found/stock
|
||||||
|
counts included), seeded with the opening balance before `date_from`.
|
||||||
|
|
||||||
|
Each day also carries a `breakdown` list — the ledger rows behind that
|
||||||
|
day's aggregates, grouped by (category, transaction type, specs, brand,
|
||||||
|
warehouse) — so the chart can subdivide categories into tonal shades and
|
||||||
|
the tooltip can itemize them. When a single material is selected, the
|
||||||
|
payload also carries that material's canonical `unit`.
|
||||||
|
"""
|
||||||
|
def _with_joins(sel, *, include_brand: bool = False):
|
||||||
|
# ترتیب joinها مهم است: برند فقط بعد از join شدن inventory قابل ارجاع است
|
||||||
|
stmt = (
|
||||||
|
sel.select_from(InventoryTransaction)
|
||||||
|
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||||
|
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
|
||||||
|
.join(Material, Material.id == InventoryItem.material_id)
|
||||||
|
)
|
||||||
|
if include_brand:
|
||||||
|
stmt = stmt.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
scope_conditions = []
|
||||||
|
if project_id is not None:
|
||||||
|
scope_conditions.append(Warehouse.project_id == project_id)
|
||||||
|
elif project_ids is not None:
|
||||||
|
scope_conditions.append(Warehouse.project_id.in_(project_ids))
|
||||||
|
if warehouse_id is not None:
|
||||||
|
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
|
||||||
|
what_conditions = []
|
||||||
|
family_ids: list[uuid.UUID] = []
|
||||||
|
if material_family:
|
||||||
|
family_ids = await _family_material_ids(db, material_family)
|
||||||
|
what_conditions.append(InventoryItem.material_id.in_(family_ids))
|
||||||
|
if material_id is not None:
|
||||||
|
what_conditions.append(InventoryItem.material_id == material_id)
|
||||||
|
if brand_id is not None:
|
||||||
|
what_conditions.append(InventoryItem.brand_id == brand_id)
|
||||||
|
if specs_hash:
|
||||||
|
what_conditions.append(InventoryItem.specs_hash == specs_hash)
|
||||||
|
|
||||||
|
# «روز» بر مبنای تقویم تهران است؛ بدون این تبدیل، تراکنشهای بامداد
|
||||||
|
# (۰۰:۰۰ تا ۰۳:۳۰ تهران) به روز قبلِ UTC میافتادند.
|
||||||
|
day_expr = func.date_trunc(
|
||||||
|
"day", func.timezone("Asia/Tehran", InventoryTransaction.transaction_date)
|
||||||
|
).label("day")
|
||||||
|
# ریزتراکنشها بهازای (نوع، مشخصات، برند، انبار) گروه میشوند تا هم تجمیع
|
||||||
|
# روزانه و هم شکست چندبعدی نمودار از یک کوئری واحد بیایند (بدون تکرار رکورد).
|
||||||
|
detail_rows = (
|
||||||
|
await db.execute(
|
||||||
|
_with_joins(
|
||||||
|
select(
|
||||||
|
day_expr,
|
||||||
|
InventoryTransaction.transaction_type,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Brand.name,
|
||||||
|
Warehouse.name,
|
||||||
|
func.sum(InventoryTransaction.quantity),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
InventoryTransaction.transaction_date >= date_from,
|
||||||
|
InventoryTransaction.transaction_date < date_to,
|
||||||
|
*scope_conditions,
|
||||||
|
*what_conditions,
|
||||||
|
)
|
||||||
|
.group_by(
|
||||||
|
day_expr,
|
||||||
|
InventoryTransaction.transaction_type,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Brand.name,
|
||||||
|
Warehouse.name,
|
||||||
|
),
|
||||||
|
include_brand=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
opening = _qty(
|
||||||
|
await db.scalar(
|
||||||
|
_with_joins(
|
||||||
|
select(func.coalesce(func.sum(InventoryTransaction.quantity), 0.0)).where(
|
||||||
|
InventoryTransaction.transaction_date < date_from,
|
||||||
|
*scope_conditions,
|
||||||
|
*what_conditions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def category_of(tx_type: str) -> str:
|
||||||
|
if tx_type == "PURCHASE":
|
||||||
|
return "inflow"
|
||||||
|
if tx_type == "RETURN":
|
||||||
|
return "returns"
|
||||||
|
if tx_type == "WITHDRAWAL":
|
||||||
|
return "siteOut"
|
||||||
|
if tx_type in WASTE_TYPES:
|
||||||
|
return "waste"
|
||||||
|
# انتقالها/شمارش/یافتشده — خارج از چهار سبد نمایشی، علامتدار
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
signed_by_day: dict[date_cls, dict[str, float]] = {}
|
||||||
|
breakdown_by_day: dict[date_cls, list[dict]] = {}
|
||||||
|
for day, tx_type, specs, brand_name, warehouse_name, signed in detail_rows:
|
||||||
|
day_key = day.date() if isinstance(day, datetime) else day
|
||||||
|
tx_type = str(tx_type)
|
||||||
|
qty = _qty(signed)
|
||||||
|
day_types = signed_by_day.setdefault(day_key, {})
|
||||||
|
day_types[tx_type] = round(day_types.get(tx_type, 0.0) + qty, 4)
|
||||||
|
breakdown_by_day.setdefault(day_key, []).append({
|
||||||
|
"category": category_of(tx_type),
|
||||||
|
"transaction_type": tx_type,
|
||||||
|
"specs": specs or None,
|
||||||
|
"brand": brand_name or None,
|
||||||
|
"warehouse": warehouse_name,
|
||||||
|
"quantity": qty,
|
||||||
|
})
|
||||||
|
|
||||||
|
unit = None
|
||||||
|
unit_material_id = material_id or (family_ids[0] if family_ids else None)
|
||||||
|
if unit_material_id is not None:
|
||||||
|
unit_row = (
|
||||||
|
await db.execute(
|
||||||
|
select(Unit.code, Unit.name_fa)
|
||||||
|
.select_from(Material)
|
||||||
|
.join(Unit, Unit.id == Material.canonical_unit_id)
|
||||||
|
.where(Material.id == unit_material_id)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if unit_row is not None:
|
||||||
|
unit = {"code": unit_row[0], "name_fa": unit_row[1]}
|
||||||
|
|
||||||
|
days = []
|
||||||
|
running = opening
|
||||||
|
current = date_from.date()
|
||||||
|
while current < date_to.date():
|
||||||
|
types = signed_by_day.get(current, {})
|
||||||
|
waste = round(-sum(types.get(t, 0.0) for t in WASTE_TYPES), 4) or 0.0
|
||||||
|
outbound = round(waste - types.get("WITHDRAWAL", 0.0), 4) or 0.0
|
||||||
|
running = round(running + sum(types.values()), 4)
|
||||||
|
days.append({
|
||||||
|
"date": current.isoformat(),
|
||||||
|
"inbound": round(types.get("PURCHASE", 0.0), 4) or 0.0,
|
||||||
|
"outbound": outbound,
|
||||||
|
"waste": waste,
|
||||||
|
"returns": round(types.get("RETURN", 0.0), 4) or 0.0,
|
||||||
|
"balance": running,
|
||||||
|
"breakdown": breakdown_by_day.get(current, []),
|
||||||
|
})
|
||||||
|
current += timedelta(days=1)
|
||||||
|
|
||||||
|
return {"data": {"opening_balance": opening, "unit": unit, "days": days}}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Widget 4 — Daily control (today's site operations)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def daily_control(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
project_ids,
|
||||||
|
project_id,
|
||||||
|
day: date_cls,
|
||||||
|
warehouse_id=None,
|
||||||
|
) -> dict:
|
||||||
|
"""One day (Tehran calendar): operational counts per category, the people
|
||||||
|
involved (warehouse clerk + site recipient) and the transaction feed.
|
||||||
|
|
||||||
|
Quantities are never exposed as a unitless total: every category carries a
|
||||||
|
per-unit breakdown (`units`) plus the number of distinct stock items
|
||||||
|
(`items`), so the UI can render «۱۲۲ عدد» for single-unit days and
|
||||||
|
«چندواحدی (۳ قلم کالا)» for mixed-unit days.
|
||||||
|
"""
|
||||||
|
day_start = datetime.combine(day, time.min, tzinfo=TEHRAN)
|
||||||
|
day_end = day_start + timedelta(days=1)
|
||||||
|
|
||||||
|
scope_conditions = []
|
||||||
|
if project_id is not None:
|
||||||
|
scope_conditions.append(Warehouse.project_id == project_id)
|
||||||
|
elif project_ids is not None:
|
||||||
|
scope_conditions.append(Warehouse.project_id.in_(project_ids))
|
||||||
|
if warehouse_id is not None:
|
||||||
|
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
|
||||||
|
|
||||||
|
# ۱) شمارش/کمیت هر دسته، تفکیکشده بر اساس واحد + تعداد قلم کالای متمایز
|
||||||
|
category_unit_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
InventoryTransaction.transaction_type,
|
||||||
|
Unit.code,
|
||||||
|
Unit.name_fa,
|
||||||
|
func.count().label("tickets"),
|
||||||
|
func.count(func.distinct(InventoryItem.id)).label("items"),
|
||||||
|
func.sum(func.abs(InventoryTransaction.quantity)).label("qty"),
|
||||||
|
)
|
||||||
|
.select_from(InventoryTransaction)
|
||||||
|
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||||
|
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
|
||||||
|
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||||
|
.where(
|
||||||
|
InventoryTransaction.transaction_date >= day_start,
|
||||||
|
InventoryTransaction.transaction_date < day_end,
|
||||||
|
InventoryTransaction.transaction_type.in_(OPS_TYPES),
|
||||||
|
*scope_conditions,
|
||||||
|
)
|
||||||
|
.group_by(InventoryTransaction.transaction_type, Unit.code, Unit.name_fa)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
categories: dict[str, dict] = {
|
||||||
|
key: {"tickets": 0, "items": 0, "qty": 0.0, "units": []} for key in OPS_CATEGORIES
|
||||||
|
}
|
||||||
|
for tx_type, unit_code, unit_name_fa, tickets, items, qty in category_unit_rows:
|
||||||
|
bucket = categories[_ops_category(str(tx_type))]
|
||||||
|
bucket["tickets"] += tickets
|
||||||
|
bucket["items"] += items
|
||||||
|
bucket["qty"] = round(bucket["qty"] + _qty(qty), 4)
|
||||||
|
bucket["units"].append({"code": unit_code, "name_fa": unit_name_fa, "qty": _qty(qty)})
|
||||||
|
|
||||||
|
# ۲) اشخاص درگیر: ثبتکنندهی انبار (performed_by) و تحویلگیرنده (delivered_to)
|
||||||
|
async def _people(field, role: str) -> list[dict]:
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
field,
|
||||||
|
InventoryTransaction.transaction_type,
|
||||||
|
func.count(),
|
||||||
|
func.sum(func.abs(InventoryTransaction.quantity)),
|
||||||
|
)
|
||||||
|
.select_from(InventoryTransaction)
|
||||||
|
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||||
|
.where(
|
||||||
|
InventoryTransaction.transaction_date >= day_start,
|
||||||
|
InventoryTransaction.transaction_date < day_end,
|
||||||
|
InventoryTransaction.transaction_type.in_(OPS_TYPES),
|
||||||
|
field.isnot(None),
|
||||||
|
*scope_conditions,
|
||||||
|
)
|
||||||
|
.group_by(field, InventoryTransaction.transaction_type)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
people: dict[str, dict] = {}
|
||||||
|
for name, tx_type, count, qty in rows:
|
||||||
|
clean = (name or "").strip()
|
||||||
|
if not clean:
|
||||||
|
continue
|
||||||
|
person = people.setdefault(
|
||||||
|
clean,
|
||||||
|
{
|
||||||
|
"name": clean,
|
||||||
|
"role": role,
|
||||||
|
"tickets": 0,
|
||||||
|
"qty": 0.0,
|
||||||
|
"categories": {key: 0 for key in OPS_CATEGORIES},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
person["tickets"] += count
|
||||||
|
person["qty"] = round(person["qty"] + _qty(qty), 4)
|
||||||
|
person["categories"][_ops_category(str(tx_type))] += count
|
||||||
|
return list(people.values())
|
||||||
|
|
||||||
|
people = await _people(InventoryTransaction.performed_by, "performer")
|
||||||
|
people += await _people(InventoryTransaction.delivered_to, "recipient")
|
||||||
|
people.sort(key=lambda p: (p["role"] != "performer", -p["tickets"], p["name"]))
|
||||||
|
|
||||||
|
# ۳) ریز عملیات امروز (همراه با انبار، برند، واحد فارسی و تحویلگیرنده)
|
||||||
|
detail_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
InventoryTransaction,
|
||||||
|
Warehouse.name,
|
||||||
|
Material.name,
|
||||||
|
InventoryItem.specs,
|
||||||
|
Unit.code,
|
||||||
|
Unit.name_fa,
|
||||||
|
Brand.name,
|
||||||
|
)
|
||||||
|
.select_from(InventoryTransaction)
|
||||||
|
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
|
||||||
|
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
|
||||||
|
.join(Material, Material.id == InventoryItem.material_id)
|
||||||
|
.join(Unit, Unit.id == InventoryItem.unit_id)
|
||||||
|
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
|
||||||
|
.where(
|
||||||
|
InventoryTransaction.transaction_date >= day_start,
|
||||||
|
InventoryTransaction.transaction_date < day_end,
|
||||||
|
InventoryTransaction.transaction_type.in_(OPS_TYPES),
|
||||||
|
*scope_conditions,
|
||||||
|
)
|
||||||
|
.order_by(InventoryTransaction.transaction_date.desc())
|
||||||
|
.limit(200)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for tx, wh_name, mat_name, specs, unit_code, unit_name_fa, brand_name in detail_rows:
|
||||||
|
items.append({
|
||||||
|
"id": str(tx.id),
|
||||||
|
"transaction_type": str(tx.transaction_type),
|
||||||
|
"quantity": _qty(tx.quantity),
|
||||||
|
"unit_code": unit_code,
|
||||||
|
"unit_name_fa": unit_name_fa,
|
||||||
|
"performed_by": tx.performed_by,
|
||||||
|
"delivered_to": tx.delivered_to,
|
||||||
|
"brand": brand_name,
|
||||||
|
"note": tx.note,
|
||||||
|
"warehouse": wh_name,
|
||||||
|
"material": mat_name,
|
||||||
|
"specs": specs or {},
|
||||||
|
"transaction_date": tx.transaction_date.isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"date": day.isoformat(),
|
||||||
|
"outbound": categories["outbound"],
|
||||||
|
"returns": categories["returns"],
|
||||||
|
"waste": categories["waste"],
|
||||||
|
"purchases": categories["purchases"],
|
||||||
|
# همیشه آرایهاند (هرگز null/حذفشده) تا مصرفکنندهها امن بمانند
|
||||||
|
"people": people,
|
||||||
|
"items": items,
|
||||||
|
# سازگاری عقبرو: باندلهای فرانت که هنوز فقط performers را میخوانند
|
||||||
|
# با API جدید کرش نکنند (تا زمان انتشار باندل تازه)
|
||||||
|
"performers": [
|
||||||
|
{"name": person["name"], "tickets": person["tickets"], "qty": person["qty"]}
|
||||||
|
for person in people
|
||||||
|
if person["role"] == "performer"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Widget 8 — Procurement workload per project (project density)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def procurement_workload(db: AsyncSession, *, project_ids, project_id) -> dict:
|
||||||
|
"""Per project: offered quotes still open + chosen purchase lines (open
|
||||||
|
vs fully received). Chosen lines attribute to item_decisions.project_id
|
||||||
|
(the purchased destination); offered quotes to any linked project."""
|
||||||
|
received_sq = (
|
||||||
|
select(ItemReceipt.item_decision_id.label("did"), func.sum(ItemReceipt.received_qty).label("received_qty"))
|
||||||
|
.group_by(ItemReceipt.item_decision_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
order_conditions = [ItemDecision.status == "chosen"]
|
||||||
|
if project_id is not None:
|
||||||
|
order_conditions.append(ItemDecision.project_id == project_id)
|
||||||
|
elif project_ids is not None:
|
||||||
|
order_conditions.append(ItemDecision.project_id.in_(project_ids))
|
||||||
|
|
||||||
|
order_rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(
|
||||||
|
Project.id,
|
||||||
|
Project.name,
|
||||||
|
func.count().label("chosen_items"),
|
||||||
|
func.sum(LineItem.quantity).label("ordered_qty"),
|
||||||
|
func.coalesce(func.sum(received_sq.c.received_qty), 0).label("received_qty"),
|
||||||
|
func.sum(
|
||||||
|
case(
|
||||||
|
(func.coalesce(received_sq.c.received_qty, 0) < LineItem.quantity, 1),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
).label("open_items"),
|
||||||
|
)
|
||||||
|
.select_from(ItemDecision)
|
||||||
|
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
||||||
|
.join(Quote, Quote.id == LineItem.quote_id)
|
||||||
|
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
||||||
|
.outerjoin(received_sq, received_sq.c.did == ItemDecision.id)
|
||||||
|
.where(*order_conditions)
|
||||||
|
.group_by(Project.id, Project.name)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
offered_by_project: dict[uuid.UUID | None, set] = {}
|
||||||
|
offered_primary = (
|
||||||
|
await db.execute(
|
||||||
|
select(Quote.id, Quote.project_id).where(
|
||||||
|
Quote.status == "offered", Quote.project_id.is_not(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for quote_id, pid in offered_primary:
|
||||||
|
offered_by_project.setdefault(pid, set()).add(quote_id)
|
||||||
|
offered_junction = (
|
||||||
|
await db.execute(
|
||||||
|
select(QuoteProjects.quote_id, QuoteProjects.project_id)
|
||||||
|
.join(Quote, Quote.id == QuoteProjects.quote_id)
|
||||||
|
.where(Quote.status == "offered")
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for quote_id, pid in offered_junction:
|
||||||
|
offered_by_project.setdefault(pid, set()).add(quote_id)
|
||||||
|
|
||||||
|
project_ids_needed = {row[0] for row in order_rows if row[0] is not None}
|
||||||
|
project_ids_needed.update(pid for pid in offered_by_project if pid is not None)
|
||||||
|
names: dict[uuid.UUID, str] = {}
|
||||||
|
if project_ids_needed:
|
||||||
|
names = {
|
||||||
|
p.id: p.name
|
||||||
|
for p in (
|
||||||
|
await db.execute(select(Project).where(Project.id.in_(project_ids_needed)))
|
||||||
|
).scalars()
|
||||||
|
}
|
||||||
|
|
||||||
|
projects = []
|
||||||
|
for pid, _pname, chosen, ordered, received, open_items in order_rows:
|
||||||
|
if pid is None:
|
||||||
|
offered = 0
|
||||||
|
else:
|
||||||
|
offered = len(offered_by_project.pop(pid, set()))
|
||||||
|
if project_id is not None and pid != project_id:
|
||||||
|
offered = 0
|
||||||
|
elif project_ids is not None and pid not in project_ids:
|
||||||
|
offered = 0
|
||||||
|
projects.append({
|
||||||
|
"id": str(pid) if pid else None,
|
||||||
|
"name": names.get(pid, "بدون پروژه") if pid else "بدون پروژه",
|
||||||
|
"offered_quotes": offered,
|
||||||
|
"chosen_items": chosen,
|
||||||
|
"open_orders": open_items,
|
||||||
|
"ordered_qty": _qty(ordered),
|
||||||
|
"received_qty": _qty(received),
|
||||||
|
})
|
||||||
|
# offered-only projects that have no chosen lines yet
|
||||||
|
for pid, quote_ids in offered_by_project.items():
|
||||||
|
if not quote_ids:
|
||||||
|
continue
|
||||||
|
if project_id is not None and pid != project_id:
|
||||||
|
continue
|
||||||
|
if project_ids is not None and (pid is None or pid not in project_ids):
|
||||||
|
continue
|
||||||
|
projects.append({
|
||||||
|
"id": str(pid) if pid else None,
|
||||||
|
"name": names.get(pid, "بدون پروژه") if pid else "بدون پروژه",
|
||||||
|
"offered_quotes": len(quote_ids),
|
||||||
|
"chosen_items": 0,
|
||||||
|
"open_orders": 0,
|
||||||
|
"ordered_qty": 0.0,
|
||||||
|
"received_qty": 0.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
projects.sort(key=lambda p: (-p["open_orders"], -p["offered_quotes"], p["name"]))
|
||||||
|
return {"data": {"projects": projects}}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""نامگذاری متریال — تفکیک «خانواده» از «مشخصه/واریانت» بهصورت عمومی.
|
||||||
|
|
||||||
|
برخی ردیفهای کاتالوگ، سایز/گرید را داخل نام ماده دارند
|
||||||
|
(«بلوک سیمانی ۲۰×۲۰×۴۰»، «لوله پلیاتیلن PE100 سایز ۲۵»). این ماژول با قواعد
|
||||||
|
الگویی (ابعاد NxNxN، کلیدواژههای مشخصه، کدهای گرید لاتین) نام را به
|
||||||
|
«خانواده» و «واریانت» میشکند — بدون هیچ هاردکدِ نام محصول مشخصی.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
_DIGIT_MAP = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
|
||||||
|
|
||||||
|
# واژههایی که شروع بخش مشخصه را نشان میدهند (ابعاد/درجه/تیپ/مدل/…)
|
||||||
|
_ATTRIBUTE_WORDS = {
|
||||||
|
"سایز",
|
||||||
|
"تیپ",
|
||||||
|
"مدل",
|
||||||
|
"درجه",
|
||||||
|
"ضخامت",
|
||||||
|
"قطر",
|
||||||
|
"ابعاد",
|
||||||
|
"طول",
|
||||||
|
"عرض",
|
||||||
|
"ارتفاع",
|
||||||
|
"رنگ",
|
||||||
|
"وزن",
|
||||||
|
"ظرفیت",
|
||||||
|
"شماره",
|
||||||
|
"کلاس",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ابعاد ترکیبی: ۲۰×۲۰×۴۰ / ۲×۲٫۵ / 60x60 / 2*2.5
|
||||||
|
_DIMENSION_RE = re.compile(r"^\d+(?:[×xX*]\d+(?:[.,٫/]\d+)?)+$")
|
||||||
|
# کد گرید: PE100 / PN10 / DN50 / A3 / SN4 / ST37
|
||||||
|
_GRADE_RE = re.compile(r"^(?:PN|PE|DN|SN|SCH|ST|A|B|C)[-]?\d+$")
|
||||||
|
_NUMBER_RE = re.compile(r"^\d+(?:[.,٫/]\d+)?$")
|
||||||
|
_LATIN_WORD_RE = re.compile(r"^[A-Za-z]{2,6}$")
|
||||||
|
|
||||||
|
STANDARD_VARIANT_LABEL = "استاندارد"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_digits(text: str) -> str:
|
||||||
|
return (text or "").translate(_DIGIT_MAP)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_spec_start(tokens: list[str], index: int) -> bool:
|
||||||
|
token = tokens[index]
|
||||||
|
normalized = normalize_digits(token)
|
||||||
|
if normalized in _ATTRIBUTE_WORDS: # «سایز»، «تیپ»، «مدل»، …
|
||||||
|
return True
|
||||||
|
if _DIMENSION_RE.match(normalized): # ۲۰×۲۰×۴۰
|
||||||
|
return True
|
||||||
|
if _GRADE_RE.match(token): # PE100 / PN10 / A3
|
||||||
|
return True
|
||||||
|
# کد لاتین کوتاه که پشتش عدد میآید: «IPE ۱۴»
|
||||||
|
if (
|
||||||
|
_LATIN_WORD_RE.match(token)
|
||||||
|
and index + 1 < len(tokens)
|
||||||
|
and _NUMBER_RE.match(normalize_digits(tokens[index + 1]))
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
# عددِ تنها در انتهای نام (بدون کلیدواژه): «لوله مسی ۱۵»
|
||||||
|
if _NUMBER_RE.match(normalized) and index == len(tokens) - 1 and index >= 1:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def split_material_name(name: str) -> tuple[str, str | None]:
|
||||||
|
"""«لوله پلیاتیلن PE100 سایز ۲۵» → («لوله پلیاتیلن», «PE100 سایز ۲۵»)."""
|
||||||
|
tokens = [token for token in (name or "").split() if token]
|
||||||
|
for index in range(1, len(tokens)):
|
||||||
|
if _is_spec_start(tokens, index):
|
||||||
|
family = " ".join(tokens[:index]).strip()
|
||||||
|
variant = " ".join(tokens[index:]).strip()
|
||||||
|
if family and variant:
|
||||||
|
return family, variant
|
||||||
|
return " ".join(tokens).strip(), None
|
||||||
|
|
||||||
|
|
||||||
|
def material_family(name: str) -> str:
|
||||||
|
"""نام خانواده (بدون واریانت)."""
|
||||||
|
family, _ = split_material_name(name)
|
||||||
|
return " ".join(family.split())
|
||||||
|
|
||||||
|
|
||||||
|
def material_family_key(name: str) -> str:
|
||||||
|
"""شناسهی پایدار خانواده — همان نام نرمالشده (بدون UUID اختصاصی)."""
|
||||||
|
return material_family(name)
|
||||||
|
|
||||||
|
|
||||||
|
def variant_label(name: str) -> str:
|
||||||
|
"""برچسب واریانت؛ در نبود واریانت، «استاندارد»."""
|
||||||
|
_, variant = split_material_name(name)
|
||||||
|
return variant or STANDARD_VARIANT_LABEL
|
||||||
|
|
||||||
|
|
||||||
|
def spec_label_from_specs(specs: dict | None) -> str | None:
|
||||||
|
"""برچسب کوتاه مشخصات از JSON موجودی (فقط مقدارها، نه «کلید: مقدار»)."""
|
||||||
|
if not specs:
|
||||||
|
return None
|
||||||
|
values = [str(value).strip() for value in specs.values() if str(value).strip()]
|
||||||
|
return "، ".join(values) if values else None
|
||||||
|
|
||||||
|
|
||||||
|
def material_entry_labels(material_name: str, specs: dict | None) -> dict:
|
||||||
|
"""برچسبهای نمایشی یک ردیف کاتالوگ: خانواده، واریانت، مشخصه.
|
||||||
|
|
||||||
|
`spec_label` (برچسب محور X) = مقدار مشخصات JSON اگر موجود باشد، وگرنه
|
||||||
|
واریانتِ استخراجشده از نام، وگرنه «استاندارد».
|
||||||
|
"""
|
||||||
|
family = material_family(material_name)
|
||||||
|
variant = variant_label(material_name)
|
||||||
|
spec_from_json = spec_label_from_specs(specs)
|
||||||
|
# برچسب محور X: واریانت (سایز/گرید) ترجیح دارد؛ در نبود آن مقدار مشخصات
|
||||||
|
axis_label = variant if variant != STANDARD_VARIANT_LABEL else (spec_from_json or STANDARD_VARIANT_LABEL)
|
||||||
|
return {
|
||||||
|
"family_id": material_family_key(material_name),
|
||||||
|
"family_name": family,
|
||||||
|
"variant_label": variant,
|
||||||
|
"spec_label": axis_label,
|
||||||
|
"spec_detail": spec_from_json,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -302,7 +302,7 @@ services:
|
|||||||
|
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- "traefik.http.routers.frontend.rule=Host(`${APP_HOST:-localhost}`) || Host(`192.168.100.8`)"
|
- traefik.http.routers.frontend.rule=Host(`${APP_HOST:-localhost}`)
|
||||||
- traefik.http.routers.frontend.entrypoints=web
|
- traefik.http.routers.frontend.entrypoints=web
|
||||||
- traefik.http.services.frontend.loadbalancer.server.port=80
|
- traefik.http.services.frontend.loadbalancer.server.port=80
|
||||||
|
|
||||||
|
|||||||
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";
|
||||||
@@ -16,6 +17,9 @@ export interface ComboboxProps {
|
|||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (item: ComboboxItem) => void;
|
onChange?: (item: ComboboxItem) => void;
|
||||||
items: ComboboxSource; // آرایه از string یا {id, name}
|
items: ComboboxSource; // آرایه از string یا {id, name}
|
||||||
|
// شناسهی آیتمهایی که همیشه در ابتدای فهرست میمانند و با جستجوی کاربر
|
||||||
|
// فیلتر نمیشوند (مثل گزینهی «همهی …» در فیلترهای آبشاری داشبورد)
|
||||||
|
pinnedIds?: string[];
|
||||||
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
||||||
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
|
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
|
||||||
addSuffix?: string; // متن بعد از +
|
addSuffix?: string; // متن بعد از +
|
||||||
@@ -26,6 +30,9 @@ export interface ComboboxProps {
|
|||||||
onAdd?: (val: string) => void;
|
onAdd?: (val: string) => void;
|
||||||
onInputChange?: (val: string) => void;
|
onInputChange?: (val: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
|
// با ارائهی این تابع، دکمهی «×» داخل فیلد ظاهر میشود تا انتخاب فعلی
|
||||||
|
// (و فیلتر وابسته به آن) بهتنهایی پاک شود.
|
||||||
|
onClear?: () => void;
|
||||||
// برای فوکوس برنامهای (مثل بازگشت فوکوس پس از ثبت پیوسته)
|
// برای فوکوس برنامهای (مثل بازگشت فوکوس پس از ثبت پیوسته)
|
||||||
inputRef?: RefObject<HTMLInputElement | null>;
|
inputRef?: RefObject<HTMLInputElement | null>;
|
||||||
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
||||||
@@ -46,6 +53,7 @@ export function Combobox({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
items,
|
items,
|
||||||
|
pinnedIds,
|
||||||
allowAdd = true,
|
allowAdd = true,
|
||||||
addPrefix,
|
addPrefix,
|
||||||
addSuffix = "به عنوان گزینه جدید",
|
addSuffix = "به عنوان گزینه جدید",
|
||||||
@@ -56,12 +64,17 @@ export function Combobox({
|
|||||||
onAdd,
|
onAdd,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
|
onClear,
|
||||||
inputRef,
|
inputRef,
|
||||||
openOnFocus = true,
|
openOnFocus = true,
|
||||||
}: ComboboxProps) {
|
}: ComboboxProps) {
|
||||||
const [text, setText] = useState(value ?? "");
|
const [text, setText] = useState(value ?? "");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [highlight, setHighlight] = useState(-1);
|
const [highlight, setHighlight] = useState(-1);
|
||||||
|
// کلیکِ روی اینپوتِ در حالت فوکوسنشده، اول onFocus را صدا میزند و منو باز
|
||||||
|
// میشود؛ این نشانگر میگوید «همین لحظه با فوکوس باز شد» تا همان کلیک
|
||||||
|
// دوباره منو را نبندد. کلیکهای بعدی = تاگل باز/بسته.
|
||||||
|
const justOpenedByFocus = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setText(value ?? "");
|
setText(value ?? "");
|
||||||
@@ -79,19 +92,37 @@ export function Combobox({
|
|||||||
return all.filter((x) => normalize(x.label).includes(q));
|
return all.filter((x) => normalize(x.label).includes(q));
|
||||||
}, [all, q]);
|
}, [all, q]);
|
||||||
|
|
||||||
const showAdd =
|
// آیتمهای pinشده همیشه در ابتدای فهرست میمانند و با جستجو حذف نمیشوند؛
|
||||||
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q);
|
// ناوبری با کلیدها هم روی همین فهرست نهایی (visible) انجام میشود.
|
||||||
|
const { visible, pinnedCount, hasUnpinned } = useMemo(() => {
|
||||||
|
const pin = pinnedIds ?? [];
|
||||||
|
if (pin.length === 0) return { visible: filtered, pinnedCount: 0, hasUnpinned: false };
|
||||||
|
const pinned = pin
|
||||||
|
.map((id) => all.find((x) => x.id === id))
|
||||||
|
.filter((x): x is ComboboxItem => x !== undefined);
|
||||||
|
const pinnedSet = new Set(pinned.map((x) => x.id));
|
||||||
|
const rest = filtered.filter((x) => !pinnedSet.has(x.id));
|
||||||
|
return { visible: [...pinned, ...rest], pinnedCount: pinned.length, hasUnpinned: rest.length > 0 };
|
||||||
|
}, [all, filtered, pinnedIds]);
|
||||||
|
|
||||||
const listCount = filtered.length + (showAdd ? 1 : 0);
|
const showAdd =
|
||||||
|
allowAdd && q.length > 0 && !visible.some((x) => normalize(x.label) === q);
|
||||||
|
|
||||||
|
const showClear = Boolean(onClear) && !disabled && text.length > 0;
|
||||||
|
|
||||||
|
const listCount = visible.length + (showAdd ? 1 : 0);
|
||||||
|
|
||||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||||
|
|
||||||
const pick = (it: ComboboxItem) => {
|
const pick = (it: ComboboxItem) => {
|
||||||
setText(it.label);
|
// آیتم با شناسهی خالی یعنی «انتخاب نشده» (گزینهی «همهی …»)؛ فیلد به
|
||||||
|
// placeholder برمیگردد تا متن/دکمهی × برای مقدار پاکشده باقی نماند.
|
||||||
|
const nextText = it.id ? it.label : "";
|
||||||
|
setText(nextText);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setHighlight(-1);
|
setHighlight(-1);
|
||||||
onChange?.(it);
|
onChange?.(it);
|
||||||
onInputChange?.(it.label);
|
onInputChange?.(nextText);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addCurrent = () => {
|
const addCurrent = () => {
|
||||||
@@ -116,9 +147,9 @@ export function Combobox({
|
|||||||
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
|
setHighlight((h) => (h - 1 + Math.max(listCount, 1)) % Math.max(listCount, 1));
|
||||||
} else if (e.key === "Enter") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (highlight >= 0 && highlight < filtered.length) {
|
if (highlight >= 0 && highlight < visible.length) {
|
||||||
pick(filtered[highlight]);
|
pick(visible[highlight]);
|
||||||
} else if (highlight === filtered.length && showAdd) {
|
} else if (highlight === visible.length && showAdd) {
|
||||||
addCurrent();
|
addCurrent();
|
||||||
}
|
}
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
@@ -133,6 +164,7 @@ export function Combobox({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
showClear && "pe-8",
|
||||||
inputClassName,
|
inputClassName,
|
||||||
)}
|
)}
|
||||||
value={text}
|
value={text}
|
||||||
@@ -146,24 +178,53 @@ export function Combobox({
|
|||||||
onInputChange?.(e.target.value);
|
onInputChange?.(e.target.value);
|
||||||
}}
|
}}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (openOnFocus) setOpen(true);
|
if (openOnFocus && !open) {
|
||||||
|
setOpen(true);
|
||||||
|
justOpenedByFocus.current = true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
// اولین کلیک بعد از فوکوس = همان بازشدن؛ کلیکهای بعدی تاگل میکنند
|
||||||
|
if (justOpenedByFocus.current) {
|
||||||
|
justOpenedByFocus.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen((o) => !o);
|
||||||
}}
|
}}
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
/>
|
/>
|
||||||
|
{showClear && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="پاک کردن"
|
||||||
|
title="پاک کردن"
|
||||||
|
className="absolute end-2 top-1/2 z-10 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full text-muted transition-colors hover:bg-slate-bg hover:text-ink"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setText("");
|
||||||
|
setHighlight(-1);
|
||||||
|
setOpen(true);
|
||||||
|
onClear?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
|
<div className="absolute inset-x-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-lg border border-line bg-panel py-1 shadow-lg">
|
||||||
{listCount === 0 && (
|
{listCount === 0 && (
|
||||||
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
|
<div className="px-3 py-1.5 text-[12px] text-muted">موردی یافت نشد</div>
|
||||||
)}
|
)}
|
||||||
{filtered.map((it, i) => (
|
{visible.map((it, i) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={it.id + "|" + i}
|
key={it.id + "|" + i}
|
||||||
className={cn(
|
className={cn(
|
||||||
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
|
"block w-full cursor-pointer px-3 py-1.5 text-start text-[13px] hover:bg-slate-bg",
|
||||||
i === highlight && "bg-slate-bg",
|
i === highlight && "bg-slate-bg",
|
||||||
|
pinnedCount > 0 && hasUnpinned && i === pinnedCount - 1 && "border-b border-line",
|
||||||
)}
|
)}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault(); // حفظ فوکوس input
|
e.preventDefault(); // حفظ فوکوس input
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ import { cn } from "../lib/utils";
|
|||||||
import { useClickOutside } from "./useClickOutside";
|
import { useClickOutside } from "./useClickOutside";
|
||||||
|
|
||||||
export interface JalaliDatePickerProps {
|
export interface JalaliDatePickerProps {
|
||||||
value: string | null;
|
value: string | null; // ISO date (یا YYYY-MM-DD با time اختیاری)
|
||||||
onChange: (val: string | null) => void;
|
onChange: (val: string | null) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
withTime?: boolean;
|
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,16 +42,20 @@ function isoToJalali(iso: string | null): [number, number, number] | null {
|
|||||||
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ساخت ISO با زمان محلی (تاریخ شمسی + ساعت:دقیقه)
|
||||||
function buildIso(jy: number, jm: number, jd: number, h: number, m: number): string {
|
function buildIso(jy: number, jm: number, jd: number, h: number, m: number): string {
|
||||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||||
return new Date(gy, gm - 1, gd, h, m).toISOString();
|
return new Date(gy, gm - 1, gd, h, m).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ساخت تاریخ استاندارد YYYY-MM-DD بدون مؤلفه زمان
|
||||||
function buildDateOnly(jy: number, jm: number, jd: number): string {
|
function buildDateOnly(jy: number, jm: number, jd: number): string {
|
||||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||||
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقهی فعلی سیستم پیشفرض است
|
||||||
|
// (نه ۰۰:۰۰) — دکمهی «امروز» و انتخاب روز، همین زمان را نگه میدارند.
|
||||||
function nowTime(): { h: number; m: number } {
|
function nowTime(): { h: number; m: number } {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
return { h: d.getHours(), m: d.getMinutes() };
|
return { h: d.getHours(), m: d.getMinutes() };
|
||||||
@@ -64,6 +68,7 @@ function timeOf(iso: string | null): { h: number; m: number } {
|
|||||||
return { h: d.getHours(), m: d.getMinutes() };
|
return { h: d.getHours(), m: d.getMinutes() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// تقویم شمسی — پورت Jalali.buildPicker به React (با پشتیبانی ساعت:دقیقه)
|
||||||
export function JalaliDatePicker({
|
export function JalaliDatePicker({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -73,7 +78,6 @@ export function JalaliDatePicker({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
}: JalaliDatePickerProps) {
|
}: JalaliDatePickerProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [alignment, setAlignment] = useState<"start" | "end">("start"); // تراز هوشمند
|
|
||||||
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
||||||
const initialTime = timeOf(value);
|
const initialTime = timeOf(value);
|
||||||
const [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0")));
|
const [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0")));
|
||||||
@@ -107,6 +111,7 @@ export function JalaliDatePicker({
|
|||||||
const mm = String(t.m).padStart(2, "0");
|
const mm = String(t.m).padStart(2, "0");
|
||||||
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||||
|
|
||||||
|
// Only update local input texts if numeric values differ, to avoid wiping in-progress typing
|
||||||
const curH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
const curH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||||
if (curH !== t.h) {
|
if (curH !== t.h) {
|
||||||
setHourText(faNum(hh));
|
setHourText(faNum(hh));
|
||||||
@@ -122,18 +127,7 @@ export function JalaliDatePicker({
|
|||||||
|
|
||||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||||
|
|
||||||
// تابع تشخیص موقعیت فیلد و تنظیم تراز تقویم
|
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
||||||
const handleOpen = () => {
|
|
||||||
if (disabled) return;
|
|
||||||
if (ref.current) {
|
|
||||||
const rect = ref.current.getBoundingClientRect();
|
|
||||||
const isLeftHalf = rect.left + rect.width / 2 < window.innerWidth / 2;
|
|
||||||
// اگر فیلد در نیمه چپ صفحه بود، تراز را از لبه چپ (end) میبندیم تا به سمت راست باز شود
|
|
||||||
setAlignment(isLeftHalf ? "end" : "start");
|
|
||||||
}
|
|
||||||
setOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
function selectedLocalDate(): string | null {
|
function selectedLocalDate(): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
@@ -147,7 +141,7 @@ export function JalaliDatePicker({
|
|||||||
const grid = useMemo(() => {
|
const grid = useMemo(() => {
|
||||||
const [jy, jm] = curJ;
|
const [jy, jm] = curJ;
|
||||||
const firstG = jalaliToGregorian(jy, jm, 1);
|
const firstG = jalaliToGregorian(jy, jm, 1);
|
||||||
const firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7;
|
const firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7; // شنبه = 0
|
||||||
const dim = daysInMonth(jy, jm);
|
const dim = daysInMonth(jy, jm);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||||
@@ -196,8 +190,11 @@ export function JalaliDatePicker({
|
|||||||
|
|
||||||
const handleHourChange = (raw: string) => {
|
const handleHourChange = (raw: string) => {
|
||||||
let digits = toAsciiDigits(raw).replace(/\D/g, "");
|
let digits = toAsciiDigits(raw).replace(/\D/g, "");
|
||||||
if (digits.length === 3) digits = digits.slice(-1);
|
if (digits.length === 3) {
|
||||||
else if (digits.length > 3) digits = digits.slice(0, 2);
|
digits = digits.slice(-1);
|
||||||
|
} else if (digits.length > 3) {
|
||||||
|
digits = digits.slice(0, 2);
|
||||||
|
}
|
||||||
if (!digits) {
|
if (!digits) {
|
||||||
setHourText("");
|
setHourText("");
|
||||||
return;
|
return;
|
||||||
@@ -231,8 +228,11 @@ export function JalaliDatePicker({
|
|||||||
|
|
||||||
const handleMinuteChange = (raw: string) => {
|
const handleMinuteChange = (raw: string) => {
|
||||||
let digits = toAsciiDigits(raw).replace(/\D/g, "");
|
let digits = toAsciiDigits(raw).replace(/\D/g, "");
|
||||||
if (digits.length === 3) digits = digits.slice(-1);
|
if (digits.length === 3) {
|
||||||
else if (digits.length > 3) digits = digits.slice(0, 2);
|
digits = digits.slice(-1);
|
||||||
|
} else if (digits.length > 3) {
|
||||||
|
digits = digits.slice(0, 2);
|
||||||
|
}
|
||||||
if (!digits) {
|
if (!digits) {
|
||||||
setMinuteText("");
|
setMinuteText("");
|
||||||
return;
|
return;
|
||||||
@@ -269,7 +269,11 @@ export function JalaliDatePicker({
|
|||||||
const [jy, jm, jd] = todayJalali();
|
const [jy, jm, jd] = todayJalali();
|
||||||
const nh = now.getHours();
|
const nh = now.getHours();
|
||||||
const nm = now.getMinutes();
|
const nm = now.getMinutes();
|
||||||
const val = withTime ? buildIso(jy, jm, jd, nh, nm) : buildDateOnly(jy, jm, jd);
|
setHourText(faNum(String(nh).padStart(2, "0")));
|
||||||
|
setMinuteText(faNum(String(nm).padStart(2, "0")));
|
||||||
|
const val = withTime
|
||||||
|
? buildIso(jy, jm, jd, nh, nm)
|
||||||
|
: buildDateOnly(jy, jm, jd);
|
||||||
onChange(val);
|
onChange(val);
|
||||||
setText(
|
setText(
|
||||||
withTime
|
withTime
|
||||||
@@ -360,7 +364,7 @@ export function JalaliDatePicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={cn("relative w-full", className)}>
|
<div ref={ref} className={cn("relative", className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -368,45 +372,43 @@ export function JalaliDatePicker({
|
|||||||
value={text}
|
value={text}
|
||||||
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
|
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onFocus={handleOpen}
|
onFocus={() => !disabled && setOpen(true)}
|
||||||
onClick={handleOpen}
|
onClick={() => !disabled && setOpen(true)}
|
||||||
onChange={(e) => onInputChange(e.target.value)}
|
onChange={(e) => onInputChange(e.target.value)}
|
||||||
onBlur={handleInputBlur}
|
onBlur={handleInputBlur}
|
||||||
/>
|
/>
|
||||||
{open && !disabled && (
|
{open && !disabled && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
// پاپآپ مطلق که بر اساس کلاس تراز تنظیم میشود
|
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
|
||||||
"absolute top-full z-[100] mt-1",
|
// حداکثر عرض = viewport (موبایل باریک سرریز نمیشود؛ دسکتاپ بیتغییر)
|
||||||
alignment === "start" ? "start-0" : "end-0", // اگر فیلد چپ بود -> end-0 (تراز چپ) | اگر راست بود -> start-0 (تراز راست)
|
"max-w-[calc(100vw-2rem)]",
|
||||||
"rounded-xl border border-line bg-panel shadow-2xl p-3.5",
|
withTime ? "w-80 p-3" : "w-64 p-2",
|
||||||
withTime ? "w-80" : "w-[280px]",
|
|
||||||
"max-w-[calc(100vw-2rem)]" // حاشیه امنیت برای صفحه نمایش خیلی کوچک
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* 1. Header: Month / Year Navigation */}
|
{/* 1. Header: Month / Year Navigation */}
|
||||||
<div className="mb-2 flex items-center justify-between gap-1">
|
<div className="mb-1.5 flex items-center justify-between gap-1">
|
||||||
<button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
|
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
|
||||||
«
|
«
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("m+1")}>
|
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m+1")}>
|
||||||
‹
|
‹
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm font-bold">
|
<span className="text-[13px] font-bold">
|
||||||
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
|
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
|
||||||
</span>
|
</span>
|
||||||
<button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}>
|
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}>
|
||||||
›
|
›
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}>
|
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}>
|
||||||
»
|
»
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. Day of Week Header & Days Grid */}
|
{/* 2. Day of Week Header & Days Grid */}
|
||||||
<div className="grid grid-cols-7 gap-1 text-center">
|
<div className="grid grid-cols-7 gap-0.5 text-center">
|
||||||
{WEEKDAYS.map((w) => (
|
{WEEKDAYS.map((w) => (
|
||||||
<div key={w} className="py-1 text-[11px] font-bold text-muted">
|
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
|
||||||
{w}
|
{w}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -418,7 +420,7 @@ export function JalaliDatePicker({
|
|||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer rounded-lg py-2 text-[13px] hover:bg-slate-bg transition-colors",
|
"cursor-pointer rounded py-0.5 text-[12px] hover:bg-slate-bg max-md:py-1.5",
|
||||||
cell.isToday && !selectedIso && "bg-blue-bg font-bold text-[#1d4ed8]",
|
cell.isToday && !selectedIso && "bg-blue-bg font-bold text-[#1d4ed8]",
|
||||||
cell.isToday && selectedIso && "font-bold text-[#1d4ed8]",
|
cell.isToday && selectedIso && "font-bold text-[#1d4ed8]",
|
||||||
cell.iso === selectedIso && "bg-accent text-white hover:bg-accent-dark",
|
cell.iso === selectedIso && "bg-accent text-white hover:bg-accent-dark",
|
||||||
@@ -431,43 +433,44 @@ export function JalaliDatePicker({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 3. Time Inputs Row */}
|
{/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */}
|
||||||
{withTime && (
|
{withTime && (
|
||||||
<div className="mt-3 flex items-center justify-center gap-3 rounded-lg border border-line/70 bg-slate-bg/40 py-2">
|
<div className="mt-2 flex items-center justify-center gap-3 rounded-md border border-line/70 bg-slate-bg/40 py-1.5">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-sm tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
|
||||||
value={minuteText}
|
value={minuteText}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
onFocus={(e) => e.target.select()}
|
onFocus={(e) => e.target.select()}
|
||||||
onChange={(e) => handleMinuteChange(e.target.value)}
|
onChange={(e) => handleMinuteChange(e.target.value)}
|
||||||
onBlur={handleMinuteBlur}
|
onBlur={handleMinuteBlur}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-semibold text-muted">دقیقه</span>
|
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-muted">:</span>
|
<span className="font-bold text-muted">:</span>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-sm tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
|
||||||
value={hourText}
|
value={hourText}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
onFocus={(e) => e.target.select()}
|
onFocus={(e) => e.target.select()}
|
||||||
onChange={(e) => handleHourChange(e.target.value)}
|
onChange={(e) => handleHourChange(e.target.value)}
|
||||||
onBlur={handleHourBlur}
|
onBlur={handleHourBlur}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs font-semibold text-muted">ساعت</span>
|
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* 4. Action Buttons Footer */}
|
{/* 4. Action Buttons Footer */}
|
||||||
<div className="mt-3 flex justify-between border-t border-line pt-2">
|
<div className="mt-2 flex justify-between border-t border-line pt-2">
|
||||||
<button type="button" className="cursor-pointer rounded px-3 py-1 text-xs font-medium text-accent hover:bg-slate-bg" onClick={pickToday}>
|
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}>
|
||||||
امروز
|
امروز
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="cursor-pointer rounded px-3 py-1 text-xs font-medium text-muted hover:bg-slate-bg" onClick={clear}>
|
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-muted hover:bg-slate-bg" onClick={clear}>
|
||||||
پاک کردن
|
پاک کردن
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -475,4 +478,4 @@ export function JalaliDatePicker({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,604 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۴ — کنترل روزانه (عملیات امروز)
|
||||||
|
// • کارتهای KPI: «حواله» (خروج) / «برگشتی» (برگشت) / «ثبت» (ضایعات) /
|
||||||
|
// «رسید» (پذیرش خرید). کمیتها هرگز بدون واحد جمع نمیشوند: با یک واحد،
|
||||||
|
// «۱۲۲ عدد» و با چند واحد «چندواحدی (۳ قلم کالا)» نمایش داده میشود.
|
||||||
|
// • کارتها و اشخاص، فیلتر فید «ریز عملیات امروز» را روشن/خاموش میکنند.
|
||||||
|
// • هر ردیف فید: نام کامل متریال + مشخصات، کمیت علامتدار با واحد فارسی،
|
||||||
|
// و متادیتای عملیاتی (انبار/برند/ثبتکننده/تحویلگیرنده) + ساعت تهران.
|
||||||
|
// • هیچ متن کاربردی بریده نمیشود: برش/خطچین/عرض محدود در کارتها نیست؛
|
||||||
|
// نامها و متادیتا کامل و چندخطی دیده میشوند.
|
||||||
|
// هر ۶۰ ثانیه خودکار تازهسازی میشود (useDailyControl).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ArrowDownToLine,
|
||||||
|
ArrowUpFromLine,
|
||||||
|
PackageOpen,
|
||||||
|
TriangleAlert,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useDailyControl } from "../../hooks/useDashboardData";
|
||||||
|
import { faNum, fmtNum } from "../../lib/formatters";
|
||||||
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
|
||||||
|
import type {
|
||||||
|
DailyControlCategoryStats,
|
||||||
|
DailyControlData,
|
||||||
|
DailyControlItem,
|
||||||
|
DailyControlPerson,
|
||||||
|
DailyControlPersonRole,
|
||||||
|
} from "../../lib/types";
|
||||||
|
|
||||||
|
// دستههای عملیاتی امروز — عنوان، واحد شمارش فارسی، آیکون و رنگبندی
|
||||||
|
type OpsCategory = "outbound" | "returns" | "waste" | "purchases";
|
||||||
|
|
||||||
|
const OPS_CATEGORY_ORDER: OpsCategory[] = ["outbound", "returns", "waste", "purchases"];
|
||||||
|
|
||||||
|
const CATEGORY_META: Record<
|
||||||
|
OpsCategory,
|
||||||
|
{ label: string; countNoun: string; icon: LucideIcon; tone: string }
|
||||||
|
> = {
|
||||||
|
outbound: {
|
||||||
|
label: "خروج به سایت",
|
||||||
|
countNoun: "حواله",
|
||||||
|
icon: ArrowUpFromLine,
|
||||||
|
tone: "bg-blue-bg text-blue-700",
|
||||||
|
},
|
||||||
|
returns: {
|
||||||
|
label: "برگشت از سایت",
|
||||||
|
countNoun: "برگشتی",
|
||||||
|
icon: ArrowDownToLine,
|
||||||
|
tone: "bg-amber-bg text-amber",
|
||||||
|
},
|
||||||
|
waste: {
|
||||||
|
label: "ضایعات / اتلاف / مفقودی",
|
||||||
|
countNoun: "ثبت",
|
||||||
|
icon: TriangleAlert,
|
||||||
|
tone: "bg-red-bg text-red",
|
||||||
|
},
|
||||||
|
purchases: {
|
||||||
|
label: "پذیرش خرید (ورود)",
|
||||||
|
countNoun: "رسید",
|
||||||
|
icon: PackageOpen,
|
||||||
|
tone: "bg-green-bg text-green",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// نوع تراکنش → برچسب نشان و دستهی عملیاتی
|
||||||
|
const TYPE_META: Record<string, { label: string; category: OpsCategory }> = {
|
||||||
|
WITHDRAWAL: { label: "برداشت از انبار", category: "outbound" },
|
||||||
|
RETURN: { label: "برگشت از سایت", category: "returns" },
|
||||||
|
DAMAGE: { label: "آسیبدیده", category: "waste" },
|
||||||
|
WASTE: { label: "اتلاف", category: "waste" },
|
||||||
|
LOSS: { label: "مفقودی", category: "waste" },
|
||||||
|
PURCHASE: { label: "پذیرش خرید", category: "purchases" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERSON_ROLE_META: Record<DailyControlPersonRole, { label: string; tone: string }> = {
|
||||||
|
performer: { label: "ثبتکننده انبار", tone: "bg-blue-bg text-blue-700" },
|
||||||
|
recipient: { label: "تحویلگیرنده کارگاه", tone: "bg-amber-bg text-amber" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// اسکرولبار نازک و ظریف برای فهرستهای داخلی (کلاسها بهصورت literal)
|
||||||
|
const SCROLLBAR_CLASSES =
|
||||||
|
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300/70";
|
||||||
|
|
||||||
|
const LABELS = {
|
||||||
|
peopleTitle: "اشخاص درگیر در عملیات امروز",
|
||||||
|
peopleSub: "(ثبتکننده / تحویلگیرنده)",
|
||||||
|
feedTitle: "ریز عملیات امروز",
|
||||||
|
warehouse: "انبار",
|
||||||
|
brand: "برند",
|
||||||
|
performer: "ثبتکننده",
|
||||||
|
recipient: "تحویلگیرنده",
|
||||||
|
transaction: "تراکنش",
|
||||||
|
noActivity: "ثبتی نشده",
|
||||||
|
multiUnit: "چندواحدی",
|
||||||
|
itemUnit: "قلم کالا",
|
||||||
|
noPeople: "امروز عملیاتی ثبت نشده است",
|
||||||
|
emptyFeed: "هنوز جابهجاییای امروز ثبت نشده است",
|
||||||
|
emptyFiltered: "عملیاتی با این فیلتر امروز ثبت نشده است",
|
||||||
|
noSpecs: "بدون مشخصه",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// برچسب مشخصات فنی: «کلید: مقدار، …» یا خالی
|
||||||
|
function specLabel(specs: Record<string, string>): string {
|
||||||
|
const parts = Object.entries(specs ?? {});
|
||||||
|
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// کمیت با علامت (+/−) و ارقام فارسی
|
||||||
|
const signedQty = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`;
|
||||||
|
|
||||||
|
// فقط ساعت تهران (ارقام فارسی) — تاریخ در کل ویجت امروز است و تکرار نمیشود
|
||||||
|
function timeOnly(iso: string): string {
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return "";
|
||||||
|
const text = new Intl.DateTimeFormat("en-GB", {
|
||||||
|
timeZone: "Asia/Tehran",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
}).format(date);
|
||||||
|
return faNum(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// متریک دوم کارت: تکواحدی → «۱۲۲ عدد» / چندواحدی → «چندواحدی (۳ قلم کالا)»
|
||||||
|
function secondaryMetric(stats: DailyControlCategoryStats): string {
|
||||||
|
if (!stats || stats.tickets === 0) return LABELS.noActivity;
|
||||||
|
const units = stats.units ?? [];
|
||||||
|
if (units.length === 1) {
|
||||||
|
const unit = units[0];
|
||||||
|
return `${fmtNum(unit.qty)} ${unit.name_fa ?? unit.code}`;
|
||||||
|
}
|
||||||
|
return `${LABELS.multiUnit} (${faNum(stats.items)} ${LABELS.itemUnit})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// تفکیک فعالیت هر شخص: «۲ حواله · ۱ رسید»
|
||||||
|
function personBreakdown(person: DailyControlPerson): string {
|
||||||
|
return OPS_CATEGORY_ORDER.filter((category) => person.categories[category] > 0)
|
||||||
|
.map((category) => `${faNum(person.categories[category])} ${CATEGORY_META[category].countNoun}`)
|
||||||
|
.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— نرمالسازی payload ———
|
||||||
|
// هر آرایه/عدد/متن با پیشفرض امن ساخته میشود تا هیچ مصرفکنندهای هرگز
|
||||||
|
// `.length` یا `.map` را روی undefined اجرا نکند (پاسخ خالی/قدیمی/ناقص هم امن است).
|
||||||
|
const toNumber = (value: unknown): number => {
|
||||||
|
const parsed = typeof value === "number" ? value : Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
};
|
||||||
|
const toArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []);
|
||||||
|
const toText = (value: unknown): string => (typeof value === "string" ? value : "");
|
||||||
|
const toNullableText = (value: unknown): string | null =>
|
||||||
|
typeof value === "string" ? value : null;
|
||||||
|
|
||||||
|
const EMPTY_PERSON_CATEGORIES: DailyControlPerson["categories"] = {
|
||||||
|
outbound: 0,
|
||||||
|
returns: 0,
|
||||||
|
waste: 0,
|
||||||
|
purchases: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeUnits(raw: unknown): DailyControlCategoryStats["units"] {
|
||||||
|
return toArray<Partial<DailyControlCategoryStats["units"][number]>>(raw).map((unit) => ({
|
||||||
|
code: toText(unit?.code),
|
||||||
|
name_fa: toNullableText(unit?.name_fa),
|
||||||
|
qty: toNumber(unit?.qty),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStats(raw: unknown): DailyControlCategoryStats {
|
||||||
|
const stats = (raw ?? {}) as Partial<DailyControlCategoryStats>;
|
||||||
|
return {
|
||||||
|
tickets: toNumber(stats.tickets),
|
||||||
|
items: toNumber(stats.items),
|
||||||
|
qty: toNumber(stats.qty),
|
||||||
|
// همیشه آرایه — منبع کرش قبلی وقتی API فیلد units را نداشت
|
||||||
|
units: normalizeUnits(stats.units),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePerson(raw: unknown): DailyControlPerson | null {
|
||||||
|
const person = (raw ?? {}) as Partial<DailyControlPerson>;
|
||||||
|
const name = toText(person.name).trim();
|
||||||
|
if (!name) return null;
|
||||||
|
const categories = (person.categories ?? EMPTY_PERSON_CATEGORIES) as Partial<
|
||||||
|
DailyControlPerson["categories"]
|
||||||
|
>;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
role: person.role === "recipient" ? "recipient" : "performer",
|
||||||
|
tickets: toNumber(person.tickets),
|
||||||
|
categories: {
|
||||||
|
outbound: toNumber(categories.outbound),
|
||||||
|
returns: toNumber(categories.returns),
|
||||||
|
waste: toNumber(categories.waste),
|
||||||
|
purchases: toNumber(categories.purchases),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItem(raw: unknown): DailyControlItem | null {
|
||||||
|
const item = (raw ?? {}) as Partial<DailyControlItem>;
|
||||||
|
const id = toText(item.id);
|
||||||
|
if (!id) return null; // ردیف بدون شناسه قابل رندر نیست
|
||||||
|
const specs =
|
||||||
|
item.specs && typeof item.specs === "object" && !Array.isArray(item.specs)
|
||||||
|
? (item.specs as Record<string, string>)
|
||||||
|
: {};
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
transaction_type: toText(item.transaction_type),
|
||||||
|
quantity: toNumber(item.quantity),
|
||||||
|
unit_code: toText(item.unit_code),
|
||||||
|
unit_name_fa: toNullableText(item.unit_name_fa),
|
||||||
|
performed_by: toNullableText(item.performed_by),
|
||||||
|
delivered_to: toNullableText(item.delivered_to),
|
||||||
|
brand: toNullableText(item.brand),
|
||||||
|
note: toNullableText(item.note),
|
||||||
|
warehouse: toText(item.warehouse),
|
||||||
|
material: toText(item.material),
|
||||||
|
specs,
|
||||||
|
transaction_date: toText(item.transaction_date),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyControlView {
|
||||||
|
date: string;
|
||||||
|
categories: Record<OpsCategory, DailyControlCategoryStats>;
|
||||||
|
people: DailyControlPerson[];
|
||||||
|
items: DailyControlItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// پاسخ API (نسخهی جدید یا قدیمی) → مدل نمایشیِ کاملاً تضمینشده
|
||||||
|
export function normalizeDailyControl(raw: unknown): DailyControlView {
|
||||||
|
const data = (raw ?? {}) as Partial<DailyControlData> & { performers?: unknown };
|
||||||
|
|
||||||
|
let people = toArray<unknown>(data.people)
|
||||||
|
.map(normalizePerson)
|
||||||
|
.filter((person): person is DailyControlPerson => person !== null);
|
||||||
|
|
||||||
|
// سازگاری با payload قدیمی که فقط performers دارد (بدون نقش/تفکیک دسته)
|
||||||
|
if (people.length === 0) {
|
||||||
|
people = toArray<{ name?: unknown; tickets?: unknown }>(data.performers)
|
||||||
|
.map((person) =>
|
||||||
|
normalizePerson({
|
||||||
|
name: person?.name,
|
||||||
|
role: "performer",
|
||||||
|
tickets: person?.tickets,
|
||||||
|
categories: EMPTY_PERSON_CATEGORIES,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.filter((person): person is DailyControlPerson => person !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: toText(data.date),
|
||||||
|
categories: {
|
||||||
|
outbound: normalizeStats(data.outbound),
|
||||||
|
returns: normalizeStats(data.returns),
|
||||||
|
waste: normalizeStats(data.waste),
|
||||||
|
purchases: normalizeStats(data.purchases),
|
||||||
|
},
|
||||||
|
people,
|
||||||
|
items: toArray<unknown>(data.items)
|
||||||
|
.map(normalizeItem)
|
||||||
|
.filter((item): item is DailyControlItem => item !== null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeedFilter =
|
||||||
|
| { kind: "category"; value: OpsCategory }
|
||||||
|
| { kind: "person"; name: string; role: DailyControlPersonRole };
|
||||||
|
|
||||||
|
// ——— کارت KPI ———
|
||||||
|
// نوار فشرده: عنوان و آیکون / متریک اصلی / زیرنویس. در ستونهای باریک عنوان
|
||||||
|
// بهطور طبیعی میشکند (بدون برش) و هیچ متنی پنهان نمیشود.
|
||||||
|
function KpiCard({
|
||||||
|
category,
|
||||||
|
stats,
|
||||||
|
active,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
category: OpsCategory;
|
||||||
|
stats: DailyControlCategoryStats;
|
||||||
|
active: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const meta = CATEGORY_META[category];
|
||||||
|
const Icon = meta.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={meta.label}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col justify-between gap-1.5 rounded-xl border border-slate-100 bg-slate-50/40 p-3 text-start transition-all",
|
||||||
|
active ? "border-indigo-300 ring-1 ring-indigo-200" : "hover:border-slate-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* عنوان + آیکون */}
|
||||||
|
<span className="flex items-start justify-between gap-1.5">
|
||||||
|
<span className="min-w-0 break-words text-[11px] font-medium leading-snug text-slate-500">
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg",
|
||||||
|
meta.tone,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* متریک اصلی */}
|
||||||
|
<span className="text-sm font-bold tabular-nums text-slate-800 md:text-base">
|
||||||
|
{faNum(stats.tickets)} {meta.countNoun}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* زیرنویس */}
|
||||||
|
<span className="text-[10px] leading-normal text-slate-400">
|
||||||
|
{secondaryMetric(stats)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— یک ردیف فید عملیات ———
|
||||||
|
// نام کامل متریال و همهی متادیتا (بدون برش متن)؛ کمیت و ساعت در ستون
|
||||||
|
// جداگانه و nowrap میمانند تا هرگز با متنهای بلند قاطی نشوند.
|
||||||
|
function FeedRow({ item }: { item: DailyControlItem }) {
|
||||||
|
const type = TYPE_META[item.transaction_type] ?? {
|
||||||
|
label: item.transaction_type,
|
||||||
|
category: "outbound" as OpsCategory,
|
||||||
|
};
|
||||||
|
const specs = specLabel(item.specs);
|
||||||
|
const unitName = item.unit_name_fa ?? item.unit_code;
|
||||||
|
return (
|
||||||
|
<li className="flex shrink-0 flex-col gap-2 rounded-xl border border-slate-100 bg-slate-50/30 p-3.5 transition-all hover:bg-white hover:shadow-xs">
|
||||||
|
{/* ردیف ۱: نشان نوع + نام کامل متریال/مشخصات و کمیت */}
|
||||||
|
<div className="flex w-full items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium",
|
||||||
|
CATEGORY_META[type.category].tone,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{type.label}
|
||||||
|
</Badge>
|
||||||
|
<span className="min-w-0 break-words text-xs font-bold leading-snug text-slate-800 sm:text-sm">
|
||||||
|
{item.material}
|
||||||
|
{specs && <span className="font-normal text-slate-400"> — {specs}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
dir="ltr"
|
||||||
|
className="shrink-0 whitespace-nowrap text-xs font-bold tabular-nums text-slate-800 sm:text-sm"
|
||||||
|
>
|
||||||
|
{signedQty(item.quantity)} {unitName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ردیف ۲: متادیتای کامل + ساعت */}
|
||||||
|
<div className="flex w-full items-center justify-between gap-3 border-t border-slate-100/80 pt-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
|
||||||
|
<span>
|
||||||
|
{LABELS.warehouse}: {item.warehouse}
|
||||||
|
</span>
|
||||||
|
{item.brand && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden className="text-slate-300">
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{LABELS.brand}: {item.brand}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.performed_by && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden className="text-slate-300">
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{LABELS.performer}: {item.performed_by}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.delivered_to && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden className="text-slate-300">
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{LABELS.recipient}: {item.delivered_to}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 whitespace-nowrap text-[11px] font-medium tabular-nums text-slate-400">
|
||||||
|
{timeOnly(item.transaction_date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DailyControlPanel() {
|
||||||
|
const query = useDailyControl();
|
||||||
|
// مدل نمایشیِ نرمالشده: هیچ آرایهای undefined نیست (پاسخ خالی/قدیمی هم امن است)
|
||||||
|
const view = useMemo(
|
||||||
|
() => (query.data ? normalizeDailyControl(query.data) : null),
|
||||||
|
[query.data],
|
||||||
|
);
|
||||||
|
const [filter, setFilter] = useState<FeedFilter | null>(null);
|
||||||
|
|
||||||
|
const items = useMemo(() => view?.items ?? [], [view]);
|
||||||
|
|
||||||
|
const visibleItems = useMemo(() => {
|
||||||
|
if (!filter) return items;
|
||||||
|
if (filter.kind === "category") {
|
||||||
|
return items.filter(
|
||||||
|
(item) => TYPE_META[item.transaction_type]?.category === filter.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return items.filter((item) =>
|
||||||
|
filter.role === "performer"
|
||||||
|
? item.performed_by === filter.name
|
||||||
|
: item.delivered_to === filter.name,
|
||||||
|
);
|
||||||
|
}, [items, filter]);
|
||||||
|
|
||||||
|
const toggleCategory = (category: OpsCategory) =>
|
||||||
|
setFilter((prev) =>
|
||||||
|
prev?.kind === "category" && prev.value === category
|
||||||
|
? null
|
||||||
|
: { kind: "category", value: category },
|
||||||
|
);
|
||||||
|
|
||||||
|
const togglePerson = (person: DailyControlPerson) =>
|
||||||
|
setFilter((prev) =>
|
||||||
|
prev?.kind === "person" && prev.name === person.name && prev.role === person.role
|
||||||
|
? null
|
||||||
|
: { kind: "person", name: person.name, role: person.role },
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterLabel =
|
||||||
|
filter?.kind === "category"
|
||||||
|
? CATEGORY_META[filter.value].label
|
||||||
|
: filter?.kind === "person"
|
||||||
|
? `${filter.name} (${PERSON_ROLE_META[filter.role].label})`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-2xl !border-gray-100/80 [&>header]:shrink-0 [&>header_h2]:text-gray-900 [&>header_h2]:!font-bold [&>header_p]:!text-xs [&>header_p]:!text-gray-400"
|
||||||
|
title="کنترل روزانه — عملیات امروز"
|
||||||
|
sub={
|
||||||
|
view?.date
|
||||||
|
? `تاریخ ${isoToJalaliStr(view.date) ?? view.date} — هر دقیقه بهروزرسانی میشود`
|
||||||
|
: "هر دقیقه بهروزرسانی میشود"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : query.isError || !view ? (
|
||||||
|
<ChartEmpty message="دادهی امروز در دسترس نیست" />
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
{/* نوار فشردهی KPI — همیشه چهار ستون در دسکتاپ؛ قابل کلیک برای فیلتر فید */}
|
||||||
|
<div className="grid shrink-0 grid-cols-2 gap-2.5 md:grid-cols-4">
|
||||||
|
{OPS_CATEGORY_ORDER.map((category) => (
|
||||||
|
<KpiCard
|
||||||
|
key={category}
|
||||||
|
category={category}
|
||||||
|
stats={view.categories[category]}
|
||||||
|
active={filter?.kind === "category" && filter.value === category}
|
||||||
|
onToggle={() => toggleCategory(category)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid min-h-0 flex-1 grid-cols-1 gap-3.5 md:grid-cols-12 md:grid-rows-1 md:items-stretch">
|
||||||
|
{/* اشخاص درگیر در عملیات امروز — کاشی دوردیفی */}
|
||||||
|
<div className="flex h-[420px] min-h-0 w-full min-w-0 shrink-0 flex-col rounded-xl border border-slate-100 bg-white p-4 md:col-span-5 lg:h-auto lg:max-h-[420px]">
|
||||||
|
<div className="mb-3 shrink-0">
|
||||||
|
<h3 className="flex items-center gap-1.5 text-sm font-bold text-slate-800">
|
||||||
|
<Users aria-hidden className="h-4 w-4 shrink-0 text-slate-400" />
|
||||||
|
{LABELS.peopleTitle}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-0.5 whitespace-nowrap text-[11px] text-slate-400">
|
||||||
|
{LABELS.peopleSub}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{view.people.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-[12px] text-muted">{LABELS.noPeople}</p>
|
||||||
|
) : (
|
||||||
|
<ul
|
||||||
|
className={cn(
|
||||||
|
"custom-scrollbar flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto pe-1",
|
||||||
|
SCROLLBAR_CLASSES,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{view.people.map((person, index) => {
|
||||||
|
const active =
|
||||||
|
filter?.kind === "person" &&
|
||||||
|
filter.name === person.name &&
|
||||||
|
filter.role === person.role;
|
||||||
|
const breakdown = personBreakdown(person);
|
||||||
|
return (
|
||||||
|
<li key={`${person.role}|${person.name}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => togglePerson(person)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={person.name}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full shrink-0 flex-col gap-2.5 rounded-xl border p-3 text-right transition-all",
|
||||||
|
active
|
||||||
|
? "border-blue-300 bg-blue-50/40 ring-1 ring-blue-200"
|
||||||
|
: "border-slate-100 bg-slate-50/50 hover:border-slate-200 hover:bg-slate-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* ردیف ۱: هویت */}
|
||||||
|
<span className="flex w-full items-center gap-2">
|
||||||
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-100/80 text-xs font-bold tabular-nums text-blue-700">
|
||||||
|
{faNum(index + 1)}
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-nowrap text-xs font-bold text-slate-800 sm:text-sm">
|
||||||
|
{person.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* ردیف ۲: نقش + آمار */}
|
||||||
|
<span className="flex w-full flex-wrap items-center justify-between gap-2 border-t border-slate-100/80 pt-2">
|
||||||
|
<span className="w-fit shrink-0 whitespace-nowrap rounded-md bg-blue-50 px-2 py-0.5 text-[10px] font-medium text-blue-600">
|
||||||
|
{PERSON_ROLE_META[person.role].label}
|
||||||
|
</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[11px] tabular-nums">
|
||||||
|
<span className="font-bold text-slate-700">
|
||||||
|
{faNum(person.tickets)} {LABELS.transaction}
|
||||||
|
</span>
|
||||||
|
{breakdown && (
|
||||||
|
<span className="font-medium text-slate-400">({breakdown})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ریز عملیات امروز */}
|
||||||
|
<div className="flex h-[420px] min-h-0 min-w-0 flex-1 flex-col rounded-xl border border-slate-100 bg-white p-4 md:col-span-7 lg:h-auto lg:max-h-[420px]">
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
<h3 className="text-sm font-bold text-slate-800">{LABELS.feedTitle}</h3>
|
||||||
|
{filter && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter(null)}
|
||||||
|
className="ms-auto inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-indigo-100 bg-indigo-50 px-2.5 py-1 text-xs font-medium text-indigo-700 transition-colors hover:bg-indigo-100/70"
|
||||||
|
aria-label={`حذف فیلتر ${filterLabel}`}
|
||||||
|
title="حذف فیلتر"
|
||||||
|
>
|
||||||
|
{filterLabel}
|
||||||
|
<X aria-hidden className="h-3 w-3 shrink-0" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{visibleItems.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-[12px] text-muted">
|
||||||
|
{items.length === 0 ? LABELS.emptyFeed : LABELS.emptyFiltered}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul
|
||||||
|
className={cn(
|
||||||
|
"custom-scrollbar mt-3 flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto pe-1",
|
||||||
|
SCROLLBAR_CLASSES,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{visibleItems.map((item) => (
|
||||||
|
<FeedRow key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// ============================================================
|
||||||
|
// فیلتر سراسری «چه چیزی» — آبشار: متریال (خانواده) → برند → مشخصات فنی
|
||||||
|
// • «متریال» فقط خانوادههای پایه را نشان میدهد (بلوک سیمانی، لوله پلیاتیلن…)
|
||||||
|
// نه ردیفهای دارای سایز/گرید.
|
||||||
|
// • «مشخصات فنی» پس از انتخاب خانواده، واریانتهای همان خانواده (سایز/گرید)
|
||||||
|
// و مشخصههای موجودیِ آن خانواده را فهرست میکند.
|
||||||
|
// • همهی گزینهها از API مشتق میشوند (بدون هیچ لیست یا نام هاردکدشده).
|
||||||
|
// • سه فیلتر وسطچین و متقارناند؛ روی موبایل تکستونی میشوند.
|
||||||
|
// وضعیت در Zustand نگه داشته میشود تا با جابهجایی بین تبها از دست نرود.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Filter, X } from "lucide-react";
|
||||||
|
import { Combobox, type ComboboxItem } from "../Combobox";
|
||||||
|
import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
|
||||||
|
import { useDashboardFilter } from "../../store/dashboardFilter";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
// گزینهی «همه» — شناسهی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox)
|
||||||
|
const ALL_OPTION_ID = "";
|
||||||
|
const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID];
|
||||||
|
|
||||||
|
// پیشوندهای شناسهی گزینههای «مشخصات» — واریانت کاتالوگ یا مشخصهی موجودی
|
||||||
|
const SPEC_VARIANT_PREFIX = "v:";
|
||||||
|
const SPEC_STOCK_PREFIX = "s:";
|
||||||
|
|
||||||
|
const LABELS = {
|
||||||
|
allFamilies: "همهی متریالها",
|
||||||
|
allBrands: "همهی برندها",
|
||||||
|
allSpecs: "همهی مشخصات",
|
||||||
|
familyFirst: "ابتدا متریال را انتخاب کنید",
|
||||||
|
clearAll: "حذف فیلترها",
|
||||||
|
wholeOrg: "نمای کل سازمان",
|
||||||
|
cascade: "متریال ← برند ← مشخصات فنی",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label });
|
||||||
|
|
||||||
|
export function DashboardFilterBar({ className }: { className?: string }) {
|
||||||
|
const filterQuery = useDashboardFilterOptions();
|
||||||
|
const state = useDashboardFilter();
|
||||||
|
|
||||||
|
const families = useMemo(() => filterQuery.data?.families ?? [], [filterQuery.data]);
|
||||||
|
const selectedFamily = useMemo(
|
||||||
|
() => families.find((family) => family.id === state.materialFamilyId) ?? null,
|
||||||
|
[families, state.materialFamilyId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeCount = [state.materialFamilyId, state.brandId, state.spec].filter(Boolean).length;
|
||||||
|
|
||||||
|
const pickFamily = (item: ComboboxItem) =>
|
||||||
|
item.id === ALL_OPTION_ID
|
||||||
|
? state.setMaterialFamily(null, null)
|
||||||
|
: state.setMaterialFamily(item.id, item.label);
|
||||||
|
|
||||||
|
const pickBrand = (item: ComboboxItem) =>
|
||||||
|
item.id === ALL_OPTION_ID
|
||||||
|
? state.setBrand(null, null)
|
||||||
|
: state.setBrand(item.id, item.label);
|
||||||
|
|
||||||
|
// گزینههای مشخصات: «v:<materialId>» برای واریانت و «s:<hash>» برای مشخصهی موجودی
|
||||||
|
const pickSpec = (item: ComboboxItem) => {
|
||||||
|
if (item.id === ALL_OPTION_ID) {
|
||||||
|
state.setSpec(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.id.startsWith(SPEC_VARIANT_PREFIX)) {
|
||||||
|
state.setSpec({
|
||||||
|
kind: "variant",
|
||||||
|
id: item.id.slice(SPEC_VARIANT_PREFIX.length),
|
||||||
|
label: item.label,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.id.startsWith(SPEC_STOCK_PREFIX)) {
|
||||||
|
state.setSpec({
|
||||||
|
kind: "stockSpec",
|
||||||
|
id: item.id.slice(SPEC_STOCK_PREFIX.length),
|
||||||
|
label: item.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const familyItems = useMemo<ComboboxItem[]>(
|
||||||
|
() => [allOption(LABELS.allFamilies), ...families.map((f) => ({ id: f.id, label: f.name }))],
|
||||||
|
[families],
|
||||||
|
);
|
||||||
|
|
||||||
|
const brandItems = useMemo<ComboboxItem[]>(
|
||||||
|
() => [
|
||||||
|
allOption(LABELS.allBrands),
|
||||||
|
...(selectedFamily?.brands ?? []).map((brand) => ({ id: brand.id, label: brand.name })),
|
||||||
|
],
|
||||||
|
[selectedFamily],
|
||||||
|
);
|
||||||
|
|
||||||
|
const specItems = useMemo<ComboboxItem[]>(() => {
|
||||||
|
if (!selectedFamily) return [allOption(LABELS.allSpecs)];
|
||||||
|
// واریانتها (سایز/گرید) — یکتاسازی بر اساس برچسب برای پرهیز از تکرار
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const variants = selectedFamily.materials
|
||||||
|
.filter((material) => (seen.has(material.variant_label) ? false : (seen.add(material.variant_label), true)))
|
||||||
|
.map((material) => ({
|
||||||
|
id: `${SPEC_VARIANT_PREFIX}${material.id}`,
|
||||||
|
label: material.variant_label,
|
||||||
|
}));
|
||||||
|
const stockSpecs = selectedFamily.specs.map((spec) => ({
|
||||||
|
id: `${SPEC_STOCK_PREFIX}${spec.hash}`,
|
||||||
|
label: spec.label,
|
||||||
|
}));
|
||||||
|
return [allOption(LABELS.allSpecs), ...variants, ...stockSpecs];
|
||||||
|
}, [selectedFamily]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn("rounded-2xl border border-line bg-panel p-3 sm:p-4", className)}
|
||||||
|
aria-label="فیلتر سراسری متریال"
|
||||||
|
>
|
||||||
|
<div className="mb-2.5 flex items-center gap-2">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-bg text-accent">
|
||||||
|
<Filter className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-[13px] font-extrabold leading-5">فیلتر جنس</h2>
|
||||||
|
<p className="text-[10.5px] text-muted">{LABELS.cascade}</p>
|
||||||
|
</div>
|
||||||
|
{activeCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={state.reset}
|
||||||
|
aria-label={`${LABELS.clearAll} (بازگشت به ${LABELS.wholeOrg})`}
|
||||||
|
title={`${LABELS.clearAll} — ${LABELS.wholeOrg}`}
|
||||||
|
className="group ms-auto inline-flex items-center gap-1.5 rounded-full border border-red/20 bg-red-bg px-2.5 py-1.5 text-[11px] font-bold text-red shadow-sm transition-all hover:border-red/40 hover:shadow active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
<X aria-hidden className="h-3.5 w-3.5 transition-transform group-hover:rotate-90" />
|
||||||
|
{LABELS.clearAll}
|
||||||
|
<span className="rounded-full bg-panel/60 px-1.5 leading-4 tabular-nums">
|
||||||
|
{faNum(activeCount)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* سه فیلتر با عرض یکسان، وسطچین و متقارن؛ روی موبایل تکستونی میشود */}
|
||||||
|
<div className="mx-auto grid w-full max-w-4xl grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
|
||||||
|
{/* ۱ — متریال (خانوادهی پایه) */}
|
||||||
|
<Combobox
|
||||||
|
value={state.materialFamilyLabel ?? ""}
|
||||||
|
placeholder={LABELS.allFamilies}
|
||||||
|
allowAdd={false}
|
||||||
|
disabled={families.length === 0}
|
||||||
|
items={familyItems}
|
||||||
|
pinnedIds={ALL_PINNED_IDS}
|
||||||
|
onClear={() => state.setMaterialFamily(null, null)}
|
||||||
|
onChange={pickFamily}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ۲ — برند (برندهای همان خانواده) */}
|
||||||
|
<Combobox
|
||||||
|
value={state.brandLabel ?? ""}
|
||||||
|
placeholder={selectedFamily ? LABELS.allBrands : LABELS.familyFirst}
|
||||||
|
allowAdd={false}
|
||||||
|
disabled={!selectedFamily || selectedFamily.brands.length === 0}
|
||||||
|
items={brandItems}
|
||||||
|
pinnedIds={ALL_PINNED_IDS}
|
||||||
|
onClear={() => state.setBrand(null, null)}
|
||||||
|
onChange={pickBrand}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ۳ — مشخصات فنی (واریانتها + مشخصههای موجودی همان خانواده) */}
|
||||||
|
<Combobox
|
||||||
|
value={state.spec?.label ?? ""}
|
||||||
|
placeholder={selectedFamily ? LABELS.allSpecs : LABELS.familyFirst}
|
||||||
|
allowAdd={false}
|
||||||
|
disabled={!selectedFamily}
|
||||||
|
items={specItems}
|
||||||
|
pinnedIds={ALL_PINNED_IDS}
|
||||||
|
onClear={() => state.setSpec(null)}
|
||||||
|
onChange={pickSpec}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۲ — موجودی راکد (سرمایهی خوابیده) — فقطخواندنی
|
||||||
|
// داشبورد برای پایش و تحلیل است، نه ویرایش: هیچ استپر/آستانهی ردیفی یا
|
||||||
|
// پنهانسازی وجود ندارد. همهی ردیفها با دادهی خام API رندر میشوند و
|
||||||
|
// مرتبسازی سهحالته (صعودی ← نزولی ← پیشفرض) روی هدر سه ستون کلیدی است.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { ArrowDown, ArrowUp, ArrowUpDown, Snowflake } from "lucide-react";
|
||||||
|
import { useDeadStock } from "../../hooks/useDashboardData";
|
||||||
|
import { faNum, fmtNum } from "../../lib/formatters";
|
||||||
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
|
||||||
|
import type { DeadStockItem } from "../../lib/types";
|
||||||
|
|
||||||
|
// صفر = بدون هیچ آستانهای؛ همهی ردیفهای موجودی برگردانده میشوند
|
||||||
|
const ALL_ITEMS_IDLE_DAYS = 0;
|
||||||
|
const MAX_LIST_HEIGHT = "max-h-[450px]";
|
||||||
|
|
||||||
|
// اسکرولبار ظریف (کلاسها بهصورت literal برای Tailwind)
|
||||||
|
const SCROLLBAR_CLASSES =
|
||||||
|
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300/70";
|
||||||
|
|
||||||
|
// شبکهی مشترک ستونها: هویت + سه ستون متریک با عرض ثابت.
|
||||||
|
// هدر و همهی ردیفها از همین قالب استفاده میکنند تا ستونها در کل لیست
|
||||||
|
// دقیقاً روی هم بیفتند؛ در موبایل هویت بالا و متریکها در سه ستون مساوی
|
||||||
|
// زیر آن میآیند (sm:contents پوستهی متریک را از چیدمان حذف میکند).
|
||||||
|
const ROW_GRID =
|
||||||
|
"grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_repeat(3,7rem)] sm:items-center sm:gap-4 lg:gap-6";
|
||||||
|
const HEADER_GRID =
|
||||||
|
"grid grid-cols-3 items-center gap-4 px-3.5 pb-2 sm:grid-cols-[minmax(0,1fr)_repeat(3,7rem)] sm:gap-4 lg:gap-6";
|
||||||
|
const METRICS_GRID = "grid grid-cols-3 gap-4 sm:contents";
|
||||||
|
const METRIC_CELL = "flex min-w-0 flex-col items-center justify-center gap-0.5 text-center";
|
||||||
|
const METRIC_LABEL = "whitespace-nowrap text-[9.5px] font-bold leading-4 text-gray-400";
|
||||||
|
const METRIC_VALUE = "whitespace-nowrap text-sm font-bold leading-5 tabular-nums";
|
||||||
|
|
||||||
|
const LABELS = {
|
||||||
|
sub: "همهی اقلام راکد — برای مرتبسازی روی عنوان ستونها کلیک کنید",
|
||||||
|
qty: "مقدار راکد",
|
||||||
|
days: "روزهای رکود",
|
||||||
|
dayUnit: "روز",
|
||||||
|
lastActivity: "آخرین فعالیت",
|
||||||
|
brand: "برند",
|
||||||
|
warehouse: "انبار",
|
||||||
|
project: "پروژه",
|
||||||
|
sortHint: "ترتیب: صعودی ← نزولی ← پیشفرض",
|
||||||
|
empty: "موجودی راکدی یافت نشد",
|
||||||
|
noDate: "—",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ——— مرتبسازی سهحالته ———
|
||||||
|
type SortKey = "quantity" | "days" | "activity";
|
||||||
|
type SortDirection = "asc" | "desc";
|
||||||
|
|
||||||
|
interface SortState {
|
||||||
|
key: SortKey;
|
||||||
|
direction: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORT_FIELDS: { key: SortKey; label: string }[] = [
|
||||||
|
{ key: "quantity", label: LABELS.qty },
|
||||||
|
{ key: "days", label: LABELS.days },
|
||||||
|
{ key: "activity", label: LABELS.lastActivity },
|
||||||
|
];
|
||||||
|
|
||||||
|
// برچسب مشخصات: «کلید: مقدار، …»
|
||||||
|
function specLabel(specs: Record<string, string>): string {
|
||||||
|
const parts = Object.entries(specs || {});
|
||||||
|
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// شدت رکود نسبت به بیشترین رکود مجموعه (کاملاً دادهمحور)
|
||||||
|
function severityClass(days: number, maxDays: number): string {
|
||||||
|
const ratio = maxDays > 0 ? days / maxDays : 0;
|
||||||
|
if (ratio > 0.66) return "text-red";
|
||||||
|
if (ratio > 0.33) return "text-amber";
|
||||||
|
return "text-green";
|
||||||
|
}
|
||||||
|
|
||||||
|
// مقایسهی امن: ردیفهای بدون تاریخ همیشه در انتها میآیند
|
||||||
|
function compareActivity(a: DeadStockItem, b: DeadStockItem, direction: 1 | -1): number {
|
||||||
|
const time = (value: string | null): number | null => {
|
||||||
|
if (!value) return null;
|
||||||
|
const parsed = new Date(value).getTime();
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
const at = time(a.last_activity);
|
||||||
|
const bt = time(b.last_activity);
|
||||||
|
if (at === null && bt === null) return 0;
|
||||||
|
if (at === null) return 1;
|
||||||
|
if (bt === null) return -1;
|
||||||
|
return (at - bt) * direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortItems(items: DeadStockItem[], sort: SortState | null): DeadStockItem[] {
|
||||||
|
// بیترتیب = ترتیب پیشفرض دیتابیس (بدون هیچ تغییر)
|
||||||
|
if (!sort) return items;
|
||||||
|
const direction = sort.direction === "asc" ? 1 : -1;
|
||||||
|
const list = [...items];
|
||||||
|
list.sort((a, b) => {
|
||||||
|
if (sort.key === "quantity") return (a.quantity - b.quantity) * direction;
|
||||||
|
if (sort.key === "days") return ((a.idle_days ?? 0) - (b.idle_days ?? 0)) * direction;
|
||||||
|
return compareActivity(a, b, direction);
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// چرخهی سهحالتهی هر ستون: صعودی ← نزولی ← برگشت به پیشفرض دیتابیس
|
||||||
|
export function nextSortState(prev: SortState | null, key: SortKey): SortState | null {
|
||||||
|
if (!prev || prev.key !== key) return { key, direction: "asc" };
|
||||||
|
if (prev.direction === "asc") return { key, direction: "desc" };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— هدر ستون تعاملی ———
|
||||||
|
// برچسب متریک خودِ دکمه است. نشانگر مرتبسازی همیشه دیده میشود (نه فقط با
|
||||||
|
// هاور) تا از نگاه اول مشخص باشد ستون قابل مرتبسازی است: حالت غیرفعال با
|
||||||
|
// فلش دوگانهی خاکستری ملایم و حالت فعال با فلش جهتدار و رنگ برند.
|
||||||
|
function SortHeader({
|
||||||
|
field,
|
||||||
|
sort,
|
||||||
|
onCycle,
|
||||||
|
}: {
|
||||||
|
field: { key: SortKey; label: string };
|
||||||
|
sort: SortState | null;
|
||||||
|
onCycle: (key: SortKey) => void;
|
||||||
|
}) {
|
||||||
|
const active = sort?.key === field.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCycle(field.key)}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={LABELS.sortHint}
|
||||||
|
className={cn(
|
||||||
|
"group inline-flex w-full min-w-0 cursor-pointer select-none items-center justify-center gap-1 rounded-md px-1 py-1 text-[10px] font-bold transition-colors",
|
||||||
|
active ? "text-indigo-600" : "text-gray-400 hover:text-gray-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{field.label}</span>
|
||||||
|
{active ? (
|
||||||
|
sort.direction === "asc" ? (
|
||||||
|
<ArrowUp aria-hidden className="h-3 w-3 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown aria-hidden className="h-3 w-3 shrink-0" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<ArrowUpDown
|
||||||
|
aria-hidden
|
||||||
|
className="h-3 w-3 shrink-0 text-gray-300 transition-colors group-hover:text-gray-400"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— یک ردیف فقطخواندنی ———
|
||||||
|
function DeadStockRow({ item, maxDays }: { item: DeadStockItem; maxDays: number }) {
|
||||||
|
const specs = specLabel(item.specs);
|
||||||
|
const unitName = item.unit?.name_fa || item.unit?.code || "";
|
||||||
|
const days = item.idle_days ?? 0;
|
||||||
|
return (
|
||||||
|
<li className="rounded-xl border border-gray-100 bg-white p-3.5 shadow-xs transition-colors hover:border-gray-200">
|
||||||
|
<div className={ROW_GRID}>
|
||||||
|
{/* هویت */}
|
||||||
|
<div className="flex min-w-0 flex-col gap-1 text-start">
|
||||||
|
<span className="text-sm font-bold text-gray-900">
|
||||||
|
{item.material}
|
||||||
|
{specs && <span className="font-medium text-gray-400"> — {specs}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-gray-400">
|
||||||
|
{item.brand && (
|
||||||
|
<span>
|
||||||
|
{LABELS.brand}: {item.brand}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{LABELS.warehouse}: {item.warehouse}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{LABELS.project}: {item.project}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* سه متریک فقطخواندنی — در sm بهصورت contents داخل شبکهی ردیف
|
||||||
|
مینشینند تا دقیقاً زیر ستونهای هدر قرار بگیرند */}
|
||||||
|
<div className={METRICS_GRID}>
|
||||||
|
<div className={METRIC_CELL}>
|
||||||
|
<span className={METRIC_LABEL}>{LABELS.qty}</span>
|
||||||
|
<span dir="ltr" className={cn(METRIC_VALUE, "text-gray-800")}>
|
||||||
|
{fmtNum(item.quantity)} {unitName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={METRIC_CELL}>
|
||||||
|
<span className={METRIC_LABEL}>{LABELS.days}</span>
|
||||||
|
<span className={cn(METRIC_VALUE, severityClass(days, maxDays))}>
|
||||||
|
{item.idle_days == null ? LABELS.noDate : `${faNum(days)} ${LABELS.dayUnit}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={METRIC_CELL}>
|
||||||
|
<span className={METRIC_LABEL}>{LABELS.lastActivity}</span>
|
||||||
|
<span className={cn(METRIC_VALUE, "text-gray-800")}>
|
||||||
|
{item.last_activity
|
||||||
|
? isoToJalaliStr(item.last_activity) ?? LABELS.noDate
|
||||||
|
: LABELS.noDate}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeadStockTable() {
|
||||||
|
const query = useDeadStock(ALL_ITEMS_IDLE_DAYS);
|
||||||
|
const items = query.data?.items ?? [];
|
||||||
|
const [sort, setSort] = useState<SortState | null>(null);
|
||||||
|
|
||||||
|
const maxDays = useMemo(
|
||||||
|
() => Math.max(1, ...items.map((item) => item.idle_days ?? 0)),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedItems = useMemo(() => sortItems(items, sort), [items, sort]);
|
||||||
|
|
||||||
|
const cycleSort = (key: SortKey) => setSort((prev) => nextSortState(prev, key));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Snowflake className="h-4 w-4 text-[#0ea5e9]" />
|
||||||
|
موجودی راکد — سرمایهی خوابیده
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
sub={LABELS.sub}
|
||||||
|
>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<ChartEmpty message={LABELS.empty} />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className={cn(
|
||||||
|
"overflow-y-auto scroll-smooth pe-1",
|
||||||
|
MAX_LIST_HEIGHT,
|
||||||
|
SCROLLBAR_CLASSES,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* هدر و ردیفها در یک اسکرولکانتینر مشترکاند؛ پس عرض محتوا و گاتر
|
||||||
|
اسکرولبار برای هر دو یکسان است و ستونهای هدر دقیقاً روی ستونهای
|
||||||
|
هر ردیف مینشینند. هدر sticky است تا هنگام پیمایش دیده شود. */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
HEADER_GRID,
|
||||||
|
"sticky top-0 z-10 border-b border-gray-100 bg-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* ستون هویت در دسکتاپ؛ در موبایل فضای خالی لازم نیست */}
|
||||||
|
<span aria-hidden className="hidden sm:block" />
|
||||||
|
{SORT_FIELDS.map((field) => (
|
||||||
|
<SortHeader key={field.key} field={field} sort={sort} onCycle={cycleSort} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="flex flex-col gap-2.5 pt-2">
|
||||||
|
{sortedItems.map((item) => (
|
||||||
|
<DeadStockRow key={item.inventory_id} item={item} maxDays={maxDays} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,617 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۳ — سری زمانی جریان روزانه (نمودار آبشاری شناور، تکمتریالی)
|
||||||
|
// هر روز یک ستون شناور است که از «مانده ابتدای روز» تا «مانده پایان روز»
|
||||||
|
// کشیده میشود؛ اجزای روز (ورود، برگشت، خروج به سایت، ضایعات، سایر حرکات)
|
||||||
|
// روی مسیر واقعی مانده مینشینند، پس ستون دقیقاً پلهی خط stepAfter را میسازد.
|
||||||
|
//
|
||||||
|
// قواعد کسبوکار:
|
||||||
|
// • جمعزدن چند متریال با واحدهای متفاوت (متر + کیلوگرم + عدد) بیمعناست؛ پس
|
||||||
|
// بدون انتخاب متریال، نمودار رندر نمیشود و بهجایش انتخاب سریع متریال میآید.
|
||||||
|
// • هر دسته (ورود/برگشت/خروج/ضایعات/سایر) میتواند چند «گروه» (مشخصات + برند)
|
||||||
|
// داشته باشد؛ هر گروه یک پلهی روشنتر از خانوادهی رنگ همان دسته میگیرد و
|
||||||
|
// سگمنتها یک ستون مشترک با جداکنندهی سفید ۱px میسازند.
|
||||||
|
// • همهی موجودیتها (متریال/مشخصات/برند/انبار/واحد) از payload مشتق میشوند.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
Line,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { Package } from "lucide-react";
|
||||||
|
import { useDailyFlow, useDashboardFilterOptions } from "../../hooks/useDashboardData";
|
||||||
|
import { useDashboardFilter } from "../../store/dashboardFilter";
|
||||||
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
|
import { fmtNum } from "../../lib/formatters";
|
||||||
|
import { ChartCard, ChartEmpty, ChartLoading, faAxisTick } from "./chartShared";
|
||||||
|
import type { FlowBreakdownItem, FlowCategory, FlowDay } from "../../lib/types";
|
||||||
|
|
||||||
|
// ——— چیدمان ———
|
||||||
|
const FLOW_STACK_ID = "flow-waterfall";
|
||||||
|
const MAX_BAR_SIZE = 20;
|
||||||
|
const BAR_CATEGORY_GAP = "18%";
|
||||||
|
const DOMAIN_PAD_RATIO = 0.1; // ۱۰٪ سرصفحه بالا/پایین دامنه
|
||||||
|
const OTHER_RESIDUAL_KEY = "__adjust__";
|
||||||
|
|
||||||
|
const CATEGORY_ORDER: FlowCategory[] = ["inflow", "returns", "siteOut", "waste", "other"];
|
||||||
|
|
||||||
|
// خانوادهی رنگی هر دسته (HSL پایه) + جهت علامت در مسیر مانده
|
||||||
|
const CATEGORY_META: Record<
|
||||||
|
FlowCategory,
|
||||||
|
{ label: string; sign: 1 | -1; base: { h: number; s: number; l: number } }
|
||||||
|
> = {
|
||||||
|
inflow: { label: "ورود به انبار", sign: 1, base: { h: 160, s: 84, l: 39 } }, // زمردی
|
||||||
|
returns: { label: "برگشت از سایت", sign: 1, base: { h: 175, s: 77, l: 41 } }, // تیل
|
||||||
|
siteOut: { label: "خروج به سایت", sign: -1, base: { h: 35, s: 92, l: 47 } }, // کهربایی
|
||||||
|
waste: { label: "ضایعات و اتلاف", sign: -1, base: { h: 0, s: 72, l: 51 } }, // قرمز
|
||||||
|
other: { label: "سایر حرکات", sign: 1, base: { h: 215, s: 16, l: 55 } }, // خاکستری
|
||||||
|
};
|
||||||
|
|
||||||
|
// تنالیتهی گروههای یک دسته: هر گروه (مشخصات+برند) یک پله روشنتر
|
||||||
|
const SHADE_STEP_L = 9;
|
||||||
|
const SHADE_MAX_L = 78;
|
||||||
|
function categoryColor(category: FlowCategory, shadeIndex: number): string {
|
||||||
|
const { h, s, l } = CATEGORY_META[category].base;
|
||||||
|
const lightness = Math.min(SHADE_MAX_L, l + shadeIndex * SHADE_STEP_L);
|
||||||
|
return `hsl(${h} ${s}% ${lightness}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABELS = {
|
||||||
|
title: "جریان روزانه موجودی",
|
||||||
|
needMaterial:
|
||||||
|
"برای مشاهدهی جریان روزانه و تحلیل دقیق تغییرات موجودی، لطفاً یک متریال را از فیلترهای بالا انتخاب کنید.",
|
||||||
|
quickPick: "انتخاب سریع متریال",
|
||||||
|
empty: "در این بازه جابهجایی موجودی ثبت نشده است",
|
||||||
|
opening: "مانده ابتدای روز",
|
||||||
|
net: "تغییر خالص روز",
|
||||||
|
closing: "مانده پایان روز",
|
||||||
|
unit: "واحد",
|
||||||
|
brand: "برند",
|
||||||
|
warehouse: "انبار",
|
||||||
|
noSpecs: "بدون مشخصه",
|
||||||
|
aggregate: "کل دسته",
|
||||||
|
adjustment: "تعدیل حساب",
|
||||||
|
shadeHint: "طیف روشنتر هر رنگ = برند/مشخصات دیگر در همان روز",
|
||||||
|
error: "دریافت دادهی جریان روزانه ناموفق بود",
|
||||||
|
errorHint: "اتصال به سرور را بررسی کنید؛ اگر ادامه داشت با مدیر سیستم تماس بگیرید.",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ——— مدل داده ———
|
||||||
|
type Range = [number, number]; // [پایین، بالا] — سگمنت شناور <Bar>
|
||||||
|
|
||||||
|
interface CatItem {
|
||||||
|
key: string; // هویت گروه: مشخصات|برند
|
||||||
|
specLabel: string;
|
||||||
|
brand: string | null;
|
||||||
|
warehouse: string;
|
||||||
|
quantity: number; // چهار دسته: قدر مطلق — «سایر»: علامتدار
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CatGroup {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
brand: string | null;
|
||||||
|
quantity: number; // مجموع گروه (برای «سایر» علامتدار)
|
||||||
|
items: CatItem[]; // ریزردیفها برای تولتیپ
|
||||||
|
}
|
||||||
|
|
||||||
|
type CatMap = Record<FlowCategory, CatGroup[]>;
|
||||||
|
|
||||||
|
interface FlowRow {
|
||||||
|
date: string;
|
||||||
|
opening: number;
|
||||||
|
closing: number;
|
||||||
|
net: number;
|
||||||
|
balance: number;
|
||||||
|
groups: CatMap; // متادیتای تولتیپ (dataKey نیست)
|
||||||
|
hasMovement: boolean;
|
||||||
|
// سگمنتها با کلیدهای مسطح seg_<category>_<shadeIndex>
|
||||||
|
[segmentKey: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const round4 = (v: number) => Math.round(v * 10000) / 10000;
|
||||||
|
const signed = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`;
|
||||||
|
|
||||||
|
// خطای شبکه/سرور باید صریح دیده شود — هرگز نباید «دادهای ثبت نشده» تلقّی شود
|
||||||
|
function FlowError() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="flex h-40 flex-col items-center justify-center gap-1 rounded-xl border border-dashed border-red/30 bg-red-bg/40 px-4 text-center"
|
||||||
|
>
|
||||||
|
<span className="text-[12px] font-bold text-red">{LABELS.error}</span>
|
||||||
|
<span className="max-w-md text-[11px] leading-5 text-muted">{LABELS.errorHint}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// برچسب مشخصات فنی: «کلید: مقدار، …» یا «بدون مشخصه»
|
||||||
|
function specLabel(specs: Record<string, string> | null): string {
|
||||||
|
const parts = Object.entries(specs ?? {});
|
||||||
|
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : LABELS.noSpecs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— تولتیپ تحلیلی RTL ———
|
||||||
|
function FlowTooltip({
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
unitLabel,
|
||||||
|
materialName,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: readonly { payload?: FlowRow }[];
|
||||||
|
unitLabel: string;
|
||||||
|
materialName: string;
|
||||||
|
}) {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const row = payload[0]?.payload;
|
||||||
|
if (!row) return null;
|
||||||
|
const withUnit = (value: string) => (unitLabel ? `${value} ${unitLabel}` : value);
|
||||||
|
const netTone =
|
||||||
|
row.net > 0 ? "text-emerald-600" : row.net < 0 ? "text-red-600" : "text-slate-600";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="w-max max-w-[85vw] whitespace-normal rounded-lg border border-slate-200 bg-white/95 p-3 shadow-lg backdrop-blur-md sm:max-w-[380px] sm:whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{/* سربرگ: تاریخ + متریال + نشان واحد */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs font-bold text-slate-800">
|
||||||
|
{isoToJalaliStr(row.date) ?? row.date}
|
||||||
|
</span>
|
||||||
|
{materialName && <span className="text-[11px] text-slate-500">{materialName}</span>}
|
||||||
|
{unitLabel && (
|
||||||
|
<span className="rounded-full bg-slate-100 px-1.5 py-0.5 text-[10px] font-semibold text-slate-500">
|
||||||
|
{unitLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-2 border-b border-slate-100" />
|
||||||
|
|
||||||
|
{/* مانده ابتدای روز */}
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="text-[11px] text-slate-500 sm:text-xs">{LABELS.opening}</span>
|
||||||
|
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
|
||||||
|
{withUnit(fmtNum(row.opening))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-2 border-b border-slate-100" />
|
||||||
|
|
||||||
|
{/* دستهها + ریزردیف مشخصات/برند/انبار */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{CATEGORY_ORDER.map((category) => {
|
||||||
|
const groups = row.groups[category] ?? [];
|
||||||
|
if (groups.length === 0) return null;
|
||||||
|
const total = round4(
|
||||||
|
groups.reduce((sum, group) => sum + group.quantity, 0) *
|
||||||
|
CATEGORY_META[category].sign,
|
||||||
|
);
|
||||||
|
if (total === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={category} className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="flex items-center gap-2 text-[11px] font-semibold text-slate-700 sm:text-xs">
|
||||||
|
<span
|
||||||
|
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||||
|
style={{ background: categoryColor(category, 0) }}
|
||||||
|
/>
|
||||||
|
{CATEGORY_META[category].label}
|
||||||
|
</span>
|
||||||
|
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
|
||||||
|
{withUnit(signed(total))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.key} className="flex flex-col">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`${group.key}|${item.warehouse}|${item.specLabel}`}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-0.5 border-s border-slate-100 ps-3 text-[10.5px] text-slate-500 sm:text-[11px]"
|
||||||
|
>
|
||||||
|
<span className="flex flex-wrap items-center gap-1">
|
||||||
|
<span aria-hidden>↳</span>
|
||||||
|
<span>{item.specLabel}</span>
|
||||||
|
{item.brand && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-300">|</span>
|
||||||
|
<span>
|
||||||
|
{LABELS.brand}: {item.brand}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.warehouse && (
|
||||||
|
<span className="text-slate-400">
|
||||||
|
({LABELS.warehouse}: {item.warehouse})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span dir="ltr" className="font-medium tabular-nums text-slate-600">
|
||||||
|
{withUnit(signed(round4(item.quantity * CATEGORY_META[category].sign)))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-2 border-b border-slate-100" />
|
||||||
|
|
||||||
|
{/* جمعبندی */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="text-[11px] font-semibold text-slate-600 sm:text-xs">{LABELS.net}</span>
|
||||||
|
<span dir="ltr" className={`text-[11px] font-bold tabular-nums sm:text-xs ${netTone}`}>
|
||||||
|
{withUnit(signed(row.net))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-6">
|
||||||
|
<span className="text-[11px] font-semibold text-slate-600 sm:text-xs">{LABELS.closing}</span>
|
||||||
|
<span dir="ltr" className="text-[11px] font-bold tabular-nums text-slate-900 sm:text-xs">
|
||||||
|
{withUnit(fmtNum(row.closing))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— نرمالسازی یک روز: گروهبندی ریزردیفها بر اساس (دسته، مشخصات+برند) ———
|
||||||
|
function dayGroups(day: FlowDay): CatMap {
|
||||||
|
const groups: CatMap = { inflow: [], returns: [], siteOut: [], waste: [], other: [] };
|
||||||
|
const insert = (category: FlowCategory, item: CatItem) => {
|
||||||
|
const existing = groups[category].find((group) => group.key === item.key);
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity = round4(existing.quantity + item.quantity);
|
||||||
|
existing.items.push(item);
|
||||||
|
} else {
|
||||||
|
groups[category].push({
|
||||||
|
key: item.key,
|
||||||
|
label: item.specLabel,
|
||||||
|
brand: item.brand,
|
||||||
|
quantity: item.quantity,
|
||||||
|
items: [item],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const breakdown: FlowBreakdownItem[] = day.breakdown ?? [];
|
||||||
|
if (breakdown.length > 0) {
|
||||||
|
breakdown.forEach((item) => {
|
||||||
|
// چهار سبد نمایشی همیشه مثبت نشان داده میشوند؛ «سایر» علامت دفتر را نگه میدارد
|
||||||
|
const quantity = item.category === "other" ? item.quantity : Math.abs(item.quantity);
|
||||||
|
if (quantity === 0) return;
|
||||||
|
insert(item.category, {
|
||||||
|
key: `${JSON.stringify(item.specs ?? {})}|${item.brand ?? ""}`,
|
||||||
|
specLabel: specLabel(item.specs),
|
||||||
|
brand: item.brand,
|
||||||
|
warehouse: item.warehouse,
|
||||||
|
quantity: round4(quantity),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// سازگاری عقبرو: اگر شکست چندبعدی در payload نباشد، یک گروهِ کلِ دسته ساخته میشود
|
||||||
|
const aggregates: [FlowCategory, number][] = [
|
||||||
|
["inflow", day.inbound],
|
||||||
|
["returns", day.returns],
|
||||||
|
["siteOut", Math.max(0, day.outbound - day.waste)],
|
||||||
|
["waste", day.waste],
|
||||||
|
];
|
||||||
|
aggregates.forEach(([category, quantity]) => {
|
||||||
|
if (quantity > 0) {
|
||||||
|
insert(category, {
|
||||||
|
key: `${category}-all`,
|
||||||
|
specLabel: LABELS.aggregate,
|
||||||
|
brand: null,
|
||||||
|
warehouse: "",
|
||||||
|
quantity: round4(quantity),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlowChart() {
|
||||||
|
const filter = useDashboardFilter();
|
||||||
|
// بدون خانوادهی متریال، کوئری اصلاً اجرا نمیشود (تجمیع واحدهای ناسازگار ممنوع)
|
||||||
|
const query = useDailyFlow(undefined, undefined, Boolean(filter.materialFamilyId));
|
||||||
|
const options = useDashboardFilterOptions();
|
||||||
|
|
||||||
|
const families = options.data?.families ?? [];
|
||||||
|
const selectedFamily = families.find((family) => family.id === filter.materialFamilyId) ?? null;
|
||||||
|
const materialName = selectedFamily?.name ?? filter.materialFamilyLabel ?? "";
|
||||||
|
const unitLabel =
|
||||||
|
query.data?.unit?.name_fa ??
|
||||||
|
query.data?.unit?.code ??
|
||||||
|
selectedFamily?.unit?.name_fa ??
|
||||||
|
selectedFamily?.unit?.code ??
|
||||||
|
"";
|
||||||
|
|
||||||
|
// ——— همسازماندهی روزها + سگمنتهای شناور ———
|
||||||
|
const { rows, groupCounts, hasMovement } = useMemo(() => {
|
||||||
|
const days = query.data?.days ?? [];
|
||||||
|
let opening = query.data?.opening_balance ?? 0;
|
||||||
|
|
||||||
|
// گذر اول: نرمالسازی + تعدیل دقیق حساب (ستون باید همیشه روی خط بنشیند)
|
||||||
|
const normalized = days.map((day) => {
|
||||||
|
const closing = day.balance;
|
||||||
|
const net = round4(closing - opening);
|
||||||
|
const groups = dayGroups(day);
|
||||||
|
const categorized = round4(
|
||||||
|
CATEGORY_ORDER.reduce((sum, category) => {
|
||||||
|
const categoryTotal = groups[category].reduce(
|
||||||
|
(groupSum, group) => groupSum + group.quantity,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return sum + categoryTotal * CATEGORY_META[category].sign;
|
||||||
|
}, 0),
|
||||||
|
);
|
||||||
|
const residual = round4(net - categorized);
|
||||||
|
if (residual !== 0) {
|
||||||
|
groups.other.push({
|
||||||
|
key: OTHER_RESIDUAL_KEY,
|
||||||
|
label: LABELS.adjustment,
|
||||||
|
brand: null,
|
||||||
|
quantity: residual,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const normalizedDay = { day, opening, closing, net, groups };
|
||||||
|
opening = closing;
|
||||||
|
return normalizedDay;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ترتیب گروهها (و در نتیجه پلهی رنگ) در کل بازه ثابت میماند
|
||||||
|
const totalByCategory = new Map<FlowCategory, Map<string, number>>();
|
||||||
|
normalized.forEach(({ groups }) => {
|
||||||
|
CATEGORY_ORDER.forEach((category) => {
|
||||||
|
const perCategory = totalByCategory.get(category) ?? new Map<string, number>();
|
||||||
|
groups[category].forEach((group) => {
|
||||||
|
perCategory.set(group.key, round4((perCategory.get(group.key) ?? 0) + Math.abs(group.quantity)));
|
||||||
|
});
|
||||||
|
totalByCategory.set(category, perCategory);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const orderByCategory = new Map<FlowCategory, string[]>();
|
||||||
|
const groupCounts: Record<FlowCategory, number> = {
|
||||||
|
inflow: 0,
|
||||||
|
returns: 0,
|
||||||
|
siteOut: 0,
|
||||||
|
waste: 0,
|
||||||
|
other: 0,
|
||||||
|
};
|
||||||
|
CATEGORY_ORDER.forEach((category) => {
|
||||||
|
const orderedKeys = [...(totalByCategory.get(category) ?? new Map<string, number>()).entries()]
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([key]) => key);
|
||||||
|
orderByCategory.set(category, orderedKeys);
|
||||||
|
groupCounts[category] = orderedKeys.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// گذر دوم: هر گروه یک سگمنت [پایین، بالا] روی مسیر مانده میگیرد
|
||||||
|
const rows: FlowRow[] = normalized.map(({ day, opening: dayOpening, closing, net, groups }) => {
|
||||||
|
const row: FlowRow = {
|
||||||
|
date: day.date,
|
||||||
|
opening: dayOpening,
|
||||||
|
closing,
|
||||||
|
net,
|
||||||
|
balance: closing,
|
||||||
|
groups,
|
||||||
|
hasMovement: false,
|
||||||
|
};
|
||||||
|
let cursor = dayOpening;
|
||||||
|
CATEGORY_ORDER.forEach((category) => {
|
||||||
|
const orderedKeys = orderByCategory.get(category) ?? [];
|
||||||
|
const sign = CATEGORY_META[category].sign;
|
||||||
|
orderedKeys.forEach((key, index) => {
|
||||||
|
const group = groups[category].find((item) => item.key === key);
|
||||||
|
let range: Range = [cursor, cursor];
|
||||||
|
if (group && group.quantity !== 0) {
|
||||||
|
const from = cursor;
|
||||||
|
cursor = round4(cursor + group.quantity * sign);
|
||||||
|
range = [Math.min(from, cursor), Math.max(from, cursor)];
|
||||||
|
row.hasMovement = true;
|
||||||
|
}
|
||||||
|
row[`seg_${category}_${index}`] = range;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
groupCounts,
|
||||||
|
hasMovement: rows.some((row) => row.hasMovement || row.net !== 0),
|
||||||
|
};
|
||||||
|
}, [query.data]);
|
||||||
|
|
||||||
|
// ——— دامنهی محور Y با ۱۰٪ سرصفحه ———
|
||||||
|
const [domainMin, domainMax] = useMemo(() => {
|
||||||
|
const values: number[] = [];
|
||||||
|
rows.forEach((row) => {
|
||||||
|
values.push(row.balance, row.opening, row.closing);
|
||||||
|
CATEGORY_ORDER.forEach((category) => {
|
||||||
|
const count = groupCounts[category] ?? 0;
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const range = row[`seg_${category}_${index}`] as Range | undefined;
|
||||||
|
if (range) values.push(range[0], range[1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (values.length === 0) return [0, 1] as const;
|
||||||
|
const lo = Math.min(...values);
|
||||||
|
const hi = Math.max(...values);
|
||||||
|
const pad = (hi - lo) * DOMAIN_PAD_RATIO || 1;
|
||||||
|
return [Math.floor(lo - pad), Math.ceil(hi + pad)] as const;
|
||||||
|
}, [rows, groupCounts]);
|
||||||
|
|
||||||
|
const visibleCategories = CATEGORY_ORDER.filter((category) => (groupCounts[category] ?? 0) > 0);
|
||||||
|
const hasMultipleShades = visibleCategories.some((category) => groupCounts[category] > 1);
|
||||||
|
|
||||||
|
// ——— دروازهی تکمتریالی: بدون متریال، نمودار رندر نمیشود ———
|
||||||
|
if (!filter.materialFamilyId) {
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden [&>header]:shrink-0"
|
||||||
|
title={LABELS.title}
|
||||||
|
>
|
||||||
|
{options.isError ? (
|
||||||
|
<FlowError />
|
||||||
|
) : options.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="flex min-h-0 w-full flex-1 flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-line bg-slate-bg/40 px-4 py-7 text-center"
|
||||||
|
>
|
||||||
|
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-accent-bg text-accent">
|
||||||
|
<Package aria-hidden className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<p className="max-w-md text-[12.5px] leading-6 text-muted">{LABELS.needMaterial}</p>
|
||||||
|
{families.length > 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<span className="text-[11px] font-bold text-slate-500">{LABELS.quickPick}</span>
|
||||||
|
<div className="flex max-w-xl flex-wrap justify-center gap-2">
|
||||||
|
{families.map((family) => {
|
||||||
|
const familyUnit = family.unit?.name_fa ?? family.unit?.code ?? "";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={family.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => filter.setMaterialFamily(family.id, family.name)}
|
||||||
|
title={family.name}
|
||||||
|
className="inline-flex max-w-[16rem] cursor-pointer items-center gap-1.5 rounded-full border border-line bg-panel px-3 py-1.5 text-[11.5px] font-semibold text-ink shadow-sm transition-colors hover:border-accent hover:text-accent"
|
||||||
|
>
|
||||||
|
<span className="truncate">{family.name}</span>
|
||||||
|
{familyUnit && (
|
||||||
|
<span className="shrink-0 rounded-full bg-slate-bg px-1.5 text-[10px] font-medium text-muted">
|
||||||
|
{familyUnit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden [&>header]:shrink-0"
|
||||||
|
title={LABELS.title}
|
||||||
|
sub={`${materialName}${unitLabel ? ` — ${LABELS.unit}: ${unitLabel}` : ""}`}
|
||||||
|
>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : query.isError ? (
|
||||||
|
<FlowError />
|
||||||
|
) : !hasMovement ? (
|
||||||
|
<ChartEmpty message={LABELS.empty} />
|
||||||
|
) : (
|
||||||
|
<div dir="rtl" className="flex min-h-0 flex-1 flex-col gap-1">
|
||||||
|
{/* راهنمای رنگ دستهها (بهجای Legend پیشفرض؛ سریهای تنالیته معنا ندارند) */}
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
|
||||||
|
{visibleCategories.map((category) => (
|
||||||
|
<span key={category} className="inline-flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{ background: categoryColor(category, 0) }}
|
||||||
|
/>
|
||||||
|
{CATEGORY_META[category].label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{hasMultipleShades && (
|
||||||
|
<span className="text-[10px] text-slate-400">{LABELS.shadeHint}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div dir="ltr" className="min-h-0 w-full grow basis-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart
|
||||||
|
data={rows}
|
||||||
|
barCategoryGap={BAR_CATEGORY_GAP}
|
||||||
|
margin={{ top: 12, right: 12, left: 8, bottom: 4 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(v: string) => isoToJalaliStr(v) ?? v}
|
||||||
|
tick={{ fontSize: 10.5, fill: "#64748b", fontFamily: "Vazirmatn" }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#e5e9f0" }}
|
||||||
|
minTickGap={18}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[domainMin, domainMax]}
|
||||||
|
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||||
|
tickFormatter={faAxisTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={unitLabel ? 70 : 56}
|
||||||
|
label={
|
||||||
|
unitLabel
|
||||||
|
? {
|
||||||
|
value: unitLabel,
|
||||||
|
angle: -90,
|
||||||
|
position: "insideLeft",
|
||||||
|
style: { fontSize: 10, fill: "#64748b" },
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
||||||
|
content={<FlowTooltip unitLabel={unitLabel} materialName={materialName} />}
|
||||||
|
/>
|
||||||
|
{/* سگمنتهای شناور: هر گروه (مشخصات+برند) یک پله از خانوادهی رنگ
|
||||||
|
دسته، با جداکنندهی سفید ۱px و یک ستون مشترک */}
|
||||||
|
{CATEGORY_ORDER.map((category) =>
|
||||||
|
Array.from({ length: groupCounts[category] ?? 0 }, (_, index) => (
|
||||||
|
<Bar
|
||||||
|
key={`${category}-${index}`}
|
||||||
|
dataKey={`seg_${category}_${index}`}
|
||||||
|
fill={categoryColor(category, index)}
|
||||||
|
stroke="#ffffff"
|
||||||
|
strokeWidth={1}
|
||||||
|
stackId={FLOW_STACK_ID}
|
||||||
|
maxBarSize={MAX_BAR_SIZE}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
{/* خط پلهای مانده — همتراز هندسی با سرِ ستونهای شناور */}
|
||||||
|
<Line
|
||||||
|
type="stepAfter"
|
||||||
|
dataKey="balance"
|
||||||
|
name={LABELS.closing}
|
||||||
|
stroke="#0f172a"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
strokeDasharray="5 3"
|
||||||
|
dot={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۶ — شبکهی وضعیت سفارشهای در راه
|
||||||
|
// جدول کمی سفارشهای باز: متریال و تأمینکننده | سفارششده | رسیده
|
||||||
|
// | وضعیت (در راه / تحویل جزئی / تحویل کامل) + نوار پیشرفت.
|
||||||
|
// داده از /pm/on-the-way (با برچسب برند و مشخصات و فیلتر سراسری).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Truck } from "lucide-react";
|
||||||
|
import { useInTransit } from "../../hooks/useDashboardData";
|
||||||
|
import type { InTransitRow } from "../../lib/types";
|
||||||
|
import { faNum, fmtNum } from "../../lib/formatters";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../ui/table";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
|
||||||
|
|
||||||
|
const STATE_META: Record<
|
||||||
|
InTransitRow["state"],
|
||||||
|
{ label: string; cls: string; bar: string }
|
||||||
|
> = {
|
||||||
|
on_the_way: { label: "در راه", cls: "bg-amber-bg text-amber", bar: "bg-amber" },
|
||||||
|
partially_received: { label: "تحویل جزئی", cls: "bg-blue-bg text-[#1d4ed8]", bar: "bg-[#2563eb]" },
|
||||||
|
arrived: { label: "تحویل کامل", cls: "bg-green-bg text-green", bar: "bg-green" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function specLabel(specs: Record<string, string>): string {
|
||||||
|
const parts = Object.entries(specs || {});
|
||||||
|
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v: string): number {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InTransitGrid() {
|
||||||
|
const query = useInTransit();
|
||||||
|
const rows = query.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title={
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Truck className="h-4 w-4 text-muted" />
|
||||||
|
سفارشهای در راه — وضعیت تحویل
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
sub="اقلام پذیرفتهشدهی خرید تا رسیدن کامل به انبار"
|
||||||
|
>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<ChartEmpty message="سفارش بازی در جریان نیست" />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead>متریال / تأمینکننده</TableHead>
|
||||||
|
<TableHead className="text-end">سفارششده</TableHead>
|
||||||
|
<TableHead className="text-end">رسیده تاکنون</TableHead>
|
||||||
|
<TableHead className="min-w-[140px]">پیشرفت</TableHead>
|
||||||
|
<TableHead>وضعیت</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((r) => {
|
||||||
|
const ordered = num(r.ordered_qty);
|
||||||
|
const received = num(r.received_qty);
|
||||||
|
const pct = ordered > 0 ? Math.min(100, Math.round((received / ordered) * 100)) : 0;
|
||||||
|
const meta = STATE_META[r.state] ?? STATE_META.on_the_way;
|
||||||
|
const specs = specLabel(r.specs);
|
||||||
|
return (
|
||||||
|
<TableRow key={r.item_decision_id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-[13px] font-bold">{r.material.name}</div>
|
||||||
|
<div className="text-[11px] text-muted">
|
||||||
|
{r.supplier.name}
|
||||||
|
{r.brand ? ` · ${r.brand.name}` : ""}
|
||||||
|
</div>
|
||||||
|
{specs && <div className="text-[10.5px] text-muted">{specs}</div>}
|
||||||
|
{r.project && (
|
||||||
|
<div className="mt-0.5 text-[10.5px] text-accent">{r.project.name}</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-end font-bold tabular-nums">
|
||||||
|
{fmtNum(ordered)}
|
||||||
|
<div className="text-[10px] font-normal text-muted">
|
||||||
|
{r.unit?.name_fa || r.unit?.code}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-end font-bold tabular-nums">
|
||||||
|
{fmtNum(received)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-bg">
|
||||||
|
<div
|
||||||
|
className={cn("h-full rounded-full", meta.bar)}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-[10.5px] tabular-nums text-muted">
|
||||||
|
{faNum(pct)}٪
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={cn("px-2 py-0.5 text-[10.5px]", meta.cls)}>{meta.label}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۵ — KPI زمان تأمین (Lead Time)
|
||||||
|
// میانگین روزهای «پذیرش رسمی سفارش → رسیدن فیزیکی به انبار»
|
||||||
|
// از /quotes/stats (میانگین arrived_date − accepted_date).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Timer } from "lucide-react";
|
||||||
|
import { useQuoteStats } from "../../hooks/useDashboardData";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
import { ChartCard, ChartLoading } from "./chartShared";
|
||||||
|
|
||||||
|
export function LeadTimeKpiCard() {
|
||||||
|
const query = useQuoteStats();
|
||||||
|
const days = query.data?.average_lead_time_days;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard title="میانگین زمان تأمین" sub="از پذیرش رسمی سفارش تا رسیدن فیزیکی به انبار">
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-accent-bg text-accent">
|
||||||
|
<Timer className="h-7 w-7" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<span className="text-[38px] font-extrabold leading-none tabular-nums text-ink">
|
||||||
|
{days == null ? "—" : faNum(days)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[14px] font-bold text-muted">روز</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] text-muted">
|
||||||
|
{days == null
|
||||||
|
? "هنوز سفارشی به مرحلهی رسیدن نرسیده است"
|
||||||
|
: `بر پایهی سفارشهای رسیدهشده به انبار`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۷ — قیف تدارکات
|
||||||
|
// تبدیل تجمعی: کل درخواستها (پیشنهادشده) → پذیرفتهشده → رسیده به انبار
|
||||||
|
// از /quotes/stats.funnel (شمارش تجمعی بر پایهی تاریخهای واقعی).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import { useQuoteStats } from "../../hooks/useDashboardData";
|
||||||
|
import { faNum, fmtNum } from "../../lib/formatters";
|
||||||
|
import { CHART_COLORS, ChartCard, ChartEmpty, ChartLoading, ChartTooltip } from "./chartShared";
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
fill: string;
|
||||||
|
conversion: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProcurementFunnel() {
|
||||||
|
const query = useQuoteStats();
|
||||||
|
|
||||||
|
const steps: Step[] = useMemo(() => {
|
||||||
|
const f = query.data?.funnel;
|
||||||
|
if (!f) return [];
|
||||||
|
const offered = f.offered_total;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "کل درخواستها (پیشنهادشده)",
|
||||||
|
value: f.offered_total,
|
||||||
|
fill: CHART_COLORS.accent,
|
||||||
|
conversion: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "پذیرفتهشده",
|
||||||
|
value: f.accepted_total,
|
||||||
|
fill: CHART_COLORS.blue,
|
||||||
|
conversion: offered > 0 ? Math.round((f.accepted_total / offered) * 100) : 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "رسیده به انبار",
|
||||||
|
value: f.arrived_total,
|
||||||
|
fill: CHART_COLORS.green,
|
||||||
|
conversion: offered > 0 ? Math.round((f.arrived_total / offered) * 100) : 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [query.data]);
|
||||||
|
|
||||||
|
const total = steps[0]?.value ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title="قیف تدارکات"
|
||||||
|
sub="تبدیل درخواستها به سفارش پذیرفتهشده و رسیدن کالا به انبار"
|
||||||
|
>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : total === 0 ? (
|
||||||
|
<ChartEmpty message="هنوز درخواستی ثبت نشده است" />
|
||||||
|
) : (
|
||||||
|
<div className="grid items-center gap-3 sm:grid-cols-5">
|
||||||
|
<div dir="ltr" className="h-56 sm:col-span-3">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<FunnelChart>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltip
|
||||||
|
labelFormatter={(l) => String(l)}
|
||||||
|
valueFormatter={(v) => `${fmtNum(v)} درخواست`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Funnel dataKey="value" data={steps} isAnimationActive={false}>
|
||||||
|
{/* مقدار هر مرحله — با dataKey صریح تا Recharts مقدار درست بدهد */}
|
||||||
|
<LabelList
|
||||||
|
dataKey="value"
|
||||||
|
position="right"
|
||||||
|
fill="#0f172a"
|
||||||
|
stroke="none"
|
||||||
|
fontSize={12}
|
||||||
|
fontWeight={700}
|
||||||
|
formatter={(v: unknown) => {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? fmtNum(n) : "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* نام مرحله داخل نوار */}
|
||||||
|
<LabelList
|
||||||
|
dataKey="name"
|
||||||
|
position="left"
|
||||||
|
fill="#ffffff"
|
||||||
|
stroke="none"
|
||||||
|
fontSize={11}
|
||||||
|
formatter={(v: unknown) => (v == null ? "" : String(v))}
|
||||||
|
/>
|
||||||
|
</Funnel>
|
||||||
|
</FunnelChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* مراحل قیف + درصد تبدیل */}
|
||||||
|
<ul className="flex flex-col gap-2 sm:col-span-2">
|
||||||
|
{steps.map((s) => (
|
||||||
|
<li key={s.name} className="rounded-xl border border-line px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: s.fill }} />
|
||||||
|
<span className="text-[12px] font-bold">{s.name}</span>
|
||||||
|
<span className="ms-auto text-[15px] font-extrabold tabular-nums">
|
||||||
|
{fmtNum(s.value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{s.conversion != null && (
|
||||||
|
<div className="mt-0.5 text-[10.5px] text-muted">
|
||||||
|
{faNum(s.conversion)}٪ از کل درخواستها
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ویجت ۸ — تراکم پروژهها (Project Density)
|
||||||
|
// نمودار میلهای افقی: توزیع بار تدارکات روی پروژههای فعال —
|
||||||
|
// سفارشهای باز (ستهشده با پیشنهادهای باز).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { useProcurementWorkload } from "../../hooks/useDashboardData";
|
||||||
|
import { faNum } from "../../lib/formatters";
|
||||||
|
import {
|
||||||
|
CHART_COLORS,
|
||||||
|
ChartCard,
|
||||||
|
ChartEmpty,
|
||||||
|
ChartLoading,
|
||||||
|
ChartTooltip,
|
||||||
|
faAxisTick,
|
||||||
|
} from "./chartShared";
|
||||||
|
|
||||||
|
export function ProjectDensityChart() {
|
||||||
|
const query = useProcurementWorkload();
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const projects = query.data?.projects ?? [];
|
||||||
|
return projects
|
||||||
|
.filter((p) => p.open_orders > 0 || p.offered_quotes > 0 || p.chosen_items > 0)
|
||||||
|
.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
"سفارش باز (در راه)": p.open_orders,
|
||||||
|
"پیشنهاد باز": p.offered_quotes,
|
||||||
|
_ordered: p.ordered_qty,
|
||||||
|
_received: p.received_qty,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (b["سفارش باز (در راه)"] + b["پیشنهاد باز"]) - (a["سفارش باز (در راه)"] + a["پیشنهاد باز"]));
|
||||||
|
}, [query.data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCard
|
||||||
|
title="تراکم بار تدارکات روی پروژهها"
|
||||||
|
sub="تعداد سفارشهای باز و پیشنهادهای در جریان به تفکیک پروژه"
|
||||||
|
>
|
||||||
|
{query.isLoading ? (
|
||||||
|
<ChartLoading />
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<ChartEmpty message="بار تدارکاتی فعالی روی پروژهها وجود ندارد" />
|
||||||
|
) : (
|
||||||
|
<div dir="ltr" className="w-full" style={{ height: Math.max(180, rows.length * 56 + 60) }}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={rows}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ top: 8, right: 16, left: 8, bottom: 4 }}
|
||||||
|
barCategoryGap="28%"
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" horizontal={false} />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||||
|
tickFormatter={faAxisTick}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="category"
|
||||||
|
dataKey="name"
|
||||||
|
width={150}
|
||||||
|
tick={{ fontSize: 11, fill: "#0f172a", fontFamily: "Vazirmatn" }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
|
||||||
|
content={
|
||||||
|
<ChartTooltip
|
||||||
|
labelFormatter={(l) => String(l)}
|
||||||
|
valueFormatter={(v) => `${faNum(v)} مورد`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ direction: "rtl", width: "100%" }}
|
||||||
|
verticalAlign="top"
|
||||||
|
height={26}
|
||||||
|
iconType="circle"
|
||||||
|
iconSize={8}
|
||||||
|
formatter={(value: unknown) => (
|
||||||
|
<span dir="rtl" className="text-[11px] text-muted">
|
||||||
|
{String(value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="سفارش باز (در راه)"
|
||||||
|
stackId="load"
|
||||||
|
fill={CHART_COLORS.blue}
|
||||||
|
maxBarSize={22}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="پیشنهاد باز"
|
||||||
|
stackId="load"
|
||||||
|
fill={CHART_COLORS.amber}
|
||||||
|
radius={[0, 4, 4, 0]}
|
||||||
|
maxBarSize={22}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ChartCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ابزار مشترک نمودارها — اعداد فارسی، تاریخ شمسی، تولتیپ RTL
|
||||||
|
// همهی نمودارهای داشبورد از این قالب استفاده میکنند تا خروجی
|
||||||
|
// Recharts فارسی و هماهنگ با دیزاینسیستم (Tailwind @theme) باشد.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { groupCommas } from "../../lib/numutil";
|
||||||
|
import { isoToJalaliStr } from "../../lib/jalali";
|
||||||
|
import { faNum, fmtNum } from "../../lib/formatters";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
// پالت معنایی نمودارها — همراستا با توکنهای @theme در globals.css
|
||||||
|
export const CHART_COLORS = {
|
||||||
|
inbound: "#16a34a", // ورود = سبز
|
||||||
|
outbound: "#2563eb", // خروج = آبی
|
||||||
|
waste: "#dc2626", // ضایعات/اتلاف/مفقودی = قرمز (لایهی داخل ستون خروج)
|
||||||
|
returns: "#d97706", // برگشت از سایت = کهربایی
|
||||||
|
balance: "#0f172a", // مانده = خط سرمهای نقطهچین
|
||||||
|
accent: "#4f46e5", // ایندیگو — رنگ اصلی سیستم
|
||||||
|
amber: "#d97706",
|
||||||
|
green: "#16a34a",
|
||||||
|
red: "#dc2626",
|
||||||
|
blue: "#2563eb",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// عدد → رقم فارسی با جداکنندهی هزارگان (تیک محورها)
|
||||||
|
export function faAxisTick(value: unknown): string {
|
||||||
|
const n = typeof value === "number" ? value : Number(value);
|
||||||
|
if (!Number.isFinite(n)) return "";
|
||||||
|
if (Number.isInteger(n) && Math.abs(n) < 1000) return faNum(n);
|
||||||
|
return groupCommas(n, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO (YYYY-MM-DD) → تاریخ شمسی کوتاه برای محور X
|
||||||
|
export function jaAxisDate(iso: unknown): string {
|
||||||
|
const j = isoToJalaliStr(typeof iso === "string" ? iso : String(iso ?? ""));
|
||||||
|
return j || String(iso ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— تولتیپ سفارشی RTL — اعداد فارسی و نامهای فارسی سریها ———
|
||||||
|
interface TooltipEntry {
|
||||||
|
name?: string | number;
|
||||||
|
value?: string | number;
|
||||||
|
color?: string;
|
||||||
|
dataKey?: string | number;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: TooltipEntry[];
|
||||||
|
label?: string | number;
|
||||||
|
labelFormatter?: (label: string | number) => ReactNode;
|
||||||
|
valueFormatter?: (value: number, name: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartTooltip({ active, payload, label, labelFormatter, valueFormatter }: TooltipProps) {
|
||||||
|
if (!active || !payload || payload.length === 0) return null;
|
||||||
|
// ردیفهای صفر را از تولتیپ حذف میکنیم — شلوغیِ بیمعنا (اصل Power BI)
|
||||||
|
const rows = payload.filter((p) => {
|
||||||
|
const v = typeof p.value === "number" ? p.value : Number(p.value);
|
||||||
|
return Number.isFinite(v) && v !== 0;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
dir="rtl"
|
||||||
|
className="rounded-xl border border-line bg-panel/95 px-3 py-2 shadow-lg backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
{label !== undefined && label !== "" && (
|
||||||
|
<div className="mb-1 text-[11px] font-bold text-muted">
|
||||||
|
{labelFormatter ? labelFormatter(label) : String(label)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{rows.map((p, i) => {
|
||||||
|
const v = typeof p.value === "number" ? p.value : Number(p.value);
|
||||||
|
const name = String(p.name ?? p.dataKey ?? "");
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-[12px] leading-5">
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: p.color }} />
|
||||||
|
<span className="text-muted">{name}</span>
|
||||||
|
<span className="ms-auto font-bold tabular-nums text-ink">
|
||||||
|
{valueFormatter ? valueFormatter(v, name) : groupCommas(v, true)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— کارت نگهدارندهی ویجت — عنوان + توضیح + اکشن ———
|
||||||
|
export function ChartCard({
|
||||||
|
title,
|
||||||
|
sub,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title: ReactNode;
|
||||||
|
sub?: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className={cn("rounded-2xl border border-line bg-panel p-4", className)}>
|
||||||
|
<header className="mb-3 flex flex-wrap items-center gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="text-[14px] font-extrabold leading-6">{title}</h2>
|
||||||
|
{sub && <p className="text-[11px] text-muted">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="ms-auto flex items-center gap-1.5">{actions}</div>}
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— حالت خالی/خطای ویجت ———
|
||||||
|
export function ChartEmpty({ message = "دادهای برای نمایش وجود ندارد" }: { message?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-40 items-center justify-center rounded-xl border border-dashed border-line text-[12px] text-muted">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-40 items-center justify-center text-[12px] text-muted">
|
||||||
|
در حال بارگذاری...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ——— چیپ انتخاب واحد — با مجموع هر واحد تا تغییرِ نمودار فوراً دیده شود ———
|
||||||
|
export function UnitPicker({
|
||||||
|
units,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
totals,
|
||||||
|
}: {
|
||||||
|
units: Record<string, string>;
|
||||||
|
value: string;
|
||||||
|
onChange: (code: string) => void;
|
||||||
|
totals?: Record<string, number>;
|
||||||
|
}) {
|
||||||
|
const codes = Object.keys(units);
|
||||||
|
if (codes.length <= 1) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{codes.map((code) => {
|
||||||
|
const total = totals?.[code];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={code}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(code)}
|
||||||
|
title={"نمایش موجودی به " + (units[code] || code)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2.5 py-1 text-[11px] font-semibold transition-colors",
|
||||||
|
value === code
|
||||||
|
? "bg-accent text-white"
|
||||||
|
: "bg-slate-bg text-muted hover:text-ink",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{units[code] || code}
|
||||||
|
{total != null && (
|
||||||
|
<span className={cn("ms-1 tabular-nums", value === code ? "text-white/80" : "text-muted/80")}>
|
||||||
|
{fmtNum(total)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -101,6 +101,7 @@ export function MobileTabBar() {
|
|||||||
if (pathname === "/wizard" || pathname.startsWith("/review")) return null;
|
if (pathname === "/wizard" || pathname.startsWith("/review")) return null;
|
||||||
const onOfficePage =
|
const onOfficePage =
|
||||||
pathname === "/" ||
|
pathname === "/" ||
|
||||||
|
pathname === "/dashboard" ||
|
||||||
pathname === "/projects" ||
|
pathname === "/projects" ||
|
||||||
pathname === "/units" ||
|
pathname === "/units" ||
|
||||||
pathname.startsWith("/suppliers");
|
pathname.startsWith("/suppliers");
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// ============================================================
|
||||||
|
// هوکهای TanStack Query برای داشبورد MVP
|
||||||
|
// همهی ویجتها مستقیم به API واقعی وصلاند — بدون هیچ دادهی ساختگی.
|
||||||
|
// کلیدهای کوئری شامل پارامترهای فیلتر «چه چیزی» هستند تا با هر
|
||||||
|
// تغییر فیلتر، فقط ویجتهای همان تب دوباره خوانده شوند.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiV26 } from "../lib/api";
|
||||||
|
import type { DashboardFilterParams } from "../lib/api";
|
||||||
|
import { dashboardFilterParams, useDashboardFilter } from "../store/dashboardFilter";
|
||||||
|
|
||||||
|
function useFilterParams(): DashboardFilterParams {
|
||||||
|
const state = useDashboardFilter();
|
||||||
|
return dashboardFilterParams(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// گزینههای آبشاری فیلتر سراسری (دسته → متریال → برند → مشخصات)
|
||||||
|
export function useDashboardFilterOptions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "filters"],
|
||||||
|
queryFn: () => apiV26.dashboard.filters(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ویجت ۱ — ماتریس Drill-Down (کل سازمان → پروژه → انبار)
|
||||||
|
export function useDrilldown() {
|
||||||
|
const params = useFilterParams();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "drilldown", params],
|
||||||
|
queryFn: () => apiV26.dashboard.drilldown(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ویجت ۲ — موجودی راکد (dead stock)
|
||||||
|
export function useDeadStock(idleDays: number) {
|
||||||
|
const params = useFilterParams();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "dead-stock", idleDays, params],
|
||||||
|
queryFn: () => apiV26.dashboard.deadStock({ ...params, idle_days: idleDays }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ویجت ۳ — سری زمانی جریان روزانه
|
||||||
|
// فقط با متریال مشخص اجرا میشود: جمع چند متریال با واحدهای متفاوت بیمعناست.
|
||||||
|
export function useDailyFlow(dateFrom?: string, dateTo?: string, enabled = true) {
|
||||||
|
const params = useFilterParams();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "flow", dateFrom, dateTo, params],
|
||||||
|
queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ویجت ۴ — کنترل روزانه (امروز) — هر ۶۰ ثانیه تازهسازی میشود
|
||||||
|
export function useDailyControl(date?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "daily-control", date],
|
||||||
|
queryFn: () => apiV26.dashboard.dailyControl(date ? { date } : {}),
|
||||||
|
refetchInterval: 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ویجت ۵ + ۷ — KPI زمان تأمین و قیف تدارکات (/quotes/stats)
|
||||||
|
export function useQuoteStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "quote-stats"],
|
||||||
|
queryFn: () => apiV26.dashboard.quoteStats(),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ویجت ۶ — کالاهای در راه (با فیلتر متریال/برند سراسری)
|
||||||
|
export function useInTransit() {
|
||||||
|
const state = useDashboardFilter();
|
||||||
|
const params = dashboardFilterParams(state);
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "in-transit", params],
|
||||||
|
queryFn: () =>
|
||||||
|
apiV26.pm.onTheWay(null, { material_id: params.material_id, brand_id: params.brand_id }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ویجت ۸ — بار تدارکات روی پروژهها
|
||||||
|
export function useProcurementWorkload() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["dashboard", "workload"],
|
||||||
|
queryFn: () => apiV26.dashboard.workload(),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
+49
-6
@@ -19,23 +19,41 @@ import { useAuthStore } from "../store/auth";
|
|||||||
import type {
|
import type {
|
||||||
Attachment,
|
Attachment,
|
||||||
ConfirmedReceipt,
|
ConfirmedReceipt,
|
||||||
|
DashboardFilterOptions,
|
||||||
|
DailyControlData,
|
||||||
|
DeadStockItem,
|
||||||
|
DrilldownData,
|
||||||
|
FlowData,
|
||||||
|
InTransitRow,
|
||||||
InventoryRow,
|
InventoryRow,
|
||||||
InventoryTransaction,
|
InventoryTransaction,
|
||||||
Material,
|
Material,
|
||||||
PendingArrival,
|
|
||||||
Project,
|
Project,
|
||||||
Quote,
|
Quote,
|
||||||
|
QuoteStats,
|
||||||
StandardConversion,
|
StandardConversion,
|
||||||
Store,
|
Store,
|
||||||
Supplier,
|
Supplier,
|
||||||
TransactionType,
|
|
||||||
Warehouse,
|
|
||||||
ProjectTransfer,
|
ProjectTransfer,
|
||||||
ProjectTransferCreateIn,
|
ProjectTransferCreateIn,
|
||||||
ProjectTransferUpdateIn,
|
|
||||||
ProjectTransferReceiveIn,
|
ProjectTransferReceiveIn,
|
||||||
|
ProjectTransferUpdateIn,
|
||||||
|
TransactionType,
|
||||||
|
Warehouse,
|
||||||
|
WorkloadProject,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد
|
||||||
|
// (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد)
|
||||||
|
export type DashboardFilterParams = {
|
||||||
|
// خانوادهی متریال (نام پایه) — سرور به همهی واریانتهای خانواده map میکند
|
||||||
|
material_family?: string | null;
|
||||||
|
// واریانت مشخص (ردیف کاتالوگ) برای محدودکردن به یک سایز/گرید
|
||||||
|
material_id?: string | null;
|
||||||
|
brand_id?: string | null;
|
||||||
|
specs_hash?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
@@ -1887,8 +1905,12 @@ export const apiV26 = {
|
|||||||
|
|
||||||
pm: {
|
pm: {
|
||||||
// پروژه فعال بهصورت صریح ارسال میشود؛ بکاند تعلق آن به PM را بررسی میکند
|
// پروژه فعال بهصورت صریح ارسال میشود؛ بکاند تعلق آن به PM را بررسی میکند
|
||||||
onTheWay: async (projectId?: string | null): Promise<PendingArrival[]> =>
|
onTheWay: async (projectId?: string | null, params: { material_id?: string | null; brand_id?: string | null } = {}): Promise<InTransitRow[]> => {
|
||||||
getData<PendingArrival[]>(projectId ? `/pm/on-the-way?project_id=${projectId}` : "/pm/on-the-way"),
|
const qs = new URLSearchParams(
|
||||||
|
cleanParams({ project_id: projectId ?? activeProjectId(), ...params }),
|
||||||
|
).toString();
|
||||||
|
return getData<InTransitRow[]>(`/pm/on-the-way${qs ? `?${qs}` : ""}`);
|
||||||
|
},
|
||||||
confirmReceipt: async (body: {
|
confirmReceipt: async (body: {
|
||||||
item_decision_id: string; received_qty: number;
|
item_decision_id: string; received_qty: number;
|
||||||
allocations: { warehouse_id: string; quantity: number }[];
|
allocations: { warehouse_id: string; quantity: number }[];
|
||||||
@@ -1899,6 +1921,27 @@ export const apiV26 = {
|
|||||||
getData<ConfirmedReceipt[]>(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"),
|
getData<ConfirmedReceipt[]>(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---------- داشبورد MVP (v2.8) — تجمیعهای سمت سرور، فقطخواندنی ----------
|
||||||
|
// نقش/پروژه سمت بکاند از JWT (یا هدرهای dev) حل میشود؛ PM به پروژههای
|
||||||
|
// خودش محدود است و ادمین/مدیر تدارکات کل سازمان را میبیند.
|
||||||
|
dashboard: {
|
||||||
|
filters: async (): Promise<DashboardFilterOptions> =>
|
||||||
|
getData<DashboardFilterOptions>("/dashboard/filters"),
|
||||||
|
drilldown: async (params: DashboardFilterParams = {}): Promise<DrilldownData> =>
|
||||||
|
getData<DrilldownData>(`/dashboard/inventory/drilldown?${new URLSearchParams(cleanParams(params))}`),
|
||||||
|
deadStock: async (params: DashboardFilterParams & { idle_days?: number } = {}): Promise<{ idle_days_threshold: number; items: DeadStockItem[] }> =>
|
||||||
|
getData<{ idle_days_threshold: number; items: DeadStockItem[] }>(`/dashboard/inventory/dead-stock?${new URLSearchParams(cleanParams(params))}`),
|
||||||
|
flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<FlowData> =>
|
||||||
|
getData<FlowData>(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`),
|
||||||
|
dailyControl: async (params: { date?: string; warehouse_id?: string } = {}): Promise<DailyControlData> =>
|
||||||
|
getData<DailyControlData>(`/dashboard/daily-control?${new URLSearchParams(cleanParams(params))}`),
|
||||||
|
workload: async (): Promise<{ projects: WorkloadProject[] }> =>
|
||||||
|
getData<{ projects: WorkloadProject[] }>("/dashboard/procurement/workload"),
|
||||||
|
// KPI زمان تأمین + قیف تدارکات (endpoint آماری موجود /quotes/stats)
|
||||||
|
quoteStats: async (): Promise<QuoteStats> =>
|
||||||
|
getData<QuoteStats>("/quotes/stats"),
|
||||||
|
},
|
||||||
|
|
||||||
// ساخت موجودیتهای کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):
|
// ساخت موجودیتهای کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):
|
||||||
// متریال/برند/واحد جدید بدون وابستگی به دفتر تدارکات ساخته میشوند.
|
// متریال/برند/واحد جدید بدون وابستگی به دفتر تدارکات ساخته میشوند.
|
||||||
catalog: {
|
catalog: {
|
||||||
|
|||||||
+209
-1
@@ -195,6 +195,12 @@ export interface PendingArrival {
|
|||||||
state: "on_the_way" | "partially_received" | "arrived";
|
state: "on_the_way" | "partially_received" | "arrived";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2.6.1 — برند/مشخصههای فrozen قلم (اضافهشده به /pm/on-the-way برای داشبورد)
|
||||||
|
export interface InTransitRow extends PendingArrival {
|
||||||
|
brand: { id: string; name: string } | null;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConfirmedReceipt {
|
export interface ConfirmedReceipt {
|
||||||
receipt_id: string;
|
receipt_id: string;
|
||||||
quote_id: string;
|
quote_id: string;
|
||||||
@@ -208,6 +214,209 @@ export interface ConfirmedReceipt {
|
|||||||
allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[];
|
allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// v2.8 — داشبورد MVP (انبار + بازرگانی)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// — فیلتر سراسری «چه چیزی» —
|
||||||
|
export interface FilterSpecOption {
|
||||||
|
hash: string;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
// برچسب کوتاه مشخصه (مقدارها) — برای محور X و فهرست کشویی
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterMaterialOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
// تفکیک «خانواده» (نام پایه) از «واریانت» (سایز/گرید) — از API
|
||||||
|
family_id?: string;
|
||||||
|
family_name?: string;
|
||||||
|
variant_label?: string;
|
||||||
|
unit: { id: number; code: string; name_fa: string | null } | null;
|
||||||
|
brands: { id: string; name: string }[];
|
||||||
|
specs: FilterSpecOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// خانوادهی متریال: چند ردیف کاتالوگ با یک نام پایه (سایزها/گریدها زیرمجموعه)
|
||||||
|
export interface FilterFamilyOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
unit: { id: number; code: string; name_fa: string | null } | null;
|
||||||
|
brands: { id: string; name: string }[];
|
||||||
|
specs: { hash: string; label: string }[];
|
||||||
|
materials: { id: string; name: string; variant_label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardFilterOptions {
|
||||||
|
materials: FilterMaterialOption[];
|
||||||
|
families: FilterFamilyOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// — ماتریس Drill-Down (کل سازمان → پروژه → انبار) —
|
||||||
|
export type QtyByUnit = Record<string, number>;
|
||||||
|
|
||||||
|
export interface DrilldownWarehouse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
qty_by_unit: QtyByUnit;
|
||||||
|
rows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DrilldownProject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
qty_by_unit: QtyByUnit;
|
||||||
|
rows: number;
|
||||||
|
warehouses: DrilldownWarehouse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DrilldownMaterial {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
// ردیف کاتالوگ + برچسبهای محور (خانواده/واریانت/مشخصه) و برند
|
||||||
|
material_id?: string;
|
||||||
|
specs_hash?: string;
|
||||||
|
family_id?: string;
|
||||||
|
family_name?: string;
|
||||||
|
variant_label?: string;
|
||||||
|
spec_label?: string;
|
||||||
|
spec_detail?: string | null;
|
||||||
|
brand_name?: string | null;
|
||||||
|
qty_by_unit: QtyByUnit;
|
||||||
|
rows: number;
|
||||||
|
projects: DrilldownProject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DrilldownData {
|
||||||
|
units: Record<string, string>;
|
||||||
|
total: { qty_by_unit: QtyByUnit; rows: number };
|
||||||
|
materials: DrilldownMaterial[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// — جدول موجودی راکد —
|
||||||
|
export interface DeadStockItem {
|
||||||
|
inventory_id: string;
|
||||||
|
material: string;
|
||||||
|
brand: string | null;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
warehouse: string;
|
||||||
|
project: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: { code: string; name_fa: string | null };
|
||||||
|
last_activity: string | null;
|
||||||
|
idle_days: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// — سری زمانی جریان روزانه —
|
||||||
|
// دستههای نمایشی جریان: ورود، برگشت، خروج به سایت، ضایعات، و سایر حرکات
|
||||||
|
// (انتقال/شمارش/یافتشده که در چهار سبد نمایشی جا نمیگیرند)
|
||||||
|
export type FlowCategory = "inflow" | "returns" | "siteOut" | "waste" | "other";
|
||||||
|
|
||||||
|
// یک ریزردیف دفتر: نوع تراکنش + مشخصات + برند + انبار — پایهی شکست چندبعدی
|
||||||
|
export interface FlowBreakdownItem {
|
||||||
|
category: FlowCategory;
|
||||||
|
transaction_type: string;
|
||||||
|
specs: Record<string, string> | null;
|
||||||
|
brand: string | null;
|
||||||
|
warehouse: string;
|
||||||
|
quantity: number; // علامتدار مطابق دفتر (مثبت/منفی)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowDay {
|
||||||
|
date: string; // ISO (YYYY-MM-DD)
|
||||||
|
inbound: number;
|
||||||
|
outbound: number;
|
||||||
|
waste: number;
|
||||||
|
returns: number;
|
||||||
|
balance: number;
|
||||||
|
breakdown?: FlowBreakdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowData {
|
||||||
|
opening_balance: number;
|
||||||
|
// واحد کاتالوگی متریال انتخابشده (فقط وقتی یک متریال فیلتر شده باشد)
|
||||||
|
unit: { code: string; name_fa: string | null } | null;
|
||||||
|
days: FlowDay[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// — کنترل روزانه —
|
||||||
|
export interface DailyControlUnitStat {
|
||||||
|
code: string;
|
||||||
|
name_fa: string | null;
|
||||||
|
qty: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// آمار هر دستهی عملیاتی: تعداد تراکنش، تعداد قلم کالای متمایز و تفکیک واحدها
|
||||||
|
export interface DailyControlCategoryStats {
|
||||||
|
tickets: number;
|
||||||
|
items: number;
|
||||||
|
qty: number;
|
||||||
|
units: DailyControlUnitStat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DailyControlPersonRole = "performer" | "recipient";
|
||||||
|
|
||||||
|
export interface DailyControlPerson {
|
||||||
|
name: string;
|
||||||
|
role: DailyControlPersonRole;
|
||||||
|
tickets: number;
|
||||||
|
categories: { outbound: number; returns: number; waste: number; purchases: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyControlItem {
|
||||||
|
id: string;
|
||||||
|
transaction_type: string;
|
||||||
|
quantity: number; // علامتدار مطابق دفتر
|
||||||
|
unit_code: string;
|
||||||
|
unit_name_fa: string | null;
|
||||||
|
performed_by: string | null;
|
||||||
|
delivered_to: string | null;
|
||||||
|
brand: string | null;
|
||||||
|
note: string | null;
|
||||||
|
warehouse: string;
|
||||||
|
material: string;
|
||||||
|
specs: Record<string, string>;
|
||||||
|
transaction_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyControlData {
|
||||||
|
date: string;
|
||||||
|
outbound: DailyControlCategoryStats;
|
||||||
|
returns: DailyControlCategoryStats;
|
||||||
|
waste: DailyControlCategoryStats;
|
||||||
|
purchases: DailyControlCategoryStats;
|
||||||
|
people: DailyControlPerson[];
|
||||||
|
items: DailyControlItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// — بازرگانی: قیف تدارکات + KPI زمان تأمین (/quotes/stats) —
|
||||||
|
export interface QuoteStats {
|
||||||
|
total: number;
|
||||||
|
offered: number;
|
||||||
|
accepted: number;
|
||||||
|
arrived: number;
|
||||||
|
rejected: number;
|
||||||
|
expired: number;
|
||||||
|
average_lead_time_days: number | null;
|
||||||
|
funnel: {
|
||||||
|
offered_total: number;
|
||||||
|
accepted_total: number;
|
||||||
|
arrived_total: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// — بار تدارکات روی پروژهها (Project Density) —
|
||||||
|
export interface WorkloadProject {
|
||||||
|
id: string | null;
|
||||||
|
name: string;
|
||||||
|
offered_quotes: number;
|
||||||
|
chosen_items: number;
|
||||||
|
open_orders: number;
|
||||||
|
ordered_qty: number;
|
||||||
|
received_qty: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectTransfer {
|
export interface ProjectTransfer {
|
||||||
id: string;
|
id: string;
|
||||||
from_project: { id: string; name: string };
|
from_project: { id: string; name: string };
|
||||||
@@ -255,4 +464,3 @@ export interface ProjectTransferReceiveIn {
|
|||||||
received_at?: string | null;
|
received_at?: string | null;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
|
import { Users, FolderKanban, Ruler, Plus, ChartColumnBig } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useMaterials,
|
useMaterials,
|
||||||
useProjects,
|
useProjects,
|
||||||
@@ -94,6 +94,15 @@ export function DashboardPage() {
|
|||||||
menu={<AppSwitcherMenu activeModule="procurement" />}
|
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||||
actions={
|
actions={
|
||||||
<div className="hidden md:flex items-center gap-2">
|
<div className="hidden md:flex items-center gap-2">
|
||||||
|
{/* داشبورد تحلیلی — دکمهی مستقل، همسبک با «پروژهها» و «ضرایب تبدیل» */}
|
||||||
|
<Link
|
||||||
|
to="/dashboard"
|
||||||
|
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 text-xs font-semibold hover:bg-gray-50 dark:hover:bg-zinc-700 shadow-2xs transition-all no-underline"
|
||||||
|
>
|
||||||
|
<ChartColumnBig className="h-4 w-4 text-gray-500 dark:text-zinc-400" />
|
||||||
|
<span>داشبورد</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Link to="/suppliers" className="btn-line-file no-underline">
|
<Link to="/suppliers" className="btn-line-file no-underline">
|
||||||
<Users className="h-3.5 w-3.5" />
|
<Users className="h-3.5 w-3.5" />
|
||||||
تأمینکنندهها
|
تأمینکنندهها
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// ============================================================
|
||||||
|
// داشبورد MVP — نمای «انبار» و نمای «بازرگانی و لجستیک»
|
||||||
|
// فیلتر سراسری «چه چیزی» بالای هر دو نما (Zustand — با جابهجایی
|
||||||
|
// بین تبها حفظ میشود). تمام ویجتها مستقیم به API واقعی وصلاند.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { BarChart3, Warehouse } from "lucide-react";
|
||||||
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
|
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||||
|
import { DashboardFilterBar } from "../../components/dashboard/DashboardFilterBar";
|
||||||
|
import { DrilldownMatrix } from "../../components/dashboard/DrilldownMatrix";
|
||||||
|
import { FlowChart } from "../../components/dashboard/FlowChart";
|
||||||
|
import { DeadStockTable } from "../../components/dashboard/DeadStockTable";
|
||||||
|
import { DailyControlPanel } from "../../components/dashboard/DailyControlPanel";
|
||||||
|
import { LeadTimeKpiCard } from "../../components/dashboard/LeadTimeKpiCard";
|
||||||
|
import { InTransitGrid } from "../../components/dashboard/InTransitGrid";
|
||||||
|
import { ProcurementFunnel } from "../../components/dashboard/ProcurementFunnel";
|
||||||
|
import { ProjectDensityChart } from "../../components/dashboard/ProjectDensityChart";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
type Tab = "warehouse" | "procurement";
|
||||||
|
|
||||||
|
const TABS: { key: Tab; label: string; icon: typeof Warehouse }[] = [
|
||||||
|
{ key: "warehouse", label: "داشبورد انبار", icon: Warehouse },
|
||||||
|
{ key: "procurement", label: "داشبورد بازرگانی و لجستیک", icon: BarChart3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MvpDashboardPage() {
|
||||||
|
const [tab, setTab] = useState<Tab>("warehouse");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen pb-20 md:pb-6">
|
||||||
|
<Header
|
||||||
|
title="داشبورد تحلیلی"
|
||||||
|
sub="انبار و تدارکات — فیلتر جنس روی همهی ویجتها اعمال میشود"
|
||||||
|
brand
|
||||||
|
menu={<AppSwitcherMenu activeModule="procurement" />}
|
||||||
|
actions={
|
||||||
|
/* تبهای نما — سگمنتکنترل هماهنگ با دیزاینسیستم */
|
||||||
|
<div className="flex items-center gap-1 rounded-xl border border-line bg-slate-bg p-1">
|
||||||
|
{TABS.map(({ key, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(key)}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[12px] font-bold transition-all",
|
||||||
|
tab === key
|
||||||
|
? "bg-panel text-accent shadow-sm"
|
||||||
|
: "text-muted hover:text-ink",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
|
||||||
|
{/* فیلتر سراسری «چه چیزی» — مشترک بین دو نما */}
|
||||||
|
<DashboardFilterBar />
|
||||||
|
|
||||||
|
{tab === "warehouse" ? (
|
||||||
|
/* ——— نمای A: انبار ——— */
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* ردیف اول: ماتریس Drill-Down (تمام عرض — ستون فقرات نما) */}
|
||||||
|
<DrilldownMatrix />
|
||||||
|
{/* ردیف دوم: سری زمانی + کنترل روزانه */}
|
||||||
|
<div className="grid h-auto min-w-0 items-stretch gap-3 lg:h-[550px] xl:grid-cols-2">
|
||||||
|
<FlowChart />
|
||||||
|
<DailyControlPanel />
|
||||||
|
</div>
|
||||||
|
{/* ردیف سوم: موجودی راکد */}
|
||||||
|
<DeadStockTable />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ——— نمای B: بازرگانی و لجستیک ——— */
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* KPI + قیف */}
|
||||||
|
<div className="grid gap-3 xl:grid-cols-3">
|
||||||
|
<LeadTimeKpiCard />
|
||||||
|
<div className="xl:col-span-2">
|
||||||
|
<ProcurementFunnel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* تراکم پروژهها + سفارشهای در راه */}
|
||||||
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
|
<ProjectDensityChart />
|
||||||
|
<InTransitGrid />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageLayout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -212,9 +212,6 @@ export function OrganizationPage() {
|
|||||||
{users.map((u) => {
|
{users.map((u) => {
|
||||||
const isAdmin = u.role === "admin";
|
const isAdmin = u.role === "admin";
|
||||||
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm);
|
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm);
|
||||||
// سرپرست/مدیر پروژه بدون پنل هم عضویت پروژهای میخواهد —
|
|
||||||
// ورود سرپرست بدون پروژه مسدود است، پس ویرایشگر باید باز باشد
|
|
||||||
const canAssignProjects = hasPanel || u.role === "supervisor" || u.role === "pm";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={u.id} className="border-b border-line/50 transition-colors hover:bg-canvas/30">
|
<tr key={u.id} className="border-b border-line/50 transition-colors hover:bg-canvas/30">
|
||||||
@@ -323,7 +320,7 @@ export function OrganizationPage() {
|
|||||||
<td className="py-2.5">
|
<td className="py-2.5">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژهها</span>
|
<span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژهها</span>
|
||||||
) : canAssignProjects ? (
|
) : hasPanel ? (
|
||||||
<div className="w-72">
|
<div className="w-72">
|
||||||
<ProjectMultiSelect
|
<ProjectMultiSelect
|
||||||
options={projects.map((p) => ({ id: p.id, name: p.name }))}
|
options={projects.map((p) => ({ id: p.id, name: p.name }))}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Zustand store — فیلتر سراسری «چه چیزی» داشبورد
|
||||||
|
// متریال (خانواده) → برند → مشخصات فنی
|
||||||
|
// «مشخصات» یکی از دو چیز است: واریانتِ کاتالوگ (سایز/گرید) یا مشخصهی موجودی.
|
||||||
|
// بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ میشود.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
// انتخاب مشخصه: واریانت (ردیف کاتالوگ) یا مشخصهی موجودی (specs_hash)
|
||||||
|
export type SpecSelection =
|
||||||
|
| { kind: "variant"; id: string; label: string }
|
||||||
|
| { kind: "stockSpec"; id: string; label: string };
|
||||||
|
|
||||||
|
export interface DashboardFilterState {
|
||||||
|
// شناسه/نام خانواده (نام پایه) — نه ردیف تکیِ کاتالوگ
|
||||||
|
materialFamilyId: string | null;
|
||||||
|
materialFamilyLabel: string | null;
|
||||||
|
brandId: string | null;
|
||||||
|
brandLabel: string | null;
|
||||||
|
spec: SpecSelection | null;
|
||||||
|
|
||||||
|
setMaterialFamily: (id: string | null, label: string | null) => void;
|
||||||
|
setBrand: (id: string | null, label: string | null) => void;
|
||||||
|
setSpec: (selection: SpecSelection | null) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL = {
|
||||||
|
materialFamilyId: null,
|
||||||
|
materialFamilyLabel: null,
|
||||||
|
brandId: null,
|
||||||
|
brandLabel: null,
|
||||||
|
spec: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDashboardFilter = create<DashboardFilterState>((set) => ({
|
||||||
|
...INITIAL,
|
||||||
|
// آبشاری: تغییر هر سطح، سطحهای پاییندست را پاک میکند
|
||||||
|
setMaterialFamily: (id, label) =>
|
||||||
|
set({
|
||||||
|
materialFamilyId: id,
|
||||||
|
materialFamilyLabel: label,
|
||||||
|
brandId: null,
|
||||||
|
brandLabel: null,
|
||||||
|
spec: null,
|
||||||
|
}),
|
||||||
|
setBrand: (id, label) =>
|
||||||
|
set({
|
||||||
|
brandId: id,
|
||||||
|
brandLabel: label,
|
||||||
|
spec: null,
|
||||||
|
}),
|
||||||
|
setSpec: (selection) => set({ spec: selection }),
|
||||||
|
reset: () => set({ ...INITIAL }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// پارامترهای آماده برای endpointهای داشبورد
|
||||||
|
export function dashboardFilterParams(s: DashboardFilterState) {
|
||||||
|
return {
|
||||||
|
material_family: s.materialFamilyId,
|
||||||
|
// واریانت = ردیف کاتالوگ همان سایز/گرید
|
||||||
|
material_id: s.spec?.kind === "variant" ? s.spec.id : null,
|
||||||
|
brand_id: s.brandId,
|
||||||
|
// مشخصهی موجودی
|
||||||
|
specs_hash: s.spec?.kind === "stockSpec" ? s.spec.id : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
+39
-9
@@ -1,4 +1,24 @@
|
|||||||
|
# PM — Construction Project Management Platform
|
||||||
services:
|
services:
|
||||||
|
pm-postgres:
|
||||||
|
image: postgres:18
|
||||||
|
container_name: pm-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: pm
|
||||||
|
POSTGRES_USER: pm
|
||||||
|
POSTGRES_PASSWORD: ${PM_DB_PASSWORD:-pm_secret_2026}
|
||||||
|
ports:
|
||||||
|
- "5434:5432"
|
||||||
|
volumes:
|
||||||
|
- pm_postgres_data:/var/lib/postgresql
|
||||||
|
- ./sql/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U pm -d pm"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
pm-api:
|
pm-api:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
@@ -11,19 +31,24 @@ services:
|
|||||||
PM_ADMIN_PASSWORD: ${PM_ADMIN_PASSWORD:-pm123456}
|
PM_ADMIN_PASSWORD: ${PM_ADMIN_PASSWORD:-pm123456}
|
||||||
PM_FILES_DIR: /data/files
|
PM_FILES_DIR: /data/files
|
||||||
PM_DEV_ROLE_SWITCH: ${PM_DEV_ROLE_SWITCH:-false}
|
PM_DEV_ROLE_SWITCH: ${PM_DEV_ROLE_SWITCH:-false}
|
||||||
|
# ERP integration (read-only service account)
|
||||||
PM_ERP_BASE_URL: http://procurement-api:8000
|
PM_ERP_BASE_URL: http://procurement-api:8000
|
||||||
PM_ERP_CASDOOR_ENDPOINT: http://casdoor:8000
|
PM_ERP_CASDOOR_ENDPOINT: http://casdoor:8000
|
||||||
PM_ERP_CASDOOR_CLIENT_ID: ${PM_ERP_CASDOOR_CLIENT_ID:-inventory-app-client}
|
PM_ERP_CASDOOR_CLIENT_ID: ${PM_ERP_CASDOOR_CLIENT_ID:-inventory-app-client}
|
||||||
PM_ERP_CASDOOR_CLIENT_SECRET: ${PM_ERP_CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026}
|
PM_ERP_CASDOOR_CLIENT_SECRET: ${PM_ERP_CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026}
|
||||||
PM_ERP_SERVICE_USERNAME: ${PM_ERP_SERVICE_USERNAME:-pm-service}
|
PM_ERP_SERVICE_USERNAME: ${PM_ERP_SERVICE_USERNAME:-pm-service}
|
||||||
PM_ERP_SERVICE_PASSWORD: ${PM_ERP_SERVICE_PASSWORD:-pm-service-2026}
|
PM_ERP_SERVICE_PASSWORD: ${PM_ERP_SERVICE_PASSWORD:-pm-service-2026}
|
||||||
|
# Casdoor SSO — همان نشست ERP؛ پیشفرض لوکال، origin کسدور لوکال است
|
||||||
PM_CASDOOR_ISSUER: ${PM_CASDOOR_ISSUER:-http://localhost:8080}
|
PM_CASDOOR_ISSUER: ${PM_CASDOOR_ISSUER:-http://localhost:8080}
|
||||||
volumes:
|
volumes:
|
||||||
- pm_files:/data/files
|
- pm_files:/data/files
|
||||||
- ./backend/app:/srv/pm/app
|
- ./backend/app:/srv/pm/app
|
||||||
|
depends_on:
|
||||||
|
pm-postgres:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- erp_network
|
- erp_default
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
pm-frontend:
|
pm-frontend:
|
||||||
@@ -35,25 +60,30 @@ services:
|
|||||||
- "8083:80"
|
- "8083:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- pm-api
|
- pm-api
|
||||||
|
# پنل «مدیریت پروژه» روی همان دامنه ERP زیر مسیر /pm سرو میشود —
|
||||||
|
# Traefikِ استک ERP این کانتینر را با قاعده PathPrefix کشف میکند
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- traefik.enable=true
|
||||||
- "traefik.http.routers.pm.rule=PathPrefix(`/pm`)"
|
- traefik.http.routers.pm.rule=PathPrefix(`/pm`)
|
||||||
- "traefik.http.routers.pm.entrypoints=web"
|
- traefik.http.routers.pm.entrypoints=web
|
||||||
- "traefik.http.routers.pm.priority=100"
|
# اولویت بالاتر از روتر Host(`localhost`) فرانت ERP — وگرنه /pm/api
|
||||||
- "traefik.http.services.pm.loadbalancer.server.port=80"
|
# ممکن است به SPA فرانت ERP برسد (POST → 405)
|
||||||
|
- traefik.http.routers.pm.priority=100
|
||||||
|
- traefik.http.services.pm.loadbalancer.server.port=80
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- traefik_network
|
- traefik_network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
pm_postgres_data:
|
||||||
pm_files:
|
pm_files:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: pm_default
|
name: pm_default
|
||||||
erp_network:
|
erp_default:
|
||||||
name: all-together_default
|
|
||||||
external: true
|
external: true
|
||||||
traefik_network:
|
traefik_network:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AlertTriangle, Link2, Plus, Trash2, Users } from "lucide-react";
|
import { AlertTriangle, Link2, Plus, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
createTaskRelation,
|
createTaskRelation,
|
||||||
@@ -24,110 +24,6 @@ import { isoToApiDate } from "../lib/jalali";
|
|||||||
import { reconcileDates } from "../lib/dates";
|
import { reconcileDates } from "../lib/dates";
|
||||||
import { formatDate } from "../lib/format";
|
import { formatDate } from "../lib/format";
|
||||||
|
|
||||||
export function HumanResourceInputs({
|
|
||||||
humanResources,
|
|
||||||
setHumanResources,
|
|
||||||
}: {
|
|
||||||
humanResources: HumanResourceItem[];
|
|
||||||
setHumanResources: React.Dispatch<React.SetStateAction<HumanResourceItem[]>>;
|
|
||||||
}) {
|
|
||||||
const handleAdd = () => {
|
|
||||||
setHumanResources([...humanResources, { role: "", count: 1 }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemove = (index: number) => {
|
|
||||||
setHumanResources(humanResources.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (index: number, field: "role" | "count", val: string | number) => {
|
|
||||||
const next = [...humanResources];
|
|
||||||
if (field === "role") {
|
|
||||||
next[index] = { ...next[index], role: String(val) };
|
|
||||||
} else {
|
|
||||||
next[index] = { ...next[index], count: Number(val) || 0 };
|
|
||||||
}
|
|
||||||
setHumanResources(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-sky-200 bg-sky-50/50 p-3 space-y-2.5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-bold text-sky-950 flex items-center gap-1.5">
|
|
||||||
<Users size={14} className="text-sky-600" />
|
|
||||||
<span>نیروی انسانی مورد نیاز</span>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAdd}
|
|
||||||
className="text-[10px] font-semibold text-sky-700 hover:text-sky-900 bg-white border border-sky-200 rounded-lg px-2.5 py-1 flex items-center gap-1 shadow-2xs hover:bg-sky-50 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus size={12} />
|
|
||||||
<span>افزودن ردیف نیرو</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{humanResources.length === 0 ? (
|
|
||||||
<p className="text-center py-2 text-xs text-slate-400">
|
|
||||||
ردیفی برای نیروی انسانی تعریف نشده است.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{humanResources.map((item, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="rounded-lg border border-slate-200/80 bg-white p-2.5 text-xs shadow-2xs flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<label className="text-[10px] font-semibold text-slate-500 block mb-1">
|
|
||||||
نقش / رده شغلی
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={item.role}
|
|
||||||
onChange={(e) => handleChange(idx, "role", e.target.value)}
|
|
||||||
placeholder="مثلاً: آرماتوربند، کارگر ساده..."
|
|
||||||
className="h-8 w-full rounded-lg border border-slate-200 px-2.5 text-xs text-slate-800 focus:border-sky-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-24 shrink-0">
|
|
||||||
<label className="text-[10px] font-semibold text-slate-500 block mb-1">
|
|
||||||
تعداد درخواستی
|
|
||||||
</label>
|
|
||||||
<div className="relative flex items-center">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={item.count ? item.count : ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleChange(idx, "count", e.target.value ? parseInt(e.target.value) : 0)
|
|
||||||
}
|
|
||||||
placeholder="نفر"
|
|
||||||
className="h-8 w-full rounded-lg border border-slate-200 px-2 pe-7 text-xs text-center font-bold text-slate-800 focus:border-sky-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<span className="absolute left-2 text-[10px] text-slate-400 pointer-events-none">
|
|
||||||
نفر
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRemove(idx)}
|
|
||||||
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
|
||||||
title="حذف ردیف"
|
|
||||||
>
|
|
||||||
<Trash2 size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TaskEditModalProps {
|
interface TaskEditModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
task: Task | TaskDetail | null;
|
task: Task | TaskDetail | null;
|
||||||
@@ -191,15 +87,6 @@ export function TaskEditModal({
|
|||||||
const [errorMsg, setErrorMsg] = useState("");
|
const [errorMsg, setErrorMsg] = useState("");
|
||||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
|
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
|
||||||
|
|
||||||
// بررسی زنده برای کوچکتر بودن تاریخ پایان از شروع
|
|
||||||
const isDateRangeInvalid = Boolean(
|
|
||||||
plannedStart &&
|
|
||||||
plannedEnd &&
|
|
||||||
isoToApiDate(plannedStart) &&
|
|
||||||
isoToApiDate(plannedEnd) &&
|
|
||||||
isoToApiDate(plannedStart)! > isoToApiDate(plannedEnd)!
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && task) {
|
if (open && task) {
|
||||||
setTitle(task.title || task.subject || "");
|
setTitle(task.title || task.subject || "");
|
||||||
@@ -225,7 +112,7 @@ export function TaskEditModal({
|
|||||||
|
|
||||||
const taskId = task ? String(task.id || task.op_wp_id) : "";
|
const taskId = task ? String(task.id || task.op_wp_id) : "";
|
||||||
|
|
||||||
// لیست والدهای مجاز
|
// لیست والدهای مجاز (حذف خود تسک و تمام زیرتسکهای آن برای جلوگیری از حلقه)
|
||||||
const validParents = useMemo(() => {
|
const validParents = useMemo(() => {
|
||||||
if (!taskId) return allTasks;
|
if (!taskId) return allTasks;
|
||||||
const descendantIds = getDescendantIds(allTasks, taskId);
|
const descendantIds = getDescendantIds(allTasks, taskId);
|
||||||
@@ -235,11 +122,12 @@ export function TaskEditModal({
|
|||||||
});
|
});
|
||||||
}, [allTasks, taskId]);
|
}, [allTasks, taskId]);
|
||||||
|
|
||||||
// لیست وظایف مجاز جهت برقراری رابطه وابستگی
|
// لیست وظایف مجاز جهت برقراری رابطه وابستگی (حذف خود تسک)
|
||||||
const validRelationTargets = useMemo(() => {
|
const validRelationTargets = useMemo(() => {
|
||||||
return allTasks.filter((t) => String(t.id || t.op_wp_id) !== taskId);
|
return allTasks.filter((t) => String(t.id || t.op_wp_id) !== taskId);
|
||||||
}, [allTasks, taskId]);
|
}, [allTasks, taskId]);
|
||||||
|
|
||||||
|
// کوئری روابط پروژه جهت استخراج روابط ورودی و خروجی این وظیفه
|
||||||
const relationsQuery = useQuery({
|
const relationsQuery = useQuery({
|
||||||
queryKey: ["project-relations", projectId],
|
queryKey: ["project-relations", projectId],
|
||||||
queryFn: () => getProjectRelations(projectId),
|
queryFn: () => getProjectRelations(projectId),
|
||||||
@@ -273,7 +161,7 @@ export function TaskEditModal({
|
|||||||
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
|
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی");
|
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی (احتمال وجود دور یا ورودی تکراری)");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -291,9 +179,12 @@ export function TaskEditModal({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Save the whole form. Progress goes to its own endpoint (see below). */
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: async (payload: UpdateTaskPayload) => {
|
mutationFn: async (payload: UpdateTaskPayload) => {
|
||||||
await updateTask(task!.id || task!.op_wp_id, payload);
|
await updateTask(task!.id || task!.op_wp_id, payload);
|
||||||
|
// progress is NOT part of the task patch schema — it has its own endpoint,
|
||||||
|
// which also enforces the QC gating rules server-side
|
||||||
if (typeof progress === "number" && progress !== (task!.progress ?? task!.percentage_done ?? 0)) {
|
if (typeof progress === "number" && progress !== (task!.progress ?? task!.percentage_done ?? 0)) {
|
||||||
await updateTaskProgress(task!.id || task!.op_wp_id, progress);
|
await updateTaskProgress(task!.id || task!.op_wp_id, progress);
|
||||||
}
|
}
|
||||||
@@ -341,6 +232,7 @@ export function TaskEditModal({
|
|||||||
const startIso = isoToApiDate(plannedStart);
|
const startIso = isoToApiDate(plannedStart);
|
||||||
const endIso = isoToApiDate(plannedEnd);
|
const endIso = isoToApiDate(plannedEnd);
|
||||||
|
|
||||||
|
// ۱. اجباری بودن تاریخ شروع و پایان
|
||||||
if (!startIso) {
|
if (!startIso) {
|
||||||
setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است");
|
setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است");
|
||||||
return;
|
return;
|
||||||
@@ -350,11 +242,13 @@ export function TaskEditModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ۲. تاریخ شروع نباید از تاریخ پایان جلوتر باشد
|
||||||
if (startIso > endIso) {
|
if (startIso > endIso) {
|
||||||
setErrorMsg("تاریخ پایان نمیتواند کمتر از تاریخ شروع باشد");
|
setErrorMsg("تاریخ شروع وظیفه نمیتواند بعد از تاریخ اتمام آن باشد");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ۲. محدودیت نسبت به والد انتخابی
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
const selectedParent = allTasks.find(
|
const selectedParent = allTasks.find(
|
||||||
(t) => String(t.id || t.op_wp_id) === String(parentId)
|
(t) => String(t.id || t.op_wp_id) === String(parentId)
|
||||||
@@ -387,6 +281,7 @@ export function TaskEditModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ۳. محدودیت نسبت به فرزندان موجود
|
||||||
const children = allTasks.filter((t) => {
|
const children = allTasks.filter((t) => {
|
||||||
const p = t.parent_id
|
const p = t.parent_id
|
||||||
? String(t.parent_id)
|
? String(t.parent_id)
|
||||||
@@ -515,20 +410,20 @@ export function TaskEditModal({
|
|||||||
<label className="mb-1 block text-xs font-semibold text-slate-600">
|
<label className="mb-1 block text-xs font-semibold text-slate-600">
|
||||||
پیشرفت (٪)
|
پیشرفت (٪)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
value={progress}
|
value={progress}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const raw = e.target.value;
|
const raw = e.target.value;
|
||||||
setProgress(raw === "" ? "" : Math.max(0, Math.min(100, Number(raw))));
|
setProgress(raw === "" ? "" : Math.max(0, Math.min(100, Number(raw))));
|
||||||
}}
|
}}
|
||||||
placeholder="۰"
|
placeholder="۰"
|
||||||
className={`${inputCls} text-left`}
|
className={`${inputCls} text-left`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -548,6 +443,7 @@ export function TaskEditModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-semibold text-slate-600">
|
<label className="mb-1 block text-xs font-semibold text-slate-600">
|
||||||
@@ -587,14 +483,6 @@ export function TaskEditModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* کادر اخطار بلادرنگ در صورت نامعتبر بودن بازه زمانی */}
|
|
||||||
{isDateRangeInvalid && (
|
|
||||||
<div className="flex items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 p-2.5 text-xs font-bold text-rose-600">
|
|
||||||
<AlertTriangle size={15} className="shrink-0 text-rose-500" />
|
|
||||||
<span>تاریخ پایان نمیتواند کمتر از تاریخ شروع باشد</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-2.5 text-xs">
|
<div className="rounded-xl border border-slate-200 bg-slate-50 p-2.5 text-xs">
|
||||||
<p className="font-semibold text-slate-700 mb-1">تاریخهای واقعی (محاسبه و ثبت خودکار):</p>
|
<p className="font-semibold text-slate-700 mb-1">تاریخهای واقعی (محاسبه و ثبت خودکار):</p>
|
||||||
<div className="grid grid-cols-2 gap-2 text-slate-600">
|
<div className="grid grid-cols-2 gap-2 text-slate-600">
|
||||||
@@ -662,11 +550,7 @@ export function TaskEditModal({
|
|||||||
setMaterials={setMaterials}
|
setMaterials={setMaterials}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HumanResourceInputs
|
{/* بخش روابط پیشنیازی و وابستگیها (FS, SS, FF, SF) */}
|
||||||
humanResources={humanResources}
|
|
||||||
setHumanResources={setHumanResources}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-3">
|
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-3">
|
||||||
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
|
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
|
||||||
<span className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
|
<span className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
|
||||||
@@ -680,6 +564,7 @@ export function TaskEditModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* لیست وابستگیهای موجود */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{currentTaskRelations.length === 0 ? (
|
{currentTaskRelations.length === 0 ? (
|
||||||
<p className="py-2 text-center text-xs text-slate-400">
|
<p className="py-2 text-center text-xs text-slate-400">
|
||||||
@@ -734,6 +619,7 @@ export function TaskEditModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* فرم افزودن رابطه جدید */}
|
||||||
<div className="pt-2 border-t border-slate-200/80 space-y-2">
|
<div className="pt-2 border-t border-slate-200/80 space-y-2">
|
||||||
<span className="text-[11px] font-bold text-slate-700 block">افزودن رابطه وابستگی جدید</span>
|
<span className="text-[11px] font-bold text-slate-700 block">افزودن رابطه وابستگی جدید</span>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2">
|
||||||
@@ -837,7 +723,7 @@ export function TaskEditModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={updateMutation.isPending || isDateRangeInvalid}
|
disabled={updateMutation.isPending}
|
||||||
className={btnPrimary}
|
className={btnPrimary}
|
||||||
>
|
>
|
||||||
{updateMutation.isPending ? "در حال ذخیره..." : "ذخیره تغییرات"}
|
{updateMutation.isPending ? "در حال ذخیره..." : "ذخیره تغییرات"}
|
||||||
@@ -847,4 +733,4 @@ export function TaskEditModal({
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -48,7 +48,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
|
|||||||
const [defectToResolve, setDefectToResolve] = useState<Defect | null>(null);
|
const [defectToResolve, setDefectToResolve] = useState<Defect | null>(null);
|
||||||
const [resolutionNotes, setResolutionNotes] = useState("");
|
const [resolutionNotes, setResolutionNotes] = useState("");
|
||||||
|
|
||||||
// دریافت اطلاعات QC وظیفه
|
// Load Task QC Data
|
||||||
const qcQuery = useQuery({
|
const qcQuery = useQuery({
|
||||||
queryKey: ["task-qc", taskId],
|
queryKey: ["task-qc", taskId],
|
||||||
queryFn: () => getTaskQc(taskId),
|
queryFn: () => getTaskQc(taskId),
|
||||||
@@ -66,50 +66,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
|
|||||||
const questions = definitions.flatMap((d) => d.questions);
|
const questions = definitions.flatMap((d) => d.questions);
|
||||||
const selectedTemplate = (templatesQuery.data ?? []).find((t) => t.id === selectedTemplateId) ?? null;
|
const selectedTemplate = (templatesQuery.data ?? []).find((t) => t.id === selectedTemplateId) ?? null;
|
||||||
|
|
||||||
// محاسبه زنده و دقیق آمار هر مرحله بر اساس لیست واقعی سوالات
|
// Mutations
|
||||||
const preStats = useMemo(() => {
|
|
||||||
if (questions.length > 0) {
|
|
||||||
const list = questions.filter((q) => q.phase === "pre");
|
|
||||||
return {
|
|
||||||
total: list.length,
|
|
||||||
passed: list.filter((q) => q.result?.answer === "pass" || q.result?.answer === "na").length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
total: readiness?.pre?.total ?? 0,
|
|
||||||
passed: readiness?.pre?.passed ?? 0,
|
|
||||||
};
|
|
||||||
}, [questions, readiness?.pre]);
|
|
||||||
|
|
||||||
const duringStats = useMemo(() => {
|
|
||||||
if (questions.length > 0) {
|
|
||||||
const list = questions.filter((q) => q.phase === "during");
|
|
||||||
return {
|
|
||||||
total: list.length,
|
|
||||||
passed: list.filter((q) => q.result?.answer === "pass" || q.result?.answer === "na").length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
total: readiness?.exec?.total ?? 0,
|
|
||||||
passed: readiness?.exec?.passed ?? 0,
|
|
||||||
};
|
|
||||||
}, [questions, readiness?.exec]);
|
|
||||||
|
|
||||||
const afterStats = useMemo(() => {
|
|
||||||
if (questions.length > 0) {
|
|
||||||
const list = questions.filter((q) => q.phase === "after");
|
|
||||||
return {
|
|
||||||
total: list.length,
|
|
||||||
passed: list.filter((q) => q.result?.answer === "pass" || q.result?.answer === "na").length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
total: (readiness as any)?.after?.total ?? 0,
|
|
||||||
passed: (readiness as any)?.after?.passed ?? 0,
|
|
||||||
};
|
|
||||||
}, [questions, readiness]);
|
|
||||||
|
|
||||||
// جهشها (Mutations)
|
|
||||||
const invalidateQc = () => {
|
const invalidateQc = () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["task-qc", taskId] });
|
void queryClient.invalidateQueries({ queryKey: ["task-qc", taskId] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
|
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
|
||||||
@@ -136,6 +93,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
|
|||||||
const handleToggleAnswer = (questionId: string, currentAnswer: string | null | undefined, newAnswer: AnswerValue) => {
|
const handleToggleAnswer = (questionId: string, currentAnswer: string | null | undefined, newAnswer: AnswerValue) => {
|
||||||
if (!canExecuteQc || answerMutation.isPending) return;
|
if (!canExecuteQc || answerMutation.isPending) return;
|
||||||
if (currentAnswer === newAnswer) {
|
if (currentAnswer === newAnswer) {
|
||||||
|
// کلیک مجدد: لغو انتخاب قبلی
|
||||||
answerMutation.mutate({ questionId, answer: null });
|
answerMutation.mutate({ questionId, answer: null });
|
||||||
} else {
|
} else {
|
||||||
answerMutation.mutate({ questionId, answer: newAnswer });
|
answerMutation.mutate({ questionId, answer: newAnswer });
|
||||||
@@ -200,15 +158,18 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// محاسبه پیشرفت کل چکلیست
|
// Calculate Progress
|
||||||
const totalQuestions = questions.length > 0 ? questions.length : (readiness?.all.total ?? 0);
|
const totalQuestions = readiness?.all.total ?? questions.length;
|
||||||
const answeredCount = questions.length > 0
|
const pendingCount = readiness?.all.pending ?? 0;
|
||||||
? questions.filter((q) => q.result?.answer != null).length
|
const answeredCount = totalQuestions - pendingCount;
|
||||||
: (totalQuestions - (readiness?.all.pending ?? 0));
|
|
||||||
const qcPercent = totalQuestions > 0 ? Math.round((answeredCount / totalQuestions) * 100) : 0;
|
const qcPercent = totalQuestions > 0 ? Math.round((answeredCount / totalQuestions) * 100) : 0;
|
||||||
|
|
||||||
const currentPhaseQuestions = questions.filter((q) => q.phase === activePhase);
|
const currentPhaseQuestions = questions.filter((q) => q.phase === activePhase);
|
||||||
|
|
||||||
|
// Group stats by phase
|
||||||
|
const preStats = readiness?.pre;
|
||||||
|
const execStats = readiness?.exec;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3.5">
|
<div className="space-y-3.5">
|
||||||
{/* 1. بنر وضعیت آمادگی شروع و اتمام کار */}
|
{/* 1. بنر وضعیت آمادگی شروع و اتمام کار */}
|
||||||
@@ -245,13 +206,12 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* نوار پیشرفت کلی QC */}
|
||||||
{totalQuestions > 0 && (
|
{totalQuestions > 0 && (
|
||||||
<div className="mt-2.5">
|
<div className="mt-2.5">
|
||||||
<div className="flex items-center justify-between text-[11px] text-slate-500 mb-1">
|
<div className="flex items-center justify-between text-[11px] text-slate-500 mb-1">
|
||||||
<span>پیشرفت بازرسیهای کیفی</span>
|
<span>پیشرفت بازرسیهای کیفی</span>
|
||||||
<span className="font-bold tabular-nums text-slate-700">
|
<span className="font-bold tabular-nums text-slate-700">{qcPercent}٪ ({answeredCount} از {totalQuestions})</span>
|
||||||
{qcPercent}٪ ({answeredCount} از {totalQuestions})
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar value={qcPercent} />
|
<ProgressBar value={qcPercent} />
|
||||||
</div>
|
</div>
|
||||||
@@ -292,72 +252,52 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ۳ تب فازها با عرض مساوی و شمارنده دقیق */}
|
{/* ۳ تب فازها: پیش از اجرا / حین اجرا / پس از اجرا */}
|
||||||
<div className="grid grid-cols-3 gap-1 rounded-lg bg-slate-200/80 p-1 text-xs font-semibold text-slate-600">
|
<div className="flex rounded-lg bg-slate-200/80 p-0.5 text-xs font-semibold text-slate-600">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActivePhase("pre")}
|
onClick={() => setActivePhase("pre")}
|
||||||
className={`min-w-0 rounded-md py-1.5 px-1 text-center transition-all flex items-center justify-center gap-1 ${
|
className={`flex-1 rounded-md py-1.5 text-center transition-all ${
|
||||||
activePhase === "pre"
|
activePhase === "pre"
|
||||||
? "bg-white text-amber-800 shadow-sm font-bold"
|
? "bg-white text-amber-800 shadow-sm font-bold"
|
||||||
: "text-slate-600 hover:text-slate-800"
|
: "text-slate-600 hover:text-slate-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate">۱. پیش از اجرا</span>
|
<span>۱. پیش از اجرا</span>
|
||||||
<span
|
{preStats && preStats.total > 0 && (
|
||||||
className={`rounded-full px-1.5 py-0.2 text-[10px] tabular-nums shrink-0 ${
|
<span className={`ms-1.5 rounded-full px-1.5 py-0.2 text-[10px] ${activePhase === "pre" ? "bg-amber-100 text-amber-800" : "bg-slate-300 text-slate-700"}`}>
|
||||||
activePhase === "pre"
|
{preStats.passed}/{preStats.total}
|
||||||
? "bg-amber-100 text-amber-800 font-bold"
|
</span>
|
||||||
: "bg-slate-300 text-slate-700"
|
)}
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{preStats.passed}/{preStats.total}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActivePhase("during")}
|
onClick={() => setActivePhase("during")}
|
||||||
className={`min-w-0 rounded-md py-1.5 px-1 text-center transition-all flex items-center justify-center gap-1 ${
|
className={`flex-1 rounded-md py-1.5 text-center transition-all ${
|
||||||
activePhase === "during"
|
activePhase === "during"
|
||||||
? "bg-white text-sky-800 shadow-sm font-bold"
|
? "bg-white text-sky-800 shadow-sm font-bold"
|
||||||
: "text-slate-600 hover:text-slate-800"
|
: "text-slate-600 hover:text-slate-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate">۲. حین اجرا</span>
|
<span>۲. حین اجرا</span>
|
||||||
<span
|
{execStats && execStats.total > 0 && (
|
||||||
className={`rounded-full px-1.5 py-0.2 text-[10px] tabular-nums shrink-0 ${
|
<span className={`ms-1.5 rounded-full px-1.5 py-0.2 text-[10px] ${activePhase === "during" ? "bg-sky-100 text-sky-800" : "bg-slate-300 text-slate-700"}`}>
|
||||||
activePhase === "during"
|
{execStats.passed}/{execStats.total}
|
||||||
? "bg-sky-100 text-sky-800 font-bold"
|
</span>
|
||||||
: "bg-slate-300 text-slate-700"
|
)}
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{duringStats.passed}/{duringStats.total}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActivePhase("after")}
|
onClick={() => setActivePhase("after")}
|
||||||
className={`min-w-0 rounded-md py-1.5 px-1 text-center transition-all flex items-center justify-center gap-1 ${
|
className={`flex-1 rounded-md py-1.5 text-center transition-all ${
|
||||||
activePhase === "after"
|
activePhase === "after"
|
||||||
? "bg-white text-emerald-800 shadow-sm font-bold"
|
? "bg-white text-emerald-800 shadow-sm font-bold"
|
||||||
: "text-slate-600 hover:text-slate-800"
|
: "text-slate-600 hover:text-slate-800"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span>۳. تحویل و پس از اجرا</span>
|
||||||
<span className="sm:hidden">۳. پس از اجرا</span>
|
|
||||||
<span className="hidden sm:inline">۳. تحویل و پس از اجرا</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-1.5 py-0.2 text-[10px] tabular-nums shrink-0 ${
|
|
||||||
activePhase === "after"
|
|
||||||
? "bg-emerald-100 text-emerald-800 font-bold"
|
|
||||||
: "bg-slate-300 text-slate-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{afterStats.passed}/{afterStats.total}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -423,11 +363,12 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* دکمههای ۳ گانه پاسخ */}
|
{/* دکمههای پاسخ فوری ۳ گانه */}
|
||||||
<div className="mt-2.5 flex items-center justify-between gap-2 pt-2 border-t border-slate-100">
|
<div className="mt-2.5 flex items-center justify-between gap-2 pt-2 border-t border-slate-100">
|
||||||
<span className="text-[11px] text-slate-500">وضعیت بازرسی:</span>
|
<span className="text-[11px] text-slate-500">وضعیت بازرسی:</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{/* دکمه قبول / Pass */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canExecuteQc || answerMutation.isPending}
|
disabled={!canExecuteQc || answerMutation.isPending}
|
||||||
@@ -442,6 +383,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
|
|||||||
<span>قبول</span>
|
<span>قبول</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* دکمه رد / Fail */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canExecuteQc || answerMutation.isPending}
|
disabled={!canExecuteQc || answerMutation.isPending}
|
||||||
@@ -456,6 +398,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
|
|||||||
<span>مردود</span>
|
<span>مردود</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* دکمه مطرح نیست */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!canExecuteQc || answerMutation.isPending}
|
disabled={!canExecuteQc || answerMutation.isPending}
|
||||||
@@ -472,7 +415,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* در صورت قبول بودن: امکان آپلود تصویر مستند */}
|
{/* در صورت قبول بودن: اکشن آپلود عکس تأییدیه */}
|
||||||
{latestAns === "pass" && (
|
{latestAns === "pass" && (
|
||||||
<div className="mt-2 rounded-lg bg-emerald-50/70 p-2 text-xs border border-emerald-100 space-y-1.5">
|
<div className="mt-2 rounded-lg bg-emerald-50/70 p-2 text-xs border border-emerald-100 space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -989,4 +932,4 @@ function CreateDefectModal({
|
|||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { LucideIcon } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ApiError } from "../lib/api";
|
import { ApiError } from "../lib/api";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { visibleTabs, getRoleTitle } from "../lib/roles";
|
import { visibleTabs } from "../lib/roles";
|
||||||
|
|
||||||
export type Tone = "red" | "amber" | "green" | "sky" | "slate";
|
export type Tone = "red" | "amber" | "green" | "sky" | "slate";
|
||||||
|
|
||||||
@@ -69,27 +69,28 @@ export function PageHeader({
|
|||||||
}) {
|
}) {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const fullName = user?.display_name || user?.username || "کاربر";
|
const fullName = user?.display_name || user?.username || "کاربر";
|
||||||
const role = getRoleTitle(user?.role, user?.role_label);
|
const username = user?.username || "-";
|
||||||
const userSub = user ? (
|
const role =
|
||||||
<span className="inline-flex items-center gap-1.5 text-[11px] text-slate-500 font-normal">
|
user?.role_label ||
|
||||||
<span className="font-semibold text-slate-700">{fullName}</span>
|
((user?.role as string) === "project_supervisor" || (user?.role as string) === "supervisor"
|
||||||
<span className="text-slate-300">•</span>
|
? "سرپرست پروژه"
|
||||||
<span className="font-medium text-sky-600">{role}</span>
|
: (user?.role as string) === "admin"
|
||||||
</span>
|
? "مدیر ارشد"
|
||||||
) : null;
|
: "مدیر پروژه");
|
||||||
|
const userSub = user ? `${fullName} | نام کاربری: ${username} | نقش: ${role}` : null;
|
||||||
const displaySub = sub !== undefined ? sub : userSub;
|
const displaySub = sub !== undefined ? sub : userSub;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-20 flex min-h-[56px] items-center gap-1.5 border-b border-slate-200/80 bg-white/95 px-2.5 py-1.5 backdrop-blur shadow-xs">
|
<header className="sticky top-0 z-20 flex min-h-[56px] items-center gap-1.5 border-b border-slate-200 bg-white/95 px-2 py-1.5 backdrop-blur">
|
||||||
{menu && <div className="flex shrink-0 items-center">{menu}</div>}
|
{menu && <div className="flex shrink-0 items-center">{menu}</div>}
|
||||||
{onBack && (
|
{onBack && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
aria-label="بازگشت"
|
aria-label="بازگشت"
|
||||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-slate-600 active:bg-slate-100"
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-slate-600 active:bg-slate-100"
|
||||||
>
|
>
|
||||||
<ChevronRight size={20} />
|
<ChevronRight size={22} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1 px-1">
|
<div className="min-w-0 flex-1 px-1">
|
||||||
@@ -113,26 +114,13 @@ const ERP_URL =
|
|||||||
type PanelMenuItem =
|
type PanelMenuItem =
|
||||||
| { kind: "divider" }
|
| { kind: "divider" }
|
||||||
| { kind: "link"; label: string; href: string }
|
| { kind: "link"; label: string; href: string }
|
||||||
| {
|
| { kind: "current"; label: string }
|
||||||
kind: "current";
|
|
||||||
label: string;
|
|
||||||
children?: { label: string; active: boolean; onClick: () => void }[];
|
|
||||||
}
|
|
||||||
| { kind: "button"; label: string; onClick: () => void };
|
| { kind: "button"; label: string; onClick: () => void };
|
||||||
|
|
||||||
export function PanelsMenu({
|
export function PanelsMenu() {
|
||||||
projects,
|
|
||||||
activeProjectId,
|
|
||||||
onSelectProject,
|
|
||||||
}: {
|
|
||||||
projects?: { id?: string | number; op_project_id?: number; name: string }[];
|
|
||||||
activeProjectId?: string | number;
|
|
||||||
onSelectProject?: (id: string) => void;
|
|
||||||
} = {}) {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const clear = useAuthStore((s) => s.clear);
|
const clear = useAuthStore((s) => s.clear);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(true);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -206,23 +194,7 @@ export function PanelsMenu({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasPm) {
|
if (hasPm) {
|
||||||
const projectChildren =
|
items.push({ kind: "current", label: "مدیریت پروژه" });
|
||||||
projects && projects.length > 0
|
|
||||||
? projects.map((p) => {
|
|
||||||
const pId = String(p.id ?? p.op_project_id ?? "");
|
|
||||||
return {
|
|
||||||
label: p.name,
|
|
||||||
active: String(activeProjectId) === pId,
|
|
||||||
onClick: () => onSelectProject?.(pId),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
kind: "current",
|
|
||||||
label: "مدیریت پروژه",
|
|
||||||
children: projectChildren,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (canManageOrg) {
|
if (canManageOrg) {
|
||||||
@@ -239,7 +211,7 @@ export function PanelsMenu({
|
|||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
aria-label="منو"
|
aria-label="منو"
|
||||||
title="منو"
|
title="منو"
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-xl text-slate-600 hover:bg-slate-100 active:scale-95 transition-all"
|
className="flex h-11 w-11 items-center justify-center rounded-full text-slate-600 active:bg-slate-100"
|
||||||
>
|
>
|
||||||
{open ? <X size={22} /> : <Menu size={22} />}
|
{open ? <X size={22} /> : <Menu size={22} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -268,51 +240,12 @@ export function PanelsMenu({
|
|||||||
{it.label}
|
{it.label}
|
||||||
</a>
|
</a>
|
||||||
) : it.kind === "current" ? (
|
) : it.kind === "current" ? (
|
||||||
<div key={i}>
|
<div
|
||||||
<div className="flex items-center justify-between rounded-lg bg-sky-600 px-3 py-2 text-[13px] font-bold text-white shadow-xs">
|
key={i}
|
||||||
<div className="flex items-center gap-2">
|
className="flex items-center justify-between gap-3 rounded-lg bg-sky-600 px-3 py-2 text-[13px] font-bold text-white"
|
||||||
<span>{it.label}</span>
|
>
|
||||||
<span className="text-[10px] opacity-80">✓ فعلی</span>
|
{it.label}
|
||||||
</div>
|
<span className="text-[10px] opacity-80">✓ فعلی</span>
|
||||||
{it.children && it.children.length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={expanded ? "بستن فهرست پروژهها" : "نمایش پروژهها"}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setExpanded((ex) => !ex);
|
|
||||||
}}
|
|
||||||
className="inline-flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-white hover:bg-white/15 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronDown
|
|
||||||
size={15}
|
|
||||||
className={`transition-transform duration-200 ${expanded ? "rotate-180" : ""}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{it.children && it.children.length > 0 && expanded && (
|
|
||||||
<div className="mt-1 flex flex-col gap-0.5 border-s-2 border-slate-200 ps-2 ms-3">
|
|
||||||
{it.children.map((c, j) => (
|
|
||||||
<button
|
|
||||||
key={j}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false);
|
|
||||||
c.onClick();
|
|
||||||
}}
|
|
||||||
className={`flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-[12.5px] text-start transition-colors ${
|
|
||||||
c.active
|
|
||||||
? "bg-sky-50 font-bold text-sky-700"
|
|
||||||
: "text-slate-700 hover:bg-slate-50 active:bg-slate-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="truncate">{c.label}</span>
|
|
||||||
{c.active && <span className="shrink-0 text-[10px] font-bold">✓ فعلی</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -342,30 +275,23 @@ export function TabBar() {
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const tabs = visibleTabs(user);
|
const tabs = visibleTabs(user);
|
||||||
return (
|
return (
|
||||||
<nav className="fixed bottom-0 left-1/2 z-30 w-full max-w-[420px] -translate-x-1/2 border-t border-slate-200/80 bg-white/95 pb-[env(safe-area-inset-bottom)] shadow-[0_-4px_16px_rgba(0,0,0,0.04)] backdrop-blur-md">
|
<nav className="fixed bottom-0 left-1/2 z-30 w-full max-w-[420px] -translate-x-1/2 border-t border-slate-200 bg-white/95 pb-[env(safe-area-inset-bottom)] backdrop-blur">
|
||||||
<div className="flex h-14 items-center justify-around px-2">
|
<div className="flex h-16 items-stretch">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={tab.to}
|
key={tab.to}
|
||||||
to={tab.to}
|
to={tab.to}
|
||||||
end={tab.end}
|
end={tab.end}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`group relative flex flex-1 flex-col items-center justify-center py-1 transition-all duration-200 active:scale-95 ${
|
`flex flex-1 flex-col items-center justify-center gap-0.5 text-[10px] font-medium transition-colors ${
|
||||||
isActive ? "text-sky-600 font-bold" : "text-slate-400 hover:text-slate-600 font-medium"
|
isActive ? "text-sky-600" : "text-slate-400"
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{({ isActive }) => (
|
{({ isActive }) => (
|
||||||
<>
|
<>
|
||||||
{/* Soft active indicator background pill & glowing top indicator */}
|
<tab.icon size={22} strokeWidth={isActive ? 2.3 : 1.8} />
|
||||||
{isActive && (
|
<span>{tab.label}</span>
|
||||||
<>
|
|
||||||
<span className="absolute inset-x-1.5 inset-y-0.5 -z-10 rounded-xl bg-sky-50 shadow-xs border border-sky-100/70" />
|
|
||||||
<span className="absolute -top-1.5 h-1 w-5 rounded-full bg-sky-600 shadow-[0_0_8px_rgba(2,132,199,0.55)]" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<tab.icon size={20} strokeWidth={isActive ? 2.4 : 1.7} className="transition-transform group-hover:scale-105" />
|
|
||||||
<span className="text-[10px] leading-tight mt-0.5">{tab.label}</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -375,71 +301,6 @@ export function TabBar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------
|
|
||||||
// کنترل سگمنتد تبها (Segmented Control)
|
|
||||||
// ----------------------------------------
|
|
||||||
|
|
||||||
export interface SegmentedOption<T extends string = string> {
|
|
||||||
id: T;
|
|
||||||
label: string;
|
|
||||||
count?: number;
|
|
||||||
tone?: "red" | "amber" | "green" | "sky" | "slate";
|
|
||||||
icon?: LucideIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SegmentedControl<T extends string>({
|
|
||||||
options,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
className = "",
|
|
||||||
}: {
|
|
||||||
options: SegmentedOption<T>[];
|
|
||||||
value: T;
|
|
||||||
onChange: (val: T) => void;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="tablist"
|
|
||||||
className={`flex items-center rounded-xl bg-slate-100/90 p-1 text-xs font-medium text-slate-500 shadow-inner ${className}`}
|
|
||||||
>
|
|
||||||
{options.map((opt) => {
|
|
||||||
const active = opt.id === value;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={opt.id}
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected={active}
|
|
||||||
onClick={() => onChange(opt.id)}
|
|
||||||
className={`relative flex flex-1 items-center justify-center gap-1.5 rounded-lg py-1.5 px-2 text-center text-xs font-semibold transition-all duration-150 select-none ${
|
|
||||||
active
|
|
||||||
? "bg-white text-slate-900 shadow-sm border border-slate-200/60"
|
|
||||||
: "text-slate-500 hover:text-slate-800 active:bg-slate-200/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.icon && <opt.icon size={13} className={active ? "text-sky-600" : "text-slate-400"} />}
|
|
||||||
<span className="truncate">{opt.label}</span>
|
|
||||||
{opt.count !== undefined && opt.count > 0 && (
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-1.5 py-0.2 text-[10px] font-bold tabular-nums leading-none ${
|
|
||||||
opt.tone === "red"
|
|
||||||
? active ? "bg-red-100 text-red-700" : "bg-red-50 text-red-600"
|
|
||||||
: opt.tone === "amber"
|
|
||||||
? active ? "bg-amber-100 text-amber-700" : "bg-amber-50 text-amber-600"
|
|
||||||
: active ? "bg-sky-100 text-sky-700" : "bg-slate-200 text-slate-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
// کارت و چیپ
|
// کارت و چیپ
|
||||||
// ----------------------------------------
|
// ----------------------------------------
|
||||||
|
|||||||
@@ -209,10 +209,8 @@ export type DefectSeverity = "low" | "medium" | "high";
|
|||||||
|
|
||||||
export interface DashboardTask {
|
export interface DashboardTask {
|
||||||
id?: string;
|
id?: string;
|
||||||
project_id?: string;
|
|
||||||
op_wp_id?: number | null;
|
op_wp_id?: number | null;
|
||||||
subject: string;
|
subject: string;
|
||||||
description?: string;
|
|
||||||
due_date: string | null;
|
due_date: string | null;
|
||||||
percentage_done: number;
|
percentage_done: number;
|
||||||
project_name: string;
|
project_name: string;
|
||||||
@@ -250,9 +248,6 @@ export interface DashboardToday {
|
|||||||
export interface ProjectStats {
|
export interface ProjectStats {
|
||||||
total: number;
|
total: number;
|
||||||
active: number;
|
active: number;
|
||||||
completed?: number;
|
|
||||||
in_progress?: number;
|
|
||||||
blocked?: number;
|
|
||||||
awaiting_qc: number;
|
awaiting_qc: number;
|
||||||
failed_qc: number;
|
failed_qc: number;
|
||||||
overdue: number;
|
overdue: number;
|
||||||
|
|||||||
@@ -34,61 +34,9 @@ export type Permission = (typeof Perm)[keyof typeof Perm];
|
|||||||
|
|
||||||
export const roleLabels: Record<Role, string> = {
|
export const roleLabels: Record<Role, string> = {
|
||||||
project_manager: "مدیر پروژه",
|
project_manager: "مدیر پروژه",
|
||||||
project_supervisor: "سرپرست کارگاه",
|
project_supervisor: "سرپرست پروژه",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* نگاشت تمیز کدهای نقش به عناوین فارسی استاندارد سیستم:
|
|
||||||
* - site_supervisor / project_supervisor / supervisor -> "سرپرست کارگاه"
|
|
||||||
* - project_manager / pm -> "مدیر پروژه"
|
|
||||||
* - admin / ceo -> "مدیر ارشد"
|
|
||||||
*/
|
|
||||||
export function getRoleTitle(role?: string | null, customLabel?: string | null): string {
|
|
||||||
const normalized = (role ?? "").toLowerCase().trim();
|
|
||||||
switch (normalized) {
|
|
||||||
case "site_supervisor":
|
|
||||||
case "project_supervisor":
|
|
||||||
case "supervisor":
|
|
||||||
return "سرپرست کارگاه";
|
|
||||||
case "project_manager":
|
|
||||||
case "pm":
|
|
||||||
return "مدیر پروژه";
|
|
||||||
case "admin":
|
|
||||||
case "ceo":
|
|
||||||
case "administrator":
|
|
||||||
return "مدیر ارشد";
|
|
||||||
case "procurement_manager":
|
|
||||||
return "مدیر تدارکات";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (customLabel && customLabel.trim()) {
|
|
||||||
if (customLabel.trim() === "سرپرست پروژه") {
|
|
||||||
return "سرپرست کارگاه";
|
|
||||||
}
|
|
||||||
return customLabel.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!role) return "کاربر سیستم";
|
|
||||||
return roleLabels[normalized as Role] || "کاربر سیستم";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* تعیین عنوان پویای بالای هدر صفحه بر اساس نقش کاربر:
|
|
||||||
* - سرپرست کارگاه -> "میز کار سرپرست کارگاه"
|
|
||||||
* - مدیر پروژه / سایر -> "مدیریت پروژه"
|
|
||||||
*/
|
|
||||||
export function getDashboardHeaderTitle(role?: string | null): string {
|
|
||||||
const normalized = (role ?? "").toLowerCase().trim();
|
|
||||||
if (
|
|
||||||
normalized === "site_supervisor" ||
|
|
||||||
normalized === "project_supervisor" ||
|
|
||||||
normalized === "supervisor"
|
|
||||||
) {
|
|
||||||
return "میز کار سرپرست کارگاه";
|
|
||||||
}
|
|
||||||
return "مدیریت پروژه";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasPermission(user: User | null | undefined, permission: Permission | string): boolean {
|
export function hasPermission(user: User | null | undefined, permission: Permission | string): boolean {
|
||||||
return user?.permissions?.includes(permission) ?? false;
|
return user?.permissions?.includes(permission) ?? false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Link, useNavigate, useParams } from "react-router-dom";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Calendar,
|
Calendar,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -16,15 +15,21 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
UserPlus,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
addProjectMember,
|
||||||
createTask,
|
createTask,
|
||||||
createTaskRelation,
|
createTaskRelation,
|
||||||
deleteTaskRelation,
|
deleteTaskRelation,
|
||||||
getProject,
|
getProject,
|
||||||
getProjectGantt,
|
getProjectGantt,
|
||||||
getTasks,
|
getTasks,
|
||||||
|
getUsers,
|
||||||
|
removeProjectMember,
|
||||||
rescheduleProject,
|
rescheduleProject,
|
||||||
|
updateProject,
|
||||||
updateTask,
|
updateTask,
|
||||||
updateTaskRelation,
|
updateTaskRelation,
|
||||||
} from "../lib/api";
|
} from "../lib/api";
|
||||||
@@ -33,6 +38,7 @@ import type {
|
|||||||
CreateTaskPayload,
|
CreateTaskPayload,
|
||||||
HumanResourceItem,
|
HumanResourceItem,
|
||||||
MaterialItem,
|
MaterialItem,
|
||||||
|
ProjectMember,
|
||||||
Task,
|
Task,
|
||||||
TaskRelation,
|
TaskRelation,
|
||||||
UpdateRelationPayload,
|
UpdateRelationPayload,
|
||||||
@@ -44,6 +50,7 @@ import {
|
|||||||
Chip,
|
Chip,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
ErrorState,
|
ErrorState,
|
||||||
|
InitialAvatar,
|
||||||
Modal,
|
Modal,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
ProgressBar,
|
ProgressBar,
|
||||||
@@ -53,7 +60,7 @@ import {
|
|||||||
} from "../components/ui";
|
} from "../components/ui";
|
||||||
import { JalaliDatePicker } from "../components/JalaliDatePicker";
|
import { JalaliDatePicker } from "../components/JalaliDatePicker";
|
||||||
import { GanttChart } from "../components/GanttChart";
|
import { GanttChart } from "../components/GanttChart";
|
||||||
import { TaskEditModal, HumanResourceInputs } from "../components/TaskEditModal";
|
import { TaskEditModal } from "../components/TaskEditModal";
|
||||||
import { formatDate, isOverdue, qcStatusMeta } from "../lib/format";
|
import { formatDate, isOverdue, qcStatusMeta } from "../lib/format";
|
||||||
import { isoToApiDate } from "../lib/jalali";
|
import { isoToApiDate } from "../lib/jalali";
|
||||||
import { reconcileDates } from "../lib/dates";
|
import { reconcileDates } from "../lib/dates";
|
||||||
@@ -135,6 +142,7 @@ function TreeRow({
|
|||||||
const displayTitle = t.title || t.subject;
|
const displayTitle = t.title || t.subject;
|
||||||
const targetUrl = `/tasks/${t.id || t.op_wp_id}`;
|
const targetUrl = `/tasks/${t.id || t.op_wp_id}`;
|
||||||
const progress = t.progress ?? t.percentage_done ?? 0;
|
const progress = t.progress ?? t.percentage_done ?? 0;
|
||||||
|
// رنگ درصد: ۱۰۰٪ سبز، در حال انجام آبی، صفر خاکستری
|
||||||
const progressCls =
|
const progressCls =
|
||||||
progress >= 100 ? "text-emerald-600" : progress > 0 ? "text-sky-600" : "text-slate-400";
|
progress >= 100 ? "text-emerald-600" : progress > 0 ? "text-sky-600" : "text-slate-400";
|
||||||
|
|
||||||
@@ -163,6 +171,7 @@ function TreeRow({
|
|||||||
<span className="min-w-0 truncate text-[13px] font-medium leading-5 text-slate-800 group-hover:text-sky-600 transition-colors">
|
<span className="min-w-0 truncate text-[13px] font-medium leading-5 text-slate-800 group-hover:text-sky-600 transition-colors">
|
||||||
{displayTitle}
|
{displayTitle}
|
||||||
</span>
|
</span>
|
||||||
|
{/* درصد تکمیل — بلافاصله سمت چپ نام و بالای نوار پیشرفت */}
|
||||||
<span className={`shrink-0 text-[13px] font-bold tabular-nums ${progressCls}`}>
|
<span className={`shrink-0 text-[13px] font-bold tabular-nums ${progressCls}`}>
|
||||||
{progress}٪
|
{progress}٪
|
||||||
</span>
|
</span>
|
||||||
@@ -251,6 +260,7 @@ function TaskCreateModal({
|
|||||||
const [materials, setMaterials] = useState<MaterialItem[]>([]);
|
const [materials, setMaterials] = useState<MaterialItem[]>([]);
|
||||||
const [errorMsg, setErrorMsg] = useState("");
|
const [errorMsg, setErrorMsg] = useState("");
|
||||||
|
|
||||||
|
// روابط پیشنیازی و وابستگیها (FS, SS, FF, SF)
|
||||||
interface DraftRelation {
|
interface DraftRelation {
|
||||||
target_task_id: string;
|
target_task_id: string;
|
||||||
target_task_title: string;
|
target_task_title: string;
|
||||||
@@ -262,16 +272,7 @@ function TaskCreateModal({
|
|||||||
const [relTargetId, setRelTargetId] = useState("");
|
const [relTargetId, setRelTargetId] = useState("");
|
||||||
const [relDirection, setRelDirection] = useState<"predecessor" | "successor">("predecessor");
|
const [relDirection, setRelDirection] = useState<"predecessor" | "successor">("predecessor");
|
||||||
const [relType, setRelType] = useState<"FS" | "SS" | "FF" | "SF">("FS");
|
const [relType, setRelType] = useState<"FS" | "SS" | "FF" | "SF">("FS");
|
||||||
const [relLag, setRelLag] = useState<number | "">("");
|
const [relLag, setRelLag] = useState<number>(0);
|
||||||
|
|
||||||
// بررسی زنده برای کوچکتر بودن تاریخ پایان از شروع
|
|
||||||
const isDateRangeInvalid = Boolean(
|
|
||||||
plannedStart &&
|
|
||||||
plannedEnd &&
|
|
||||||
isoToApiDate(plannedStart) &&
|
|
||||||
isoToApiDate(plannedEnd) &&
|
|
||||||
isoToApiDate(plannedStart)! > isoToApiDate(plannedEnd)!
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -279,7 +280,7 @@ function TaskCreateModal({
|
|||||||
setSelectedChildIds([]);
|
setSelectedChildIds([]);
|
||||||
setDraftRelations([]);
|
setDraftRelations([]);
|
||||||
setRelTargetId("");
|
setRelTargetId("");
|
||||||
setRelLag("");
|
setRelLag(0);
|
||||||
setErrorMsg("");
|
setErrorMsg("");
|
||||||
}
|
}
|
||||||
}, [open, parentTask]);
|
}, [open, parentTask]);
|
||||||
@@ -289,6 +290,7 @@ function TaskCreateModal({
|
|||||||
const created = await createTask(payload);
|
const created = await createTask(payload);
|
||||||
const newTaskId = String(created.id || (created as any).data?.id || (created as any).op_wp_id);
|
const newTaskId = String(created.id || (created as any).data?.id || (created as any).op_wp_id);
|
||||||
|
|
||||||
|
// ۱. انتساب وظایف انتخاب شده به عنوان فرزندان (زیرتسکها)
|
||||||
if (selectedChildIds.length > 0 && newTaskId) {
|
if (selectedChildIds.length > 0 && newTaskId) {
|
||||||
for (const childId of selectedChildIds) {
|
for (const childId of selectedChildIds) {
|
||||||
try {
|
try {
|
||||||
@@ -299,6 +301,7 @@ function TaskCreateModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ۲. ایجاد روابط وابستگی تعریف شده (FS, SS, FF, SF)
|
||||||
if (draftRelations.length > 0 && newTaskId) {
|
if (draftRelations.length > 0 && newTaskId) {
|
||||||
for (const rel of draftRelations) {
|
for (const rel of draftRelations) {
|
||||||
try {
|
try {
|
||||||
@@ -353,6 +356,7 @@ function TaskCreateModal({
|
|||||||
const startIso = isoToApiDate(plannedStart);
|
const startIso = isoToApiDate(plannedStart);
|
||||||
const endIso = isoToApiDate(plannedEnd);
|
const endIso = isoToApiDate(plannedEnd);
|
||||||
|
|
||||||
|
// ۱. اجباری بودن تاریخ شروع و پایان
|
||||||
if (!startIso) {
|
if (!startIso) {
|
||||||
setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است");
|
setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است");
|
||||||
return;
|
return;
|
||||||
@@ -362,11 +366,13 @@ function TaskCreateModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ۲. تاریخ شروع نباید از تاریخ پایان جلوتر باشد
|
||||||
if (startIso > endIso) {
|
if (startIso > endIso) {
|
||||||
setErrorMsg("تاریخ پایان نمیتواند کمتر از تاریخ شروع باشد");
|
setErrorMsg("تاریخ شروع وظیفه نمیتواند بعد از تاریخ اتمام آن باشد");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ۳. انطباق با تاریخهای والد انتخابی
|
||||||
const activeParent = selectedParentId
|
const activeParent = selectedParentId
|
||||||
? allTasks.find((t) => String(t.id || t.op_wp_id) === selectedParentId)
|
? allTasks.find((t) => String(t.id || t.op_wp_id) === selectedParentId)
|
||||||
: null;
|
: null;
|
||||||
@@ -413,6 +419,7 @@ function TaskCreateModal({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// وظایف کاندید برای فرزند بودن (به جز والدی که هماکنون انتخاب شده)
|
||||||
const candidateChildren = allTasks.filter(
|
const candidateChildren = allTasks.filter(
|
||||||
(t) => String(t.id || t.op_wp_id) !== selectedParentId
|
(t) => String(t.id || t.op_wp_id) !== selectedParentId
|
||||||
);
|
);
|
||||||
@@ -437,6 +444,7 @@ function TaskCreateModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ۱. تعیین وظیفه والد (PARENT) */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-semibold text-slate-600">
|
<label className="mb-1 block text-xs font-semibold text-slate-600">
|
||||||
وظیفه والد (سطح بالاتر - Parent)
|
وظیفه والد (سطح بالاتر - Parent)
|
||||||
@@ -464,6 +472,7 @@ function TaskCreateModal({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ۲. تعیین زیرتسکها و فرزندان (CHILD) */}
|
||||||
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-2">
|
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs font-bold text-slate-700">
|
<label className="text-xs font-bold text-slate-700">
|
||||||
@@ -567,14 +576,6 @@ function TaskCreateModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ارور در صورت کمتر بودن تاریخ پایان از شروع */}
|
|
||||||
{isDateRangeInvalid && (
|
|
||||||
<div className="flex items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 p-2.5 text-xs font-bold text-rose-600">
|
|
||||||
<AlertTriangle size={15} className="shrink-0 text-rose-500" />
|
|
||||||
<span>تاریخ پایان نمیتواند کمتر از تاریخ شروع باشد</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs font-semibold text-slate-600">مدت زمان برآورد شده (روز)</label>
|
<label className="mb-1 block text-xs font-semibold text-slate-600">مدت زمان برآورد شده (روز)</label>
|
||||||
<input
|
<input
|
||||||
@@ -594,9 +595,8 @@ function TaskCreateModal({
|
|||||||
setPlannedEnd(next.end);
|
setPlannedEnd(next.end);
|
||||||
setDurationDays(next.days);
|
setDurationDays(next.days);
|
||||||
}}
|
}}
|
||||||
onFocus={(e) => e.target.select()}
|
|
||||||
placeholder="مثال: ۵"
|
placeholder="مثال: ۵"
|
||||||
className={`${inputCls} [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none`}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-[11px] leading-4 text-slate-400">
|
<p className="mt-1 text-[11px] leading-4 text-slate-400">
|
||||||
روز، تاریخ پایان را میسازد: شروع ۱۶ مرداد و ۴ روز ⇒ پایان ۲۰ مرداد.
|
روز، تاریخ پایان را میسازد: شروع ۱۶ مرداد و ۴ روز ⇒ پایان ۲۰ مرداد.
|
||||||
@@ -621,11 +621,7 @@ function TaskCreateModal({
|
|||||||
setMaterials={setMaterials}
|
setMaterials={setMaterials}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<HumanResourceInputs
|
{/* ۳. تعریف روابط وابستگی (FS, SS, FF, SF + lag) در تعریف وظیفه */}
|
||||||
humanResources={humanResources}
|
|
||||||
setHumanResources={setHumanResources}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-3">
|
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-3">
|
||||||
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
|
<div className="flex items-center justify-between border-b border-slate-200 pb-2">
|
||||||
<span className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
|
<span className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
|
||||||
@@ -639,6 +635,7 @@ function TaskCreateModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* لیست روابط پیشنویس */}
|
||||||
{draftRelations.length > 0 && (
|
{draftRelations.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{draftRelations.map((r, idx) => (
|
{draftRelations.map((r, idx) => (
|
||||||
@@ -675,6 +672,7 @@ function TaskCreateModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* فرم افزودن رابطه پیشنویس */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 pt-1 border-t border-slate-200/80">
|
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 pt-1 border-t border-slate-200/80">
|
||||||
<div className="sm:col-span-4">
|
<div className="sm:col-span-4">
|
||||||
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">وظیفه مرتبط</label>
|
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">وظیفه مرتبط</label>
|
||||||
@@ -726,13 +724,8 @@ function TaskCreateModal({
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={relLag}
|
value={relLag}
|
||||||
onChange={(e) => {
|
onChange={(e) => setRelLag(parseInt(e.target.value) || 0)}
|
||||||
const val = e.target.value;
|
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-sky-500 focus:outline-none"
|
||||||
setRelLag(val === "" ? "" : Number(val));
|
|
||||||
}}
|
|
||||||
onFocus={(e) => e.target.select()}
|
|
||||||
placeholder="0"
|
|
||||||
className="h-8 w-full rounded-md border border-slate-300 px-2 text-xs text-slate-800 focus:border-sky-500 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -754,11 +747,11 @@ function TaskCreateModal({
|
|||||||
target_task_title: targetTask?.title || targetTask?.subject || "وظیفه",
|
target_task_title: targetTask?.title || targetTask?.subject || "وظیفه",
|
||||||
direction: relDirection,
|
direction: relDirection,
|
||||||
relation_type: relType,
|
relation_type: relType,
|
||||||
lag_days: Number(relLag) || 0,
|
lag_days: relLag,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
setRelTargetId("");
|
setRelTargetId("");
|
||||||
setRelLag("");
|
setRelLag(0);
|
||||||
}}
|
}}
|
||||||
className="inline-flex h-7 items-center justify-center gap-1 rounded-lg border border-sky-300 bg-sky-50 px-2.5 text-[11px] font-semibold text-sky-700 hover:bg-sky-100 transition-colors"
|
className="inline-flex h-7 items-center justify-center gap-1 rounded-lg border border-sky-300 bg-sky-50 px-2.5 text-[11px] font-semibold text-sky-700 hover:bg-sky-100 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -771,11 +764,7 @@ function TaskCreateModal({
|
|||||||
<button type="button" onClick={onClose} className={btnSecondary}>
|
<button type="button" onClick={onClose} className={btnSecondary}>
|
||||||
انصراف
|
انصراف
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="submit" disabled={createMutation.isPending} className={btnPrimary}>
|
||||||
type="submit"
|
|
||||||
disabled={createMutation.isPending || isDateRangeInvalid}
|
|
||||||
className={btnPrimary}
|
|
||||||
>
|
|
||||||
{createMutation.isPending ? "در حال ثبت..." : "افزودن وظیفه"}
|
{createMutation.isPending ? "در حال ثبت..." : "افزودن وظیفه"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -796,6 +785,7 @@ function CreateRelationModal({
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
initialFromTaskId: string | null;
|
initialFromTaskId: string | null;
|
||||||
|
/** در حالت ویرایش: وابستگی موجود؛ دو سر وابستگی تغییرناپذیرند */
|
||||||
editRelation?: TaskRelation | null;
|
editRelation?: TaskRelation | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -804,32 +794,24 @@ function CreateRelationModal({
|
|||||||
const [fromTaskId, setFromTaskId] = useState(initialFromTaskId || "");
|
const [fromTaskId, setFromTaskId] = useState(initialFromTaskId || "");
|
||||||
const [toTaskId, setToTaskId] = useState("");
|
const [toTaskId, setToTaskId] = useState("");
|
||||||
const [relationType, setRelationType] = useState<"FS" | "SS" | "FF" | "SF">("FS");
|
const [relationType, setRelationType] = useState<"FS" | "SS" | "FF" | "SF">("FS");
|
||||||
const [lagDays, setLagDays] = useState<number | "">("");
|
const [lagDays, setLagDays] = useState(0);
|
||||||
const [errorMsg, setErrorMsg] = useState("");
|
const [errorMsg, setErrorMsg] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialFromTaskId) setFromTaskId(initialFromTaskId);
|
if (initialFromTaskId) setFromTaskId(initialFromTaskId);
|
||||||
}, [initialFromTaskId]);
|
}, [initialFromTaskId]);
|
||||||
|
|
||||||
|
// در حالت ویرایش، فرم با مقادیر فعلی وابستگی پر میشود
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
if (editRelation) {
|
if (editRelation) {
|
||||||
setFromTaskId(editRelation.from_task_id);
|
setFromTaskId(editRelation.from_task_id);
|
||||||
setToTaskId(editRelation.to_task_id);
|
setToTaskId(editRelation.to_task_id);
|
||||||
setRelationType(editRelation.relation_type);
|
setRelationType(editRelation.relation_type);
|
||||||
setLagDays(
|
setLagDays(editRelation.lag_days || 0);
|
||||||
editRelation.lag_days !== undefined && editRelation.lag_days !== null
|
|
||||||
? editRelation.lag_days
|
|
||||||
: ""
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setFromTaskId(initialFromTaskId || "");
|
|
||||||
setToTaskId("");
|
|
||||||
setRelationType("FS");
|
|
||||||
setLagDays("");
|
|
||||||
}
|
}
|
||||||
setErrorMsg("");
|
setErrorMsg("");
|
||||||
}, [open, editRelation, initialFromTaskId]);
|
}, [open, editRelation]);
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
|
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
|
||||||
@@ -842,7 +824,7 @@ function CreateRelationModal({
|
|||||||
invalidate();
|
invalidate();
|
||||||
onClose();
|
onClose();
|
||||||
setToTaskId("");
|
setToTaskId("");
|
||||||
setLagDays("");
|
setLagDays(0);
|
||||||
setErrorMsg("");
|
setErrorMsg("");
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
@@ -951,13 +933,9 @@ function CreateRelationModal({
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={lagDays}
|
value={lagDays}
|
||||||
onChange={(e) => {
|
onChange={(e) => setLagDays(Number(e.target.value))}
|
||||||
const val = e.target.value;
|
placeholder="مثلاً ۲ (گیرش بتن)"
|
||||||
setLagDays(val === "" ? "" : Number(val));
|
className={inputCls}
|
||||||
}}
|
|
||||||
onFocus={(e) => e.target.select()}
|
|
||||||
placeholder="0"
|
|
||||||
className={`${inputCls} [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -980,8 +958,113 @@ function CreateRelationModal({
|
|||||||
{createRelMutation.isPending || updateRelMutation.isPending
|
{createRelMutation.isPending || updateRelMutation.isPending
|
||||||
? "در حال محاسبه..."
|
? "در حال محاسبه..."
|
||||||
: isEdit
|
: isEdit
|
||||||
? "ذخیره تغییرات"
|
? "ذخیره تغییرات"
|
||||||
: "ثبت وابستگی"}
|
: "ثبت وابستگی"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddMemberModal({
|
||||||
|
open,
|
||||||
|
projectId,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
projectId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState("");
|
||||||
|
const [memberRole, setMemberRole] = useState("supervisor");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [errorMsg, setErrorMsg] = useState("");
|
||||||
|
|
||||||
|
const usersQuery = useQuery({ queryKey: ["users"], queryFn: getUsers });
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
addProjectMember(projectId, {
|
||||||
|
user_id: selectedUserId,
|
||||||
|
role: memberRole,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
onClose();
|
||||||
|
setSelectedUserId("");
|
||||||
|
setNotes("");
|
||||||
|
setErrorMsg("");
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
setErrorMsg(err?.message || "خطا در افزودن عضو به پروژه");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedUserId) {
|
||||||
|
setErrorMsg("لطفاً یک کاربر را انتخاب کنید");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} title="افزودن عضو جدید به تیم پروژه" onClose={onClose}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
{errorMsg && <div className="rounded-xl bg-red-50 p-2.5 text-xs text-red-600">{errorMsg}</div>}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-slate-600">انتخاب کاربر *</label>
|
||||||
|
<select
|
||||||
|
value={selectedUserId}
|
||||||
|
onChange={(e) => setSelectedUserId(e.target.value)}
|
||||||
|
className={inputCls}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">-- انتخاب کاربر --</option>
|
||||||
|
{(usersQuery.data ?? []).map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.display_name} ({u.username}) — {u.role_label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-slate-600">نقش در پروژه</label>
|
||||||
|
<select
|
||||||
|
value={memberRole}
|
||||||
|
onChange={(e) => setMemberRole(e.target.value)}
|
||||||
|
className={inputCls}
|
||||||
|
>
|
||||||
|
<option value="supervisor">ناظر اجرایی</option>
|
||||||
|
<option value="manager">مدیر پروژه</option>
|
||||||
|
<option value="qc_inspector">بازرس کیفیت (QC)</option>
|
||||||
|
<option value="subcontractor">پیمانکار جزء</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-slate-600">یادداشت / حدود اختیارات</label>
|
||||||
|
<input
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="یادداشت اختیاری"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<button type="button" onClick={onClose} className={btnSecondary}>
|
||||||
|
انصراف
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={addMutation.isPending} className={btnPrimary}>
|
||||||
|
{addMutation.isPending ? "در حال ثبت..." : "افزودن به تیم"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -999,15 +1082,17 @@ export default function ProjectDetailPage() {
|
|||||||
const canManageProjects = !isSupervisor && hasPermission(user, Perm.projectsUpdate);
|
const canManageProjects = !isSupervisor && hasPermission(user, Perm.projectsUpdate);
|
||||||
|
|
||||||
const projectId = id ?? "";
|
const projectId = id ?? "";
|
||||||
const [activeTab, setActiveTab] = useState<"tasks" | "gantt">("tasks");
|
const [activeTab, setActiveTab] = useState<"tasks" | "gantt" | "members">("tasks");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const [showTaskModal, setShowTaskModal] = useState(false);
|
const [showTaskModal, setShowTaskModal] = useState(false);
|
||||||
const [parentTaskForModal, setParentTaskForModal] = useState<Task | null>(null);
|
const [parentTaskForModal, setParentTaskForModal] = useState<Task | null>(null);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [taskToEdit, setTaskToEdit] = useState<Task | null>(null);
|
const [taskToEdit, setTaskToEdit] = useState<Task | null>(null);
|
||||||
|
const [showMemberModal, setShowMemberModal] = useState(false);
|
||||||
const [showRelationModal, setShowRelationModal] = useState(false);
|
const [showRelationModal, setShowRelationModal] = useState(false);
|
||||||
const [relationFromTaskId, setRelationFromTaskId] = useState<string | null>(null);
|
const [relationFromTaskId, setRelationFromTaskId] = useState<string | null>(null);
|
||||||
|
/** وابستگی در حال ویرایش (null = حالت تعریف جدید) */
|
||||||
const [relationToEdit, setRelationToEdit] = useState<TaskRelation | null>(null);
|
const [relationToEdit, setRelationToEdit] = useState<TaskRelation | null>(null);
|
||||||
const initializedFor = useRef<string | null>(null);
|
const initializedFor = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -1072,12 +1157,21 @@ export default function ProjectDetailPage() {
|
|||||||
setShowRelationModal(true);
|
setShowRelationModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** ویرایش یک وابستگی موجود از داخل کشوی تسک */
|
||||||
const handleEditRelation = (relation: TaskRelation) => {
|
const handleEditRelation = (relation: TaskRelation) => {
|
||||||
setRelationFromTaskId(null);
|
setRelationFromTaskId(null);
|
||||||
setRelationToEdit(relation);
|
setRelationToEdit(relation);
|
||||||
setShowRelationModal(true);
|
setShowRelationModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const removeMemberMutation = useMutation({
|
||||||
|
mutationFn: (memberId: string) => removeProjectMember(projectId, memberId),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const deleteRelationMutation = useMutation({
|
const deleteRelationMutation = useMutation({
|
||||||
mutationFn: (relId: string) => deleteTaskRelation(projectId, relId),
|
mutationFn: (relId: string) => deleteTaskRelation(projectId, relId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -1095,6 +1189,18 @@ export default function ProjectDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateStatusMutation = useMutation({
|
||||||
|
mutationFn: (newStatus: string) => updateProject(projectId, { status: newStatus }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("وضعیت پروژه با موفقیت تغییر کرد");
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["project", projectId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err?.message || "خطا در تغییر وضعیت پروژه");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
void projectQuery.refetch();
|
void projectQuery.refetch();
|
||||||
void tasksQuery.refetch();
|
void tasksQuery.refetch();
|
||||||
@@ -1141,6 +1247,58 @@ export default function ProjectDetailPage() {
|
|||||||
<p className="text-xs font-mono font-semibold text-sky-600">{project.identifier}</p>
|
<p className="text-xs font-mono font-semibold text-sky-600">{project.identifier}</p>
|
||||||
<h2 className="text-base font-bold text-slate-800 mt-0.5">{project.name}</h2>
|
<h2 className="text-base font-bold text-slate-800 mt-0.5">{project.name}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs text-slate-500 font-medium">وضعیت:</span>
|
||||||
|
{canManageProjects ? (
|
||||||
|
<select
|
||||||
|
value={project.status?.toUpperCase() || "ACTIVE"}
|
||||||
|
onChange={(e) => updateStatusMutation.mutate(e.target.value)}
|
||||||
|
disabled={updateStatusMutation.isPending}
|
||||||
|
aria-label="تغییر وضعیت پروژه"
|
||||||
|
className={`rounded-lg px-2.5 py-1 text-xs font-semibold border transition-all cursor-pointer shadow-sm ${
|
||||||
|
(project.status || "").toUpperCase() === "ACTIVE"
|
||||||
|
? "bg-emerald-50 text-emerald-700 border-emerald-300 focus:ring-emerald-200"
|
||||||
|
: (project.status || "").toUpperCase() === "COMPLETED"
|
||||||
|
? "bg-sky-50 text-sky-700 border-sky-300 focus:ring-sky-200"
|
||||||
|
: (project.status || "").toUpperCase() === "ON_HOLD"
|
||||||
|
? "bg-amber-50 text-amber-700 border-amber-300 focus:ring-amber-200"
|
||||||
|
: (project.status || "").toUpperCase() === "PLANNED" || (project.status || "").toUpperCase() === "PLANNING"
|
||||||
|
? "bg-amber-50 text-amber-700 border-amber-300 focus:ring-amber-200"
|
||||||
|
: "bg-slate-50 text-slate-700 border-slate-300 focus:ring-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<option value="ACTIVE">در حال اجرا (ACTIVE)</option>
|
||||||
|
<option value="PLANNED">برنامهریزی (PLANNED)</option>
|
||||||
|
<option value="ON_HOLD">متوقف (ON_HOLD)</option>
|
||||||
|
<option value="COMPLETED">تکمیلشده (COMPLETED)</option>
|
||||||
|
<option value="CANCELLED">لغو شده (CANCELLED)</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
tone={
|
||||||
|
project.status === "ACTIVE"
|
||||||
|
? "green"
|
||||||
|
: project.status === "COMPLETED"
|
||||||
|
? "sky"
|
||||||
|
: project.status === "ON_HOLD" || project.status === "PLANNED"
|
||||||
|
? "amber"
|
||||||
|
: "slate"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{project.status === "ACTIVE"
|
||||||
|
? "در حال اجرا"
|
||||||
|
: project.status === "PLANNED" || project.status === "PLANNING"
|
||||||
|
? "برنامهریزی"
|
||||||
|
: project.status === "ON_HOLD"
|
||||||
|
? "متوقف"
|
||||||
|
: project.status === "COMPLETED"
|
||||||
|
? "تکمیلشده"
|
||||||
|
: project.status === "CANCELLED"
|
||||||
|
? "لغو شده"
|
||||||
|
: project.status}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{project.location && (
|
{project.location && (
|
||||||
@@ -1190,6 +1348,7 @@ export default function ProjectDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ۳ تب: ساختار شکست کار / گانت و زمانبندی / اعضای تیم */}
|
||||||
<div className="mb-3 flex rounded-xl bg-slate-200/80 p-1 text-xs font-semibold text-slate-600">
|
<div className="mb-3 flex rounded-xl bg-slate-200/80 p-1 text-xs font-semibold text-slate-600">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1209,6 +1368,15 @@ export default function ProjectDetailPage() {
|
|||||||
>
|
>
|
||||||
نمودار گانت و زمانبندی
|
نمودار گانت و زمانبندی
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab("members")}
|
||||||
|
className={`flex-1 rounded-lg py-1.5 text-center transition-all ${
|
||||||
|
activeTab === "members" ? "bg-white text-sky-700 shadow-sm" : "hover:text-slate-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
اعضای تیم ({project?.members?.length ?? project?.member_count ?? 0})
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === "tasks" && (
|
{activeTab === "tasks" && (
|
||||||
@@ -1321,6 +1489,69 @@ export default function ProjectDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "members" && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-bold text-slate-700">اعضای اختصاصیافته به این پروژه</h3>
|
||||||
|
{canManageProjects && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowMemberModal(true)}
|
||||||
|
className="inline-flex h-8 items-center gap-1 rounded-lg bg-sky-50 px-2.5 text-xs font-semibold text-sky-700 hover:bg-sky-100 transition-colors"
|
||||||
|
>
|
||||||
|
<UserPlus size={14} />
|
||||||
|
<span>افزودن عضو</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(!project?.members || project.members.length === 0) ? (
|
||||||
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
icon={Users}
|
||||||
|
title="عضوی ثبت نشده است"
|
||||||
|
description="برای تخصیص ناظران یا کارشناسان به این پروژه، دکمه «افزودن عضو» را بزنید."
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{project.members.map((m: ProjectMember) => (
|
||||||
|
<Card key={m.id} className="p-3 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<InitialAvatar name={m.display_name || m.username} className="h-8 w-8" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-bold text-slate-800 truncate">
|
||||||
|
{m.display_name || m.username}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
{m.role === "manager"
|
||||||
|
? "مدیر پروژه"
|
||||||
|
: m.role === "supervisor"
|
||||||
|
? "ناظر اجرایی"
|
||||||
|
: m.role === "qc_inspector"
|
||||||
|
? "بازرس QC"
|
||||||
|
: m.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canManageProjects && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeMemberMutation.mutate(m.id)}
|
||||||
|
disabled={removeMemberMutation.isPending}
|
||||||
|
title="حذف عضو"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 hover:bg-red-50 hover:text-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TaskCreateModal
|
<TaskCreateModal
|
||||||
@@ -1357,6 +1588,13 @@ export default function ProjectDetailPage() {
|
|||||||
setRelationToEdit(null);
|
setRelationToEdit(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AddMemberModal
|
||||||
|
open={showMemberModal}
|
||||||
|
projectId={projectId}
|
||||||
|
onClose={() => setShowMemberModal(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Folder, Plus, RefreshCw, ChevronLeft } from "lucide-react";
|
import { Folder, Plus, RefreshCw, Users, MapPin, Calendar } from "lucide-react";
|
||||||
import { createProject, getProjects } from "../lib/api";
|
import { createProject, getProjects } from "../lib/api";
|
||||||
import type { CreateProjectPayload, Project } from "../lib/api";
|
import type { CreateProjectPayload, Project } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PanelsMenu,
|
PanelsMenu,
|
||||||
|
ProgressBar,
|
||||||
btnPrimary,
|
btnPrimary,
|
||||||
btnSecondary,
|
btnSecondary,
|
||||||
inputCls,
|
inputCls,
|
||||||
@@ -33,84 +34,73 @@ function ProjectCard({ project }: { project: Project }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentStatus = project.status ? statusLabels[project.status.toUpperCase()] : null;
|
const currentStatus = project.status ? statusLabels[project.status.toUpperCase()] : null;
|
||||||
const progressVal =
|
|
||||||
project.progress !== undefined
|
|
||||||
? Math.round(project.progress)
|
|
||||||
: project.stats.total > 0
|
|
||||||
? Math.round(
|
|
||||||
Math.max(
|
|
||||||
0,
|
|
||||||
((project.stats.total - project.stats.overdue - project.stats.failed_qc) /
|
|
||||||
project.stats.total) *
|
|
||||||
100
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={`/projects/${targetId}`} className="group block">
|
<Link to={`/projects/${targetId}`} className="block">
|
||||||
<Card className="p-3 shadow-xs transition-all duration-150 hover:border-slate-300 hover:shadow-sm active:bg-slate-50/90">
|
<Card className="p-4 active:bg-slate-100/60 transition-all hover:border-slate-300">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-start justify-between gap-2">
|
||||||
{/* بلوک محتوایی ۲ سطره در سمت راست */}
|
<div className="min-w-0 flex-1">
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* سطر ۱: نام پروژه و بج پیل وضعیت */}
|
<p className="min-w-0 truncate text-sm font-bold text-slate-800">{project.name}</p>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
{currentStatus && <Chip tone={currentStatus.tone}>{currentStatus.label}</Chip>}
|
||||||
<p className="min-w-0 truncate text-xs font-bold text-slate-800 group-hover:text-sky-700 transition-colors">
|
|
||||||
{project.name}
|
|
||||||
</p>
|
|
||||||
{project.stats.failed_qc > 0 ? (
|
|
||||||
<span className="inline-flex shrink-0 items-center rounded-full bg-red-50 border border-red-200/60 px-2 py-0.5 text-[10px] font-bold text-red-600">
|
|
||||||
{project.stats.failed_qc} مردود
|
|
||||||
</span>
|
|
||||||
) : currentStatus ? (
|
|
||||||
<Chip tone={currentStatus.tone} className="text-[10px] py-0 px-2 shrink-0">
|
|
||||||
{currentStatus.label}
|
|
||||||
</Chip>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex shrink-0 items-center rounded-full bg-emerald-50 border border-emerald-200/60 px-2 py-0.5 text-[10px] font-medium text-emerald-700">
|
|
||||||
در حال اجرا
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<p dir="ltr" className="mt-0.5 text-right font-mono text-[11px] text-slate-400">
|
||||||
|
{project.identifier}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* سطر ۲: پیل تعداد فعالیت در راست — نوار پیشرفت باریک با درصد در چپ */}
|
{project.location && (
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<span className="inline-flex items-center gap-1">
|
||||||
<span className="inline-flex shrink-0 items-center rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
<MapPin size={13} className="text-slate-400" />
|
||||||
{project.stats.total} فعالیت
|
{project.location}
|
||||||
</span>
|
</span>
|
||||||
{project.stats.awaiting_qc > 0 && (
|
</div>
|
||||||
<span className="inline-flex shrink-0 items-center rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700">
|
)}
|
||||||
{project.stats.awaiting_qc} منتظر QC
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{project.location && (
|
|
||||||
<span className="truncate text-[10px] text-slate-400 hidden sm:inline">
|
|
||||||
• {project.location}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 w-28 sm:w-32 shrink-0 justify-end">
|
{(project.planned_start_jalali || project.planned_end_jalali || project.actual_start_jalali) && (
|
||||||
<div dir="rtl" className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
|
||||||
<div
|
{(project.planned_start_jalali || project.planned_end_jalali) && (
|
||||||
className={`h-full rounded-full transition-all duration-300 ${
|
<span className="inline-flex items-center gap-1">
|
||||||
progressVal >= 100 ? "bg-emerald-500" : "bg-sky-500"
|
<Calendar size={12} className="text-slate-400" />
|
||||||
}`}
|
<span>برنامه:</span>
|
||||||
style={{ width: `${progressVal}%` }}
|
<span className="font-medium text-slate-700">
|
||||||
/>
|
{project.planned_start_jalali || "—"} تا {project.planned_end_jalali || "—"}
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-left text-[10px] font-bold tabular-nums text-slate-500 w-7">
|
|
||||||
{progressVal}٪
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
|
)}
|
||||||
|
{project.actual_start_jalali && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-emerald-50 px-1.5 py-0.5 text-emerald-700">
|
||||||
|
<span>واقعی:</span>
|
||||||
|
<span>{project.actual_start_jalali}</span>
|
||||||
|
{project.actual_end_jalali && <span> تا {project.actual_end_jalali}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{project.progress !== undefined && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-slate-500 mb-1">
|
||||||
|
<span>پیشرفت پروژه</span>
|
||||||
|
<span className="font-bold tabular-nums text-slate-700">{Math.round(project.progress)}٪</span>
|
||||||
</div>
|
</div>
|
||||||
|
<ProgressBar value={project.progress} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* فلش تعاملی در منتهیالیه چپ با تراز عمودی کامل روی کل ارتفاع کارت */}
|
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center self-center text-slate-400 transition-all group-hover:text-sky-600 group-hover:bg-sky-50 group-active:-translate-x-0.5 rounded-lg">
|
<Chip tone="sky">کل: {project.stats.total}</Chip>
|
||||||
<ChevronLeft size={18} />
|
<Chip tone="amber">انتظار QC: {project.stats.awaiting_qc}</Chip>
|
||||||
</div>
|
{project.stats.failed_qc > 0 && <Chip tone="red">مردود: {project.stats.failed_qc}</Chip>}
|
||||||
|
{project.stats.overdue > 0 && <Chip tone="red">عقبافتاده: {project.stats.overdue}</Chip>}
|
||||||
|
{project.member_count !== undefined && project.member_count > 0 && (
|
||||||
|
<span className="ms-auto inline-flex items-center gap-1 text-[11px] text-slate-400">
|
||||||
|
<Users size={12} />
|
||||||
|
{project.member_count} عضو
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -62,16 +62,6 @@ import { isoToApiDate } from "../lib/jalali";
|
|||||||
|
|
||||||
const quickValues = [0, 25, 50, 75, 100];
|
const quickValues = [0, 25, 50, 75, 100];
|
||||||
|
|
||||||
function parseCleanNumber(raw: string): number {
|
|
||||||
if (!raw) return 0;
|
|
||||||
const clean = raw
|
|
||||||
.replace(/[۰-۹]/g, (d) => "۰۱۲۳۴۵۶۷۸۹".indexOf(d).toString())
|
|
||||||
.replace(/[٠-٩]/g, (d) => "٠١٢٣٤٥٦٧٨٩".indexOf(d).toString())
|
|
||||||
.replace(/[^0-9.]/g, "");
|
|
||||||
const val = parseFloat(clean);
|
|
||||||
return isNaN(val) ? 0 : Math.max(0, val);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionCard({ title, children }: { title: ReactNode; children: ReactNode }) {
|
function SectionCard({ title, children }: { title: ReactNode; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
@@ -81,6 +71,10 @@ function SectionCard({ title, children }: { title: ReactNode; children: ReactNod
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------
|
||||||
|
// صفحه جزئیات وظیفه
|
||||||
|
// ----------------------------------------
|
||||||
|
|
||||||
export default function TaskDetailPage() {
|
export default function TaskDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -105,6 +99,7 @@ export default function TaskDetailPage() {
|
|||||||
|
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
|
/** ISO میلادی — ارسال با isoToApiDate */
|
||||||
const [startDate, setStartDate] = useState<string | null>(null);
|
const [startDate, setStartDate] = useState<string | null>(null);
|
||||||
const [dueDate, setDueDate] = useState<string | null>(null);
|
const [dueDate, setDueDate] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -114,14 +109,7 @@ export default function TaskDetailPage() {
|
|||||||
const [consumedMaterials, setConsumedMaterials] = useState<MaterialConsumption[]>([]);
|
const [consumedMaterials, setConsumedMaterials] = useState<MaterialConsumption[]>([]);
|
||||||
const [progressNote, setProgressNote] = useState("");
|
const [progressNote, setProgressNote] = useState("");
|
||||||
|
|
||||||
const isDateRangeInvalid = Boolean(
|
// محاسبه مجموع مصارف ثبتشده قبلی از سوابق گزارشهای وظیفه
|
||||||
startDate &&
|
|
||||||
dueDate &&
|
|
||||||
isoToApiDate(startDate) &&
|
|
||||||
isoToApiDate(dueDate) &&
|
|
||||||
isoToApiDate(startDate)! > isoToApiDate(dueDate)!
|
|
||||||
);
|
|
||||||
|
|
||||||
const pastConsumptionMap = useMemo(() => {
|
const pastConsumptionMap = useMemo(() => {
|
||||||
const map = new Map<string, { total: number; unit: string }>();
|
const map = new Map<string, { total: number; unit: string }>();
|
||||||
if (!task?.progress_logs) return map;
|
if (!task?.progress_logs) return map;
|
||||||
@@ -160,6 +148,7 @@ export default function TaskDetailPage() {
|
|||||||
setConsumedHr([]);
|
setConsumedHr([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// یکپارچهسازی مصالح تخصیصیافته و مصارف قبلی
|
||||||
const items: MaterialConsumption[] = [];
|
const items: MaterialConsumption[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
@@ -169,7 +158,6 @@ export default function TaskDetailPage() {
|
|||||||
seen.add(key);
|
seen.add(key);
|
||||||
const past = pastConsumptionMap.get(key)?.total || 0;
|
const past = pastConsumptionMap.get(key)?.total || 0;
|
||||||
const alloc = Number(m.count) || 0;
|
const alloc = Number(m.count) || 0;
|
||||||
const isOver = alloc > 0 && past > alloc;
|
|
||||||
items.push({
|
items.push({
|
||||||
name: m.name,
|
name: m.name,
|
||||||
unit: m.unit || "",
|
unit: m.unit || "",
|
||||||
@@ -178,13 +166,14 @@ export default function TaskDetailPage() {
|
|||||||
previous_consumed: past,
|
previous_consumed: past,
|
||||||
total_consumed: past,
|
total_consumed: past,
|
||||||
remaining_amount: Math.max(0, alloc - past),
|
remaining_amount: Math.max(0, alloc - past),
|
||||||
is_over_allocated: isOver,
|
is_over_allocated: alloc > 0 ? past > alloc : past > 0,
|
||||||
over_allocated_amount: isOver ? past - alloc : 0,
|
over_allocated_amount: Math.max(0, past - alloc),
|
||||||
note: "",
|
note: "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// مصالحی که در سوابق قبل مصرف شدهاند ولی در تخصیص اولیه نبودهاند
|
||||||
pastConsumptionMap.forEach((val, key) => {
|
pastConsumptionMap.forEach((val, key) => {
|
||||||
if (!seen.has(key)) {
|
if (!seen.has(key)) {
|
||||||
items.push({
|
items.push({
|
||||||
@@ -195,8 +184,8 @@ export default function TaskDetailPage() {
|
|||||||
previous_consumed: val.total,
|
previous_consumed: val.total,
|
||||||
total_consumed: val.total,
|
total_consumed: val.total,
|
||||||
remaining_amount: 0,
|
remaining_amount: 0,
|
||||||
is_over_allocated: false,
|
is_over_allocated: val.total > 0,
|
||||||
over_allocated_amount: 0,
|
over_allocated_amount: val.total,
|
||||||
note: "خارج از سقف مصوب",
|
note: "خارج از سقف مصوب",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -208,12 +197,14 @@ export default function TaskDetailPage() {
|
|||||||
|
|
||||||
const invalidateTask = () => {
|
const invalidateTask = () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["task", taskIdParam] });
|
void queryClient.invalidateQueries({ queryKey: ["task", taskIdParam] });
|
||||||
|
// کلید QC با همان شناسهای است که صفحههای QC استفاده میکنند
|
||||||
void queryClient.invalidateQueries({ queryKey: ["task-qc", currentTaskId] });
|
void queryClient.invalidateQueries({ queryKey: ["task-qc", currentTaskId] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["today"] });
|
void queryClient.invalidateQueries({ queryKey: ["today"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const progressMutation = useMutation({
|
const progressMutation = useMutation({
|
||||||
|
// ثبت پیشرفت کار همراه با مصرف واقعی نیروی انسانی و مصالح
|
||||||
mutationFn: (payload: TaskProgressPayload) => updateTaskProgress(currentTaskId, payload),
|
mutationFn: (payload: TaskProgressPayload) => updateTaskProgress(currentTaskId, payload),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("پیشرفت کار و گزارش مصرف منابع با موفقیت ثبت شد");
|
toast.success("پیشرفت کار و گزارش مصرف منابع با موفقیت ثبت شد");
|
||||||
@@ -224,7 +215,8 @@ export default function TaskDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleMaterialAmountChange = (idx: number, rawVal: string) => {
|
const handleMaterialAmountChange = (idx: number, rawVal: string) => {
|
||||||
const amt = parseCleanNumber(rawVal);
|
const val = parseFloat(rawVal);
|
||||||
|
const amt = Math.max(0, isNaN(val) ? 0 : val);
|
||||||
setConsumedMaterials((prev) => {
|
setConsumedMaterials((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
const item = { ...next[idx] };
|
const item = { ...next[idx] };
|
||||||
@@ -232,8 +224,8 @@ export default function TaskDetailPage() {
|
|||||||
const prevC = item.previous_consumed ?? 0;
|
const prevC = item.previous_consumed ?? 0;
|
||||||
const tot = prevC + amt;
|
const tot = prevC + amt;
|
||||||
const rem = Math.max(0, alloc - tot);
|
const rem = Math.max(0, alloc - tot);
|
||||||
const isOver = alloc > 0 && tot > alloc;
|
const isOver = alloc > 0 ? tot > alloc : tot > 0;
|
||||||
const overAmt = isOver ? tot - alloc : 0;
|
const overAmt = Math.max(0, tot - alloc);
|
||||||
|
|
||||||
item.amount = amt;
|
item.amount = amt;
|
||||||
item.total_consumed = tot;
|
item.total_consumed = tot;
|
||||||
@@ -258,7 +250,7 @@ export default function TaskDetailPage() {
|
|||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
name: "",
|
name: "",
|
||||||
unit: "عدد",
|
unit: "",
|
||||||
amount: 0,
|
amount: 0,
|
||||||
allocated_amount: 0,
|
allocated_amount: 0,
|
||||||
previous_consumed: 0,
|
previous_consumed: 0,
|
||||||
@@ -266,7 +258,7 @@ export default function TaskDetailPage() {
|
|||||||
remaining_amount: 0,
|
remaining_amount: 0,
|
||||||
is_over_allocated: false,
|
is_over_allocated: false,
|
||||||
over_allocated_amount: 0,
|
over_allocated_amount: 0,
|
||||||
note: "مصالح خارج از برنامه",
|
note: "مصالح مازاد / خارج از برنامه",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
@@ -281,7 +273,7 @@ export default function TaskDetailPage() {
|
|||||||
const start = isoToApiDate(startDate);
|
const start = isoToApiDate(startDate);
|
||||||
const due = isoToApiDate(dueDate);
|
const due = isoToApiDate(dueDate);
|
||||||
if (start && due && start > due) {
|
if (start && due && start > due) {
|
||||||
throw new ApiError("تاریخ پایان نمیتواند کمتر از تاریخ شروع باشد", 400, "VALIDATION");
|
throw new ApiError("تاریخ شروع یک وظیفه نمیتواند بعد از تاریخ اتمام آن باشد", 400, "VALIDATION");
|
||||||
}
|
}
|
||||||
if (start) payload.start_date = start;
|
if (start) payload.start_date = start;
|
||||||
if (due) payload.due_date = due;
|
if (due) payload.due_date = due;
|
||||||
@@ -294,6 +286,7 @@ export default function TaskDetailPage() {
|
|||||||
onError: (err) => toast.error(err instanceof ApiError ? err.message : "ثبت تاریخها ناموفق بود"),
|
onError: (err) => toast.error(err instanceof ApiError ? err.message : "ثبت تاریخها ناموفق بود"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ۱. وضعیت و جهش ویرایش منابع تخصیصیافته به وظیفه
|
||||||
const [showEditResourcesModal, setShowEditResourcesModal] = useState(false);
|
const [showEditResourcesModal, setShowEditResourcesModal] = useState(false);
|
||||||
const [editMaterials, setEditMaterials] = useState<MaterialItem[]>([]);
|
const [editMaterials, setEditMaterials] = useState<MaterialItem[]>([]);
|
||||||
|
|
||||||
@@ -317,6 +310,7 @@ export default function TaskDetailPage() {
|
|||||||
toast.error(err instanceof ApiError ? err.message : "خطا در بهروزرسانی منابع وظیفه"),
|
toast.error(err instanceof ApiError ? err.message : "خطا در بهروزرسانی منابع وظیفه"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ۲. وضعیت و جهشهای مدیریت روابط وابستگی (FS, SS, FF, SF)
|
||||||
const [showAddRelationModal, setShowAddRelationModal] = useState(false);
|
const [showAddRelationModal, setShowAddRelationModal] = useState(false);
|
||||||
const [relTargetId, setRelTargetId] = useState("");
|
const [relTargetId, setRelTargetId] = useState("");
|
||||||
const [relDirection, setRelDirection] = useState<"predecessor" | "successor">("predecessor");
|
const [relDirection, setRelDirection] = useState<"predecessor" | "successor">("predecessor");
|
||||||
@@ -342,7 +336,7 @@ export default function TaskDetailPage() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی");
|
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی (احتمال وجود دور یا ورودی نامعتبر)");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -405,6 +399,8 @@ export default function TaskDetailPage() {
|
|||||||
const projectLabel = project?.name ?? "—";
|
const projectLabel = project?.name ?? "—";
|
||||||
const overdue = isOverdue(task.due_date);
|
const overdue = isOverdue(task.due_date);
|
||||||
|
|
||||||
|
// مجوزهای نقش: ناظر پیشرفت را ثبت و بازرسی را اجرا میکند، اما ساختار QC و
|
||||||
|
// تاریخهای برنامه و ویرایش تسک را تغییر نمیدهد (سرور هم مستقل همین را بررسی میکند).
|
||||||
const isSupervisor = (user?.role as string) === "project_supervisor" || (user?.role as string) === "supervisor";
|
const isSupervisor = (user?.role as string) === "project_supervisor" || (user?.role as string) === "supervisor";
|
||||||
const canDefineQc = !isSupervisor && hasPermission(user, Perm.qcDefine);
|
const canDefineQc = !isSupervisor && hasPermission(user, Perm.qcDefine);
|
||||||
const canExecuteQc = hasPermission(user, Perm.qcExecute) || isSupervisor;
|
const canExecuteQc = hasPermission(user, Perm.qcExecute) || isSupervisor;
|
||||||
@@ -423,6 +419,7 @@ export default function TaskDetailPage() {
|
|||||||
const preStatus = qcSummaryStatusMeta(readiness?.pre?.status || "none");
|
const preStatus = qcSummaryStatusMeta(readiness?.pre?.status || "none");
|
||||||
const execStatus = qcSummaryStatusMeta(readiness?.exec?.status || "none");
|
const execStatus = qcSummaryStatusMeta(readiness?.exec?.status || "none");
|
||||||
|
|
||||||
|
// پیشبینی قواعد سرور: شروع نیازمند تأیید «پیش از اجرا» و ۱۰۰٪ نیازمند تأیید همه بازرسیها
|
||||||
const blockedByStart = progress > 0 && !readiness.can_start;
|
const blockedByStart = progress > 0 && !readiness.can_start;
|
||||||
const blockedByComplete = progress >= 100 && !readiness.can_complete;
|
const blockedByComplete = progress >= 100 && !readiness.can_complete;
|
||||||
const progressBlocked = blockedByStart || blockedByComplete;
|
const progressBlocked = blockedByStart || blockedByComplete;
|
||||||
@@ -454,6 +451,7 @@ export default function TaskDetailPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-3 px-4 pb-28 pt-4">
|
<div className="space-y-3 px-4 pb-28 pt-4">
|
||||||
|
{/* کارت اطلاعات اصلی */}
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p className="text-base font-bold leading-6 text-slate-800">{task.subject}</p>
|
<p className="text-base font-bold leading-6 text-slate-800">{task.subject}</p>
|
||||||
@@ -495,7 +493,7 @@ export default function TaskDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* کارت پیشرفت و مدیریت یکپارچه مصالح */}
|
{/* کارت یکپارچه پیشرفت، تخصیص و گزارش مصرف مصالح و نیروی انسانی */}
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
@@ -540,6 +538,7 @@ export default function TaskDetailPage() {
|
|||||||
className="mt-3 w-full accent-sky-600"
|
className="mt-3 w-full accent-sky-600"
|
||||||
aria-label="درصد پیشرفت"
|
aria-label="درصد پیشرفت"
|
||||||
/>
|
/>
|
||||||
|
{/* dir=ltr فقط برای ترتیب چشمی دکمهها: ۰ تا ۱۰۰ از چپ به راست */}
|
||||||
<div dir="ltr" className="mt-2 grid grid-cols-5 gap-1.5">
|
<div dir="ltr" className="mt-2 grid grid-cols-5 gap-1.5">
|
||||||
{quickValues.map((v) => (
|
{quickValues.map((v) => (
|
||||||
<button
|
<button
|
||||||
@@ -573,7 +572,7 @@ export default function TaskDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* بخش مصالح و تجهیزات مصرفی */}
|
{/* ۱. بخش مصالح و تجهیزات: تخصیص، مصارف قبلی، مصرف جاری و مانده */}
|
||||||
<div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 space-y-3 shadow-2xs">
|
<div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 space-y-3 shadow-2xs">
|
||||||
<div className="flex items-center justify-between border-b border-slate-100 pb-2 gap-2">
|
<div className="flex items-center justify-between border-b border-slate-100 pb-2 gap-2">
|
||||||
<span className="text-xs font-bold text-slate-900 flex items-center gap-1.5 min-w-0">
|
<span className="text-xs font-bold text-slate-900 flex items-center gap-1.5 min-w-0">
|
||||||
@@ -583,9 +582,9 @@ export default function TaskDetailPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAddCustomMaterial}
|
onClick={handleAddCustomMaterial}
|
||||||
className="shrink-0 text-[11px] font-semibold text-amber-800 hover:text-amber-950 bg-amber-50 border border-amber-200 rounded-lg px-2.5 py-1 flex items-center gap-1 shadow-2xs hover:bg-amber-100 transition-colors"
|
className="shrink-0 text-[10px] font-semibold text-amber-800 hover:text-amber-950 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1 flex items-center gap-1 shadow-2xs hover:bg-amber-100 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={13} />
|
<Plus size={12} />
|
||||||
<span>افزودن مصالح خارج برنامه</span>
|
<span>افزودن مصالح خارج برنامه</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -595,15 +594,15 @@ export default function TaskDetailPage() {
|
|||||||
مصالح یا تجهیزاتی برای این وظیفه تعیین نشده است. میتوانید با دکمه بالا مصالح جدید اضافه کنید.
|
مصالح یا تجهیزاتی برای این وظیفه تعیین نشده است. میتوانید با دکمه بالا مصالح جدید اضافه کنید.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2.5">
|
||||||
{consumedMaterials.map((item, idx) => {
|
{consumedMaterials.map((item, idx) => {
|
||||||
const alloc = item.allocated_amount || 0;
|
const alloc = item.allocated_amount || 0;
|
||||||
const prevC = item.previous_consumed || 0;
|
const prevC = item.previous_consumed || 0;
|
||||||
const currentC = item.amount || 0;
|
const currentC = item.amount || 0;
|
||||||
const totC = prevC + currentC;
|
const totC = prevC + currentC;
|
||||||
const rem = Math.max(0, alloc - totC);
|
const rem = Math.max(0, alloc - totC);
|
||||||
const isOver = alloc > 0 && totC > alloc;
|
const isOver = alloc > 0 ? totC > alloc : totC > 0;
|
||||||
const overAmt = isOver ? totC - alloc : 0;
|
const overAmt = Math.max(0, totC - alloc);
|
||||||
const pctConsumed = alloc > 0 ? Math.min(100, Math.round((totC / alloc) * 100)) : 100;
|
const pctConsumed = alloc > 0 ? Math.min(100, Math.round((totC / alloc) * 100)) : 100;
|
||||||
const specs =
|
const specs =
|
||||||
task?.materials?.find(
|
task?.materials?.find(
|
||||||
@@ -613,50 +612,40 @@ export default function TaskDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className={`rounded-xl border p-3 space-y-3 bg-white shadow-2xs transition-all ${
|
className={`rounded-xl border p-2.5 space-y-2.5 bg-white shadow-2xs transition-all ${
|
||||||
isOver ? "border-rose-300 ring-1 ring-rose-200 bg-rose-50/10" : "border-slate-200"
|
isOver ? "border-rose-300 ring-1 ring-rose-200 bg-rose-50/10" : "border-slate-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{/* ۱. سطر عنوان، واحد و مشخصات کالا */}
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{alloc === 0 ? (
|
{alloc === 0 ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 w-full">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<div>
|
<input
|
||||||
<label className="text-[10px] font-semibold text-slate-500 block mb-0.5">
|
value={item.name}
|
||||||
نام مصالح یا کالا
|
onChange={(e) => handleMaterialFieldChange(idx, "name", e.target.value)}
|
||||||
</label>
|
placeholder="نام مصالح..."
|
||||||
<input
|
className="h-7 w-36 rounded border border-amber-300 bg-amber-50/40 px-2 text-xs font-bold text-slate-800 focus:outline-none focus:ring-1 focus:ring-amber-500"
|
||||||
value={item.name}
|
/>
|
||||||
onChange={(e) => handleMaterialFieldChange(idx, "name", e.target.value)}
|
<input
|
||||||
placeholder="مثلاً: آجر فشاری، سیمان..."
|
value={item.unit}
|
||||||
className="h-8 w-full rounded-lg border border-amber-300 bg-amber-50/30 px-2.5 text-xs font-bold text-slate-800 focus:outline-none focus:ring-1 focus:ring-amber-500"
|
onChange={(e) => handleMaterialFieldChange(idx, "unit", e.target.value)}
|
||||||
/>
|
placeholder="واحد"
|
||||||
</div>
|
className="h-7 w-16 rounded border border-amber-300 bg-amber-50/40 px-2 text-xs text-slate-700 focus:outline-none focus:ring-1 focus:ring-amber-500"
|
||||||
<div>
|
/>
|
||||||
<label className="text-[10px] font-semibold text-slate-500 block mb-0.5">
|
|
||||||
واحد سنجش
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={item.unit}
|
|
||||||
onChange={(e) => handleMaterialFieldChange(idx, "unit", e.target.value)}
|
|
||||||
placeholder="مثلاً: عدد، تن، پاکت..."
|
|
||||||
className="h-8 w-full rounded-lg border border-amber-300 bg-amber-50/30 px-2.5 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-amber-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<span className="font-extrabold text-slate-900 text-sm">{item.name}</span>
|
<span className="font-extrabold text-slate-900 text-[13px]">{item.name}</span>
|
||||||
{item.unit && <span className="text-xs text-slate-500 font-medium">({item.unit})</span>}
|
{item.unit && <span className="text-[11px] text-slate-400 font-medium">({item.unit})</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{specs.length > 0 && (
|
{specs.length > 0 && (
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{specs.map((s, si) => (
|
{specs.map((s, si) => (
|
||||||
<span
|
<span
|
||||||
key={si}
|
key={si}
|
||||||
className="rounded bg-slate-100 text-slate-600 px-1.5 py-0.5 text-[10px] border border-slate-200"
|
className="rounded bg-slate-100 text-slate-600 px-1.5 py-0.5 text-[9.5px] border border-slate-200"
|
||||||
>
|
>
|
||||||
{s}
|
{s}
|
||||||
</span>
|
</span>
|
||||||
@@ -665,21 +654,17 @@ export default function TaskDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 pt-0.5">
|
{/* نشانگر وضعیت سقف یا دکمه حذف */}
|
||||||
|
<div className="shrink-0">
|
||||||
{alloc === 0 ? (
|
{alloc === 0 ? (
|
||||||
<div className="flex items-center gap-1.5">
|
<button
|
||||||
<span className="rounded-md px-2 py-0.5 text-[10px] font-bold border bg-amber-50 text-amber-700 border-amber-200">
|
type="button"
|
||||||
خارج از برنامه
|
onClick={() => handleRemoveMaterialRow(idx)}
|
||||||
</span>
|
className="text-slate-400 hover:text-rose-600 p-1 rounded-md hover:bg-rose-50 transition-colors"
|
||||||
<button
|
title="حذف ردیف"
|
||||||
type="button"
|
>
|
||||||
onClick={() => handleRemoveMaterialRow(idx)}
|
<Trash2 size={14} />
|
||||||
className="text-slate-400 hover:text-rose-600 p-1 rounded-md hover:bg-rose-50 transition-colors"
|
</button>
|
||||||
title="حذف ردیف"
|
|
||||||
>
|
|
||||||
<Trash2 size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className={`rounded-md px-2 py-0.5 text-[10px] font-bold border ${
|
className={`rounded-md px-2 py-0.5 text-[10px] font-bold border ${
|
||||||
@@ -688,94 +673,74 @@ export default function TaskDetailPage() {
|
|||||||
: "bg-emerald-50 text-emerald-700 border-emerald-200"
|
: "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isOver ? "⚠️ مازاد بر سقف" : "مجاز"}
|
{isOver ? "⚠️ مازاد" : "مجاز"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 divide-x divide-x-reverse divide-slate-200 rounded-lg bg-slate-50 border border-slate-200 py-2 text-center text-xs">
|
{/* ۲. جدول ۳بخشی منظم حسابداری مصالح (مقدار کل مصوب، مصرف تاکنون، مانده قابل مصرف) */}
|
||||||
|
<div className="grid grid-cols-3 divide-x divide-x-reverse divide-slate-200 rounded-lg bg-slate-50 border border-slate-200 py-1.5 text-center">
|
||||||
<div className="px-1">
|
<div className="px-1">
|
||||||
<span className="text-slate-400 block text-[10px]">سقف مصوب</span>
|
<span className="text-slate-400 block text-[9.5px]">مقدار کل (سقف)</span>
|
||||||
<span className="font-bold text-slate-800 text-xs mt-0.5 block">
|
<span className="font-bold text-slate-800 text-[11.5px] mt-0.5 block">
|
||||||
{alloc > 0 ? (
|
{alloc > 0 ? `${alloc} ${item.unit || ""}` : "نامحدود"}
|
||||||
<>
|
|
||||||
<span className="tabular-nums">{alloc}</span>{" "}
|
|
||||||
<span className="text-slate-500 font-normal">{item.unit}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-slate-500 font-medium">بدون سقف</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-1">
|
<div className="px-1">
|
||||||
<span className="text-slate-400 block text-[10px]">مصرف تاکنون</span>
|
<span className="text-slate-400 block text-[9.5px]">مصرف تاکنون</span>
|
||||||
<span className="font-bold text-slate-700 text-xs mt-0.5 block">
|
<span className="font-bold text-slate-700 text-[11.5px] mt-0.5 block">
|
||||||
<span className="tabular-nums">{prevC}</span>{" "}
|
{prevC} {item.unit || ""}
|
||||||
<span className="text-slate-500 font-normal">{item.unit}</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-1">
|
<div className="px-1">
|
||||||
<span
|
<span className={`block text-[9.5px] ${isOver ? "text-rose-600 font-bold" : "text-emerald-700 font-bold"}`}>
|
||||||
className={`block text-[10px] font-bold ${
|
{isOver ? "مازاد سقف" : "مانده قابل مصرف"}
|
||||||
isOver ? "text-rose-600" : alloc > 0 ? "text-emerald-700" : "text-sky-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isOver ? "مازاد سقف" : alloc > 0 ? "مانده مجاز" : "کل مصرفی"}
|
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className={`font-black text-[11.5px] mt-0.5 block ${isOver ? "text-rose-600" : "text-emerald-700"}`}>
|
||||||
className={`font-black text-xs mt-0.5 block ${
|
{isOver ? `+${overAmt} ${item.unit || ""}` : `${rem} ${item.unit || ""}`}
|
||||||
isOver ? "text-rose-600" : alloc > 0 ? "text-emerald-700" : "text-sky-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isOver ? (
|
|
||||||
<span dir="ltr">+{overAmt} {item.unit}</span>
|
|
||||||
) : alloc > 0 ? (
|
|
||||||
<span>{rem} {item.unit}</span>
|
|
||||||
) : (
|
|
||||||
<span>{totC} {item.unit}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg bg-amber-50/60 border border-amber-200 p-2.5">
|
{/* ۳. ورودی شاخص و مستقل «مصرف این گزارش *» */}
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="space-y-1 rounded-lg bg-amber-50/70 border border-amber-200 p-2">
|
||||||
<label className="text-xs font-bold text-amber-950 flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<label className="text-[11.5px] font-black text-amber-950 flex items-center gap-1.5 shrink-0">
|
||||||
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
|
<span className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
|
||||||
<span>مصرف این گزارش *:</span>
|
<span>مصرف این گزارش *:</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="relative flex items-center w-36">
|
<div className="flex items-center rounded-lg border-2 border-amber-400 bg-white shadow-xs focus-within:border-amber-600 focus-within:ring-2 focus-within:ring-amber-200">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="number"
|
||||||
inputMode="decimal"
|
step="any"
|
||||||
|
min={0}
|
||||||
value={item.amount === 0 ? "" : item.amount}
|
value={item.amount === 0 ? "" : item.amount}
|
||||||
onFocus={(e) => e.target.select()}
|
|
||||||
onChange={(e) => handleMaterialAmountChange(idx, e.target.value)}
|
onChange={(e) => handleMaterialAmountChange(idx, e.target.value)}
|
||||||
placeholder="۰"
|
placeholder="۰"
|
||||||
className="h-8 w-full rounded-lg border-2 border-amber-400 bg-white px-3 pe-12 text-center text-sm font-black text-slate-900 focus:border-amber-600 focus:outline-none focus:ring-2 focus:ring-amber-200"
|
className="h-8 w-24 bg-transparent px-2 text-center text-sm font-black text-slate-900 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
{item.unit && (
|
{item.unit && (
|
||||||
<span className="absolute left-2 text-[11px] font-bold text-slate-500 pointer-events-none">
|
<span className="shrink-0 bg-amber-100/90 border-s border-amber-300 px-2 py-1 text-[11px] font-bold text-amber-900 select-none">
|
||||||
{item.unit}
|
{item.unit}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentC > 0 && (
|
{currentC > 0 && (
|
||||||
<div className="mt-2 pt-1.5 border-t border-amber-200/60 flex items-center justify-between text-[11px] text-amber-900">
|
<div className="pt-1 border-t border-amber-200/60 flex items-center justify-between text-[10px] text-amber-900">
|
||||||
<span>مجموع پس از ثبت:</span>
|
<span>مانده پس از ثبت این گزارش:</span>
|
||||||
<b className="font-black text-slate-800">
|
<b className={`font-black ${isOver ? "text-rose-600" : "text-emerald-700"}`}>
|
||||||
{totC} {item.unit}
|
{isOver ? `+${overAmt} ${item.unit} (مازاد سقف)` : `${rem} ${item.unit}`}
|
||||||
</b>
|
</b>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ۴. نوار پیشرفت مصرف به سقف */}
|
||||||
{alloc > 0 && (
|
{alloc > 0 && (
|
||||||
<div className="space-y-1 pt-0.5">
|
<div className="space-y-1 pt-0.5">
|
||||||
<div className="flex items-center justify-between text-[10px] text-slate-500">
|
<div className="flex items-center justify-between text-[9.5px] text-slate-500">
|
||||||
<span>کل مصرف: <b className="text-slate-800">{totC}</b> از {alloc} {item.unit}</span>
|
<span>کل مصرف: <b className="text-slate-800">{totC}</b> از {alloc} {item.unit}</span>
|
||||||
<span className="font-bold">
|
<span className="font-bold">
|
||||||
{totC > alloc ? `اضافه مصرف (${Math.round((totC / alloc) * 100)}٪)` : `${pctConsumed}٪ از سقف`}
|
{totC > alloc ? `اضافه مصرف (${Math.round((totC / alloc) * 100)}٪)` : `${pctConsumed}٪ از سقف`}
|
||||||
@@ -792,13 +757,14 @@ export default function TaskDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ۵. هشدار اضافه مصرف جهت ثبت در سوابق و استعلام */}
|
||||||
{isOver && (
|
{isOver && (
|
||||||
<div className="flex items-start gap-1.5 rounded-lg border border-rose-200 bg-rose-50 p-2 text-[11px] text-rose-700">
|
<p className="text-[10px] text-rose-700 font-medium flex items-center gap-1 bg-rose-50 p-1.5 rounded-lg border border-rose-200">
|
||||||
<AlertTriangle size={14} className="shrink-0 text-rose-600 mt-0.5" />
|
<AlertTriangle size={12} className="shrink-0 text-rose-600" />
|
||||||
<span>
|
<span>
|
||||||
میزان مصرف (<strong className="tabular-nums">{totC}</strong> {item.unit}) از سقف مصوب (<strong className="tabular-nums">{alloc}</strong> {item.unit}) بیشتر است. (<span dir="ltr" className="font-bold">+{overAmt}</span> {item.unit} مازاد)
|
میزان مصرف ({totC} {item.unit}) از سقف تخصیص ({alloc}) فراتر رفته است. مازاد (+{overAmt} {item.unit}) در استعلامهای آتی قابل رهگیری است.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -807,7 +773,7 @@ export default function TaskDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* نیروی انسانی */}
|
{/* ۲. نیروی انسانی تحویلشده و حاضر در کارگاه */}
|
||||||
<div className="mt-3 rounded-xl border border-sky-200 bg-sky-50/50 p-3 space-y-2.5">
|
<div className="mt-3 rounded-xl border border-sky-200 bg-sky-50/50 p-3 space-y-2.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-bold text-sky-950 flex items-center gap-1">
|
<span className="text-xs font-bold text-sky-950 flex items-center gap-1">
|
||||||
@@ -896,6 +862,7 @@ export default function TaskDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ۳. توضیحات و شرح گزارش روزانه کارگاه */}
|
||||||
<div className="mt-3 pt-2 border-t border-slate-200">
|
<div className="mt-3 pt-2 border-t border-slate-200">
|
||||||
<label className="text-[10px] font-medium text-slate-600 block mb-1">
|
<label className="text-[10px] font-medium text-slate-600 block mb-1">
|
||||||
توضیحات و گزارش روزانه کارگاه (اختیاری)
|
توضیحات و گزارش روزانه کارگاه (اختیاری)
|
||||||
@@ -936,7 +903,7 @@ export default function TaskDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{/* کارت تاریخچه ثبت پیشرفت */}
|
{/* کارت تاریخچه ثبت پیشرفت و مصرف منابع */}
|
||||||
{task.progress_logs && task.progress_logs.length > 0 && (
|
{task.progress_logs && task.progress_logs.length > 0 && (
|
||||||
<SectionCard title={`تاریخچه گزارشهای پیشرفت و مصرف منابع (${task.progress_logs.length})`}>
|
<SectionCard title={`تاریخچه گزارشهای پیشرفت و مصرف منابع (${task.progress_logs.length})`}>
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
@@ -954,10 +921,11 @@ export default function TaskDetailPage() {
|
|||||||
<div className="text-[10px] text-slate-500 flex items-center gap-1">
|
<div className="text-[10px] text-slate-500 flex items-center gap-1">
|
||||||
<Clock size={11} className="text-slate-400" />
|
<Clock size={11} className="text-slate-400" />
|
||||||
<span>{relativeTime(log.created_at)}</span>
|
<span>{relativeTime(log.created_at)}</span>
|
||||||
{log.logged_by && <span>توسط {log.logged_by}</span>}
|
{log.logged_by && <span>• توسط {log.logged_by}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* نیروی کار حاضر در این گزارش */}
|
||||||
{log.human_resources_used && log.human_resources_used.length > 0 && (
|
{log.human_resources_used && log.human_resources_used.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[10px] font-bold text-slate-600 block mb-1">نیروی کار حاضر:</span>
|
<span className="text-[10px] font-bold text-slate-600 block mb-1">نیروی کار حاضر:</span>
|
||||||
@@ -976,6 +944,7 @@ export default function TaskDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* مصالح مصرفی در این گزارش */}
|
||||||
{log.materials_used && log.materials_used.length > 0 && (
|
{log.materials_used && log.materials_used.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[10px] font-bold text-slate-600 block mb-1">مصالح مصرفشده:</span>
|
<span className="text-[10px] font-bold text-slate-600 block mb-1">مصالح مصرفشده:</span>
|
||||||
@@ -1003,6 +972,7 @@ export default function TaskDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* توضیحات در صورت وجود */}
|
||||||
{log.description && (
|
{log.description && (
|
||||||
<div className="rounded-lg bg-white p-2 border border-slate-100 text-[11px] text-slate-700 leading-relaxed">
|
<div className="rounded-lg bg-white p-2 border border-slate-100 text-[11px] text-slate-700 leading-relaxed">
|
||||||
{log.description}
|
{log.description}
|
||||||
@@ -1014,7 +984,7 @@ export default function TaskDetailPage() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* کارت تاریخهای وظیفه */}
|
{/* کارت تاریخها — برنامهریزیشده و واقعی */}
|
||||||
<SectionCard title="تاریخهای وظیفه">
|
<SectionCard title="تاریخهای وظیفه">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -1031,17 +1001,9 @@ export default function TaskDetailPage() {
|
|||||||
<JalaliDatePicker value={dueDate} onChange={setDueDate} placeholder="۱۴۰۵/۰۸/۲۶" />
|
<JalaliDatePicker value={dueDate} onChange={setDueDate} placeholder="۱۴۰۵/۰۸/۲۶" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isDateRangeInvalid && (
|
|
||||||
<div className="mt-2.5 flex items-center gap-2 rounded-xl border border-rose-200 bg-rose-50 p-2.5 text-xs font-bold text-rose-600">
|
|
||||||
<AlertTriangle size={15} className="shrink-0 text-rose-500" />
|
|
||||||
<span>تاریخ پایان نمیتواند کمتر از تاریخ شروع باشد</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={datesMutation.isPending || isDateRangeInvalid}
|
disabled={datesMutation.isPending}
|
||||||
onClick={() => datesMutation.mutate()}
|
onClick={() => datesMutation.mutate()}
|
||||||
className={`${btnSecondary} mt-2.5 w-full`}
|
className={`${btnSecondary} mt-2.5 w-full`}
|
||||||
>
|
>
|
||||||
@@ -1101,6 +1063,8 @@ export default function TaskDetailPage() {
|
|||||||
/>
|
/>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* کارت ارتباطات */}
|
{/* کارت ارتباطات */}
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title={
|
title={
|
||||||
@@ -1251,6 +1215,7 @@ export default function TaskDetailPage() {
|
|||||||
onUpdated={() => invalidateTask()}
|
onUpdated={() => invalidateTask()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* مودال ویرایش منابع تخصیصیافته به وظیفه */}
|
||||||
{showEditResourcesModal && (
|
{showEditResourcesModal && (
|
||||||
<Modal
|
<Modal
|
||||||
open={showEditResourcesModal}
|
open={showEditResourcesModal}
|
||||||
@@ -1283,6 +1248,7 @@ export default function TaskDetailPage() {
|
|||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* مودال افزودن رابطه وابستگی جدید */}
|
||||||
{showAddRelationModal && (
|
{showAddRelationModal && (
|
||||||
<Modal
|
<Modal
|
||||||
open={showAddRelationModal}
|
open={showAddRelationModal}
|
||||||
@@ -1386,4 +1352,4 @@ export default function TaskDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+192
-569
@@ -1,244 +1,103 @@
|
|||||||
import { useState, useMemo, useRef, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import { CheckCircle2, ChevronDown, ChevronUp, Folder, RefreshCw } from "lucide-react";
|
||||||
AlertCircle,
|
import { dashboardTaskKey, devLogin, getDashboardToday, getDevRoles } from "../lib/api";
|
||||||
AlertTriangle,
|
|
||||||
Building2,
|
|
||||||
Calendar,
|
|
||||||
CheckCircle2,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronUp,
|
|
||||||
ClipboardCheck,
|
|
||||||
RefreshCw,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { dashboardTaskKey, devLogin, getDashboardToday, getDevRoles, getProjects } from "../lib/api";
|
|
||||||
import type { DashboardTask, Project } from "../lib/api";
|
import type { DashboardTask, Project } from "../lib/api";
|
||||||
import {
|
import { Card, CardSkeleton, Chip, EmptyState, ErrorState, PageHeader, PanelsMenu, ProgressBar } from "../components/ui";
|
||||||
Card,
|
import { formatDate, isOverdue } from "../lib/format";
|
||||||
CardSkeleton,
|
import { roleLabels, scopeLabel } from "../lib/roles";
|
||||||
Chip,
|
|
||||||
EmptyState,
|
|
||||||
ErrorState,
|
|
||||||
PageHeader,
|
|
||||||
PanelsMenu,
|
|
||||||
type SegmentedOption,
|
|
||||||
} from "../components/ui";
|
|
||||||
import { formatDate } from "../lib/format";
|
|
||||||
import { getDashboardHeaderTitle, getRoleTitle } from "../lib/roles";
|
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
|
|
||||||
type FieldTab = "all" | "pending_approval" | "in_progress";
|
type SectionKey = "overdue" | "due_today" | "awaiting_qc" | "failed_qc" | "blocked" | "upcoming";
|
||||||
|
|
||||||
/** کارت اکشن فوری برای موارد QC مردود (طرح اضطراری با دکمه تمامعرض) */
|
interface SectionMeta {
|
||||||
function QcFailedEmergencyCard({
|
key: SectionKey;
|
||||||
task,
|
title: string;
|
||||||
projectName,
|
tone: "red" | "amber" | "sky" | "slate";
|
||||||
}: {
|
}
|
||||||
task: DashboardTask;
|
|
||||||
projectName: string;
|
/**
|
||||||
}) {
|
* بخشهای داشبورد به تفکیک نقش:
|
||||||
|
* مدیر پروژه نگاه مدیریتی دارد (تأخیرها و QC همه پروژهها) و سرپرست پروژه نگاه
|
||||||
|
* عملیاتی (کار امروز و کاری که باید همین حالا اجرا/بازرسی شود).
|
||||||
|
*/
|
||||||
|
const managerSections: SectionMeta[] = [
|
||||||
|
{ key: "overdue", title: "عقبافتاده", tone: "red" },
|
||||||
|
{ key: "due_today", title: "کارهای امروز", tone: "sky" },
|
||||||
|
{ key: "awaiting_qc", title: "منتظر QC", tone: "amber" },
|
||||||
|
{ key: "failed_qc", title: "QC مردود", tone: "red" },
|
||||||
|
{ key: "blocked", title: "مسدود شده", tone: "slate" },
|
||||||
|
{ key: "upcoming", title: "هفته آینده", tone: "sky" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const supervisorSections: SectionMeta[] = [
|
||||||
|
{ key: "due_today", title: "کارهای امروز", tone: "sky" },
|
||||||
|
{ key: "overdue", title: "عقبافتاده", tone: "red" },
|
||||||
|
{ key: "awaiting_qc", title: "منتظر اجرای بازرسی", tone: "amber" },
|
||||||
|
{ key: "failed_qc", title: "بازرسی مردود", tone: "red" },
|
||||||
|
{ key: "blocked", title: "مسدود شده (پیشنیاز ناتمام)", tone: "slate" },
|
||||||
|
{ key: "upcoming", title: "هفته آینده", tone: "sky" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function TaskRow({ task, showBlockedBy = false }: { task: DashboardTask; showBlockedBy?: boolean }) {
|
||||||
|
const overdue = isOverdue(task.due_date);
|
||||||
|
// شناسهٔ پایدار: UUID تسک بومی، وگرنه op_wp_id میراث
|
||||||
const taskKey = dashboardTaskKey(task);
|
const taskKey = dashboardTaskKey(task);
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-xl border border-red-200/90 bg-gradient-to-l from-red-50/70 via-white to-white p-3.5 shadow-xs border-r-4 border-r-red-600 transition-all hover:border-red-300">
|
<Link to={`/tasks/${taskKey}`} className="block py-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h3 className="text-xs sm:text-[13px] font-bold text-slate-800 leading-snug min-w-0 flex-1">
|
<p className="min-w-0 flex-1 text-[13px] font-medium leading-5 text-slate-800">{task.subject}</p>
|
||||||
{task.subject}
|
<span className={`shrink-0 text-[11px] ${overdue ? "font-semibold text-red-600" : "text-slate-400"}`}>
|
||||||
</h3>
|
{formatDate(task.due_date)}
|
||||||
<span className="shrink-0 rounded-full bg-red-100 px-2 py-0.5 text-[10px] font-bold text-red-700 border border-red-200/60">
|
|
||||||
عدم انطباق بحرانی
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<div className="mt-2.5 grid grid-cols-3 gap-2 rounded-lg bg-slate-50/90 p-2 text-xs border border-slate-100">
|
<ProgressBar value={task.percentage_done} className="flex-1" />
|
||||||
<div className="min-w-0">
|
<span className="w-9 shrink-0 text-left text-[10px] text-slate-400">{task.percentage_done}٪</span>
|
||||||
<span className="block text-[10px] text-slate-400">پروژه فعال</span>
|
|
||||||
<span className="truncate font-semibold text-slate-700 text-[11px] block mt-0.5">
|
|
||||||
{projectName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 text-center">
|
|
||||||
<span className="block text-[10px] text-slate-400">تاریخ بازرسی</span>
|
|
||||||
<span className="font-semibold text-red-600 text-[11px] block mt-0.5 tabular-nums">
|
|
||||||
{formatDate(task.due_date)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 text-left">
|
|
||||||
<span className="block text-[10px] text-slate-400">پیشرفت فعلی</span>
|
|
||||||
<span className="font-bold text-slate-700 text-[11px] block mt-0.5 tabular-nums">
|
|
||||||
{task.percentage_done}٪
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-slate-400">
|
||||||
<div className="mt-3">
|
<span className="truncate">{task.project_name}</span>
|
||||||
<Link
|
{showBlockedBy && task.blocked_by && (
|
||||||
to={`/tasks/${taskKey}`}
|
<span className="shrink-0 truncate text-slate-500">مسدود توسط: {task.blocked_by}</span>
|
||||||
className="inline-flex w-full items-center justify-center gap-1.5 rounded-xl bg-red-600 py-2.5 text-xs font-bold text-white shadow-xs transition-all hover:bg-red-700 active:scale-[0.99]"
|
)}
|
||||||
>
|
|
||||||
<span>مشاهده عدم انطباق و ثبت اقدام اصلاحی</span>
|
|
||||||
<ChevronLeft size={15} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryBox({ tone, label, value }: { tone: "red" | "amber" | "green"; label: string; value: number }) {
|
||||||
|
const cls = {
|
||||||
|
red: "border-red-100 bg-red-50 text-red-600",
|
||||||
|
amber: "border-amber-100 bg-amber-50 text-amber-600",
|
||||||
|
green: "border-emerald-100 bg-emerald-50 text-emerald-600",
|
||||||
|
}[tone];
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl border p-3 text-center ${cls}`}>
|
||||||
|
<p className="text-xl font-extrabold">{value}</p>
|
||||||
|
<p className="mt-0.5 text-[11px] font-medium opacity-80">{label}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ردیف فشرده و مدرن چکلیست بازرسی روزانه (ارتفاع ۵۶ تا ۶۴ پیکسل) */
|
/** کاشی مدیریتی یک پروژه — فقط در داشبورد مدیر پروژه */
|
||||||
function TodayChecklistRow({
|
function ProjectTile({ project }: { project: Project }) {
|
||||||
task,
|
|
||||||
projectName,
|
|
||||||
phase,
|
|
||||||
}: {
|
|
||||||
task: DashboardTask;
|
|
||||||
projectName: string;
|
|
||||||
phase?: string;
|
|
||||||
}) {
|
|
||||||
const taskKey = dashboardTaskKey(task);
|
|
||||||
|
|
||||||
const statusMeta =
|
|
||||||
task.qc_status === "pre_qc_pending" || task.qc_status === "exec_qc_pending"
|
|
||||||
? { label: "در انتظار بازرسی", bg: "bg-amber-50 text-amber-700 border-amber-200/60" }
|
|
||||||
: task.qc_status === "ready"
|
|
||||||
? { label: "آماده اجرا / تایید", bg: "bg-sky-50 text-sky-700 border-sky-200/60" }
|
|
||||||
: task.qc_status === "in_progress"
|
|
||||||
? { label: "در حال بررسی", bg: "bg-slate-100 text-slate-700 border-slate-200" }
|
|
||||||
: task.qc_status === "final_passed"
|
|
||||||
? { label: "تأیید نهایی", bg: "bg-emerald-50 text-emerald-700 border-emerald-200/60" }
|
|
||||||
: { label: "آماده اقدام", bg: "bg-sky-50 text-sky-700 border-sky-200/60" };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[60px] items-center justify-between gap-3 px-3.5 py-2.5 bg-white transition-colors hover:bg-slate-50/80">
|
<Link to={`/projects/${project.id || project.op_project_id}`} className="block">
|
||||||
{/* سمت راست: آیکون وضعیت + عنوان چکلیست (با شکست تا ۲ خط بدون truncate سخت) و مرحله */}
|
<div className="rounded-xl border border-slate-200 p-3 active:bg-slate-50">
|
||||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
<p className="truncate text-xs font-bold text-slate-800">{project.name}</p>
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-sky-50 text-sky-600 border border-sky-100 self-center">
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
<ClipboardCheck size={16} />
|
<Chip tone="sky">{project.stats.total} فعالیت</Chip>
|
||||||
</div>
|
{project.stats.overdue > 0 && <Chip tone="red">{project.stats.overdue} عقبافتاده</Chip>}
|
||||||
<div className="min-w-0 flex-1">
|
{project.stats.awaiting_qc > 0 && <Chip tone="amber">{project.stats.awaiting_qc} منتظر QC</Chip>}
|
||||||
<p className="line-clamp-2 text-xs font-bold text-slate-800 leading-snug break-words">
|
{project.stats.failed_qc > 0 && <Chip tone="red">{project.stats.failed_qc} مردود</Chip>}
|
||||||
{task.subject}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-1.5 text-[10px] text-slate-400 mt-1 truncate">
|
|
||||||
<span className="font-medium text-slate-500 truncate">{projectName}</span>
|
|
||||||
{phase && (
|
|
||||||
<>
|
|
||||||
<span>•</span>
|
|
||||||
<span className="truncate">{phase}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* سمت چپ: پیل فشرده وضعیت + دکمه آیکونی کوچک برای بازکردن بازرسی (تراز عمودی مرکز) */}
|
|
||||||
<div className="flex items-center gap-2 shrink-0 self-center">
|
|
||||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-bold border ${statusMeta.bg}`}>
|
|
||||||
{statusMeta.label}
|
|
||||||
</span>
|
|
||||||
<Link
|
|
||||||
to={`/tasks/${taskKey}`}
|
|
||||||
aria-label="بازکردن بازرسی"
|
|
||||||
title="بازکردن فرم بازرسی"
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 active:scale-95 transition-all"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={16} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** کارت پیشنمایش وظایف و نقاط عطف هفته آینده */
|
|
||||||
function UpcomingTaskCard({
|
|
||||||
task,
|
|
||||||
projectName,
|
|
||||||
}: {
|
|
||||||
task: DashboardTask;
|
|
||||||
projectName: string;
|
|
||||||
}) {
|
|
||||||
const taskKey = dashboardTaskKey(task);
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={`/tasks/${taskKey}`}
|
|
||||||
className="group block rounded-xl border border-slate-200/80 bg-white p-3 shadow-xs transition-all duration-150 hover:border-sky-300 hover:shadow-sm active:bg-slate-50/80"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="min-w-0 flex-1 space-y-1.5">
|
|
||||||
<p className="truncate text-xs font-bold text-slate-800 group-hover:text-sky-700 transition-colors">
|
|
||||||
{task.subject}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5 text-[10px]">
|
|
||||||
<span className="inline-flex items-center rounded-md bg-slate-100 px-2 py-0.5 font-medium text-slate-600 truncate max-w-[150px]">
|
|
||||||
{projectName}
|
|
||||||
</span>
|
|
||||||
{task.due_date && (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-md bg-sky-50 border border-sky-200/60 px-2 py-0.5 font-semibold text-sky-700 tabular-nums">
|
|
||||||
<Calendar size={11} className="text-sky-600" />
|
|
||||||
<span>موعد: {formatDate(task.due_date)}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{task.percentage_done > 0 && (
|
|
||||||
<span className="inline-flex items-center rounded-md bg-slate-50 px-1.5 py-0.5 text-slate-400 tabular-nums">
|
|
||||||
{task.percentage_done}٪
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center self-center text-slate-400 transition-all group-hover:text-sky-600 group-hover:bg-sky-50 group-active:-translate-x-0.5 rounded-lg">
|
|
||||||
<ChevronLeft size={18} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** کارت شاخص آماری با عناوین کوتاه و بدون بریدگی متن */
|
/** انتخابگر نقش — فقط در فاز توسعه (سرور آن را با PM_DEV_ROLE_SWITCH کنترل میکند) */
|
||||||
function SummaryBox({
|
|
||||||
tone,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
icon: Icon,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
tone: "red" | "amber" | "green";
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
onClick?: () => void;
|
|
||||||
}) {
|
|
||||||
const cls = {
|
|
||||||
red: "border-red-200/80 bg-red-50/60 text-red-700 hover:border-red-300",
|
|
||||||
amber: "border-amber-200/80 bg-amber-50/60 text-amber-700 hover:border-amber-300",
|
|
||||||
green: "border-emerald-200/80 bg-emerald-50/60 text-emerald-700 hover:border-emerald-300",
|
|
||||||
}[tone];
|
|
||||||
|
|
||||||
const iconCls = {
|
|
||||||
red: "text-red-500",
|
|
||||||
amber: "text-amber-500",
|
|
||||||
green: "text-emerald-500",
|
|
||||||
}[tone];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={`rounded-xl border p-2 text-center transition-all duration-150 active:scale-95 ${cls} hover:shadow-xs`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center gap-1.5">
|
|
||||||
{Icon && <Icon size={16} className={`${iconCls} shrink-0 opacity-90`} />}
|
|
||||||
<span className="text-lg font-black tabular-nums">{value}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 text-xs font-bold leading-tight truncate">{label}</p>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/** انتخابگر نقش در حالت توسعه */
|
|
||||||
function DevRoleSwitcher() {
|
function DevRoleSwitcher() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const setAuth = useAuthStore((s) => s.setAuth);
|
const setAuth = useAuthStore((s) => s.setAuth);
|
||||||
@@ -281,7 +140,7 @@ function DevRoleSwitcher() {
|
|||||||
>
|
>
|
||||||
{roles.map((r) => (
|
{roles.map((r) => (
|
||||||
<option key={r.username} value={r.username}>
|
<option key={r.username} value={r.username}>
|
||||||
{getRoleTitle(r.role)} — {r.all_projects ? "همه پروژهها" : `${r.project_count} پروژه`}
|
{roleLabels[r.role]} — {r.all_projects ? "همه پروژهها" : `${r.project_count} پروژه`}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -291,170 +150,28 @@ function DevRoleSwitcher() {
|
|||||||
export default function TodayPage() {
|
export default function TodayPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const [showUpcoming, setShowUpcoming] = useState(false);
|
||||||
// تب فعال عملیاتی چکلیستهای روزانه: همه اقدامات | در انتظار تایید / بازرسی | در حال بررسی
|
|
||||||
const [activeTab, setActiveTab] = useState<FieldTab>("all");
|
|
||||||
const [showUpcoming, setShowUpcoming] = useState<boolean | null>(null);
|
|
||||||
const emergencyRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const handleCriticalClick = () => {
|
|
||||||
if (emergencyRef.current) {
|
|
||||||
emergencyRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// واکشی دادههای داشبورد امروز و لیست پروژهها از اندپوینتهای واقعی
|
|
||||||
const todayQuery = useQuery({ queryKey: ["today"], queryFn: getDashboardToday });
|
const todayQuery = useQuery({ queryKey: ["today"], queryFn: getDashboardToday });
|
||||||
const projectsQuery = useQuery({ queryKey: ["projects"], queryFn: getProjects });
|
|
||||||
const data = todayQuery.data;
|
const data = todayQuery.data;
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
void queryClient.invalidateQueries({ queryKey: ["today"] });
|
void queryClient.invalidateQueries({ queryKey: ["today"] });
|
||||||
void queryClient.invalidateQueries({ queryKey: ["projects"] });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// لیست کلی پروژهها مستقیماً از دادههای API
|
const isManager = user?.role === "project_manager";
|
||||||
const allProjects = useMemo(() => {
|
const role = data?.role ?? user?.role ?? "project_supervisor";
|
||||||
return projectsQuery.data ?? data?.projects ?? [];
|
const sections = isManager ? managerSections : supervisorSections;
|
||||||
}, [projectsQuery.data, data?.projects]);
|
const projects = data?.projects ?? [];
|
||||||
|
|
||||||
// مدیریت شناسه پروژه فعال با ذخیرهسازی محلی
|
const totalItems = data
|
||||||
const [activeProjectId, setActiveProjectId] = useState<string>(() => {
|
? Object.values(data.sections).reduce((sum, list) => sum + list.length, 0)
|
||||||
return localStorage.getItem("pm_active_project_id") || "";
|
: 0;
|
||||||
});
|
|
||||||
|
|
||||||
const activeProject = useMemo(() => {
|
|
||||||
if (allProjects.length === 0) return null;
|
|
||||||
const found = allProjects.find(
|
|
||||||
(p) => String(p.id || (p as Project).op_project_id) === String(activeProjectId)
|
|
||||||
);
|
|
||||||
return found || allProjects[0];
|
|
||||||
}, [allProjects, activeProjectId]);
|
|
||||||
|
|
||||||
// همگامسازی خودکار شناسه در صورت نامعتبر بودن مقدار محلی
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeProject) {
|
|
||||||
const currentId = String(activeProject.id || (activeProject as Project).op_project_id);
|
|
||||||
if (currentId !== activeProjectId) {
|
|
||||||
setActiveProjectId(currentId);
|
|
||||||
localStorage.setItem("pm_active_project_id", currentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [activeProject, activeProjectId]);
|
|
||||||
|
|
||||||
const currentPhase =
|
|
||||||
activeProject?.description?.trim() ||
|
|
||||||
(activeProject?.status === "ACTIVE" ? "در حال ساخت / اجرا" : activeProject?.status) ||
|
|
||||||
"در حال اجرا";
|
|
||||||
|
|
||||||
const handleSelectProject = (id: string) => {
|
|
||||||
setActiveProjectId(id);
|
|
||||||
localStorage.setItem("pm_active_project_id", id);
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem("procurement-auth-v1");
|
|
||||||
if (raw) {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (parsed?.state?.user) {
|
|
||||||
parsed.state.user.project_id = id;
|
|
||||||
localStorage.setItem("procurement-auth-v1", JSON.stringify(parsed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
// وظایف و چکلیستهای عملیاتی کارگاه از اندپوینت واقعی dashboard/today
|
|
||||||
const failedQcTasks = useMemo(() => {
|
|
||||||
const list = data?.sections?.failed_qc ?? [];
|
|
||||||
if (!activeProject?.id) return list;
|
|
||||||
return list.filter((t) => !t.project_id || String(t.project_id) === String(activeProject.id));
|
|
||||||
}, [data?.sections?.failed_qc, activeProject?.id]);
|
|
||||||
|
|
||||||
const overdueTasks = useMemo(() => {
|
|
||||||
const list = data?.sections?.overdue ?? [];
|
|
||||||
if (!activeProject?.id) return list;
|
|
||||||
return list.filter((t) => !t.project_id || String(t.project_id) === String(activeProject.id));
|
|
||||||
}, [data?.sections?.overdue, activeProject?.id]);
|
|
||||||
|
|
||||||
const todayChecklists = useMemo(() => {
|
|
||||||
if (!data?.sections) return [];
|
|
||||||
const dueToday = data.sections.due_today ?? [];
|
|
||||||
const awaitingQc = data.sections.awaiting_qc ?? [];
|
|
||||||
const combined = [...dueToday, ...awaitingQc];
|
|
||||||
if (!activeProject?.id) return combined;
|
|
||||||
return combined.filter((t) => !t.project_id || String(t.project_id) === String(activeProject.id));
|
|
||||||
}, [data?.sections, activeProject?.id]);
|
|
||||||
|
|
||||||
const upcomingTasks = useMemo(() => {
|
|
||||||
const list = data?.sections?.upcoming ?? [];
|
|
||||||
if (!activeProject?.id) return list;
|
|
||||||
return list.filter((t) => !t.project_id || String(t.project_id) === String(activeProject.id));
|
|
||||||
}, [data?.sections?.upcoming, activeProject?.id]);
|
|
||||||
|
|
||||||
// فیلتر چکلیستهای عملیاتی کارگاه
|
|
||||||
const pendingApprovalList = useMemo(() => {
|
|
||||||
return todayChecklists.filter((t) => t.qc_status !== "in_progress");
|
|
||||||
}, [todayChecklists]);
|
|
||||||
|
|
||||||
const inProgressList = useMemo(() => {
|
|
||||||
return todayChecklists.filter((t) => t.qc_status === "in_progress");
|
|
||||||
}, [todayChecklists]);
|
|
||||||
|
|
||||||
const displayedChecklists = useMemo(() => {
|
|
||||||
if (activeTab === "pending_approval") return pendingApprovalList;
|
|
||||||
if (activeTab === "in_progress") return inProgressList;
|
|
||||||
return todayChecklists;
|
|
||||||
}, [activeTab, todayChecklists, pendingApprovalList, inProgressList]);
|
|
||||||
|
|
||||||
// محاسبه شاخصهای سلامت کارگاه بر اساس دادههای واقعی سرور
|
|
||||||
const criticalCount = activeProject?.stats
|
|
||||||
? (activeProject.stats.failed_qc ?? 0) + (activeProject.stats.overdue ?? 0)
|
|
||||||
: failedQcTasks.length + overdueTasks.length;
|
|
||||||
|
|
||||||
const pendingApprovalCount = pendingApprovalList.length;
|
|
||||||
|
|
||||||
const verifiedChecklistsCount = activeProject?.stats
|
|
||||||
? (activeProject.stats.completed ?? 0)
|
|
||||||
: (data?.summary?.ok ?? 0);
|
|
||||||
|
|
||||||
// تبهای فیلتر چکلیستهای روزانه کارگاه
|
|
||||||
const filterTabs = useMemo<SegmentedOption<FieldTab>[]>(() => [
|
|
||||||
{ id: "all", label: "همه اقدامات", count: todayChecklists.length },
|
|
||||||
{
|
|
||||||
id: "pending_approval",
|
|
||||||
label: "در انتظار تایید / بازرسی",
|
|
||||||
count: pendingApprovalList.length,
|
|
||||||
tone: pendingApprovalList.length > 0 ? "amber" : undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "in_progress",
|
|
||||||
label: "در حال بررسی",
|
|
||||||
count: inProgressList.length,
|
|
||||||
},
|
|
||||||
], [todayChecklists.length, pendingApprovalList.length, inProgressList.length]);
|
|
||||||
|
|
||||||
const activeRole = data?.role || user?.role || "site_supervisor";
|
|
||||||
const roleTitle = getRoleTitle(activeRole, user?.role_label || data?.role_label);
|
|
||||||
const headerTitle = getDashboardHeaderTitle(activeRole);
|
|
||||||
const userDisplayName = user?.display_name || user?.username || "کاربر سیستم";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={headerTitle}
|
title="مدیریت پروژه"
|
||||||
sub={
|
menu={<PanelsMenu />}
|
||||||
<span className="inline-flex items-center gap-1.5 text-[11px] text-slate-500 font-normal">
|
|
||||||
<span className="font-semibold text-slate-700">{userDisplayName}</span>
|
|
||||||
<span className="text-slate-300">•</span>
|
|
||||||
<span className="font-medium text-sky-600">{roleTitle}</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
menu={
|
|
||||||
<PanelsMenu
|
|
||||||
projects={allProjects}
|
|
||||||
activeProjectId={activeProjectId}
|
|
||||||
onSelectProject={handleSelectProject}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
right={
|
right={
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<DevRoleSwitcher />
|
<DevRoleSwitcher />
|
||||||
@@ -462,237 +179,143 @@ export default function TodayPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={refresh}
|
onClick={refresh}
|
||||||
aria-label="بهروزرسانی"
|
aria-label="بهروزرسانی"
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-full text-slate-500 hover:text-slate-700 active:bg-slate-100"
|
className="flex h-11 w-11 items-center justify-center rounded-full text-slate-500 active:bg-slate-100"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw size={20} className={todayQuery.isFetching ? "animate-spin" : ""} />
|
||||||
size={18}
|
|
||||||
className={todayQuery.isFetching || projectsQuery.isFetching ? "animate-spin" : ""}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-3.5 px-4 pb-24 pt-2.5">
|
<div className="px-4 pb-28 pt-4">
|
||||||
{todayQuery.isPending || projectsQuery.isPending ? (
|
{/* هویت نقش و محدوده دسترسی */}
|
||||||
<div className="space-y-3.5 py-4">
|
<div className="mb-3 flex flex-wrap items-center gap-1.5">
|
||||||
<div className="flex items-center justify-center gap-2 py-3 text-xs font-medium text-slate-500">
|
<Chip tone={isManager ? "sky" : "green"}>{roleLabels[role]}</Chip>
|
||||||
<RefreshCw size={16} className="animate-spin text-sky-600" />
|
<Chip tone="slate">{scopeLabel(user)}</Chip>
|
||||||
<span>در حال بارگذاری اطلاعات پروژه و وظایف...</span>
|
<span className="text-[11px] text-slate-400">
|
||||||
</div>
|
{isManager
|
||||||
<div className="h-9 animate-pulse rounded-full bg-slate-200/80" />
|
? "نمای مدیریتی همه پروژهها"
|
||||||
|
: "نمای عملیاتی پروژههای اختصاصیافته"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{todayQuery.isPending ? (
|
||||||
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<div className="h-[58px] animate-pulse rounded-xl bg-slate-200/80" />
|
<div className="h-[70px] animate-pulse rounded-2xl bg-slate-200/80" />
|
||||||
<div className="h-[58px] animate-pulse rounded-xl bg-slate-200/80" />
|
<div className="h-[70px] animate-pulse rounded-2xl bg-slate-200/80" />
|
||||||
<div className="h-[58px] animate-pulse rounded-xl bg-slate-200/80" />
|
<div className="h-[70px] animate-pulse rounded-2xl bg-slate-200/80" />
|
||||||
</div>
|
</div>
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
<CardSkeleton />
|
<CardSkeleton />
|
||||||
|
<CardSkeleton />
|
||||||
</div>
|
</div>
|
||||||
) : todayQuery.isError || projectsQuery.isError ? (
|
) : todayQuery.isError ? (
|
||||||
<ErrorState onRetry={refresh} />
|
<ErrorState onRetry={refresh} />
|
||||||
) : allProjects.length === 0 ? (
|
) : data ? (
|
||||||
<Card className="p-8 text-center">
|
|
||||||
<EmptyState
|
|
||||||
icon={Building2}
|
|
||||||
title="پروژهای یافت نشد"
|
|
||||||
description="شما به هیچ پروژهای دسترسی ندارید یا هنوز پروژهای در سامانه تعریف نشده است."
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
) : !activeProject ? (
|
|
||||||
<Card className="p-8 text-center">
|
|
||||||
<EmptyState
|
|
||||||
icon={Building2}
|
|
||||||
title="پروژهای انتخاب نشده است"
|
|
||||||
description="لطفاً از منوی سامانه یک پروژه را انتخاب نمایید."
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
{/* ---------------------------------------------------- */}
|
|
||||||
{/* ۱. نمایش وضعیت پروژه و کارگاه فعال */}
|
|
||||||
{/* ---------------------------------------------------- */}
|
|
||||||
<div className="space-y-1.5 pt-0.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="inline-flex items-center gap-2 rounded-full bg-slate-100 py-1.5 px-3.5 text-xs font-bold text-slate-800 border border-slate-200/80 shadow-2xs">
|
|
||||||
<Building2 size={13} className="text-sky-600 shrink-0" />
|
|
||||||
<span>پروژه فعال: {activeProject.name}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-[11px] text-slate-500 leading-normal px-1">
|
|
||||||
<span className="text-slate-400 font-normal">مرحله: </span>
|
|
||||||
<span className="font-bold text-slate-700">{currentPhase}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ---------------------------------------------------- */}
|
|
||||||
{/* ۲. کارتهای شاخص سلامت کارگاه با عناوین کوتاه و بدون بریدگی */}
|
|
||||||
{/* ---------------------------------------------------- */}
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<SummaryBox
|
<SummaryBox
|
||||||
tone="red"
|
tone="red"
|
||||||
label="بحرانی"
|
label={isManager ? "مشکلات" : "بحرانی"}
|
||||||
value={criticalCount}
|
value={data.summary.problems}
|
||||||
icon={AlertCircle}
|
|
||||||
onClick={handleCriticalClick}
|
|
||||||
/>
|
/>
|
||||||
<SummaryBox
|
<SummaryBox
|
||||||
tone="amber"
|
tone="amber"
|
||||||
label="هشدارها"
|
label={isManager ? "هشدارها" : "نیازمند پیگیری"}
|
||||||
value={pendingApprovalCount}
|
value={data.summary.warnings}
|
||||||
icon={AlertTriangle}
|
|
||||||
onClick={() => setActiveTab("pending_approval")}
|
|
||||||
/>
|
|
||||||
<SummaryBox
|
|
||||||
tone="green"
|
|
||||||
label="تأیید شده"
|
|
||||||
value={verifiedChecklistsCount}
|
|
||||||
icon={CheckCircle2}
|
|
||||||
onClick={() => setActiveTab("all")}
|
|
||||||
/>
|
/>
|
||||||
|
<SummaryBox tone="green" label="سالم" value={data.summary.ok} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---------------------------------------------------- */}
|
{/* نمای مدیریتی پروژهها — فقط مدیر پروژه */}
|
||||||
{/* ۳. بخش اقدام فوری: QC مردود و عدم انطباق (مستقل و رندر مشروط) */}
|
{isManager && projects.length > 0 && (
|
||||||
{/* ---------------------------------------------------- */}
|
<Card className="mt-4 p-4">
|
||||||
{failedQcTasks.length > 0 && (
|
<div className="mb-2.5 flex items-center justify-between gap-2">
|
||||||
<div ref={emergencyRef} id="emergency-section" className="space-y-2 scroll-mt-20">
|
<h2 className="text-sm font-bold text-slate-700">وضعیت پروژهها</h2>
|
||||||
<div className="flex items-center justify-between px-1">
|
<Link to="/projects" className="text-[11px] font-semibold text-sky-600">
|
||||||
<h2 className="text-xs font-bold text-red-700 flex items-center gap-1.5">
|
همه پروژهها
|
||||||
<AlertCircle size={15} className="text-red-600 shrink-0" />
|
</Link>
|
||||||
<span>نیازمند اقدام فوری (عدم انطباق و QC مردود)</span>
|
|
||||||
</h2>
|
|
||||||
<Chip tone="red">{failedQcTasks.length} مورد</Chip>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{failedQcTasks.map((t) => (
|
{projects.map((p) => (
|
||||||
<QcFailedEmergencyCard
|
<ProjectTile key={p.op_project_id} project={p} />
|
||||||
key={dashboardTaskKey(t)}
|
|
||||||
task={t}
|
|
||||||
projectName={activeProject.name}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---------------------------------------------------- */}
|
{/* ناظر بدون پروژه اختصاصیافته */}
|
||||||
{/* ۴. چیپهای فیلتر عملیاتی چکلیستهای روزانه */}
|
{!isManager && (data?.scope?.project_count ?? user?.scope?.project_count ?? 0) === 0 && totalItems === 0 && (
|
||||||
{/* ---------------------------------------------------- */}
|
<Card className="mt-4">
|
||||||
<div className="w-full overflow-x-auto no-scrollbar flex items-center gap-2 px-1 py-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
<EmptyState
|
||||||
{filterTabs.map((opt) => {
|
icon={Folder}
|
||||||
const active = opt.id === activeTab;
|
title="پروژهای به شما اختصاص داده نشده است"
|
||||||
return (
|
description="برای مشاهده و ثبت اجرا، مدیر پروژه باید پروژهها را از بخش «کاربران و دسترسیها» به شما اختصاص دهد"
|
||||||
<button
|
/>
|
||||||
key={opt.id}
|
</Card>
|
||||||
type="button"
|
)}
|
||||||
onClick={() => setActiveTab(opt.id)}
|
|
||||||
className={`shrink-0 flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-all duration-150 select-none ${
|
|
||||||
active
|
|
||||||
? "bg-slate-900 text-white shadow-xs"
|
|
||||||
: "bg-slate-100 text-slate-600 hover:bg-slate-200 hover:text-slate-900"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span>{opt.label}</span>
|
|
||||||
{opt.count !== undefined && opt.count > 0 && (
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-1.5 py-0.2 text-[10px] font-bold tabular-nums ${
|
|
||||||
opt.tone === "red"
|
|
||||||
? active ? "bg-red-500 text-white" : "bg-red-100 text-red-700"
|
|
||||||
: opt.tone === "amber"
|
|
||||||
? active ? "bg-amber-500 text-white" : "bg-amber-100 text-amber-700"
|
|
||||||
: active ? "bg-slate-700 text-slate-200" : "bg-slate-200 text-slate-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ---------------------------------------------------- */}
|
{totalItems === 0 && (isManager || (data?.scope?.project_count ?? user?.scope?.project_count ?? 0) > 0) ? (
|
||||||
{/* ۵. بخش چکلیستها و تأیدیههای امروز کارگاه (طرح سطری فشرده) */}
|
<Card className="mt-4">
|
||||||
{/* ---------------------------------------------------- */}
|
<EmptyState
|
||||||
<div className="space-y-2">
|
icon={CheckCircle2}
|
||||||
<div className="flex items-center justify-between px-1">
|
title="همه چیز مرتب است"
|
||||||
<h2 className="text-xs font-bold text-slate-800 flex items-center gap-1.5">
|
description="در حال حاضر موردی برای پیگیری وجود ندارد"
|
||||||
<ClipboardCheck size={15} className="text-sky-600 shrink-0" />
|
/>
|
||||||
<span>چکلیستها و تأیدیههای امروز</span>
|
</Card>
|
||||||
</h2>
|
) : (
|
||||||
<Chip tone="sky">{displayedChecklists.length} مورد</Chip>
|
<div className="mt-4 space-y-3">
|
||||||
|
{sections.map(({ key, title, tone }) => {
|
||||||
|
const items = data.sections[key];
|
||||||
|
if (!items || items.length === 0) return null;
|
||||||
|
const isUpcoming = key === "upcoming";
|
||||||
|
|
||||||
|
if (isUpcoming && !showUpcoming) {
|
||||||
|
return (
|
||||||
|
<button key={key} type="button" onClick={() => setShowUpcoming(true)} className="block w-full">
|
||||||
|
<Card className="flex items-center justify-between p-4">
|
||||||
|
<span className="text-sm font-bold text-slate-700">{title}</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Chip tone="sky">{items.length} مورد</Chip>
|
||||||
|
<ChevronDown size={16} className="text-slate-400" />
|
||||||
|
</span>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={key} className="p-0">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-100 px-4 py-3">
|
||||||
|
<h2 className="text-sm font-bold text-slate-700">{title}</h2>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Chip tone={tone}>{items.length} مورد</Chip>
|
||||||
|
{isUpcoming && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUpcoming(false)}
|
||||||
|
aria-label="بستن بخش"
|
||||||
|
className="text-slate-400"
|
||||||
|
>
|
||||||
|
<ChevronUp size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-slate-100 px-4">
|
||||||
|
{items.map((t) => (
|
||||||
|
<TaskRow key={dashboardTaskKey(t)} task={t} showBlockedBy={key === "blocked"} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{displayedChecklists.length > 0 ? (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-xs divide-y divide-slate-100">
|
|
||||||
{displayedChecklists.map((t) => (
|
|
||||||
<TodayChecklistRow
|
|
||||||
key={dashboardTaskKey(t)}
|
|
||||||
task={t}
|
|
||||||
projectName={activeProject.name}
|
|
||||||
phase={currentPhase}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Card className="p-4 text-center">
|
|
||||||
<EmptyState
|
|
||||||
icon={CheckCircle2}
|
|
||||||
title="چکلیستی برای امروز باقی نمانده است"
|
|
||||||
description="هیچ موردی در این وضعیت برای پروژه انتخابشده یافت نشد."
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ---------------------------------------------------- */}
|
|
||||||
{/* ۶. بخش برنامه آتی و نقاط عطف کارگاه (هفته آینده) */}
|
|
||||||
{/* ---------------------------------------------------- */}
|
|
||||||
<Card className="p-0 overflow-hidden shadow-xs border-sky-100/80">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowUpcoming(showUpcoming === null ? false : !showUpcoming)}
|
|
||||||
className="flex w-full items-center justify-between border-b border-slate-100 bg-sky-50/40 px-3.5 py-2.5 text-right transition-colors hover:bg-sky-50/70"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Calendar size={15} className="text-sky-600 shrink-0" />
|
|
||||||
<h2 className="text-xs font-bold text-slate-800">برنامه آتی و نقاط عطف کارگاه (هفته آینده)</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Chip tone="sky">{upcomingTasks.length} مورد</Chip>
|
|
||||||
{(showUpcoming ?? true) ? (
|
|
||||||
<ChevronUp size={16} className="text-slate-400" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown size={16} className="text-slate-400" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{(showUpcoming ?? true) && (
|
|
||||||
upcomingTasks.length > 0 ? (
|
|
||||||
<div className="p-2.5 space-y-2 bg-slate-50/30">
|
|
||||||
{upcomingTasks.map((t) => (
|
|
||||||
<UpcomingTaskCard
|
|
||||||
key={dashboardTaskKey(t)}
|
|
||||||
task={t}
|
|
||||||
projectName={activeProject.name}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="p-4 text-center text-xs text-slate-400">
|
|
||||||
هیچ برنامه یا فعالیت آتی برای هفته آینده در این پروژه ثبت نشده است.
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+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