From 0ea032296703d2af6c7f09afe9d38710d9ebd1d0 Mon Sep 17 00:00:00 2001 From: MMD Date: Thu, 1 Oct 2026 15:24:56 +0330 Subject: [PATCH] Revert 253c922: restore the PM/RBAC work to the integration Reverting the production revert so the pm branch can merge cleanly on top of the restored RBAC base. Conflicts against newer integration work resolved by combining: - App.tsx: v7's bot-draft wizard bypass + login returnUrl kept alongside the supervisor gating (no-project block screen, panel scoping) - LoginPage: returnUrl navigation kept, supervisor routing added - migration.sql: bot_drafts table kept, unified-projects ALTERs restored --- backend/app/auth.py | 9 +- backend/app/db/init_db.py | 2 +- backend/app/models/procurement.py | 14 +- backend/app/routers/attribute_definitions.py | 19 +- backend/app/routers/auth.py | 4 +- backend/app/routers/brand_materials.py | 21 +- backend/app/routers/brands.py | 21 +- backend/app/routers/files.py | 7 +- backend/app/routers/inventory.py | 46 ++- backend/app/routers/item_receipts.py | 18 +- backend/app/routers/item_termsheets.py | 21 +- backend/app/routers/line_item_specs.py | 10 +- backend/app/routers/line_items.py | 22 +- backend/app/routers/material_categories.py | 21 +- backend/app/routers/materials.py | 43 ++- backend/app/routers/projects.py | 70 +++- backend/app/routers/quote_events.py | 9 +- backend/app/routers/quotes.py | 61 ++- backend/app/routers/schedule_steps.py | 23 +- backend/app/routers/standard_conversions.py | 21 +- backend/app/routers/supplier_materials.py | 14 +- backend/app/routers/suppliers.py | 21 +- backend/app/routers/termsheets.py | 28 +- backend/app/routers/units.py | 21 +- backend/app/routers/users.py | 61 ++- backend/app/schemas/procurement.py | 9 +- docker-compose.yml | 3 + frontend/src/App.tsx | 122 +++++- frontend/src/components/AppSwitcherMenu.tsx | 88 +++-- frontend/src/components/PanelMenu.tsx | 17 + frontend/src/components/RequirePmProjects.tsx | 6 +- frontend/src/components/layout/Header.tsx | 23 +- .../src/components/layout/MobileTabBar.tsx | 9 +- frontend/src/hooks/index.ts | 2 +- frontend/src/hooks/useProjects.ts | 12 + frontend/src/lib/api.ts | 53 ++- frontend/src/lib/types.ts | 6 + frontend/src/pages/Admin/AdminPanel.tsx | 59 ++- frontend/src/pages/Auth/LoginPage.tsx | 11 +- .../src/pages/Dashboard/DashboardPage.tsx | 39 +- frontend/src/pages/Dashboard/LedgerTable.tsx | 3 + frontend/src/pages/Dashboard/QuoteCard.tsx | 35 +- .../pages/Inventory/ChangeInventoryPage.tsx | 19 +- .../src/pages/Inventory/InventoryPage.tsx | 28 +- .../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 | 229 ++++++++--- frontend/src/pages/Suppliers/CategoryPage.tsx | 15 +- .../pages/Suppliers/SupplierDetailPage.tsx | 41 +- frontend/src/store/auth.ts | 3 +- frontend/src/store/index.ts | 2 + sql/migration.sql | 12 + 56 files changed, 1481 insertions(+), 753 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/db/init_db.py b/backend/app/db/init_db.py index f1dd67b..14b96a2 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -174,7 +174,7 @@ _V26_DDL = [ """, """ DO $$ BEGIN - CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin'); + CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin', 'supervisor'); EXCEPTION WHEN duplicate_object THEN NULL; END $$ """, """ diff --git a/backend/app/models/procurement.py b/backend/app/models/procurement.py index f41b355..523649c 100644 --- a/backend/app/models/procurement.py +++ b/backend/app/models/procurement.py @@ -26,7 +26,7 @@ from app.db.base import Base from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk user_role_enum = Enum( - "pm", "procurement_manager", "admin", + "pm", "procurement_manager", "admin", "supervisor", name="user_role", native_enum=True, ) quote_status_enum = Enum( @@ -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/attribute_definitions.py b/backend/app/routers/attribute_definitions.py index 8e0c0ad..6124262 100644 --- a/backend/app/routers/attribute_definitions.py +++ b/backend/app/routers/attribute_definitions.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit from app.models.procurement import LineItemSpec @@ -13,6 +14,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"]) +_procurement_writer = require_role("admin", "procurement_manager") def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict: @@ -69,7 +71,11 @@ async def list_attribute_definitions( @router.post("", status_code=201) -async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)): +async def create_attribute_definition( + payload: AttributeDefinitionCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key)) if duplicate is not None: raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key") @@ -102,7 +108,10 @@ async def get_attribute_definition( @router.patch("/{definition_id}") async def update_attribute_definition( - definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db) + definition_id, + payload: AttributeDefinitionUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): ad = await db.get(AttributeDefinition, definition_id) if ad is None: @@ -136,7 +145,11 @@ async def update_attribute_definition( @router.delete("/{definition_id}") -async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)): +async def delete_attribute_definition( + definition_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): ad = await db.get(AttributeDefinition, definition_id) if ad is None: raise NotFoundError("تعریف ویژگی یافت نشد") 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/brand_materials.py b/backend/app/routers/brand_materials.py index 1200f96..13f96d8 100644 --- a/backend/app/routers/brand_materials.py +++ b/backend/app/routers/brand_materials.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import Brand, BrandMaterial, Material from app.models.procurement import LineItem @@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/brand-materials", tags=["brand-materials"]) +_procurement_writer = require_role("admin", "procurement_manager") def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict: @@ -70,7 +72,11 @@ async def list_brand_materials( @router.post("", status_code=201) -async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)): +async def create_brand_material( + payload: BrandMaterialCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): if await db.get(Brand, payload.brand_id) is None: raise NotFoundError("برند یافت نشد", field="brand_id") if await db.get(Material, payload.material_id) is None: @@ -107,7 +113,12 @@ async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession @router.patch("/{bm_id}") -async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)): +async def update_brand_material( + bm_id, + payload: BrandMaterialUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): bm = await db.get(BrandMaterial, bm_id) if bm is None: raise NotFoundError("ارتباط برند-متریال یافت نشد") @@ -120,7 +131,11 @@ async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSe @router.delete("/{bm_id}") -async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)): +async def delete_brand_material( + bm_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): bm = await db.get(BrandMaterial, bm_id) if bm is None: raise NotFoundError("ارتباط برند-متریال یافت نشد") diff --git a/backend/app/routers/brands.py b/backend/app/routers/brands.py index 9173c48..1112cc0 100644 --- a/backend/app/routers/brands.py +++ b/backend/app/routers/brands.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import Brand, BrandMaterial from app.schemas.catalog import BrandCreate, BrandUpdate @@ -11,6 +12,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/brands", tags=["brands"]) +_procurement_writer = require_role("admin", "procurement_manager") def brand_to_dict(brand: Brand) -> dict: @@ -42,7 +44,11 @@ async def list_brands( @router.post("", status_code=201) -async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)): +async def create_brand( + payload: BrandCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all() if any(b.country == payload.country for b in rows): raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است") @@ -61,7 +67,12 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)): @router.patch("/{brand_id}") -async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)): +async def update_brand( + brand_id, + payload: BrandUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): brand = await db.get(Brand, brand_id) if brand is None: raise NotFoundError("برند یافت نشد") @@ -79,7 +90,11 @@ async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depend @router.delete("/{brand_id}") -async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)): +async def delete_brand( + brand_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): brand = await db.get(Brand, brand_id) if brand is None: raise NotFoundError("برند یافت نشد") diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index 791dd16..ea5b4ac 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -29,9 +29,10 @@ from app.utils.preview_token import make_preview_token, verify_preview_token router = APIRouter(prefix="/quotes", tags=["files"]) -# حذف پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود +# حذف و بارگذاری پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود # برای هر کاربر واردشده آزاد است. _attachment_deleter = require_role("procurement_manager", "admin") +_attachment_writer = require_role("procurement_manager", "admin") ALLOWED_MIME_TYPES = { "image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf", @@ -125,7 +126,7 @@ async def _attachment_or_404( # Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش) # --------------------------------------------------------------------------- -@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)]) +@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(_attachment_writer)]) async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): await _quote_or_404(db, quote_id) mime = _validate_upload(file) @@ -208,7 +209,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn # Proforma — no status restriction # --------------------------------------------------------------------------- -@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)]) +@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(_attachment_writer)]) async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): await _quote_or_404(db, quote_id) mime = _validate_upload(file) diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py index 8aa9370..9816cd7 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("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند") # --------------------------------------------------------------------------- @@ -216,8 +214,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) @@ -324,7 +322,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 ) @@ -389,7 +387,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) @@ -455,7 +453,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) @@ -475,17 +473,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/item_receipts.py b/backend/app/routers/item_receipts.py index f29254e..12d2d6d 100644 --- a/backend/app/routers/item_receipts.py +++ b/backend/app/routers/item_receipts.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.procurement import ItemDecision, ItemReceipt, LineItem from app.schemas.procurement import ReceiptCreate, ReceiptUpdate @@ -14,6 +15,7 @@ router = APIRouter( prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt", tags=["item-receipts"], ) +_procurement_writer = require_role("admin", "procurement_manager") async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: @@ -45,7 +47,13 @@ async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db) @router.post("", status_code=201) -async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)): +async def create_receipt( + quote_id, + line_item_id, + payload: ReceiptCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): await _line_item_or_404(db, quote_id, line_item_id) decision = await _decision_or_404(db, line_item_id) existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id)) @@ -65,7 +73,13 @@ async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: Asy @router.patch("") -async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)): +async def update_receipt( + quote_id, + line_item_id, + payload: ReceiptUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): await _line_item_or_404(db, quote_id, line_item_id) decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id)) if decision is None: diff --git a/backend/app/routers/item_termsheets.py b/backend/app/routers/item_termsheets.py index 2d2977c..701e25c 100644 --- a/backend/app/routers/item_termsheets.py +++ b/backend/app/routers/item_termsheets.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.procurement import ( ItemDecision, @@ -23,6 +24,7 @@ router = APIRouter( prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets", tags=["item-termsheets"], ) +_procurement_writer = require_role("admin", "procurement_manager") async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: @@ -58,7 +60,11 @@ async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depend @router.post("", status_code=201) async def create_item_termsheet( - quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db) + quote_id, + line_item_id, + payload: ItemTermsheetCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): async with db.begin(): li = await _line_item_or_404(db, quote_id, line_item_id) @@ -93,7 +99,12 @@ async def create_item_termsheet( @router.patch("/{item_termsheet_id}") async def update_item_termsheet( - quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db) + quote_id, + line_item_id, + item_termsheet_id, + payload: ItemTermsheetUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): li = await _line_item_or_404(db, quote_id, line_item_id) quote = await db.get(Quote, quote_id) @@ -114,7 +125,11 @@ async def update_item_termsheet( @router.delete("/{item_termsheet_id}") async def delete_item_termsheet( - quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db) + quote_id, + line_item_id, + item_termsheet_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): it = await db.scalar( select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id) diff --git a/backend/app/routers/line_item_specs.py b/backend/app/routers/line_item_specs.py index 9f49e95..a00f535 100644 --- a/backend/app/routers/line_item_specs.py +++ b/backend/app/routers/line_item_specs.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import AttributeDefinition, Unit from app.models.procurement import LineItem, LineItemSpec, Quote @@ -12,6 +13,7 @@ from app.services.eav_service import flatten_eav_values from app.utils.errors import ConflictError, NotFoundError router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"]) +_procurement_writer = require_role("admin", "procurement_manager") async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: @@ -53,7 +55,13 @@ async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends @router.put("") -async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)): +async def put_line_item_specs( + quote_id, + line_item_id, + payload: SpecsPutIn, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): """FULL replacement — specs not in the payload are DELETED (P1-19).""" async with db.begin(): li = await _line_item_or_404(db, quote_id, line_item_id) diff --git a/backend/app/routers/line_items.py b/backend/app/routers/line_items.py index 963d1f5..e9f6c79 100644 --- a/backend/app/routers/line_items.py +++ b/backend/app/routers/line_items.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import Material, Unit from app.models.procurement import ItemDecision, LineItem, Quote @@ -14,6 +15,7 @@ from app.utils.errors import ConflictError, NotFoundError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"]) +_procurement_writer = require_role("admin", "procurement_manager") async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: @@ -50,7 +52,12 @@ async def list_line_items( @router.post("", status_code=201) -async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)): +async def create_line_item( + quote_id, + payload: LineItemCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): async with db.begin(): quote = await _quote_or_404(db, quote_id) if quote.status != "offered": @@ -89,7 +96,11 @@ async def get_line_item( @router.patch("/{line_item_id}") async def update_line_item( - quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db) + quote_id, + line_item_id, + payload: LineItemUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): li = await _line_item_or_404(db, quote_id, line_item_id) quote = await _quote_or_404(db, quote_id) @@ -131,7 +142,12 @@ async def update_line_item( @router.delete("/{line_item_id}") -async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)): +async def delete_line_item( + quote_id, + line_item_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): li = await _line_item_or_404(db, quote_id, line_item_id) quote = await _quote_or_404(db, quote_id) if quote.status != "offered": diff --git a/backend/app/routers/material_categories.py b/backend/app/routers/material_categories.py index 8611a74..ccd4ce5 100644 --- a/backend/app/routers/material_categories.py +++ b/backend/app/routers/material_categories.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import MaterialCategory from app.models.procurement import Supplier @@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/material-categories", tags=["material-categories"]) +_procurement_writer = require_role("admin", "procurement_manager") def category_to_dict(category: MaterialCategory) -> dict: @@ -46,7 +48,11 @@ async def list_categories( @router.post("", status_code=201) -async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)): +async def create_category( + payload: MaterialCategoryCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug)) if duplicate is not None: raise ConflictError("دسته‌بندی با این slug قبلاً ثبت شده است", field="slug") @@ -70,7 +76,12 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)): @router.patch("/{category_id}") -async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)): +async def update_category( + category_id, + payload: MaterialCategoryUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): category = await db.get(MaterialCategory, category_id) if category is None: raise NotFoundError("دسته‌بندی یافت نشد") @@ -92,7 +103,11 @@ async def update_category(category_id, payload: MaterialCategoryUpdate, db: Asyn @router.delete("/{category_id}") -async def delete_category(category_id, db: AsyncSession = Depends(get_db)): +async def delete_category( + category_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): category = await db.get(MaterialCategory, category_id) if category is None: raise NotFoundError("دسته‌بندی یافت نشد") diff --git a/backend/app/routers/materials.py b/backend/app/routers/materials.py index c128f9c..c78fcdd 100644 --- a/backend/app/routers/materials.py +++ b/backend/app/routers/materials.py @@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request from sqlalchemy import exists, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import ( AttributeDefinition, @@ -38,6 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/materials", tags=["materials"]) +_procurement_writer = require_role("admin", "procurement_manager") def _coerce_filter_value(data_type: str, raw: str): @@ -125,7 +127,10 @@ async def list_materials( @router.post("", status_code=201) async def create_material( - payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db) + payload: MaterialCreate, + expand: str | None = None, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): material = await material_service.create_material(db, payload) return {"data": await serialize_material(db, material, parse_expand(expand))} @@ -140,7 +145,12 @@ async def get_material(material_id, expand: str | None = None, db: AsyncSession @router.patch("/{material_id}") -async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)): +async def update_material( + material_id, + payload: MaterialUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): material = await db.get(Material, material_id) if material is None: raise NotFoundError("متریال یافت نشد") @@ -149,7 +159,11 @@ async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession @router.delete("/{material_id}") -async def delete_material(material_id, db: AsyncSession = Depends(get_db)): +async def delete_material( + material_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): material = await db.get(Material, material_id) if material is None: raise NotFoundError("متریال یافت نشد") @@ -248,7 +262,10 @@ async def list_material_units( @router.post("/{material_id}/units", status_code=201) async def create_material_unit( - material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db) + material_id, + payload: MaterialUnitCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): async with db.begin(): await _material_or_404(db, material_id) @@ -287,7 +304,11 @@ async def create_material_unit( @router.patch("/{material_id}/units/{mu_id}") async def update_material_unit( - material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db) + material_id, + mu_id, + payload: MaterialUnitUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): async with db.begin(): mu = await db.scalar( @@ -319,7 +340,12 @@ async def update_material_unit( @router.delete("/{material_id}/units/{mu_id}") -async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)): +async def delete_material_unit( + material_id, + mu_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): mu = await db.scalar( select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id) ) @@ -365,7 +391,10 @@ async def get_material_attribute_values( @router.put("/{material_id}/attribute-values") async def put_material_attribute_values( - material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db) + material_id, + payload: MaterialAttributeValuesPut, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): """FULL replacement of a material's EAV values (empty array deletes all).""" async with db.begin(): diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 2ebd785..7e6b69d 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from app.auth import UserContext, get_optional_user +from app.auth import UserContext, get_optional_user, require_role from app.db.engine import get_db from app.models.procurement import Project, Quote, User from app.schemas.procurement import ProjectCreate, ProjectUpdate @@ -13,6 +13,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_date, to_iso router = APIRouter(prefix="/projects", tags=["projects"]) +_procurement_writer = require_role("admin", "procurement_manager") async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None: @@ -33,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), } @@ -42,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 @@ -55,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) @@ -64,24 +78,38 @@ 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} @router.post("", status_code=201) -async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)): +async def create_project( + payload: ProjectCreate, + db: AsyncSession = Depends(get_db), + _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() @@ -103,37 +131,51 @@ async def update_project( request: Request, ctx: UserContext | None = Depends(_optional_ctx), db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): project = await db.get(Project, project_id) 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)} @router.delete("/{project_id}") -async def delete_project(project_id, db: AsyncSession = Depends(get_db)): +async def delete_project( + project_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): project = await db.get(Project, project_id) if project is None: raise NotFoundError("پروژه یافت نشد") diff --git a/backend/app/routers/quote_events.py b/backend/app/routers/quote_events.py index 1f91c41..962bb0b 100644 --- a/backend/app/routers/quote_events.py +++ b/backend/app/routers/quote_events.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.procurement import Quote, QuoteEvent from app.schemas.procurement import NoteEventIn @@ -11,6 +12,7 @@ from app.utils.errors import NotFoundError, ValidationError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"]) +_procurement_writer = require_role("admin", "procurement_manager") @router.get("") @@ -34,7 +36,12 @@ async def list_events( @router.post("", status_code=201) -async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)): +async def create_event( + quote_id, + payload: NoteEventIn, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): quote = await db.get(Quote, quote_id) if quote is None: raise NotFoundError("پیشنهاد (استعلام) یافت نشد") diff --git a/backend/app/routers/quotes.py b/backend/app/routers/quotes.py index 489921c..2849be3 100644 --- a/backend/app/routers/quotes.py +++ b/backend/app/routers/quotes.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import Material from app.models.procurement import ( @@ -42,6 +43,9 @@ from app.utils.security import get_actor router = APIRouter(prefix="/quotes", tags=["quotes"]) +# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد) +_procurement_writer = require_role("admin", "procurement_manager") + # --- list filter helpers --------------------------------------------------- @@ -156,7 +160,12 @@ async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depe # --------------------------------------------------------------------------- @router.post("", status_code=201) -async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): +async def create_quote( + payload: QuoteCreate, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), +): quote = await quote_service.create_quote(db, payload, actor) return {"data": await serialize_quote_with_children(db, quote)} @@ -167,7 +176,11 @@ async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), @router.patch("/{quote_id}") async def update_quote( - quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) + quote_id, + payload: QuoteUpdate, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), ): quote = await quote_service.update_quote(db, quote_id, payload, actor) return {"data": await serialize_quote(db, quote, set())} @@ -175,7 +188,11 @@ async def update_quote( @router.patch("/{quote_id}/children") async def replace_quote_children( - quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) + quote_id, + payload: QuoteChildrenReplaceIn, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), ): """Full replacement of a quote's line items + termsheets (edit modal).""" quote = await quote_service.replace_quote_children(db, quote_id, payload, actor) @@ -188,7 +205,11 @@ async def replace_quote_children( @router.patch("/{quote_id}/accept") async def accept_quote( - quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) + quote_id, + payload: AcceptQuoteIn, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), ): quote = await quote_service.accept_quote(db, quote_id, payload, actor) return {"data": await _accept_response(db, quote)} @@ -224,7 +245,12 @@ async def _accept_response(db: AsyncSession, quote: Quote) -> dict: @router.patch("/{quote_id}/arrive") -async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): +async def arrive_quote( + quote_id, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), +): quote = await quote_service.arrive_quote(db, quote_id, actor) return { "data": { @@ -237,7 +263,11 @@ async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str @router.patch("/{quote_id}/arrive-items") async def arrive_items( - quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) + quote_id, + payload: ArriveItemsIn, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), ): quote = await quote_service.arrive_items(db, quote_id, payload, actor) return { @@ -259,14 +289,23 @@ async def arrive_items( @router.patch("/{quote_id}/reject") async def reject_quote( - quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) + quote_id, + payload: RejectQuoteIn | None = None, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), ): quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor) return {"data": {"id": str(quote.id), "status": quote.status}} @router.patch("/{quote_id}/expire") -async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): +async def expire_quote( + quote_id, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), +): quote = await quote_service.expire_quote(db, quote_id, actor) return {"data": {"id": str(quote.id), "status": quote.status}} @@ -276,6 +315,10 @@ async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str # --------------------------------------------------------------------------- @router.delete("/{quote_id}") -async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)): +async def delete_quote( + quote_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): result = await quote_service.delete_quote(db, quote_id) return {"data": result} diff --git a/backend/app/routers/schedule_steps.py b/backend/app/routers/schedule_steps.py index fc265e7..d2c03d4 100644 --- a/backend/app/routers/schedule_steps.py +++ b/backend/app/routers/schedule_steps.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.procurement import Termsheet, TermsheetScheduleStep from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate @@ -15,6 +16,7 @@ router = APIRouter( prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps", tags=["schedule-steps"], ) +_procurement_writer = require_role("admin", "procurement_manager") async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet: @@ -57,7 +59,11 @@ async def list_schedule_steps( @router.post("", status_code=201) async def create_schedule_step( - quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db) + quote_id, + termsheet_id, + payload: ScheduleStepCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): ts = await _termsheet_or_404(db, quote_id, termsheet_id) step_no = payload.step_no @@ -84,7 +90,12 @@ async def create_schedule_step( @router.patch("/{step_id}") async def update_schedule_step( - quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db) + quote_id, + termsheet_id, + step_id, + payload: ScheduleStepUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): step = await _step_or_404(db, termsheet_id, step_id) if payload.step_no is not None and payload.step_no != step.step_no: @@ -109,7 +120,13 @@ async def update_schedule_step( @router.delete("/{step_id}") -async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)): +async def delete_schedule_step( + quote_id, + termsheet_id, + step_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): step = await _step_or_404(db, termsheet_id, step_id) count = await db.scalar( select(func.count()).select_from(TermsheetScheduleStep).where( diff --git a/backend/app/routers/standard_conversions.py b/backend/app/routers/standard_conversions.py index b63434a..6469399 100644 --- a/backend/app/routers/standard_conversions.py +++ b/backend/app/routers/standard_conversions.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import StandardConversion from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate @@ -11,6 +12,7 @@ from app.utils.pagination import paginate from app.utils.serialization import to_iso router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"]) +_procurement_writer = require_role("admin", "procurement_manager") def sc_to_dict(sc: StandardConversion) -> dict: @@ -41,7 +43,11 @@ async def list_standard_conversions( @router.post("", status_code=201) -async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)): +async def create_standard_conversion( + payload: StandardConversionCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): duplicate = await db.scalar( select(StandardConversion.id).where( StandardConversion.from_unit == payload.from_unit, @@ -67,7 +73,12 @@ async def get_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)): @router.patch("/{sc_id}") -async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)): +async def update_standard_conversion( + sc_id, + payload: StandardConversionUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): sc = await db.get(StandardConversion, sc_id) if sc is None: raise NotFoundError("تبدیل استاندارد یافت نشد") @@ -94,7 +105,11 @@ async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, d @router.delete("/{sc_id}") -async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)): +async def delete_standard_conversion( + sc_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): sc = await db.get(StandardConversion, sc_id) if sc is None: raise NotFoundError("تبدیل استاندارد یافت نشد") diff --git a/backend/app/routers/supplier_materials.py b/backend/app/routers/supplier_materials.py index 26e4026..02d5a55 100644 --- a/backend/app/routers/supplier_materials.py +++ b/backend/app/routers/supplier_materials.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import Material, MaterialCategory from app.models.procurement import Supplier, SupplierMaterial @@ -18,6 +19,7 @@ from app.utils.pagination import paginate from app.utils.serialization import to_iso router = APIRouter(tags=["supplier-materials"]) +_procurement_writer = require_role("admin", "procurement_manager") def link_to_dict(link: SupplierMaterial, material: Material | None = None, @@ -63,7 +65,10 @@ async def list_supplier_materials( @router.post("/suppliers/{supplier_id}/materials", status_code=201) async def create_supplier_material( - supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db) + supplier_id, + payload: SupplierMaterialCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): async with db.begin(): link = await link_supplier_material(db, supplier_id, payload.material_id) @@ -72,7 +77,12 @@ async def create_supplier_material( @router.delete("/suppliers/{supplier_id}/materials/{material_id}") -async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)): +async def delete_supplier_material( + supplier_id, + material_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): async with db.begin(): await unlink_supplier_material(db, supplier_id, material_id) return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}} diff --git a/backend/app/routers/suppliers.py b/backend/app/routers/suppliers.py index 3ab5175..070a9e6 100644 --- a/backend/app/routers/suppliers.py +++ b/backend/app/routers/suppliers.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import MaterialCategory from app.models.procurement import Quote, Supplier @@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/suppliers", tags=["suppliers"]) +_procurement_writer = require_role("admin", "procurement_manager") def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict: @@ -71,7 +73,11 @@ async def list_suppliers( @router.post("", status_code=201) -async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)): +async def create_supplier( + payload: SupplierCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): category = await _category_or_404(db, payload.category_id) supplier = Supplier( name=payload.name, @@ -96,7 +102,12 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)): @router.patch("/{supplier_id}") -async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)): +async def update_supplier( + supplier_id, + payload: SupplierUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): supplier = await db.get(Supplier, supplier_id) if supplier is None: raise NotFoundError("تأمین‌کننده یافت نشد") @@ -120,7 +131,11 @@ async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession @router.delete("/{supplier_id}") -async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)): +async def delete_supplier( + supplier_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): supplier = await db.get(Supplier, supplier_id) if supplier is None: raise NotFoundError("تأمین‌کننده یافت نشد") diff --git a/backend/app/routers/termsheets.py b/backend/app/routers/termsheets.py index 16c8f98..b1d5ffd 100644 --- a/backend/app/routers/termsheets.py +++ b/backend/app/routers/termsheets.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.procurement import ( ItemDecision, @@ -26,6 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"]) +_procurement_writer = require_role("admin", "procurement_manager") async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: @@ -80,7 +82,12 @@ async def list_termsheets( @router.post("", status_code=201) -async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)): +async def create_termsheet( + quote_id, + payload: TermsheetCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): async with db.begin(): quote = await _quote_or_404(db, quote_id) if quote.status != "offered": @@ -110,7 +117,11 @@ async def get_termsheet( @router.patch("/{termsheet_id}") async def update_termsheet( - quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db) + quote_id, + termsheet_id, + payload: TermsheetUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): ts = await _termsheet_or_404(db, quote_id, termsheet_id) if ts.status != "offered": @@ -127,7 +138,11 @@ async def update_termsheet( @router.patch("/{termsheet_id}/status") async def update_termsheet_status( - quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db) + quote_id, + termsheet_id, + payload: TermsheetStatusUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): async with db.begin(): ts = await _termsheet_or_404(db, quote_id, termsheet_id) @@ -139,7 +154,12 @@ async def update_termsheet_status( @router.delete("/{termsheet_id}") -async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)): +async def delete_termsheet( + quote_id, + termsheet_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): ts = await _termsheet_or_404(db, quote_id, termsheet_id) quote = await _quote_or_404(db, quote_id) if quote.status != "offered": diff --git a/backend/app/routers/units.py b/backend/app/routers/units.py index 7152ecd..5004f7a 100644 --- a/backend/app/routers/units.py +++ b/backend/app/routers/units.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit from app.models.procurement import LineItem, LineItemSpec @@ -11,6 +12,7 @@ from app.utils.errors import ConflictError, NotFoundError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/units", tags=["units"]) +_procurement_writer = require_role("admin", "procurement_manager") def unit_full_to_dict(unit: Unit) -> dict: @@ -45,7 +47,11 @@ async def list_units( @router.post("", status_code=201) -async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)): +async def create_unit( + payload: UnitCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code)) if duplicate is not None: raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code") @@ -70,7 +76,12 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)): @router.patch("/{unit_id}") -async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)): +async def update_unit( + unit_id: int, + payload: UnitUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): unit = await db.get(Unit, unit_id) if unit is None: raise NotFoundError("واحد یافت نشد") @@ -92,7 +103,11 @@ async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depe @router.delete("/{unit_id}") -async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)): +async def delete_unit( + unit_id: int, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): unit = await db.get(Unit, unit_id) if unit is None: raise NotFoundError("واحد یافت نشد") diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 702484d..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 @@ -41,6 +41,8 @@ USER_ROLE_LABELS = { "pm": "مدیر پروژه", "procurement_manager": "مدیر تدارکات", "admin": "ادمین", + # کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد + "supervisor": "سرپرست پروژه", } @@ -52,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, @@ -65,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( @@ -148,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() @@ -204,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() @@ -231,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") @@ -243,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() @@ -282,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)} @@ -294,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 372131b..9bc549a 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 @@ -209,13 +214,13 @@ class UserCreate(APIModel): last_name: str = "" phone: str | None = None casdoor_id: str | None = None - role: Literal["pm", "procurement_manager", "admin"] | None = None + role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None class UserRoleUpdate(APIModel): """role=None revokes the role (and any project assignment).""" - role: Literal["pm", "procurement_manager", "admin"] | None = None + role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None class UserProjectUpdate(APIModel): diff --git a/docker-compose.yml b/docker-compose.yml index 2c01cb0..4dc93b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -249,6 +249,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 d9eb7f0..346568b 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 })), @@ -68,16 +68,101 @@ function RequireAuth({ children }: { children: React.ReactNode }) { const returnUrl = encodeURIComponent(location.pathname + location.search); return ; } + if (user.role === "supervisor") { + // اگر سرپرست پروژه‌ای نداشته باشد، دسترسی مسدود می‌شود + if (!user.projects || user.projects.length === 0) { + return ( +
+
+

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

+

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

+ +
+
+ ); + } + // سرپرست پروژه فقط به انبار و PM دسترسی دارد + if (!location.pathname.startsWith("/inventory") && location.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" && user?.role !== "pm") { + return ; + } + return <>{children}; +} + +// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود +function PmPanelPage() { + return ( +