feat(pm): multi-project PMs (1:N) with project switching, scoping validation and org multi-select
Task 1 — PM 1:N + access-loss handling:
- projects.pm_user_id had no UNIQUE constraint (verified live + in schema); added a lookup index via init_db
- auth: UserContext.project_ids = every project of a PM (oldest first); project_id stays the first for compatibility
- GET /users/me/projects — assigned projects of the caller (drives the switcher + revocation validation)
- login / /users/me / /users list now return the full projects array per user
- /inventory/* and /pm/* scopes accept an explicit ?project_id=… and verify the PM manages it (403 otherwise); without the param they fall back to the first project; admin/manager use it as an optional filter
- POST /users/{id}/project is multi-aware: assigns one project (replacing that project's previous PM) and KEEPS the user's other assignments; null clears all
- PATCH /projects/{id} accepts pm_user_id (admin / procurement manager only)
- frontend: auth store holds projects + the active project (persisted via the existing localStorage store); RequirePmProjects re-validates on load, falls back when the active project was revoked, and shows a dedicated no-projects screen with logout (no redirect loop, no re-auth)
- Panels menu: «مدیریت انبار» gets a ChevronDown accordion listing the PM's projects with «✓ فعلی»; selecting one switches only the active project and reloads the section data
- inventory/pm API calls now send the active project_id explicitly
Task 2 — Organization page:
- new searchable ProjectMultiSelect combobox (chips with ×, live Persian filtering via normalize, assigned projects hidden) replaces the single select for PM users
- add = PATCH project pm_user_id=<user>; × = pm_user_id=null; table and project cards sync immediately; RBAC unchanged (role column admin-only, combobox editable for admin + procurement manager)
Verified locally: 17 scripted checks — multi-project assignment without unique conflicts, /users/me/projects, explicit project_id authorization (foreign project 403 on /inventory, /pm/on-the-way, /pm/receipts), PM blocked from pm_user_id changes, legacy assign endpoint keeps other assignments, revocation removes the project from the list, zero-project PM gets an empty list and a guarded inventory endpoint. All test data cleaned up afterwards.
This commit is contained in:
+34
-7
@@ -103,15 +103,17 @@ async def _verify_token(token: str) -> dict:
|
||||
class UserContext:
|
||||
user: User
|
||||
role: str | None
|
||||
project_id: object | None # uuid.UUID | None
|
||||
project_id: object | None # uuid.UUID | None — default (first) project of a PM
|
||||
project_ids: list # every project this PM manages (1:N — empty for non-PMs)
|
||||
display_name: str
|
||||
|
||||
|
||||
async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||
"""Auto-provision the `users` row from token claims; resolve role + PM project.
|
||||
"""Auto-provision the `users` row from token claims; resolve role + PM projects.
|
||||
|
||||
First login: creates the row with role NULL (an admin assigns it later).
|
||||
PMs: resolves their single project via projects.pm_user_id.
|
||||
PMs: resolves EVERY project they manage (projects.pm_user_id = user);
|
||||
`project_id` stays the first one (oldest) for backward compatibility.
|
||||
"""
|
||||
casdoor_id = str(claims.get("sub") or claims.get("id") or "")
|
||||
if not casdoor_id:
|
||||
@@ -134,13 +136,38 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
project_id = None
|
||||
project_ids: list = []
|
||||
if user.role == "pm":
|
||||
project = await db.scalar(select(Project).where(Project.pm_user_id == user.id))
|
||||
project_id = project.id if project else None
|
||||
projects = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
project_ids = [p.id for p in projects]
|
||||
project_id = project_ids[0] if project_ids else None
|
||||
|
||||
display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id
|
||||
return UserContext(user=user, role=user.role, project_id=project_id, display_name=display_name)
|
||||
return UserContext(
|
||||
user=user,
|
||||
role=user.role,
|
||||
project_id=project_id,
|
||||
project_ids=project_ids,
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
|
||||
async def pm_project_ids(db: AsyncSession, user: User) -> list:
|
||||
"""Every project id managed by the given PM user (oldest first)."""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Project.id)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
return list(rows)
|
||||
|
||||
|
||||
async def get_optional_user(request: Request, db: AsyncSession) -> UserContext | None:
|
||||
|
||||
@@ -302,6 +302,11 @@ _V26_DDL = [
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_suppliers_category_id ON suppliers(category_id)
|
||||
""",
|
||||
# PM → projects is 1:N (a PM may manage several projects). No UNIQUE on
|
||||
# pm_user_id — just a lookup index for "projects of this PM" queries.
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_pm_user_id ON projects(pm_user_id)
|
||||
""",
|
||||
# supplier_materials (v2.5) + its triggers (functions are CREATE OR REPLACE below).
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS supplier_materials (
|
||||
|
||||
@@ -20,7 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.auth import _verify_token, context_from_claims
|
||||
from app.config import settings
|
||||
from app.db.engine import get_db
|
||||
from app.routers.users import _user_project, user_to_dict
|
||||
from app.routers.users import _user_projects, user_to_dict
|
||||
from app.utils.errors import ServiceUnavailableError, UnauthorizedError
|
||||
|
||||
logger = logging.getLogger("procurement.auth")
|
||||
@@ -74,12 +74,12 @@ async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
# the app-side user (auto-provision on first login).
|
||||
claims = await _verify_token(access_token)
|
||||
ctx = await context_from_claims(claims, db)
|
||||
project = await _user_project(db, ctx.user) if ctx.role == "pm" else None
|
||||
projects = await _user_projects(db, ctx.user)
|
||||
return {
|
||||
"data": {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": body.get("expires_in"),
|
||||
"user": user_to_dict(ctx.user, project),
|
||||
"user": user_to_dict(ctx.user, projects),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,10 +59,18 @@ class Scope:
|
||||
actor: str | None
|
||||
|
||||
|
||||
async def inventory_scope(request: Request, db: AsyncSession = Depends(get_db)) -> Scope:
|
||||
"""Resolve the caller role/project — Casdoor JWT first, dev headers as fallback."""
|
||||
# Primary path: Casdoor JWT. Role from users.role; PM project from
|
||||
# projects.pm_user_id (single PM per project).
|
||||
async def inventory_scope(
|
||||
request: Request,
|
||||
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: دادههای همین پروژه)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Scope:
|
||||
"""Resolve the caller role/project — Casdoor JWT first, dev headers as fallback.
|
||||
|
||||
The active project is explicit via `?project_id=…`. For a PM the backend
|
||||
verifies they manage that project (projects.pm_user_id, 1:N); without the
|
||||
param it falls back to their first assigned project. Admin / procurement
|
||||
manager may pass it as a filter (no param = all projects).
|
||||
"""
|
||||
if request.headers.get("authorization"):
|
||||
user: UserContext = await get_optional_user(request, db)
|
||||
if user is not None:
|
||||
@@ -70,16 +78,21 @@ async def inventory_scope(request: Request, db: AsyncSession = Depends(get_db))
|
||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقطخواندنی به پنل انبار.
|
||||
if user.role == "procurement_manager":
|
||||
return Scope(role="admin", project_id=None, actor=user.display_name)
|
||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||
if user.role not in ("pm", "admin"):
|
||||
raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role")
|
||||
if user.role == "pm":
|
||||
if user.project_id is None:
|
||||
allowed = list(user.project_ids or [])
|
||||
if project_id is not None:
|
||||
if project_id not in allowed:
|
||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||
return Scope(role="pm", project_id=project_id, actor=user.display_name)
|
||||
if not allowed:
|
||||
raise ValidationError(
|
||||
"کاربر PM به هیچ پروژهای متصل نیست (projects.pm_user_id)", field="project_id"
|
||||
)
|
||||
return Scope(role="pm", project_id=user.project_id, actor=user.display_name)
|
||||
return Scope(role="admin", project_id=None, actor=user.display_name)
|
||||
return Scope(role="pm", project_id=allowed[0], actor=user.display_name)
|
||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||
|
||||
# Dev fallback: header-based identity (fork's placeholder mode) — only
|
||||
# when an explicit X-User-Role header is sent. No auth info at all → 401.
|
||||
@@ -90,8 +103,9 @@ async def inventory_scope(request: Request, db: AsyncSession = Depends(get_db))
|
||||
role = ROLE_ADMIN # مدیر تدارکات = ادمین (فقطخواندنی)
|
||||
if role not in (ROLE_PM, ROLE_ADMIN):
|
||||
raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role")
|
||||
# The explicit query param wins; otherwise the dev header carries it.
|
||||
if project_id is None:
|
||||
raw_project = request.headers.get("x-project-id") or settings.INVENTORY_DEFAULT_PROJECT_ID
|
||||
project_id = None
|
||||
if raw_project:
|
||||
try:
|
||||
project_id = uuid.UUID(str(raw_project))
|
||||
|
||||
+28
-15
@@ -29,7 +29,7 @@ from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -66,14 +66,20 @@ NULL_BRAND_SENTINEL = "00000000-0000-0000-0000-000000000000"
|
||||
class ArrivalScope:
|
||||
role: str # "pm" | "admin" (admin = admin or procurement_manager)
|
||||
user_id: uuid.UUID | None
|
||||
project_id: uuid.UUID | None # PM's single project; None for admin
|
||||
project_id: uuid.UUID | None # PM's active project; admin: optional filter
|
||||
display_name: str
|
||||
|
||||
|
||||
async def arrivals_scope(request: Request, db: AsyncSession = Depends(get_db)) -> ArrivalScope:
|
||||
async def arrivals_scope(
|
||||
request: Request,
|
||||
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: الزامی برای دادههای پروژه)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ArrivalScope:
|
||||
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
|
||||
|
||||
PM → scoped to his own project. Admin / procurement manager → all projects.
|
||||
PM → scoped to the requested project, which must be one they manage
|
||||
(projects.pm_user_id, 1:N); without the param, their first project.
|
||||
Admin / procurement manager → all projects (param = optional filter).
|
||||
"""
|
||||
if request.headers.get("authorization"):
|
||||
ctx: UserContext | None = await get_optional_user(request, db)
|
||||
@@ -81,11 +87,16 @@ async def arrivals_scope(request: Request, db: AsyncSession = Depends(get_db)) -
|
||||
if ctx.role is None:
|
||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||
if ctx.role == "pm":
|
||||
if ctx.project_id is None:
|
||||
allowed = list(ctx.project_ids or [])
|
||||
if project_id is not None:
|
||||
if project_id not in allowed:
|
||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
||||
if not allowed:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=ctx.project_id, display_name=ctx.display_name)
|
||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name)
|
||||
if ctx.role in ("admin", "procurement_manager"):
|
||||
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=None, 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("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
|
||||
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to admin)
|
||||
if not request.headers.get("x-user-role"):
|
||||
@@ -95,8 +106,8 @@ async def arrivals_scope(request: Request, db: AsyncSession = Depends(get_db)) -
|
||||
role = "admin"
|
||||
if role not in ("pm", "admin"):
|
||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید")
|
||||
project_id = None
|
||||
if role == "pm":
|
||||
# The explicit query param wins; otherwise the dev header carries it.
|
||||
if project_id is None and role == "pm":
|
||||
raw_project = request.headers.get("x-project-id")
|
||||
if not raw_project:
|
||||
raise ForbiddenError("هدر X-Project-Id (پروژه مسئول) الزامی است")
|
||||
@@ -172,9 +183,10 @@ async def pm_on_the_way(
|
||||
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
||||
.where(ItemDecision.status == "chosen")
|
||||
)
|
||||
if ctx.role == "pm":
|
||||
if ctx.project_id is None:
|
||||
if ctx.role == "pm" and ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
if ctx.project_id is not None:
|
||||
# PM: active project; admin/manager: optional project filter
|
||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
|
||||
|
||||
@@ -238,8 +250,8 @@ async def pm_confirm_receipt(
|
||||
decision = await db.get(ItemDecision, payload.item_decision_id)
|
||||
if decision is None or decision.status != "chosen":
|
||||
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
|
||||
if ctx.role == "pm" and decision.project_id != ctx.project_id:
|
||||
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
|
||||
if ctx.project_id is not None and decision.project_id != ctx.project_id:
|
||||
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
|
||||
|
||||
li = await db.get(LineItem, decision.line_item_id)
|
||||
quote = await db.get(Quote, li.quote_id)
|
||||
@@ -436,9 +448,10 @@ async def pm_receipts(
|
||||
.join(Quote, Quote.id == LineItem.quote_id)
|
||||
.join(Project, Project.id == ItemDecision.project_id)
|
||||
)
|
||||
if ctx.role == "pm":
|
||||
if ctx.project_id is None:
|
||||
if ctx.role == "pm" and ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
if ctx.project_id is not None:
|
||||
# PM: active project; admin/manager: optional project filter
|
||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
|
||||
receipts = [r.ItemReceipt for r in rows]
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
"""Projects CRUD + per-project stats."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import UserContext, get_optional_user
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Project, Quote, User
|
||||
from app.schemas.procurement import ProjectCreate, ProjectUpdate
|
||||
from app.services.quote_service import project_stats
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_date, to_iso
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
|
||||
"""JWT caller when present — used for RBAC on PM assignment (PATCH)."""
|
||||
return await get_optional_user(request, db)
|
||||
|
||||
|
||||
async def _pm_map(db: AsyncSession, projects: list[Project]) -> dict:
|
||||
"""project_id -> pm brief (v2.6: the single PM of the project)."""
|
||||
pm_ids = {p.pm_user_id for p in projects if p.pm_user_id}
|
||||
@@ -91,7 +97,13 @@ async def get_project(project_id, db: AsyncSession = Depends(get_db)):
|
||||
|
||||
|
||||
@router.patch("/{project_id}")
|
||||
async def update_project(project_id, payload: ProjectUpdate, db: AsyncSession = Depends(get_db)):
|
||||
async def update_project(
|
||||
project_id,
|
||||
payload: ProjectUpdate,
|
||||
request: Request,
|
||||
ctx: UserContext | None = Depends(_optional_ctx),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
project = await db.get(Project, project_id)
|
||||
if project is None:
|
||||
raise NotFoundError("پروژه یافت نشد")
|
||||
@@ -101,6 +113,21 @@ async def update_project(project_id, payload: ProjectUpdate, db: AsyncSession =
|
||||
project.description = payload.description
|
||||
if payload.start_date is not None:
|
||||
project.start_date = payload.start_date
|
||||
# PM assignment (1:N): one PM per project, a PM may own many projects.
|
||||
# Admin / procurement manager only (same rule as the Organization panel).
|
||||
if "pm_user_id" in payload.model_fields_set:
|
||||
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
|
||||
if role not in ("admin", "procurement_manager"):
|
||||
raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است")
|
||||
if payload.pm_user_id is None:
|
||||
project.pm_user_id = None
|
||||
else:
|
||||
pm_user = await db.get(User, payload.pm_user_id)
|
||||
if pm_user is None:
|
||||
raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
|
||||
if pm_user.role != "pm":
|
||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id")
|
||||
project.pm_user_id = pm_user.id
|
||||
await db.flush()
|
||||
return {"data": await project_to_dict(db, project)}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Users — profile + role assignment + organization management (v2.6, Casdoor-backed).
|
||||
|
||||
Organization panel endpoints (admin / procurement manager — same person):
|
||||
- GET /users — list every user with role + assigned project.
|
||||
- GET /users — list every user with role + assigned projects.
|
||||
- POST /users — define a new user (manual identity, casdoor_id optional).
|
||||
- PATCH /users/{id} — set/clear role; clearing (or switching away from
|
||||
«pm») also unassigns the user from their project.
|
||||
- POST /users/{id}/project — assign/unassign the user's single project
|
||||
(project_id=None unassigns; one PM per project).
|
||||
«pm») also unassigns the user from every project.
|
||||
- POST /users/{id}/project — assign/unassign ONE project (multi-project:
|
||||
other assignments are kept; project_id=None
|
||||
clears all; one PM per project).
|
||||
- GET /users/me/projects — every project assigned to the caller (PM).
|
||||
|
||||
Role management is open to BOTH «admin» and «procurement_manager» (they are
|
||||
the same person in this system), unlike the plain require_role("admin").
|
||||
@@ -41,7 +43,11 @@ USER_ROLE_LABELS = {
|
||||
}
|
||||
|
||||
|
||||
def user_to_dict(user: User, project: Project | None = None) -> dict:
|
||||
def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
|
||||
"""Serialize a user. `projects` = every project the PM manages (1:N);
|
||||
project_id/project_name stay as the first (oldest) one for compatibility."""
|
||||
projects = projects or []
|
||||
first = projects[0] if projects else None
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"casdoor_id": user.casdoor_id,
|
||||
@@ -50,8 +56,9 @@ def user_to_dict(user: User, project: Project | None = None) -> dict:
|
||||
"phone": user.phone,
|
||||
"role": user.role,
|
||||
"role_label": USER_ROLE_LABELS.get(user.role) if user.role else None,
|
||||
"project_id": str(project.id) if project else None,
|
||||
"project_name": project.name if project else None,
|
||||
"project_id": str(first.id) if first else None,
|
||||
"project_name": first.name if first else None,
|
||||
"projects": [{"id": str(p.id), "name": p.name} for p in projects],
|
||||
"created_at": to_iso(user.created_at),
|
||||
}
|
||||
|
||||
@@ -63,14 +70,28 @@ async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserCont
|
||||
return ctx
|
||||
|
||||
|
||||
async def _user_project(db: AsyncSession, user: User) -> Project | None:
|
||||
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
|
||||
"""Every project the user manages as PM (oldest first; [] for non-PMs)."""
|
||||
if user.role != "pm":
|
||||
return None
|
||||
return await db.scalar(select(Project).where(Project.pm_user_id == user.id))
|
||||
return []
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id == user.id)
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
return list(rows)
|
||||
|
||||
|
||||
async def _user_project(db: AsyncSession, user: User) -> Project | None:
|
||||
"""First (oldest) project of a PM — legacy single-project view."""
|
||||
projects = await _user_projects(db, user)
|
||||
return projects[0] if projects else None
|
||||
|
||||
|
||||
async def _unassign_projects(db: AsyncSession, user_id) -> None:
|
||||
"""Clear the user's pm assignment on every project (one project per PM)."""
|
||||
"""Clear the user's pm assignment on every project (role revocation)."""
|
||||
await db.execute(
|
||||
update(Project).where(Project.pm_user_id == user_id).values(pm_user_id=None)
|
||||
)
|
||||
@@ -104,8 +125,19 @@ async def _casdoor_users() -> list[dict]:
|
||||
|
||||
@router.get("/me")
|
||||
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await _user_project(db, ctx.user) if ctx.role == "pm" else None
|
||||
return {"data": user_to_dict(ctx.user, project)}
|
||||
projects = await _user_projects(db, ctx.user)
|
||||
return {"data": user_to_dict(ctx.user, projects)}
|
||||
|
||||
|
||||
@router.get("/me/projects")
|
||||
async def my_projects(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
"""Every project assigned to the caller (empty list for non-PM roles).
|
||||
|
||||
Drives the PM project switcher: the frontend validates its persisted
|
||||
active project against this list and falls back gracefully when a
|
||||
project is revoked."""
|
||||
projects = await _user_projects(db, ctx.user)
|
||||
return {"data": [{"id": str(p.id), "name": p.name} for p in projects]}
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -113,15 +145,22 @@ async def list_users(
|
||||
_: UserContext = Depends(_org_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Every user (with role + assigned project) — Organization panel."""
|
||||
"""Every user (with role + assigned projects) — Organization panel."""
|
||||
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
||||
pm_ids = {u.id for u in users if u.role == "pm"}
|
||||
projects: dict = {}
|
||||
by_user: dict = {}
|
||||
if pm_ids:
|
||||
projs = (await db.execute(select(Project).where(Project.pm_user_id.in_(pm_ids)))).scalars().all()
|
||||
projects = {p.pm_user_id: p for p in projs}
|
||||
projs = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id.in_(pm_ids))
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
for p in projs:
|
||||
by_user.setdefault(p.pm_user_id, []).append(p)
|
||||
return {
|
||||
"data": [user_to_dict(u, projects.get(u.id)) for u in users],
|
||||
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
|
||||
"meta": {"total": len(users)},
|
||||
}
|
||||
|
||||
@@ -166,8 +205,8 @@ async def update_user_role(
|
||||
await _unassign_projects(db, user.id)
|
||||
user.role = payload.role
|
||||
await db.flush()
|
||||
project = await _user_project(db, user)
|
||||
return {"data": user_to_dict(user, project)}
|
||||
projects = await _user_projects(db, user)
|
||||
return {"data": user_to_dict(user, projects)}
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
@@ -204,12 +243,19 @@ async def sync_casdoor_users(
|
||||
|
||||
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
|
||||
pm_ids = {u.id for u in users if u.role == "pm"}
|
||||
projects: dict = {}
|
||||
by_user: dict = {}
|
||||
if pm_ids:
|
||||
projs = (await db.execute(select(Project).where(Project.pm_user_id.in_(pm_ids)))).scalars().all()
|
||||
projects = {p.pm_user_id: p for p in projs}
|
||||
projs = (
|
||||
await db.execute(
|
||||
select(Project)
|
||||
.where(Project.pm_user_id.in_(pm_ids))
|
||||
.order_by(Project.created_at, Project.id)
|
||||
)
|
||||
).scalars().all()
|
||||
for p in projs:
|
||||
by_user.setdefault(p.pm_user_id, []).append(p)
|
||||
return {
|
||||
"data": [user_to_dict(u, projects.get(u.id)) for u in users],
|
||||
"data": [user_to_dict(u, by_user.get(u.id, [])) for u in users],
|
||||
"meta": {"total": len(users), "created": created},
|
||||
}
|
||||
|
||||
@@ -221,11 +267,13 @@ async def assign_user_project(
|
||||
_: UserContext = Depends(_org_manager),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Assign the user as PM of one project (project_id=None unassigns).
|
||||
"""Assign the user as PM of ONE project (multi-project aware).
|
||||
|
||||
Rules: the user must hold the «pm» role; a PM manages ONE project at a
|
||||
time (assignment on other projects is cleared); assigning a new PM to a
|
||||
project replaces the previous PM there.
|
||||
- project_id given: that project gets this PM (a previous PM there is
|
||||
replaced); the user's OTHER project assignments are kept.
|
||||
- project_id=None: clears ALL of the user's project assignments.
|
||||
|
||||
Rules: the user must hold the «pm» role; a project has at most one PM.
|
||||
"""
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
@@ -233,8 +281,8 @@ async def assign_user_project(
|
||||
if user.role != "pm":
|
||||
raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
|
||||
|
||||
await _unassign_projects(db, user.id)
|
||||
if payload.project_id is None:
|
||||
await _unassign_projects(db, user.id)
|
||||
await db.flush()
|
||||
return {"data": user_to_dict(user)}
|
||||
|
||||
@@ -243,4 +291,4 @@ async def assign_user_project(
|
||||
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
||||
project.pm_user_id = user.id
|
||||
await db.flush()
|
||||
return {"data": user_to_dict(user, project)}
|
||||
return {"data": user_to_dict(user, await _user_projects(db, user))}
|
||||
|
||||
@@ -52,6 +52,9 @@ class ProjectUpdate(APIModel):
|
||||
name: str | None = Field(default=None, min_length=1)
|
||||
description: str | None = None
|
||||
start_date: date | None = None
|
||||
# PM assignment (1:N — one PM per project, a PM may own many projects).
|
||||
# Admin / procurement manager only; None unassigns this project's PM.
|
||||
pm_user_id: UUID | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -210,7 +213,11 @@ class UserRoleUpdate(APIModel):
|
||||
|
||||
|
||||
class UserProjectUpdate(APIModel):
|
||||
"""Assign/unassign the user's single project (project_id=None unassigns)."""
|
||||
"""Assign the user to one project (multi-project aware).
|
||||
|
||||
project_id given → that project gets this PM (previous PM replaced,
|
||||
the user's other projects kept); None → clears every assignment.
|
||||
"""
|
||||
|
||||
project_id: UUID | None = None
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { lazy, Suspense } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { useAuthStore } from "./store/auth";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { RequirePmProjects } from "./components/RequirePmProjects";
|
||||
|
||||
// هر صفحه با React.lazy لود میشود (code splitting)
|
||||
const DashboardPage = lazy(() =>
|
||||
@@ -141,7 +142,9 @@ export default function App() {
|
||||
path="/inventory"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequirePmProjects>
|
||||
<InventoryPage />
|
||||
</RequirePmProjects>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
@@ -149,7 +152,9 @@ export default function App() {
|
||||
path="/inventory/arrivals"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequirePmProjects>
|
||||
<ArrivalsPage />
|
||||
</RequirePmProjects>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
@@ -157,7 +162,9 @@ export default function App() {
|
||||
path="/inventory/change"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequirePmProjects>
|
||||
<ChangeInventoryPage />
|
||||
</RequirePmProjects>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
@@ -165,7 +172,9 @@ export default function App() {
|
||||
path="/inventory/daily"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequirePmProjects>
|
||||
<DailyControlPage />
|
||||
</RequirePmProjects>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
@@ -173,7 +182,9 @@ export default function App() {
|
||||
path="/inventory/warehouses"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequirePmProjects>
|
||||
<WarehousesPage />
|
||||
</RequirePmProjects>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
@@ -181,7 +192,9 @@ export default function App() {
|
||||
path="/inventory/org"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<RequirePmProjects>
|
||||
<OrganizationPage />
|
||||
</RequirePmProjects>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { PanelMenu } from "./PanelMenu";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { PanelMenu, type MenuItem } from "./PanelMenu";
|
||||
import { useLogout } from "../pages/Inventory/panelMenu";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
|
||||
// منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) —
|
||||
// دقیقاً مثل منوی دفتر: عنوان «پنلها»، ماژول فعلی با استایل فعال (پر)
|
||||
// + «✓ فعلی»، ماژول دیگر بهعنوان سوییچر، و در پایان جداکننده + خروج.
|
||||
// مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز میشود که همهی
|
||||
// پروژههای تخصیصیافته را نشان میدهد؛ پروژهی فعال با «✓ فعلی» مشخص است و
|
||||
// انتخاب هر پروژه فقط «پروژهی فعال» را عوض میکند (نشست/توکن دستنخورده).
|
||||
export function AppSwitcherMenu({
|
||||
activeModule,
|
||||
canSwitchToProcurement = true,
|
||||
@@ -12,13 +17,28 @@ export function AppSwitcherMenu({
|
||||
canSwitchToProcurement?: boolean; // مدیر پروژه به دفتر تدارکات دسترسی ندارد
|
||||
}) {
|
||||
const logout = useLogout();
|
||||
const items = [];
|
||||
const { pathname } = useLocation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const setActiveProject = useAuthStore((s) => s.setActiveProject);
|
||||
const items: MenuItem[] = [];
|
||||
|
||||
const isPm = user?.role === "pm";
|
||||
const pmProjects = isPm ? user?.projects ?? [] : [];
|
||||
const projectChildren: MenuItem[] | undefined =
|
||||
activeModule === "warehouse" && isPm
|
||||
? pmProjects.map((p) => ({
|
||||
label: p.name,
|
||||
to: pathname, // همان بخش فعلی؛ فقط پروژه عوض میشود
|
||||
active: p.id === user?.project_id,
|
||||
onClick: () => setActiveProject(p.id),
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
if (activeModule === "procurement") {
|
||||
items.push({ label: "مدیریت انبار", to: "/inventory" });
|
||||
items.push({ label: "مدیریت تدارکات", to: "/", active: true });
|
||||
} else {
|
||||
items.push({ label: "مدیریت انبار", to: "/inventory", active: true });
|
||||
items.push({ label: "مدیریت انبار", to: "/inventory", active: true, children: projectChildren });
|
||||
if (canSwitchToProcurement) {
|
||||
items.push({ label: "مدیریت تدارکات", to: "/" });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { ChevronDown, Menu, X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export interface MenuItem {
|
||||
@@ -9,11 +9,14 @@ export interface MenuItem {
|
||||
active?: boolean;
|
||||
divider?: boolean;
|
||||
onClick?: () => void;
|
||||
// آیتم بازشو (آکاردئونی) — مثلاً فهرست پروژههای مدیر پروژه زیر «مدیریت انبار»
|
||||
children?: MenuItem[];
|
||||
}
|
||||
|
||||
// منوی همبرگری — بالای راست صفحات؛ نمایش پنل فعلی + ناوبری بین پنلها
|
||||
export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState<number | null>(null);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -42,11 +45,81 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
|
||||
{items.map((it, i) =>
|
||||
it.divider ? (
|
||||
<div key={i} className="my-1 border-t border-line" />
|
||||
) : it.children?.length ? (
|
||||
<div key={i}>
|
||||
<div className={cn("flex items-center rounded-lg", it.active && "bg-accent")}>
|
||||
{it.to ? (
|
||||
<Link
|
||||
to={it.to}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
it.onClick?.();
|
||||
}}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
|
||||
it.active ? "font-bold text-white" : "text-ink hover:bg-slate-bg",
|
||||
)}
|
||||
>
|
||||
{it.label}
|
||||
{it.active && <span className="text-[10px] opacity-80">✓ فعلی</span>}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
it.onClick?.();
|
||||
}}
|
||||
className="flex flex-1 items-center gap-3 rounded-lg px-3 py-2 text-start text-[13px] text-ink hover:bg-slate-bg"
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={expanded === i ? "بستن فهرست پروژهها" : "نمایش پروژهها"}
|
||||
aria-expanded={expanded === i}
|
||||
onClick={() => setExpanded(expanded === i ? null : i)}
|
||||
className={cn(
|
||||
"me-1 inline-flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md",
|
||||
it.active ? "text-white hover:bg-white/15" : "text-muted hover:bg-slate-bg",
|
||||
)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 transition-transform", expanded === i && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{expanded === i && (
|
||||
<div className="mt-0.5 flex flex-col gap-0.5 border-s border-line ps-2 ms-3">
|
||||
{it.children.map((c, j) => (
|
||||
<Link
|
||||
key={j}
|
||||
to={c.to ?? "#"}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
c.onClick?.();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-[12.5px] no-underline",
|
||||
c.active ? "bg-accent-bg font-bold text-accent" : "text-ink hover:bg-slate-bg",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{c.label}</span>
|
||||
{c.active && <span className="shrink-0 text-[10px]">✓ فعلی</span>}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : it.to ? (
|
||||
<Link
|
||||
key={i}
|
||||
to={it.to}
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
it.onClick?.();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
|
||||
it.active ? "bg-accent font-bold text-white" : "text-ink hover:bg-slate-bg",
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { normalize } from "../lib/normalize";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
|
||||
export interface ProjectOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// چند-انتخابیِ جستوجوپذیرِ پروژهها (1:N — مدیر پروژه ↔ پروژهها):
|
||||
// - چیپهای پروژه با دکمهی × (جداسازی فوری مدیر از پروژه)
|
||||
// - جستوجوی زندهی فارسی (normalize: ی/ك عربی، نیمفاصله و فاصلهها)
|
||||
// - پروژههای انتخابشده از فهرست پنهان میشوند تا انتخاب تکراری رخ ندهد
|
||||
export function ProjectMultiSelect({
|
||||
options,
|
||||
values,
|
||||
onChange,
|
||||
placeholder = "انتخاب پروژه(ها)...",
|
||||
disabled = false,
|
||||
}: {
|
||||
options: ProjectOption[];
|
||||
values: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const ref = useClickOutside<HTMLDivElement>(() => {
|
||||
setOpen(false);
|
||||
setText("");
|
||||
});
|
||||
|
||||
const byId = useMemo(() => new Map(options.map((o) => [o.id, o])), [options]);
|
||||
const selected = values.map((id) => byId.get(id)).filter(Boolean) as ProjectOption[];
|
||||
const q = normalize(text);
|
||||
const available = useMemo(
|
||||
() =>
|
||||
options.filter((o) => !values.includes(o.id) && (!q || normalize(o.name).includes(q))),
|
||||
[options, values, q],
|
||||
);
|
||||
|
||||
const add = (id: string) => {
|
||||
onChange([...values, id]);
|
||||
setText("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
const remove = (id: string) => onChange(values.filter((v) => v !== id));
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative min-w-52">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-9 flex-wrap items-center gap-1 rounded-lg border border-line bg-panel px-2 py-1 text-[12px]",
|
||||
disabled ? "opacity-60" : "cursor-text",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setOpen(true);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selected.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-md bg-accent-bg px-2 py-0.5 font-semibold text-accent"
|
||||
>
|
||||
<span className="truncate">{p.name}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`جدا کردن ${p.name}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
remove(p.id);
|
||||
}}
|
||||
className="cursor-pointer rounded p-0.5 hover:bg-accent/10"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
dir="rtl"
|
||||
disabled={disabled}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
placeholder={selected.length ? "" : placeholder}
|
||||
className="h-7 min-w-24 flex-1 border-0 bg-transparent px-1 text-[12px] outline-none placeholder:text-muted/70"
|
||||
/>
|
||||
</div>
|
||||
{open && !disabled && (
|
||||
<div className="absolute z-40 mt-1 max-h-56 w-full min-w-52 overflow-auto rounded-xl border border-line bg-panel p-1 shadow-lg">
|
||||
{available.length ? (
|
||||
available.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => add(o.id)}
|
||||
className="flex w-full cursor-pointer items-center rounded-lg px-2.5 py-1.5 text-start text-[12.5px] text-ink hover:bg-slate-bg"
|
||||
>
|
||||
{o.name}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-2.5 py-2 text-[11.5px] text-muted">
|
||||
{options.length === 0
|
||||
? "پروژهای وجود ندارد"
|
||||
: q
|
||||
? "پروژهای یافت نشد"
|
||||
: "همهی پروژهها متصل شدهاند"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { apiV26 } from "../lib/api";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useLogout } from "../pages/Inventory/panelMenu";
|
||||
|
||||
// گیتِ پروژههای مدیر پروژه (PM → 1:N):
|
||||
// - لیست پروژههای تخصیصیافته را از بکاند میگیرد و پروژهی فعالِ ذخیرهشده را
|
||||
// اعتبارسنجی میکند؛ اگر پروژه باطل شده باشد به اولین پروژه برمیگردد
|
||||
// (localStorage هم با همان store بهروز میشود).
|
||||
// - اگر هیچ پروژهای تخصیص نیافته باشد: صفحهی خالی با پیام + دکمهی خروج
|
||||
// (بدون حلقهی ریدایرکت و بدون باطلکردن نشست).
|
||||
// - حالت توسعه (بدون توکن) از همین پروژهی انتخابیِ dev استفاده میکند.
|
||||
export function RequirePmProjects({ children }: { children: ReactNode }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isDev = useAuthStore((s) => s.isDev);
|
||||
const setProjects = useAuthStore((s) => s.setProjects);
|
||||
const logout = useLogout();
|
||||
|
||||
const isPm = user?.role === "pm";
|
||||
const [state, setState] = useState<"loading" | "ok" | "empty">(isPm ? "loading" : "ok");
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || user.role !== "pm") {
|
||||
setState("ok");
|
||||
return;
|
||||
}
|
||||
if (isDev) {
|
||||
setState(user.project_id ? "ok" : "empty");
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setState("loading");
|
||||
void apiV26.users
|
||||
.myProjects()
|
||||
.then((list) => {
|
||||
if (!alive) return;
|
||||
setProjects(list); // پروژهی فعال را اعتبارسنجی/جایگزین میکند
|
||||
setState(list.length ? "ok" : "empty");
|
||||
})
|
||||
.catch(() => {
|
||||
// خطای شبکه نباید کاربر را قفل کند — با وضعیت فعلی ادامه میدهیم
|
||||
if (alive) setState("ok");
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id, user?.role, isDev]);
|
||||
|
||||
if (state === "loading") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center text-sm text-muted">
|
||||
در حال بارگذاری پروژهها…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "empty") {
|
||||
return (
|
||||
<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">
|
||||
<h1 className="text-[16px] font-extrabold">هیچ پروژهای به شما تخصیص داده نشده است</h1>
|
||||
<p className="mt-2 text-[12.5px] leading-6 text-muted">
|
||||
در حال حاضر هیچ پروژهای به شما تخصیص داده نشده است. برای دسترسی به پنل انبار، از مدیر
|
||||
سیستم بخواهید پروژهای را به شما اختصاص دهد.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="mt-5 inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg bg-accent px-4 text-[13px] font-bold text-white transition-colors hover:bg-accent-dark"
|
||||
>
|
||||
خروج از حساب
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
+53
-13
@@ -69,6 +69,19 @@ function authHeaders(init?: RequestInit): Record<string, string> {
|
||||
return headers;
|
||||
}
|
||||
|
||||
// پروژهی فعالِ مدیر پروژه (چند-پروژهای، 1:N): برای همهی مسیرهای پنل انبار
|
||||
// صریحاً ارسال میشود تا بکاند دادههای همان پروژه را برگرداند/اعتبارسنجی کند.
|
||||
// ادمین/مدیر تدارکات بدون پارامتر (همهی پروژهها) باقی میماند.
|
||||
function activeProjectId(): string | null {
|
||||
const { user } = useAuthStore.getState();
|
||||
return user?.role === "pm" ? user.project_id : null;
|
||||
}
|
||||
|
||||
function projectQuery(projectId?: string | null): string {
|
||||
const pid = projectId ?? activeProjectId();
|
||||
return pid ? `?project_id=${pid}` : "";
|
||||
}
|
||||
|
||||
// ترجمهی پیامهای رایج اعتبارسنجی FastAPI (422) به فارسی
|
||||
const VALIDATION_FA = (msg: string): string => {
|
||||
const lower = msg.toLowerCase();
|
||||
@@ -1450,6 +1463,8 @@ export interface UserRow {
|
||||
role_label: string | null;
|
||||
project_id: string | null;
|
||||
project_name: string | null;
|
||||
// 1:N — every project this PM manages (oldest first).
|
||||
projects?: { id: string; name: string }[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -1476,6 +1491,7 @@ export const apiV26 = {
|
||||
role_label: string | null;
|
||||
project_id: string | null;
|
||||
project_name: string | null;
|
||||
projects?: { id: string; name: string }[];
|
||||
};
|
||||
};
|
||||
}>("/auth/login", { method: "POST", body: JSON.stringify(body) });
|
||||
@@ -1507,6 +1523,7 @@ export const apiV26 = {
|
||||
id: string; casdoor_id: string; first_name: string; last_name: string; phone: string | null;
|
||||
role: "pm" | "procurement_manager" | "admin" | null; role_label: string | null;
|
||||
project_id: string | null; project_name: string | null;
|
||||
projects?: { id: string; name: string }[];
|
||||
}>("/users/me");
|
||||
return {
|
||||
id: data.id,
|
||||
@@ -1518,9 +1535,13 @@ export const apiV26 = {
|
||||
role_label: data.role_label,
|
||||
project_id: data.project_id,
|
||||
project_name: data.project_name,
|
||||
projects: data.projects ?? [],
|
||||
display_name: `${data.first_name} ${data.last_name}`.trim() || data.casdoor_id,
|
||||
};
|
||||
},
|
||||
// پروژههای تخصیصیافته به کاربر جاری (PM) — مبنای سوییچر پروژه + اعتبارسنجی
|
||||
myProjects: async (): Promise<{ id: string; name: string }[]> =>
|
||||
getData<{ id: string; name: string }[]>("/users/me/projects"),
|
||||
// --- Organization panel (admin / procurement manager) ---
|
||||
list: async (): Promise<UserRow[]> => {
|
||||
const data = await getData<UserRow[]>("/users");
|
||||
@@ -1543,33 +1564,44 @@ export const apiV26 = {
|
||||
},
|
||||
},
|
||||
|
||||
// 1:N تخصیص مدیر پروژه ↔ پروژه: هر پروژه حداکثر یک PM دارد؛
|
||||
// pm_user_id=null یعنی جداسازی مدیر از پروژه. فقط ادمین/مدیر تدارکات.
|
||||
projects: {
|
||||
setPm: async (projectId: string, userId: string | null): Promise<void> => {
|
||||
await patchData(`/projects/${projectId}`, { pm_user_id: userId });
|
||||
},
|
||||
},
|
||||
|
||||
warehouses: {
|
||||
list: async (projectId?: string): Promise<Warehouse[]> => {
|
||||
const qs = projectId ? `?project_id=${projectId}&page_size=100` : "?page_size=100";
|
||||
const pid = projectId ?? activeProjectId() ?? undefined;
|
||||
const qs = pid ? `?project_id=${pid}&page_size=100` : "?page_size=100";
|
||||
const data = await getData<Parameters<typeof rawWarehouse>[0][]>(`/inventory/warehouses${qs}`);
|
||||
return data.map(rawWarehouse);
|
||||
},
|
||||
create: async (body: { project_id: string; name: string; description?: string | null }): Promise<Warehouse> => {
|
||||
const d = await postData<Parameters<typeof rawWarehouse>[0]>("/inventory/warehouses", body);
|
||||
const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body);
|
||||
return rawWarehouse(d);
|
||||
},
|
||||
update: async (id: string, body: { name?: string; description?: string | null; project_id?: string }): Promise<Warehouse> => {
|
||||
const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}`, body);
|
||||
const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body);
|
||||
return rawWarehouse(d);
|
||||
},
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await deleteData(`/inventory/warehouses/${id}`);
|
||||
await deleteData(`/inventory/warehouses/${id}${projectQuery()}`);
|
||||
},
|
||||
},
|
||||
|
||||
inventory: {
|
||||
list: async (params: { project_id?: string; category_id?: string; warehouse_id?: string; material_id?: string; sort?: string } = {}): Promise<InventoryRow[]> => {
|
||||
const qs = new URLSearchParams({ page_size: "100", ...cleanParams(params) }).toString();
|
||||
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
||||
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
|
||||
return data.map(rawInventory);
|
||||
},
|
||||
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise<InventoryTransaction[]> => {
|
||||
const qs = new URLSearchParams({ page_size: "100", ...cleanParams(params) }).toString();
|
||||
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
||||
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
|
||||
},
|
||||
createTransaction: async (body: {
|
||||
@@ -1578,26 +1610,34 @@ export const apiV26 = {
|
||||
physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null;
|
||||
note?: string | null; transaction_date?: string | null;
|
||||
}): Promise<InventoryTransaction[]> => {
|
||||
return postData<InventoryTransaction[]>("/inventory/transactions", body);
|
||||
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
|
||||
},
|
||||
types: async (): Promise<{ type: TransactionType; label: string }[]> => {
|
||||
return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types");
|
||||
},
|
||||
materialOptions: async (category_id: string, warehouse_id?: string): Promise<{ id: string; name: string; brands: { id: string; name: string }[] }[]> => {
|
||||
const qs = warehouse_id ? `?category_id=${category_id}&warehouse_id=${warehouse_id}` : `?category_id=${category_id}`;
|
||||
return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>(`/inventory/materials/options${qs}`);
|
||||
const params: Record<string, string> = { category_id };
|
||||
if (warehouse_id) params.warehouse_id = warehouse_id;
|
||||
const pid = activeProjectId();
|
||||
if (pid) params.project_id = pid;
|
||||
return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>(
|
||||
`/inventory/materials/options?${new URLSearchParams(params).toString()}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
pm: {
|
||||
onTheWay: async (): Promise<PendingArrival[]> => getData<PendingArrival[]>("/pm/on-the-way"),
|
||||
// پروژه فعال بهصورت صریح ارسال میشود؛ بکاند تعلق آن به PM را بررسی میکند
|
||||
onTheWay: async (projectId?: string | null): Promise<PendingArrival[]> =>
|
||||
getData<PendingArrival[]>(projectId ? `/pm/on-the-way?project_id=${projectId}` : "/pm/on-the-way"),
|
||||
confirmReceipt: async (body: {
|
||||
item_decision_id: string; received_qty: number;
|
||||
allocations: { warehouse_id: string; quantity: number }[];
|
||||
received_at?: string | null; on_time?: boolean | null; quality_note?: string | null;
|
||||
}): Promise<{ receipt_id: string; state: string; posted_transactions: string[] }> =>
|
||||
postData("/pm/receipts", body),
|
||||
receipts: async (): Promise<ConfirmedReceipt[]> => getData<ConfirmedReceipt[]>("/pm/receipts"),
|
||||
}, projectId?: string | null): Promise<{ receipt_id: string; state: string; posted_transactions: string[] }> =>
|
||||
postData(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts", body),
|
||||
receipts: async (projectId?: string | null): Promise<ConfirmedReceipt[]> =>
|
||||
getData<ConfirmedReceipt[]>(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"),
|
||||
},
|
||||
|
||||
// ساخت موجودیتهای کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه):
|
||||
|
||||
@@ -33,9 +33,11 @@ export function ArrivalsPage() {
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const activeProject = isPM ? user?.project_id || undefined : undefined;
|
||||
const [arr, whs] = await Promise.all([
|
||||
apiV26.pm.onTheWay(),
|
||||
apiV26.warehouses.list(isPM ? user?.project_id || undefined : undefined),
|
||||
// پروژهی فعال صریحاً ارسال میشود؛ بکاند تخصیص PM را بررسی میکند
|
||||
apiV26.pm.onTheWay(activeProject),
|
||||
apiV26.warehouses.list(activeProject),
|
||||
]);
|
||||
setItems(arr);
|
||||
setWarehouses(whs);
|
||||
@@ -186,7 +188,7 @@ export function ArrivalsPage() {
|
||||
item_decision_id: it.item_decision_id,
|
||||
received_qty: draftReceivedOf(it),
|
||||
allocations,
|
||||
});
|
||||
}, isPM ? user?.project_id || undefined : undefined);
|
||||
}
|
||||
toast.success("رسید (کامل یا جزیی) ثبت شد و به انبارها وارد شد ✓ — اقلام باقیمانده همچنان در راه هستند");
|
||||
// همگامسازی با دفتر تدارکات: store پیشنهادها را تازه کن تا کارتهای
|
||||
|
||||
@@ -8,6 +8,7 @@ import { apiV26, type UserRow } from "../../lib/api";
|
||||
import { refreshProcurementStore } from "../../hooks";
|
||||
import { useInventoryData } from "./useInventoryData";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
|
||||
|
||||
type Role = "pm" | "procurement_manager" | "admin" | null;
|
||||
|
||||
@@ -96,17 +97,36 @@ export function OrganizationPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const changeProject = async (u: UserRow, projectId: string) => {
|
||||
// پروژههای متصل به یک کاربر (1:N) — با پشتیبانی از شکل قدیمیِ تکپروژهای
|
||||
const assignedProjectsOf = (u: UserRow) =>
|
||||
u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []);
|
||||
|
||||
// همگامسازی چند-انتخابیِ پروژهها: افزودن/حذف هر اختلاف با یک PATCH
|
||||
// (pm_user_id = کاربر برای اتصال، null برای جداسازی؛ مدیر قبلی جایگزین میشود)
|
||||
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);
|
||||
try {
|
||||
const project = data.lookup.projects.find((p) => p.id === projectId);
|
||||
await apiV26.users.assignProject(u.id, projectId || null);
|
||||
toast.success(project ? `«${u.first_name} ${u.last_name}» به پروژه «${project.name}» متصل شد ✓` : "مدیر پروژه از پروژه جدا شد");
|
||||
for (const pid of added) await apiV26.projects.setPm(pid, u.id);
|
||||
for (const pid of removed) await apiV26.projects.setPm(pid, null);
|
||||
const names = nextIds
|
||||
.map((id) => data.lookup.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 data.refresh();
|
||||
refreshProcurementStore();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در اتصال مدیر به پروژه");
|
||||
toast.error(err instanceof Error ? err.message : "خطا در بهروزرسانی پروژهها");
|
||||
await loadUsers(); // وضعیت واقعی سرور را برگردان
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
@@ -149,7 +169,13 @@ export function OrganizationPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const pmOf = (projectId: string) => users.find((u) => u.role === "pm" && u.project_id === projectId) || null;
|
||||
// مدیرِ مسئول یک پروژه (1:N — کاربر ممکن است مدیر چند پروژه باشد)
|
||||
const pmOf = (projectId: string) =>
|
||||
users.find(
|
||||
(u) =>
|
||||
u.role === "pm" &&
|
||||
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
|
||||
) || null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
@@ -226,19 +252,14 @@ export function OrganizationPage() {
|
||||
<small className="block text-[10px] text-muted">(تغییر نقش فقط توسط ادمین)</small>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 pe-3">
|
||||
<td className="min-w-[240px] py-1.5 pe-3 align-top">
|
||||
{u.role === "pm" ? (
|
||||
<select
|
||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
||||
value={u.project_id || ""}
|
||||
<ProjectMultiSelect
|
||||
options={data.lookup.projects.map((p) => ({ id: p.id, name: p.name }))}
|
||||
values={assignedProjectsOf(u).map((p) => p.id)}
|
||||
onChange={(ids) => void syncUserProjects(u, ids)}
|
||||
disabled={busyId === u.id}
|
||||
onChange={(e) => void changeProject(u, e.target.value)}
|
||||
>
|
||||
<option value="">— بدون پروژه —</option>
|
||||
{data.lookup.projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[11.5px] text-muted">—</span>
|
||||
)}
|
||||
@@ -253,7 +274,9 @@ export function OrganizationPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-1 text-[11px] text-muted">
|
||||
«بدون نقش» = حذف مدیر پروژه (از پروژه هم جدا میشود). اتصال به یک پروژه، مدیر قبلی آن پروژه را جابهجا میکند.
|
||||
«بدون نقش» = حذف مدیر پروژه (از همهی پروژهها هم جدا میشود). هر مدیر پروژه میتواند به
|
||||
چند پروژه متصل باشد؛ حذف هر چیپ (×)، فقط همان پروژه را جدا میکند و اتصال به یک پروژه، مدیرِ
|
||||
قبلیِ آن پروژه را جابهجا میکند.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -54,6 +54,9 @@ export function useInventoryData(): InventoryData {
|
||||
setWarehouses(whs);
|
||||
setInventory(inv);
|
||||
setTransactions(txs);
|
||||
} catch {
|
||||
// پروژهی فعال ممکن است همین حالا باطل شده باشد — گیتِ پروژه در سطح مسیر
|
||||
// (RequirePmProjects) پروژهی جایگزین/صفحهی خالی را نشان میدهد.
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@ export interface AuthUser {
|
||||
phone: string | null;
|
||||
role: "pm" | "procurement_manager" | "admin" | null;
|
||||
role_label: string | null;
|
||||
project_id: string | null;
|
||||
project_id: string | null; // پروژهی فعال (PM) — با سوییچر عوض میشود
|
||||
project_name: string | null;
|
||||
projects?: { id: string; name: string }[]; // همهی پروژههای تخصیصیافته (1:N)
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
@@ -28,6 +29,11 @@ interface AuthStore {
|
||||
setToken: (token: string | null) => void;
|
||||
setUser: (user: AuthUser | null) => void;
|
||||
setDevUser: (role: "pm" | "admin" | "procurement_manager", projectId: string | null, projectName: string | null) => void;
|
||||
// پروژههای تخصیصیافته را مینشاند و پروژهی فعال را اعتبارسنجی میکند:
|
||||
// اگر پروژهی ذخیرهشده (localStorage) باطل شده باشد، به اولین پروژه برمیگردد.
|
||||
setProjects: (projects: { id: string; name: string }[]) => void;
|
||||
// جابهجایی پروژهی فعال بدون هیچ تغییری در نشست/توکن (بدون احراز هویت مجدد)
|
||||
setActiveProject: (projectId: string) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
@@ -53,9 +59,30 @@ export const useAuthStore = create<AuthStore>()(
|
||||
role_label: role === "pm" ? "مدیر پروژه (آزمایشی)" : role === "procurement_manager" ? "مدیر تدارکات (آزمایشی)" : "ادمین (آزمایشی)",
|
||||
project_id: projectId,
|
||||
project_name: projectName,
|
||||
projects: projectId ? [{ id: projectId, name: projectName || "" }] : [],
|
||||
display_name: projectId ? `کاربر آزمایشی — ${projectName || ""}` : "کاربر آزمایشی",
|
||||
},
|
||||
}),
|
||||
setProjects: (projects) =>
|
||||
set((s) => {
|
||||
if (!s.user) return {};
|
||||
const active = projects.find((p) => p.id === s.user!.project_id) ?? projects[0] ?? null;
|
||||
return {
|
||||
user: {
|
||||
...s.user,
|
||||
projects,
|
||||
project_id: active?.id ?? null,
|
||||
project_name: active?.name ?? null,
|
||||
},
|
||||
};
|
||||
}),
|
||||
setActiveProject: (projectId) =>
|
||||
set((s) => {
|
||||
if (!s.user) return {};
|
||||
const target = (s.user.projects ?? []).find((p) => p.id === projectId);
|
||||
if (!target) return {};
|
||||
return { user: { ...s.user, project_id: target.id, project_name: target.name } };
|
||||
}),
|
||||
logout: () => set({ token: null, user: null, isDev: false }),
|
||||
}),
|
||||
{ name: "procurement-auth-v1" },
|
||||
|
||||
Reference in New Issue
Block a user