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:
2026-09-15 19:23:50 +03:30
parent 3f4bbee6de
commit 1f16e44874
18 changed files with 673 additions and 122 deletions
+34 -7
View File
@@ -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:
+5
View File
@@ -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 (
+3 -3
View File
@@ -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),
}
}
+23 -9
View File
@@ -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
View File
@@ -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]
+30 -3
View File
@@ -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)}
+78 -30
View File
@@ -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))}
+8 -1
View File
@@ -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
+13
View File
@@ -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>
}
/>
+23 -3
View File
@@ -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: "/" });
}
+75 -2
View File
@@ -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
View File
@@ -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);
}
+28 -1
View File
@@ -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" },