From 387edfb1ab5552eaec278cd0b6a26d86a2ba6297 Mon Sep 17 00:00:00 2001 From: Milad Date: Sat, 26 Sep 2026 18:51:37 +0330 Subject: [PATCH] feat(org,rbac): dedicate organization panel, enforce role permissions, subheader info, and fix supervisor project sync --- backend/app/auth.py | 9 +- backend/app/models/procurement.py | 12 + backend/app/routers/auth.py | 4 +- backend/app/routers/inventory.py | 46 ++- backend/app/routers/projects.py | 54 ++- backend/app/routers/users.py | 59 ++- backend/app/schemas/procurement.py | 5 + docker-compose.yml | 3 + frontend/src/App.tsx | 93 ++++- frontend/src/components/AppSwitcherMenu.tsx | 95 ++--- frontend/src/components/RequirePmProjects.tsx | 6 +- frontend/src/components/layout/Header.tsx | 23 +- .../src/components/layout/MobileTabBar.tsx | 5 +- frontend/src/hooks/index.ts | 2 +- frontend/src/hooks/useProjects.ts | 12 + frontend/src/lib/api.ts | 49 ++- frontend/src/lib/types.ts | 6 + frontend/src/pages/Auth/LoginPage.tsx | 8 +- .../src/pages/Dashboard/DashboardPage.tsx | 2 +- .../pages/Inventory/ChangeInventoryPage.tsx | 19 +- .../src/pages/Inventory/InventoryPage.tsx | 26 +- .../src/pages/Inventory/InventoryTopNav.tsx | 34 +- .../src/pages/Inventory/OrganizationPage.tsx | 354 ------------------ .../src/pages/Inventory/WarehousesPage.tsx | 48 +-- frontend/src/pages/Inventory/panelMenu.ts | 50 ++- .../src/pages/Inventory/useInventoryData.ts | 4 +- .../pages/Organization/OrganizationPage.tsx | 291 ++++++++++++++ frontend/src/pages/Projects/ProjectsPage.tsx | 231 ++++++++---- frontend/src/store/auth.ts | 1 + frontend/src/store/index.ts | 2 + sql/migration.sql | 11 + 31 files changed, 921 insertions(+), 643 deletions(-) delete mode 100644 frontend/src/pages/Inventory/OrganizationPage.tsx create mode 100644 frontend/src/pages/Organization/OrganizationPage.tsx diff --git a/backend/app/auth.py b/backend/app/auth.py index e5972fa..b284525 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -119,10 +119,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: if not casdoor_id: raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization") + username = str(claims.get("name") or claims.get("preferred_username") or "") user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id)) if user is None: user = User( casdoor_id=casdoor_id, + username=username or None, first_name=str( claims.get("firstName") or claims.get("firstname") @@ -135,9 +137,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: ) db.add(user) await db.flush() + elif username and not user.username: + user.username = username + await db.flush() project_ids: list = [] - if user.role == "pm": + if user.role in ("supervisor", "pm"): projects = ( await db.execute( select(Project) @@ -159,7 +164,7 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: async def pm_project_ids(db: AsyncSession, user: User) -> list: - """Every project id managed by the given PM user (oldest first).""" + """Every project id managed by the given PM/supervisor user (oldest first).""" rows = ( await db.execute( select(Project.id) diff --git a/backend/app/models/procurement.py b/backend/app/models/procurement.py index 3860c87..523649c 100644 --- a/backend/app/models/procurement.py +++ b/backend/app/models/procurement.py @@ -58,6 +58,7 @@ class User(Base): id: Mapped[uuid.UUID] = _uuid_pk() casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True) + username: Mapped[str | None] = mapped_column(Text) first_name: Mapped[str] = mapped_column(Text, nullable=False) last_name: Mapped[str] = mapped_column(Text, nullable=False) phone: Mapped[str | None] = mapped_column(Text) @@ -148,12 +149,23 @@ class Project(Base): id: Mapped[uuid.UUID] = _uuid_pk() name: Mapped[str] = mapped_column(Text, nullable=False) description: Mapped[str | None] = mapped_column(Text) + location: Mapped[str | None] = mapped_column(Text, server_default=text("''")) + project_manager: Mapped[str | None] = mapped_column(Text, server_default=text("''")) start_date: Mapped[date | None] = mapped_column(Date) + planned_start: Mapped[date | None] = mapped_column(Date) + planned_end: Mapped[date | None] = mapped_column(Date) + actual_start: Mapped[date | None] = mapped_column(Date) + actual_end: Mapped[date | None] = mapped_column(Date) + status: Mapped[str | None] = mapped_column(Text, server_default=text("'ACTIVE'")) + progress: Mapped[float | None] = mapped_column(Numeric(5, 2), server_default=text("0.0")) # v2.6: the single PM responsible for this project (confirms arrivals). pm_user_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL") ) created_at: Mapped[datetime] = _timestamptz() + updated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) class Quote(Base): diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 2005e66..b3bb3c6 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -22,7 +22,7 @@ from app.config import settings from app.db.engine import get_db from app.routers.users import _user_projects, user_to_dict from app.services import analytics -from app.utils.errors import ServiceUnavailableError, UnauthorizedError +from app.utils.errors import ForbiddenError, ServiceUnavailableError, UnauthorizedError from app.utils.ratelimit import limiter logger = logging.getLogger("procurement.auth") @@ -78,6 +78,8 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe claims = await _verify_token(access_token) ctx = await context_from_claims(claims, db) projects = await _user_projects(db, ctx.user) + if ctx.role == "supervisor" and not projects: + raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید") await analytics.track( "login_success", {"username": payload.username, "role": ctx.role}, diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py index 468998e..416c4c3 100644 --- a/backend/app/routers/inventory.py +++ b/backend/app/routers/inventory.py @@ -77,23 +77,21 @@ async def inventory_scope( if user is not None: if user.role is None: raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") - # v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقط‌خواندنی به پنل انبار. - if user.role == "procurement_manager": - return Scope(role="admin", project_id=project_id, actor=user.display_name) - if user.role not in ("pm", "admin"): - raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role") - if user.role == "pm": + # مدیر تدارکات و مدیر پروژه: دسترسی کامل به پنل انبار، اما فقط‌خواندنی در تغییر موجودی + if user.role in ("procurement_manager", "pm"): + return Scope(role="manager", project_id=project_id, actor=user.display_name) + if user.role == "supervisor": allowed = list(user.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="pm", project_id=project_id, actor=user.display_name) - if not allowed: - raise ValidationError( - "کاربر PM به هیچ پروژه‌ای متصل نیست (projects.pm_user_id)", field="project_id" - ) - return Scope(role="pm", project_id=allowed[0], actor=user.display_name) - return Scope(role="admin", project_id=project_id, actor=user.display_name) + return Scope(role="supervisor", project_id=project_id, actor=user.display_name) + return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name) + if user.role == "admin": + return Scope(role="admin", project_id=project_id, actor=user.display_name) + raise ValidationError("نقش نامعتبر است", field="role") # Dev fallback: header-based identity (fork's placeholder mode) — only # when DEV_LOGIN_ENABLED is explicitly turned on (local testing without @@ -128,9 +126,9 @@ async def inventory_scope( return Scope(role=role, project_id=project_id, actor=actor) -def _require_pm(scope: Scope) -> None: - if scope.role != ROLE_PM: - raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست") +def _require_inventory_modifier(scope: Scope) -> None: + if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM): + raise ForbiddenError("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند") # --------------------------------------------------------------------------- @@ -215,8 +213,8 @@ async def list_warehouses( db: AsyncSession = Depends(get_db), ): stmt = select(Warehouse) - if scope.role == ROLE_PM: - stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope + if scope.role in (ROLE_PM, "supervisor"): + stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM / supervisor scope elif project_id is not None: stmt = stmt.where(Warehouse.project_id == project_id) stmt = stmt.order_by(Warehouse.name) @@ -323,7 +321,7 @@ async def list_inventory( db: AsyncSession = Depends(get_db), ): stmt = select(InventoryItem) - if scope.role == ROLE_PM: + if scope.role in (ROLE_PM, "supervisor"): stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( Warehouse.project_id == scope.project_id ) @@ -388,7 +386,7 @@ async def list_transactions( stmt = select(InventoryTransaction).join( InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id ).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id) - if scope.role == ROLE_PM: + if scope.role in (ROLE_PM, "supervisor"): stmt = stmt.where(Warehouse.project_id == scope.project_id) elif project_id is not None: stmt = stmt.where(Warehouse.project_id == project_id) @@ -454,7 +452,7 @@ async def inventory_history( if item is None: raise NotFoundError("موجودی یافت نشد") wh = await get_warehouse_or_404(db, item.warehouse_id) - if scope.role == ROLE_PM and wh.project_id != scope.project_id: + if scope.role in (ROLE_PM, "supervisor") and wh.project_id != scope.project_id: raise ForbiddenError("این موجودی متعلق به پروژه شما نیست") txs = (await db.execute( select(InventoryTransaction) @@ -474,17 +472,17 @@ async def create_transaction( scope: Scope = Depends(inventory_scope), db: AsyncSession = Depends(get_db), ): - _require_pm(scope) + _require_inventory_modifier(scope) # No explicit db.begin(): the session may already be inside an implicit # transaction (inventory_scope read from it in the JWT path); get_db # commits on success / rolls back on failure. with nullcontext(): wh = await get_warehouse_or_404(db, payload.warehouse_id) - if wh.project_id != scope.project_id: + if scope.project_id is not None and wh.project_id != scope.project_id: raise ForbiddenError("این انبار متعلق به پروژه شما نیست") if payload.dest_warehouse_id is not None: dest = await get_warehouse_or_404(db, payload.dest_warehouse_id) - if dest.project_id != scope.project_id: + if scope.project_id is not None and dest.project_id != scope.project_id: raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست") txs = await apply_transaction(db, payload, performed_by=scope.actor) await audit_record( diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index abf791a..7e6b69d 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -34,8 +34,12 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict: d = { "id": str(project.id), "name": project.name, - "description": project.description, + "location": project.location or "", + "project_manager": project.project_manager or "", + "description": project.description or "", "start_date": to_date(project.start_date), + "status": project.status or "ACTIVE", + "progress": float(project.progress) if project.progress is not None else 0.0, "pm_user_id": str(project.pm_user_id) if project.pm_user_id else None, "created_at": to_iso(project.created_at), } @@ -43,6 +47,8 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict: pm = await db.get(User, project.pm_user_id) if pm: d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id + elif project.project_manager: + d["pm_name"] = project.project_manager return d @@ -56,7 +62,14 @@ async def list_projects( ): stmt = select(Project) if q: - stmt = stmt.where(or_(Project.name.ilike(f"%{q}%"), Project.description.ilike(f"%{q}%"))) + stmt = stmt.where( + or_( + Project.name.ilike(f"%{q}%"), + Project.description.ilike(f"%{q}%"), + Project.location.ilike(f"%{q}%"), + Project.project_manager.ilike(f"%{q}%"), + ) + ) stmt = sort_query(stmt, Project, sort) items, meta = await paginate(stmt, page, page_size, db) pm_map = await _pm_map(db, items) @@ -65,14 +78,20 @@ async def list_projects( d = { "id": str(p.id), "name": p.name, - "description": p.description, + "location": p.location or "", + "project_manager": p.project_manager or "", + "description": p.description or "", "start_date": to_date(p.start_date), + "status": p.status or "ACTIVE", + "progress": float(p.progress) if p.progress is not None else 0.0, "pm_user_id": str(p.pm_user_id) if p.pm_user_id else None, "created_at": to_iso(p.created_at), } pm = pm_map.get(p.pm_user_id) if pm: d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id + elif p.project_manager: + d["pm_name"] = p.project_manager data.append(d) return {"data": data, "meta": meta} @@ -84,9 +103,13 @@ async def create_project( _auth=Depends(_procurement_writer), ): project = Project( - name=payload.name, - description=payload.description, + name=payload.name.strip(), + location=payload.location.strip() if payload.location else "", + project_manager=payload.project_manager.strip() if payload.project_manager else "", + description=payload.description.strip() if payload.description else "", start_date=payload.start_date, + planned_start=payload.start_date, + status="ACTIVE", ) db.add(project) await db.flush() @@ -114,26 +137,35 @@ async def update_project( if project is None: raise NotFoundError("پروژه یافت نشد") if payload.name is not None: - project.name = payload.name + project.name = payload.name.strip() + if payload.location is not None: + project.location = payload.location.strip() + if payload.project_manager is not None: + project.project_manager = payload.project_manager.strip() if payload.description is not None: - project.description = payload.description + project.description = payload.description.strip() if payload.start_date is not None: project.start_date = payload.start_date + project.planned_start = payload.start_date + if payload.status is not None: + project.status = payload.status # PM assignment (1:N): one PM per project, a PM may own many projects. # Admin / procurement manager only (same rule as the Organization panel). if "pm_user_id" in payload.model_fields_set: role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower() - if role not in ("admin", "procurement_manager"): - raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است") + if role not in ("admin", "procurement_manager", "pm"): + raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است") if payload.pm_user_id is None: project.pm_user_id = None else: pm_user = await db.get(User, payload.pm_user_id) if pm_user is None: raise NotFoundError("کاربر یافت نشد", field="pm_user_id") - if pm_user.role != "pm": - raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id") + if pm_user.role not in ("supervisor", "pm"): + raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="pm_user_id") project.pm_user_id = pm_user.id + if not project.project_manager: + project.project_manager = f"{pm_user.first_name} {pm_user.last_name}".strip() await db.flush() return {"data": await project_to_dict(db, project)} diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index f6517e6..ecd0eef 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -17,7 +17,7 @@ import uuid import httpx from fastapi import APIRouter, Depends -from sqlalchemy import select, update +from sqlalchemy import select, update, text from sqlalchemy.ext.asyncio import AsyncSession from app.auth import UserContext, get_current_user, require_role @@ -54,6 +54,7 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict: return { "id": str(user.id), "casdoor_id": user.casdoor_id, + "username": user.username, "first_name": user.first_name, "last_name": user.last_name, "phone": user.phone, @@ -67,15 +68,15 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict: async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext: - """Admin OR procurement manager (the same person) — organization management.""" - if ctx.role not in ("admin", "procurement_manager"): + """Admin, procurement manager, or project manager — organization management.""" + if ctx.role not in ("admin", "procurement_manager", "pm"): raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست") return ctx async def _user_projects(db: AsyncSession, user: User) -> list[Project]: - """Every project the user manages as PM (oldest first; [] for non-PMs).""" - if user.role != "pm": + """Every project assigned to the user (supervisor or PM) (oldest first).""" + if user.role not in ("supervisor", "pm"): return [] rows = ( await db.execute( @@ -150,13 +151,13 @@ async def list_users( ): """Every user (with role + assigned projects) — Organization panel.""" users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() - pm_ids = {u.id for u in users if u.role == "pm"} + supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")} by_user: dict = {} - if pm_ids: + if supervisor_ids: projs = ( await db.execute( select(Project) - .where(Project.pm_user_id.in_(pm_ids)) + .where(Project.pm_user_id.in_(supervisor_ids)) .order_by(Project.created_at, Project.id) ) ).scalars().all() @@ -206,7 +207,7 @@ async def update_user_role( if user is None: raise NotFoundError("کاربر یافت نشد") previous_role = user.role - if user.role == "pm" and payload.role != "pm": + if user.role in ("supervisor", "pm") and payload.role not in ("supervisor", "pm"): await _unassign_projects(db, user.id) user.role = payload.role await db.flush() @@ -233,8 +234,15 @@ async def sync_casdoor_users( continue existing = await db.scalar(select(User).where(User.casdoor_id == cid)) if existing is None: + uname = str(cu.get("name") or cu.get("preferred_username") or "") or None + if uname: + by_uname = await db.scalar(select(User).where(User.username == uname)) + if by_uname is not None: + by_uname.casdoor_id = cid + continue db.add(User( casdoor_id=cid, + username=uname, first_name=str( cu.get("firstName") or cu.get("displayName") @@ -245,16 +253,20 @@ async def sync_casdoor_users( phone=str(cu.get("phone") or "") or None, )) created += 1 + elif not existing.username: + uname = str(cu.get("name") or cu.get("preferred_username") or "") or None + if uname: + existing.username = uname await db.flush() users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() - pm_ids = {u.id for u in users if u.role == "pm"} + supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")} by_user: dict = {} - if pm_ids: + if supervisor_ids: projs = ( await db.execute( select(Project) - .where(Project.pm_user_id.in_(pm_ids)) + .where(Project.pm_user_id.in_(supervisor_ids)) .order_by(Project.created_at, Project.id) ) ).scalars().all() @@ -284,11 +296,16 @@ async def assign_user_project( user = await db.get(User, user_id) if user is None: raise NotFoundError("کاربر یافت نشد") - if user.role != "pm": - raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role") + if user.role not in ("supervisor", "pm"): + raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role") if payload.project_id is None: await _unassign_projects(db, user.id) + if user.username: + await db.execute( + text("DELETE FROM project_assignments WHERE user_id IN (SELECT id FROM pm_users WHERE username = :uname)"), + {"uname": user.username} + ) await db.flush() return {"data": user_to_dict(user)} @@ -296,5 +313,19 @@ async def assign_user_project( if project is None: raise NotFoundError("پروژه یافت نشد", field="project_id") project.pm_user_id = user.id + if user.username: + await db.execute( + text(""" + INSERT INTO project_assignments (id, user_id, project_id) + SELECT gen_random_uuid(), pu.id, :pid + FROM pm_users pu + WHERE pu.username = :uname + AND NOT EXISTS ( + SELECT 1 FROM project_assignments pa + WHERE pa.user_id = pu.id AND pa.project_id = :pid + ) + """), + {"pid": project.id, "uname": user.username} + ) await db.flush() return {"data": user_to_dict(user, await _user_projects(db, user))} diff --git a/backend/app/schemas/procurement.py b/backend/app/schemas/procurement.py index 666c669..77e39e7 100644 --- a/backend/app/schemas/procurement.py +++ b/backend/app/schemas/procurement.py @@ -44,14 +44,19 @@ class SupplierMaterialCreate(APIModel): class ProjectCreate(APIModel): name: str = Field(min_length=1) + location: str | None = None + project_manager: str | None = None description: str | None = None start_date: date | None = None class ProjectUpdate(APIModel): name: str | None = Field(default=None, min_length=1) + location: str | None = None + project_manager: str | None = None description: str | None = None start_date: date | None = None + status: str | None = None # PM assignment (1:N — one PM per project, a PM may own many projects). # Admin / procurement manager only; None unassigns this project's PM. pm_user_id: UUID | None = None diff --git a/docker-compose.yml b/docker-compose.yml index b2187e0..fbf2548 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -248,6 +248,9 @@ services: ports: - "8000:8000" + volumes: + - ./backend/app:/srv/backend/app + networks: - traefik_network - default diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8794d12..384f91d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,7 +38,7 @@ const WarehousesPage = lazy(() => import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })), ); const OrganizationPage = lazy(() => - import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })), + import("./pages/Organization/OrganizationPage").then((m) => ({ default: m.OrganizationPage })), ); const AdminPanelPage = lazy(() => import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })), @@ -53,35 +53,60 @@ function Loading() { } // همه‌ی صفحات پشت ورود هستند — بدون نشست، به صفحه‌ی ورود می‌رویم. -// سرپرست پروژه (فقطِ پنل PM) از هر مسیری به پنل PM هدایت می‌شود. function RequireAuth({ children }: { children: React.ReactNode }) { const user = useAuthStore((s) => s.user); const { pathname } = useLocation(); if (!user) return ; - if (user.role === "supervisor" && pathname !== "/panel/pm") { - return ; + if (user.role === "supervisor") { + // اگر سرپرست پروژه‌ای نداشته باشد، دسترسی مسدود می‌شود + if (!user.projects || user.projects.length === 0) { + return ( +
+
+

پروژه تخصیص داده نشده است

+

+ شما به عنوان سرپرست پروژه در سیستم ثبت شده‌اید اما هنوز هیچ پروژه‌ای به شما تخصیص داده نشده است. + تا زمان تخصیص پروژه توسط مدیر سیستم، اجازه ورود به سامانه را ندارید. +

+ +
+
+ ); + } + // سرپرست پروژه فقط به انبار و PM دسترسی دارد + if (!pathname.startsWith("/inventory") && pathname !== "/panel/pm") { + return ; + } } return <>{children}; } -// صفحه‌ی اصلی بر اساس نقش: مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر +// صفحه‌ی اصلی بر اساس نقش: سرپرست پروژه → پنل مدیریت پروژه (PM)؛ مدیران و ادمین → دفتر تدارکات function HomeRoute() { const user = useAuthStore((s) => s.user); - if (user?.role === "pm") return ; + if (user?.role === "supervisor") return ; return ; } -// دسترسی ایجاد استعلام/پیشنهاد (ویزارد) فقط برای مدیر تدارکات و ادمین +// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): مدیر تدارکات، مدیر پروژه و ادمین function RequireProcurementWrite({ children }: { children: React.ReactNode }) { const user = useAuthStore((s) => s.user); - if (user?.role !== "admin" && user?.role !== "procurement_manager") { - return ; + if (user?.role !== "admin" && user?.role !== "procurement_manager" && user?.role !== "pm") { + return ; } return <>{children}; } -// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود و -// اینجا به‌صورت iframe تمام‌صفحه داخل پوسته ERP نمایش داده می‌شود. +// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود function PmPanelPage() { return (