From f48f3752afa336f5e288b90b8c219bd4d45bced2 Mon Sep 17 00:00:00 2001 From: ali_mohaghegh Date: Thu, 27 Aug 2026 15:04:24 +0330 Subject: [PATCH] feat(org): user and PM management - list/define users, roles, project assignment (API + organization panel) --- backend/app/routers/users.py | 126 ++++++++- backend/app/schemas/procurement.py | 25 +- frontend/src/lib/api.ts | 27 ++ .../src/pages/Inventory/OrganizationPage.tsx | 247 +++++++++++++++++- 4 files changed, 403 insertions(+), 22 deletions(-) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 03be558..4d14b49 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -1,17 +1,27 @@ -"""Users — profile + role assignment (v2.6, Casdoor-backed). +"""Users — profile + role assignment + organization management (v2.6, Casdoor-backed). -GET /users/me — the caller's profile: role, PM's project, display name. -PATCH /users/{id} — assign role (admin only). +Organization panel endpoints (admin / procurement manager — same person): +- GET /users — list every user with role + assigned project. +- 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). + +Role management is open to BOTH «admin» and «procurement_manager» (they are +the same person in this system), unlike the plain require_role("admin"). """ +import uuid + from fastapi import APIRouter, Depends -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession -from app.auth import UserContext, get_current_user, require_role +from app.auth import UserContext, get_current_user from app.db.engine import get_db from app.models.procurement import Project, User -from app.schemas.procurement import UserRoleUpdate -from app.utils.errors import NotFoundError +from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate +from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError from app.utils.serialization import to_iso router = APIRouter(prefix="/users", tags=["users"]) @@ -38,23 +48,119 @@ def user_to_dict(user: User, project: Project | None = None) -> dict: } +async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext: + """Admin OR procurement manager (the same person) — organization management.""" + if ctx.role not in ("admin", "procurement_manager"): + raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست") + return ctx + + +async def _user_project(db: AsyncSession, user: User) -> Project | None: + if user.role != "pm": + return None + return await db.scalar(select(Project).where(Project.pm_user_id == user.id)) + + +async def _unassign_projects(db: AsyncSession, user_id) -> None: + """Clear the user's pm assignment on every project (one project per PM).""" + await db.execute( + update(Project).where(Project.pm_user_id == user_id).values(pm_user_id=None) + ) + + @router.get("/me") async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)): - project = await db.scalar(select(Project).where(Project.pm_user_id == ctx.user.id)) if ctx.role == "pm" else None + project = await _user_project(db, ctx.user) if ctx.role == "pm" else None return {"data": user_to_dict(ctx.user, project)} +@router.get("") +async def list_users( + _: UserContext = Depends(_org_manager), + db: AsyncSession = Depends(get_db), +): + """Every user (with role + assigned project) — 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 = {} + 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} + return { + "data": [user_to_dict(u, projects.get(u.id)) for u in users], + "meta": {"total": len(users)}, + } + + +@router.post("", status_code=201) +async def create_user( + payload: UserCreate, + _: UserContext = Depends(_org_manager), + db: AsyncSession = Depends(get_db), +): + """Define a new user (manual identity — casdoor_id optional).""" + casdoor_id = (payload.casdoor_id or "").strip() or f"manual-{uuid.uuid4()}" + duplicate = await db.scalar(select(User).where(User.casdoor_id == casdoor_id)) + if duplicate is not None: + raise ConflictError("کاربری با این شناسه قبلاً ثبت شده است", field="casdoor_id") + user = User( + casdoor_id=casdoor_id, + first_name=payload.first_name, + last_name=payload.last_name or "", + phone=payload.phone, + role=payload.role, + ) + db.add(user) + await db.flush() + return {"data": user_to_dict(user)} + + @router.patch("/{user_id}") async def update_user_role( user_id, payload: UserRoleUpdate, - _: UserContext = Depends(require_role("admin")), + _: UserContext = Depends(_org_manager), db: AsyncSession = Depends(get_db), ): + """Set/clear a role. Revoking (or switching away from «pm») unassigns projects.""" user = await db.get(User, user_id) if user is None: raise NotFoundError("کاربر یافت نشد") + if user.role == "pm" and payload.role != "pm": + await _unassign_projects(db, user.id) user.role = payload.role await db.flush() - project = await db.scalar(select(Project).where(Project.pm_user_id == user.id)) if user.role == "pm" else None + project = await _user_project(db, user) + return {"data": user_to_dict(user, project)} + + +@router.post("/{user_id}/project") +async def assign_user_project( + user_id, + payload: UserProjectUpdate, + _: UserContext = Depends(_org_manager), + db: AsyncSession = Depends(get_db), +): + """Assign the user as PM of one project (project_id=None unassigns). + + 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. + """ + user = await db.get(User, user_id) + if user is None: + raise NotFoundError("کاربر یافت نشد") + if user.role != "pm": + raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role") + + await _unassign_projects(db, user.id) + if payload.project_id is None: + await db.flush() + return {"data": user_to_dict(user)} + + project = await db.get(Project, payload.project_id) + if project is None: + raise NotFoundError("پروژه یافت نشد", field="project_id") + project.pm_user_id = user.id + await db.flush() return {"data": user_to_dict(user, project)} diff --git a/backend/app/schemas/procurement.py b/backend/app/schemas/procurement.py index e71d875..352e047 100644 --- a/backend/app/schemas/procurement.py +++ b/backend/app/schemas/procurement.py @@ -188,8 +188,31 @@ class PMReceiptCreate(APIModel): quality_note: str | None = None +class UserCreate(APIModel): + """Define a new user from the Organization panel (admin / procurement manager). + + casdoor_id is optional: real Casdoor users auto-provision on their first + login; a manual entry gets a synthetic casdoor_id so the row can exist + before the person ever signs in. + """ + + first_name: str = Field(min_length=1) + last_name: str = "" + phone: str | None = None + casdoor_id: str | None = None + role: Literal["pm", "procurement_manager", "admin"] | None = None + + class UserRoleUpdate(APIModel): - role: Literal["pm", "procurement_manager", "admin"] + """role=None revokes the role (and any project assignment).""" + + role: Literal["pm", "procurement_manager", "admin"] | None = None + + +class UserProjectUpdate(APIModel): + """Assign/unassign the user's single project (project_id=None unassigns).""" + + project_id: UUID | None = None class RejectQuoteIn(APIModel): diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b59153f..9dd12cf 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1325,6 +1325,20 @@ const rawInventory = (r: { interface RawCat { id: string; slug: string; name: string; } interface RawUnitL { id: number; code: string; name_fa: string | null; } +// Organization panel — one user row (backend GET /users shape). +export interface UserRow { + 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; + created_at: string; +} + export const apiV26 = { lookup: { categories: async (): Promise => getData("/material-categories?page_size=100"), @@ -1356,6 +1370,19 @@ export const apiV26 = { display_name: `${data.first_name} ${data.last_name}`.trim() || data.casdoor_id, }; }, + // --- Organization panel (admin / procurement manager) --- + list: async (): Promise => { + const data = await getData("/users"); + return data; + }, + create: async (body: { + first_name: string; last_name?: string; phone?: string | null; + casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null; + }): Promise => postData("/users", body), + updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | null): Promise => + patchData(`/users/${id}`, { role }), + assignProject: async (id: string, projectId: string | null): Promise => + postData(`/users/${id}/project`, { project_id: projectId }), }, warehouses: { diff --git a/frontend/src/pages/Inventory/OrganizationPage.tsx b/frontend/src/pages/Inventory/OrganizationPage.tsx index 5520e2e..4a702f3 100644 --- a/frontend/src/pages/Inventory/OrganizationPage.tsx +++ b/frontend/src/pages/Inventory/OrganizationPage.tsx @@ -1,35 +1,124 @@ -import { useState } from "react"; -import { useLocation } from "react-router-dom"; +import { useCallback, useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { Header, PageLayout } from "../../components/layout/Header"; import { useAuthStore } from "../../store/auth"; import { PanelMenu } from "../../components/PanelMenu"; -import { panelMenuItems, useLogout } from "./panelMenu"; -import { apiV26 } from "../../lib/api"; +import { panelMenuItems, accountMenuItems, useLogout } from "./panelMenu"; +import { InventoryTopNav } from "./InventoryTopNav"; +import { apiV26, type UserRow } from "../../lib/api"; import { useInventoryData } from "./useInventoryData"; import { faNum } from "../../lib/formatters"; -// سازمان‌دهی (فقط ادمین) — پروژه‌ها، مدیران و انبارهای هر پروژه +type Role = "pm" | "procurement_manager" | "admin" | null; + +const ROLE_LABELS: Record = { + pm: "مدیر پروژه", + procurement_manager: "مدیر تدارکات", + admin: "ادمین", +}; + +// سازمان‌دهی (فقط ادمین / مدیر تدارکات) — کاربران، مدیران پروژه و انبارهای هر پروژه export function OrganizationPage() { const location = useLocation(); + const navigate = useNavigate(); const logout = useLogout(); const user = useAuthStore((s) => s.user); const data = useInventoryData(); + const [users, setUsers] = useState([]); + const [loadingUsers, setLoadingUsers] = useState(true); + const [busyId, setBusyId] = useState(null); + const [newWh, setNewWh] = useState>({}); const [newWhDesc, setNewWhDesc] = useState>({}); - if (user?.role !== "admin" && user?.role !== "procurement_manager") { + // کاربر جدید (تعریف مدیر پروژه) + const [newUserFirst, setNewUserFirst] = useState(""); + const [newUserLast, setNewUserLast] = useState(""); + const [newUserCasdoor, setNewUserCasdoor] = useState(""); + const [newUserRole, setNewUserRole] = useState<"pm" | "procurement_manager">("pm"); + + const canManage = user?.role === "admin" || user?.role === "procurement_manager"; + + const loadUsers = useCallback(async () => { + try { + const list = await apiV26.users.list(); + setUsers(list); + } catch (err) { + toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران"); + } finally { + setLoadingUsers(false); + } + }, []); + + useEffect(() => { + if (canManage) void loadUsers(); + }, [canManage, loadUsers]); + + if (!canManage) { return (
} /> -
این بخش فقط برای ادمین است.
+
این بخش فقط برای ادمین / مدیر تدارکات است.
); } + // ---------- actions: users ---------- + + const addUser = async () => { + const first = newUserFirst.trim(); + if (!first) return toast.error("نام کاربر الزامی است"); + try { + await apiV26.users.create({ + first_name: first, + last_name: newUserLast.trim() || "", + casdoor_id: newUserCasdoor.trim() || null, + role: newUserRole, + }); + toast.success(`کاربر «${first}» تعریف شد ✓`); + setNewUserFirst(""); + setNewUserLast(""); + setNewUserCasdoor(""); + await loadUsers(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "خطا در تعریف کاربر"); + } + }; + + const changeRole = async (u: UserRow, role: Role) => { + setBusyId(u.id); + try { + await apiV26.users.updateRole(u.id, role); + toast.success(`نقش «${ROLE_LABELS[role ?? ""] ?? "بدون نقش"}» برای ${u.first_name} ${u.last_name} ثبت شد ✓`); + await loadUsers(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش"); + } finally { + setBusyId(null); + } + }; + + const changeProject = async (u: UserRow, projectId: string) => { + 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}» متصل شد ✓` : "مدیر پروژه از پروژه جدا شد"); + await loadUsers(); + await data.refresh(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "خطا در اتصال مدیر به پروژه"); + } finally { + setBusyId(null); + } + }; + + // ---------- actions: warehouses ---------- + const addWarehouse = async (projectId: string) => { const name = (newWh[projectId] || "").trim(); if (!name) return toast.error("نام انبار الزامی است"); @@ -65,18 +154,147 @@ export function OrganizationPage() { } }; + const pmOf = (projectId: string) => users.find((u) => u.role === "pm" && u.project_id === projectId) || null; + return (
-
} /> +
} + actions={} + />
ℹ️ پروژه‌ها از سیستم تدارکات می‌آیند. اینجا مشخص می‌کنید هر پروژه با کدام مدیر و انبار(ها) کار می‌کند؛ هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرم‌ها اعمال می‌شود.
+ {/* ---------- کاربران و مدیران پروژه ---------- */} +
+

👥 کاربران و مدیران پروژه

+ + {/* تعریف کاربر جدید */} +
+
+ + setNewUserFirst(e.target.value)} + /> +
+
+ + setNewUserLast(e.target.value)} + /> +
+
+ + +
+
+ + setNewUserCasdoor(e.target.value)} + /> +
+ +
+ + {loadingUsers ? ( +
در حال بارگذاری کاربران…
+ ) : !users.length ? ( +
+ کاربری ثبت نشده است — کاربران پس از اولین ورود (Casdoor) به‌صورت خودکار ساخته می‌شوند؛ + یا از فرم بالا یک مدیر پروژه تعریف کنید. +
+ ) : ( +
+ + + + + + + + + + + {users.map((u) => ( + + + + + + + ))} + +
نامنقشپروژه (مدیر پروژه)عملیات
+ {u.first_name} {u.last_name} + {u.casdoor_id} + + + + {u.role === "pm" ? ( + + ) : ( + — + )} + + {u.role === "pm" && u.project_id && ( + فعال + )} +
+
+ «بدون نقش» = حذف مدیر پروژه (از پروژه هم جدا می‌شود). اتصال به یک پروژه، مدیر قبلی آن پروژه را جابه‌جا می‌کند. +
+
+ )} +
+ + {/* ---------- پروژه‌ها و انبارها ---------- */}
{data.lookup.projects.map((p) => { const whs = data.warehouses.filter((w) => w.project_id === p.id); + const pm = pmOf(p.id); return (
@@ -88,14 +306,14 @@ export function OrganizationPage() {

👤 مدیر پروژه

- {p.pm_user_id ? ( + {pm ? (
- {p.pm_name || "—"} + {pm.first_name} {pm.last_name} (مدیر مسئول پروژه)
) : (
- مدیری تعیین نشده است — از بخش کاربران (Casdoor) نقش مدیر پروژه را به کاربر بدهید و در تنظیمات پروژه وصل کنید. + مدیری تعیین نشده است — از جدول «کاربران و مدیران پروژه» بالا، نقش مدیر پروژه را بدهید و به این پروژه متصل کنید.
)}
@@ -112,6 +330,13 @@ export function OrganizationPage() { {w.description || ""}
+