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