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
This commit is contained in:
2026-09-29 13:37:59 +03:30
parent c7d860f431
commit 4a3c226518
35 changed files with 591 additions and 72 deletions
+55 -1
View File
@@ -27,7 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
from app.db.engine import AsyncSessionLocal from app.db.engine import AsyncSessionLocal
from app.models.procurement import Project, User from app.models.procurement import Project, User, UserPanel, UserProjectMembership
from app.utils.errors import ( from app.utils.errors import (
ForbiddenError, ForbiddenError,
ServiceUnavailableError, ServiceUnavailableError,
@@ -106,6 +106,10 @@ class UserContext:
project_id: object | None # uuid.UUID | None — default (first) project of a PM project_id: object | None # uuid.UUID | None — default (first) project of a PM
project_ids: list # every project this PM manages (1:N — empty for non-PMs) project_ids: list # every project this PM manages (1:N — empty for non-PMs)
display_name: str display_name: str
# لایه پنل‌ها (جایگزین نقش‌ها): {'procurement': 'full'|'limited', 'inventory': ...}
panels: dict | None = None
# عضویت M:N در پروژه‌ها — محدوده داده سطح limited (جدا از مسئولیت PM)
member_project_ids: list | None = None
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
@@ -153,6 +157,26 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
project_ids = [p.id for p in projects] project_ids = [p.id for p in projects]
project_id = project_ids[0] if project_ids else None 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 display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
return UserContext( return UserContext(
user=user, user=user,
@@ -160,6 +184,8 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
project_id=project_id, project_id=project_id,
project_ids=project_ids, project_ids=project_ids,
display_name=display_name, display_name=display_name,
panels=panels,
member_project_ids=member_project_ids,
) )
@@ -222,3 +248,31 @@ def require_role(*roles: str):
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست") raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx return ctx
return _dep return _dep
def require_procurement_writer():
"""نوشتن در پنل تدارکات — ادمین، پنل تدارکات 'full'، یا نقش میراث مدیر تدارکات
(دوره مهاجرت از مدل نقش به مدل پنل)."""
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
if ctx.role in ("admin", "procurement_manager"):
return ctx
if (ctx.panels or {}).get("procurement") == "full":
return ctx
raise ForbiddenError("دسترسی نوشتن در پنل تدارکات را ندارید")
return _dep
def allow_quote_create():
"""افزودن استعلام — علاوه بر «نوشتن»، پنل تدارکات 'limited' هم مجاز است
(محدود به پروژه‌های عضویت؛ اعتبارسنجی عضویت در خود endpoint انجام می‌شود)."""
async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext:
if ctx.role in ("admin", "procurement_manager"):
return ctx
if (ctx.panels or {}).get("procurement") in ("full", "limited"):
return ctx
raise ForbiddenError("دسترسی افزودن استعلام ندارید")
return _dep
+41
View File
@@ -66,6 +66,47 @@ class User(Base):
created_at: Mapped[datetime] = _timestamptz() created_at: Mapped[datetime] = _timestamptz()
class UserPanel(Base):
"""user_panels — تخصیص پنل + سطح دسترسی به کاربر (جایگزین مدل نقش).
panel: 'procurement' | 'inventory' | 'pm'
level: 'full' — تدارکات: تغییر کامل در کل پنل؛ انبار: همه پروژه‌ها
(به‌جز تغییر موجودی)
'limited' — تدارکات: فقط افزودن استعلام برای پروژه‌های عضویت؛
انبار: فقط پروژه‌های عضویت + امکان تغییر موجودی
"""
__tablename__ = "user_panels"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
panel: Mapped[str] = mapped_column(Text, nullable=False)
level: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = _timestamptz()
__table_args__ = (UniqueConstraint("user_id", "panel", name="uq_user_panel"),)
class UserProjectMembership(Base):
"""user_project_members — عضویت M:N کاربر در پروژه‌ها، جدا از مسئولیت
پروژه (projects.pm_user_id). محدوده‌ی داده‌ی سطح limited را تعیین می‌کند."""
__tablename__ = "user_project_members"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = _timestamptz()
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_user_project_member"),)
class Supplier(Base): class Supplier(Base):
__tablename__ = "suppliers" __tablename__ = "suppliers"
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
from app.models.procurement import LineItemSpec from app.models.procurement import LineItemSpec
@@ -14,7 +14,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"]) router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict: def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
+1 -1
View File
@@ -94,6 +94,6 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
"access_token": access_token, "access_token": access_token,
"token_type": "bearer", "token_type": "bearer",
"expires_in": body.get("expires_in"), "expires_in": body.get("expires_in"),
"user": user_to_dict(ctx.user, projects), "user": await user_to_dict(ctx.user, projects, db),
} }
} }
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial, Material from app.models.catalog import Brand, BrandMaterial, Material
from app.models.procurement import LineItem from app.models.procurement import LineItem
@@ -13,7 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"]) router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict: def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial from app.models.catalog import Brand, BrandMaterial
from app.schemas.catalog import BrandCreate, BrandUpdate from app.schemas.catalog import BrandCreate, BrandUpdate
@@ -12,7 +12,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/brands", tags=["brands"]) router = APIRouter(prefix="/brands", tags=["brands"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def brand_to_dict(brand: Brand) -> dict: def brand_to_dict(brand: Brand) -> dict:
+21 -3
View File
@@ -76,9 +76,27 @@ async def inventory_scope(
if request.headers.get("authorization"): if request.headers.get("authorization"):
user: UserContext = await get_optional_user(request, db) user: UserContext = await get_optional_user(request, db)
if user is not None: if user is not None:
if user.role is None: panels = user.panels or {}
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") if user.role is None and not panels:
# مدیر تدارکات و مدیر پروژه: دسترسی کامل به پنل انبار، اما فقط‌خواندنی در تغییر موجودی raise ForbiddenError("دسترسی شما هنوز در سیستم تعیین نشده است")
# ادمین: دست‌نخورده — همه‌چیز از جمله تغییر موجودی
if user.role == "admin":
return Scope(role="admin", project_id=project_id, actor=user.display_name)
# لایه پنل‌ها (جایگزین نقش‌ها): پنل انبار اولویت دارد
if "inventory" in panels:
if panels["inventory"] == "full":
# کل سیستم و تمام پروژه‌ها — به‌جز تغییر موجودی
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.member_project_ids or []))
# limited: فقط پروژه‌های عضویت + امکان تغییر موجودی
allowed = list(user.member_project_ids or [])
if not allowed:
raise ForbiddenError("پروژه‌ای به شما تخصیص داده نشده است")
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
# مدیر تدارکات و مدیر پروژه (میراث): دسترسی کامل به پنل انبار، اما فقط‌خواندنی در تغییر موجودی
if user.role in ("procurement_manager", "pm"): if user.role in ("procurement_manager", "pm"):
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.project_ids or [])) return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.project_ids or []))
if user.role == "supervisor": if user.role == "supervisor":
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ItemDecision, ItemReceipt, LineItem from app.models.procurement import ItemDecision, ItemReceipt, LineItem
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
@@ -15,7 +15,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt", prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
tags=["item-receipts"], tags=["item-receipts"],
) )
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
+2 -2
View File
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
@@ -24,7 +24,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets", prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
tags=["item-termsheets"], tags=["item-termsheets"],
) )
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Unit from app.models.catalog import AttributeDefinition, Unit
from app.models.procurement import LineItem, LineItemSpec, Quote from app.models.procurement import LineItem, LineItemSpec, Quote
@@ -13,7 +13,7 @@ from app.services.eav_service import flatten_eav_values
from app.utils.errors import ConflictError, NotFoundError from app.utils.errors import ConflictError, NotFoundError
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"]) router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material, Unit from app.models.catalog import Material, Unit
from app.models.procurement import ItemDecision, LineItem, Quote from app.models.procurement import ItemDecision, LineItem, Quote
@@ -15,7 +15,7 @@ from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"]) router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import MaterialCategory from app.models.catalog import MaterialCategory
from app.models.procurement import Supplier from app.models.procurement import Supplier
@@ -13,7 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/material-categories", tags=["material-categories"]) router = APIRouter(prefix="/material-categories", tags=["material-categories"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def category_to_dict(category: MaterialCategory) -> dict: def category_to_dict(category: MaterialCategory) -> dict:
+2 -2
View File
@@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import exists, select from sqlalchemy import exists, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import ( from app.models.catalog import (
AttributeDefinition, AttributeDefinition,
@@ -39,7 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/materials", tags=["materials"]) router = APIRouter(prefix="/materials", tags=["materials"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def _coerce_filter_value(data_type: str, raw: str): def _coerce_filter_value(data_type: str, raw: str):
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user, require_role from app.auth import UserContext, get_optional_user, require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Project, Quote, User from app.models.procurement import Project, Quote, User
from app.schemas.procurement import ProjectCreate, ProjectUpdate from app.schemas.procurement import ProjectCreate, ProjectUpdate
@@ -13,7 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_date, to_iso from app.utils.serialization import to_date, to_iso
router = APIRouter(prefix="/projects", tags=["projects"]) router = APIRouter(prefix="/projects", tags=["projects"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None: async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Quote, QuoteEvent from app.models.procurement import Quote, QuoteEvent
from app.schemas.procurement import NoteEventIn from app.schemas.procurement import NoteEventIn
@@ -12,7 +12,7 @@ from app.utils.errors import NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"]) router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
@router.get("") @router.get("")
+13 -4
View File
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import exists, func, or_, select from sqlalchemy import exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import UserContext, allow_quote_create, require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material from app.models.catalog import Material
from app.models.procurement import ( from app.models.procurement import (
@@ -37,14 +37,14 @@ from app.services.expand_service import (
serialize_quote_with_children, serialize_quote_with_children,
serialize_quotes, serialize_quotes,
) )
from app.utils.errors import NotFoundError from app.utils.errors import ForbiddenError, NotFoundError, ValidationError
from app.utils.pagination import sort_query from app.utils.pagination import sort_query
from app.utils.security import get_actor from app.utils.security import get_actor
router = APIRouter(prefix="/quotes", tags=["quotes"]) router = APIRouter(prefix="/quotes", tags=["quotes"])
# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد) # عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
# --- list filter helpers --------------------------------------------------- # --- list filter helpers ---------------------------------------------------
@@ -164,8 +164,17 @@ async def create_quote(
payload: QuoteCreate, payload: QuoteCreate,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
actor: str = Depends(get_actor), actor: str = Depends(get_actor),
_auth=Depends(_procurement_writer), ctx: UserContext = Depends(allow_quote_create()),
_auth=None,
): ):
# سطح limited: استعلام فقط برای پروژه‌های عضویت خودش
if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin":
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) quote = await quote_service.create_quote(db, payload, actor)
return {"data": await serialize_quote_with_children(db, quote)} return {"data": await serialize_quote_with_children(db, quote)}
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Termsheet, TermsheetScheduleStep from app.models.procurement import Termsheet, TermsheetScheduleStep
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
@@ -16,7 +16,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps", prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
tags=["schedule-steps"], tags=["schedule-steps"],
) )
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet: async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import StandardConversion from app.models.catalog import StandardConversion
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
@@ -12,7 +12,7 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"]) router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def sc_to_dict(sc: StandardConversion) -> dict: def sc_to_dict(sc: StandardConversion) -> dict:
+2 -2
View File
@@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import Material, MaterialCategory from app.models.catalog import Material, MaterialCategory
from app.models.procurement import Supplier, SupplierMaterial from app.models.procurement import Supplier, SupplierMaterial
@@ -19,7 +19,7 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(tags=["supplier-materials"]) router = APIRouter(tags=["supplier-materials"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def link_to_dict(link: SupplierMaterial, material: Material | None = None, def link_to_dict(link: SupplierMaterial, material: Material | None = None,
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import MaterialCategory from app.models.catalog import MaterialCategory
from app.models.procurement import Quote, Supplier from app.models.procurement import Quote, Supplier
@@ -13,7 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso from app.utils.serialization import to_iso
router = APIRouter(prefix="/suppliers", tags=["suppliers"]) router = APIRouter(prefix="/suppliers", tags=["suppliers"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict: def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import ( from app.models.procurement import (
ItemDecision, ItemDecision,
@@ -27,7 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"]) router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
+2 -2
View File
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_role from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
from app.models.procurement import LineItem, LineItemSpec from app.models.procurement import LineItem, LineItemSpec
@@ -12,7 +12,7 @@ from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/units", tags=["units"]) router = APIRouter(prefix="/units", tags=["units"])
_procurement_writer = require_role("admin", "procurement_manager") _procurement_writer = require_procurement_writer()
def unit_full_to_dict(unit: Unit) -> dict: def unit_full_to_dict(unit: Unit) -> dict:
+121 -13
View File
@@ -17,15 +17,21 @@ import uuid
import httpx import httpx
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select, update, text from sqlalchemy import delete, select, update, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, require_role from app.auth import UserContext, get_current_user, require_role
from app.utils.audit import record as audit_record from app.utils.audit import record as audit_record
from app.config import settings from app.config import settings
from app.db.engine import get_db from app.db.engine import get_db
from app.models.procurement import Project, User from app.models.procurement import Project, User, UserPanel, UserProjectMembership
from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate from app.schemas.procurement import (
UserCreate,
UserPanelsUpdate,
UserPanelProjectsUpdate,
UserProjectUpdate,
UserRoleUpdate,
)
from app.utils.errors import ( from app.utils.errors import (
ConflictError, ConflictError,
ForbiddenError, ForbiddenError,
@@ -46,12 +52,39 @@ USER_ROLE_LABELS = {
} }
def user_to_dict(user: User, projects: list[Project] | None = None) -> dict: PANEL_LEVELS = ("full", "limited")
PANELS = ("procurement", "inventory", "pm")
async def _panels_of(db: AsyncSession, user_id) -> dict:
"""panel -> level map for the user (لایه پنل‌ها — جایگزین نقش‌ها)."""
rows = (
await db.execute(
select(UserPanel).where(UserPanel.user_id == user_id).order_by(UserPanel.created_at)
)
).scalars().all()
return {row.panel: row.level for row in rows}
async def _member_project_ids_of(db: AsyncSession, user_id) -> list[str]:
"""عضویت M:N کاربر در پروژه‌ها (جدا از مسئولیت) — رشته."""
rows = (
await db.execute(
select(UserProjectMembership.project_id)
.where(UserProjectMembership.user_id == user_id)
.order_by(UserProjectMembership.created_at)
)
).scalars().all()
return [str(pid) for pid in rows]
async def user_to_dict(user: User, projects: list[Project] | None = None, db: AsyncSession | None = None) -> dict:
"""Serialize a user. `projects` = every project the PM manages (1:N); """Serialize a user. `projects` = every project the PM manages (1:N);
project_id/project_name stay as the first (oldest) one for compatibility.""" project_id/project_name stay as the first (oldest) one for compatibility.
با db، پنل‌های تخصیصی و عضویت پروژه‌ای هم برمی‌گردند."""
projects = projects or [] projects = projects or []
first = projects[0] if projects else None first = projects[0] if projects else None
return { out = {
"id": str(user.id), "id": str(user.id),
"casdoor_id": user.casdoor_id, "casdoor_id": user.casdoor_id,
"username": user.username, "username": user.username,
@@ -65,6 +98,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], "projects": [{"id": str(p.id), "name": p.name} for p in projects],
"created_at": to_iso(user.created_at), "created_at": to_iso(user.created_at),
} }
if db is not None:
out["panels"] = await _panels_of(db, user.id)
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: async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
@@ -130,7 +167,7 @@ async def _casdoor_users() -> list[dict]:
@router.get("/me") @router.get("/me")
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)): async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
projects = await _user_projects(db, ctx.user) projects = await _user_projects(db, ctx.user)
return {"data": user_to_dict(ctx.user, projects)} return {"data": await user_to_dict(ctx.user, projects, db)}
@router.get("/me/projects") @router.get("/me/projects")
@@ -141,6 +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 active project against this list and falls back gracefully when a
project is revoked.""" project is revoked."""
projects = await _user_projects(db, ctx.user) projects = await _user_projects(db, ctx.user)
# عضویت‌های M:N (لایه پنل‌ها) هم به فهرست اضافه می‌شود
member_ids = set(ctx.member_project_ids or [])
have = {p.id for p in projects}
if member_ids:
extra = (
await db.execute(
select(Project)
.where(Project.id.in_(member_ids))
.order_by(Project.created_at, Project.id)
)
).scalars().all()
projects = list(projects) + [p for p in extra if p.id not in have]
return {"data": [{"id": str(p.id), "name": p.name} for p in projects]} return {"data": [{"id": str(p.id), "name": p.name} for p in projects]}
@@ -164,7 +213,7 @@ async def list_users(
for p in projs: for p in projs:
by_user.setdefault(p.pm_user_id, []).append(p) by_user.setdefault(p.pm_user_id, []).append(p)
return { return {
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users], "data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
"meta": {"total": len(users)}, "meta": {"total": len(users)},
} }
@@ -190,7 +239,7 @@ async def create_user(
db.add(user) db.add(user)
await db.flush() await db.flush()
await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role}) await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role})
return {"data": user_to_dict(user)} return {"data": await user_to_dict(user)}
@router.patch("/{user_id}") @router.patch("/{user_id}")
@@ -213,7 +262,7 @@ async def update_user_role(
await db.flush() await db.flush()
await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role}) await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role})
projects = await _user_projects(db, user) projects = await _user_projects(db, user)
return {"data": user_to_dict(user, projects)} return {"data": await user_to_dict(user, projects)}
@router.post("/sync") @router.post("/sync")
@@ -273,7 +322,7 @@ async def sync_casdoor_users(
for p in projs: for p in projs:
by_user.setdefault(p.pm_user_id, []).append(p) by_user.setdefault(p.pm_user_id, []).append(p)
return { return {
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users], "data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users],
"meta": {"total": len(users), "created": created}, "meta": {"total": len(users), "created": created},
} }
@@ -307,7 +356,7 @@ async def assign_user_project(
{"uname": user.username} {"uname": user.username}
) )
await db.flush() await db.flush()
return {"data": user_to_dict(user)} return {"data": await user_to_dict(user)}
project = await db.get(Project, payload.project_id) project = await db.get(Project, payload.project_id)
if project is None: if project is None:
@@ -328,4 +377,63 @@ async def assign_user_project(
{"pid": project.id, "uname": user.username} {"pid": project.id, "uname": user.username}
) )
await db.flush() await db.flush()
return {"data": user_to_dict(user, await _user_projects(db, user))} return {"data": await user_to_dict(user, await _user_projects(db, user))}
# ---------------------------------------------------------------------------
# پنل‌ها و سطوح دسترسی — جایگزین مدل نقش برای کاربران عادی
# ---------------------------------------------------------------------------
@router.put("/{user_id}/panels")
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)}
+13
View File
@@ -346,3 +346,16 @@ class ReceiptUpdate(APIModel):
received_qty: Decimal | None = Field(default=None, gt=0) received_qty: Decimal | None = Field(default=None, gt=0)
on_time: bool | None = None on_time: bool | None = None
quality_note: str | None = None quality_note: str | None = None
class UserPanelsUpdate(APIModel):
"""Replace the user's panel assignments. هر پنل: 'full' | 'limited' —
پنل غایب یعنی تخصیص نداشتن و کلید آن حذف می‌شود."""
panels: dict[str, str] = Field(default_factory=dict)
class UserPanelProjectsUpdate(APIModel):
"""Replace the user's project memberships (M:N — جدا از مسئولیت پروژه)."""
project_ids: list[UUID] = Field(default_factory=list)
+34 -7
View File
@@ -43,6 +43,9 @@ const OrganizationPage = lazy(() =>
const AdminPanelPage = lazy(() => const AdminPanelPage = lazy(() =>
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })), import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
); );
const RequesterHome = lazy(() =>
import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })),
);
function Loading() { function Loading() {
return ( return (
@@ -90,20 +93,30 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <>{children}</>;
} }
// صفحه‌ی اصلی بر اساس نقش: سرپرست پروژه → پنل مدیریت پروژه (PM)؛ مدیران و ادمین → دفتر تدارکات // صفحه‌ی اصلی بر اساس لایه پنل‌ها (جایگزین نقش‌ها):
// انبار → پنل انبار · تدارکات limited → خانه حداقلی استعلام · بقیه → دفتر تدارکات
// نقش‌های میراث (سرپرست → PM، مدیر پروژه → انبار) برای دوره مهاجرت حفظ شده‌اند.
function HomeRoute() { function HomeRoute() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
if (panels.inventory) return <Navigate to="/inventory" replace />;
if (panels.procurement === "limited") return <RequesterHome />;
if (user?.role === "supervisor") return <Navigate to="/panel/pm" replace />; if (user?.role === "supervisor") return <Navigate to="/panel/pm" replace />;
if (user?.role === "pm") return <Navigate to="/inventory" replace />;
return <DashboardPage />; return <DashboardPage />;
} }
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): مدیر تدارکات، مدیر پروژه و ادمین // دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های میراث، تدارکات full
// و تدارکات limited (محدود به پروژه‌های عضویت — صفحه /requester)
function RequireProcurementWrite({ children }: { children: React.ReactNode }) { function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
if (user?.role !== "admin" && user?.role !== "procurement_manager" && user?.role !== "pm") { const panels = user?.panels ?? {};
return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />; 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 <Navigate to="/requester" replace />;
return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
} }
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود // پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود
@@ -133,10 +146,15 @@ function RequireOrganization({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <>{children}</>;
} }
// دسترسی تغییر موجودی انبار: فقط سرپرست پروژه و ادمین (مدیر تدارکات و مدیر پروژه نمی‌توانند موجودی تغییر دهند) // دسترسی تغییر موجودی انبار: ادمین، سرپرست پروژه (میراث) و پنل انبار limited —
// پنل انبار full طبق طراحی به تغییر موجودی دسترسی ندارد
function RequireInventoryChange({ children }: { children: React.ReactNode }) { function RequireInventoryChange({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
if (user?.role !== "admin" && user?.role !== "supervisor") { const allowed =
user?.role === "admin" ||
user?.role === "supervisor" ||
user?.panels?.inventory === "limited";
if (!allowed) {
return <Navigate to="/inventory" replace />; return <Navigate to="/inventory" replace />;
} }
return <>{children}</>; return <>{children}</>;
@@ -303,6 +321,15 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
{/* خانه حداقلی استعلام — تدارکات سطح limited */}
<Route
path="/requester"
element={
<RequireAuth>
<RequesterHome />
</RequireAuth>
}
/>
{/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */} {/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */}
<Route <Route
path="/panel/pm" path="/panel/pm"
@@ -16,11 +16,13 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
const setProjects = useAuthStore((s) => s.setProjects); const setProjects = useAuthStore((s) => s.setProjects);
const logout = useLogout(); const logout = useLogout();
const isSupervisor = user?.role === "supervisor"; // پنل انبار limited هم مثل سرپرست: پروژه‌های فعالش از عضویت‌ها اعتبارسنجی می‌شود
const isSupervisor = user?.role === "supervisor" || user?.panels?.inventory === "limited";
const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok"); const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok");
useEffect(() => { useEffect(() => {
if (!user || user.role !== "supervisor") { const scoped = user?.role === "supervisor" || user?.panels?.inventory === "limited";
if (!user || !scoped) {
setState("ok"); setState("ok");
return; return;
} }
@@ -45,7 +47,7 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
alive = false; alive = false;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, user?.role, isDev]); }, [user?.id, user?.role, user?.panels, isDev]);
if (state === "loading") { if (state === "loading") {
return ( return (
@@ -80,6 +80,11 @@ export function MobileTabBar() {
const { pathname } = useLocation(); const { pathname } = useLocation();
if (pathname === "/login" || pathname === "/admin") return null; if (pathname === "/login" || pathname === "/admin") return null;
// خانه حداقلی استعلام (تدارکات limited): بدون تب ERP — PM هم تب‌بار خودش را دارد
if (pathname === "/requester" || pathname === "/panel/pm") return null;
// کاربر فقطِ تدارکات-limited: تب‌های انبار/دفتر ندارد
const panels = useAuthStore.getState().user?.panels ?? {};
if (panels.procurement === "limited" && !panels.inventory && !panels.pm) return null;
// پنل مدیریت پروژه: iframe تمام‌صفحه‌ی PM است و خودش تب‌بار داخلی دارد // پنل مدیریت پروژه: iframe تمام‌صفحه‌ی PM است و خودش تب‌بار داخلی دارد
if (pathname === "/panel/pm") return null; if (pathname === "/panel/pm") return null;
const isPM = role === "pm"; const isPM = role === "pm";
+9
View File
@@ -1562,6 +1562,10 @@ export interface UserRow {
phone: string | null; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
role_label: string | null; role_label: string | null;
// لایه پنل‌ها (جایگزین نقش‌ها): {'procurement': 'full'|'limited', ...}
panels?: Record<string, string>;
// عضویت M:N پروژه‌ای — محدوده داده سطح limited
member_project_ids?: string[];
project_id: string | null; project_id: string | null;
project_name: string | null; project_name: string | null;
// 1:N — every project this PM manages (oldest first). // 1:N — every project this PM manages (oldest first).
@@ -1657,6 +1661,11 @@ export const apiV26 = {
}): Promise<UserRow> => postData<UserRow>("/users", body), }): Promise<UserRow> => postData<UserRow>("/users", body),
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | "supervisor" | null): Promise<UserRow> => updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | "supervisor" | null): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}`, { role }), patchData<UserRow>(`/users/${id}`, { role }),
// لایه پنل‌ها — تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (فقط ادمین)
setPanels: async (id: string, panels: Record<string, string>): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}/panels`, { panels }),
setPanelProjects: async (id: string, project_ids: string[]): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
assignProject: async (id: string, projectId: string | null): Promise<UserRow> => assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }), postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
// همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند // همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند
@@ -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 (
<div className="min-h-screen">
<Header title="افزودن استعلام" brand />
<PageLayout className="flex flex-col gap-4">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
<span className="inline-flex items-center gap-1.5">
<Info className="h-3.5 w-3.5" />
شما فقط مجاز به ثبت استعلام برای پروژه‌های تخصیص‌یافته‌ی خود هستید.
</span>
</div>
{myProjects.length === 0 ? (
<div className="rounded-xl border border-dashed border-line bg-panel px-4 py-8 text-center text-[12.5px] text-muted">
هنوز پروژه‌ای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید.
</div>
) : (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{myProjects.map((p) => (
<Link
key={p.id}
to="/wizard"
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50"
>
<div>
<b className="text-[13.5px]">🏢 {p.name}</b>
<p className="mt-0.5 text-[11.5px] text-muted">ثبت استعلام خرید برای این پروژه</p>
</div>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-accent/10 text-accent">
<FilePlus2 className="h-5 w-5" />
</span>
</Link>
))}
</div>
<Link to="/wizard" className="btn-line-file no-underline">
<FilePlus2 className="h-3.5 w-3.5" />
افزودن استعلام جدید ({faNum(myProjects.length)} پروژه فعال)
</Link>
</>
)}
</PageLayout>
</div>
);
}
@@ -54,7 +54,8 @@ export function InventoryPage() {
const isManager = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm"; const isManager = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
const isSupervisor = user?.role === "supervisor"; const isSupervisor = user?.role === "supervisor";
const isAdmin = user?.role === "admin"; const isAdmin = user?.role === "admin";
const canModify = isSupervisor || isAdmin; // پنل انبار limited: تغییر موجودی مجاز است؛ full: طبق طراحی ممنوع
const canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited";
const [params] = useSearchParams(); const [params] = useSearchParams();
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({ const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
@@ -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) => const supervisorsOf = (projectId: string) =>
users.filter( users.filter(
@@ -285,6 +320,99 @@ export function OrganizationPage() {
})} })}
</div> </div>
</div> </div>
{/* ---------- بخش ۳: پنل‌ها و سطوح دسترسی (جایگزین نقش‌ها) ---------- */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-3">
<h3 className="text-[14px] font-bold">🔑 پنل‌ها و سطوح دسترسی</h3>
<p className="mt-0.5 text-[11.5px] leading-5 text-muted">
به هر شخص پنل «تدارکات» یا «انبار» را تخصیص دهید. تدارکات — کامل: تغییر در کل پنل؛ محدود: فقط افزودن
استعلام برای پروژه‌های عضویت. انبار — کامل: همه پروژه‌ها بدون تغییر موجودی؛ محدود: فقط پروژه‌های عضویت با
امکان تغییر موجودی. ادمین دست‌نخورده است.
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b border-line text-right text-muted">
<th className="py-2 pe-3">نام</th>
<th className="py-2 pe-3">پنل تدارکات</th>
<th className="py-2 pe-3">پنل انبار</th>
<th className="py-2">پروژه‌های عضویت (محدوده سطح محدود)</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
if (u.role === "admin") {
return (
<tr key={u.id} className="border-b border-line/50">
<td className="py-2.5 pe-3"><b>{u.first_name} {u.last_name}</b></td>
<td colSpan={3} className="py-2.5 text-[11px] text-muted">
ادمین — دسترسی کامل به همه پنل‌ها (دست‌نخورده)
</td>
</tr>
);
}
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory);
return (
<tr key={u.id} className="border-b border-line/50">
<td className="py-2.5 pe-3">
<b>{u.first_name} {u.last_name}</b>
<span className="ms-1 font-mono text-[10px] text-muted">({u.username || "—"})</span>
</td>
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.panels?.procurement ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "procurement", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل</option>
<option value="limited">محدود (فقط استعلام)</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.procurement)}</span>
)}
</td>
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.panels?.inventory ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "inventory", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل (بدون تغییر موجودی)</option>
<option value="limited">محدود (با تغییر موجودی)</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.inventory)}</span>
)}
</td>
<td className="py-2.5">
{hasPanel ? (
<div className="w-72">
<ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))}
values={u.member_project_ids ?? []}
onChange={(nextIds) => void setMemberProjects(u, nextIds)}
disabled={busyId === u.id}
/>
</div>
) : (
<span className="text-[11px] text-muted">— ابتدا پنل تخصیص دهید —</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</PageLayout> </PageLayout>
</div> </div>
); );
+5 -3
View File
@@ -14,6 +14,8 @@ export interface Step4Props {
onAssign: (tempId: string, proj: string) => void; onAssign: (tempId: string, proj: string) => void;
onUnassign: (tempId: string, proj: string) => void; onUnassign: (tempId: string, proj: string) => void;
onAddProject: (name: string) => void; onAddProject: (name: string) => void;
/** سطح limited تدارکات: افزودن پروژه جدید ممنوع */
canAddProject?: boolean;
} }
const ALL_KEY = "__all__"; const ALL_KEY = "__all__";
@@ -111,7 +113,7 @@ function DroppableProject({
} }
// مرحله ۴ — تخصیص پروژه‌ها با درگ‌اندروپ (یا کلیک-قلم بعد کلیک-پروژه) // مرحله ۴ — تخصیص پروژه‌ها با درگ‌اندروپ (یا کلیک-قلم بعد کلیک-پروژه)
export function Step4Projects({ items, projects, assignment, onAssign, onUnassign, onAddProject }: Step4Props) { export function Step4Projects({ items, projects, assignment, onAssign, onUnassign, onAddProject, canAddProject = true }: Step4Props) {
const [picked, setPicked] = useState<string | null>(null); const [picked, setPicked] = useState<string | null>(null);
const [newProj, setNewProj] = useState(""); const [newProj, setNewProj] = useState("");
@@ -213,8 +215,8 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
{!projects.length && ( {!projects.length && (
<div className="dd-empty">هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید.</div> <div className="dd-empty">هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید.</div>
)} )}
{/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها */} {/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها (سطح limited: ممنوع) */}
<div className="mt-3 rounded-md border border-dashed border-line p-2"> <div className="mt-3 rounded-md border border-dashed border-line p-2" hidden={!canAddProject}>
<div className="mb-1.5 text-[11.5px] font-semibold text-muted">افزودن پروژه‌ی جدید:</div> <div className="mb-1.5 text-[11.5px] font-semibold text-muted">افزودن پروژه‌ی جدید:</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
+19 -2
View File
@@ -36,6 +36,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { useAuthStore } from "../../store/auth";
const TOTAL_STEPS = 5; const TOTAL_STEPS = 5;
@@ -49,6 +50,17 @@ export function WizardPage() {
const suppliers = useSuppliers(); const suppliers = useSuppliers();
const materials = useMaterials(); const materials = useMaterials();
const projects = useProjects(); const projects = useProjects();
// سطح limited تدارکات: استعلام فقط برای پروژه‌های عضویت — بدون افزودن پروژه جدید
const user = useAuthStore((s) => s.user);
const isLimitedProcurement =
user?.panels?.procurement === "limited" && user?.role !== "admin";
const memberProjectNames = useMemo(
() =>
projects
.filter((p) => (user?.member_project_ids ?? []).includes(p.id))
.map((p) => p.name),
[projects, user],
);
const topics = useAppStore((s) => s.topics); const topics = useAppStore((s) => s.topics);
const createQuote = useCreateQuote(); const createQuote = useCreateQuote();
const addSupplier = useAddSupplier(); const addSupplier = useAddSupplier();
@@ -131,13 +143,17 @@ export function WizardPage() {
// ---------- پروژه‌ها ---------- // ---------- پروژه‌ها ----------
const projectNames = useMemo(() => { const projectNames = useMemo(() => {
const names = new Set<string>(projects.map((p) => p.name)); const names = new Set<string>(
isLimitedProcurement
? memberProjectNames
: projects.map((p) => p.name),
);
quotes.forEach((q) => { quotes.forEach((q) => {
if (q.project) names.add(q.project); if (q.project) names.add(q.project);
(q.projects || []).forEach((n) => names.add(n)); (q.projects || []).forEach((n) => names.add(n));
}); });
return [...names]; return [...names];
}, [projects, quotes]); }, [projects, quotes, isLimitedProcurement, memberProjectNames]);
const assign = (tempId: string, proj: string) => { const assign = (tempId: string, proj: string) => {
setAssignment((a) => { setAssignment((a) => {
@@ -471,6 +487,7 @@ export function WizardPage() {
assignment={assignment} assignment={assignment}
onAssign={assign} onAssign={assign}
onUnassign={unassign} onUnassign={unassign}
canAddProject={!isLimitedProcurement}
onAddProject={(name) => { onAddProject={(name) => {
void addProject.mutateAsync({ id: nextId("p", projects), name }); void addProject.mutateAsync({ id: nextId("p", projects), name });
}} }}
+3
View File
@@ -17,6 +17,9 @@ export interface AuthUser {
phone: string | null; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
role_label: string | null; role_label: string | null;
// لایه پنل‌ها (جایگزین نقش‌ها)
panels?: Record<string, string>;
member_project_ids?: string[];
project_id: string | null; // پروژه‌ی فعال (PM) — با سوییچر عوض می‌شود project_id: string | null; // پروژه‌ی فعال (PM) — با سوییچر عوض می‌شود
project_name: string | null; project_name: string | null;
projects?: { id: string; name: string }[]; // همه‌ی پروژه‌های تخصیص‌یافته (1:N) projects?: { id: string; name: string }[]; // همه‌ی پروژه‌های تخصیص‌یافته (1:N)
+20
View File
@@ -637,3 +637,23 @@ ALTER TABLE projects ADD COLUMN IF NOT EXISTS progress DOUBLE PRECISION DEFAULT
ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now(); ALTER TABLE projects ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT now();
COMMIT; COMMIT;
-- ---------- PANEL ACCESS (جایگزین مدل نقش) ----------
-- تخصیص پنل + سطح دسترسی به ازای هر کاربر و عضویت M:N پروژه‌ای
CREATE TABLE IF NOT EXISTS user_panels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
panel TEXT NOT NULL CHECK (panel IN ('procurement', 'inventory', 'pm')),
level TEXT NOT NULL CHECK (level IN ('full', 'limited')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, panel)
);
CREATE TABLE IF NOT EXISTS user_project_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, project_id)
);
COMMIT;