41 changed files with 4502 additions and 801 deletions
+71 -6
View File
@@ -384,11 +384,12 @@ async def ensure_v26(engine: AsyncEngine) -> None:
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
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 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("""
@@ -420,9 +421,6 @@ async def ensure_rbac_schema(engine: AsyncEngine) -> None:
volume — the RBAC model expects these and login 500s without them."""
stmts = (
"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 project_manager TEXT DEFAULT ''",
"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))
logger.info("project_transfers table ensured")
# ---------------------------------------------------------------------------
# v2.8 — MVP dashboard schema: materials.category_id + reporting indexes
# The dashboard's «What» filter cascade (Category → Material → Brand → Specs)
# needs a real materials.category_id link. v2.6 had deliberately dropped the
# old supplier-derived column; this is a fresh, topic-backfilled link
# (nullable — a material may stay uncategorized). The rest is B-tree index
# coverage for the aggregation queries in services/dashboard_service.py.
# Runs AFTER ensure_v26 so the 8 fixed categories always exist for the
# backfill. Every statement is idempotent.
# ---------------------------------------------------------------------------
_DASHBOARD_SCHEMA_DDL = [
# Fresh column (inline FK names the constraint materials_category_id_fkey,
# matching the DO-block guard below for volumes that predate v2.8).
"""
ALTER TABLE materials ADD COLUMN IF NOT EXISTS category_id UUID REFERENCES material_categories(id) ON DELETE SET NULL
""",
# Backfill from the legacy Persian topic text (NULL stays NULL — the UI
# groups those under «بدون دسته»).
"""
UPDATE materials SET category_id = mc.id
FROM material_categories mc
WHERE materials.topic = mc.name AND materials.category_id IS NULL
""",
# FK for the (rare) volume that already had the column without it.
"""
DO $$ BEGIN
ALTER TABLE materials ADD CONSTRAINT materials_category_id_fkey
FOREIGN KEY (category_id) REFERENCES material_categories(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL; END $$
""",
"CREATE INDEX IF NOT EXISTS idx_materials_category ON materials(category_id)",
# Drill-down / PM scoping: every dashboard query filters through it.
"CREATE INDEX IF NOT EXISTS idx_warehouses_project ON warehouses(project_id)",
# Daily flow + daily control (date-range aggregation).
"CREATE INDEX IF NOT EXISTS idx_inventory_tx_date ON inventory_transactions(transaction_date)",
# Type slices (withdrawal / return / waste) inside a date range.
"CREATE INDEX IF NOT EXISTS idx_inventory_tx_type_date ON inventory_transactions(transaction_type, transaction_date)",
# Funnel cumulative counts + lead-time average (partial — tiny & hot).
"CREATE INDEX IF NOT EXISTS idx_quotes_accepted ON quotes(accepted_date) WHERE accepted_date IS NOT NULL",
"CREATE INDEX IF NOT EXISTS idx_quotes_arrived ON quotes(arrived_date) WHERE arrived_date IS NOT NULL",
# Funnel per-status counts (existing composite leads with supplier_id).
"CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status)",
# Project-density workload grouping.
"CREATE INDEX IF NOT EXISTS idx_item_decisions_project ON item_decisions(project_id)",
# Receipt-level arrival reporting.
"CREATE INDEX IF NOT EXISTS idx_item_receipts_received ON item_receipts(received_at)",
# «What»-filtered stock scans across warehouses (existing composites lead
# with warehouse_id, useless for a bare material/brand filter).
"CREATE INDEX IF NOT EXISTS idx_inventory_material_brand ON inventory(material_id, brand_id)",
]
async def ensure_dashboard_schema(engine: AsyncEngine) -> None:
"""v2.8: materials.category_id (+ backfill) + dashboard reporting indexes.
Statement-per-transaction like ensure_v26: one failure can never block
the rest of the upgrade. Safe to call on every startup.
"""
for ddl in _DASHBOARD_SCHEMA_DDL:
try:
async with engine.begin() as conn:
await conn.execute(text(ddl))
except Exception: # pragma: no cover - pre-existing data conflicts
first_line = ddl.splitlines()[0][:100] if ddl.splitlines() else ddl[:100]
logger.exception("dashboard schema statement skipped (continuing): %s", first_line)
logger.info("dashboard schema ensured (materials.category_id + reporting indexes)")
+2
View File
@@ -28,6 +28,7 @@ from app.db.init_db import (
ensure_attachment_table,
ensure_audit_table,
ensure_bot_drafts_table,
ensure_dashboard_schema,
ensure_drop_category_tables,
ensure_dashboard_tables,
ensure_material_topic_column,
@@ -109,6 +110,7 @@ async def lifespan(_app: FastAPI):
await ensure_quote_projects(engine)
await ensure_v26(engine)
await ensure_rbac_schema(engine)
await ensure_dashboard_schema(engine)
await ensure_audit_table(engine)
await ensure_drop_category_tables(engine)
await ensure_bot_drafts_table(engine)
+7 -1
View File
@@ -130,13 +130,18 @@ class CategoryAttribute(Base):
class Material(Base):
__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"),)
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False)
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(
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
)
@@ -147,6 +152,7 @@ class Material(Base):
)
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
back_populates="material", lazy="raise", passive_deletes=True
)
+2
View File
@@ -4,6 +4,7 @@ from app.routers import (
auth,
brand_materials,
brands,
dashboard,
drafts,
files,
inventory,
@@ -53,6 +54,7 @@ ROUTERS = [
inventory.router,
pm.router,
project_transfers.router,
dashboard.router,
users.router,
drafts.router,
]
+217
View File
@@ -0,0 +1,217 @@
"""MVP dashboard router — read-only aggregations for the warehouse +
procurement dashboard widgets consumed by the frontend `apiV26.dashboard`
client (frontend/src/lib/api.ts).
Scope (same resolution pattern as the inventory / pm routers):
- role 'pm' → limited to the projects they manage (projects.pm_user_id, 1:N);
an explicit ?project_id must be one of them.
- role 'admin' / 'procurement_manager' → whole organization; ?project_id is
an optional filter.
- Dev fallback via X-User-Role / X-Project-Id headers, ONLY when
PROCUREMENT_DEV_LOGIN_ENABLED=true (read-only endpoints, same gate as the
inventory router).
Endpoints:
- GET /dashboard/filters — «What» cascade options
- GET /dashboard/inventory/drilldown — org → project → warehouse tree
- GET /dashboard/inventory/dead-stock — dormant stock table
- GET /dashboard/inventory/flow — daily inbound/outbound/returns/balance
- GET /dashboard/daily-control — today's tickets + performers
- GET /dashboard/procurement/workload — workload per project (density)
Every response is the standard `{"data": …}` envelope.
"""
import uuid
from dataclasses import dataclass
from datetime import date as date_cls
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user
from app.config import settings
from app.db.engine import get_db
from app.services import dashboard_service
from app.utils.errors import (
ForbiddenError,
UnauthorizedError,
ValidationError,
)
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@dataclass
class DashboardScope:
role: str # "pm" | "office"
project_ids: list | None # PM: managed project ids; office: None = all
project_id: uuid.UUID | None # explicit ?project_id filter (validated for PM)
async def dashboard_scope(
request: Request,
project_id: uuid.UUID | None = Query(default=None, description="فیلتر پروژه (PM: فقط پروژه‌های خودش)"),
db: AsyncSession = Depends(get_db),
) -> DashboardScope:
"""Resolve the dashboard caller — Casdoor JWT first, dev headers as fallback."""
if request.headers.get("authorization"):
user: UserContext | None = await get_optional_user(request, db)
if user is not None:
if user.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
if user.role == "pm":
allowed = list(user.project_ids or [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return DashboardScope(role="pm", project_ids=allowed, project_id=project_id)
if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
return DashboardScope(role="pm", project_ids=allowed, project_id=None)
if user.role in ("admin", "procurement_manager"):
return DashboardScope(role="office", project_ids=None, project_id=project_id)
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا دفتر تدارکات است")
if not settings.DEV_LOGIN_ENABLED or not request.headers.get("x-user-role"):
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
role = (request.headers.get("x-user-role") or "").strip().lower()
if role == "procurement_manager" or role == "admin":
role = "office"
elif role == "pm":
role = "pm"
else:
raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role")
if role == "pm":
raw = request.headers.get("x-project-id")
if not raw:
raise ValidationError("هدر X-Project-Id (پروژه مسئول) برای نقش PM الزامی است", field="X-Project-Id")
try:
if project_id is None:
project_id = uuid.UUID(str(raw))
except ValueError:
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
return DashboardScope(role="pm", project_ids=[project_id], project_id=project_id)
return DashboardScope(role="office", project_ids=None, project_id=project_id)
def _what_filters(
category_id: uuid.UUID | None,
material_id: uuid.UUID | None,
brand_id: uuid.UUID | None,
specs_hash: str | None,
) -> dict:
return {
"category_id": category_id,
"material_id": material_id,
"brand_id": brand_id,
"specs_hash": specs_hash,
}
@router.get("/filters")
async def dashboard_filters(db: AsyncSession = Depends(get_db)):
"""Cascading options for the global filter bar (Category→Material→Brand→Specs)."""
return await dashboard_service.filter_options(db)
@router.get("/inventory/drilldown")
async def inventory_drilldown(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
category_id: uuid.UUID | None = Query(default=None),
material_id: uuid.UUID | None = Query(default=None),
brand_id: uuid.UUID | None = Query(default=None),
specs_hash: str | None = Query(default=None),
):
"""Drill-down matrix: کل سازمان → پروژه → انبار (per-unit aggregates)."""
return await dashboard_service.inventory_drilldown(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
**_what_filters(category_id, material_id, brand_id, specs_hash),
)
@router.get("/inventory/dead-stock")
async def dead_stock(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
idle_days: int = Query(default=60, ge=1, le=365, description="آستانه رکود (روز)"),
limit: int = Query(default=200, ge=1, le=1000),
category_id: uuid.UUID | None = Query(default=None),
material_id: uuid.UUID | None = Query(default=None),
brand_id: uuid.UUID | None = Query(default=None),
specs_hash: str | None = Query(default=None),
):
"""Dead stock: موجودی مثبت بدون هیچ تراکنشی در بازه‌ی رکود."""
return await dashboard_service.dead_stock(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
idle_days=idle_days,
limit=limit,
**_what_filters(category_id, material_id, brand_id, specs_hash),
)
@router.get("/inventory/flow")
async def inventory_flow(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
date_from: date_cls | None = Query(default=None, description="از تاریخ (ISO، پیش‌فرض ۳۰ روز اخیر)"),
date_to: date_cls | None = Query(default=None, description="تا تاریخ (ISO، انحصاری)"),
warehouse_id: uuid.UUID | None = Query(default=None),
category_id: uuid.UUID | None = Query(default=None),
material_id: uuid.UUID | None = Query(default=None),
brand_id: uuid.UUID | None = Query(default=None),
specs_hash: str | None = Query(default=None),
):
"""Daily flow series: ورود / خروج (با لایه‌ی ضایعات) / برگشت / مانده."""
today = datetime.now(dashboard_service.TEHRAN).date()
date_from = date_from or (today - timedelta(days=29))
date_to = date_to or (today + timedelta(days=1))
if date_to <= date_from:
raise ValidationError("بازه‌ی تاریخ نامعتبر است — «تا» باید بعد از «از» باشد", field="date_to")
if (date_to - date_from).days > 180:
raise ValidationError("بازه‌ی تاریخ حداکثر ۱۸۰ روز است", field="date_to")
return await dashboard_service.daily_flow(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
date_from=datetime.combine(date_from, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
date_to=datetime.combine(date_to, datetime.min.time(), tzinfo=dashboard_service.TEHRAN),
warehouse_id=warehouse_id,
**_what_filters(category_id, material_id, brand_id, specs_hash),
)
@router.get("/daily-control")
async def daily_control(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
date: date_cls | None = Query(default=None, description="روز موردنظر (ISO، پیش‌فرض امروز)"),
warehouse_id: uuid.UUID | None = Query(default=None),
):
"""Daily control: today's outbound/return tickets + exact performer names."""
day = date or datetime.now(dashboard_service.TEHRAN).date()
return await dashboard_service.daily_control(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
day=day,
warehouse_id=warehouse_id,
)
@router.get("/procurement/workload")
async def procurement_workload(
scope: DashboardScope = Depends(dashboard_scope),
db: AsyncSession = Depends(get_db),
):
"""Project density: توزیع بار تدارکات (پیشنهادهای باز + خطوط خرید جاری) روی پروژه‌ها."""
return await dashboard_service.procurement_workload(
db,
project_ids=scope.project_ids,
project_id=scope.project_id,
)
+35 -3
View File
@@ -38,13 +38,14 @@ 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.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.procurement import (
ItemDecision,
ItemReceipt,
ItemReceiptAllocation,
LineItem,
LineItemSpec,
Project,
Quote,
Supplier,
@@ -182,16 +183,24 @@ def arrival_state(ordered: Decimal, received: Decimal | None) -> str:
async def pm_on_the_way(
ctx: ArrivalScope = Depends(arrivals_scope),
db: AsyncSession = Depends(get_db),
material_id: uuid.UUID | None = Query(default=None, description="فیلتر متریال (داشبورد بازرگانی)"),
brand_id: uuid.UUID | None = Query(default=None, description="فیلتر برند (داشبورد بازرگانی)"),
):
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(Quote, Quote.id == LineItem.quote_id)
.join(Material, Material.id == LineItem.material_id)
.join(Supplier, Supplier.id == Quote.supplier_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")
)
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":
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if not allowed:
@@ -211,8 +220,29 @@ async def pm_on_the_way(
# unit / category lookups for display
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()}
# 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 = []
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)
out.append({
"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,
"decided_at": to_iso(d.decided_at),
"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,
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
"ordered_qty": str(li.quantity),
-19
View File
@@ -122,25 +122,6 @@ async def delete_supplier_material(
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
@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
# ---------------------------------------------------------------------------
+658
View File
@@ -0,0 +1,658 @@
"""Aggregation queries behind the MVP dashboard (warehouse + procurement).
Read-only service. Every function receives the resolved scope (PM → the list
of projects they manage; office roles → project_ids=None = whole
organization) plus an optional explicit project filter, and the optional
«What» filters (category_id / material_id / brand_id / specs_hash).
Quantities are NEVER mixed across units: inventory rows live in the
material's canonical unit, so stock aggregates carry their unit code and the
UI picks one unit at a time. Numeric results leave the service chart-ready
(floats); identifiers and labels are plain strings.
Response shapes mirror frontend/src/lib/types.ts (v2.8 dashboard DTOs)
exactly — this module is the server side of that contract.
"""
import uuid
from datetime import date as date_cls
from datetime import datetime, time, timedelta, timezone
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.catalog import Brand, BrandMaterial, Material, MaterialCategory, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import (
ItemDecision,
ItemReceipt,
LineItem,
Project,
Quote,
QuoteProjects,
)
# Iran has been on a fixed UTC+03:30 (no DST) since 2022 — «امروز» in the
# daily widgets means today at this offset.
TEHRAN = timezone(timedelta(hours=3, minutes=30))
# Flow buckets: outbound = site consumption + shrinkage; waste is reported
# separately so the UI can stack it as a distinct red segment inside the
# outbound bar. Transfers are excluded from the buckets (they are internal
# moves) but DO count toward the running balance.
OUTBOUND_TYPES = ("WITHDRAWAL", "DAMAGE", "WASTE", "LOSS")
WASTE_TYPES = ("DAMAGE", "WASTE", "LOSS")
def _stock_filters(stmt, *, category_id=None, material_id=None, brand_id=None, specs_hash=None):
"""«What» filters for a statement that already joins inventory→material."""
if category_id is not None:
stmt = stmt.where(Material.category_id == category_id)
if material_id is not None:
stmt = stmt.where(InventoryItem.material_id == material_id)
if brand_id is not None:
stmt = stmt.where(InventoryItem.brand_id == brand_id)
if specs_hash:
stmt = stmt.where(InventoryItem.specs_hash == specs_hash)
return stmt
def _scope_filters(stmt, *, project_ids, project_id):
"""warehouse.project_id restriction for the resolved dashboard scope."""
if project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id)
elif project_ids is not None:
stmt = stmt.where(Warehouse.project_id.in_(project_ids))
return stmt
def _qty(value) -> float:
return round(float(value), 4) if value is not None else 0.0
# ---------------------------------------------------------------------------
# «What» filter cascade — Category → Material → Brand → Specs
# ---------------------------------------------------------------------------
async def filter_options(db: AsyncSession) -> dict:
"""Cascading options for the global filter bar.
Categories/materials come from the catalog (active only). Brand options
per material are the UNION of the catalog links (brand_materials — every
brand defined for that material) and the distinct brands actually present
in stock, so the cascade is complete even for materials whose current
stock rows carry no brand yet. Spec options come from stock rows
(inventory.specs_hash), since specs are a stock-level dimension.
Materials without a category land in the synthetic «بدون دسته» bucket.
"""
categories = (
await db.execute(
select(MaterialCategory)
.where(MaterialCategory.is_active.is_(True))
.order_by(MaterialCategory.name)
)
).scalars().all()
materials = (
await db.execute(select(Material).where(Material.status == "active").order_by(Material.name))
).scalars().all()
units = {u.id: u for u in (await db.execute(select(Unit))).scalars()}
inv_rows = (
await db.execute(
select(
InventoryItem.material_id,
InventoryItem.brand_id,
InventoryItem.specs_hash,
InventoryItem.specs,
Brand.name,
)
.select_from(InventoryItem)
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
.distinct()
)
).all()
# برندهای کاتالوگی هر متریال (brand_materials) — مستقل از موجودی فعلی
catalog_brand_rows = (
await db.execute(
select(BrandMaterial.material_id, Brand.id, Brand.name)
.select_from(BrandMaterial)
.join(Brand, Brand.id == BrandMaterial.brand_id)
.where(BrandMaterial.is_active.is_(True))
)
).all()
brands_by_material: dict[uuid.UUID, dict] = {}
specs_by_material: dict[uuid.UUID, dict[str, dict]] = {}
for material_id, brand_id, brand_name in catalog_brand_rows:
brands_by_material.setdefault(material_id, {})[brand_id] = brand_name
for material_id, brand_id, specs_hash, specs, brand_name in inv_rows:
if brand_id is not None:
brands_by_material.setdefault(material_id, {}).setdefault(brand_id, brand_name)
if specs_hash:
specs_by_material.setdefault(material_id, {})[specs_hash] = specs or {}
def material_entry(m: Material) -> dict:
unit = units.get(m.canonical_unit_id)
brands = brands_by_material.get(m.id, {})
spec_map = specs_by_material.get(m.id, {})
return {
"id": str(m.id),
"name": m.name,
"unit": {"id": unit.id, "code": unit.code, "name_fa": unit.name_fa} if unit else None,
"brands": [
{"id": str(bid), "name": bname}
for bid, bname in sorted(brands.items(), key=lambda kv: kv[1] or "")
],
"specs": [
{"hash": h, "specs": sp}
for h, sp in sorted(spec_map.items())
],
}
buckets: dict[uuid.UUID | None, list] = {c.id: [] for c in categories}
buckets.setdefault(None, [])
for m in materials:
buckets.setdefault(m.category_id, []).append(material_entry(m))
out = [
{"id": str(c.id), "name": c.name, "slug": c.slug, "materials": buckets.get(c.id, [])}
for c in categories
]
if buckets.get(None):
out.append({"id": None, "name": "بدون دسته", "slug": None, "materials": buckets[None]})
return {"data": {"categories": out}}
# ---------------------------------------------------------------------------
# Widget 1 — Drill-down matrix (organization → project → warehouse)
# ---------------------------------------------------------------------------
async def inventory_drilldown(
db: AsyncSession,
*,
project_ids,
project_id,
category_id=None,
material_id=None,
brand_id=None,
specs_hash=None,
) -> dict:
"""Stock quantity as a three-level tree; each aggregate keyed by unit code
so incompatible units never merge into one number."""
stmt = (
select(
Project.id,
Project.name,
Warehouse.id,
Warehouse.name,
Unit.code,
Unit.name_fa,
func.sum(InventoryItem.quantity).label("qty"),
func.count().label("rows"),
)
.select_from(InventoryItem)
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
.join(Project, Project.id == Warehouse.project_id)
.join(Material, Material.id == InventoryItem.material_id)
.join(Unit, Unit.id == InventoryItem.unit_id)
.where(InventoryItem.quantity > 0)
.group_by(Project.id, Project.name, Warehouse.id, Warehouse.name, Unit.code, Unit.name_fa)
.order_by(Project.name, Warehouse.name)
)
stmt = _stock_filters(
stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
)
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
rows = (await db.execute(stmt)).all()
units_legend: dict[str, str] = {}
total_by_unit: dict[str, float] = {}
total_rows = 0
projects_tree: dict[uuid.UUID, dict] = {}
for pid, pname, wid, wname, ucode, uname_fa, qty, row_count in rows:
q = _qty(qty)
units_legend[ucode] = uname_fa
total_by_unit[ucode] = round(total_by_unit.get(ucode, 0.0) + q, 4)
total_rows += row_count
proj = projects_tree.setdefault(
pid,
{"id": str(pid), "name": pname, "qty_by_unit": {}, "rows": 0, "_warehouses": {}},
)
proj["qty_by_unit"][ucode] = round(proj["qty_by_unit"].get(ucode, 0.0) + q, 4)
proj["rows"] += row_count
wh = proj["_warehouses"].setdefault(wid, {"id": str(wid), "name": wname, "qty_by_unit": {}, "rows": 0})
wh["qty_by_unit"][ucode] = round(wh["qty_by_unit"].get(ucode, 0.0) + q, 4)
wh["rows"] += row_count
projects = []
for proj in projects_tree.values():
warehouses = sorted(proj.pop("_warehouses").values(), key=lambda w: w["name"])
projects.append({**proj, "warehouses": warehouses})
projects.sort(key=lambda p: p["name"])
return {
"data": {
"units": units_legend,
"total": {"qty_by_unit": total_by_unit, "rows": total_rows},
"projects": projects,
}
}
# ---------------------------------------------------------------------------
# Widget 2 — Dead stock (dormant capital)
# ---------------------------------------------------------------------------
async def dead_stock(
db: AsyncSession,
*,
project_ids,
project_id,
idle_days: int = 60,
category_id=None,
material_id=None,
brand_id=None,
specs_hash=None,
limit: int = 200,
) -> dict:
"""Stock rows with quantity > 0 and NO ledger activity within `idle_days`.
Last activity = MAX(inventory_transactions.transaction_date) per row —
served by idx_inventory_tx_inventory_date — falling back to
inventory.updated_at for rows that never posted a transaction.
"""
last_tx = (
select(func.max(InventoryTransaction.transaction_date))
.where(InventoryTransaction.inventory_id == InventoryItem.id)
.correlate(InventoryItem)
.scalar_subquery()
)
last_activity = func.coalesce(last_tx, InventoryItem.updated_at)
cutoff = datetime.now(TEHRAN) - timedelta(days=idle_days)
stmt = (
select(
InventoryItem.id,
InventoryItem.quantity,
InventoryItem.specs,
Material.name,
Brand.name,
Warehouse.name,
Project.name,
Unit.code,
Unit.name_fa,
last_activity.label("last_activity"),
)
.select_from(InventoryItem)
.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
.join(Project, Project.id == Warehouse.project_id)
.join(Material, Material.id == InventoryItem.material_id)
.join(Unit, Unit.id == InventoryItem.unit_id)
.outerjoin(Brand, Brand.id == InventoryItem.brand_id)
.where(InventoryItem.quantity > 0, last_activity <= cutoff)
.order_by(last_activity.asc())
.limit(limit)
)
stmt = _stock_filters(
stmt, category_id=category_id, material_id=material_id, brand_id=brand_id, specs_hash=specs_hash
)
stmt = _scope_filters(stmt, project_ids=project_ids, project_id=project_id)
rows = (await db.execute(stmt)).all()
now = datetime.now(TEHRAN)
items = []
for inv_id, quantity, specs, mat_name, brand_name, wh_name, proj_name, ucode, uname_fa, last in rows:
items.append({
"inventory_id": str(inv_id),
"material": mat_name,
"brand": brand_name,
"specs": specs or {},
"warehouse": wh_name,
"project": proj_name,
"quantity": _qty(quantity),
"unit": {"code": ucode, "name_fa": uname_fa},
"last_activity": last.isoformat() if last else None,
"idle_days": (now - last).days if last else None,
})
return {"data": {"idle_days_threshold": idle_days, "items": items}}
# ---------------------------------------------------------------------------
# Widget 3 — Daily time-series flow (inbound / outbound / returns / balance)
# ---------------------------------------------------------------------------
async def daily_flow(
db: AsyncSession,
*,
project_ids,
project_id,
date_from: datetime,
date_to: datetime,
warehouse_id=None,
category_id=None,
material_id=None,
brand_id=None,
specs_hash=None,
) -> dict:
"""Per-day buckets from the signed ledger.
inbound = PURCHASE · returns = RETURN · outbound = WITHDRAWAL + waste
(DAMAGE/WASTE/LOSS, reported separately for the stacked red segment).
balance = running sum of EVERY signed movement (transfers/found/stock
counts included), seeded with the opening balance before `date_from`.
"""
def _with_joins(sel):
return (
sel.select_from(InventoryTransaction)
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
.join(Material, Material.id == InventoryItem.material_id)
)
scope_conditions = []
if project_id is not None:
scope_conditions.append(Warehouse.project_id == project_id)
elif project_ids is not None:
scope_conditions.append(Warehouse.project_id.in_(project_ids))
if warehouse_id is not None:
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
what_conditions = [Material.category_id == category_id] if category_id is not None else []
if material_id is not None:
what_conditions.append(InventoryItem.material_id == material_id)
if brand_id is not None:
what_conditions.append(InventoryItem.brand_id == brand_id)
if specs_hash:
what_conditions.append(InventoryItem.specs_hash == specs_hash)
day_expr = func.date_trunc("day", InventoryTransaction.transaction_date).label("day")
bucket_rows = (
await db.execute(
_with_joins(
select(day_expr, InventoryTransaction.transaction_type, func.sum(InventoryTransaction.quantity))
.where(
InventoryTransaction.transaction_date >= date_from,
InventoryTransaction.transaction_date < date_to,
*scope_conditions,
*what_conditions,
)
.group_by(day_expr, InventoryTransaction.transaction_type)
)
)
).all()
opening = _qty(
await db.scalar(
_with_joins(
select(func.coalesce(func.sum(InventoryTransaction.quantity), 0.0)).where(
InventoryTransaction.transaction_date < date_from,
*scope_conditions,
*what_conditions,
)
)
)
)
signed_by_day: dict[date_cls, dict[str, float]] = {}
for day, tx_type, signed in bucket_rows:
day_key = day.date() if isinstance(day, datetime) else day
signed_by_day.setdefault(day_key, {})[str(tx_type)] = _qty(signed)
days = []
running = opening
current = date_from.date()
while current < date_to.date():
types = signed_by_day.get(current, {})
waste = round(-sum(types.get(t, 0.0) for t in WASTE_TYPES), 4) or 0.0
outbound = round(waste - types.get("WITHDRAWAL", 0.0), 4) or 0.0
running = round(running + sum(types.values()), 4)
days.append({
"date": current.isoformat(),
"inbound": round(types.get("PURCHASE", 0.0), 4) or 0.0,
"outbound": outbound,
"waste": waste,
"returns": round(types.get("RETURN", 0.0), 4) or 0.0,
"balance": running,
})
current += timedelta(days=1)
return {"data": {"opening_balance": opening, "days": days}}
# ---------------------------------------------------------------------------
# Widget 4 — Daily control (today's site operations)
# ---------------------------------------------------------------------------
async def daily_control(
db: AsyncSession,
*,
project_ids,
project_id,
day: date_cls,
warehouse_id=None,
) -> dict:
"""One day (Tehran calendar): outbound / return / waste / purchase ticket
counts + quantities, the exact performer names, and the operation detail."""
day_start = datetime.combine(day, time.min, tzinfo=TEHRAN)
day_end = day_start + timedelta(days=1)
scope_conditions = []
if project_id is not None:
scope_conditions.append(Warehouse.project_id == project_id)
elif project_ids is not None:
scope_conditions.append(Warehouse.project_id.in_(project_ids))
if warehouse_id is not None:
scope_conditions.append(InventoryTransaction.warehouse_id == warehouse_id)
count_rows = (
await db.execute(
select(
InventoryTransaction.transaction_type,
func.count().label("tickets"),
func.sum(func.abs(InventoryTransaction.quantity)).label("qty"),
)
.select_from(InventoryTransaction)
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
.where(
InventoryTransaction.transaction_date >= day_start,
InventoryTransaction.transaction_date < day_end,
*scope_conditions,
)
.group_by(InventoryTransaction.transaction_type)
)
).all()
by_type = {str(tx_type): {"tickets": tickets, "qty": _qty(qty)} for tx_type, tickets, qty in count_rows}
performers_rows = (
await db.execute(
select(
InventoryTransaction.performed_by,
func.count().label("tickets"),
func.sum(func.abs(InventoryTransaction.quantity)).label("qty"),
)
.select_from(InventoryTransaction)
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
.where(
InventoryTransaction.transaction_date >= day_start,
InventoryTransaction.transaction_date < day_end,
*scope_conditions,
)
.group_by(InventoryTransaction.performed_by)
.order_by(func.count().desc())
)
).all()
detail_rows = (
await db.execute(
select(
InventoryTransaction,
Warehouse.name,
Material.name,
InventoryItem.specs,
Unit.code,
)
.select_from(InventoryTransaction)
.join(Warehouse, Warehouse.id == InventoryTransaction.warehouse_id)
.join(InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id)
.join(Material, Material.id == InventoryItem.material_id)
.join(Unit, Unit.id == InventoryItem.unit_id)
.where(
InventoryTransaction.transaction_date >= day_start,
InventoryTransaction.transaction_date < day_end,
InventoryTransaction.transaction_type.in_(
("WITHDRAWAL", "RETURN", "DAMAGE", "WASTE", "LOSS", "PURCHASE")
),
*scope_conditions,
)
.order_by(InventoryTransaction.transaction_date.desc())
.limit(200)
)
).all()
zero = {"tickets": 0, "qty": 0.0}
items = []
for tx, wh_name, mat_name, specs, ucode in detail_rows:
items.append({
"id": str(tx.id),
"transaction_type": str(tx.transaction_type),
"quantity": _qty(tx.quantity),
"unit_code": ucode,
"performed_by": tx.performed_by,
"note": tx.note,
"warehouse": wh_name,
"material": mat_name,
"specs": specs or {},
"transaction_date": tx.transaction_date.isoformat(),
})
return {
"data": {
"date": day.isoformat(),
"outbound": by_type.get("WITHDRAWAL", zero),
"returns": by_type.get("RETURN", zero),
"waste": {
"tickets": sum(by_type.get(t, zero)["tickets"] for t in WASTE_TYPES),
"qty": round(sum(by_type.get(t, zero)["qty"] for t in WASTE_TYPES), 4),
},
"purchases": by_type.get("PURCHASE", zero),
"performers": [
{"name": name or "نامشخص", "tickets": tickets, "qty": _qty(qty)}
for name, tickets, qty in performers_rows
],
"items": items,
}
}
# ---------------------------------------------------------------------------
# Widget 8 — Procurement workload per project (project density)
# ---------------------------------------------------------------------------
async def procurement_workload(db: AsyncSession, *, project_ids, project_id) -> dict:
"""Per project: offered quotes still open + chosen purchase lines (open
vs fully received). Chosen lines attribute to item_decisions.project_id
(the purchased destination); offered quotes to any linked project."""
received_sq = (
select(ItemReceipt.item_decision_id.label("did"), func.sum(ItemReceipt.received_qty).label("received_qty"))
.group_by(ItemReceipt.item_decision_id)
.subquery()
)
order_conditions = [ItemDecision.status == "chosen"]
if project_id is not None:
order_conditions.append(ItemDecision.project_id == project_id)
elif project_ids is not None:
order_conditions.append(ItemDecision.project_id.in_(project_ids))
order_rows = (
await db.execute(
select(
Project.id,
Project.name,
func.count().label("chosen_items"),
func.sum(LineItem.quantity).label("ordered_qty"),
func.coalesce(func.sum(received_sq.c.received_qty), 0).label("received_qty"),
func.sum(
case(
(func.coalesce(received_sq.c.received_qty, 0) < LineItem.quantity, 1),
else_=0,
)
).label("open_items"),
)
.select_from(ItemDecision)
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
.join(Quote, Quote.id == LineItem.quote_id)
.outerjoin(Project, Project.id == ItemDecision.project_id)
.outerjoin(received_sq, received_sq.c.did == ItemDecision.id)
.where(*order_conditions)
.group_by(Project.id, Project.name)
)
).all()
offered_by_project: dict[uuid.UUID | None, set] = {}
offered_primary = (
await db.execute(
select(Quote.id, Quote.project_id).where(
Quote.status == "offered", Quote.project_id.is_not(None)
)
)
).all()
for quote_id, pid in offered_primary:
offered_by_project.setdefault(pid, set()).add(quote_id)
offered_junction = (
await db.execute(
select(QuoteProjects.quote_id, QuoteProjects.project_id)
.join(Quote, Quote.id == QuoteProjects.quote_id)
.where(Quote.status == "offered")
)
).all()
for quote_id, pid in offered_junction:
offered_by_project.setdefault(pid, set()).add(quote_id)
project_ids_needed = {row[0] for row in order_rows if row[0] is not None}
project_ids_needed.update(pid for pid in offered_by_project if pid is not None)
names: dict[uuid.UUID, str] = {}
if project_ids_needed:
names = {
p.id: p.name
for p in (
await db.execute(select(Project).where(Project.id.in_(project_ids_needed)))
).scalars()
}
projects = []
for pid, _pname, chosen, ordered, received, open_items in order_rows:
if pid is None:
offered = 0
else:
offered = len(offered_by_project.pop(pid, set()))
if project_id is not None and pid != project_id:
offered = 0
elif project_ids is not None and pid not in project_ids:
offered = 0
projects.append({
"id": str(pid) if pid else None,
"name": names.get(pid, "بدون پروژه") if pid else "بدون پروژه",
"offered_quotes": offered,
"chosen_items": chosen,
"open_orders": open_items,
"ordered_qty": _qty(ordered),
"received_qty": _qty(received),
})
# offered-only projects that have no chosen lines yet
for pid, quote_ids in offered_by_project.items():
if not quote_ids:
continue
if project_id is not None and pid != project_id:
continue
if project_ids is not None and (pid is None or pid not in project_ids):
continue
projects.append({
"id": str(pid) if pid else None,
"name": names.get(pid, "بدون پروژه") if pid else "بدون پروژه",
"offered_quotes": len(quote_ids),
"chosen_items": 0,
"open_orders": 0,
"ordered_qty": 0.0,
"received_qty": 0.0,
})
projects.sort(key=lambda p: (-p["open_orders"], -p["offered_quotes"], p["name"]))
return {"data": {"projects": projects}}
+16
View File
@@ -1100,6 +1100,17 @@ async def quote_stats(
*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 {
"total": sum(counts.values()),
"offered": counts["offered"],
@@ -1108,6 +1119,11 @@ async def quote_stats(
"rejected": counts["rejected"],
"expired": counts["expired"],
"average_lead_time_days": int(round(float(avg))) if avg is not None else None,
"funnel": {
"offered_total": funnel_row.offered_total,
"accepted_total": funnel_row.accepted_total,
"arrived_total": funnel_row.arrived_total,
},
}
+83
View File
@@ -262,8 +262,11 @@ async def run() -> None:
material_by_name: dict[str, Material] = {}
brand_material_by_material: dict[str, object] = {}
for name, cat_slug, unit_code, brand_name in MATERIALS:
cat = await get_category(db, cat_slug)
material = Material(
name=name,
topic=cat.name,
category_id=cat.id, # v2.8: dashboard Category→Material cascade
canonical_unit_id=unit_ids[unit_code],
status="active",
)
@@ -441,6 +444,86 @@ async def run() -> None:
))
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()
# summary
+390
View File
@@ -28,6 +28,7 @@
"react-dom": "^19.2.8",
"react-hook-form": "^7.85.0",
"react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
@@ -1366,6 +1367,32 @@
"integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
"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": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
@@ -1714,6 +1741,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": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
@@ -2003,6 +2042,69 @@
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
"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": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
@@ -2033,6 +2135,12 @@
"@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": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
@@ -2127,6 +2235,133 @@
"devOptional": true,
"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": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2155,6 +2390,24 @@
"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": {
"version": "6.5.0",
"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==",
"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": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -2645,6 +2917,36 @@
"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": {
"version": "2.7.2",
"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": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
@@ -2884,6 +3237,12 @@
"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": {
"version": "0.2.17",
"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": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
+1
View File
@@ -30,6 +30,7 @@
"react-dom": "^19.2.8",
"react-hook-form": "^7.85.0",
"react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
+15
View File
@@ -46,6 +46,10 @@ const AdminPanelPage = lazy(() =>
const RequesterHome = lazy(() =>
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() {
return (
@@ -386,6 +390,17 @@ export default function App() {
</RequireAuth>
}
/>
{/* داشبورد تحلیلی MVP (انبار + بازرگانی) — برای PM نیز در دسترس */}
<Route
path="/dashboard"
element={
<RequireAuth>
<RequirePmProjects>
<MvpDashboardPage />
</RequirePmProjects>
</RequireAuth>
}
/>
<Route
path="/units"
element={
+43 -3
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, type RefObject } from "react";
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { X } from "lucide-react";
import { normalize } from "../lib/normalize";
import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside";
@@ -26,6 +27,9 @@ export interface ComboboxProps {
onAdd?: (val: string) => void;
onInputChange?: (val: string) => void;
onBlur?: () => void;
// با ارائه‌ی این تابع، دکمه‌ی «×» داخل فیلد ظاهر می‌شود تا انتخاب فعلی
// (و فیلتر وابسته به آن) به‌تنهایی پاک شود.
onClear?: () => void;
// برای فوکوس برنامه‌ای (مثل بازگشت فوکوس پس از ثبت پیوسته)
inputRef?: RefObject<HTMLInputElement | null>;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
@@ -56,12 +60,17 @@ export function Combobox({
onAdd,
onInputChange,
onBlur,
onClear,
inputRef,
openOnFocus = true,
}: ComboboxProps) {
const [text, setText] = useState(value ?? "");
const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(-1);
// کلیکِ روی اینپوتِ در حالت فوکوس‌نشده، اول onFocus را صدا می‌زند و منو باز
// می‌شود؛ این نشانگر می‌گوید «همین لحظه با فوکوس باز شد» تا همان کلیک
// دوباره منو را نبندد. کلیک‌های بعدی = تاگل باز/بسته.
const justOpenedByFocus = useRef(false);
useEffect(() => {
setText(value ?? "");
@@ -82,6 +91,8 @@ export function Combobox({
const showAdd =
allowAdd && q.length > 0 && !filtered.some((x) => normalize(x.label) === q);
const showClear = Boolean(onClear) && !disabled && text.length > 0;
const listCount = filtered.length + (showAdd ? 1 : 0);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
@@ -133,6 +144,7 @@ export function Combobox({
ref={inputRef}
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",
showClear && "pe-8",
inputClassName,
)}
value={text}
@@ -146,12 +158,40 @@ export function Combobox({
onInputChange?.(e.target.value);
}}
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}
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 && (
<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 && (
+52 -49
View File
@@ -15,11 +15,11 @@ import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside";
export interface JalaliDatePickerProps {
value: string | null;
value: string | null; // ISO date (یا YYYY-MM-DD با time اختیاری)
onChange: (val: string | null) => void;
placeholder?: string;
className?: string;
withTime?: boolean;
withTime?: 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());
}
// ساخت ISO با زمان محلی (تاریخ شمسی + ساعت:دقیقه)
function buildIso(jy: number, jm: number, jd: number, h: number, m: number): string {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
return new Date(gy, gm - 1, gd, h, m).toISOString();
}
// ساخت تاریخ استاندارد YYYY-MM-DD بدون مؤلفه زمان
function buildDateOnly(jy: number, jm: number, jd: number): string {
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
}
// وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقه‌ی فعلی سیستم پیش‌فرض است
// (نه ۰۰:۰۰) — دکمه‌ی «امروز» و انتخاب روز، همین زمان را نگه می‌دارند.
function nowTime(): { h: number; m: number } {
const d = new Date();
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() };
}
// تقویم شمسی — پورت Jalali.buildPicker به React (با پشتیبانی ساعت:دقیقه)
export function JalaliDatePicker({
value,
onChange,
@@ -73,7 +78,6 @@ export function JalaliDatePicker({
disabled = false,
}: JalaliDatePickerProps) {
const [open, setOpen] = useState(false);
const [alignment, setAlignment] = useState<"start" | "end">("start"); // تراز هوشمند
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
const initialTime = timeOf(value);
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");
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);
if (curH !== t.h) {
setHourText(faNum(hh));
@@ -122,18 +127,7 @@ export function JalaliDatePicker({
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
// تابع تشخیص موقعیت فیلد و تنظیم تراز تقویم
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);
};
// تاریخ انتخاب‌شده به صورت محلی (نه UTC) برای مقایسه با سلول‌های تقویم
function selectedLocalDate(): string | null {
if (!value) return null;
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/);
@@ -147,7 +141,7 @@ export function JalaliDatePicker({
const grid = useMemo(() => {
const [jy, jm] = curJ;
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 today = new Date();
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) => {
let digits = toAsciiDigits(raw).replace(/\D/g, "");
if (digits.length === 3) digits = digits.slice(-1);
else if (digits.length > 3) digits = digits.slice(0, 2);
if (digits.length === 3) {
digits = digits.slice(-1);
} else if (digits.length > 3) {
digits = digits.slice(0, 2);
}
if (!digits) {
setHourText("");
return;
@@ -231,8 +228,11 @@ export function JalaliDatePicker({
const handleMinuteChange = (raw: string) => {
let digits = toAsciiDigits(raw).replace(/\D/g, "");
if (digits.length === 3) digits = digits.slice(-1);
else if (digits.length > 3) digits = digits.slice(0, 2);
if (digits.length === 3) {
digits = digits.slice(-1);
} else if (digits.length > 3) {
digits = digits.slice(0, 2);
}
if (!digits) {
setMinuteText("");
return;
@@ -269,7 +269,11 @@ export function JalaliDatePicker({
const [jy, jm, jd] = todayJalali();
const nh = now.getHours();
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);
setText(
withTime
@@ -360,7 +364,7 @@ export function JalaliDatePicker({
};
return (
<div ref={ref} className={cn("relative w-full", className)}>
<div ref={ref} className={cn("relative", className)}>
<input
type="text"
disabled={disabled}
@@ -368,45 +372,43 @@ export function JalaliDatePicker({
value={text}
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
autoComplete="off"
onFocus={handleOpen}
onClick={handleOpen}
onFocus={() => !disabled && setOpen(true)}
onClick={() => !disabled && setOpen(true)}
onChange={(e) => onInputChange(e.target.value)}
onBlur={handleInputBlur}
/>
{open && !disabled && (
<div
className={cn(
// پاپ‌آپ مطلق که بر اساس کلاس تراز تنظیم می‌شود
"absolute top-full z-[100] mt-1",
alignment === "start" ? "start-0" : "end-0", // اگر فیلد چپ بود -> end-0 (تراز چپ) | اگر راست بود -> start-0 (تراز راست)
"rounded-xl border border-line bg-panel shadow-2xl p-3.5",
withTime ? "w-80" : "w-[280px]",
"max-w-[calc(100vw-2rem)]" // حاشیه امنیت برای صفحه نمایش خیلی کوچک
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
// حداکثر عرض = viewport (موبایل باریک سرریز نمی‌شود؛ دسکتاپ بی‌تغییر)
"max-w-[calc(100vw-2rem)]",
withTime ? "w-80 p-3" : "w-64 p-2",
)}
>
{/* 1. Header: Month / Year Navigation */}
<div className="mb-2 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")}>
<div className="mb-1.5 flex items-center justify-between gap-1">
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
«
</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>
<span className="text-sm font-bold">
<span className="text-[13px] font-bold">
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
</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 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>
</div>
{/* 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) => (
<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}
</div>
))}
@@ -418,7 +420,7 @@ export function JalaliDatePicker({
key={i}
type="button"
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 && "font-bold text-[#1d4ed8]",
cell.iso === selectedIso && "bg-accent text-white hover:bg-accent-dark",
@@ -431,43 +433,44 @@ export function JalaliDatePicker({
)}
</div>
{/* 3. Time Inputs Row */}
{/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */}
{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">
<input
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}
inputMode="numeric"
onFocus={(e) => e.target.select()}
onChange={(e) => handleMinuteChange(e.target.value)}
onBlur={handleMinuteBlur}
/>
<span className="text-xs font-semibold text-muted">دقیقه</span>
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
</div>
<span className="font-bold text-muted">:</span>
<div className="flex items-center gap-1.5">
<input
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}
inputMode="numeric"
onFocus={(e) => e.target.select()}
onChange={(e) => handleHourChange(e.target.value)}
onBlur={handleHourBlur}
/>
<span className="text-xs font-semibold text-muted">ساعت</span>
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
</div>
</div>
)}
{/* 4. Action Buttons Footer */}
<div className="mt-3 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}>
<div className="mt-2 flex justify-between border-t border-line pt-2">
<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 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>
</div>
@@ -475,4 +478,4 @@ export function JalaliDatePicker({
)}
</div>
);
}
}
@@ -0,0 +1,183 @@
// ============================================================
// ویجت ۴ — کنترل روزانه (امروز)
// کارت‌های KPI: بلیت‌های خروج امروز، برگشت از سایت، ضایعات و پذیرش
// + فهرست دقیق نام تحویل‌دهنده‌ها/انجام‌دهنده‌ها + ریز عملیات امروز.
// هر ۶۰ ثانیه خودکار تازه‌سازی می‌شود (useDailyControl).
// ============================================================
import {
ArrowDownToLine,
ArrowUpFromLine,
PackageOpen,
TriangleAlert,
Users,
type LucideIcon,
} from "lucide-react";
import { useDailyControl } from "../../hooks/useDashboardData";
import { faNum, fmtDateTime, fmtNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
import { cn } from "../../lib/utils";
import { Badge } from "../ui/badge";
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
const TYPE_FA: Record<string, string> = {
WITHDRAWAL: "برداشت از انبار",
RETURN: "برگشت از سایت",
DAMAGE: "آسیب‌دیده",
WASTE: "اتلاف",
LOSS: "مفقودی",
PURCHASE: "پذیرش خرید",
TRANSFER_IN: "انتقال ورودی",
TRANSFER_OUT: "انتقال خروجی",
FOUND: "پیدا شده",
STOCK_COUNT: "انبارگردانی",
};
const TYPE_STYLE: Record<string, string> = {
WITHDRAWAL: "bg-blue-bg text-[#1d4ed8]",
RETURN: "bg-amber-bg text-amber",
DAMAGE: "bg-red-bg text-red",
WASTE: "bg-red-bg text-red",
LOSS: "bg-red-bg text-red",
PURCHASE: "bg-green-bg text-green",
};
function KpiCard({
icon: Icon,
label,
tickets,
qty,
tone,
}: {
icon: LucideIcon;
label: string;
tickets: number;
qty: number;
tone: string;
}) {
return (
<div className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2.5">
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-lg", tone)}>
<Icon className="h-4.5 w-4.5" />
</span>
<div className="min-w-0">
<div className="text-[11px] text-muted">{label}</div>
<div className="flex items-baseline gap-1.5 tabular-nums">
<span className="text-[17px] font-extrabold leading-6">{faNum(tickets)}</span>
<span className="text-[10.5px] text-muted">بلیت</span>
<span className="text-[11px] text-muted">·</span>
<span className="text-[12px] font-bold">{fmtNum(qty)}</span>
</div>
</div>
</div>
);
}
export function DailyControlPanel() {
const query = useDailyControl();
const d = query.data;
return (
<ChartCard
title="کنترل روزانه — عملیات امروز"
sub={d ? `تاریخ ${isoToJalaliStr(d.date) ?? d.date} — هر دقیقه به‌روزرسانی می‌شود` : "هر دقیقه به‌روزرسانی می‌شود"}
>
{query.isLoading ? (
<ChartLoading />
) : !d ? (
<ChartEmpty message="داده‌ی امروز در دسترس نیست" />
) : (
<div className="flex flex-col gap-3">
{/* KPI کارت‌ها */}
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<KpiCard
icon={ArrowUpFromLine}
label="خروج به سایت (برداشت)"
tickets={d.outbound.tickets}
qty={d.outbound.qty}
tone="bg-blue-bg text-[#1d4ed8]"
/>
<KpiCard
icon={ArrowDownToLine}
label="برگشت از سایت"
tickets={d.returns.tickets}
qty={d.returns.qty}
tone="bg-amber-bg text-amber"
/>
<KpiCard
icon={TriangleAlert}
label="ضایعات / اتلاف / مفقودی"
tickets={d.waste.tickets}
qty={d.waste.qty}
tone="bg-red-bg text-red"
/>
<KpiCard
icon={PackageOpen}
label="پذیرش خرید (ورود)"
tickets={d.purchases.tickets}
qty={d.purchases.qty}
tone="bg-green-bg text-green"
/>
</div>
<div className="grid gap-3 lg:grid-cols-5">
{/* نام دقیق انجام‌دهنده‌ها */}
<div className="rounded-xl border border-line p-3 lg:col-span-2">
<h3 className="mb-2 flex items-center gap-1.5 text-[12.5px] font-extrabold">
<Users className="h-4 w-4 text-muted" />
تحویل‌دهنده‌ها / انجام‌دهنده‌های امروز
</h3>
{d.performers.length === 0 ? (
<p className="py-4 text-center text-[12px] text-muted">امروز عملیاتی ثبت نشده است</p>
) : (
<ul className="flex flex-col divide-y divide-line">
{d.performers.map((p, i) => (
<li key={i} className="flex items-center gap-2 py-1.5">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-accent-bg text-[11px] font-extrabold text-accent">
{faNum(i + 1)}
</span>
<span className="truncate text-[12.5px] font-bold">{p.name}</span>
<span className="ms-auto shrink-0 text-[11px] tabular-nums text-muted">
{fmtNum(p.tickets)} بلیت · {fmtNum(p.qty)}
</span>
</li>
))}
</ul>
)}
</div>
{/* ریز عملیات امروز */}
<div className="rounded-xl border border-line p-3 lg:col-span-3">
<h3 className="mb-2 text-[12.5px] font-extrabold">ریز عملیات امروز</h3>
{d.items.length === 0 ? (
<p className="py-4 text-center text-[12px] text-muted">هنوز جابه‌جایی‌ای امروز ثبت نشده است</p>
) : (
<ul className="max-h-56 flex flex-col divide-y divide-line overflow-y-auto pe-1">
{d.items.map((it) => (
<li key={it.id} className="flex items-center gap-2 py-1.5">
<Badge
className={cn(
"shrink-0 px-2 py-0 text-[10px]",
TYPE_STYLE[it.transaction_type] ?? "bg-slate-bg text-muted",
)}
>
{TYPE_FA[it.transaction_type] ?? it.transaction_type}
</Badge>
<span className="truncate text-[12px] font-bold">{it.material}</span>
<span className="shrink-0 text-[11px] tabular-nums text-muted">
{fmtNum(it.quantity)} {it.unit_code}
</span>
<span className="ms-auto shrink-0 text-[10.5px] text-muted" title={it.warehouse}>
{fmtDateTime(it.transaction_date)}
</span>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)}
</ChartCard>
);
}
@@ -0,0 +1,118 @@
// ============================================================
// فیلتر سراسری «چه چیزی» — آبشار: دسته‌بندی → متریال → برند → مشخصات
// داده از /dashboard/filters می‌آید (بدون هیچ لیست ساختگی) و انتخاب
// هر سطح، سطح‌های پایین‌دست را بازنشانی می‌کند:
// دسته → متریال‌های همان دسته
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
// متریال → مشخصات فنی موجودِ همان متریال
// هر فیلد دکمه‌ی «×» دارد که فقط همان سطح (و زیرمجموعه‌اش) را پاک می‌کند.
// وضعیت در Zustand نگه داشته می‌شود تا با جابه‌جایی بین تب‌ها از دست نرود.
// ============================================================
import { useMemo } from "react";
import { Filter } from "lucide-react";
import { Combobox } from "../Combobox";
import { useDashboardFilterOptions } from "../../hooks/useDashboardData";
import { useDashboardFilter } from "../../store/dashboardFilter";
import { faNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
export function DashboardFilterBar({ className }: { className?: string }) {
const filterQuery = useDashboardFilterOptions();
const state = useDashboardFilter();
const categories = filterQuery.data?.categories ?? [];
// متریال‌های سطح فعلی: اگر دسته انتخاب شده باشد فقط متریال‌های همان دسته
const materialOptions = useMemo(() => {
const cat = categories.find((c) => (c.id ?? "") === (state.categoryId ?? ""));
const list = cat ? cat.materials : categories.flatMap((c) => c.materials);
// حذف متریال تکراری وقتی «همه‌ی دسته‌ها» فعال است
const seen = new Set<string>();
return list.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true)));
}, [categories, state.categoryId]);
const selectedMaterial = useMemo(
() => materialOptions.find((m) => m.id === state.materialId) ?? null,
[materialOptions, state.materialId],
);
const activeCount = [state.categoryId, state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیش‌فرض)»
const specLabel = (specs: Record<string, string> | null) => {
const parts = Object.entries(specs || {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیش‌فرض)";
};
return (
<section
className={cn("rounded-2xl border border-line bg-panel p-3 sm:p-4", className)}
aria-label="فیلتر سراسری متریال"
>
<div className="mb-2.5 flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent-bg text-accent">
<Filter className="h-3.5 w-3.5" />
</span>
<div>
<h2 className="text-[13px] font-extrabold leading-5">فیلتر جنس</h2>
<p className="text-[10.5px] text-muted">دسته‌بندی ← متریال ← برند ← مشخصات فنی</p>
</div>
{activeCount > 0 && (
<button
type="button"
onClick={state.reset}
className="ms-auto rounded-full bg-red-bg px-2.5 py-1 text-[11px] font-bold text-red transition-opacity hover:opacity-80"
>
حذف فیلترها ({faNum(activeCount)})
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
{/* ۱ — دسته‌بندی */}
<Combobox
value={state.categoryLabel ?? ""}
placeholder="همه‌ی دسته‌ها"
allowAdd={false}
items={categories.filter((c) => c.id).map((c) => ({ id: c.id as string, label: c.name }))}
onClear={() => state.setCategory(null, null)}
onChange={(item) => state.setCategory(item.id, item.label)}
/>
{/* ۲ — متریال (وابسته به دسته‌ی انتخاب‌شده) */}
<Combobox
value={state.materialLabel ?? ""}
placeholder="همه‌ی متریال‌ها"
allowAdd={false}
disabled={materialOptions.length === 0}
items={materialOptions.map((m) => ({ id: m.id, label: m.name }))}
onClear={() => state.setMaterial(null, null)}
onChange={(item) => state.setMaterial(item.id, item.label)}
/>
{/* ۳ — برند (فقط برندهای همان متریال) */}
<Combobox
value={state.brandLabel ?? ""}
placeholder={selectedMaterial ? "همه‌ی برندها" : "ابتدا متریال را انتخاب کنید"}
allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
items={(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name }))}
onClear={() => state.setBrand(null, null)}
onChange={(item) => state.setBrand(item.id, item.label)}
/>
{/* ۴ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
<Combobox
value={state.specsLabel ?? ""}
placeholder={selectedMaterial ? "همه‌ی مشخصات" : "ابتدا متریال را انتخاب کنید"}
allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
items={(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) }))}
onClear={() => state.setSpecs(null, null)}
onChange={(item) => state.setSpecs(item.id, item.label)}
/>
</div>
</section>
);
}
@@ -0,0 +1,117 @@
// ============================================================
// ویجت ۲ — جدول موجودی راکد (سرمایه‌ی خوابیده)
// ردیف‌هایی با موجودی مثبت که هیچ تراکنشی در بازه‌ی رکود نداشته‌اند.
// آستانه‌ی ۶۰/۹۰ روز با چیپ انتخاب می‌شود؛ روزهای رکود رنگی است.
// ============================================================
import { useState } from "react";
import { Snowflake } from "lucide-react";
import { useDeadStock } from "../../hooks/useDashboardData";
import { fmtNum, faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table";
import { cn } from "../../lib/utils";
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
function specLabel(specs: Record<string, string>): string {
const parts = Object.entries(specs || {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
}
function idleSeverity(days: number | null): string {
if (days == null) return "text-muted";
if (days >= 90) return "text-red";
if (days >= 60) return "text-amber";
return "text-muted";
}
export function DeadStockTable() {
const [idleDays, setIdleDays] = useState<60 | 90>(60);
const query = useDeadStock(idleDays);
const items = query.data?.items ?? [];
return (
<ChartCard
title={
<span className="inline-flex items-center gap-1.5">
<Snowflake className="h-4 w-4 text-[#0ea5e9]" />
موجودی راکد — سرمایه‌ی خوابیده
</span>
}
sub="اقلامی با موجودی مثبت که هیچ تراکنشی در بازه‌ی انتخابی نداشته‌اند"
actions={
<div className="flex items-center gap-1">
{([60, 90] as const).map((d) => (
<button
key={d}
type="button"
onClick={() => setIdleDays(d)}
className={cn(
"rounded-full px-2.5 py-0.5 text-[11px] font-bold transition-colors",
idleDays === d ? "bg-accent text-white" : "bg-slate-bg text-muted hover:text-ink",
)}
>
{faNum(d)} روز و بیشتر
</button>
))}
</div>
}
>
{query.isLoading ? (
<ChartLoading />
) : items.length === 0 ? (
<ChartEmpty message={`موجودی راکدی با آستانه‌ی ${faNum(idleDays)} روز وجود ندارد`} />
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>متریال و مشخصات</TableHead>
<TableHead>برند</TableHead>
<TableHead>انبار / پروژه</TableHead>
<TableHead className="text-end">مقدار راکد</TableHead>
<TableHead className="text-end">روزهای رکود</TableHead>
<TableHead>آخرین فعالیت</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((it) => {
const specs = specLabel(it.specs);
return (
<TableRow key={it.inventory_id}>
<TableCell>
<div className="text-[13px] font-bold">{it.material}</div>
{specs && <div className="text-[11px] text-muted">{specs}</div>}
</TableCell>
<TableCell className="text-[12px] text-muted">{it.brand || "—"}</TableCell>
<TableCell>
<div className="text-[12px]">{it.warehouse}</div>
<div className="text-[10.5px] text-muted">{it.project}</div>
</TableCell>
<TableCell className="text-end font-bold tabular-nums">
{fmtNum(it.quantity)}{" "}
<span className="text-[10.5px] font-normal text-muted">
{it.unit?.name_fa || it.unit?.code}
</span>
</TableCell>
<TableCell className={cn("text-end font-extrabold tabular-nums", idleSeverity(it.idle_days))}>
{it.idle_days == null ? "—" : faNum(it.idle_days)}
</TableCell>
<TableCell className="text-[11px] text-muted">
{it.last_activity ? isoToJalaliStr(it.last_activity) : "—"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</ChartCard>
);
}
@@ -0,0 +1,275 @@
// ============================================================
// ویجت ۱ — ماتریس Drill-Down سه‌سطحی (سبک Power BI)
// سطح ۱ «کل سازمان» → یک ستون: تجمیع موجودی همه‌ی انبارهای سازمان
// سطح ۲ «پروژه‌ها» → با کلیک روی سطح ۱: سهم هر پروژه
// سطح ۳ «انبارها» → با کلیک روی هر پروژه: انبارهای همان پروژه
// همه‌ی سطوح از پاسخ /dashboard/inventory/drilldown ساخته می‌شوند —
// هیچ مقدار/نام/وضعیتی در فرانت‌اند هارد‌کد نشده است. اگر پروژه‌ی
// انتخاب‌شده با تغییر فیلترها از داده حذف شود، سطح خودکار به بالا برمی‌گردد.
// ============================================================
import { useEffect, useMemo, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
LabelList,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { ChevronLeft, MousePointerClick } from "lucide-react";
import { useDrilldown } from "../../hooks/useDashboardData";
import { fmtNum } from "../../lib/formatters";
import {
CHART_COLORS,
ChartCard,
ChartEmpty,
ChartLoading,
ChartTooltip,
UnitPicker,
faAxisTick,
} from "./chartShared";
type Level = 0 | 1 | 2; // 0=سازمان، 1=پروژه‌ها، 2=انبارها
interface Slice {
id: string;
name: string;
value: number;
rows: number;
}
const LEVEL_TITLES: Record<Level, string> = {
0: "کل سازمان",
1: "پروژه‌ها",
2: "انبارها",
};
// پالت سطح انبارها — سایه‌های ایندیگو برای تمایز انبارهای یک پروژه
const WAREHOUSE_PALETTE = ["#6366f1", "#818cf8", "#a5b4fc", "#c7d2fe"];
export function DrilldownMatrix() {
const query = useDrilldown();
const [unit, setUnit] = useState<string | null>(null);
const [level, setLevel] = useState<Level>(0);
const [projectId, setProjectId] = useState<string | null>(null);
const data = query.data;
// واحد فعال: انتخاب کاربر یا بزرگ‌ترین مجموع (پایدار بین رندرها)
const activeUnit = useMemo(() => {
if (unit && data?.units?.[unit]) return unit;
const entries = Object.entries(data?.total?.qty_by_unit ?? {});
if (!entries.length) return null;
return entries.sort((a, b) => b[1] - a[1])[0][0];
}, [unit, data]);
const selectedProject = useMemo(
() => (projectId && data ? data.projects.find((p) => p.id === projectId) ?? null : null),
[data, projectId],
);
// کلمپِ خودکار: اگر پروژه‌ی انتخابی با فیلترهای جدید از داده خارج شد،
// به سطح پروژه‌ها برگرد (بدون هیچ مقدار ثابت).
useEffect(() => {
if (level === 2 && projectId && data && !selectedProject) {
setProjectId(null);
setLevel(1);
}
if (data && data.projects.length === 0 && level !== 0) {
setProjectId(null);
setLevel(0);
}
}, [data, level, projectId, selectedProject]);
const pick = (q: Record<string, number>) => (activeUnit ? q[activeUnit] ?? 0 : 0);
// برش‌های سطح فعال — کاملاً از پاسخ API
const slices: Slice[] = useMemo(() => {
if (!data) return [];
if (level === 0) {
return [{ id: "__org__", name: "کل سازمان", value: pick(data.total.qty_by_unit), rows: data.total.rows }];
}
if (level === 1) {
return data.projects.map((p) => ({
id: p.id,
name: p.name,
value: pick(p.qty_by_unit),
rows: p.rows,
}));
}
return (selectedProject?.warehouses ?? []).map((w) => ({
id: w.id,
name: w.name,
value: pick(w.qty_by_unit),
rows: w.rows,
}));
}, [data, level, selectedProject, activeUnit]);
const unitLabel = activeUnit ? data?.units?.[activeUnit] || activeUnit : "";
const levelTotal = slices.reduce((s, x) => s + x.value, 0);
const total = data ? pick(data.total.qty_by_unit) : 0;
const handleBarClick = (slice: Slice) => {
if (level === 0 && data && data.projects.length > 0) {
setLevel(1);
} else if (level === 1) {
setProjectId(slice.id);
setLevel(2);
}
// سطح ۳ برگ است — کلیک دیگری لازم نیست
};
const subText =
level === 0
? "تجمیع موجودی همه‌ی انبارهای سازمان برای فیلترهای فعال — برای مشاهده‌ی سهم پروژه‌ها کلیک کنید"
: level === 1
? "سهم هر پروژه از موجودی — برای مشاهده‌ی انبارهای هر پروژه روی ستون آن کلیک کنید"
: `انبارهای پروژه «${selectedProject?.name ?? ""}» — برای بازگشت روی مسیر کلیک کنید`;
return (
<ChartCard
title="ماتریس توزیع موجودی — کجا ذخیره شده؟"
sub={subText}
actions={data ? <UnitPicker units={data.units} value={activeUnit ?? ""} onChange={setUnit} totals={data.total.qty_by_unit} /> : null}
>
{/* مسیر Drill-Down (RTL) */}
<nav dir="rtl" className="mb-2 flex flex-wrap items-center gap-1 text-[11.5px]">
<button
type="button"
onClick={() => {
setProjectId(null);
setLevel(0);
}}
className={
level === 0
? "rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent"
: "rounded-md px-2 py-0.5 font-bold text-muted transition-colors hover:bg-slate-bg hover:text-ink"
}
>
{LEVEL_TITLES[0]}
</button>
{level >= 1 && (
<>
<ChevronLeft className="h-3.5 w-3.5 text-muted" />
<button
type="button"
onClick={() => {
setProjectId(null);
setLevel(1);
}}
className={
level === 1
? "rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent"
: "rounded-md px-2 py-0.5 font-bold text-muted transition-colors hover:bg-slate-bg hover:text-ink"
}
>
{LEVEL_TITLES[1]}
</button>
</>
)}
{level >= 2 && selectedProject && (
<>
<ChevronLeft className="h-3.5 w-3.5 text-muted" />
<span className="rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent">
{selectedProject.name}
</span>
<span className="text-muted">← {LEVEL_TITLES[2]}</span>
</>
)}
<span className="ms-auto text-[11px] tabular-nums text-muted">
جمع سطح فعال: {fmtNum(levelTotal)} {unitLabel}
{level > 0 && levelTotal !== total && (
<span className="ms-1">از {fmtNum(total)} {unitLabel} کل سازمان</span>
)}
</span>
</nav>
{query.isLoading ? (
<ChartLoading />
) : !slices.length || (levelTotal === 0 && level === 0) ? (
<ChartEmpty message="موجودی‌ای با این فیلترها یافت نشد" />
) : (
<>
<div dir="ltr" className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={slices} margin={{ top: 26, right: 8, left: 8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis
dataKey="name"
tick={{ fontSize: 11, fill: "#64748b", fontFamily: "Vazirmatn" }}
tickLine={false}
axisLine={{ stroke: "#e5e9f0" }}
interval={0}
height={44}
angle={slices.length > 4 ? -18 : 0}
textAnchor={slices.length > 4 ? "end" : "middle"}
/>
<YAxis
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick}
tickLine={false}
axisLine={false}
width={56}
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.06)" }}
content={
<ChartTooltip
valueFormatter={(v) => `${fmtNum(v)} ${unitLabel}`}
/>
}
/>
<Bar
dataKey="value"
name="موجودی"
radius={[6, 6, 0, 0]}
maxBarSize={level === 0 ? 110 : 64}
isAnimationActive={false}
onClick={(entry: unknown) => {
const payload = (entry as { payload?: Slice })?.payload;
if (payload) handleBarClick(payload);
}}
className={level < 2 ? "cursor-pointer" : ""}
>
<LabelList
dataKey="value"
position="top"
fill="#0f172a"
fontSize={11}
fontWeight={700}
formatter={(v: unknown) => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? fmtNum(n) : "";
}}
/>
{slices.map((s, i) => (
<Cell
key={s.id}
fill={
level === 0
? CHART_COLORS.accent
: level === 1
? "#818cf8"
: WAREHOUSE_PALETTE[i % WAREHOUSE_PALETTE.length]
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{level < 2 && (
<p className="mt-1 flex items-center justify-center gap-1 text-[10.5px] text-muted">
<MousePointerClick className="h-3.5 w-3.5" />
{level === 0 ? "برای دیدن پروژه‌ها روی ستون کلیک کنید" : "برای دیدن انبارهای هر پروژه روی ستون آن کلیک کنید"}
</p>
)}
</>
)}
</ChartCard>
);
}
@@ -0,0 +1,130 @@
// ============================================================
// ویجت ۳ — سری زمانی جریان روزانه
// ستون‌های روزانه: ورود (سبز) / برگشت (کهربایی) / خروج که لایه‌ی
// قرمزِ «ضایعات، اتلاف، مفقودی» داخل همان ستون خروج سته —
// به‌علاوه‌ی خط «مانده» (بالانس تجمعی). تاریخ‌ها شمسی، اعداد فارسی.
// ============================================================
import { useMemo } from "react";
import {
Bar,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useDailyFlow } from "../../hooks/useDashboardData";
import { isoToJalaliStr } from "../../lib/jalali";
import {
CHART_COLORS,
ChartCard,
ChartEmpty,
ChartLoading,
ChartTooltip,
faAxisTick,
} from "./chartShared";
interface Row {
date: string;
ورود: number;
برگشت: number;
"خروج سایت": number;
"ضایعات و اتلاف": number;
مانده: number;
_total: number;
}
export function FlowChart() {
const query = useDailyFlow();
const rows: Row[] = useMemo(
() =>
(query.data?.days ?? []).map((d) => ({
date: d.date,
ورود: d.inbound,
برگشت: d.returns,
"خروج سایت": Math.max(0, d.outbound - d.waste),
"ضایعات و اتلاف": d.waste,
مانده: d.balance,
_total: d.inbound + d.outbound + d.returns,
})),
[query.data],
);
const hasAnyData = rows.some((r) => r._total > 0);
return (
<ChartCard
title="جریان روزانه موجودی"
sub="ورود، خروج (با لایه‌ی قرمز ضایعات)، برگشت از سایت و مانده‌ی تجمعی — ۳۰ روز اخیر"
>
{query.isLoading ? (
<ChartLoading />
) : !hasAnyData ? (
<ChartEmpty message="در این بازه جابه‌جایی موجودی ثبت نشده است" />
) : (
<div dir="ltr" className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={rows} margin={{ top: 12, right: 8, left: 8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis
dataKey="date"
tickFormatter={(v: string) => isoToJalaliStr(v) ?? v}
tick={{ fontSize: 10.5, fill: "#64748b", fontFamily: "Vazirmatn" }}
tickLine={false}
axisLine={{ stroke: "#e5e9f0" }}
minTickGap={18}
/>
<YAxis
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick}
tickLine={false}
axisLine={false}
width={56}
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
content={
<ChartTooltip
labelFormatter={(l) => isoToJalaliStr(String(l)) ?? String(l)}
/>
}
/>
<Legend
wrapperStyle={{ direction: "rtl", width: "100%" }}
verticalAlign="top"
height={28}
iconType="circle"
iconSize={8}
formatter={(value: unknown) => (
<span dir="rtl" className="text-[11px] text-muted">
{String(value)}
</span>
)}
/>
{/* ستون‌های کنار هم: ورود و برگشت */}
<Bar dataKey="ورود" fill={CHART_COLORS.inbound} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
<Bar dataKey="برگشت" fill={CHART_COLORS.returns} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
{/* ستون خروج: دو لایه‌ی سته — بخش قرمز ضایعات داخل ستون خروج */}
<Bar dataKey="خروج سایت" stackId="out" fill={CHART_COLORS.outbound} radius={[0, 0, 0, 0]} maxBarSize={18} isAnimationActive={false} />
<Bar dataKey="ضایعات و اتلاف" stackId="out" fill={CHART_COLORS.waste} radius={[3, 3, 0, 0]} maxBarSize={18} isAnimationActive={false} />
<Line
type="monotone"
dataKey="مانده"
stroke={CHART_COLORS.balance}
strokeWidth={1.5}
strokeDasharray="5 3"
dot={false}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
</ChartCard>
);
}
@@ -0,0 +1,125 @@
// ============================================================
// ویجت ۶ — شبکه‌ی وضعیت سفارش‌های در راه
// جدول کمی سفارش‌های باز: متریال و تأمین‌کننده | سفارش‌شده | رسیده
// | وضعیت (در راه / تحویل جزئی / تحویل کامل) + نوار پیشرفت.
// داده از /pm/on-the-way (با برچسب برند و مشخصات و فیلتر سراسری).
// ============================================================
import { Truck } from "lucide-react";
import { useInTransit } from "../../hooks/useDashboardData";
import type { InTransitRow } from "../../lib/types";
import { faNum, fmtNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table";
import { Badge } from "../ui/badge";
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
const STATE_META: Record<
InTransitRow["state"],
{ label: string; cls: string; bar: string }
> = {
on_the_way: { label: "در راه", cls: "bg-amber-bg text-amber", bar: "bg-amber" },
partially_received: { label: "تحویل جزئی", cls: "bg-blue-bg text-[#1d4ed8]", bar: "bg-[#2563eb]" },
arrived: { label: "تحویل کامل", cls: "bg-green-bg text-green", bar: "bg-green" },
};
function specLabel(specs: Record<string, string>): string {
const parts = Object.entries(specs || {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
}
function num(v: string): number {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
export function InTransitGrid() {
const query = useInTransit();
const rows = query.data ?? [];
return (
<ChartCard
title={
<span className="inline-flex items-center gap-1.5">
<Truck className="h-4 w-4 text-muted" />
سفارش‌های در راه — وضعیت تحویل
</span>
}
sub="اقلام پذیرفته‌شده‌ی خرید تا رسیدن کامل به انبار"
>
{query.isLoading ? (
<ChartLoading />
) : rows.length === 0 ? (
<ChartEmpty message="سفارش بازی در جریان نیست" />
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>متریال / تأمین‌کننده</TableHead>
<TableHead className="text-end">سفارش‌شده</TableHead>
<TableHead className="text-end">رسیده تاکنون</TableHead>
<TableHead className="min-w-[140px]">پیشرفت</TableHead>
<TableHead>وضعیت</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => {
const ordered = num(r.ordered_qty);
const received = num(r.received_qty);
const pct = ordered > 0 ? Math.min(100, Math.round((received / ordered) * 100)) : 0;
const meta = STATE_META[r.state] ?? STATE_META.on_the_way;
const specs = specLabel(r.specs);
return (
<TableRow key={r.item_decision_id}>
<TableCell>
<div className="text-[13px] font-bold">{r.material.name}</div>
<div className="text-[11px] text-muted">
{r.supplier.name}
{r.brand ? ` · ${r.brand.name}` : ""}
</div>
{specs && <div className="text-[10.5px] text-muted">{specs}</div>}
{r.project && (
<div className="mt-0.5 text-[10.5px] text-accent">{r.project.name}</div>
)}
</TableCell>
<TableCell className="text-end font-bold tabular-nums">
{fmtNum(ordered)}
<div className="text-[10px] font-normal text-muted">
{r.unit?.name_fa || r.unit?.code}
</div>
</TableCell>
<TableCell className="text-end font-bold tabular-nums">
{fmtNum(received)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-bg">
<div
className={cn("h-full rounded-full", meta.bar)}
style={{ width: `${pct}%` }}
/>
</div>
<span className="shrink-0 text-[10.5px] tabular-nums text-muted">
{faNum(pct)}٪
</span>
</div>
</TableCell>
<TableCell>
<Badge className={cn("px-2 py-0.5 text-[10.5px]", meta.cls)}>{meta.label}</Badge>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</ChartCard>
);
}
@@ -0,0 +1,42 @@
// ============================================================
// ویجت ۵ — KPI زمان تأمین (Lead Time)
// میانگین روزهای «پذیرش رسمی سفارش → رسیدن فیزیکی به انبار»
// از /quotes/stats (میانگین arrived_date − accepted_date).
// ============================================================
import { Timer } from "lucide-react";
import { useQuoteStats } from "../../hooks/useDashboardData";
import { faNum } from "../../lib/formatters";
import { ChartCard, ChartLoading } from "./chartShared";
export function LeadTimeKpiCard() {
const query = useQuoteStats();
const days = query.data?.average_lead_time_days;
return (
<ChartCard title="میانگین زمان تأمین" sub="از پذیرش رسمی سفارش تا رسیدن فیزیکی به انبار">
{query.isLoading ? (
<ChartLoading />
) : (
<div className="flex items-center gap-4">
<span className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-accent-bg text-accent">
<Timer className="h-7 w-7" />
</span>
<div>
<div className="flex items-baseline gap-1.5">
<span className="text-[38px] font-extrabold leading-none tabular-nums text-ink">
{days == null ? "—" : faNum(days)}
</span>
<span className="text-[14px] font-bold text-muted">روز</span>
</div>
<p className="mt-1 text-[11px] text-muted">
{days == null
? "هنوز سفارشی به مرحله‌ی رسیدن نرسیده است"
: `بر پایه‌ی سفارش‌های رسیده‌شده به انبار`}
</p>
</div>
</div>
)}
</ChartCard>
);
}
@@ -0,0 +1,124 @@
// ============================================================
// ویجت ۷ — قیف تدارکات
// تبدیل تجمعی: کل درخواست‌ها (پیشنهادشده) → پذیرفته‌شده → رسیده به انبار
// از /quotes/stats.funnel (شمارش تجمعی بر پایه‌ی تاریخ‌های واقعی).
// ============================================================
import { useMemo } from "react";
import { Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from "recharts";
import { useQuoteStats } from "../../hooks/useDashboardData";
import { faNum, fmtNum } from "../../lib/formatters";
import { CHART_COLORS, ChartCard, ChartEmpty, ChartLoading, ChartTooltip } from "./chartShared";
interface Step {
name: string;
value: number;
fill: string;
conversion: number | null;
}
export function ProcurementFunnel() {
const query = useQuoteStats();
const steps: Step[] = useMemo(() => {
const f = query.data?.funnel;
if (!f) return [];
const offered = f.offered_total;
return [
{
name: "کل درخواست‌ها (پیشنهادشده)",
value: f.offered_total,
fill: CHART_COLORS.accent,
conversion: null,
},
{
name: "پذیرفته‌شده",
value: f.accepted_total,
fill: CHART_COLORS.blue,
conversion: offered > 0 ? Math.round((f.accepted_total / offered) * 100) : 0,
},
{
name: "رسیده به انبار",
value: f.arrived_total,
fill: CHART_COLORS.green,
conversion: offered > 0 ? Math.round((f.arrived_total / offered) * 100) : 0,
},
];
}, [query.data]);
const total = steps[0]?.value ?? 0;
return (
<ChartCard
title="قیف تدارکات"
sub="تبدیل درخواست‌ها به سفارش پذیرفته‌شده و رسیدن کالا به انبار"
>
{query.isLoading ? (
<ChartLoading />
) : total === 0 ? (
<ChartEmpty message="هنوز درخواستی ثبت نشده است" />
) : (
<div className="grid items-center gap-3 sm:grid-cols-5">
<div dir="ltr" className="h-56 sm:col-span-3">
<ResponsiveContainer width="100%" height="100%">
<FunnelChart>
<Tooltip
content={
<ChartTooltip
labelFormatter={(l) => String(l)}
valueFormatter={(v) => `${fmtNum(v)} درخواست`}
/>
}
/>
<Funnel dataKey="value" data={steps} isAnimationActive={false}>
{/* مقدار هر مرحله — با dataKey صریح تا Recharts مقدار درست بدهد */}
<LabelList
dataKey="value"
position="right"
fill="#0f172a"
stroke="none"
fontSize={12}
fontWeight={700}
formatter={(v: unknown) => {
const n = Number(v);
return Number.isFinite(n) ? fmtNum(n) : "";
}}
/>
{/* نام مرحله داخل نوار */}
<LabelList
dataKey="name"
position="left"
fill="#ffffff"
stroke="none"
fontSize={11}
formatter={(v: unknown) => (v == null ? "" : String(v))}
/>
</Funnel>
</FunnelChart>
</ResponsiveContainer>
</div>
{/* مراحل قیف + درصد تبدیل */}
<ul className="flex flex-col gap-2 sm:col-span-2">
{steps.map((s) => (
<li key={s.name} className="rounded-xl border border-line px-3 py-2">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: s.fill }} />
<span className="text-[12px] font-bold">{s.name}</span>
<span className="ms-auto text-[15px] font-extrabold tabular-nums">
{fmtNum(s.value)}
</span>
</div>
{s.conversion != null && (
<div className="mt-0.5 text-[10.5px] text-muted">
{faNum(s.conversion)}٪ از کل درخواست‌ها
</div>
)}
</li>
))}
</ul>
</div>
)}
</ChartCard>
);
}
@@ -0,0 +1,123 @@
// ============================================================
// ویجت ۸ — تراکم پروژه‌ها (Project Density)
// نمودار میله‌ای افقی: توزیع بار تدارکات روی پروژه‌های فعال —
// سفارش‌های باز (سته‌شده با پیشنهادهای باز).
// ============================================================
import { useMemo } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useProcurementWorkload } from "../../hooks/useDashboardData";
import { faNum } from "../../lib/formatters";
import {
CHART_COLORS,
ChartCard,
ChartEmpty,
ChartLoading,
ChartTooltip,
faAxisTick,
} from "./chartShared";
export function ProjectDensityChart() {
const query = useProcurementWorkload();
const rows = useMemo(() => {
const projects = query.data?.projects ?? [];
return projects
.filter((p) => p.open_orders > 0 || p.offered_quotes > 0 || p.chosen_items > 0)
.map((p) => ({
name: p.name,
"سفارش باز (در راه)": p.open_orders,
"پیشنهاد باز": p.offered_quotes,
_ordered: p.ordered_qty,
_received: p.received_qty,
}))
.sort((a, b) => (b["سفارش باز (در راه)"] + b["پیشنهاد باز"]) - (a["سفارش باز (در راه)"] + a["پیشنهاد باز"]));
}, [query.data]);
return (
<ChartCard
title="تراکم بار تدارکات روی پروژه‌ها"
sub="تعداد سفارش‌های باز و پیشنهادهای در جریان به تفکیک پروژه"
>
{query.isLoading ? (
<ChartLoading />
) : rows.length === 0 ? (
<ChartEmpty message="بار تدارکاتی فعالی روی پروژه‌ها وجود ندارد" />
) : (
<div dir="ltr" className="w-full" style={{ height: Math.max(180, rows.length * 56 + 60) }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={rows}
layout="vertical"
margin={{ top: 8, right: 16, left: 8, bottom: 4 }}
barCategoryGap="28%"
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" horizontal={false} />
<XAxis
type="number"
tick={{ fontSize: 11, fill: "#64748b" }}
tickFormatter={faAxisTick}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<YAxis
type="category"
dataKey="name"
width={150}
tick={{ fontSize: 11, fill: "#0f172a", fontFamily: "Vazirmatn" }}
tickLine={false}
axisLine={false}
/>
<Tooltip
cursor={{ fill: "rgba(79, 70, 229, 0.05)" }}
content={
<ChartTooltip
labelFormatter={(l) => String(l)}
valueFormatter={(v) => `${faNum(v)} مورد`}
/>
}
/>
<Legend
wrapperStyle={{ direction: "rtl", width: "100%" }}
verticalAlign="top"
height={26}
iconType="circle"
iconSize={8}
formatter={(value: unknown) => (
<span dir="rtl" className="text-[11px] text-muted">
{String(value)}
</span>
)}
/>
<Bar
dataKey="سفارش باز (در راه)"
stackId="load"
fill={CHART_COLORS.blue}
maxBarSize={22}
isAnimationActive={false}
/>
<Bar
dataKey="پیشنهاد باز"
stackId="load"
fill={CHART_COLORS.amber}
radius={[0, 4, 4, 0]}
maxBarSize={22}
isAnimationActive={false}
/>
</BarChart>
</ResponsiveContainer>
</div>
)}
</ChartCard>
);
}
@@ -0,0 +1,181 @@
// ============================================================
// ابزار مشترک نمودارها — اعداد فارسی، تاریخ شمسی، تولتیپ RTL
// همه‌ی نمودارهای داشبورد از این قالب استفاده می‌کنند تا خروجی
// Recharts فارسی و هماهنگ با دیزاین‌سیستم (Tailwind @theme) باشد.
// ============================================================
import type { ReactNode } from "react";
import { groupCommas } from "../../lib/numutil";
import { isoToJalaliStr } from "../../lib/jalali";
import { faNum, fmtNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
// پالت معنایی نمودارها — هم‌راستا با توکن‌های @theme در globals.css
export const CHART_COLORS = {
inbound: "#16a34a", // ورود = سبز
outbound: "#2563eb", // خروج = آبی
waste: "#dc2626", // ضایعات/اتلاف/مفقودی = قرمز (لایه‌ی داخل ستون خروج)
returns: "#d97706", // برگشت از سایت = کهربایی
balance: "#0f172a", // مانده = خط سرمه‌ای نقطه‌چین
accent: "#4f46e5", // ایندیگو — رنگ اصلی سیستم
amber: "#d97706",
green: "#16a34a",
red: "#dc2626",
blue: "#2563eb",
} as const;
// عدد → رقم فارسی با جداکننده‌ی هزارگان (تیک محورها)
export function faAxisTick(value: unknown): string {
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return "";
if (Number.isInteger(n) && Math.abs(n) < 1000) return faNum(n);
return groupCommas(n, true);
}
// ISO (YYYY-MM-DD) → تاریخ شمسی کوتاه برای محور X
export function jaAxisDate(iso: unknown): string {
const j = isoToJalaliStr(typeof iso === "string" ? iso : String(iso ?? ""));
return j || String(iso ?? "");
}
// ——— تولتیپ سفارشی RTL — اعداد فارسی و نام‌های فارسی سری‌ها ———
interface TooltipEntry {
name?: string | number;
value?: string | number;
color?: string;
dataKey?: string | number;
payload?: Record<string, unknown>;
}
interface TooltipProps {
active?: boolean;
payload?: TooltipEntry[];
label?: string | number;
labelFormatter?: (label: string | number) => ReactNode;
valueFormatter?: (value: number, name: string) => string;
}
export function ChartTooltip({ active, payload, label, labelFormatter, valueFormatter }: TooltipProps) {
if (!active || !payload || payload.length === 0) return null;
// ردیف‌های صفر را از تولتیپ حذف می‌کنیم — شلوغیِ بی‌معنا (اصل Power BI)
const rows = payload.filter((p) => {
const v = typeof p.value === "number" ? p.value : Number(p.value);
return Number.isFinite(v) && v !== 0;
});
return (
<div
dir="rtl"
className="rounded-xl border border-line bg-panel/95 px-3 py-2 shadow-lg backdrop-blur-sm"
>
{label !== undefined && label !== "" && (
<div className="mb-1 text-[11px] font-bold text-muted">
{labelFormatter ? labelFormatter(label) : String(label)}
</div>
)}
<div className="flex flex-col gap-1">
{rows.map((p, i) => {
const v = typeof p.value === "number" ? p.value : Number(p.value);
const name = String(p.name ?? p.dataKey ?? "");
return (
<div key={i} className="flex items-center gap-2 text-[12px] leading-5">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: p.color }} />
<span className="text-muted">{name}</span>
<span className="ms-auto font-bold tabular-nums text-ink">
{valueFormatter ? valueFormatter(v, name) : groupCommas(v, true)}
</span>
</div>
);
})}
</div>
</div>
);
}
// ——— کارت نگه‌دارنده‌ی ویجت — عنوان + توضیح + اکشن ———
export function ChartCard({
title,
sub,
actions,
children,
className,
}: {
title: ReactNode;
sub?: string;
actions?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section className={cn("rounded-2xl border border-line bg-panel p-4", className)}>
<header className="mb-3 flex flex-wrap items-center gap-2">
<div className="min-w-0">
<h2 className="text-[14px] font-extrabold leading-6">{title}</h2>
{sub && <p className="text-[11px] text-muted">{sub}</p>}
</div>
{actions && <div className="ms-auto flex items-center gap-1.5">{actions}</div>}
</header>
{children}
</section>
);
}
// ——— حالت خالی/خطای ویجت ———
export function ChartEmpty({ message = "داده‌ای برای نمایش وجود ندارد" }: { message?: string }) {
return (
<div className="flex h-40 items-center justify-center rounded-xl border border-dashed border-line text-[12px] text-muted">
{message}
</div>
);
}
export function ChartLoading() {
return (
<div className="flex h-40 items-center justify-center text-[12px] text-muted">
در حال بارگذاری...
</div>
);
}
// ——— چیپ انتخاب واحد — با مجموع هر واحد تا تغییرِ نمودار فوراً دیده شود ———
export function UnitPicker({
units,
value,
onChange,
totals,
}: {
units: Record<string, string>;
value: string;
onChange: (code: string) => void;
totals?: Record<string, number>;
}) {
const codes = Object.keys(units);
if (codes.length <= 1) return null;
return (
<div className="flex flex-wrap items-center gap-1">
{codes.map((code) => {
const total = totals?.[code];
return (
<button
key={code}
type="button"
onClick={() => onChange(code)}
title={"نمایش موجودی به " + (units[code] || code)}
className={cn(
"rounded-full px-2.5 py-1 text-[11px] font-semibold transition-colors",
value === code
? "bg-accent text-white"
: "bg-slate-bg text-muted hover:text-ink",
)}
>
{units[code] || code}
{total != null && (
<span className={cn("ms-1 tabular-nums", value === code ? "text-white/80" : "text-muted/80")}>
{fmtNum(total)}
</span>
)}
</button>
);
})}
</div>
);
}
@@ -101,6 +101,7 @@ export function MobileTabBar() {
if (pathname === "/wizard" || pathname.startsWith("/review")) return null;
const onOfficePage =
pathname === "/" ||
pathname === "/dashboard" ||
pathname === "/projects" ||
pathname === "/units" ||
pathname.startsWith("/suppliers");
+90
View File
@@ -0,0 +1,90 @@
// ============================================================
// هوک‌های TanStack Query برای داشبورد MVP
// همه‌ی ویجت‌ها مستقیم به API واقعی وصل‌اند — بدون هیچ داده‌ی ساختگی.
// کلیدهای کوئری شامل پارامترهای فیلتر «چه چیزی» هستند تا با هر
// تغییر فیلتر، فقط ویجت‌های همان تب دوباره خوانده شوند.
// ============================================================
import { useQuery } from "@tanstack/react-query";
import { apiV26 } from "../lib/api";
import type { DashboardFilterParams } from "../lib/api";
import { dashboardFilterParams, useDashboardFilter } from "../store/dashboardFilter";
function useFilterParams(): DashboardFilterParams {
const state = useDashboardFilter();
return dashboardFilterParams(state);
}
// گزینه‌های آبشاری فیلتر سراسری (دسته → متریال → برند → مشخصات)
export function useDashboardFilterOptions() {
return useQuery({
queryKey: ["dashboard", "filters"],
queryFn: () => apiV26.dashboard.filters(),
staleTime: 5 * 60 * 1000,
});
}
// ویجت ۱ — ماتریس Drill-Down (کل سازمان → پروژه → انبار)
export function useDrilldown() {
const params = useFilterParams();
return useQuery({
queryKey: ["dashboard", "drilldown", params],
queryFn: () => apiV26.dashboard.drilldown(params),
});
}
// ویجت ۲ — موجودی راکد (dead stock)
export function useDeadStock(idleDays: number) {
const params = useFilterParams();
return useQuery({
queryKey: ["dashboard", "dead-stock", idleDays, params],
queryFn: () => apiV26.dashboard.deadStock({ ...params, idle_days: idleDays }),
});
}
// ویجت ۳ — سری زمانی جریان روزانه
export function useDailyFlow(dateFrom?: string, dateTo?: string) {
const params = useFilterParams();
return useQuery({
queryKey: ["dashboard", "flow", dateFrom, dateTo, params],
queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }),
});
}
// ویجت ۴ — کنترل روزانه (امروز) — هر ۶۰ ثانیه تازه‌سازی می‌شود
export function useDailyControl(date?: string) {
return useQuery({
queryKey: ["dashboard", "daily-control", date],
queryFn: () => apiV26.dashboard.dailyControl(date ? { date } : {}),
refetchInterval: 60 * 1000,
});
}
// ویجت ۵ + ۷ — KPI زمان تأمین و قیف تدارکات (/quotes/stats)
export function useQuoteStats() {
return useQuery({
queryKey: ["dashboard", "quote-stats"],
queryFn: () => apiV26.dashboard.quoteStats(),
staleTime: 60 * 1000,
});
}
// ویجت ۶ — کالاهای در راه (با فیلتر متریال/برند سراسری)
export function useInTransit() {
const state = useDashboardFilter();
const params = dashboardFilterParams(state);
return useQuery({
queryKey: ["dashboard", "in-transit", params],
queryFn: () =>
apiV26.pm.onTheWay(null, { material_id: params.material_id, brand_id: params.brand_id }),
});
}
// ویجت ۸ — بار تدارکات روی پروژه‌ها
export function useProcurementWorkload() {
return useQuery({
queryKey: ["dashboard", "workload"],
queryFn: () => apiV26.dashboard.workload(),
staleTime: 60 * 1000,
});
}
+47 -6
View File
@@ -19,23 +19,39 @@ import { useAuthStore } from "../store/auth";
import type {
Attachment,
ConfirmedReceipt,
DashboardFilterOptions,
DailyControlData,
DeadStockItem,
DrilldownData,
FlowDay,
InTransitRow,
InventoryRow,
InventoryTransaction,
Material,
PendingArrival,
Project,
Quote,
QuoteStats,
StandardConversion,
Store,
Supplier,
TransactionType,
Warehouse,
ProjectTransfer,
ProjectTransferCreateIn,
ProjectTransferUpdateIn,
ProjectTransferReceiveIn,
ProjectTransferUpdateIn,
TransactionType,
Warehouse,
WorkloadProject,
} from "./types";
// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد
// (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد)
export type DashboardFilterParams = {
category_id?: string | null;
material_id?: string | null;
brand_id?: string | null;
specs_hash?: string | null;
};
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
const PAGE_SIZE = 100;
@@ -1887,8 +1903,12 @@ export const apiV26 = {
pm: {
// پروژه فعال به‌صورت صریح ارسال می‌شود؛ بک‌اند تعلق آن به PM را بررسی می‌کند
onTheWay: async (projectId?: string | null): Promise<PendingArrival[]> =>
getData<PendingArrival[]>(projectId ? `/pm/on-the-way?project_id=${projectId}` : "/pm/on-the-way"),
onTheWay: async (projectId?: string | null, params: { material_id?: string | null; brand_id?: string | null } = {}): Promise<InTransitRow[]> => {
const qs = new URLSearchParams(
cleanParams({ project_id: projectId ?? activeProjectId(), ...params }),
).toString();
return getData<InTransitRow[]>(`/pm/on-the-way${qs ? `?${qs}` : ""}`);
},
confirmReceipt: async (body: {
item_decision_id: string; received_qty: number;
allocations: { warehouse_id: string; quantity: number }[];
@@ -1899,6 +1919,27 @@ export const apiV26 = {
getData<ConfirmedReceipt[]>(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"),
},
// ---------- داشبورد MVP (v2.8) — تجمیع‌های سمت سرور، فقط‌خواندنی ----------
// نقش/پروژه سمت بک‌اند از JWT (یا هدرهای dev) حل می‌شود؛ PM به پروژه‌های
// خودش محدود است و ادمین/مدیر تدارکات کل سازمان را می‌بیند.
dashboard: {
filters: async (): Promise<DashboardFilterOptions> =>
getData<DashboardFilterOptions>("/dashboard/filters"),
drilldown: async (params: DashboardFilterParams = {}): Promise<DrilldownData> =>
getData<DrilldownData>(`/dashboard/inventory/drilldown?${new URLSearchParams(cleanParams(params))}`),
deadStock: async (params: DashboardFilterParams & { idle_days?: number } = {}): Promise<{ idle_days_threshold: number; items: DeadStockItem[] }> =>
getData<{ idle_days_threshold: number; items: DeadStockItem[] }>(`/dashboard/inventory/dead-stock?${new URLSearchParams(cleanParams(params))}`),
flow: async (params: DashboardFilterParams & { date_from?: string; date_to?: string; warehouse_id?: string } = {}): Promise<{ opening_balance: number; days: FlowDay[] }> =>
getData<{ opening_balance: number; days: FlowDay[] }>(`/dashboard/inventory/flow?${new URLSearchParams(cleanParams(params))}`),
dailyControl: async (params: { date?: string; warehouse_id?: string } = {}): Promise<DailyControlData> =>
getData<DailyControlData>(`/dashboard/daily-control?${new URLSearchParams(cleanParams(params))}`),
workload: async (): Promise<{ projects: WorkloadProject[] }> =>
getData<{ projects: WorkloadProject[] }>("/dashboard/procurement/workload"),
// KPI زمان تأمین + قیف تدارکات (endpoint آماری موجود /quotes/stats)
quoteStats: async (): Promise<QuoteStats> =>
getData<QuoteStats>("/quotes/stats"),
},
// ساخت موجودیت‌های کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):
// متریال/برند/واحد جدید بدون وابستگی به دفتر تدارکات ساخته می‌شوند.
catalog: {
+132 -1
View File
@@ -195,6 +195,12 @@ export interface PendingArrival {
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 {
receipt_id: string;
quote_id: string;
@@ -208,6 +214,132 @@ export interface ConfirmedReceipt {
allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[];
}
// ============================================================
// v2.8 — داشبورد MVP (انبار + بازرگانی)
// ============================================================
// — فیلتر سراسری «چه چیزی» —
export interface FilterSpecOption {
hash: string;
specs: Record<string, string>;
}
export interface FilterMaterialOption {
id: string;
name: string;
unit: { id: number; code: string; name_fa: string | null } | null;
brands: { id: string; name: string }[];
specs: FilterSpecOption[];
}
export interface FilterCategoryOption {
id: string | null; // null = «بدون دسته»
name: string;
slug: string | null;
materials: FilterMaterialOption[];
}
export interface DashboardFilterOptions {
categories: FilterCategoryOption[];
}
// — ماتریس Drill-Down (کل سازمان → پروژه → انبار) —
export type QtyByUnit = Record<string, number>;
export interface DrilldownWarehouse {
id: string;
name: string;
qty_by_unit: QtyByUnit;
rows: number;
}
export interface DrilldownProject {
id: string;
name: string;
qty_by_unit: QtyByUnit;
rows: number;
warehouses: DrilldownWarehouse[];
}
export interface DrilldownData {
units: Record<string, string>;
total: { qty_by_unit: QtyByUnit; rows: number };
projects: DrilldownProject[];
}
// — جدول موجودی راکد —
export interface DeadStockItem {
inventory_id: string;
material: string;
brand: string | null;
specs: Record<string, string>;
warehouse: string;
project: string;
quantity: number;
unit: { code: string; name_fa: string | null };
last_activity: string | null;
idle_days: number | null;
}
// — سری زمانی جریان روزانه —
export interface FlowDay {
date: string; // ISO (YYYY-MM-DD)
inbound: number;
outbound: number;
waste: number;
returns: number;
balance: number;
}
// — کنترل روزانه —
export interface DailyControlData {
date: string;
outbound: { tickets: number; qty: number };
returns: { tickets: number; qty: number };
waste: { tickets: number; qty: number };
purchases: { tickets: number; qty: number };
performers: { name: string; tickets: number; qty: number }[];
items: {
id: string;
transaction_type: string;
quantity: number;
unit_code: string;
performed_by: string | null;
note: string | null;
warehouse: string;
material: string;
specs: Record<string, string>;
transaction_date: string;
}[];
}
// — بازرگانی: قیف تدارکات + KPI زمان تأمین (/quotes/stats) —
export interface QuoteStats {
total: number;
offered: number;
accepted: number;
arrived: number;
rejected: number;
expired: number;
average_lead_time_days: number | null;
funnel: {
offered_total: number;
accepted_total: number;
arrived_total: number;
};
}
// — بار تدارکات روی پروژه‌ها (Project Density) —
export interface WorkloadProject {
id: string | null;
name: string;
offered_quotes: number;
chosen_items: number;
open_orders: number;
ordered_qty: number;
received_qty: number;
}
export interface ProjectTransfer {
id: string;
from_project: { id: string; name: string };
@@ -255,4 +387,3 @@ export interface ProjectTransferReceiveIn {
received_at?: string | null;
note?: string | null;
}
+10 -1
View File
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Users, FolderKanban, Ruler, Plus } from "lucide-react";
import { Users, FolderKanban, Ruler, Plus, ChartColumnBig } from "lucide-react";
import {
useMaterials,
useProjects,
@@ -94,6 +94,15 @@ export function DashboardPage() {
menu={<AppSwitcherMenu activeModule="procurement" />}
actions={
<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">
<Users className="h-3.5 w-3.5" />
تأمین‌کننده‌ها
@@ -0,0 +1,99 @@
// ============================================================
// داشبورد MVP — نمای «انبار» و نمای «بازرگانی و لجستیک»
// فیلتر سراسری «چه چیزی» بالای هر دو نما (Zustand — با جابه‌جایی
// بین تب‌ها حفظ می‌شود). تمام ویجت‌ها مستقیم به API واقعی وصل‌اند.
// ============================================================
import { useState } from "react";
import { BarChart3, Warehouse } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { DashboardFilterBar } from "../../components/dashboard/DashboardFilterBar";
import { DrilldownMatrix } from "../../components/dashboard/DrilldownMatrix";
import { FlowChart } from "../../components/dashboard/FlowChart";
import { DeadStockTable } from "../../components/dashboard/DeadStockTable";
import { DailyControlPanel } from "../../components/dashboard/DailyControlPanel";
import { LeadTimeKpiCard } from "../../components/dashboard/LeadTimeKpiCard";
import { InTransitGrid } from "../../components/dashboard/InTransitGrid";
import { ProcurementFunnel } from "../../components/dashboard/ProcurementFunnel";
import { ProjectDensityChart } from "../../components/dashboard/ProjectDensityChart";
import { cn } from "../../lib/utils";
type Tab = "warehouse" | "procurement";
const TABS: { key: Tab; label: string; icon: typeof Warehouse }[] = [
{ key: "warehouse", label: "داشبورد انبار", icon: Warehouse },
{ key: "procurement", label: "داشبورد بازرگانی و لجستیک", icon: BarChart3 },
];
export function MvpDashboardPage() {
const [tab, setTab] = useState<Tab>("warehouse");
return (
<div className="min-h-screen pb-20 md:pb-6">
<Header
title="داشبورد تحلیلی"
sub="انبار و تدارکات — فیلتر جنس روی همه‌ی ویجت‌ها اعمال می‌شود"
brand
menu={<AppSwitcherMenu activeModule="procurement" />}
actions={
/* تب‌های نما — سگمنت‌کنترل هماهنگ با دیزاین‌سیستم */
<div className="flex items-center gap-1 rounded-xl border border-line bg-slate-bg p-1">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
type="button"
onClick={() => setTab(key)}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[12px] font-bold transition-all",
tab === key
? "bg-panel text-accent shadow-sm"
: "text-muted hover:text-ink",
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
}
/>
<PageLayout className="flex flex-col gap-3 px-3 md:px-6">
{/* فیلتر سراسری «چه چیزی» — مشترک بین دو نما */}
<DashboardFilterBar />
{tab === "warehouse" ? (
/* ——— نمای A: انبار ——— */
<div className="flex flex-col gap-3">
{/* ردیف اول: ماتریس Drill-Down (تمام عرض — ستون فقرات نما) */}
<DrilldownMatrix />
{/* ردیف دوم: سری زمانی + کنترل روزانه */}
<div className="grid gap-3 xl:grid-cols-2">
<FlowChart />
<DailyControlPanel />
</div>
{/* ردیف سوم: موجودی راکد */}
<DeadStockTable />
</div>
) : (
/* ——— نمای B: بازرگانی و لجستیک ——— */
<div className="flex flex-col gap-3">
{/* KPI + قیف */}
<div className="grid gap-3 xl:grid-cols-3">
<LeadTimeKpiCard />
<div className="xl:col-span-2">
<ProcurementFunnel />
</div>
</div>
{/* تراکم پروژه‌ها + سفارش‌های در راه */}
<div className="grid gap-3 xl:grid-cols-2">
<ProjectDensityChart />
<InTransitGrid />
</div>
</div>
)}
</PageLayout>
</div>
);
}
@@ -212,9 +212,6 @@ export function OrganizationPage() {
{users.map((u) => {
const isAdmin = u.role === "admin";
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm);
// سرپرست/مدیر پروژه بدون پنل هم عضویت پروژهای میخواهد —
// ورود سرپرست بدون پروژه مسدود است، پس ویرایشگر باید باز باشد
const canAssignProjects = hasPanel || u.role === "supervisor" || u.role === "pm";
return (
<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">
{isAdmin ? (
<span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژه‌ها</span>
) : canAssignProjects ? (
) : hasPanel ? (
<div className="w-72">
<ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))}
+80
View File
@@ -0,0 +1,80 @@
// ============================================================
// Zustand store — فیلتر سراسری «چه چیزی» داشبورد
// دسته‌بندی → متریال → برند → مشخصات فنی
// بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ می‌شود.
// ============================================================
import { create } from "zustand";
export interface DashboardFilterState {
categoryId: string | null; // null = همه‌ی دسته‌ها
materialId: string | null;
brandId: string | null;
specsHash: string | null;
// برچسب‌های انتخاب‌شده برای نمایش چیپ فعال
categoryLabel: string | null;
materialLabel: string | null;
brandLabel: string | null;
specsLabel: string | null;
setCategory: (id: string | null, label: string | null) => void;
setMaterial: (id: string | null, label: string | null) => void;
setBrand: (id: string | null, label: string | null) => void;
setSpecs: (hash: string | null, label: string | null) => void;
reset: () => void;
}
const INITIAL = {
categoryId: null,
materialId: null,
brandId: null,
specsHash: null,
categoryLabel: null,
materialLabel: null,
brandLabel: null,
specsLabel: null,
};
export const useDashboardFilter = create<DashboardFilterState>((set) => ({
...INITIAL,
// آبشاری: تغییر هر سطح، سطح‌های پایین‌دست را پاک می‌کند
setCategory: (id, label) =>
set({
categoryId: id,
categoryLabel: label,
materialId: null,
materialLabel: null,
brandId: null,
brandLabel: null,
specsHash: null,
specsLabel: null,
}),
setMaterial: (id, label) =>
set({
materialId: id,
materialLabel: label,
brandId: null,
brandLabel: null,
specsHash: null,
specsLabel: null,
}),
setBrand: (id, label) =>
set({
brandId: id,
brandLabel: label,
specsHash: null,
specsLabel: null,
}),
setSpecs: (hash, label) => set({ specsHash: hash, specsLabel: label }),
reset: () => set({ ...INITIAL }),
}));
// پارامترهای آماده برای endpointهای داشبورد
export function dashboardFilterParams(s: DashboardFilterState) {
return {
category_id: s.categoryId,
material_id: s.materialId,
brand_id: s.brandId,
specs_hash: s.specsHash,
};
}
+4 -3
View File
@@ -25,7 +25,7 @@ services:
dockerfile: Dockerfile
container_name: pm-api
environment:
PM_DATABASE_URL: postgresql+asyncpg://pm:${PM_DB_PASSWORD:-pm_secret_2026}@pm-postgres:5432/pm
PM_DATABASE_URL: postgresql+asyncpg://postgres:postgres@procurement-postgres:5432/procurement
PM_SESSION_SECRET: ${PM_SESSION_SECRET:-12278fc281bd0e22950e76364e1a18cea61fbc6a24604fdd79690a817ec36ac8}
PM_ADMIN_USER: ${PM_ADMIN_USER:-admin}
PM_ADMIN_PASSWORD: ${PM_ADMIN_PASSWORD:-pm123456}
@@ -48,7 +48,7 @@ services:
condition: service_healthy
networks:
- default
- traefik_network
- erp_default
restart: unless-stopped
pm-frontend:
@@ -85,4 +85,5 @@ networks:
erp_default:
external: true
traefik_network:
external: true
external: true
File diff suppressed because it is too large Load Diff
+32 -37
View File
@@ -87,15 +87,6 @@ export function TaskEditModal({
const [errorMsg, setErrorMsg] = useState("");
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
// بررسی زنده برای کوچکتر بودن تاریخ پایان از شروع
const isDateRangeInvalid = Boolean(
plannedStart &&
plannedEnd &&
isoToApiDate(plannedStart) &&
isoToApiDate(plannedEnd) &&
isoToApiDate(plannedStart)! > isoToApiDate(plannedEnd)!
);
useEffect(() => {
if (open && task) {
setTitle(task.title || task.subject || "");
@@ -121,7 +112,7 @@ export function TaskEditModal({
const taskId = task ? String(task.id || task.op_wp_id) : "";
// لیست والدهای مجاز
// لیست والدهای مجاز (حذف خود تسک و تمام زیرتسک‌های آن برای جلوگیری از حلقه)
const validParents = useMemo(() => {
if (!taskId) return allTasks;
const descendantIds = getDescendantIds(allTasks, taskId);
@@ -131,11 +122,12 @@ export function TaskEditModal({
});
}, [allTasks, taskId]);
// لیست وظایف مجاز جهت برقراری رابطه وابستگی
// لیست وظایف مجاز جهت برقراری رابطه وابستگی (حذف خود تسک)
const validRelationTargets = useMemo(() => {
return allTasks.filter((t) => String(t.id || t.op_wp_id) !== taskId);
}, [allTasks, taskId]);
// کوئری روابط پروژه جهت استخراج روابط ورودی و خروجی این وظیفه
const relationsQuery = useQuery({
queryKey: ["project-relations", projectId],
queryFn: () => getProjectRelations(projectId),
@@ -169,7 +161,7 @@ export function TaskEditModal({
void queryClient.invalidateQueries({ queryKey: ["task", taskId] });
},
onError: (err: any) => {
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی");
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی (احتمال وجود دور یا ورودی تکراری)");
},
});
@@ -187,9 +179,12 @@ export function TaskEditModal({
},
});
/** Save the whole form. Progress goes to its own endpoint (see below). */
const updateMutation = useMutation({
mutationFn: async (payload: UpdateTaskPayload) => {
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)) {
await updateTaskProgress(task!.id || task!.op_wp_id, progress);
}
@@ -237,6 +232,7 @@ export function TaskEditModal({
const startIso = isoToApiDate(plannedStart);
const endIso = isoToApiDate(plannedEnd);
// ۱. اجباری بودن تاریخ شروع و پایان
if (!startIso) {
setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است");
return;
@@ -246,11 +242,13 @@ export function TaskEditModal({
return;
}
// ۲. تاریخ شروع نباید از تاریخ پایان جلوتر باشد
if (startIso > endIso) {
setErrorMsg("تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد");
setErrorMsg("تاریخ شروع وظیفه نمی‌تواند بعد از تاریخ اتمام آن باشد");
return;
}
// ۲. محدودیت نسبت به والد انتخابی
if (parentId) {
const selectedParent = allTasks.find(
(t) => String(t.id || t.op_wp_id) === String(parentId)
@@ -283,6 +281,7 @@ export function TaskEditModal({
}
}
// ۳. محدودیت نسبت به فرزندان موجود
const children = allTasks.filter((t) => {
const p = t.parent_id
? String(t.parent_id)
@@ -411,20 +410,20 @@ export function TaskEditModal({
<label className="mb-1 block text-xs font-semibold text-slate-600">
پیشرفت (٪)
</label>
<input
type="number"
min={0}
max={100}
dir="ltr"
value={progress}
onChange={(e) => {
const raw = e.target.value;
setProgress(raw === "" ? "" : Math.max(0, Math.min(100, Number(raw))));
}}
placeholder="۰"
className={`${inputCls} text-left`}
/>
</div>
<input
type="number"
min={0}
max={100}
dir="ltr"
value={progress}
onChange={(e) => {
const raw = e.target.value;
setProgress(raw === "" ? "" : Math.max(0, Math.min(100, Number(raw))));
}}
placeholder="۰"
className={`${inputCls} text-left`}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
@@ -444,6 +443,7 @@ export function TaskEditModal({
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
@@ -483,14 +483,6 @@ export function TaskEditModal({
</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">
<p className="font-semibold text-slate-700 mb-1">تاریخ‌های واقعی (محاسبه و ثبت خودکار):</p>
<div className="grid grid-cols-2 gap-2 text-slate-600">
@@ -558,6 +550,7 @@ export function TaskEditModal({
setMaterials={setMaterials}
/>
{/* بخش روابط پیش‌نیازی و وابستگی‌ها (FS, SS, FF, SF) */}
<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">
<span className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
@@ -571,6 +564,7 @@ export function TaskEditModal({
)}
</div>
{/* لیست وابستگی‌های موجود */}
<div className="space-y-1.5">
{currentTaskRelations.length === 0 ? (
<p className="py-2 text-center text-xs text-slate-400">
@@ -625,6 +619,7 @@ export function TaskEditModal({
)}
</div>
{/* فرم افزودن رابطه جدید */}
<div className="pt-2 border-t border-slate-200/80 space-y-2">
<span className="text-[11px] font-bold text-slate-700 block">افزودن رابطه وابستگی جدید</span>
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2">
@@ -728,7 +723,7 @@ export function TaskEditModal({
</button>
<button
type="submit"
disabled={updateMutation.isPending || isDateRangeInvalid}
disabled={updateMutation.isPending}
className={btnPrimary}
>
{updateMutation.isPending ? "در حال ذخیره..." : "ذخیره تغییرات"}
@@ -738,4 +733,4 @@ export function TaskEditModal({
</form>
</Modal>
);
}
}
+38 -95
View File
@@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
@@ -48,7 +48,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
const [defectToResolve, setDefectToResolve] = useState<Defect | null>(null);
const [resolutionNotes, setResolutionNotes] = useState("");
// دریافت اطلاعات QC وظیفه
// Load Task QC Data
const qcQuery = useQuery({
queryKey: ["task-qc", taskId],
queryFn: () => getTaskQc(taskId),
@@ -66,50 +66,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
const questions = definitions.flatMap((d) => d.questions);
const selectedTemplate = (templatesQuery.data ?? []).find((t) => t.id === selectedTemplateId) ?? null;
// محاسبه زنده و دقیق آمار هر مرحله بر اساس لیست واقعی سوالات
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)
// Mutations
const invalidateQc = () => {
void queryClient.invalidateQueries({ queryKey: ["task-qc", 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) => {
if (!canExecuteQc || answerMutation.isPending) return;
if (currentAnswer === newAnswer) {
// کلیک مجدد: لغو انتخاب قبلی
answerMutation.mutate({ questionId, answer: null });
} else {
answerMutation.mutate({ questionId, answer: newAnswer });
@@ -200,15 +158,18 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
},
});
// محاسبه پیشرفت کل چک‌لیست
const totalQuestions = questions.length > 0 ? questions.length : (readiness?.all.total ?? 0);
const answeredCount = questions.length > 0
? questions.filter((q) => q.result?.answer != null).length
: (totalQuestions - (readiness?.all.pending ?? 0));
// Calculate Progress
const totalQuestions = readiness?.all.total ?? questions.length;
const pendingCount = readiness?.all.pending ?? 0;
const answeredCount = totalQuestions - pendingCount;
const qcPercent = totalQuestions > 0 ? Math.round((answeredCount / totalQuestions) * 100) : 0;
const currentPhaseQuestions = questions.filter((q) => q.phase === activePhase);
// Group stats by phase
const preStats = readiness?.pre;
const execStats = readiness?.exec;
return (
<div className="space-y-3.5">
{/* 1. بنر وضعیت آمادگی شروع و اتمام کار */}
@@ -245,13 +206,12 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
</div>
)}
{/* نوار پیشرفت کلی QC */}
{totalQuestions > 0 && (
<div className="mt-2.5">
<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">
{qcPercent}٪ ({answeredCount} از {totalQuestions})
</span>
<span className="font-bold tabular-nums text-slate-700">{qcPercent}٪ ({answeredCount} از {totalQuestions})</span>
</div>
<ProgressBar value={qcPercent} />
</div>
@@ -292,72 +252,52 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
</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
type="button"
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"
? "bg-white text-amber-800 shadow-sm font-bold"
: "text-slate-600 hover:text-slate-800"
}`}
>
<span className="truncate">۱. پیش از اجرا</span>
<span
className={`rounded-full px-1.5 py-0.2 text-[10px] tabular-nums shrink-0 ${
activePhase === "pre"
? "bg-amber-100 text-amber-800 font-bold"
: "bg-slate-300 text-slate-700"
}`}
>
{preStats.passed}/{preStats.total}
</span>
<span>۱. پیش از اجرا</span>
{preStats && preStats.total > 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"}`}>
{preStats.passed}/{preStats.total}
</span>
)}
</button>
<button
type="button"
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"
? "bg-white text-sky-800 shadow-sm font-bold"
: "text-slate-600 hover:text-slate-800"
}`}
>
<span className="truncate">۲. حین اجرا</span>
<span
className={`rounded-full px-1.5 py-0.2 text-[10px] tabular-nums shrink-0 ${
activePhase === "during"
? "bg-sky-100 text-sky-800 font-bold"
: "bg-slate-300 text-slate-700"
}`}
>
{duringStats.passed}/{duringStats.total}
</span>
<span>۲. حین اجرا</span>
{execStats && execStats.total > 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"}`}>
{execStats.passed}/{execStats.total}
</span>
)}
</button>
<button
type="button"
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"
? "bg-white text-emerald-800 shadow-sm font-bold"
: "text-slate-600 hover:text-slate-800"
}`}
>
<span className="truncate">
<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>
<span>۳. تحویل و پس از اجرا</span>
</button>
</div>
@@ -423,11 +363,12 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
)}
</div>
{/* دکمه‌های ۳ گانه پاسخ */}
{/* دکمه‌های پاسخ فوری ۳ گانه */}
<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>
<div className="flex items-center gap-1">
{/* دکمه قبول / Pass */}
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
@@ -442,6 +383,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
<span>قبول</span>
</button>
{/* دکمه رد / Fail */}
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
@@ -456,6 +398,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
<span>مردود</span>
</button>
{/* دکمه مطرح نیست */}
<button
type="button"
disabled={!canExecuteQc || answerMutation.isPending}
@@ -472,7 +415,7 @@ export function TaskQcSection({ task, canExecuteQc, canDefineQc }: TaskQcSection
</div>
</div>
{/* در صورت قبول بودن: امکان آپلود تصویر مستند */}
{/* در صورت قبول بودن: اکشن آپلود عکس تأییدیه */}
{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="flex items-center justify-between">
@@ -989,4 +932,4 @@ function CreateDefectModal({
</form>
</Modal>
);
}
}
+304 -61
View File
@@ -3,7 +3,6 @@ import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
AlertTriangle,
BarChart3,
Calendar,
ChevronDown,
@@ -16,15 +15,21 @@ import {
RefreshCw,
Search,
Trash2,
UserPlus,
Users,
} from "lucide-react";
import {
addProjectMember,
createTask,
createTaskRelation,
deleteTaskRelation,
getProject,
getProjectGantt,
getTasks,
getUsers,
removeProjectMember,
rescheduleProject,
updateProject,
updateTask,
updateTaskRelation,
} from "../lib/api";
@@ -33,6 +38,7 @@ import type {
CreateTaskPayload,
HumanResourceItem,
MaterialItem,
ProjectMember,
Task,
TaskRelation,
UpdateRelationPayload,
@@ -44,6 +50,7 @@ import {
Chip,
EmptyState,
ErrorState,
InitialAvatar,
Modal,
PageHeader,
ProgressBar,
@@ -135,6 +142,7 @@ function TreeRow({
const displayTitle = t.title || t.subject;
const targetUrl = `/tasks/${t.id || t.op_wp_id}`;
const progress = t.progress ?? t.percentage_done ?? 0;
// رنگ درصد: ۱۰۰٪ سبز، در حال انجام آبی، صفر خاکستری
const progressCls =
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">
{displayTitle}
</span>
{/* درصد تکمیل — بلافاصله سمت چپ نام و بالای نوار پیشرفت */}
<span className={`shrink-0 text-[13px] font-bold tabular-nums ${progressCls}`}>
{progress}٪
</span>
@@ -251,6 +260,7 @@ function TaskCreateModal({
const [materials, setMaterials] = useState<MaterialItem[]>([]);
const [errorMsg, setErrorMsg] = useState("");
// روابط پیش‌نیازی و وابستگی‌ها (FS, SS, FF, SF)
interface DraftRelation {
target_task_id: string;
target_task_title: string;
@@ -262,16 +272,7 @@ function TaskCreateModal({
const [relTargetId, setRelTargetId] = useState("");
const [relDirection, setRelDirection] = useState<"predecessor" | "successor">("predecessor");
const [relType, setRelType] = useState<"FS" | "SS" | "FF" | "SF">("FS");
const [relLag, setRelLag] = useState<number | "">("");
// بررسی زنده برای کوچکتر بودن تاریخ پایان از شروع
const isDateRangeInvalid = Boolean(
plannedStart &&
plannedEnd &&
isoToApiDate(plannedStart) &&
isoToApiDate(plannedEnd) &&
isoToApiDate(plannedStart)! > isoToApiDate(plannedEnd)!
);
const [relLag, setRelLag] = useState<number>(0);
useEffect(() => {
if (open) {
@@ -279,7 +280,7 @@ function TaskCreateModal({
setSelectedChildIds([]);
setDraftRelations([]);
setRelTargetId("");
setRelLag("");
setRelLag(0);
setErrorMsg("");
}
}, [open, parentTask]);
@@ -289,6 +290,7 @@ function TaskCreateModal({
const created = await createTask(payload);
const newTaskId = String(created.id || (created as any).data?.id || (created as any).op_wp_id);
// ۱. انتساب وظایف انتخاب شده به عنوان فرزندان (زیرتسک‌ها)
if (selectedChildIds.length > 0 && newTaskId) {
for (const childId of selectedChildIds) {
try {
@@ -299,6 +301,7 @@ function TaskCreateModal({
}
}
// ۲. ایجاد روابط وابستگی تعریف شده (FS, SS, FF, SF)
if (draftRelations.length > 0 && newTaskId) {
for (const rel of draftRelations) {
try {
@@ -353,6 +356,7 @@ function TaskCreateModal({
const startIso = isoToApiDate(plannedStart);
const endIso = isoToApiDate(plannedEnd);
// ۱. اجباری بودن تاریخ شروع و پایان
if (!startIso) {
setErrorMsg("وارد کردن تاریخ شروع وظیفه الزامی است");
return;
@@ -362,11 +366,13 @@ function TaskCreateModal({
return;
}
// ۲. تاریخ شروع نباید از تاریخ پایان جلوتر باشد
if (startIso > endIso) {
setErrorMsg("تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد");
setErrorMsg("تاریخ شروع وظیفه نمی‌تواند بعد از تاریخ اتمام آن باشد");
return;
}
// ۳. انطباق با تاریخ‌های والد انتخابی
const activeParent = selectedParentId
? allTasks.find((t) => String(t.id || t.op_wp_id) === selectedParentId)
: null;
@@ -413,6 +419,7 @@ function TaskCreateModal({
});
};
// وظایف کاندید برای فرزند بودن (به جز والدی که هم‌اکنون انتخاب شده)
const candidateChildren = allTasks.filter(
(t) => String(t.id || t.op_wp_id) !== selectedParentId
);
@@ -437,6 +444,7 @@ function TaskCreateModal({
/>
</div>
{/* ۱. تعیین وظیفه والد (PARENT) */}
<div>
<label className="mb-1 block text-xs font-semibold text-slate-600">
وظیفه والد (سطح بالاتر - Parent)
@@ -464,6 +472,7 @@ function TaskCreateModal({
</select>
</div>
{/* ۲. تعیین زیرتسک‌ها و فرزندان (CHILD) */}
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-slate-700">
@@ -567,14 +576,6 @@ function TaskCreateModal({
</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>
<label className="mb-1 block text-xs font-semibold text-slate-600">مدت زمان برآورد شده (روز)</label>
<input
@@ -594,9 +595,8 @@ function TaskCreateModal({
setPlannedEnd(next.end);
setDurationDays(next.days);
}}
onFocus={(e) => e.target.select()}
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">
روز، تاریخ پایان را می‌سازد: شروع ۱۶ مرداد و ۴ روز ⇒ پایان ۲۰ مرداد.
@@ -621,6 +621,7 @@ function TaskCreateModal({
setMaterials={setMaterials}
/>
{/* ۳. تعریف روابط وابستگی (FS, SS, FF, SF + lag) در تعریف وظیفه */}
<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">
<span className="text-xs font-bold text-slate-700 flex items-center gap-1.5">
@@ -634,6 +635,7 @@ function TaskCreateModal({
)}
</div>
{/* لیست روابط پیش‌نویس */}
{draftRelations.length > 0 && (
<div className="space-y-1.5">
{draftRelations.map((r, idx) => (
@@ -670,6 +672,7 @@ function TaskCreateModal({
</div>
)}
{/* فرم افزودن رابطه پیش‌نویس */}
<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">
<label className="text-[10px] font-medium text-slate-500 block mb-0.5">وظیفه مرتبط</label>
@@ -721,13 +724,8 @@ function TaskCreateModal({
<input
type="number"
value={relLag}
onChange={(e) => {
const val = e.target.value;
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"
onChange={(e) => setRelLag(parseInt(e.target.value) || 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"
/>
</div>
</div>
@@ -749,11 +747,11 @@ function TaskCreateModal({
target_task_title: targetTask?.title || targetTask?.subject || "وظیفه",
direction: relDirection,
relation_type: relType,
lag_days: Number(relLag) || 0,
lag_days: relLag,
},
]);
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"
>
@@ -766,11 +764,7 @@ function TaskCreateModal({
<button type="button" onClick={onClose} className={btnSecondary}>
انصراف
</button>
<button
type="submit"
disabled={createMutation.isPending || isDateRangeInvalid}
className={btnPrimary}
>
<button type="submit" disabled={createMutation.isPending} className={btnPrimary}>
{createMutation.isPending ? "در حال ثبت..." : "افزودن وظیفه"}
</button>
</div>
@@ -791,6 +785,7 @@ function CreateRelationModal({
projectId: string;
tasks: Task[];
initialFromTaskId: string | null;
/** در حالت ویرایش: وابستگی موجود؛ دو سر وابستگی تغییرناپذیرند */
editRelation?: TaskRelation | null;
onClose: () => void;
}) {
@@ -799,32 +794,24 @@ function CreateRelationModal({
const [fromTaskId, setFromTaskId] = useState(initialFromTaskId || "");
const [toTaskId, setToTaskId] = useState("");
const [relationType, setRelationType] = useState<"FS" | "SS" | "FF" | "SF">("FS");
const [lagDays, setLagDays] = useState<number | "">("");
const [lagDays, setLagDays] = useState(0);
const [errorMsg, setErrorMsg] = useState("");
useEffect(() => {
if (initialFromTaskId) setFromTaskId(initialFromTaskId);
}, [initialFromTaskId]);
// در حالت ویرایش، فرم با مقادیر فعلی وابستگی پر می‌شود
useEffect(() => {
if (!open) return;
if (editRelation) {
setFromTaskId(editRelation.from_task_id);
setToTaskId(editRelation.to_task_id);
setRelationType(editRelation.relation_type);
setLagDays(
editRelation.lag_days !== undefined && editRelation.lag_days !== null
? editRelation.lag_days
: ""
);
} else {
setFromTaskId(initialFromTaskId || "");
setToTaskId("");
setRelationType("FS");
setLagDays("");
setLagDays(editRelation.lag_days || 0);
}
setErrorMsg("");
}, [open, editRelation, initialFromTaskId]);
}, [open, editRelation]);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ["project-gantt", projectId] });
@@ -837,7 +824,7 @@ function CreateRelationModal({
invalidate();
onClose();
setToTaskId("");
setLagDays("");
setLagDays(0);
setErrorMsg("");
},
onError: (err: any) => {
@@ -946,13 +933,9 @@ function CreateRelationModal({
<input
type="number"
value={lagDays}
onChange={(e) => {
const val = e.target.value;
setLagDays(val === "" ? "" : Number(val));
}}
onFocus={(e) => e.target.select()}
placeholder="0"
className={`${inputCls} [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none`}
onChange={(e) => setLagDays(Number(e.target.value))}
placeholder="مثلاً ۲ (گیرش بتن)"
className={inputCls}
/>
</div>
</div>
@@ -975,8 +958,113 @@ function CreateRelationModal({
{createRelMutation.isPending || updateRelMutation.isPending
? "در حال محاسبه..."
: 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>
</div>
</form>
@@ -994,15 +1082,17 @@ export default function ProjectDetailPage() {
const canManageProjects = !isSupervisor && hasPermission(user, Perm.projectsUpdate);
const projectId = id ?? "";
const [activeTab, setActiveTab] = useState<"tasks" | "gantt">("tasks");
const [activeTab, setActiveTab] = useState<"tasks" | "gantt" | "members">("tasks");
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [showTaskModal, setShowTaskModal] = useState(false);
const [parentTaskForModal, setParentTaskForModal] = useState<Task | null>(null);
const [showEditModal, setShowEditModal] = useState(false);
const [taskToEdit, setTaskToEdit] = useState<Task | null>(null);
const [showMemberModal, setShowMemberModal] = useState(false);
const [showRelationModal, setShowRelationModal] = useState(false);
const [relationFromTaskId, setRelationFromTaskId] = useState<string | null>(null);
/** وابستگی در حال ویرایش (null = حالت تعریف جدید) */
const [relationToEdit, setRelationToEdit] = useState<TaskRelation | null>(null);
const initializedFor = useRef<string | null>(null);
@@ -1067,12 +1157,21 @@ export default function ProjectDetailPage() {
setShowRelationModal(true);
};
/** ویرایش یک وابستگی موجود از داخل کشوی تسک */
const handleEditRelation = (relation: TaskRelation) => {
setRelationFromTaskId(null);
setRelationToEdit(relation);
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({
mutationFn: (relId: string) => deleteTaskRelation(projectId, relId),
onSuccess: () => {
@@ -1090,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 = () => {
void projectQuery.refetch();
void tasksQuery.refetch();
@@ -1136,6 +1247,58 @@ export default function ProjectDetailPage() {
<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>
</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>
{project.location && (
@@ -1185,6 +1348,7 @@ export default function ProjectDetailPage() {
</Card>
)}
{/* ۳ تب: ساختار شکست کار / گانت و زمان‌بندی / اعضای تیم */}
<div className="mb-3 flex rounded-xl bg-slate-200/80 p-1 text-xs font-semibold text-slate-600">
<button
type="button"
@@ -1204,6 +1368,15 @@ export default function ProjectDetailPage() {
>
نمودار گانت و زمان‌بندی
</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>
{activeTab === "tasks" && (
@@ -1316,6 +1489,69 @@ export default function ProjectDetailPage() {
)}
</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>
<TaskCreateModal
@@ -1352,6 +1588,13 @@ export default function ProjectDetailPage() {
setRelationToEdit(null);
}}
/>
<AddMemberModal
open={showMemberModal}
projectId={projectId}
onClose={() => setShowMemberModal(false)}
/>
</>
);
}
}
+113 -147
View File
@@ -62,16 +62,6 @@ import { isoToApiDate } from "../lib/jalali";
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 }) {
return (
<Card className="p-4">
@@ -81,6 +71,10 @@ function SectionCard({ title, children }: { title: ReactNode; children: ReactNod
);
}
// ----------------------------------------
// صفحه جزئیات وظیفه
// ----------------------------------------
export default function TaskDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -105,6 +99,7 @@ export default function TaskDetailPage() {
const [showEditModal, setShowEditModal] = useState(false);
const [progress, setProgress] = useState(0);
/** ISO میلادی — ارسال با isoToApiDate */
const [startDate, setStartDate] = 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 [progressNote, setProgressNote] = useState("");
const isDateRangeInvalid = Boolean(
startDate &&
dueDate &&
isoToApiDate(startDate) &&
isoToApiDate(dueDate) &&
isoToApiDate(startDate)! > isoToApiDate(dueDate)!
);
// محاسبه مجموع مصارف ثبت‌شده قبلی از سوابق گزارش‌های وظیفه
const pastConsumptionMap = useMemo(() => {
const map = new Map<string, { total: number; unit: string }>();
if (!task?.progress_logs) return map;
@@ -160,6 +148,7 @@ export default function TaskDetailPage() {
setConsumedHr([]);
}
// یکپارچه‌سازی مصالح تخصیص‌یافته و مصارف قبلی
const items: MaterialConsumption[] = [];
const seen = new Set<string>();
@@ -169,7 +158,6 @@ export default function TaskDetailPage() {
seen.add(key);
const past = pastConsumptionMap.get(key)?.total || 0;
const alloc = Number(m.count) || 0;
const isOver = alloc > 0 && past > alloc;
items.push({
name: m.name,
unit: m.unit || "",
@@ -178,13 +166,14 @@ export default function TaskDetailPage() {
previous_consumed: past,
total_consumed: past,
remaining_amount: Math.max(0, alloc - past),
is_over_allocated: isOver,
over_allocated_amount: isOver ? past - alloc : 0,
is_over_allocated: alloc > 0 ? past > alloc : past > 0,
over_allocated_amount: Math.max(0, past - alloc),
note: "",
});
}
}
// مصالحی که در سوابق قبل مصرف شده‌اند ولی در تخصیص اولیه نبوده‌اند
pastConsumptionMap.forEach((val, key) => {
if (!seen.has(key)) {
items.push({
@@ -195,8 +184,8 @@ export default function TaskDetailPage() {
previous_consumed: val.total,
total_consumed: val.total,
remaining_amount: 0,
is_over_allocated: false,
over_allocated_amount: 0,
is_over_allocated: val.total > 0,
over_allocated_amount: val.total,
note: "خارج از سقف مصوب",
});
}
@@ -208,12 +197,14 @@ export default function TaskDetailPage() {
const invalidateTask = () => {
void queryClient.invalidateQueries({ queryKey: ["task", taskIdParam] });
// کلید QC با همان شناسهای است که صفحههای QC استفاده میکنند
void queryClient.invalidateQueries({ queryKey: ["task-qc", currentTaskId] });
void queryClient.invalidateQueries({ queryKey: ["today"] });
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
};
const progressMutation = useMutation({
// ثبت پیشرفت کار همراه با مصرف واقعی نیروی انسانی و مصالح
mutationFn: (payload: TaskProgressPayload) => updateTaskProgress(currentTaskId, payload),
onSuccess: () => {
toast.success("پیشرفت کار و گزارش مصرف منابع با موفقیت ثبت شد");
@@ -224,7 +215,8 @@ export default function TaskDetailPage() {
});
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) => {
const next = [...prev];
const item = { ...next[idx] };
@@ -232,8 +224,8 @@ export default function TaskDetailPage() {
const prevC = item.previous_consumed ?? 0;
const tot = prevC + amt;
const rem = Math.max(0, alloc - tot);
const isOver = alloc > 0 && tot > alloc;
const overAmt = isOver ? tot - alloc : 0;
const isOver = alloc > 0 ? tot > alloc : tot > 0;
const overAmt = Math.max(0, tot - alloc);
item.amount = amt;
item.total_consumed = tot;
@@ -258,7 +250,7 @@ export default function TaskDetailPage() {
...prev,
{
name: "",
unit: "عدد",
unit: "",
amount: 0,
allocated_amount: 0,
previous_consumed: 0,
@@ -266,7 +258,7 @@ export default function TaskDetailPage() {
remaining_amount: 0,
is_over_allocated: false,
over_allocated_amount: 0,
note: "مصالح خارج از برنامه",
note: "مصالح مازاد / خارج از برنامه",
},
]);
};
@@ -281,7 +273,7 @@ export default function TaskDetailPage() {
const start = isoToApiDate(startDate);
const due = isoToApiDate(dueDate);
if (start && due && start > due) {
throw new ApiError("تاریخ پایان نمی‌تواند کمتر از تاریخ شروع باشد", 400, "VALIDATION");
throw new ApiError("تاریخ شروع یک وظیفه نمی‌تواند بعد از تاریخ اتمام آن باشد", 400, "VALIDATION");
}
if (start) payload.start_date = start;
if (due) payload.due_date = due;
@@ -294,6 +286,7 @@ export default function TaskDetailPage() {
onError: (err) => toast.error(err instanceof ApiError ? err.message : "ثبت تاریخ‌ها ناموفق بود"),
});
// ۱. وضعیت و جهش ویرایش منابع تخصیص‌یافته به وظیفه
const [showEditResourcesModal, setShowEditResourcesModal] = useState(false);
const [editMaterials, setEditMaterials] = useState<MaterialItem[]>([]);
@@ -317,6 +310,7 @@ export default function TaskDetailPage() {
toast.error(err instanceof ApiError ? err.message : "خطا در به‌روزرسانی منابع وظیفه"),
});
// ۲. وضعیت و جهش‌های مدیریت روابط وابستگی (FS, SS, FF, SF)
const [showAddRelationModal, setShowAddRelationModal] = useState(false);
const [relTargetId, setRelTargetId] = useState("");
const [relDirection, setRelDirection] = useState<"predecessor" | "successor">("predecessor");
@@ -342,7 +336,7 @@ export default function TaskDetailPage() {
}
},
onError: (err: any) => {
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی");
toast.error(err?.message || "خطا در ایجاد رابطه وابستگی (احتمال وجود دور یا ورودی نامعتبر)");
},
});
@@ -405,6 +399,8 @@ export default function TaskDetailPage() {
const projectLabel = project?.name ?? "—";
const overdue = isOverdue(task.due_date);
// مجوزهای نقش: ناظر پیشرفت را ثبت و بازرسی را اجرا می‌کند، اما ساختار QC و
// تاریخ‌های برنامه و ویرایش تسک را تغییر نمی‌دهد (سرور هم مستقل همین را بررسی می‌کند).
const isSupervisor = (user?.role as string) === "project_supervisor" || (user?.role as string) === "supervisor";
const canDefineQc = !isSupervisor && hasPermission(user, Perm.qcDefine);
const canExecuteQc = hasPermission(user, Perm.qcExecute) || isSupervisor;
@@ -423,6 +419,7 @@ export default function TaskDetailPage() {
const preStatus = qcSummaryStatusMeta(readiness?.pre?.status || "none");
const execStatus = qcSummaryStatusMeta(readiness?.exec?.status || "none");
// پیش‌بینی قواعد سرور: شروع نیازمند تأیید «پیش از اجرا» و ۱۰۰٪ نیازمند تأیید همه بازرسی‌ها
const blockedByStart = progress > 0 && !readiness.can_start;
const blockedByComplete = progress >= 100 && !readiness.can_complete;
const progressBlocked = blockedByStart || blockedByComplete;
@@ -454,6 +451,7 @@ export default function TaskDetailPage() {
/>
<div className="space-y-3 px-4 pb-28 pt-4">
{/* کارت اطلاعات اصلی */}
<Card className="p-4">
<div className="flex items-start justify-between gap-2">
<p className="text-base font-bold leading-6 text-slate-800">{task.subject}</p>
@@ -495,7 +493,7 @@ export default function TaskDetailPage() {
</div>
</Card>
{/* کارت پیشرفت و مدیریت یکپارچه مصالح */}
{/* کارت یکپارچه پیشرفت، تخصیص و گزارش مصرف مصالح و نیروی انسانی */}
<SectionCard
title={
<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"
aria-label="درصد پیشرفت"
/>
{/* dir=ltr فقط برای ترتیب چشمی دکمه‌ها: ۰ تا ۱۰۰ از چپ به راست */}
<div dir="ltr" className="mt-2 grid grid-cols-5 gap-1.5">
{quickValues.map((v) => (
<button
@@ -573,7 +572,7 @@ export default function TaskDetailPage() {
</p>
)}
{/* بخش مصالح و تجهیزات مصرفی */}
{/* ۱. بخش مصالح و تجهیزات: تخصیص، مصارف قبلی، مصرف جاری و مانده */}
<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">
<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
type="button"
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>
</button>
</div>
@@ -595,15 +594,15 @@ export default function TaskDetailPage() {
مصالح یا تجهیزاتی برای این وظیفه تعیین نشده است. می‌توانید با دکمه بالا مصالح جدید اضافه کنید.
</div>
) : (
<div className="space-y-3">
<div className="space-y-2.5">
{consumedMaterials.map((item, idx) => {
const alloc = item.allocated_amount || 0;
const prevC = item.previous_consumed || 0;
const currentC = item.amount || 0;
const totC = prevC + currentC;
const rem = Math.max(0, alloc - totC);
const isOver = alloc > 0 && totC > alloc;
const overAmt = isOver ? totC - alloc : 0;
const isOver = alloc > 0 ? totC > alloc : totC > 0;
const overAmt = Math.max(0, totC - alloc);
const pctConsumed = alloc > 0 ? Math.min(100, Math.round((totC / alloc) * 100)) : 100;
const specs =
task?.materials?.find(
@@ -613,50 +612,40 @@ export default function TaskDetailPage() {
return (
<div
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"
}`}
>
{/* ۱. سطر عنوان، واحد و مشخصات کالا */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{alloc === 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 w-full">
<div>
<label className="text-[10px] font-semibold text-slate-500 block mb-0.5">
نام مصالح یا کالا
</label>
<input
value={item.name}
onChange={(e) => handleMaterialFieldChange(idx, "name", e.target.value)}
placeholder="مثلاً: آجر فشاری، سیمان..."
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"
/>
</div>
<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 className="flex items-center gap-1.5 flex-wrap">
<input
value={item.name}
onChange={(e) => handleMaterialFieldChange(idx, "name", e.target.value)}
placeholder="نام مصالح..."
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"
/>
<input
value={item.unit}
onChange={(e) => handleMaterialFieldChange(idx, "unit", e.target.value)}
placeholder="واحد"
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>
) : (
<div className="flex items-baseline gap-1.5">
<span className="font-extrabold text-slate-900 text-sm">{item.name}</span>
{item.unit && <span className="text-xs text-slate-500 font-medium">({item.unit})</span>}
<span className="font-extrabold text-slate-900 text-[13px]">{item.name}</span>
{item.unit && <span className="text-[11px] text-slate-400 font-medium">({item.unit})</span>}
</div>
)}
{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) => (
<span
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}
</span>
@@ -665,21 +654,17 @@ export default function TaskDetailPage() {
)}
</div>
<div className="shrink-0 pt-0.5">
{/* نشانگر وضعیت سقف یا دکمه حذف */}
<div className="shrink-0">
{alloc === 0 ? (
<div className="flex items-center gap-1.5">
<span className="rounded-md px-2 py-0.5 text-[10px] font-bold border bg-amber-50 text-amber-700 border-amber-200">
خارج از برنامه
</span>
<button
type="button"
onClick={() => handleRemoveMaterialRow(idx)}
className="text-slate-400 hover:text-rose-600 p-1 rounded-md hover:bg-rose-50 transition-colors"
title="حذف ردیف"
>
<Trash2 size={15} />
</button>
</div>
<button
type="button"
onClick={() => handleRemoveMaterialRow(idx)}
className="text-slate-400 hover:text-rose-600 p-1 rounded-md hover:bg-rose-50 transition-colors"
title="حذف ردیف"
>
<Trash2 size={14} />
</button>
) : (
<span
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"
}`}
>
{isOver ? "⚠️ مازاد بر سقف" : "مجاز"}
{isOver ? "⚠️ مازاد" : "مجاز"}
</span>
)}
</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">
<span className="text-slate-400 block text-[10px]">سقف مصوب</span>
<span className="font-bold text-slate-800 text-xs mt-0.5 block">
{alloc > 0 ? (
<>
<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 className="text-slate-400 block text-[9.5px]">مقدار کل (سقف)</span>
<span className="font-bold text-slate-800 text-[11.5px] mt-0.5 block">
{alloc > 0 ? `${alloc} ${item.unit || ""}` : "نامحدود"}
</span>
</div>
<div className="px-1">
<span className="text-slate-400 block text-[10px]">مصرف تاکنون</span>
<span className="font-bold text-slate-700 text-xs mt-0.5 block">
<span className="tabular-nums">{prevC}</span>{" "}
<span className="text-slate-500 font-normal">{item.unit}</span>
<span className="text-slate-400 block text-[9.5px]">مصرف تاکنون</span>
<span className="font-bold text-slate-700 text-[11.5px] mt-0.5 block">
{prevC} {item.unit || ""}
</span>
</div>
<div className="px-1">
<span
className={`block text-[10px] font-bold ${
isOver ? "text-rose-600" : alloc > 0 ? "text-emerald-700" : "text-sky-700"
}`}
>
{isOver ? "مازاد سقف" : alloc > 0 ? "مانده مجاز" : "کل مصرفی"}
<span className={`block text-[9.5px] ${isOver ? "text-rose-600 font-bold" : "text-emerald-700 font-bold"}`}>
{isOver ? "مازاد سقف" : "مانده قابل مصرف"}
</span>
<span
className={`font-black text-xs mt-0.5 block ${
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 className={`font-black text-[11.5px] mt-0.5 block ${isOver ? "text-rose-600" : "text-emerald-700"}`}>
{isOver ? `+${overAmt} ${item.unit || ""}` : `${rem} ${item.unit || ""}`}
</span>
</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">
<label className="text-xs font-bold text-amber-950 flex items-center gap-1.5 shrink-0">
{/* ۳. ورودی شاخص و مستقل «مصرف این گزارش *» */}
<div className="space-y-1 rounded-lg bg-amber-50/70 border border-amber-200 p-2">
<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>مصرف این گزارش *:</span>
</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
type="text"
inputMode="decimal"
type="number"
step="any"
min={0}
value={item.amount === 0 ? "" : item.amount}
onFocus={(e) => e.target.select()}
onChange={(e) => handleMaterialAmountChange(idx, e.target.value)}
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 && (
<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}
</span>
)}
</div>
</div>
{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">
<span>مجموع پس از ثبت:</span>
<b className="font-black text-slate-800">
{totC} {item.unit}
<div className="pt-1 border-t border-amber-200/60 flex items-center justify-between text-[10px] text-amber-900">
<span>مانده پس از ثبت این گزارش:</span>
<b className={`font-black ${isOver ? "text-rose-600" : "text-emerald-700"}`}>
{isOver ? `+${overAmt} ${item.unit} (مازاد سقف)` : `${rem} ${item.unit}`}
</b>
</div>
)}
</div>
{/* ۴. نوار پیشرفت مصرف به سقف */}
{alloc > 0 && (
<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 className="font-bold">
{totC > alloc ? `اضافه مصرف (${Math.round((totC / alloc) * 100)}٪)` : `${pctConsumed}٪ از سقف`}
@@ -792,13 +757,14 @@ export default function TaskDetailPage() {
</div>
)}
{/* ۵. هشدار اضافه مصرف جهت ثبت در سوابق و استعلام */}
{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">
<AlertTriangle size={14} className="shrink-0 text-rose-600 mt-0.5" />
<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={12} className="shrink-0 text-rose-600" />
<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>
</div>
</p>
)}
</div>
);
@@ -807,7 +773,7 @@ export default function TaskDetailPage() {
)}
</div>
{/* نیروی انسانی */}
{/* ۲. نیروی انسانی تحویل‌شده و حاضر در کارگاه */}
<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">
<span className="text-xs font-bold text-sky-950 flex items-center gap-1">
@@ -896,6 +862,7 @@ export default function TaskDetailPage() {
)}
</div>
{/* ۳. توضیحات و شرح گزارش روزانه کارگاه */}
<div className="mt-3 pt-2 border-t border-slate-200">
<label className="text-[10px] font-medium text-slate-600 block mb-1">
توضیحات و گزارش روزانه کارگاه (اختیاری)
@@ -936,7 +903,7 @@ export default function TaskDetailPage() {
)}
</SectionCard>
{/* کارت تاریخچه ثبت پیشرفت */}
{/* کارت تاریخچه ثبت پیشرفت و مصرف منابع */}
{task.progress_logs && task.progress_logs.length > 0 && (
<SectionCard title={`تاریخچه گزارش‌های پیشرفت و مصرف منابع (${task.progress_logs.length})`}>
<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">
<Clock size={11} className="text-slate-400" />
<span>{relativeTime(log.created_at)}</span>
{log.logged_by && <span>توسط {log.logged_by}</span>}
{log.logged_by && <span>• توسط {log.logged_by}</span>}
</div>
</div>
{/* نیروی کار حاضر در این گزارش */}
{log.human_resources_used && log.human_resources_used.length > 0 && (
<div>
<span className="text-[10px] font-bold text-slate-600 block mb-1">نیروی کار حاضر:</span>
@@ -976,6 +944,7 @@ export default function TaskDetailPage() {
</div>
)}
{/* مصالح مصرفی در این گزارش */}
{log.materials_used && log.materials_used.length > 0 && (
<div>
<span className="text-[10px] font-bold text-slate-600 block mb-1">مصالح مصرف‌شده:</span>
@@ -1003,6 +972,7 @@ export default function TaskDetailPage() {
</div>
)}
{/* توضیحات در صورت وجود */}
{log.description && (
<div className="rounded-lg bg-white p-2 border border-slate-100 text-[11px] text-slate-700 leading-relaxed">
{log.description}
@@ -1014,7 +984,7 @@ export default function TaskDetailPage() {
</SectionCard>
)}
{/* کارت تاریخ‌های وظیفه */}
{/* کارت تاریخ‌ها — برنامه‌ریزی‌شده و واقعی */}
<SectionCard title="تاریخ‌های وظیفه">
<div className="space-y-3">
<div>
@@ -1031,17 +1001,9 @@ export default function TaskDetailPage() {
<JalaliDatePicker value={dueDate} onChange={setDueDate} placeholder="۱۴۰۵/۰۸/۲۶" />
</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
type="button"
disabled={datesMutation.isPending || isDateRangeInvalid}
disabled={datesMutation.isPending}
onClick={() => datesMutation.mutate()}
className={`${btnSecondary} mt-2.5 w-full`}
>
@@ -1101,6 +1063,8 @@ export default function TaskDetailPage() {
/>
</SectionCard>
{/* کارت ارتباطات */}
<SectionCard
title={
@@ -1251,6 +1215,7 @@ export default function TaskDetailPage() {
onUpdated={() => invalidateTask()}
/>
{/* مودال ویرایش منابع تخصیص‌یافته به وظیفه */}
{showEditResourcesModal && (
<Modal
open={showEditResourcesModal}
@@ -1283,6 +1248,7 @@ export default function TaskDetailPage() {
</Modal>
)}
{/* مودال افزودن رابطه وابستگی جدید */}
{showAddRelationModal && (
<Modal
open={showAddRelationModal}
@@ -1386,4 +1352,4 @@ export default function TaskDetailPage() {
)}
</>
);
}
}
+22 -3
View File
@@ -165,13 +165,15 @@ CREATE TABLE category_attributes (
);
-- ---------- MATERIALS (GLOBAL catalog entities — v2.6/v2.7) ----------
-- Materials are global, category-free entities. name is GLOBALLY
-- unique (uq_material_name). topic is a legacy display column no longer
-- written by the app.
-- Materials are global entities. name is GLOBALLY unique (uq_material_name).
-- topic is a legacy display column; category_id (v2.8) is the dashboard's
-- Category→Material cascade link — backfilled from topic at API startup
-- (init_db.ensure_dashboard_schema; NULL = «بدون دسته»).
CREATE TABLE materials (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
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,
status material_status NOT NULL DEFAULT 'active',
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_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)
-- ============================================================================
BIN
View File
Binary file not shown.