From 1f16e4487466006d3ad98fd55564626ef6cbe4e8 Mon Sep 17 00:00:00 2001 From: "reza.g" Date: Tue, 15 Sep 2026 19:23:50 +0330 Subject: [PATCH] feat(pm): multi-project PMs (1:N) with project switching, scoping validation and org multi-select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=; × = 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. --- backend/app/auth.py | 41 +++++- backend/app/db/init_db.py | 5 + backend/app/routers/auth.py | 6 +- backend/app/routers/inventory.py | 44 ++++-- backend/app/routers/pm.py | 47 ++++--- backend/app/routers/projects.py | 33 ++++- backend/app/routers/users.py | 108 +++++++++++---- backend/app/schemas/procurement.py | 9 +- frontend/src/App.tsx | 25 +++- frontend/src/components/AppSwitcherMenu.tsx | 26 +++- frontend/src/components/PanelMenu.tsx | 77 ++++++++++- .../src/components/ProjectMultiSelect.tsx | 129 ++++++++++++++++++ frontend/src/components/RequirePmProjects.tsx | 80 +++++++++++ frontend/src/lib/api.ts | 66 +++++++-- frontend/src/pages/Inventory/ArrivalsPage.tsx | 8 +- .../src/pages/Inventory/OrganizationPage.tsx | 59 +++++--- .../src/pages/Inventory/useInventoryData.ts | 3 + frontend/src/store/auth.ts | 29 +++- 18 files changed, 673 insertions(+), 122 deletions(-) create mode 100644 frontend/src/components/ProjectMultiSelect.tsx create mode 100644 frontend/src/components/RequirePmProjects.tsx diff --git a/backend/app/auth.py b/backend/app/auth.py index 5bfe4b5..6ca66fb 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -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: diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 1b38eaf..5201498 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -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 ( diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 96dba62..616810b 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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), } } diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py index 8fe5602..c75f210 100644 --- a/backend/app/routers/inventory.py +++ b/backend/app/routers/inventory.py @@ -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,13 +103,14 @@ 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") - 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)) - except ValueError: - raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id") + # 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 + if raw_project: + try: + project_id = uuid.UUID(str(raw_project)) + except ValueError: + raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id") if role == ROLE_PM and project_id is None: raise ValidationError( "برای نقش PM هدر X-Project-Id (پروژه مسئول) الزامی است", field="X-Project-Id" diff --git a/backend/app/routers/pm.py b/backend/app/routers/pm.py index 67b31f7..d5ea10f 100644 --- a/backend/app/routers/pm.py +++ b/backend/app/routers/pm.py @@ -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: - raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") + 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: - raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") + 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] diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 3cc921b..2ebd785 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -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)} diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index c9bef98..5e84675 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -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))} diff --git a/backend/app/schemas/procurement.py b/backend/app/schemas/procurement.py index 352e047..4d4946b 100644 --- a/backend/app/schemas/procurement.py +++ b/backend/app/schemas/procurement.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 36381ee..2b0ed8b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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={ - + + + } /> @@ -149,7 +152,9 @@ export default function App() { path="/inventory/arrivals" element={ - + + + } /> @@ -157,7 +162,9 @@ export default function App() { path="/inventory/change" element={ - + + + } /> @@ -165,7 +172,9 @@ export default function App() { path="/inventory/daily" element={ - + + + } /> @@ -173,7 +182,9 @@ export default function App() { path="/inventory/warehouses" element={ - + + + } /> @@ -181,7 +192,9 @@ export default function App() { path="/inventory/org" element={ - + + + } /> diff --git a/frontend/src/components/AppSwitcherMenu.tsx b/frontend/src/components/AppSwitcherMenu.tsx index 395935c..0108841 100644 --- a/frontend/src/components/AppSwitcherMenu.tsx +++ b/frontend/src/components/AppSwitcherMenu.tsx @@ -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: "/" }); } diff --git a/frontend/src/components/PanelMenu.tsx b/frontend/src/components/PanelMenu.tsx index 6b1c792..1396369 100644 --- a/frontend/src/components/PanelMenu.tsx +++ b/frontend/src/components/PanelMenu.tsx @@ -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(null); const ref = useRef(null); useEffect(() => { @@ -42,11 +45,81 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string {items.map((it, i) => it.divider ? (
+ ) : it.children?.length ? ( +
+
+ {it.to ? ( + { + 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 && ✓ فعلی} + + ) : ( + + )} + +
+ {expanded === i && ( +
+ {it.children.map((c, j) => ( + { + 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", + )} + > + {c.label} + {c.active && ✓ فعلی} + + ))} +
+ )} +
) : it.to ? ( 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", diff --git a/frontend/src/components/ProjectMultiSelect.tsx b/frontend/src/components/ProjectMultiSelect.tsx new file mode 100644 index 0000000..a67d9e4 --- /dev/null +++ b/frontend/src/components/ProjectMultiSelect.tsx @@ -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(null); + const ref = useClickOutside(() => { + 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 ( +
+
{ + if (!disabled) { + setOpen(true); + inputRef.current?.focus(); + } + }} + > + {selected.map((p) => ( + + {p.name} + {!disabled && ( + + )} + + ))} + { + 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" + /> +
+ {open && !disabled && ( +
+ {available.length ? ( + available.map((o) => ( + + )) + ) : ( +
+ {options.length === 0 + ? "پروژه‌ای وجود ندارد" + : q + ? "پروژه‌ای یافت نشد" + : "همه‌ی پروژه‌ها متصل شده‌اند"} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/RequirePmProjects.tsx b/frontend/src/components/RequirePmProjects.tsx new file mode 100644 index 0000000..7e1707e --- /dev/null +++ b/frontend/src/components/RequirePmProjects.tsx @@ -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 ( +
+ در حال بارگذاری پروژه‌ها… +
+ ); + } + + if (state === "empty") { + return ( +
+
+

هیچ پروژه‌ای به شما تخصیص داده نشده است

+

+ در حال حاضر هیچ پروژه‌ای به شما تخصیص داده نشده است. برای دسترسی به پنل انبار، از مدیر + سیستم بخواهید پروژه‌ای را به شما اختصاص دهد. +

+ +
+
+ ); + } + + return <>{children}; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b52bfde..29ebbdf 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -69,6 +69,19 @@ function authHeaders(init?: RequestInit): Record { 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 => { const data = await getData("/users"); @@ -1543,33 +1564,44 @@ export const apiV26 = { }, }, + // 1:N تخصیص مدیر پروژه ↔ پروژه: هر پروژه حداکثر یک PM دارد؛ + // pm_user_id=null یعنی جداسازی مدیر از پروژه. فقط ادمین/مدیر تدارکات. + projects: { + setPm: async (projectId: string, userId: string | null): Promise => { + await patchData(`/projects/${projectId}`, { pm_user_id: userId }); + }, + }, + warehouses: { list: async (projectId?: string): Promise => { - 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[0][]>(`/inventory/warehouses${qs}`); return data.map(rawWarehouse); }, create: async (body: { project_id: string; name: string; description?: string | null }): Promise => { - const d = await postData[0]>("/inventory/warehouses", body); + const d = await postData[0]>(`/inventory/warehouses${projectQuery()}`, body); return rawWarehouse(d); }, update: async (id: string, body: { name?: string; description?: string | null; project_id?: string }): Promise => { - const d = await patchData[0]>(`/inventory/warehouses/${id}`, body); + const d = await patchData[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body); return rawWarehouse(d); }, delete: async (id: string): Promise => { - 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 => { - 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[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 => { - 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(`/inventory/transactions?${qs}`); }, createTransaction: async (body: { @@ -1578,26 +1610,34 @@ export const apiV26 = { physical_qty?: number | null; unit_id?: number | null; specs?: Record | null; note?: string | null; transaction_date?: string | null; }): Promise => { - return postData("/inventory/transactions", body); + return postData(`/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 = { 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 => getData("/pm/on-the-way"), + // پروژه فعال به‌صورت صریح ارسال می‌شود؛ بک‌اند تعلق آن به PM را بررسی می‌کند + onTheWay: async (projectId?: string | null): Promise => + getData(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 => getData("/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 => + getData(projectId ? `/pm/receipts?project_id=${projectId}` : "/pm/receipts"), }, // ساخت موجودیت‌های کاتالوگ سراسری از داخل پنل انبار (موجودی اولیه): diff --git a/frontend/src/pages/Inventory/ArrivalsPage.tsx b/frontend/src/pages/Inventory/ArrivalsPage.tsx index 162bb22..bddd4bb 100644 --- a/frontend/src/pages/Inventory/ArrivalsPage.tsx +++ b/frontend/src/pages/Inventory/ArrivalsPage.tsx @@ -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 پیشنهادها را تازه کن تا کارت‌های diff --git a/frontend/src/pages/Inventory/OrganizationPage.tsx b/frontend/src/pages/Inventory/OrganizationPage.tsx index 8acbbb4..b1cfb90 100644 --- a/frontend/src/pages/Inventory/OrganizationPage.tsx +++ b/frontend/src/pages/Inventory/OrganizationPage.tsx @@ -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 (
@@ -226,19 +252,14 @@ export function OrganizationPage() { (تغییر نقش فقط توسط ادمین) )} - + {u.role === "pm" ? ( - + /> ) : ( — )} @@ -253,7 +274,9 @@ export function OrganizationPage() {
- «بدون نقش» = حذف مدیر پروژه (از پروژه هم جدا می‌شود). اتصال به یک پروژه، مدیر قبلی آن پروژه را جابه‌جا می‌کند. + «بدون نقش» = حذف مدیر پروژه (از همه‌ی پروژه‌ها هم جدا می‌شود). هر مدیر پروژه می‌تواند به + چند پروژه متصل باشد؛ حذف هر چیپ (×)، فقط همان پروژه را جدا می‌کند و اتصال به یک پروژه، مدیرِ + قبلیِ آن پروژه را جابه‌جا می‌کند.
)} diff --git a/frontend/src/pages/Inventory/useInventoryData.ts b/frontend/src/pages/Inventory/useInventoryData.ts index 9f7c727..2b2b55a 100644 --- a/frontend/src/pages/Inventory/useInventoryData.ts +++ b/frontend/src/pages/Inventory/useInventoryData.ts @@ -54,6 +54,9 @@ export function useInventoryData(): InventoryData { setWarehouses(whs); setInventory(inv); setTransactions(txs); + } catch { + // پروژه‌ی فعال ممکن است همین حالا باطل شده باشد — گیتِ پروژه در سطح مسیر + // (RequirePmProjects) پروژه‌ی جایگزین/صفحه‌ی خالی را نشان می‌دهد. } finally { setLoading(false); } diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 8a9d7c9..c5bc783 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -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()( 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" },