4 Commits
Author SHA1 Message Date
Milad df3ff4ea14 feat(erp-pm): integrate unified access matrix, streamline limited procurement flow, and brand as integrated project management, procurement and warehouse platform 2026-10-02 03:47:33 +03:30
Milad e9fef5a104 feat(pm-panel): pm panel level gating — RequirePmPanel/HomeRoute/menu honor panels.pm (full/limited); pm-only users land directly in the PM panel 2026-09-29 13:45:37 +03:30
Milad 4a3c226518 feat(org): panel-based access system replacing roles (procurement/inventory)
- new tables user_panels (panel: procurement/inventory/pm + level full/limited)
  and user_project_members (M:N membership, separate from pm responsibility)
- UserContext carries panels + member_project_ids; /users/me and the users
  list include them
- admin endpoints: PUT /users/{id}/panels, PUT /users/{id}/panel-projects
- inventory scope: panel inventory 'full' -> manager scope (all projects, NO
  stock change); 'limited' -> supervisor scope (own memberships, stock change
  allowed); legacy roles kept as migration fallback; admin untouched
- procurement writes: _procurement_writer is now panel-aware (admin, legacy
  procurement_manager, or procurement 'full') across all 18 write routers;
  quote CREATE additionally allows 'limited' users restricted to their member
  projects (validated server-side)
- frontend: panel-aware HomeRoute (inventory -> warehouse panel, procurement
  limited -> minimal RequesterHome), wizard project options restricted for
  limited users, change-inventory button/route gated to inventory 'limited',
  organization panel gains the panels & access-levels management section
2026-09-29 13:37:59 +03:30
Milad c7d860f431 feat(inventory): limit supervisor access to own project inventory and remove rectangular change inventory icon 2026-09-27 18:42:05 +03:30
45 changed files with 1421 additions and 365 deletions
+71 -8
View File
@@ -27,7 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.db.engine import AsyncSessionLocal from app.db.engine import AsyncSessionLocal
from app.models.procurement import Project, User from app.models.procurement import Project, User, UserPanel, UserProjectMembership
from app.utils.errors import ( from app.utils.errors import (
ForbiddenError, ForbiddenError,
ServiceUnavailableError, ServiceUnavailableError,
@@ -106,6 +106,10 @@ class UserContext:
project_id: object | None # uuid.UUID | None — default (first) project of a PM project_id: object | None # uuid.UUID | None — default (first) project of a PM
project_ids: list # every project this PM manages (1:N — empty for non-PMs) project_ids: list # every project this PM manages (1:N — empty for non-PMs)
display_name: str display_name: str
# لایه پنل‌ها (جایگزین نقش‌ها): {'procurement': 'full'|'limited', 'inventory': ...}
panels: dict | None = None
# عضویت M:N در پروژه‌ها — محدوده داده سطح limited (جدا از مسئولیت PM)
member_project_ids: list | None = None
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
@@ -141,16 +145,38 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
user.username = username user.username = username
await db.flush() await db.flush()
project_ids: list = [] # لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (جدا از نقش)
if user.role in ("supervisor", "pm"): panels: dict = {}
projects = ( panel_rows = (
await db.execute(
select(UserPanel).where(UserPanel.user_id == user.id).order_by(UserPanel.created_at)
)
).scalars().all()
for row in panel_rows:
panels[row.panel] = row.level
member_project_ids: list = [
row.project_id
for row in (
await db.execute(
select(UserProjectMembership)
.where(UserProjectMembership.user_id == user.id)
.order_by(UserProjectMembership.created_at)
)
).scalars().all()
]
pm_projects = (
await db.execute( await db.execute(
select(Project) select(Project)
.where(Project.pm_user_id == user.id) .where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
project_ids = [p.id for p in projects] pm_project_ids_list = [p.id for p in pm_projects]
# ترکیب پروژه‌های تحت مدیریت مستقیم و پروژه‌های عضویت با حفظ اولویت و بدون تکرار
project_ids = list(dict.fromkeys(pm_project_ids_list + member_project_ids))
project_id = project_ids[0] if project_ids else None project_id = project_ids[0] if project_ids else None
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
@@ -160,19 +186,28 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
project_id=project_id, project_id=project_id,
project_ids=project_ids, project_ids=project_ids,
display_name=display_name, display_name=display_name,
panels=panels,
member_project_ids=member_project_ids,
) )
async def pm_project_ids(db: AsyncSession, user: User) -> list: async def pm_project_ids(db: AsyncSession, user: User) -> list:
"""Every project id managed by the given PM/supervisor user (oldest first).""" """Every project id managed or assigned to the given PM/supervisor user (oldest first)."""
rows = ( pm_rows = (
await db.execute( await db.execute(
select(Project.id) select(Project.id)
.where(Project.pm_user_id == user.id) .where(Project.pm_user_id == user.id)
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
return list(rows) member_rows = (
await db.execute(
select(UserProjectMembership.project_id)
.where(UserProjectMembership.user_id == user.id)
.order_by(UserProjectMembership.created_at)
)
).scalars().all()
return list(dict.fromkeys(list(pm_rows) + list(member_rows)))
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None: async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
@@ -222,3 +257,31 @@ def require_role(*roles: str):
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست") raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx return ctx
return _dep return _dep
def require_procurement_writer():
"""نوشتن در پنل تدارکات — ادمین، پنل تدارکات 'full'، یا نقش میراث مدیر تدارکات
(دوره مهاجرت از مدل نقش به مدل پنل)."""
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
if ctx.role in ("admin", "procurement_manager"):
return ctx
if (ctx.panels or {}).get("procurement") == "full":
return ctx
raise ForbiddenError("دسترسی نوشتن در پنل تدارکات را ندارید")
return _dep
def allow_quote_create():
"""افزودن استعلام — علاوه بر «نوشتن»، پنل تدارکات 'limited' هم مجاز است
(محدود به پروژه‌های عضویت؛ اعتبارسنجی عضویت در خود endpoint انجام می‌شود)."""
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
if ctx.role in ("admin", "procurement_manager"):
return ctx
if (ctx.panels or {}).get("procurement") in ("full", "limited"):
return ctx
raise ForbiddenError("دسترسی افزودن استعلام ندارید")
return _dep
+41
View File
@@ -66,6 +66,47 @@ class User(Base):
created_at: Mapped[datetime] = _timestamptz() created_at: Mapped[datetime] = _timestamptz()
class UserPanel(Base):
"""user_panels — تخصیص پنل + سطح دسترسی به کاربر (جایگزین مدل نقش).
panel: 'procurement' | 'inventory' | 'pm'
level: 'full' — تدارکات: تغییر کامل در کل پنل؛ انبار: همه پروژه‌ها
(به‌جز تغییر موجودی)
'limited' — تدارکات: فقط افزودن استعلام برای پروژه‌های عضویت؛
انبار: فقط پروژه‌های عضویت + امکان تغییر موجودی
"""
__tablename__ = "user_panels"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
panel: Mapped[str] = mapped_column(Text, nullable=False)
level: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = _timestamptz()
__table_args__ = (UniqueConstraint("user_id", "panel", name="uq_user_panel"),)
class UserProjectMembership(Base):
"""user_project_members — عضویت M:N کاربر در پروژه‌ها، جدا از مسئولیت
پروژه (projects.pm_user_id). محدوده‌ی داده‌ی سطح limited را تعیین می‌کند."""
__tablename__ = "user_project_members"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = _timestamptz()
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_user_project_member"),)
class Supplier(Base): class Supplier(Base):
__tablename__ = "suppliers" __tablename__ = "suppliers"
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
from app.models.procurement import LineItemSpec from app.models.procurement import LineItemSpec
@@ -14,7 +14,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"]) router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict: def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
+3 -2
View File
@@ -78,7 +78,8 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
claims = await _verify_token(access_token) claims = await _verify_token(access_token)
ctx = await context_from_claims(claims, db) ctx = await context_from_claims(claims, db)
projects = await _user_projects(db, ctx.user) projects = await _user_projects(db, ctx.user)
if ctx.role == "supervisor" and not projects: has_projects = bool(projects) or bool(ctx.project_ids) or bool(ctx.member_project_ids)
if ctx.role == "supervisor" and not has_projects:
raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید") raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
await analytics.track( await analytics.track(
"login_success", "login_success",
@@ -94,6 +95,6 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
"access_token": access_token, "access_token": access_token,
"token_type": "bearer", "token_type": "bearer",
"expires_in": body.get("expires_in"), "expires_in": body.get("expires_in"),
"user": user_to_dict(ctx.user, projects), "user": await user_to_dict(ctx.user, projects, db),
} }
} }
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial, Material from app.models.catalog import Brand, BrandMaterial, Material
from app.models.procurement import LineItem from app.models.procurement import LineItem
@@ -13,7 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"]) router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict: def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial from app.models.catalog import Brand, BrandMaterial
from app.schemas.catalog import BrandCreate, BrandUpdate from app.schemas.catalog import BrandCreate, BrandUpdate
@@ -12,7 +12,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/brands", tags=["brands"]) router = APIRouter(prefix="/brands", tags=["brands"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def brand_to_dict(brand: Brand) -> dict: def brand_to_dict(brand: Brand) -> dict:
+72 -16
View File
@@ -58,6 +58,7 @@ class Scope:
role: str role: str
project_id: uuid.UUID | None project_id: uuid.UUID | None
actor: str | None actor: str | None
project_ids: list[uuid.UUID] | None = None
async def inventory_scope( async def inventory_scope(
@@ -75,11 +76,29 @@ async def inventory_scope(
if request.headers.get("authorization"): if request.headers.get("authorization"):
user: UserContext = await get_optional_user(request, db) user: UserContext = await get_optional_user(request, db)
if user is not None: if user is not None:
if user.role is None: panels = user.panels or {}
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") if user.role is None and not panels:
# مدیر تدارکات و مدیر پروژه: دسترسی کامل به پنل انبار، اما فقط‌خواندنی در تغییر موجودی raise ForbiddenError("دسترسی شما هنوز در سیستم تعیین نشده است")
# ادمین: دست‌نخورده — همه‌چیز از جمله تغییر موجودی
if user.role == "admin":
return Scope(role="admin", project_id=project_id, actor=user.display_name)
# لایه پنل‌ها (جایگزین نقش‌ها): پنل انبار اولویت دارد
if "inventory" in panels:
if panels["inventory"] == "full":
# کل سیستم و تمام پروژه‌ها — به‌جز تغییر موجودی
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.member_project_ids or []))
# limited: فقط پروژه‌های عضویت + امکان تغییر موجودی
allowed = list(user.member_project_ids or [])
if not allowed:
raise ForbiddenError("پروژه‌ای به شما تخصیص داده نشده است")
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
# مدیر تدارکات و مدیر پروژه (میراث): دسترسی کامل به پنل انبار، اما فقط‌خواندنی در تغییر موجودی
if user.role in ("procurement_manager", "pm"): if user.role in ("procurement_manager", "pm"):
return Scope(role="manager", project_id=project_id, actor=user.display_name) return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.project_ids or []))
if user.role == "supervisor": if user.role == "supervisor":
allowed = list(user.project_ids or []) allowed = list(user.project_ids or [])
if not allowed: if not allowed:
@@ -87,8 +106,8 @@ async def inventory_scope(
if project_id is not None: if project_id is not None:
if project_id not in allowed: if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return Scope(role="supervisor", project_id=project_id, actor=user.display_name) return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name) return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
if user.role == "admin": if user.role == "admin":
return Scope(role="admin", project_id=project_id, actor=user.display_name) return Scope(role="admin", project_id=project_id, actor=user.display_name)
raise ValidationError("نقش نامعتبر است", field="role") raise ValidationError("نقش نامعتبر است", field="role")
@@ -214,7 +233,15 @@ async def list_warehouses(
): ):
stmt = select(Warehouse) stmt = select(Warehouse)
if scope.role in (ROLE_PM, "supervisor"): if scope.role in (ROLE_PM, "supervisor"):
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM / supervisor scope allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(Warehouse.project_id == project_id)
elif allowed:
stmt = stmt.where(Warehouse.project_id.in_(allowed))
else:
stmt = stmt.where(Warehouse.project_id == scope.project_id)
elif project_id is not None: elif project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id) stmt = stmt.where(Warehouse.project_id == project_id)
stmt = stmt.order_by(Warehouse.name) stmt = stmt.order_by(Warehouse.name)
@@ -246,9 +273,11 @@ async def create_warehouse(
scope: Scope = Depends(inventory_scope), scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
# PM: فقط پروژه خودش — Admin: ساختار را مدیریت می‌کند (هر پروژه) # PM / سرپرست: فقط پروژه خودش — Admin: ساختار را مدیریت می‌کند (هر پروژه)
if scope.role == ROLE_PM and payload.project_id != scope.project_id: if scope.role in (ROLE_PM, "supervisor"):
raise ValidationError("PM فقط می‌تواند برای پروژه خودش انبار بسازد", field="project_id") allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if payload.project_id not in allowed:
raise ValidationError("شما فقط می‌توانید برای پروژه تخصیص‌یافته به خود انبار بسازید", field="project_id")
if await db.get(Project, payload.project_id) is None: if await db.get(Project, payload.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="project_id") raise NotFoundError("پروژه یافت نشد", field="project_id")
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description) wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
@@ -265,8 +294,9 @@ async def update_warehouse(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
wh = await get_warehouse_or_404(db, warehouse_id) wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM: if scope.role in (ROLE_PM, "supervisor"):
if scope.project_id != wh.project_id: allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if wh.project_id not in allowed:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست") raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if payload.name is not None: if payload.name is not None:
wh.name = payload.name wh.name = payload.name
@@ -290,7 +320,9 @@ async def delete_warehouse(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
wh = await get_warehouse_or_404(db, warehouse_id) wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM and scope.project_id != wh.project_id: if scope.role in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if wh.project_id not in allowed:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست") raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
# inventory rows cascade via DDL; transactions are RESTRICTed through # inventory rows cascade via DDL; transactions are RESTRICTed through
# inventory cascade — block deletion if any stock history exists. # inventory cascade — block deletion if any stock history exists.
@@ -322,6 +354,18 @@ async def list_inventory(
): ):
stmt = select(InventoryItem) stmt = select(InventoryItem)
if scope.role in (ROLE_PM, "supervisor"): if scope.role in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == project_id
)
elif allowed:
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id.in_(allowed)
)
else:
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == scope.project_id Warehouse.project_id == scope.project_id
) )
@@ -387,6 +431,14 @@ async def list_transactions(
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id) ).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
if scope.role in (ROLE_PM, "supervisor"): if scope.role in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(Warehouse.project_id == project_id)
elif allowed:
stmt = stmt.where(Warehouse.project_id.in_(allowed))
else:
stmt = stmt.where(Warehouse.project_id == scope.project_id) stmt = stmt.where(Warehouse.project_id == scope.project_id)
elif project_id is not None: elif project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id) stmt = stmt.where(Warehouse.project_id == project_id)
@@ -452,7 +504,9 @@ async def inventory_history(
if item is None: if item is None:
raise NotFoundError("موجودی یافت نشد") raise NotFoundError("موجودی یافت نشد")
wh = await get_warehouse_or_404(db, item.warehouse_id) wh = await get_warehouse_or_404(db, item.warehouse_id)
if scope.role in (ROLE_PM, "supervisor") and wh.project_id != scope.project_id: if scope.role in (ROLE_PM, "supervisor"):
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if wh.project_id not in allowed:
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست") raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
txs = (await db.execute( txs = (await db.execute(
select(InventoryTransaction) select(InventoryTransaction)
@@ -478,11 +532,13 @@ async def create_transaction(
# commits on success / rolls back on failure. # commits on success / rolls back on failure.
with nullcontext(): with nullcontext():
wh = await get_warehouse_or_404(db, payload.warehouse_id) wh = await get_warehouse_or_404(db, payload.warehouse_id)
if scope.project_id is not None and wh.project_id != scope.project_id: allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
if scope.role in (ROLE_PM, "supervisor"):
if wh.project_id not in allowed:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست") raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if payload.dest_warehouse_id is not None: if payload.dest_warehouse_id is not None:
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id) dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
if scope.project_id is not None and dest.project_id != scope.project_id: if dest.project_id not in allowed:
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست") raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
txs = await apply_transaction(db, payload, performed_by=scope.actor) txs = await apply_transaction(db, payload, performed_by=scope.actor)
await audit_record( await audit_record(
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ItemDecision, ItemReceipt, LineItem from app.models.procurement import ItemDecision, ItemReceipt, LineItem
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
@@ -15,7 +15,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt", prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
tags=["item-receipts"], tags=["item-receipts"],
) )
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
+2 -2
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
@@ -24,7 +24,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets", prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
tags=["item-termsheets"], tags=["item-termsheets"],
) )
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Unit from app.models.catalog import AttributeDefinition, Unit
from app.models.procurement import LineItem, LineItemSpec, Quote from app.models.procurement import LineItem, LineItemSpec, Quote
@@ -13,7 +13,7 @@ from app.services.eav_service import flatten_eav_values
from app.utils.errors import ConflictError, NotFoundError from app.utils.errors import ConflictError, NotFoundError
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"]) router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material, Unit from app.models.catalog import Material, Unit
from app.models.procurement import ItemDecision, LineItem, Quote from app.models.procurement import ItemDecision, LineItem, Quote
@@ -15,7 +15,7 @@ from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"]) router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import MaterialCategory from app.models.catalog import MaterialCategory
from app.models.procurement import Supplier from app.models.procurement import Supplier
@@ -13,7 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/material-categories", tags=["material-categories"]) router = APIRouter(prefix="/material-categories", tags=["material-categories"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def category_to_dict(category: MaterialCategory) -> dict: def category_to_dict(category: MaterialCategory) -> dict:
+2 -2
View File
@@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import exists, select from sqlalchemy import exists, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import ( from app.models.catalog import (
AttributeDefinition, AttributeDefinition,
@@ -39,7 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/materials", tags=["materials"]) router = APIRouter(prefix="/materials", tags=["materials"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def _coerce_filter_value(data_type: str, raw: str): def _coerce_filter_value(data_type: str, raw: str):
+33 -12
View File
@@ -71,16 +71,17 @@ class ArrivalScope:
user_id: uuid.UUID | None user_id: uuid.UUID | None
project_id: uuid.UUID | None # PM's active project; admin: optional filter project_id: uuid.UUID | None # PM's active project; admin: optional filter
display_name: str display_name: str
project_ids: list[uuid.UUID] | None = None
async def arrivals_scope( async def arrivals_scope(
request: Request, request: Request,
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: الزامی برای داده‌های پروژه)"), project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM / سرپرست: داده‌های پروژه)"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> ArrivalScope: ) -> ArrivalScope:
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback. """Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
PM → scoped to the requested project, which must be one they manage PM / supervisor → scoped to the requested project, which must be one they manage
(projects.pm_user_id, 1:N); without the param, their first project. (projects.pm_user_id, 1:N); without the param, their first project.
Admin / procurement manager → all projects (param = optional filter). Admin / procurement manager → all projects (param = optional filter).
""" """
@@ -89,19 +90,19 @@ async def arrivals_scope(
if ctx is not None: if ctx is not None:
if ctx.role is None: if ctx.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
if ctx.role == "pm": if ctx.role in ("pm", "supervisor"):
allowed = list(ctx.project_ids or []) allowed = list(ctx.project_ids or [])
if project_id is not None: if project_id is not None:
if project_id not in allowed: if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name) return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name, project_ids=allowed)
if not allowed: if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name) return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name, project_ids=allowed)
if ctx.role in ("admin", "procurement_manager"): if ctx.role in ("admin", "procurement_manager"):
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name) return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است") raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است")
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to # Dev fallback: X-User-Role: pm | supervisor | admin (procurement_manager maps to
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor). # admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
# Disabled by default: an ungated header identity in production would # Disabled by default: an ungated header identity in production would
# let anyone confirm receipts as PM. # let anyone confirm receipts as PM.
@@ -110,8 +111,10 @@ async def arrivals_scope(
role = (request.headers.get("x-user-role") or "").strip().lower() role = (request.headers.get("x-user-role") or "").strip().lower()
if role == "procurement_manager": if role == "procurement_manager":
role = "admin" role = "admin"
elif role == "supervisor":
role = "pm"
if role not in ("pm", "admin"): if role not in ("pm", "admin"):
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید") raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است — هدر X-User-Role را بفرستید")
# The explicit query param wins; otherwise the dev header carries it. # The explicit query param wins; otherwise the dev header carries it.
if project_id is None and role == "pm": if project_id is None and role == "pm":
raw_project = request.headers.get("x-project-id") raw_project = request.headers.get("x-project-id")
@@ -189,10 +192,17 @@ async def pm_on_the_way(
.outerjoin(Project, Project.id == ItemDecision.project_id) .outerjoin(Project, Project.id == ItemDecision.project_id)
.where(ItemDecision.status == "chosen") .where(ItemDecision.status == "chosen")
) )
if ctx.role == "pm" and ctx.project_id is None: if ctx.role == "pm":
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
if ctx.project_id is not None: if ctx.project_id is not None:
# PM: active project; admin/manager: optional project filter if ctx.project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
else:
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
elif ctx.project_id is not None:
stmt = stmt.where(ItemDecision.project_id == ctx.project_id) stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all() decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
@@ -243,6 +253,10 @@ async def pm_confirm_receipt(
decision = await db.get(ItemDecision, payload.item_decision_id) decision = await db.get(ItemDecision, payload.item_decision_id)
if decision is None or decision.status != "chosen": if decision is None or decision.status != "chosen":
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id") raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
if ctx.role == "pm":
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if decision.project_id not in allowed:
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
if ctx.project_id is not None and decision.project_id != ctx.project_id: if ctx.project_id is not None and decision.project_id != ctx.project_id:
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست") raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
@@ -451,10 +465,17 @@ async def pm_receipts(
.join(Quote, Quote.id == LineItem.quote_id) .join(Quote, Quote.id == LineItem.quote_id)
.join(Project, Project.id == ItemDecision.project_id) .join(Project, Project.id == ItemDecision.project_id)
) )
if ctx.role == "pm" and ctx.project_id is None: if ctx.role == "pm":
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
if not allowed:
raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)")
if ctx.project_id is not None: if ctx.project_id is not None:
# PM: active project; admin/manager: optional project filter if ctx.project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
else:
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
elif ctx.project_id is not None:
stmt = stmt.where(ItemDecision.project_id == ctx.project_id) stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all() rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
receipts = [r.ItemReceipt for r in rows] receipts = [r.ItemReceipt for r in rows]
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user, require_role from app.auth import UserContext, get_optional_user, require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Project, Quote, User from app.models.procurement import Project, Quote, User
from app.schemas.procurement import ProjectCreate, ProjectUpdate from app.schemas.procurement import ProjectCreate, ProjectUpdate
@@ -13,7 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_date, to_iso from app.utils.serialization import to_date, to_iso
router = APIRouter(prefix="/projects", tags=["projects"]) router = APIRouter(prefix="/projects", tags=["projects"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None: async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Quote, QuoteEvent from app.models.procurement import Quote, QuoteEvent
from app.schemas.procurement import NoteEventIn from app.schemas.procurement import NoteEventIn
@@ -12,7 +12,7 @@ from app.utils.errors import NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"]) router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
@router.get("") @router.get("")
+16 -4
View File
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import exists, func, or_, select from sqlalchemy import exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import UserContext, allow_quote_create, require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material from app.models.catalog import Material
from app.models.procurement import ( from app.models.procurement import (
@@ -37,14 +37,14 @@ from app.services.expand_service import (
serialize_quote_with_children, serialize_quote_with_children,
serialize_quotes, serialize_quotes,
) )
from app.utils.errors import NotFoundError from app.utils.errors import ForbiddenError, NotFoundError, ValidationError
from app.utils.pagination import sort_query from app.utils.pagination import sort_query
from app.utils.security import get_actor from app.utils.security import get_actor
router = APIRouter(prefix="/quotes", tags=["quotes"]) router = APIRouter(prefix="/quotes", tags=["quotes"])
# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد) # عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
# --- list filter helpers --------------------------------------------------- # --- list filter helpers ---------------------------------------------------
@@ -164,8 +164,20 @@ async def create_quote(
payload: QuoteCreate, payload: QuoteCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor), actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer), ctx: UserContext = Depends(allow_quote_create()),
_auth=None,
): ):
# سطح limited: استعلام فقط برای پروژه‌های عضویت خودش
if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin":
allowed = set(ctx.project_ids or []) | set(ctx.member_project_ids or [])
if ctx.project_id:
allowed.add(ctx.project_id)
allowed_str = {str(pid) for pid in allowed if pid}
wanted = {str(pid) for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid}
if not wanted:
raise ValidationError("برای افزودن استعلام، پروژه الزامی است")
if not wanted <= allowed_str:
raise ForbiddenError("استعلام فقط برای پروژه‌های تخصیص‌یافته به شما مجاز است")
quote = await quote_service.create_quote(db, payload, actor) quote = await quote_service.create_quote(db, payload, actor)
return {"data": await serialize_quote_with_children(db, quote)} return {"data": await serialize_quote_with_children(db, quote)}
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Termsheet, TermsheetScheduleStep from app.models.procurement import Termsheet, TermsheetScheduleStep
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
@@ -16,7 +16,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps", prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
tags=["schedule-steps"], tags=["schedule-steps"],
) )
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet: async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import StandardConversion from app.models.catalog import StandardConversion
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
@@ -12,7 +12,7 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"]) router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def sc_to_dict(sc: StandardConversion) -> dict: def sc_to_dict(sc: StandardConversion) -> dict:
+2 -2
View File
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material, MaterialCategory from app.models.catalog import Material, MaterialCategory
from app.models.procurement import Supplier, SupplierMaterial from app.models.procurement import Supplier, SupplierMaterial
@@ -19,7 +19,7 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(tags=["supplier-materials"]) router = APIRouter(tags=["supplier-materials"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def link_to_dict(link: SupplierMaterial, material: Material | None = None, def link_to_dict(link: SupplierMaterial, material: Material | None = None,
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import MaterialCategory from app.models.catalog import MaterialCategory
from app.models.procurement import Quote, Supplier from app.models.procurement import Quote, Supplier
@@ -13,7 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/suppliers", tags=["suppliers"]) router = APIRouter(prefix="/suppliers", tags=["suppliers"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict: def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
@@ -27,7 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"]) router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
from app.models.procurement import LineItem, LineItemSpec from app.models.procurement import LineItem, LineItemSpec
@@ -12,7 +12,7 @@ from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/units", tags=["units"]) router = APIRouter(prefix="/units", tags=["units"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def unit_full_to_dict(unit: Unit) -> dict: def unit_full_to_dict(unit: Unit) -> dict:
+141 -19
View File
@@ -17,15 +17,21 @@ import uuid
import httpx import httpx
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select, update, text from sqlalchemy import delete, select, update, text, or_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, require_role from app.auth import UserContext, get_current_user, require_role
from app.utils.audit import record as audit_record from app.utils.audit import record as audit_record
from app.config import settings from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Project, User from app.models.procurement import Project, User, UserPanel, UserProjectMembership
from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate from app.schemas.procurement import (
UserCreate,
UserPanelsUpdate,
UserPanelProjectsUpdate,
UserProjectUpdate,
UserRoleUpdate,
)
from app.utils.errors import ( from app.utils.errors import (
ConflictError, ConflictError,
ForbiddenError, ForbiddenError,
@@ -46,12 +52,39 @@ USER_ROLE_LABELS = {
} }
def user_to_dict(user: User, projects: list[Project] | None = None) -> dict: PANEL_LEVELS = ("full", "limited")
PANELS = ("procurement", "inventory", "pm")
async def _panels_of(db: AsyncSession, user_id) -> dict:
"""panel -> level map for the user (لایه پنل‌ها — جایگزین نقش‌ها)."""
rows = (
await db.execute(
select(UserPanel).where(UserPanel.user_id == user_id).order_by(UserPanel.created_at)
)
).scalars().all()
return {row.panel: row.level for row in rows}
async def _member_project_ids_of(db: AsyncSession, user_id) -> list[str]:
"""عضویت M:N کاربر در پروژه‌ها (جدا از مسئولیت) — رشته."""
rows = (
await db.execute(
select(UserProjectMembership.project_id)
.where(UserProjectMembership.user_id == user_id)
.order_by(UserProjectMembership.created_at)
)
).scalars().all()
return [str(pid) for pid in rows]
async def user_to_dict(user: User, projects: list[Project] | None = None, db: AsyncSession | None = None) -> dict:
"""Serialize a user. `projects` = every project the PM manages (1:N); """Serialize a user. `projects` = every project the PM manages (1:N);
project_id/project_name stay as the first (oldest) one for compatibility.""" project_id/project_name stay as the first (oldest) one for compatibility.
با db، پنل‌های تخصیصی و عضویت پروژه‌ای هم برمی‌گردند."""
projects = projects or [] projects = projects or []
first = projects[0] if projects else None first = projects[0] if projects else None
return { out = {
"id": str(user.id), "id": str(user.id),
"casdoor_id": user.casdoor_id, "casdoor_id": user.casdoor_id,
"username": user.username, "username": user.username,
@@ -65,6 +98,12 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
"projects": [{"id": str(p.id), "name": p.name} for p in projects], "projects": [{"id": str(p.id), "name": p.name} for p in projects],
"created_at": to_iso(user.created_at), "created_at": to_iso(user.created_at),
} }
if db is not None:
out["panels"] = await _panels_of(db, user.id)
member_pids = await _member_project_ids_of(db, user.id)
all_pids = list(dict.fromkeys(member_pids + [str(p.id) for p in projects]))
out["member_project_ids"] = all_pids
return out
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext: async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
@@ -75,13 +114,23 @@ async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserCont
async def _user_projects(db: AsyncSession, user: User) -> list[Project]: async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
"""Every project assigned to the user (supervisor or PM) (oldest first).""" """Every project assigned to the user (supervisor or PM) (oldest first).
Includes both direct PM assignments (pm_user_id) and member projects (UserProjectMembership).
"""
if user.role not in ("supervisor", "pm"): if user.role not in ("supervisor", "pm"):
return [] return []
member_subq = select(UserProjectMembership.project_id).where(
UserProjectMembership.user_id == user.id
)
rows = ( rows = (
await db.execute( await db.execute(
select(Project) select(Project)
.where(Project.pm_user_id == user.id) .where(
or_(
Project.pm_user_id == user.id,
Project.id.in_(member_subq),
)
)
.order_by(Project.created_at, Project.id) .order_by(Project.created_at, Project.id)
) )
).scalars().all() ).scalars().all()
@@ -130,7 +179,7 @@ async def _casdoor_users() -> list[dict]:
@router.get("/me") @router.get("/me")
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
projects = await _user_projects(db, ctx.user) projects = await _user_projects(db, ctx.user)
return {"data": user_to_dict(ctx.user, projects)} return {"data": await user_to_dict(ctx.user, projects, db)}
@router.get("/me/projects") @router.get("/me/projects")
@@ -141,6 +190,18 @@ async def my_projects(ctx: UserContext = Depends(get_current_user), db: AsyncSes
active project against this list and falls back gracefully when a active project against this list and falls back gracefully when a
project is revoked.""" project is revoked."""
projects = await _user_projects(db, ctx.user) projects = await _user_projects(db, ctx.user)
# عضویت‌های M:N (لایه پنل‌ها) هم به فهرست اضافه می‌شود
member_ids = set(ctx.member_project_ids or [])
have = {p.id for p in projects}
if member_ids:
extra = (
await db.execute(
select(Project)
.where(Project.id.in_(member_ids))
.order_by(Project.created_at, Project.id)
)
).scalars().all()
projects = list(projects) + [p for p in extra if p.id not in have]
return {"data": [{"id": str(p.id), "name": p.name} for p in projects]} return {"data": [{"id": str(p.id), "name": p.name} for p in projects]}
@@ -164,7 +225,7 @@ async def list_users(
for p in projs: for p in projs:
by_user.setdefault(p.pm_user_id, []).append(p) by_user.setdefault(p.pm_user_id, []).append(p)
return { return {
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users], "data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
"meta": {"total": len(users)}, "meta": {"total": len(users)},
} }
@@ -190,19 +251,19 @@ async def create_user(
db.add(user) db.add(user)
await db.flush() await db.flush()
await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role}) await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role})
return {"data": user_to_dict(user)} return {"data": await user_to_dict(user)}
@router.patch("/{user_id}") @router.patch("/{user_id}")
async def update_user_role( async def update_user_role(
user_id, user_id,
payload: UserRoleUpdate, payload: UserRoleUpdate,
ctx: UserContext = Depends(require_role("admin")), ctx: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""Set/clear a role — ADMIN ONLY (RBAC): procurement managers must not """Set/clear a role — organization management (admin, procurement manager, PM)."""
assign or elevate roles. Revoking (or switching away from «pm») also if payload.role == "admin" and ctx.role != "admin":
unassigns the user from their project.""" raise ForbiddenError("فقط ادمین می‌تواند نقش ادمین تعیین کند")
user = await db.get(User, user_id) user = await db.get(User, user_id)
if user is None: if user is None:
raise NotFoundError("کاربر یافت نشد") raise NotFoundError("کاربر یافت نشد")
@@ -213,7 +274,7 @@ async def update_user_role(
await db.flush() await db.flush()
await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role}) await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role})
projects = await _user_projects(db, user) projects = await _user_projects(db, user)
return {"data": user_to_dict(user, projects)} return {"data": await user_to_dict(user, projects)}
@router.post("/sync") @router.post("/sync")
@@ -273,7 +334,7 @@ async def sync_casdoor_users(
for p in projs: for p in projs:
by_user.setdefault(p.pm_user_id, []).append(p) by_user.setdefault(p.pm_user_id, []).append(p)
return { return {
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users], "data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
"meta": {"total": len(users), "created": created}, "meta": {"total": len(users), "created": created},
} }
@@ -307,7 +368,7 @@ async def assign_user_project(
{"uname": user.username} {"uname": user.username}
) )
await db.flush() await db.flush()
return {"data": user_to_dict(user)} return {"data": await user_to_dict(user)}
project = await db.get(Project, payload.project_id) project = await db.get(Project, payload.project_id)
if project is None: if project is None:
@@ -328,4 +389,65 @@ async def assign_user_project(
{"pid": project.id, "uname": user.username} {"pid": project.id, "uname": user.username}
) )
await db.flush() await db.flush()
return {"data": user_to_dict(user, await _user_projects(db, user))} return {"data": await user_to_dict(user, await _user_projects(db, user))}
# ---------------------------------------------------------------------------
# پنل‌ها و سطوح دسترسی — جایگزین مدل نقش برای کاربران عادی
# ---------------------------------------------------------------------------
@router.put("/{user_id}/panels")
@router.patch("/{user_id}/panels")
async def set_user_panels(
user_id,
payload: UserPanelsUpdate,
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""تخصیص پنل + سطح دسترسی به کاربر — مدیران سازمان (ادمین، مدیر تدارکات، مدیر پروژه)."""
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
for panel, level in payload.panels.items():
if panel not in PANELS:
raise ValidationError(f"پنل نامعتبر: {panel}", field="panels")
if level not in PANEL_LEVELS:
raise ValidationError(f"سطح دسترسی نامعتبر: {level}", field="panels")
await db.execute(delete(UserPanel).where(UserPanel.user_id == user.id))
for panel, level in payload.panels.items():
db.add(UserPanel(user_id=user.id, panel=panel, level=level))
await db.flush()
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
@router.put("/{user_id}/panel-projects")
@router.patch("/{user_id}/panel-projects")
async def set_user_panel_projects(
user_id,
payload: UserPanelProjectsUpdate,
_: UserContext = Depends(_org_manager),
db: AsyncSession = Depends(get_db),
):
"""عضویت پروژه‌ای کاربر (M:N) — محدوده داده‌ی سطح limited."""
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
wanted = list(dict.fromkeys(payload.project_ids))
if wanted:
found = (
await db.execute(select(Project).where(Project.id.in_(wanted)))
).scalars().all()
found_ids = {p.id for p in found}
unknown = [str(pid) for pid in wanted if pid not in found_ids]
if unknown:
raise ValidationError(
"پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids"
)
await db.execute(
delete(UserProjectMembership).where(UserProjectMembership.user_id == user.id)
)
for pid in wanted:
db.add(UserProjectMembership(user_id=user.id, project_id=pid))
await db.flush()
return {"data": await user_to_dict(user, await _user_projects(db, user), db)}
+13
View File
@@ -346,3 +346,16 @@ class ReceiptUpdate(APIModel):
received_qty: Decimal | None = Field(default=None, gt=0) received_qty: Decimal | None = Field(default=None, gt=0)
on_time: bool | None = None on_time: bool | None = None
quality_note: str | None = None quality_note: str | None = None
class UserPanelsUpdate(APIModel):
"""Replace the user's panel assignments. هر پنل: 'full' | 'limited' —
پنل غایب یعنی تخصیص نداشتن و کلید آن حذف می‌شود."""
panels: dict[str, str] = Field(default_factory=dict)
class UserPanelProjectsUpdate(APIModel):
"""Replace the user's project memberships (M:N — جدا از مسئولیت پروژه)."""
project_ids: list[UUID] = Field(default_factory=list)
+1 -1
View File
@@ -13,7 +13,7 @@
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap" href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title> <title>سامانه جامع مدیریت پروژه، تدارکات و انبار</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "دفترچه تأمین — سامانه تدارکات و انبار", "name": "سامانه جامع مدیریت پروژه، تدارکات و انبار",
"short_name": "دفترچه تأمین", "short_name": "مدیریت پروژه و تأمین",
"description": "سامانه تدارکات و مدیریت انبار پروژه‌های ساختمانی", "description": "سامانه جامع مدیریت عملیات پروژه، تدارکات و انبارداری",
"dir": "rtl", "dir": "rtl",
"lang": "fa", "lang": "fa",
"start_url": "/", "start_url": "/",
+183 -19
View File
@@ -43,6 +43,9 @@ const OrganizationPage = lazy(() =>
const AdminPanelPage = lazy(() => const AdminPanelPage = lazy(() =>
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })), import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
); );
const RequesterHome = lazy(() =>
import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })),
);
function Loading() { function Loading() {
return ( return (
@@ -59,7 +62,10 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
if (!user) return <Navigate to="/login" replace />; if (!user) return <Navigate to="/login" replace />;
if (user.role === "supervisor") { if (user.role === "supervisor") {
// اگر سرپرست پروژه‌ای نداشته باشد، دسترسی مسدود می‌شود // اگر سرپرست پروژه‌ای نداشته باشد، دسترسی مسدود می‌شود
if (!user.projects || user.projects.length === 0) { const hasProjects =
(Boolean(user.projects && user.projects.length > 0)) ||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
if (!hasProjects) {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-bg px-4"> <div className="flex min-h-screen items-center justify-center bg-bg px-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm"> <div className="w-full max-w-md rounded-2xl border border-line bg-panel p-8 text-center shadow-sm">
@@ -82,30 +88,139 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
</div> </div>
); );
} }
// سرپرست پروژه فقط به انبار و PM دسترسی دارد
if (!pathname.startsWith("/inventory") && pathname !== "/panel/pm") {
return <Navigate to="/panel/pm" replace />;
} }
// بررسی دسترسی به مسیرها بر اساس ترکیب نقش سازمانی و پنل‌های تخصیصی:
const panels = user.panels ?? {};
const isAdmin = user.role === "admin";
const hasPanelsConfig = Boolean(user.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user.role === "pm" || user.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user.role === "pm" || user.role === "supervisor");
const isProcurementRoute =
pathname === "/" ||
pathname.startsWith("/wizard") ||
pathname.startsWith("/suppliers") ||
pathname.startsWith("/projects") ||
pathname.startsWith("/units") ||
pathname.startsWith("/requester");
if (isProcurementRoute && !hasProcurement) {
if (hasInventory) return <Navigate to="/inventory" replace />;
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
} }
if (pathname.startsWith("/inventory") && !hasInventory) {
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
return <>{children}</>; return <>{children}</>;
} }
// صفحه‌ی اصلی بر اساس نقش: سرپرست پروژه → پنل مدیریت پروژه (PM)؛ مدیران و ادمین → دفتر تدارکات // صفحه‌ی اصلی بر اساس لایه پنل‌ها (جایگزین نقش‌ها):
function HomeRoute() { function HomeRoute() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
if (user?.role === "supervisor") return <Navigate to="/panel/pm" replace />; const panels = user?.panels ?? {};
const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
// ۱. اگر پنل تدارکات دارد:
if (hasProcurement) {
if (panels.procurement === "limited" && !isAdmin) {
return <Navigate to="/wizard" replace />;
}
return <DashboardPage />; return <DashboardPage />;
} }
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): مدیر تدارکات، مدیر پروژه و ادمین // ۲. اگر پنل انبار دارد:
if (hasInventory) return <Navigate to="/inventory" replace />;
// ۳. اگر پنل PM دارد:
if (hasPm) return <Navigate to="/panel/pm" replace />;
// در صورتی که کاربر به هیچ پنلی دسترسی ندارد:
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-6 text-center shadow-lg">
<h3 className="text-base font-bold text-slate-800">عدم دسترسی به پنل‌های سامانه</h3>
<p className="mt-2 text-xs text-muted leading-6">
حساب کاربری شما فعال است اما در حال حاضر به هیچ‌یک از پنل‌های سامانه دسترسی ندارد. لطفاً با مدیر سیستم تماس بگیرید.
</p>
</div>
</div>
);
}
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های تدارکات و پنل‌های تدارکات (full یا limited)
function RequireProcurementWrite({ children }: { children: React.ReactNode }) { function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
if (user?.role !== "admin" && user?.role !== "procurement_manager" && user?.role !== "pm") { const panels = user?.panels ?? {};
return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />; const isAdmin = user?.role === "admin";
} const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager" || user?.role === "pm");
if (hasProcurement) {
return <>{children}</>; return <>{children}</>;
} }
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
if (hasPm) return <Navigate to="/panel/pm" replace />;
return <Navigate to="/login" replace />;
}
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود // پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود
function PmPanelPage() { function PmPanelPage() {
return ( return (
@@ -117,35 +232,74 @@ function PmPanelPage() {
); );
} }
// دسترسی پنل PM: همه نقش‌ها دسترسی دارند // دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقش‌های pm/supervisor
function RequirePmPanel({ children }: { children: React.ReactNode }) { function RequirePmPanel({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || user?.role === "supervisor"; const panels = user?.panels ?? {};
if (!allowed) return <Navigate to="/" replace />; const isAdmin = user?.role === "admin";
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
if (!hasPm) {
const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
if (hasProcurement) {
return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
}
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
return <Navigate to="/login" replace />;
}
return <>{children}</>; return <>{children}</>;
} }
// دسترسی پنل سازمان‌دهی: فقط ادمین، مدیر تدارکات و مدیر پروژه // دسترسی پنل سازمان‌دهی: فقط ادمین، مدیر تدارکات و مدیر پروژه
function RequireOrganization({ children }: { children: React.ReactNode }) { function RequireOrganization({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm"; const isAdmin = user?.role === "admin";
if (!allowed) return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />; const allowed = isAdmin || user?.role === "procurement_manager" || user?.role === "pm";
if (!allowed) {
const panels = user?.panels ?? {};
const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const hasProcurement = isAdmin || (hasPanelsConfig ? Boolean(panels.procurement) : user?.role === "procurement_manager");
if (hasProcurement) return <Navigate to={panels.procurement === "limited" && !isAdmin ? "/wizard" : "/"} replace />;
const hasInventory = isAdmin || (hasPanelsConfig ? Boolean(panels.inventory) : user?.role === "supervisor");
if (hasInventory) return <Navigate to="/inventory" replace />;
return <Navigate to="/panel/pm" replace />;
}
return <>{children}</>; return <>{children}</>;
} }
// دسترسی تغییر موجودی انبار: فقط سرپرست پروژه و ادمین (مدیر تدارکات و مدیر پروژه نمی‌توانند موجودی تغییر دهند) // دسترسی تغییر موجودی انبار: ادمین، سرپرست پروژه (میراث) و پنل انبار limited —
// پنل انبار full طبق طراحی به تغییر موجودی دسترسی ندارد
function RequireInventoryChange({ children }: { children: React.ReactNode }) { function RequireInventoryChange({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
if (user?.role !== "admin" && user?.role !== "supervisor") { const allowed =
user?.role === "admin" ||
user?.role === "supervisor" ||
user?.panels?.inventory === "limited";
if (!allowed) {
return <Navigate to="/inventory" replace />; return <Navigate to="/inventory" replace />;
} }
return <>{children}</>; return <>{children}</>;
} }
// دسترسی مدیریت انبارها: فقط مدیران و ادمین (سرپرست پروژه فقط می‌تواند موجودی تغییر دهد) // دسترسی مدیریت انبارها: مدیران، ادمین و سرپرست پروژه (سرپرست پروژه منحصراً انبارهای پروژه خودش را می‌بیند)
function RequireWarehouseManage({ children }: { children: React.ReactNode }) { function RequireWarehouseManage({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
if (user?.role === "supervisor") { const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || user?.role === "supervisor";
if (!allowed) {
return <Navigate to="/inventory" replace />; return <Navigate to="/inventory" replace />;
} }
return <>{children}</>; return <>{children}</>;
@@ -175,6 +329,7 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route path="/requester" element={<Navigate to="/wizard" replace />} />
<Route <Route
path="/suppliers" path="/suppliers"
element={ element={
@@ -302,6 +457,15 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
{/* خانه حداقلی استعلام — تدارکات سطح limited */}
<Route
path="/requester"
element={
<RequireAuth>
<RequesterHome />
</RequireAuth>
}
/>
{/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */} {/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */}
<Route <Route
path="/panel/pm" path="/panel/pm"
+38 -26
View File
@@ -17,49 +17,61 @@ export function AppSwitcherMenu({
}) { }) {
const logout = useLogout(); const logout = useLogout();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const isSupervisor = user?.role === "supervisor"; const panels = user?.panels ?? {};
// سرپرست پروژه: منحصراً دو پنل «انبار» و «مدیریت پروژه» نمایش داده می‌شود const isAdmin = user?.role === "admin";
if (isSupervisor) { const hasPanelsConfig = Boolean(user?.panels && Object.keys(user.panels).length > 0);
const supervisorItems: MenuItem[] = [
{ const hasProcurement =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.procurement)
: user?.role === "procurement_manager");
const hasInventory =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.inventory)
: user?.role === "pm" || user?.role === "supervisor");
const hasPm =
isAdmin ||
(hasPanelsConfig
? Boolean(panels.pm)
: user?.role === "pm" || user?.role === "supervisor");
// فقط نقش‌های ادمین، مدیر پروژه و مدیر تدارکات به پنل سازمان‌دهی دسترسی دارند
const canManageOrg =
isAdmin ||
user?.role === "procurement_manager" ||
user?.role === "pm";
const items: MenuItem[] = [];
if (hasInventory) {
items.push({
label: "انبار", label: "انبار",
to: "/inventory", to: "/inventory",
active: activeModule === "warehouse", active: activeModule === "warehouse",
}, });
{
label: "مدیریت پروژه",
to: "/panel/pm",
},
{ divider: true },
{ label: "خروج از حساب", onClick: logout },
];
return <PanelMenu title="پنل‌ها" items={supervisorItems} />;
} }
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm"; if (hasProcurement && canSwitchToProcurement) {
const items: MenuItem[] = [
{
label: "انبار",
to: "/inventory",
active: activeModule === "warehouse",
},
];
if (canSwitchToProcurement) {
items.push({ items.push({
label: "تدارکات", label: "تدارکات",
to: "/", to: panels.procurement === "limited" && !isAdmin ? "/wizard" : "/",
active: activeModule === "procurement", active: activeModule === "procurement",
}); });
} }
if (hasPm) {
items.push({ items.push({
label: "مدیریت پروژه", label: "مدیریت پروژه",
to: "/panel/pm", to: "/panel/pm",
}); });
}
if (canManage) { if (canManageOrg) {
items.push({ items.push({
label: "سازمان‌دهی", label: "سازمان‌دهی",
to: "/organization", to: "/organization",
@@ -16,11 +16,13 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
const setProjects = useAuthStore((s) => s.setProjects); const setProjects = useAuthStore((s) => s.setProjects);
const logout = useLogout(); const logout = useLogout();
const isSupervisor = user?.role === "supervisor"; // پنل انبار limited هم مثل سرپرست: پروژه‌های فعالش از عضویت‌ها اعتبارسنجی می‌شود
const isSupervisor = user?.role === "supervisor" || user?.panels?.inventory === "limited";
const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok"); const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok");
useEffect(() => { useEffect(() => {
if (!user || user.role !== "supervisor") { const scoped = user?.role === "supervisor" || user?.panels?.inventory === "limited";
if (!user || !scoped) {
setState("ok"); setState("ok");
return; return;
} }
@@ -45,7 +47,7 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, user?.role, isDev]); }, [user?.id, user?.role, user?.panels, isDev]);
if (state === "loading") { if (state === "loading") {
return ( return (
@@ -80,18 +80,24 @@ export function MobileTabBar() {
const { pathname } = useLocation(); const { pathname } = useLocation();
if (pathname === "/login" || pathname === "/admin") return null; if (pathname === "/login" || pathname === "/admin") return null;
// خانه حداقلی استعلام (تدارکات limited): بدون تب ERP — PM هم تب‌بار خودش را دارد
if (pathname === "/requester" || pathname === "/panel/pm") return null;
// کاربر فقطِ تدارکات-limited: تب‌های انبار/دفتر ندارد
const panels = useAuthStore.getState().user?.panels ?? {};
if (panels.procurement === "limited" && !panels.inventory && !panels.pm) return null;
// پنل مدیریت پروژه: iframe تمام‌صفحه‌ی PM است و خودش تب‌بار داخلی دارد // پنل مدیریت پروژه: iframe تمام‌صفحه‌ی PM است و خودش تب‌بار داخلی دارد
if (pathname === "/panel/pm") return null; if (pathname === "/panel/pm") return null;
// سرپرست پروژه فقطِ پنل PM است و هیچ تب ERP ندارد
if (role === "supervisor") return null;
const isPM = role === "pm"; const isPM = role === "pm";
const isSupervisor = role === "supervisor";
const isOffice = role === "admin" || role === "procurement_manager"; const isOffice = role === "admin" || role === "procurement_manager";
if (pathname.startsWith("/inventory")) { if (pathname.startsWith("/inventory")) {
if (isPM) return <TabBar tabs={PM_INV_TABS} />; if (isPM) return <TabBar tabs={PM_INV_TABS} />;
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />; if (isOffice || isSupervisor) return <TabBar tabs={ADMIN_INV_TABS} />;
return null; return null;
} }
// سرپرست پروژه به بخش‌های تدارکات/دفتر دسترسی ندارد
if (isSupervisor) return null;
if (pathname === "/wizard") return null; if (pathname === "/wizard") return null;
const onOfficePage = const onOfficePage =
pathname === "/" || pathname === "/" ||
+13
View File
@@ -250,6 +250,10 @@ async function patchData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) }); const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
return res.data; return res.data;
} }
async function putData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
return res.data;
}
async function deleteData<T>(path: string): Promise<T> { async function deleteData<T>(path: string): Promise<T> {
const res = await request<{ data: T }>(path, { method: "DELETE" }); const res = await request<{ data: T }>(path, { method: "DELETE" });
return res.data; return res.data;
@@ -1562,6 +1566,10 @@ export interface UserRow {
phone: string | null; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
role_label: string | null; role_label: string | null;
// لایه پنل‌ها (جایگزین نقش‌ها): {'procurement': 'full'|'limited', ...}
panels?: Record<string, string>;
// عضویت M:N پروژه‌ای — محدوده داده سطح limited
member_project_ids?: string[];
project_id: string | null; project_id: string | null;
project_name: string | null; project_name: string | null;
// 1:N — every project this PM manages (oldest first). // 1:N — every project this PM manages (oldest first).
@@ -1657,6 +1665,11 @@ export const apiV26 = {
}): Promise<UserRow> => postData<UserRow>("/users", body), }): Promise<UserRow> => postData<UserRow>("/users", body),
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | "supervisor" | null): Promise<UserRow> => updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | "supervisor" | null): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}`, { role }), patchData<UserRow>(`/users/${id}`, { role }),
// لایه پنل‌ها — تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (فقط ادمین)
setPanels: async (id: string, panels: Record<string, string>): Promise<UserRow> =>
putData<UserRow>(`/users/${id}/panels`, { panels }),
setPanelProjects: async (id: string, project_ids: string[]): Promise<UserRow> =>
putData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
assignProject: async (id: string, projectId: string | null): Promise<UserRow> => assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }), postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
// همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند // همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند
+35 -7
View File
@@ -77,7 +77,10 @@ export function LoginPage() {
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید"); toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
return; return;
} }
if (user.role === "supervisor" && (!user.projects || user.projects.length === 0)) { const hasProjects =
(Boolean(user.projects && user.projects.length > 0)) ||
(Boolean(user.member_project_ids && user.member_project_ids.length > 0));
if (user.role === "supervisor" && !hasProjects) {
toast.error("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید"); toast.error("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه اجازه ورود ندارید");
return; return;
} }
@@ -86,9 +89,23 @@ export function LoginPage() {
// تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*) // تحلیل رفتار (no-op بدون تنظیمات VITE_OPENPANEL_*)
identifyUser({ id: user.id, name: user.display_name, role: user.role }); identifyUser({ id: user.id, name: user.display_name, role: user.role });
track("login_success", { role: user.role }); track("login_success", { role: user.role });
toast.success(`خوش آمدید، ${user.display_name}`); // هدایت پس از ورود بر اساس پنل‌ها یا نقش:
// سرپرست پروژه → پنل مدیریت پروژه (PM)؛ بقیه نقش‌ها → دفتر پیشنهادهای خرید const panels = user.panels ?? {};
navigate(user.role === "supervisor" ? "/panel/pm" : "/"); if (panels.procurement === "full") {
navigate("/");
} else if (panels.procurement === "limited") {
navigate("/requester");
} else if (panels.inventory) {
navigate("/inventory");
} else if (panels.pm) {
navigate("/panel/pm");
} else if (user.role === "supervisor") {
navigate("/panel/pm");
} else if (user.role === "pm") {
navigate("/inventory");
} else {
navigate("/");
}
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ورود"); toast.error(err instanceof Error ? err.message : "خطا در ورود");
} finally { } finally {
@@ -277,10 +294,21 @@ export function LoginPage() {
<ClipboardList className="h-6 w-6" strokeWidth={2} /> <ClipboardList className="h-6 w-6" strokeWidth={2} />
</span> </span>
<div> <div>
<h1 className="text-[19px] font-extrabold leading-7">سامانه تدارکات و انبار</h1> <h1 className="text-[19px] font-extrabold leading-7">سامانه جامع مدیریت پروژه، تدارکات و انبار</h1>
<p className="mt-1 text-[12.5px] leading-6 text-muted"> <p className="mt-1.5 text-[12.5px] leading-6 text-muted">
برای ورود، نام کاربری و رمز عبور سازمانی خود را وارد کنید درگاه یکپارچه ورود به سامانه مدیریت عملیات پروژه، تدارکات و انبارداری
</p> </p>
<div className="mt-2.5 flex items-center justify-center gap-1.5 flex-wrap">
<span className="inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700 border border-sky-200">
مدیریت پروژه
</span>
<span className="inline-flex items-center rounded-full bg-indigo-50 px-2.5 py-0.5 text-[11px] font-semibold text-indigo-700 border border-indigo-200">
تدارکات
</span>
<span className="inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-[11px] font-semibold text-emerald-700 border border-emerald-200">
انبارداری
</span>
</div>
</div> </div>
</div> </div>
@@ -0,0 +1,106 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { FilePlus2, Info } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { useAuthStore } from "../../store/auth";
import { useProjects } from "../../hooks";
import { faNum } from "../../lib/formatters";
// خانه کاربرِ «تدارکات — سطح limited»: افزودن استعلام برای پروژه‌های تخصیص‌یافته
export function RequesterHome() {
const user = useAuthStore((s) => s.user);
const projects = useProjects();
// پروژه‌های کاربر از همه منابع موجود در سیستم (user.projects، user.project_id، member_project_ids و پروژه‌های عمومی)
const myProjects = useMemo(() => {
const list: { id: string; name: string }[] = [];
const seen = new Set<string>();
// ۱. پروژه‌های موجود در آرایه user.projects
for (const p of user?.projects ?? []) {
if (p.id && !seen.has(p.id)) {
seen.add(p.id);
list.push({ id: p.id, name: p.name });
}
}
// ۲. پروژه‌ی اختصاصی فعال (project_id)
if (user?.project_id && !seen.has(user.project_id)) {
seen.add(user.project_id);
list.push({ id: user.project_id, name: user.project_name || "پروژه تخصیص‌یافته" });
}
// ۳. پروژه‌های عضویت member_project_ids تطبیق‌یافته با لیست پروژه‌ها
const memberIdSet = new Set(user?.member_project_ids ?? []);
for (const p of projects) {
if (memberIdSet.has(p.id) && !seen.has(p.id)) {
seen.add(p.id);
list.push({ id: p.id, name: p.name });
}
}
// ۴. تطبیق نام پروژه در صورتی که فقط نام در نشست باشد
if (list.length === 0 && user?.project_name) {
const match = projects.find((p) => p.name === user.project_name);
if (match && !seen.has(match.id)) {
list.push({ id: match.id, name: match.name });
}
}
return list;
}, [user, projects]);
return (
<div className="min-h-screen">
<Header
title="افزودن استعلام"
brand
menu={<AppSwitcherMenu activeModule="procurement" />}
/>
<PageLayout className="flex flex-col gap-4">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
<span className="inline-flex items-center gap-1.5">
<Info className="h-3.5 w-3.5" />
شما مجاز به ثبت استعلام برای پروژه‌های تخصیص‌یافته‌ی خود هستید.
</span>
</div>
{myProjects.length === 0 ? (
<div className="rounded-xl border border-dashed border-line bg-panel px-4 py-8 text-center text-[12.5px] text-muted">
هنوز پروژه‌ای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید.
</div>
) : (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{myProjects.map((p) => (
<Link
key={p.id}
to={`/wizard?project=${encodeURIComponent(p.name)}`}
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50 hover:bg-canvas/40"
>
<div>
<b className="text-[13.5px]">🏢 {p.name}</b>
<p className="mt-0.5 text-[11.5px] text-muted">ثبت استعلام خرید برای این پروژه</p>
</div>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-accent/10 text-accent">
<FilePlus2 className="h-5 w-5" />
</span>
</Link>
))}
</div>
<Link
to="/wizard"
className="btn-line-file inline-flex items-center justify-center gap-2 no-underline"
>
<FilePlus2 className="h-3.5 w-3.5" />
افزودن استعلام جدید ({faNum(myProjects.length)} پروژه فعال)
</Link>
</>
)}
</PageLayout>
</div>
);
}
@@ -23,6 +23,8 @@ export function ArrivalsPage() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const isPM = user?.role === "pm"; const isPM = user?.role === "pm";
const isSupervisor = user?.role === "supervisor";
const canAccess = isAdmin || isPM || isSupervisor;
const [items, setItems] = useState<PendingArrival[]>([]); const [items, setItems] = useState<PendingArrival[]>([]);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]); const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
@@ -33,9 +35,9 @@ export function ArrivalsPage() {
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
const activeProject = isPM ? user?.project_id || undefined : undefined; const activeProject = (isPM || isSupervisor) ? user?.project_id || undefined : undefined;
const [arr, whs] = await Promise.all([ const [arr, whs] = await Promise.all([
// پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM را بررسی می‌کند // پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM / سرپرست را بررسی می‌کند
apiV26.pm.onTheWay(activeProject), apiV26.pm.onTheWay(activeProject),
apiV26.warehouses.list(activeProject), apiV26.warehouses.list(activeProject),
]); ]);
@@ -46,13 +48,13 @@ export function ArrivalsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [isPM, user?.project_id]); }, [isPM, isSupervisor, user?.project_id]);
useEffect(() => { useEffect(() => {
if (!isAdmin && !isPM) return; if (!canAccess) return;
setLoading(true); setLoading(true);
void load(); void load();
}, [isAdmin, isPM, load]); }, [canAccess, load]);
// سفارش‌ها: گروه‌بندی اقلام به‌ازای هر پیشنهاد (فقط سفارش‌های دارای قلمِ در راه/ناقص) // سفارش‌ها: گروه‌بندی اقلام به‌ازای هر پیشنهاد (فقط سفارش‌های دارای قلمِ در راه/ناقص)
const orders = useMemo(() => { const orders = useMemo(() => {
@@ -212,12 +214,12 @@ export function ArrivalsPage() {
} }
}; };
if (!isAdmin && !isPM) { if (!canAccess) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<Header title="کالاهای در راه" backTo="/inventory" backLabel="بازگشت به داشبورد" /> <Header title="کالاهای در راه" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout> <PageLayout>
<EmptyState emoji="🔒" text="این بخش فقط برای مدیر پروژه و مدیر تدارکات است." /> <EmptyState emoji="🔒" text="این بخش برای نقش کاربری شما مجاز نیست." />
</PageLayout> </PageLayout>
</div> </div>
); );
@@ -41,10 +41,12 @@ export function DailyControlPage() {
const txByDay = useMemo(() => { const txByDay = useMemo(() => {
const map = new Map<string, InventoryTransaction[]>(); const map = new Map<string, InventoryTransaction[]>();
const allowedWhIds = new Set(data.warehouses.map((w) => w.id));
const projWhIds = projFilter const projWhIds = projFilter
? new Set(data.warehouses.filter((w) => String(w.project_id) === String(projFilter)).map((w) => w.id)) ? new Set(data.warehouses.filter((w) => String(w.project_id) === String(projFilter)).map((w) => w.id))
: null; : null;
data.transactions.forEach((t) => { data.transactions.forEach((t) => {
if (!isAdmin && !allowedWhIds.has(t.warehouse_id)) return;
if (projWhIds && !projWhIds.has(t.warehouse_id)) return; if (projWhIds && !projWhIds.has(t.warehouse_id)) return;
const k = gregKey(new Date(t.transaction_date)); const k = gregKey(new Date(t.transaction_date));
const arr = map.get(k) || []; const arr = map.get(k) || [];
@@ -53,7 +55,7 @@ export function DailyControlPage() {
}); });
map.forEach((arr) => arr.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime())); map.forEach((arr) => arr.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()));
return map; return map;
}, [data.transactions, data.warehouses, projFilter]); }, [data.transactions, data.warehouses, projFilter, isAdmin]);
const cells = useMemo(() => { const cells = useMemo(() => {
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate()); const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
@@ -172,11 +174,15 @@ export function DailyControlPage() {
))} ))}
</select> </select>
) : ( ) : (
<span className="font-semibold text-slate-800">پروژه فعال</span> <span className="font-semibold text-slate-800">
{user?.project_name ? `پروژه: ${user.project_name}` : "پروژه فعال"}
</span>
)} )}
{isAdmin && (
<span className="text-[11.5px] text-slate-400"> <span className="text-[11.5px] text-slate-400">
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژه‌ها"} {projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژه‌ها"}
</span> </span>
)}
</div> </div>
<Link <Link
@@ -54,7 +54,8 @@ export function InventoryPage() {
const isManager = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm"; const isManager = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
const isSupervisor = user?.role === "supervisor"; const isSupervisor = user?.role === "supervisor";
const isAdmin = user?.role === "admin"; const isAdmin = user?.role === "admin";
const canModify = isSupervisor || isAdmin; // پنل انبار limited: تغییر موجودی مجاز است؛ full: طبق طراحی ممنوع
const canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited";
const [params] = useSearchParams(); const [params] = useSearchParams();
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({ const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
@@ -12,17 +12,12 @@ export function InventoryTopNav({
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === "admin"; const isAdmin = user?.role === "admin";
const isManager = user?.role === "procurement_manager" || user?.role === "pm" || isAdmin; const isManager = user?.role === "procurement_manager" || user?.role === "pm" || isAdmin;
const isSupervisor = user?.role === "supervisor";
const canViewTabs = isManager || isSupervisor;
const items: { label: string; to: string; active: boolean }[] = []; const items: { label: string; to: string; active: boolean }[] = [];
// سرپرست پروژه از دکمه شناور گرد (+) در پایین صفحه استفاده می‌کند و دکمه مستطیلی بالای صفحه برای او نمایش داده نمی‌شود // برای تغییر موجودی از همان دکمه شناور دایره‌ای آبی (+) استفاده می‌شود و دکمه مستطیلی در بالای صفحه حذف شد
if (isAdmin) { if (canViewTabs) {
items.push({
label: "تغییر موجودی",
to: "/inventory/change",
active: pathname.startsWith("/inventory/change"),
});
}
if (isManager) {
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") }); items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") }); items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") }); items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
@@ -14,7 +14,8 @@ export function WarehousesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const data = useInventoryData(); const data = useInventoryData();
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm"; const isSupervisor = user?.role === "supervisor";
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || isSupervisor;
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState(""); const [newDesc, setNewDesc] = useState("");
@@ -32,7 +33,7 @@ export function WarehousesPage() {
<div className="text-3xl">🔒</div> <div className="text-3xl">🔒</div>
<h3 className="mt-2 text-[15px] font-bold">دسترسی محدود</h3> <h3 className="mt-2 text-[15px] font-bold">دسترسی محدود</h3>
<p className="mt-1 text-[12.5px] text-muted"> <p className="mt-1 text-[12.5px] text-muted">
مدیریت و تعریف انبارها مخصوص مدیران سیستم است. سرپرست پروژه فقط به ثبت تغییرات موجودی دسترسی دارد. دسترسی به این بخش برای حساب کاربری شما فعال نیست.
</p> </p>
</div> </div>
</PageLayout> </PageLayout>
@@ -43,7 +44,7 @@ export function WarehousesPage() {
const addWarehouse = async () => { const addWarehouse = async () => {
const name = newName.trim(); const name = newName.trim();
if (!name) return toast.error("نام انبار الزامی است"); if (!name) return toast.error("نام انبار الزامی است");
const projectId = selectedProjectId || user?.project_id || data.lookup.projects[0]?.id; const projectId = isSupervisor ? user?.project_id : (selectedProjectId || user?.project_id || data.lookup.projects[0]?.id);
if (!projectId) return toast.error("پروژه انبار را انتخاب کنید"); if (!projectId) return toast.error("پروژه انبار را انتخاب کنید");
try { try {
await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null }); await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null });
@@ -92,7 +93,7 @@ export function WarehousesPage() {
<div className="rounded-xl border border-line bg-panel p-3"> <div className="rounded-xl border border-line bg-panel p-3">
<h3 className="mb-2 text-[13px] font-bold">+ افزودن انبار جدید</h3> <h3 className="mb-2 text-[13px] font-bold">+ افزودن انبار جدید</h3>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{data.lookup.projects.length > 1 && ( {!isSupervisor && data.lookup.projects.length > 1 && (
<select <select
className="h-9 rounded-lg border border-line bg-panel px-3 text-sm" className="h-9 rounded-lg border border-line bg-panel px-3 text-sm"
value={selectedProjectId || user?.project_id || data.lookup.projects[0]?.id || ""} value={selectedProjectId || user?.project_id || data.lookup.projects[0]?.id || ""}
+2 -2
View File
@@ -61,8 +61,8 @@ export function panelMenuItems(pathname: string, role: string | null | undefined
if (isSupervisor) { if (isSupervisor) {
items.push({ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") }); items.push({ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") });
} }
// کنترل روزانه و انبارها و کالاهای در راه برای مدیران // کنترل روزانه و انبارها و کالاهای در راه برای مدیران و سرپرست پروژه
if (isManager) { if (isManager || isSupervisor) {
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") }); items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") }); items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") }); items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { apiV26, type UserRow } from "../../lib/api"; import { api, apiV26, type UserRow } from "../../lib/api";
import type { Project } from "../../lib/types";
import { refreshProcurementStore, useProjects } from "../../hooks"; import { refreshProcurementStore, useProjects } from "../../hooks";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect"; import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
@@ -17,10 +18,12 @@ const ROLE_LABELS: Record<string, string> = {
supervisor: "سرپرست پروژه", supervisor: "سرپرست پروژه",
}; };
// پنل مستقل سازمان‌دهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقش‌ها و تخصیص پروژه‌ها به سرپرستان // پنل مستقل سازمان‌دهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقش‌ها و تخصیص پروژه‌ها و پنل‌ها
export function OrganizationPage() { export function OrganizationPage() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const projects = useProjects(); const storeProjects = useProjects();
const [directProjects, setDirectProjects] = useState<Project[]>([]);
const [loadingProjects, setLoadingProjects] = useState(false);
const [users, setUsers] = useState<UserRow[]>([]); const [users, setUsers] = useState<UserRow[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true); const [loadingUsers, setLoadingUsers] = useState(true);
@@ -28,11 +31,25 @@ export function OrganizationPage() {
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm"; const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
// فقط ادمین می‌تواند نقش‌ها را تغییر دهد؛ مدیران می‌توانند پروژه‌ها را به سرپرستان تخصیص دهند const canChangeRoles = canManage;
const canChangeRoles = user?.role === "admin";
const fetchProjects = useCallback(async () => {
try {
setLoadingProjects(true);
const list = await api.projects.list();
if (list && Array.isArray(list)) {
setDirectProjects(list);
}
} catch {
// fallback silently
} finally {
setLoadingProjects(false);
}
}, []);
const loadUsers = useCallback(async () => { const loadUsers = useCallback(async () => {
try { try {
setLoadingUsers(true);
const list = await apiV26.users.list(); const list = await apiV26.users.list();
setUsers(list); setUsers(list);
} catch (err) { } catch (err) {
@@ -43,8 +60,13 @@ export function OrganizationPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (canManage) void loadUsers(); if (canManage) {
}, [canManage, loadUsers]); void loadUsers();
void fetchProjects();
}
}, [canManage, loadUsers, fetchProjects]);
const projects = storeProjects.length > 0 ? storeProjects : directProjects;
const syncUsers = async () => { const syncUsers = async () => {
setSyncing(true); setSyncing(true);
@@ -57,6 +79,7 @@ export function OrganizationPage() {
: "همگام‌سازی انجام شد — کاربر جدیدی یافت نشد", : "همگام‌سازی انجام شد — کاربر جدیدی یافت نشد",
); );
refreshProcurementStore(); refreshProcurementStore();
void fetchProjects();
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در همگام‌سازی با Casdoor"); toast.error(err instanceof Error ? err.message : "خطا در همگام‌سازی با Casdoor");
} finally { } finally {
@@ -91,43 +114,49 @@ export function OrganizationPage() {
} }
}; };
const assignedProjectsOf = (u: UserRow) => // ---------- لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای ----------
u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []); const setPanel = async (u: UserRow, panel: "procurement" | "inventory" | "pm", level: "" | "full" | "limited") => {
const syncUserProjects = async (u: UserRow, nextIds: string[]) => {
const current = assignedProjectsOf(u).map((p) => p.id);
const added = nextIds.filter((id) => !current.includes(id));
const removed = current.filter((id) => !nextIds.includes(id));
if (!added.length && !removed.length) return;
setBusyId(u.id); setBusyId(u.id);
const next = { ...(u.panels ?? {}) };
if (level) next[panel] = level;
else delete next[panel];
try { try {
for (const pid of added) await apiV26.projects.setPm(pid, u.id); await apiV26.users.setPanels(u.id, next);
for (const pid of removed) await apiV26.projects.setPm(pid, null); toast.success(`دسترسی «${u.first_name} ${u.last_name}» به‌روزرسانی شد ✓`);
const names = nextIds
.map((id) => projects.find((p) => p.id === id)?.name)
.filter(Boolean)
.join("، ");
toast.success(
nextIds.length
? `پروژه‌های «${u.first_name} ${u.last_name}» به‌روزرسانی شد: ${names} ✓`
: `«${u.first_name} ${u.last_name}» از همه‌ی پروژه‌ها جدا شد`,
);
await loadUsers(); await loadUsers();
refreshProcurementStore(); refreshProcurementStore();
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در به‌روزرسانی پروژه‌ها"); toast.error(err instanceof Error ? err.message : "خطا در ثبت دسترسی پنل");
await loadUsers();
} finally { } finally {
setBusyId(null); setBusyId(null);
} }
}; };
// سرپرستان تخصیص‌یافته به یک پروژه const setMemberProjects = async (u: UserRow, nextIds: string[]) => {
setBusyId(u.id);
try {
await apiV26.users.setPanelProjects(u.id, nextIds);
toast.success(`عضویت پروژه‌ای «${u.first_name} ${u.last_name}» به‌روزرسانی شد ✓`);
await loadUsers();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ثبت عضویت پروژه‌ها");
} finally {
setBusyId(null);
}
};
const panelLabel = (level: string | undefined) =>
level === "full" ? "کامل" : level === "limited" ? "محدود" : "—";
// سرپرستان و مسئولان تخصیص‌یافته به یک پروژه
const supervisorsOf = (projectId: string) => const supervisorsOf = (projectId: string) =>
users.filter( users.filter(
(u) => (u) =>
(u.role === "supervisor" || u.role === "pm") && (u.role === "supervisor" || u.role === "pm" || Boolean(u.panels?.pm)) &&
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId), (u.projects?.some((p) => p.id === projectId) ||
u.project_id === projectId ||
u.member_project_ids?.includes(projectId)),
); );
return ( return (
@@ -137,34 +166,33 @@ export function OrganizationPage() {
brand brand
menu={<AppSwitcherMenu activeModule="organization" />} menu={<AppSwitcherMenu activeModule="organization" />}
/> />
<PageLayout className="flex flex-col gap-4"> <PageLayout className="flex flex-col gap-5">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted"> <div className="rounded-lg border border-blue/20 bg-blue/5 px-3.5 py-2.5 text-[12px] leading-6 text-muted">
ℹ️ کاربران در Casdoor تعریف شده و از طریق دکمه «همگام‌سازی» اضافه می‌شوند. نقش‌ها در این پنل توسط ادمین تعیین می‌گردند. ℹ️ کاربران در سامانه احراز هویت (Casdoor) تعریف شده و با کلیک بر روی دکمه «همگام‌سازی» اضافه می‌شوند. نقش‌های سازمانی و سطوح دسترسی هر پنل در جدول زیر توسط ادمین تعیین می‌گردد.
مدیران پروژه و تدارکات می‌توانند پروژه‌ها را به سرپرستان پروژه تخصیص دهند.
</div> </div>
{/* ---------- بخش ۱: جدول کاربران و نقش‌ها ---------- */} {/* ---------- بخش ۱: جدول یکپارچه کاربران، نقش‌ها و سطوح دسترسی پنل‌ها ---------- */}
<div className="rounded-xl border border-line bg-panel p-4"> <div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2"> <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div> <div>
<h3 className="text-[14px] font-bold">👥 کاربران و نقش‌ها</h3> <h3 className="text-[14px] font-bold">👥 کاربران، نقش‌ها و سطوح دسترسی پنل‌ها</h3>
<p className="mt-0.5 text-[11.5px] text-muted"> <p className="mt-0.5 text-[11.5px] text-muted">
تعیین نقش کاربران و تخصیص پروژه‌ها به سرپرستان پروژه تعیین نقش سازمانی، تخصیص پنل‌ها (تدارکات، انبار، PM) و مشخص نمودن پروژه‌های عضویت کاربر
</p> </p>
</div> </div>
<button <button
onClick={() => void syncUsers()} onClick={() => void syncUsers()}
disabled={syncing} disabled={syncing}
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent disabled:opacity-50" className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent hover:bg-accent/10 transition-colors disabled:opacity-50"
> >
{syncing ? "در حال همگام‌سازی…" : "🔄 همگام‌سازی از Casdoor"} {syncing ? "در حال همگام‌سازی…" : "🔄 همگام‌سازی از Casdoor"}
</button> </button>
</div> </div>
{loadingUsers ? ( {loadingUsers ? (
<div className="py-3 text-[12.5px] text-muted">در حال بارگذاری کاربران…</div> <div className="py-8 text-center text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
) : !users.length ? ( ) : !users.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted"> <div className="rounded-lg border border-dashed border-line px-3 py-6 text-center text-[12px] text-muted">
کاربری ثبت نشده است — دکمه‌ی «همگام‌سازی از Casdoor» را بزنید. کاربری ثبت نشده است — دکمه‌ی «همگام‌سازی از Casdoor» را بزنید.
</div> </div>
) : ( ) : (
@@ -172,26 +200,45 @@ export function OrganizationPage() {
<table className="w-full text-[12.5px]"> <table className="w-full text-[12.5px]">
<thead> <thead>
<tr className="border-b border-line text-right text-muted"> <tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">نام</th> <th className="py-2.5 pe-3">نام و مشخصات</th>
<th className="py-2 pe-3">نام کاربری</th> <th className="py-2.5 pe-3">نقش سازمانی</th>
<th className="py-2 pe-3">نقش</th> <th className="py-2.5 pe-3">پنل تدارکات</th>
<th className="py-2 pe-3">پروژه‌های تخصیص‌یافته (سرپرست پروژه)</th> <th className="py-2.5 pe-3">پنل انبار</th>
<th className="py-2">وضعیت</th> <th className="py-2.5 pe-3">پنل مدیریت پروژه (PM)</th>
<th className="py-2.5">پروژه‌های عضویت (محدوده سطح محدود)</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users.map((u) => ( {users.map((u) => {
<tr key={u.id} className="border-b border-line/50"> const isAdmin = u.role === "admin";
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory || u.panels?.pm);
return (
<tr key={u.id} className="border-b border-line/50 transition-colors hover:bg-canvas/30">
{/* ۱. نام و نام کاربری */}
<td className="py-2.5 pe-3"> <td className="py-2.5 pe-3">
<b>{u.first_name} {u.last_name}</b> <div className="flex flex-col">
</td> <div className="flex items-center gap-1.5">
<td className="py-2.5 pe-3 font-mono text-[11px] text-muted"> <span className="font-bold text-foreground">
{u.username || "—"} {u.first_name} {u.last_name}
</span>
<span className="font-mono text-[10px] text-muted">
({u.username || "—"})
</span>
</div>
{busyId === u.id && (
<span className="text-[10px] text-accent animate-pulse font-medium">
در حال ذخیره تغییرات…
</span>
)}
</div>
</td> </td>
{/* ۲. نقش سازمانی */}
<td className="py-2.5 pe-3"> <td className="py-2.5 pe-3">
{canChangeRoles ? ( {canChangeRoles ? (
<select <select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]" className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
value={u.role ?? ""} value={u.role ?? ""}
disabled={busyId === u.id} disabled={busyId === u.id}
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)} onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
@@ -203,78 +250,157 @@ export function OrganizationPage() {
<option value="supervisor">سرپرست پروژه</option> <option value="supervisor">سرپرست پروژه</option>
</select> </select>
) : ( ) : (
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold"> <span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold text-muted">
{ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"} {ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"}
</span> </span>
)} )}
</td> </td>
{/* ۳. پنل تدارکات */}
<td className="py-2.5 pe-3"> <td className="py-2.5 pe-3">
{u.role === "supervisor" || u.role === "pm" ? ( {isAdmin ? (
<span className="text-[11px] text-muted">کامل (ادمین)</span>
) : canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
value={u.panels?.procurement ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "procurement", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل</option>
<option value="limited">محدود (فقط استعلام)</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.procurement)}</span>
)}
</td>
{/* ۴. پنل انبار */}
<td className="py-2.5 pe-3">
{isAdmin ? (
<span className="text-[11px] text-muted">کامل (ادمین)</span>
) : canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
value={u.panels?.inventory ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "inventory", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل (بدون تغییر موجودی)</option>
<option value="limited">محدود (با تغییر موجودی)</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.inventory)}</span>
)}
</td>
{/* ۵. پنل مدیریت پروژه (PM) */}
<td className="py-2.5 pe-3">
{isAdmin ? (
<span className="text-[11px] text-muted">کامل (ادمین)</span>
) : canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px] focus:outline-none focus:ring-1 focus:ring-accent"
value={u.panels?.pm ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "pm", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل</option>
<option value="limited">محدود</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.pm)}</span>
)}
</td>
{/* ۶. پروژه‌های عضویت */}
<td className="py-2.5">
{isAdmin ? (
<span className="text-[11px] text-muted">دسترسی خودکار به تمام پروژه‌ها</span>
) : hasPanel ? (
<div className="w-72"> <div className="w-72">
<ProjectMultiSelect <ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))} options={projects.map((p) => ({ id: p.id, name: p.name }))}
values={assignedProjectsOf(u).map((p) => p.id)} values={u.member_project_ids ?? []}
onChange={(nextIds) => void syncUserProjects(u, nextIds)} onChange={(nextIds) => void setMemberProjects(u, nextIds)}
disabled={busyId === u.id} disabled={busyId === u.id}
/> />
</div> </div>
) : ( ) : (
<span className="text-[11px] text-muted"> <span className="text-[11px] text-muted">— ابتدا پنل تخصیص دهید —</span>
{u.role === "admin"
? "دسترسی کامل به همه پروژه‌ها"
: u.role === "procurement_manager"
? "دسترسی کامل مدیریتی"
: "—"}
</span>
)}
</td>
<td className="py-2.5 text-[11px] text-muted">
{busyId === u.id ? (
<span className="text-accent">در حال ذخیره…</span>
) : (
<span className="text-emerald-600">✓ ثبت‌شده</span>
)} )}
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
<div className="mt-2 text-[11px] text-muted">
سرپرستان پروژه‌ای که پروژه‌ای به آن‌ها تخصیص داده نشده باشد، تا زمان تخصیص پروژه اجازه ورود به سامانه را نخواهند داشت.
</div>
</div> </div>
)} )}
</div> </div>
{/* ---------- بخش ۲: کارت‌های پروژه‌ها و سرپرستان مسئول ---------- */} {/* ---------- بخش ۲: کارت‌های پروژه‌ها و سرپرستان مسئول ---------- */}
<div> <div>
<h3 className="mb-2 text-[14px] font-bold">🏢 پروژه‌ها و سرپرستان مسئول</h3> <div className="mb-2 flex items-center justify-between">
<div>
<h3 className="text-[14px] font-bold">🏢 پروژه‌ها و سرپرستان مسئول</h3>
<p className="text-[11.5px] text-muted">
مشاهده وضعیت پروژه‌ها و سرپرستان یا اعضای تخصیص‌یافته به هر پروژه
</p>
</div>
{loadingProjects && (
<span className="text-[11px] text-muted animate-pulse">در حال به‌روزرسانی پروژه‌ها…</span>
)}
</div>
{loadingProjects && !projects.length ? (
<div className="rounded-xl border border-line bg-panel p-6 text-center text-xs text-muted">
در حال دریافت اطلاعات پروژه‌ها…
</div>
) : !projects.length ? (
<div className="rounded-xl border border-dashed border-line bg-panel p-6 text-center text-xs text-muted flex flex-col items-center gap-2">
<span>پروژه‌ای برای نمایش یافت نشد یا در دسترس نیست.</span>
<button
type="button"
onClick={() => void fetchProjects()}
className="rounded-lg bg-accent/10 px-3 py-1 text-xs font-semibold text-accent hover:bg-accent/20 transition-colors"
>
🔄 تلاش مجدد برای دریافت پروژه‌ها
</button>
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => { {projects.map((p) => {
const sups = supervisorsOf(p.id); const sups = supervisorsOf(p.id);
return ( return (
<div key={p.id} className="rounded-xl border border-line bg-panel p-4"> <div key={p.id} className="rounded-xl border border-line bg-panel p-4 flex flex-col justify-between">
<div>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<h4 className="text-[14px] font-bold">🏢 {p.name}</h4> <h4 className="text-[14px] font-bold text-foreground">🏢 {p.name}</h4>
<span className="rounded-lg bg-panel px-2 py-0.5 text-[11px] text-muted"> <span className="rounded-lg bg-canvas px-2 py-0.5 text-[11px] text-muted font-medium border border-line">
{p.status || "پروژه فعال"} {p.status || "پروژه فعال"}
</span> </span>
</div> </div>
{p.description && ( {p.description && (
<p className="mb-3 text-[12px] text-muted line-clamp-2">{p.description}</p> <p className="mb-3 text-[12px] text-muted line-clamp-2">{p.description}</p>
)} )}
<div> </div>
<h5 className="mb-1.5 text-[11.5px] font-bold text-muted">👤 سرپرست(های) پروژه:</h5>
<div className="pt-2 border-t border-line/50">
<h5 className="mb-1.5 text-[11.5px] font-bold text-muted">👤 سرپرست(های) مسئول:</h5>
{!sups.length ? ( {!sups.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-amber-600"> <div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-amber-600 dark:text-amber-400 bg-amber-500/5">
⚠️ سرپرستی تعیین نشده است — از جدول بالا پروژه را به سرپرست تخصیص دهید. ⚠️ سرپرستی تعیین نشده است — از جدول بالا، این پروژه را به عنوان عضویت تخصیص دهید.
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{sups.map((s) => ( {sups.map((s) => (
<div key={s.id} className="flex items-center gap-2 rounded-lg border border-line px-2.5 py-1.5 text-[12px]"> <div key={s.id} className="flex items-center justify-between rounded-lg border border-line bg-canvas/40 px-2.5 py-1.5 text-[12px]">
<span className="font-semibold">{s.first_name} {s.last_name}</span> <span className="font-semibold text-foreground">{s.first_name} {s.last_name}</span>
<span className="text-[10px] text-muted font-mono">({s.username})</span> <span className="text-[10px] text-muted font-mono">({s.username || ROLE_LABELS[s.role ?? ""] || "عضو"})</span>
</div> </div>
))} ))}
</div> </div>
@@ -284,6 +410,185 @@ export function OrganizationPage() {
); );
})} })}
</div> </div>
)}
</div>
{/* ---------- بخش ۴: راهنما و توضیحات سطوح دسترسی پنل‌ها ---------- */}
<div className="rounded-xl border border-line bg-panel p-5">
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
<div>
<h3 className="flex items-center gap-2 text-[15px] font-bold">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-accent/10 text-accent">
📖
</span>
توضیحات پنل‌ها و راهنمای سطوح دسترسی
</h3>
<p className="mt-1 text-[12px] text-muted">
مفهوم، اختیارات و محدوده کاری هر سطح دسترسی (کامل / محدود) در هر یک از پنل‌های سامانه
</p>
</div>
<div className="hidden sm:flex items-center gap-2 text-[11px] text-muted bg-panel border border-line px-3 py-1.5 rounded-lg">
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
مدل دسترسی ماژولار مبتنی بر پنل
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{/* کارت پنل تدارکات */}
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-sky-500/40">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500/10 text-[18px]">
🛒
</span>
<div>
<h4 className="text-[13.5px] font-bold text-foreground">پنل تدارکات</h4>
<span className="text-[10.5px] text-muted font-mono">Procurement</span>
</div>
</div>
<span className="rounded-md bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium text-sky-600 dark:text-sky-400">
خرید و بازرگانی
</span>
</div>
<div className="flex-1 space-y-3">
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>دسترسی کامل مدیریتی به تمامی بخش‌های تدارکات</li>
<li>ثبت و ویرایش پیش‌فاکتورها، شرایط پرداخت و فاکتورها</li>
<li>مدیریت تأمین‌کنندگان، اقلام و تصمیم‌گیری خرید در همه پروژه‌ها</li>
</ul>
</div>
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - متقاضی)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>فقط امکان ثبت درخواست استعلام قیمت و پیش‌فاکتور اولیه</li>
<li>محدود منحصراً به پروژه‌هایی که کاربر در آن‌ها عضویت دارد</li>
<li>عدم دسترسی به تأییدات نهایی، تغییر قیمت‌ها یا پروژه‌های غیرعضو</li>
</ul>
</div>
</div>
</div>
{/* کارت پنل انبار */}
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-amber-500/40">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10 text-[18px]">
📦
</span>
<div>
<h4 className="text-[13.5px] font-bold text-foreground">پنل انبار</h4>
<span className="text-[10.5px] text-muted font-mono">Inventory</span>
</div>
</div>
<span className="rounded-md bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-600 dark:text-amber-400">
انبارداری و موجودی
</span>
</div>
<div className="flex-1 space-y-3">
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full - مدیر/ناظر)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>مشاهده موجودی و کاردکس کالا در تمامی انبارها و پروژه‌ها</li>
<li>پایش و نظارت بر گردش کالا در کل سازمان و گزارش‌گیری کلان</li>
<li className="font-semibold text-amber-700 dark:text-amber-300">بدون امکان تغییر مستقیم موجودی (جهت حفظ سلامت داده و کنترل نظارتی)</li>
</ul>
</div>
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - انباردار کارگاه)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>دسترسی فقط به انبارهای پروژه‌های عضویت کاربر</li>
<li>امکان ثبت و تغییر موجودی، رسید کالا، حواله مصرف و تطبیق فیزیکی</li>
<li>تفکیک دقیق موجودی و تراکنش‌های انبار بر اساس پروژه</li>
</ul>
</div>
</div>
</div>
{/* کارت پنل مدیریت پروژه (PM) */}
<div className="flex flex-col rounded-xl border border-line bg-canvas/40 p-4 transition-all hover:border-indigo-500/40">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/10 text-[18px]">
📊
</span>
<div>
<h4 className="text-[13.5px] font-bold text-foreground">پنل مدیریت پروژه (PM)</h4>
<span className="text-[10.5px] text-muted font-mono">Project Management</span>
</div>
</div>
<span className="rounded-md bg-indigo-500/10 px-2 py-0.5 text-[11px] font-medium text-indigo-600 dark:text-indigo-400">
کنترل پروژه و اجرا
</span>
</div>
<div className="flex-1 space-y-3">
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-[12px] font-bold text-emerald-600 dark:text-emerald-400">سطح کامل (Full - مدیر پروژه)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>مدیریت جامع ساختار شکست کار (WBS)، زمان‌بندی و گانت چارت تعاملی</li>
<li>تعریف و ویرایش فعالیت‌ها، ثبت خط مبنا (Baseline)، کنترل هزینه و منابع</li>
<li>پایش شاخص‌های پیشرفت زمانی و مالی (EVM) و گزارش‌های تحلیلی</li>
</ul>
</div>
<div className="rounded-lg border border-line/60 bg-panel/70 p-3">
<div className="mb-1 flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
<span className="text-[12px] font-bold text-amber-600 dark:text-amber-400">سطح محدود (Limited - سرپرست/کارشناس)</span>
</div>
<ul className="space-y-1 pe-2 text-[11.5px] leading-relaxed text-muted list-disc list-inside">
<li>مشاهده فعالیت‌ها و تسک‌های مربوط به پروژه‌های عضویت کاربر</li>
<li>به‌روزرسانی درصد پیشرفت فیزیکی و وضعیت انجام تسک‌ها</li>
<li>تکمیل چک‌لیست‌های کنترل کیفیت (QC) و ثبت گزارش‌های اجرایی روزانه</li>
</ul>
</div>
</div>
</div>
</div>
{/* نوار توضیحات تکمیلی و راهنمای عمومی */}
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 pt-3 border-t border-line text-[11.5px]">
<div className="flex items-start gap-2.5 rounded-lg border border-line/60 bg-canvas/30 p-2.5">
<span className="text-[16px] leading-none">👑</span>
<div>
<span className="font-bold text-foreground">دسترسی ادمین (Admin):</span>
<p className="mt-0.5 text-muted leading-relaxed">
کاربر با نقش ادمین بالاترین سطح دسترسی سیستمی را داشته و به تمامی پنل‌ها و پروژه‌ها دسترسی کامل دارد و نیازی به تخصیص جداگانه پنل‌ها ندارد.
</p>
</div>
</div>
<div className="flex items-start gap-2.5 rounded-lg border border-line/60 bg-canvas/30 p-2.5">
<span className="text-[16px] leading-none">🎯</span>
<div>
<span className="font-bold text-foreground">کارکرد «پروژه‌های عضویت»:</span>
<p className="mt-0.5 text-muted leading-relaxed">
هرگاه برای کاربری در هر پنلی سطح «محدود» انتخاب شود، دامنه داده‌ها و اختیارات وی دقیقاً به پروژه‌های انتخابی در ستون «پروژه‌های عضویت» محدود خواهد شد.
</p>
</div>
</div>
</div>
</div> </div>
</PageLayout> </PageLayout>
</div> </div>
+10 -4
View File
@@ -14,6 +14,8 @@ export interface Step4Props {
onAssign: (tempId: string, proj: string) => void; onAssign: (tempId: string, proj: string) => void;
onUnassign: (tempId: string, proj: string) => void; onUnassign: (tempId: string, proj: string) => void;
onAddProject: (name: string) => void; onAddProject: (name: string) => void;
/** سطح limited تدارکات: افزودن پروژه جدید ممنوع */
canAddProject?: boolean;
} }
const ALL_KEY = "__all__"; const ALL_KEY = "__all__";
@@ -111,7 +113,7 @@ function DroppableProject({
} }
// مرحله ۴ — تخصیص پروژه‌ها با درگ‌اندروپ (یا کلیک-قلم بعد کلیک-پروژه) // مرحله ۴ — تخصیص پروژه‌ها با درگ‌اندروپ (یا کلیک-قلم بعد کلیک-پروژه)
export function Step4Projects({ items, projects, assignment, onAssign, onUnassign, onAddProject }: Step4Props) { export function Step4Projects({ items, projects, assignment, onAssign, onUnassign, onAddProject, canAddProject = true }: Step4Props) {
const [picked, setPicked] = useState<string | null>(null); const [picked, setPicked] = useState<string | null>(null);
const [newProj, setNewProj] = useState(""); const [newProj, setNewProj] = useState("");
@@ -211,10 +213,14 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
/> />
))} ))}
{!projects.length && ( {!projects.length && (
<div className="dd-empty">هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید.</div> <div className="dd-empty">
{canAddProject
? "هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید."
: "هنوز پروژه‌ای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید."}
</div>
)} )}
{/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها */} {/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها (سطح limited: ممنوع) */}
<div className="mt-3 rounded-md border border-dashed border-line p-2"> <div className="mt-3 rounded-md border border-dashed border-line p-2" hidden={!canAddProject}>
<div className="mb-1.5 text-[11.5px] font-semibold text-muted">افزودن پروژه‌ی جدید:</div> <div className="mb-1.5 text-[11.5px] font-semibold text-muted">افزودن پروژه‌ی جدید:</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
+64 -7
View File
@@ -22,6 +22,7 @@ import { normalize } from "../../lib/normalize";
import { faNum } from "../../lib/formatters"; import { faNum } from "../../lib/formatters";
import { matchMaterial } from "../../lib/quoteUtils"; import { matchMaterial } from "../../lib/quoteUtils";
import { Header, PageLayout } from "../../components/layout/Header"; import { Header, PageLayout } from "../../components/layout/Header";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { Stepper } from "./Stepper"; import { Stepper } from "./Stepper";
import { Step1TopicSupplier } from "./Step1TopicSupplier"; import { Step1TopicSupplier } from "./Step1TopicSupplier";
import { Step2LineItems, type ItemDraft, type MaterialRec } from "./Step2LineItems"; import { Step2LineItems, type ItemDraft, type MaterialRec } from "./Step2LineItems";
@@ -36,6 +37,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { useAuthStore } from "../../store/auth";
const TOTAL_STEPS = 5; const TOTAL_STEPS = 5;
@@ -49,6 +51,23 @@ export function WizardPage() {
const suppliers = useSuppliers(); const suppliers = useSuppliers();
const materials = useMaterials(); const materials = useMaterials();
const projects = useProjects(); const projects = useProjects();
// سطح limited تدارکات: استعلام فقط برای پروژه‌های عضویت — بدون افزودن پروژه جدید
const user = useAuthStore((s) => s.user);
const isLimitedProcurement =
user?.panels?.procurement === "limited" && user?.role !== "admin";
const memberProjectNames = useMemo(() => {
const allowedIds = new Set([
...(user?.member_project_ids ?? []),
...(user?.projects ?? []).map((p) => p.id),
...(user?.project_id ? [user.project_id] : []),
]);
const matched = projects
.filter((p) => allowedIds.has(p.id))
.map((p) => p.name);
const directNames = (user?.projects ?? []).map((p) => p.name);
if (user?.project_name) directNames.push(user.project_name);
return [...new Set([...matched, ...directNames])];
}, [projects, user]);
const topics = useAppStore((s) => s.topics); const topics = useAppStore((s) => s.topics);
const createQuote = useCreateQuote(); const createQuote = useCreateQuote();
const addSupplier = useAddSupplier(); const addSupplier = useAddSupplier();
@@ -83,12 +102,12 @@ export function WizardPage() {
const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]); const [emptyFields, setEmptyFields] = useState<{ label: string; step: number }[]>([]);
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
// ---------- prefill از لینک‌ها (?topic=&supplier=) ---------- // ---------- prefill از لینک‌ها (?topic=&supplier=&project=) ----------
useEffect(() => { useEffect(() => {
const t = params.get("topic"); const t = params.get("topic");
if (!t) return; if (t) {
const topic = topicByName(t, topics); const topic = topicByName(t, topics);
if (!topic) return; if (topic) {
setSelectedTopic(topic.name); setSelectedTopic(topic.name);
const sid = params.get("supplier"); const sid = params.get("supplier");
if (sid) { if (sid) {
@@ -98,7 +117,22 @@ export function WizardPage() {
setSupplierInvalid(false); setSupplierInvalid(false);
} }
} }
}, [params, suppliers, topics]); }
}
const proj = params.get("project");
if (proj) {
setAssignment((cur) => {
const next = { ...cur };
for (const it of items) {
if (!next[it.tempId]?.length) {
next[it.tempId] = [proj];
}
}
return next;
});
}
}, [params, suppliers, topics, items]);
// ---------- اعتبارسنجی قبل از ترک هر مرحله ---------- // ---------- اعتبارسنجی قبل از ترک هر مرحله ----------
const completedSteps = useMemo(() => { const completedSteps = useMemo(() => {
@@ -131,13 +165,34 @@ export function WizardPage() {
// ---------- پروژه‌ها ---------- // ---------- پروژه‌ها ----------
const projectNames = useMemo(() => { const projectNames = useMemo(() => {
if (isLimitedProcurement) {
return memberProjectNames;
}
const names = new Set<string>(projects.map((p) => p.name)); const names = new Set<string>(projects.map((p) => p.name));
quotes.forEach((q) => { quotes.forEach((q) => {
if (q.project) names.add(q.project); if (q.project) names.add(q.project);
(q.projects || []).forEach((n) => names.add(n)); (q.projects || []).forEach((n) => names.add(n));
}); });
return [...names]; return [...names];
}, [projects, quotes]); }, [projects, quotes, isLimitedProcurement, memberProjectNames]);
// سطح محدود: در صورت داشتن یک پروژه، خودکار اقلام به آن پروژه تخصیص می‌یابند
useEffect(() => {
if (isLimitedProcurement && memberProjectNames.length === 1) {
const sole = memberProjectNames[0];
setAssignment((cur) => {
let changed = false;
const next = { ...cur };
for (const it of items) {
if (!next[it.tempId]?.length) {
next[it.tempId] = [sole];
changed = true;
}
}
return changed ? next : cur;
});
}
}, [isLimitedProcurement, memberProjectNames, items]);
const assign = (tempId: string, proj: string) => { const assign = (tempId: string, proj: string) => {
setAssignment((a) => { setAssignment((a) => {
@@ -415,8 +470,9 @@ export function WizardPage() {
<Header <Header
title="افزودن پیشنهاد جدید" title="افزودن پیشنهاد جدید"
sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید" sub="اطلاعات را از تماس با تأمین‌کننده وارد کنید"
backTo="/" backTo={isLimitedProcurement ? "/requester" : "/"}
backLabel="بازگشت به دفتر" backLabel={isLimitedProcurement ? "بازگشت به استعلام‌ها" : "بازگشت به دفتر"}
menu={<AppSwitcherMenu activeModule="procurement" />}
/> />
<PageLayout> <PageLayout>
{/* نوار مراحل استپر */} {/* نوار مراحل استپر */}
@@ -471,6 +527,7 @@ export function WizardPage() {
assignment={assignment} assignment={assignment}
onAssign={assign} onAssign={assign}
onUnassign={unassign} onUnassign={unassign}
canAddProject={!isLimitedProcurement}
onAddProject={(name) => { onAddProject={(name) => {
void addProject.mutateAsync({ id: nextId("p", projects), name }); void addProject.mutateAsync({ id: nextId("p", projects), name });
}} }}
+3
View File
@@ -17,6 +17,9 @@ export interface AuthUser {
phone: string | null; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
role_label: string | null; role_label: string | null;
// لایه پنل‌ها (جایگزین نقش‌ها)
panels?: Record<string, string>;
member_project_ids?: string[];
project_id: string | null; // پروژه‌ی فعال (PM) — با سوییچر عوض می‌شود project_id: string | null; // پروژه‌ی فعال (PM) — با سوییچر عوض می‌شود
project_name: string | null; project_name: string | null;
projects?: { id: string; name: string }[]; // همه‌ی پروژه‌های تخصیص‌یافته (1:N) projects?: { id: string; name: string }[]; // همه‌ی پروژه‌های تخصیص‌یافته (1:N)
+20
View File
@@ -637,3 +637,23 @@ ALTER TABLE projects ADD COLUMN IF NOT EXISTS progress DOUBLE PRECISION DEFAULT
ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now(); ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
COMMIT; COMMIT;
-- ---------- PANEL ACCESS (جایگزین مدل نقش) ----------
-- تخصیص پنل + سطح دسترسی به ازای هر کاربر و عضویت M:N پروژه‌ای
CREATE TABLE IF NOT EXISTS user_panels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
panel TEXT NOT NULL CHECK (panel IN ('procurement', 'inventory', 'pm')),
level TEXT NOT NULL CHECK (level IN ('full', 'limited')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, panel)
);
CREATE TABLE IF NOT EXISTS user_project_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, project_id)
);
COMMIT;