From 4a3c22651855a7d89aab498700aac72b04ea7b52 Mon Sep 17 00:00:00 2001 From: Milad Date: Tue, 29 Sep 2026 13:37:59 +0330 Subject: [PATCH] feat(org): panel-based access system replacing roles (procurement/inventory) - new tables user_panels (panel: procurement/inventory/pm + level full/limited) and user_project_members (M:N membership, separate from pm responsibility) - UserContext carries panels + member_project_ids; /users/me and the users list include them - admin endpoints: PUT /users/{id}/panels, PUT /users/{id}/panel-projects - inventory scope: panel inventory 'full' -> manager scope (all projects, NO stock change); 'limited' -> supervisor scope (own memberships, stock change allowed); legacy roles kept as migration fallback; admin untouched - procurement writes: _procurement_writer is now panel-aware (admin, legacy procurement_manager, or procurement 'full') across all 18 write routers; quote CREATE additionally allows 'limited' users restricted to their member projects (validated server-side) - frontend: panel-aware HomeRoute (inventory -> warehouse panel, procurement limited -> minimal RequesterHome), wizard project options restricted for limited users, change-inventory button/route gated to inventory 'limited', organization panel gains the panels & access-levels management section --- backend/app/auth.py | 56 +++++++- backend/app/models/procurement.py | 41 ++++++ backend/app/routers/attribute_definitions.py | 4 +- backend/app/routers/auth.py | 2 +- backend/app/routers/brand_materials.py | 4 +- backend/app/routers/brands.py | 4 +- backend/app/routers/inventory.py | 24 +++- backend/app/routers/item_receipts.py | 4 +- backend/app/routers/item_termsheets.py | 4 +- backend/app/routers/line_item_specs.py | 4 +- backend/app/routers/line_items.py | 4 +- backend/app/routers/material_categories.py | 4 +- backend/app/routers/materials.py | 4 +- backend/app/routers/projects.py | 4 +- backend/app/routers/quote_events.py | 4 +- backend/app/routers/quotes.py | 17 ++- backend/app/routers/schedule_steps.py | 4 +- backend/app/routers/standard_conversions.py | 4 +- backend/app/routers/supplier_materials.py | 4 +- backend/app/routers/suppliers.py | 4 +- backend/app/routers/termsheets.py | 4 +- backend/app/routers/units.py | 4 +- backend/app/routers/users.py | 134 ++++++++++++++++-- backend/app/schemas/procurement.py | 13 ++ frontend/src/App.tsx | 41 +++++- frontend/src/components/RequirePmProjects.tsx | 8 +- .../src/components/layout/MobileTabBar.tsx | 5 + frontend/src/lib/api.ts | 9 ++ .../src/pages/Dashboard/RequesterHome.tsx | 62 ++++++++ .../src/pages/Inventory/InventoryPage.tsx | 3 +- .../pages/Organization/OrganizationPage.tsx | 128 +++++++++++++++++ frontend/src/pages/Wizard/Step4Projects.tsx | 8 +- frontend/src/pages/Wizard/WizardPage.tsx | 21 ++- frontend/src/store/auth.ts | 3 + sql/migration.sql | 20 +++ 35 files changed, 591 insertions(+), 72 deletions(-) create mode 100644 frontend/src/pages/Dashboard/RequesterHome.tsx diff --git a/backend/app/auth.py b/backend/app/auth.py index b284525..98b8691 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -27,7 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.db.engine import AsyncSessionLocal -from app.models.procurement import Project, User +from app.models.procurement import Project, User, UserPanel, UserProjectMembership from app.utils.errors import ( ForbiddenError, ServiceUnavailableError, @@ -106,6 +106,10 @@ class UserContext: project_id: object | None # uuid.UUID | None — default (first) project of a PM project_ids: list # every project this PM manages (1:N — empty for non-PMs) display_name: str + # لایه پنل‌ها (جایگزین نقش‌ها): {'procurement': 'full'|'limited', 'inventory': ...} + panels: dict | None = None + # عضویت M:N در پروژه‌ها — محدوده داده سطح limited (جدا از مسئولیت PM) + member_project_ids: list | None = None async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: @@ -153,6 +157,26 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: project_ids = [p.id for p in projects] project_id = project_ids[0] if project_ids else None + # لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (جدا از نقش) + panels: dict = {} + panel_rows = ( + await db.execute( + select(UserPanel).where(UserPanel.user_id == user.id).order_by(UserPanel.created_at) + ) + ).scalars().all() + for row in panel_rows: + panels[row.panel] = row.level + member_project_ids: list = [ + row.project_id + for row in ( + await db.execute( + select(UserProjectMembership) + .where(UserProjectMembership.user_id == user.id) + .order_by(UserProjectMembership.created_at) + ) + ).scalars().all() + ] + display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id return UserContext( user=user, @@ -160,6 +184,8 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: project_id=project_id, project_ids=project_ids, display_name=display_name, + panels=panels, + member_project_ids=member_project_ids, ) @@ -222,3 +248,31 @@ def require_role(*roles: str): raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست") return ctx return _dep + + +def require_procurement_writer(): + """نوشتن در پنل تدارکات — ادمین، پنل تدارکات 'full'، یا نقش میراث مدیر تدارکات + (دوره مهاجرت از مدل نقش به مدل پنل).""" + + async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext: + if ctx.role in ("admin", "procurement_manager"): + return ctx + if (ctx.panels or {}).get("procurement") == "full": + return ctx + raise ForbiddenError("دسترسی نوشتن در پنل تدارکات را ندارید") + + return _dep + + +def allow_quote_create(): + """افزودن استعلام — علاوه بر «نوشتن»، پنل تدارکات 'limited' هم مجاز است + (محدود به پروژه‌های عضویت؛ اعتبارسنجی عضویت در خود endpoint انجام می‌شود).""" + + async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext: + if ctx.role in ("admin", "procurement_manager"): + return ctx + if (ctx.panels or {}).get("procurement") in ("full", "limited"): + return ctx + raise ForbiddenError("دسترسی افزودن استعلام ندارید") + + return _dep diff --git a/backend/app/models/procurement.py b/backend/app/models/procurement.py index 523649c..0bbe904 100644 --- a/backend/app/models/procurement.py +++ b/backend/app/models/procurement.py @@ -66,6 +66,47 @@ class User(Base): created_at: Mapped[datetime] = _timestamptz() +class UserPanel(Base): + """user_panels — تخصیص پنل + سطح دسترسی به کاربر (جایگزین مدل نقش). + + panel: 'procurement' | 'inventory' | 'pm' + level: 'full' — تدارکات: تغییر کامل در کل پنل؛ انبار: همه پروژه‌ها + (به‌جز تغییر موجودی) + 'limited' — تدارکات: فقط افزودن استعلام برای پروژه‌های عضویت؛ + انبار: فقط پروژه‌های عضویت + امکان تغییر موجودی + """ + + __tablename__ = "user_panels" + + id: Mapped[uuid.UUID] = _uuid_pk() + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + panel: Mapped[str] = mapped_column(Text, nullable=False) + level: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = _timestamptz() + + __table_args__ = (UniqueConstraint("user_id", "panel", name="uq_user_panel"),) + + +class UserProjectMembership(Base): + """user_project_members — عضویت M:N کاربر در پروژه‌ها، جدا از مسئولیت + پروژه (projects.pm_user_id). محدوده‌ی داده‌ی سطح limited را تعیین می‌کند.""" + + __tablename__ = "user_project_members" + + id: Mapped[uuid.UUID] = _uuid_pk() + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + project_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True + ) + created_at: Mapped[datetime] = _timestamptz() + + __table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_user_project_member"),) + + class Supplier(Base): __tablename__ = "suppliers" diff --git a/backend/app/routers/attribute_definitions.py b/backend/app/routers/attribute_definitions.py index 6124262..47f12a3 100644 --- a/backend/app/routers/attribute_definitions.py +++ b/backend/app/routers/attribute_definitions.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit from app.models.procurement import LineItemSpec @@ -14,7 +14,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"]) -_procurement_writer = require_role("admin", "procurement_manager") +_procurement_writer = require_procurement_writer() def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict: diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index b3bb3c6..560b7f1 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -94,6 +94,6 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe "access_token": access_token, "token_type": "bearer", "expires_in": body.get("expires_in"), - "user": user_to_dict(ctx.user, projects), + "user": await user_to_dict(ctx.user, projects, db), } } diff --git a/backend/app/routers/brand_materials.py b/backend/app/routers/brand_materials.py index 13f96d8..0d680e4 100644 --- a/backend/app/routers/brand_materials.py +++ b/backend/app/routers/brand_materials.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import Brand, BrandMaterial, Material from app.models.procurement import LineItem @@ -13,7 +13,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/brand-materials", tags=["brand-materials"]) -_procurement_writer = require_role("admin", "procurement_manager") +_procurement_writer = require_procurement_writer() def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict: diff --git a/backend/app/routers/brands.py b/backend/app/routers/brands.py index 1112cc0..330c0a5 100644 --- a/backend/app/routers/brands.py +++ b/backend/app/routers/brands.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import Brand, BrandMaterial from app.schemas.catalog import BrandCreate, BrandUpdate @@ -12,7 +12,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/brands", tags=["brands"]) -_procurement_writer = require_role("admin", "procurement_manager") +_procurement_writer = require_procurement_writer() def brand_to_dict(brand: Brand) -> dict: diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py index fcc6a14..7d7561f 100644 --- a/backend/app/routers/inventory.py +++ b/backend/app/routers/inventory.py @@ -76,9 +76,27 @@ async def inventory_scope( if request.headers.get("authorization"): user: UserContext = await get_optional_user(request, db) if user is not None: - if user.role is None: - raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") - # مدیر تدارکات و مدیر پروژه: دسترسی کامل به پنل انبار، اما فقط‌خواندنی در تغییر موجودی + panels = user.panels or {} + if user.role is None and not panels: + raise ForbiddenError("دسترسی شما هنوز در سیستم تعیین نشده است") + # ادمین: دست‌نخورده — همه‌چیز از جمله تغییر موجودی + if user.role == "admin": + return Scope(role="admin", project_id=project_id, actor=user.display_name) + # لایه پنل‌ها (جایگزین نقش‌ها): پنل انبار اولویت دارد + if "inventory" in panels: + if panels["inventory"] == "full": + # کل سیستم و تمام پروژه‌ها — به‌جز تغییر موجودی + return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.member_project_ids or [])) + # limited: فقط پروژه‌های عضویت + امکان تغییر موجودی + allowed = list(user.member_project_ids or []) + if not allowed: + raise ForbiddenError("پروژه‌ای به شما تخصیص داده نشده است") + if project_id is not None: + if project_id not in allowed: + raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") + return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed) + return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed) + # مدیر تدارکات و مدیر پروژه (میراث): دسترسی کامل به پنل انبار، اما فقط‌خواندنی در تغییر موجودی if user.role in ("procurement_manager", "pm"): return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.project_ids or [])) if user.role == "supervisor": diff --git a/backend/app/routers/item_receipts.py b/backend/app/routers/item_receipts.py index 12d2d6d..61b2646 100644 --- a/backend/app/routers/item_receipts.py +++ b/backend/app/routers/item_receipts.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.procurement import ItemDecision, ItemReceipt, LineItem from app.schemas.procurement import ReceiptCreate, ReceiptUpdate @@ -15,7 +15,7 @@ router = APIRouter( prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt", tags=["item-receipts"], ) -_procurement_writer = require_role("admin", "procurement_manager") +_procurement_writer = require_procurement_writer() async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: diff --git a/backend/app/routers/item_termsheets.py b/backend/app/routers/item_termsheets.py index 701e25c..0b2469d 100644 --- a/backend/app/routers/item_termsheets.py +++ b/backend/app/routers/item_termsheets.py @@ -7,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.procurement import ( ItemDecision, @@ -24,7 +24,7 @@ router = APIRouter( prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets", tags=["item-termsheets"], ) -_procurement_writer = require_role("admin", "procurement_manager") +_procurement_writer = require_procurement_writer() async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: diff --git a/backend/app/routers/line_item_specs.py b/backend/app/routers/line_item_specs.py index a00f535..dc00633 100644 --- a/backend/app/routers/line_item_specs.py +++ b/backend/app/routers/line_item_specs.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import AttributeDefinition, Unit from app.models.procurement import LineItem, LineItemSpec, Quote @@ -13,7 +13,7 @@ from app.services.eav_service import flatten_eav_values from app.utils.errors import ConflictError, NotFoundError router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"]) -_procurement_writer = require_role("admin", "procurement_manager") +_procurement_writer = require_procurement_writer() async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: diff --git a/backend/app/routers/line_items.py b/backend/app/routers/line_items.py index e9f6c79..4c9c5f4 100644 --- a/backend/app/routers/line_items.py +++ b/backend/app/routers/line_items.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import Material, Unit from app.models.procurement import ItemDecision, LineItem, Quote @@ -15,7 +15,7 @@ from app.utils.errors import ConflictError, NotFoundError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"]) -_procurement_writer = require_role("admin", "procurement_manager") +_procurement_writer = require_procurement_writer() async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: diff --git a/backend/app/routers/material_categories.py b/backend/app/routers/material_categories.py index ccd4ce5..7d76c10 100644 --- a/backend/app/routers/material_categories.py +++ b/backend/app/routers/material_categories.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import MaterialCategory from app.models.procurement import Supplier @@ -13,7 +13,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/material-categories", tags=["material-categories"]) -_procurement_writer = require_role("admin", "procurement_manager") +_procurement_writer = require_procurement_writer() def category_to_dict(category: MaterialCategory) -> dict: diff --git a/backend/app/routers/materials.py b/backend/app/routers/materials.py index c78fcdd..b703d22 100644 --- a/backend/app/routers/materials.py +++ b/backend/app/routers/materials.py @@ -9,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import ( AttributeDefinition, @@ -39,7 +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") +_procurement_writer = require_procurement_writer() def _coerce_filter_value(data_type: str, raw: str): diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 7e6b69d..47e8e6b 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, require_role +from app.auth import UserContext, get_optional_user, require_procurement_writer, 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,7 +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") +_procurement_writer = require_procurement_writer() async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None: diff --git a/backend/app/routers/quote_events.py b/backend/app/routers/quote_events.py index 962bb0b..e6cd9d3 100644 --- a/backend/app/routers/quote_events.py +++ b/backend/app/routers/quote_events.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.procurement import Quote, QuoteEvent from app.schemas.procurement import NoteEventIn @@ -12,7 +12,7 @@ from app.utils.errors import NotFoundError, ValidationError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"]) -_procurement_writer = require_role("admin", "procurement_manager") +_procurement_writer = require_procurement_writer() @router.get("") diff --git a/backend/app/routers/quotes.py b/backend/app/routers/quotes.py index 2849be3..f8a03e6 100644 --- a/backend/app/routers/quotes.py +++ b/backend/app/routers/quotes.py @@ -8,7 +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.auth import UserContext, allow_quote_create, require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import Material from app.models.procurement import ( @@ -37,14 +37,14 @@ from app.services.expand_service import ( serialize_quote_with_children, serialize_quotes, ) -from app.utils.errors import NotFoundError +from app.utils.errors import ForbiddenError, NotFoundError, ValidationError from app.utils.pagination import sort_query from app.utils.security import get_actor router = APIRouter(prefix="/quotes", tags=["quotes"]) # عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد) -_procurement_writer = require_role("admin", "procurement_manager") +_procurement_writer = require_procurement_writer() # --- list filter helpers --------------------------------------------------- @@ -164,8 +164,17 @@ async def create_quote( payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor), - _auth=Depends(_procurement_writer), + ctx: UserContext = Depends(allow_quote_create()), + _auth=None, ): + # سطح limited: استعلام فقط برای پروژه‌های عضویت خودش + if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin": + member = set(ctx.member_project_ids or []) + wanted = {pid for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid} + if not wanted: + raise ValidationError("برای افزودن استعلام، پروژه الزامی است") + if not wanted <= member: + raise ForbiddenError("استعلام فقط برای پروژه‌های تخصیص‌یافته به شما مجاز است") quote = await quote_service.create_quote(db, payload, actor) return {"data": await serialize_quote_with_children(db, quote)} diff --git a/backend/app/routers/schedule_steps.py b/backend/app/routers/schedule_steps.py index d2c03d4..5e8a857 100644 --- a/backend/app/routers/schedule_steps.py +++ b/backend/app/routers/schedule_steps.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.procurement import Termsheet, TermsheetScheduleStep from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate @@ -16,7 +16,7 @@ router = APIRouter( prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps", tags=["schedule-steps"], ) -_procurement_writer = require_role("admin", "procurement_manager") +_procurement_writer = require_procurement_writer() async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet: diff --git a/backend/app/routers/standard_conversions.py b/backend/app/routers/standard_conversions.py index 6469399..73deeaf 100644 --- a/backend/app/routers/standard_conversions.py +++ b/backend/app/routers/standard_conversions.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import StandardConversion from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate @@ -12,7 +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") +_procurement_writer = require_procurement_writer() def sc_to_dict(sc: StandardConversion) -> dict: diff --git a/backend/app/routers/supplier_materials.py b/backend/app/routers/supplier_materials.py index 02d5a55..3a9a31e 100644 --- a/backend/app/routers/supplier_materials.py +++ b/backend/app/routers/supplier_materials.py @@ -8,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import Material, MaterialCategory from app.models.procurement import Supplier, SupplierMaterial @@ -19,7 +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") +_procurement_writer = require_procurement_writer() def link_to_dict(link: SupplierMaterial, material: Material | None = None, diff --git a/backend/app/routers/suppliers.py b/backend/app/routers/suppliers.py index 070a9e6..b25d8d7 100644 --- a/backend/app/routers/suppliers.py +++ b/backend/app/routers/suppliers.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import MaterialCategory from app.models.procurement import Quote, Supplier @@ -13,7 +13,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/suppliers", tags=["suppliers"]) -_procurement_writer = require_role("admin", "procurement_manager") +_procurement_writer = require_procurement_writer() def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict: diff --git a/backend/app/routers/termsheets.py b/backend/app/routers/termsheets.py index b1d5ffd..6226838 100644 --- a/backend/app/routers/termsheets.py +++ b/backend/app/routers/termsheets.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.procurement import ( ItemDecision, @@ -27,7 +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") +_procurement_writer = require_procurement_writer() async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: diff --git a/backend/app/routers/units.py b/backend/app/routers/units.py index 5004f7a..5a78bea 100644 --- a/backend/app/routers/units.py +++ b/backend/app/routers/units.py @@ -3,7 +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.auth import require_procurement_writer, 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 @@ -12,7 +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") +_procurement_writer = require_procurement_writer() def unit_full_to_dict(unit: Unit) -> dict: diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index ecd0eef..180daf9 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -17,15 +17,21 @@ import uuid import httpx from fastapi import APIRouter, Depends -from sqlalchemy import select, update, text +from sqlalchemy import delete, select, update, text from sqlalchemy.ext.asyncio import AsyncSession from app.auth import UserContext, get_current_user, require_role from app.utils.audit import record as audit_record from app.config import settings from app.db.engine import get_db -from app.models.procurement import Project, User -from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate +from app.models.procurement import Project, User, UserPanel, UserProjectMembership +from app.schemas.procurement import ( + UserCreate, + UserPanelsUpdate, + UserPanelProjectsUpdate, + UserProjectUpdate, + UserRoleUpdate, +) from app.utils.errors import ( ConflictError, ForbiddenError, @@ -46,12 +52,39 @@ USER_ROLE_LABELS = { } -def user_to_dict(user: User, projects: list[Project] | None = None) -> dict: +PANEL_LEVELS = ("full", "limited") +PANELS = ("procurement", "inventory", "pm") + + +async def _panels_of(db: AsyncSession, user_id) -> dict: + """panel -> level map for the user (لایه پنل‌ها — جایگزین نقش‌ها).""" + rows = ( + await db.execute( + select(UserPanel).where(UserPanel.user_id == user_id).order_by(UserPanel.created_at) + ) + ).scalars().all() + return {row.panel: row.level for row in rows} + + +async def _member_project_ids_of(db: AsyncSession, user_id) -> list[str]: + """عضویت M:N کاربر در پروژه‌ها (جدا از مسئولیت) — رشته.""" + rows = ( + await db.execute( + select(UserProjectMembership.project_id) + .where(UserProjectMembership.user_id == user_id) + .order_by(UserProjectMembership.created_at) + ) + ).scalars().all() + return [str(pid) for pid in rows] + + +async def user_to_dict(user: User, projects: list[Project] | None = None, db: AsyncSession | None = None) -> dict: """Serialize a user. `projects` = every project the PM manages (1:N); - project_id/project_name stay as the first (oldest) one for compatibility.""" + project_id/project_name stay as the first (oldest) one for compatibility. + با db، پنل‌های تخصیصی و عضویت پروژه‌ای هم برمی‌گردند.""" projects = projects or [] first = projects[0] if projects else None - return { + out = { "id": str(user.id), "casdoor_id": user.casdoor_id, "username": user.username, @@ -65,6 +98,10 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict: "projects": [{"id": str(p.id), "name": p.name} for p in projects], "created_at": to_iso(user.created_at), } + if db is not None: + out["panels"] = await _panels_of(db, user.id) + out["member_project_ids"] = await _member_project_ids_of(db, user.id) + return out async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext: @@ -130,7 +167,7 @@ async def _casdoor_users() -> list[dict]: @router.get("/me") async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)): projects = await _user_projects(db, ctx.user) - return {"data": user_to_dict(ctx.user, projects)} + return {"data": await user_to_dict(ctx.user, projects, db)} @router.get("/me/projects") @@ -141,6 +178,18 @@ async def my_projects(ctx: UserContext = Depends(get_current_user), db: AsyncSes active project against this list and falls back gracefully when a project is revoked.""" projects = await _user_projects(db, ctx.user) + # عضویت‌های M:N (لایه پنل‌ها) هم به فهرست اضافه می‌شود + member_ids = set(ctx.member_project_ids or []) + have = {p.id for p in projects} + if member_ids: + extra = ( + await db.execute( + select(Project) + .where(Project.id.in_(member_ids)) + .order_by(Project.created_at, Project.id) + ) + ).scalars().all() + projects = list(projects) + [p for p in extra if p.id not in have] return {"data": [{"id": str(p.id), "name": p.name} for p in projects]} @@ -164,7 +213,7 @@ async def list_users( for p in projs: by_user.setdefault(p.pm_user_id, []).append(p) return { - "data": [user_to_dict(u, by_user.get(u.id, [])) for u in users], + "data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users], "meta": {"total": len(users)}, } @@ -190,7 +239,7 @@ async def create_user( db.add(user) await db.flush() await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role}) - return {"data": user_to_dict(user)} + return {"data": await user_to_dict(user)} @router.patch("/{user_id}") @@ -213,7 +262,7 @@ async def update_user_role( await db.flush() await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role}) projects = await _user_projects(db, user) - return {"data": user_to_dict(user, projects)} + return {"data": await user_to_dict(user, projects)} @router.post("/sync") @@ -273,7 +322,7 @@ async def sync_casdoor_users( for p in projs: by_user.setdefault(p.pm_user_id, []).append(p) return { - "data": [user_to_dict(u, by_user.get(u.id, [])) for u in users], + "data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users], "meta": {"total": len(users), "created": created}, } @@ -307,7 +356,7 @@ async def assign_user_project( {"uname": user.username} ) await db.flush() - return {"data": user_to_dict(user)} + return {"data": await user_to_dict(user)} project = await db.get(Project, payload.project_id) if project is None: @@ -328,4 +377,63 @@ async def assign_user_project( {"pid": project.id, "uname": user.username} ) await db.flush() - return {"data": user_to_dict(user, await _user_projects(db, user))} + return {"data": await user_to_dict(user, await _user_projects(db, user))} + + +# --------------------------------------------------------------------------- +# پنل‌ها و سطوح دسترسی — جایگزین مدل نقش برای کاربران عادی +# --------------------------------------------------------------------------- + + +@router.put("/{user_id}/panels") +async def set_user_panels( + user_id, + payload: UserPanelsUpdate, + _: UserContext = Depends(require_role("admin")), + db: AsyncSession = Depends(get_db), +): + """تخصیص پنل + سطح دسترسی به کاربر — فقط ادمین.""" + user = await db.get(User, user_id) + if user is None: + raise NotFoundError("کاربر یافت نشد") + for panel, level in payload.panels.items(): + if panel not in PANELS: + raise ValidationError(f"پنل نامعتبر: {panel}", field="panels") + if level not in PANEL_LEVELS: + raise ValidationError(f"سطح دسترسی نامعتبر: {level}", field="panels") + await db.execute(delete(UserPanel).where(UserPanel.user_id == user.id)) + for panel, level in payload.panels.items(): + db.add(UserPanel(user_id=user.id, panel=panel, level=level)) + await db.flush() + return {"data": await user_to_dict(user, await _user_projects(db, user), db)} + + +@router.put("/{user_id}/panel-projects") +async def set_user_panel_projects( + user_id, + payload: UserPanelProjectsUpdate, + _: UserContext = Depends(require_role("admin")), + db: AsyncSession = Depends(get_db), +): + """عضویت پروژه‌ای کاربر (M:N) — محدوده داده‌ی سطح limited.""" + user = await db.get(User, user_id) + if user is None: + raise NotFoundError("کاربر یافت نشد") + wanted = list(dict.fromkeys(payload.project_ids)) + if wanted: + found = ( + await db.execute(select(Project).where(Project.id.in_(wanted))) + ).scalars().all() + found_ids = {p.id for p in found} + unknown = [str(pid) for pid in wanted if pid not in found_ids] + if unknown: + raise ValidationError( + "پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids" + ) + await db.execute( + delete(UserProjectMembership).where(UserProjectMembership.user_id == user.id) + ) + for pid in wanted: + db.add(UserProjectMembership(user_id=user.id, project_id=pid)) + await db.flush() + return {"data": await user_to_dict(user, await _user_projects(db, user), db)} diff --git a/backend/app/schemas/procurement.py b/backend/app/schemas/procurement.py index 77e39e7..be587c9 100644 --- a/backend/app/schemas/procurement.py +++ b/backend/app/schemas/procurement.py @@ -346,3 +346,16 @@ class ReceiptUpdate(APIModel): received_qty: Decimal | None = Field(default=None, gt=0) on_time: bool | None = None quality_note: str | None = None + + +class UserPanelsUpdate(APIModel): + """Replace the user's panel assignments. هر پنل: 'full' | 'limited' — + پنل غایب یعنی تخصیص نداشتن و کلید آن حذف می‌شود.""" + + panels: dict[str, str] = Field(default_factory=dict) + + +class UserPanelProjectsUpdate(APIModel): + """Replace the user's project memberships (M:N — جدا از مسئولیت پروژه).""" + + project_ids: list[UUID] = Field(default_factory=list) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 885828d..b17098a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -43,6 +43,9 @@ const OrganizationPage = lazy(() => const AdminPanelPage = lazy(() => import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })), ); +const RequesterHome = lazy(() => + import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })), +); function Loading() { return ( @@ -90,20 +93,30 @@ function RequireAuth({ children }: { children: React.ReactNode }) { return <>{children}; } -// صفحه‌ی اصلی بر اساس نقش: سرپرست پروژه → پنل مدیریت پروژه (PM)؛ مدیران و ادمین → دفتر تدارکات +// صفحه‌ی اصلی بر اساس لایه پنل‌ها (جایگزین نقش‌ها): +// انبار → پنل انبار · تدارکات limited → خانه حداقلی استعلام · بقیه → دفتر تدارکات +// نقش‌های میراث (سرپرست → PM، مدیر پروژه → انبار) برای دوره مهاجرت حفظ شده‌اند. function HomeRoute() { const user = useAuthStore((s) => s.user); + const panels = user?.panels ?? {}; + if (panels.inventory) return ; + if (panels.procurement === "limited") return ; if (user?.role === "supervisor") return ; + if (user?.role === "pm") return ; return ; } -// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): مدیر تدارکات، مدیر پروژه و ادمین +// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های میراث، تدارکات full +// و تدارکات limited (محدود به پروژه‌های عضویت — صفحه /requester) function RequireProcurementWrite({ children }: { children: React.ReactNode }) { const user = useAuthStore((s) => s.user); - if (user?.role !== "admin" && user?.role !== "procurement_manager" && user?.role !== "pm") { - return ; + const panels = user?.panels ?? {}; + if (user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm") { + return <>{children}; } - return <>{children}; + if (panels.procurement === "full") return <>{children}; + if (panels.procurement === "limited") return ; + return ; } // پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود @@ -133,10 +146,15 @@ function RequireOrganization({ children }: { children: React.ReactNode }) { return <>{children}; } -// دسترسی تغییر موجودی انبار: فقط سرپرست پروژه و ادمین (مدیر تدارکات و مدیر پروژه نمی‌توانند موجودی تغییر دهند) +// دسترسی تغییر موجودی انبار: ادمین، سرپرست پروژه (میراث) و پنل انبار limited — +// پنل انبار full طبق طراحی به تغییر موجودی دسترسی ندارد function RequireInventoryChange({ children }: { children: React.ReactNode }) { const user = useAuthStore((s) => s.user); - if (user?.role !== "admin" && user?.role !== "supervisor") { + const allowed = + user?.role === "admin" || + user?.role === "supervisor" || + user?.panels?.inventory === "limited"; + if (!allowed) { return ; } return <>{children}; @@ -303,6 +321,15 @@ export default function App() { } /> + {/* خانه حداقلی استعلام — تدارکات سطح limited */} + + + + } + /> {/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */} s.setProjects); const logout = useLogout(); - const isSupervisor = user?.role === "supervisor"; + // پنل انبار limited هم مثل سرپرست: پروژه‌های فعالش از عضویت‌ها اعتبارسنجی می‌شود + const isSupervisor = user?.role === "supervisor" || user?.panels?.inventory === "limited"; const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok"); useEffect(() => { - if (!user || user.role !== "supervisor") { + const scoped = user?.role === "supervisor" || user?.panels?.inventory === "limited"; + if (!user || !scoped) { setState("ok"); return; } @@ -45,7 +47,7 @@ export function RequirePmProjects({ children }: { children: ReactNode }) { alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user?.id, user?.role, isDev]); + }, [user?.id, user?.role, user?.panels, isDev]); if (state === "loading") { return ( diff --git a/frontend/src/components/layout/MobileTabBar.tsx b/frontend/src/components/layout/MobileTabBar.tsx index 8e36d46..405e641 100644 --- a/frontend/src/components/layout/MobileTabBar.tsx +++ b/frontend/src/components/layout/MobileTabBar.tsx @@ -80,6 +80,11 @@ export function MobileTabBar() { const { pathname } = useLocation(); if (pathname === "/login" || pathname === "/admin") return null; + // خانه حداقلی استعلام (تدارکات limited): بدون تب ERP — PM هم تب‌بار خودش را دارد + if (pathname === "/requester" || pathname === "/panel/pm") return null; + // کاربر فقطِ تدارکات-limited: تب‌های انبار/دفتر ندارد + const panels = useAuthStore.getState().user?.panels ?? {}; + if (panels.procurement === "limited" && !panels.inventory && !panels.pm) return null; // پنل مدیریت پروژه: iframe تمام‌صفحه‌ی PM است و خودش تب‌بار داخلی دارد if (pathname === "/panel/pm") return null; const isPM = role === "pm"; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2e0f898..1b45bb4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1562,6 +1562,10 @@ export interface UserRow { phone: string | null; role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role_label: string | null; + // لایه پنل‌ها (جایگزین نقش‌ها): {'procurement': 'full'|'limited', ...} + panels?: Record; + // عضویت M:N پروژه‌ای — محدوده داده سطح limited + member_project_ids?: string[]; project_id: string | null; project_name: string | null; // 1:N — every project this PM manages (oldest first). @@ -1657,6 +1661,11 @@ export const apiV26 = { }): Promise => postData("/users", body), updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | "supervisor" | null): Promise => patchData(`/users/${id}`, { role }), + // لایه پنل‌ها — تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (فقط ادمین) + setPanels: async (id: string, panels: Record): Promise => + patchData(`/users/${id}/panels`, { panels }), + setPanelProjects: async (id: string, project_ids: string[]): Promise => + patchData(`/users/${id}/panel-projects`, { project_ids }), assignProject: async (id: string, projectId: string | null): Promise => postData(`/users/${id}/project`, { project_id: projectId }), // همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند diff --git a/frontend/src/pages/Dashboard/RequesterHome.tsx b/frontend/src/pages/Dashboard/RequesterHome.tsx new file mode 100644 index 0000000..64032b6 --- /dev/null +++ b/frontend/src/pages/Dashboard/RequesterHome.tsx @@ -0,0 +1,62 @@ +import { Link } from "react-router-dom"; +import { FilePlus2, Info } from "lucide-react"; +import { Header, PageLayout } from "../../components/layout/Header"; +import { useAuthStore } from "../../store/auth"; +import { useProjects } from "../../hooks"; +import { faNum } from "../../lib/formatters"; + +// خانه حداقلی کاربرِ «تدارکات — سطح limited»: فقط افزودن استعلام برای +// پروژه‌های عضویت خودش. هیچ سربرگ دیگری از پنل تدارکات دیده نمی‌شود. +export function RequesterHome() { + const user = useAuthStore((s) => s.user); + const projects = useProjects(); + const memberIds = new Set(user?.member_project_ids ?? []); + const myProjects = memberIds.size + ? projects.filter((p) => memberIds.has(p.id)) + : []; + + return ( +
+
+ +
+ + + شما فقط مجاز به ثبت استعلام برای پروژه‌های تخصیص‌یافته‌ی خود هستید. + +
+ + {myProjects.length === 0 ? ( +
+ هنوز پروژه‌ای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید. +
+ ) : ( + <> +
+ {myProjects.map((p) => ( + +
+ 🏢 {p.name} +

ثبت استعلام خرید برای این پروژه

+
+ + + + + ))} +
+ + + + افزودن استعلام جدید ({faNum(myProjects.length)} پروژه فعال) + + + )} +
+
+ ); +} diff --git a/frontend/src/pages/Inventory/InventoryPage.tsx b/frontend/src/pages/Inventory/InventoryPage.tsx index f5e3cd5..cacfe6c 100644 --- a/frontend/src/pages/Inventory/InventoryPage.tsx +++ b/frontend/src/pages/Inventory/InventoryPage.tsx @@ -54,7 +54,8 @@ export function InventoryPage() { const isManager = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm"; const isSupervisor = user?.role === "supervisor"; const isAdmin = user?.role === "admin"; - const canModify = isSupervisor || isAdmin; + // پنل انبار limited: تغییر موجودی مجاز است؛ full: طبق طراحی ممنوع + const canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited"; const [params] = useSearchParams(); const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({ diff --git a/frontend/src/pages/Organization/OrganizationPage.tsx b/frontend/src/pages/Organization/OrganizationPage.tsx index acfafdb..6fbff6e 100644 --- a/frontend/src/pages/Organization/OrganizationPage.tsx +++ b/frontend/src/pages/Organization/OrganizationPage.tsx @@ -122,6 +122,41 @@ export function OrganizationPage() { } }; + // ---------- لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای ---------- + const setPanel = async (u: UserRow, panel: "procurement" | "inventory", level: "" | "full" | "limited") => { + setBusyId(u.id); + const next = { ...(u.panels ?? {}) }; + if (level) next[panel] = level; + else delete next[panel]; + try { + await apiV26.users.setPanels(u.id, next); + toast.success(`دسترسی «${u.first_name} ${u.last_name}» به‌روزرسانی شد ✓`); + await loadUsers(); + refreshProcurementStore(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "خطا در ثبت دسترسی پنل"); + } finally { + setBusyId(null); + } + }; + + const setMemberProjects = async (u: UserRow, nextIds: string[]) => { + setBusyId(u.id); + try { + await apiV26.users.setPanelProjects(u.id, nextIds); + toast.success(`عضویت پروژه‌ای «${u.first_name} ${u.last_name}» به‌روزرسانی شد ✓`); + await loadUsers(); + refreshProcurementStore(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "خطا در ثبت عضویت پروژه‌ها"); + } finally { + setBusyId(null); + } + }; + + const panelLabel = (level: string | undefined) => + level === "full" ? "کامل" : level === "limited" ? "محدود" : "—"; + // سرپرستان تخصیص‌یافته به یک پروژه const supervisorsOf = (projectId: string) => users.filter( @@ -285,6 +320,99 @@ export function OrganizationPage() { })} + {/* ---------- بخش ۳: پنل‌ها و سطوح دسترسی (جایگزین نقش‌ها) ---------- */} +
+
+

🔑 پنل‌ها و سطوح دسترسی

+

+ به هر شخص پنل «تدارکات» یا «انبار» را تخصیص دهید. تدارکات — کامل: تغییر در کل پنل؛ محدود: فقط افزودن + استعلام برای پروژه‌های عضویت. انبار — کامل: همه پروژه‌ها بدون تغییر موجودی؛ محدود: فقط پروژه‌های عضویت با + امکان تغییر موجودی. ادمین دست‌نخورده است. +

+
+ +
+ + + + + + + + + + + {users.map((u) => { + if (u.role === "admin") { + return ( + + + + + ); + } + const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory); + return ( + + + + + + + ); + })} + +
نامپنل تدارکاتپنل انبارپروژه‌های عضویت (محدوده سطح محدود)
{u.first_name} {u.last_name} + ادمین — دسترسی کامل به همه پنل‌ها (دست‌نخورده) +
+ {u.first_name} {u.last_name} + ({u.username || "—"}) + + {canChangeRoles ? ( + + ) : ( + {panelLabel(u.panels?.procurement)} + )} + + {canChangeRoles ? ( + + ) : ( + {panelLabel(u.panels?.inventory)} + )} + + {hasPanel ? ( +
+ ({ id: p.id, name: p.name }))} + values={u.member_project_ids ?? []} + onChange={(nextIds) => void setMemberProjects(u, nextIds)} + disabled={busyId === u.id} + /> +
+ ) : ( + — ابتدا پنل تخصیص دهید — + )} +
+
+
); diff --git a/frontend/src/pages/Wizard/Step4Projects.tsx b/frontend/src/pages/Wizard/Step4Projects.tsx index 0b1d290..5e6690a 100644 --- a/frontend/src/pages/Wizard/Step4Projects.tsx +++ b/frontend/src/pages/Wizard/Step4Projects.tsx @@ -14,6 +14,8 @@ export interface Step4Props { onAssign: (tempId: string, proj: string) => void; onUnassign: (tempId: string, proj: string) => void; onAddProject: (name: string) => void; + /** سطح limited تدارکات: افزودن پروژه جدید ممنوع */ + canAddProject?: boolean; } const ALL_KEY = "__all__"; @@ -111,7 +113,7 @@ function DroppableProject({ } // مرحله ۴ — تخصیص پروژه‌ها با درگ‌اندروپ (یا کلیک-قلم بعد کلیک-پروژه) -export function Step4Projects({ items, projects, assignment, onAssign, onUnassign, onAddProject }: Step4Props) { +export function Step4Projects({ items, projects, assignment, onAssign, onUnassign, onAddProject, canAddProject = true }: Step4Props) { const [picked, setPicked] = useState(null); const [newProj, setNewProj] = useState(""); @@ -213,8 +215,8 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig {!projects.length && (
هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید.
)} - {/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها */} -
+ {/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها (سطح limited: ممنوع) */} +