feat(org): user and PM management - list/define users, roles, project assignment (API + organization panel)
This commit is contained in:
+116
-10
@@ -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.
|
Organization panel endpoints (admin / procurement manager — same person):
|
||||||
PATCH /users/{id} — assign role (admin only).
|
- 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 fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_current_user, require_role
|
from app.auth import UserContext, get_current_user
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.procurement import Project, User
|
from app.models.procurement import Project, User
|
||||||
from app.schemas.procurement import UserRoleUpdate
|
from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate
|
||||||
from app.utils.errors import NotFoundError
|
from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
|
||||||
from app.utils.serialization import to_iso
|
from app.utils.serialization import to_iso
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
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")
|
@router.get("/me")
|
||||||
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||||
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)}
|
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}")
|
@router.patch("/{user_id}")
|
||||||
async def update_user_role(
|
async def update_user_role(
|
||||||
user_id,
|
user_id,
|
||||||
payload: UserRoleUpdate,
|
payload: UserRoleUpdate,
|
||||||
_: UserContext = Depends(require_role("admin")),
|
_: UserContext = Depends(_org_manager),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
"""Set/clear a role. Revoking (or switching away from «pm») unassigns projects."""
|
||||||
user = await db.get(User, user_id)
|
user = await db.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("کاربر یافت نشد")
|
raise NotFoundError("کاربر یافت نشد")
|
||||||
|
if user.role == "pm" and payload.role != "pm":
|
||||||
|
await _unassign_projects(db, user.id)
|
||||||
user.role = payload.role
|
user.role = payload.role
|
||||||
await db.flush()
|
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)}
|
return {"data": user_to_dict(user, project)}
|
||||||
|
|||||||
@@ -188,8 +188,31 @@ class PMReceiptCreate(APIModel):
|
|||||||
quality_note: str | None = None
|
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):
|
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):
|
class RejectQuoteIn(APIModel):
|
||||||
|
|||||||
@@ -1325,6 +1325,20 @@ const rawInventory = (r: {
|
|||||||
interface RawCat { id: string; slug: string; name: string; }
|
interface RawCat { id: string; slug: string; name: string; }
|
||||||
interface RawUnitL { id: number; code: string; name_fa: string | null; }
|
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 = {
|
export const apiV26 = {
|
||||||
lookup: {
|
lookup: {
|
||||||
categories: async (): Promise<RawCat[]> => getData<RawCat[]>("/material-categories?page_size=100"),
|
categories: async (): Promise<RawCat[]> => getData<RawCat[]>("/material-categories?page_size=100"),
|
||||||
@@ -1356,6 +1370,19 @@ export const apiV26 = {
|
|||||||
display_name: `${data.first_name} ${data.last_name}`.trim() || data.casdoor_id,
|
display_name: `${data.first_name} ${data.last_name}`.trim() || data.casdoor_id,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
// --- Organization panel (admin / procurement manager) ---
|
||||||
|
list: async (): Promise<UserRow[]> => {
|
||||||
|
const data = await getData<UserRow[]>("/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<UserRow> => postData<UserRow>("/users", body),
|
||||||
|
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | null): Promise<UserRow> =>
|
||||||
|
patchData<UserRow>(`/users/${id}`, { role }),
|
||||||
|
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
|
||||||
|
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
|
||||||
},
|
},
|
||||||
|
|
||||||
warehouses: {
|
warehouses: {
|
||||||
|
|||||||
@@ -1,35 +1,124 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import { PanelMenu } from "../../components/PanelMenu";
|
import { PanelMenu } from "../../components/PanelMenu";
|
||||||
import { panelMenuItems, useLogout } from "./panelMenu";
|
import { panelMenuItems, accountMenuItems, useLogout } from "./panelMenu";
|
||||||
import { apiV26 } from "../../lib/api";
|
import { InventoryTopNav } from "./InventoryTopNav";
|
||||||
|
import { apiV26, type UserRow } from "../../lib/api";
|
||||||
import { useInventoryData } from "./useInventoryData";
|
import { useInventoryData } from "./useInventoryData";
|
||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
|
|
||||||
// سازماندهی (فقط ادمین) — پروژهها، مدیران و انبارهای هر پروژه
|
type Role = "pm" | "procurement_manager" | "admin" | null;
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
pm: "مدیر پروژه",
|
||||||
|
procurement_manager: "مدیر تدارکات",
|
||||||
|
admin: "ادمین",
|
||||||
|
};
|
||||||
|
|
||||||
|
// سازماندهی (فقط ادمین / مدیر تدارکات) — کاربران، مدیران پروژه و انبارهای هر پروژه
|
||||||
export function OrganizationPage() {
|
export function OrganizationPage() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const data = useInventoryData();
|
const data = useInventoryData();
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<UserRow[]>([]);
|
||||||
|
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [newWh, setNewWh] = useState<Record<string, string>>({});
|
const [newWh, setNewWh] = useState<Record<string, string>>({});
|
||||||
const [newWhDesc, setNewWhDesc] = useState<Record<string, string>>({});
|
const [newWhDesc, setNewWhDesc] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header title="سازماندهی" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
<Header title="سازماندهی" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="text-[13px] text-muted">این بخش فقط برای ادمین است.</div>
|
<div className="text-[13px] text-muted">این بخش فقط برای ادمین / مدیر تدارکات است.</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 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 addWarehouse = async (projectId: string) => {
|
||||||
const name = (newWh[projectId] || "").trim();
|
const name = (newWh[projectId] || "").trim();
|
||||||
if (!name) return toast.error("نام انبار الزامی است");
|
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 (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header title="سازماندهی" sub="ادمین" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
<Header
|
||||||
|
title="سازماندهی"
|
||||||
|
sub="ادمین / مدیر تدارکات"
|
||||||
|
menu={<PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} />}
|
||||||
|
actions={<InventoryTopNav pathname={location.pathname} onLogout={logout} variant="admin" />}
|
||||||
|
/>
|
||||||
<PageLayout className="flex flex-col gap-3">
|
<PageLayout className="flex flex-col gap-3">
|
||||||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||||
ℹ️ پروژهها از سیستم تدارکات میآیند. اینجا مشخص میکنید هر پروژه با کدام مدیر و انبار(ها) کار میکند؛
|
ℹ️ پروژهها از سیستم تدارکات میآیند. اینجا مشخص میکنید هر پروژه با کدام مدیر و انبار(ها) کار میکند؛
|
||||||
هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرمها اعمال میشود.
|
هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرمها اعمال میشود.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ---------- کاربران و مدیران پروژه ---------- */}
|
||||||
|
<div className="rounded-xl border border-line bg-panel p-4">
|
||||||
|
<h3 className="mb-2 text-[14px] font-bold">👥 کاربران و مدیران پروژه</h3>
|
||||||
|
|
||||||
|
{/* تعریف کاربر جدید */}
|
||||||
|
<div className="mb-3 flex flex-wrap items-end gap-2 rounded-lg border border-dashed border-accent/40 bg-accent/5 p-3">
|
||||||
|
<div className="field mb-0">
|
||||||
|
<label>نام *</label>
|
||||||
|
<input
|
||||||
|
className="h-8 w-36 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||||
|
placeholder="مثلاً رضایی"
|
||||||
|
value={newUserFirst}
|
||||||
|
onChange={(e) => setNewUserFirst(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field mb-0">
|
||||||
|
<label>نام خانوادگی</label>
|
||||||
|
<input
|
||||||
|
className="h-8 w-36 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||||
|
placeholder="(اختیاری)"
|
||||||
|
value={newUserLast}
|
||||||
|
onChange={(e) => setNewUserLast(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field mb-0">
|
||||||
|
<label>نقش</label>
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
|
||||||
|
value={newUserRole ?? ""}
|
||||||
|
onChange={(e) => setNewUserRole(e.target.value as "pm" | "procurement_manager")}
|
||||||
|
>
|
||||||
|
<option value="pm">مدیر پروژه</option>
|
||||||
|
<option value="procurement_manager">مدیر تدارکات</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field mb-0">
|
||||||
|
<label>شناسه Casdoor</label>
|
||||||
|
<input
|
||||||
|
className="h-8 w-44 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||||
|
placeholder="(اختیاری — برای کاربر واقعی)"
|
||||||
|
value={newUserCasdoor}
|
||||||
|
onChange={(e) => setNewUserCasdoor(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="rounded-lg bg-accent px-3 py-1.5 text-[12px] font-bold text-white" onClick={() => void addUser()}>
|
||||||
|
+ تعریف کاربر
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingUsers ? (
|
||||||
|
<div className="py-3 text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
|
||||||
|
) : !users.length ? (
|
||||||
|
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
|
||||||
|
کاربری ثبت نشده است — کاربران پس از اولین ورود (Casdoor) بهصورت خودکار ساخته میشوند؛
|
||||||
|
یا از فرم بالا یک مدیر پروژه تعریف کنید.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-[12.5px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-line text-right text-muted">
|
||||||
|
<th className="py-2 pe-3">نام</th>
|
||||||
|
<th className="py-2 pe-3">نقش</th>
|
||||||
|
<th className="py-2 pe-3">پروژه (مدیر پروژه)</th>
|
||||||
|
<th className="py-2">عملیات</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id} className="border-b border-line/50">
|
||||||
|
<td className="py-1.5 pe-3">
|
||||||
|
<b>{u.first_name} {u.last_name}</b>
|
||||||
|
<small className="block text-[10.5px] text-muted ltr">{u.casdoor_id}</small>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pe-3">
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
||||||
|
value={u.role ?? ""}
|
||||||
|
disabled={busyId === u.id}
|
||||||
|
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
|
||||||
|
>
|
||||||
|
<option value="">— بدون نقش —</option>
|
||||||
|
<option value="pm">مدیر پروژه</option>
|
||||||
|
<option value="procurement_manager">مدیر تدارکات</option>
|
||||||
|
<option value="admin">ادمین</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5 pe-3">
|
||||||
|
{u.role === "pm" ? (
|
||||||
|
<select
|
||||||
|
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
||||||
|
value={u.project_id || ""}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1.5">
|
||||||
|
{u.role === "pm" && u.project_id && (
|
||||||
|
<span className="rounded-lg bg-green/10 px-2 py-1 text-[10.5px] font-bold text-green">فعال</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="mt-1 text-[11px] text-muted">
|
||||||
|
«بدون نقش» = حذف مدیر پروژه (از پروژه هم جدا میشود). اتصال به یک پروژه، مدیر قبلی آن پروژه را جابهجا میکند.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---------- پروژهها و انبارها ---------- */}
|
||||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
|
||||||
{data.lookup.projects.map((p) => {
|
{data.lookup.projects.map((p) => {
|
||||||
const whs = data.warehouses.filter((w) => w.project_id === p.id);
|
const whs = data.warehouses.filter((w) => w.project_id === p.id);
|
||||||
|
const pm = pmOf(p.id);
|
||||||
return (
|
return (
|
||||||
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
|
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
@@ -88,14 +306,14 @@ export function OrganizationPage() {
|
|||||||
|
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<h4 className="mb-1 text-[12px] font-bold text-muted">👤 مدیر پروژه</h4>
|
<h4 className="mb-1 text-[12px] font-bold text-muted">👤 مدیر پروژه</h4>
|
||||||
{p.pm_user_id ? (
|
{pm ? (
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
|
<div className="flex items-center gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
|
||||||
<span className="font-semibold">{p.pm_name || "—"}</span>
|
<span className="font-semibold">{pm.first_name} {pm.last_name}</span>
|
||||||
<span className="text-[10.5px] text-muted">(مدیر مسئول پروژه)</span>
|
<span className="text-[10.5px] text-muted">(مدیر مسئول پروژه)</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">
|
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">
|
||||||
مدیری تعیین نشده است — از بخش کاربران (Casdoor) نقش مدیر پروژه را به کاربر بدهید و در تنظیمات پروژه وصل کنید.
|
مدیری تعیین نشده است — از جدول «کاربران و مدیران پروژه» بالا، نقش مدیر پروژه را بدهید و به این پروژه متصل کنید.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -112,6 +330,13 @@ export function OrganizationPage() {
|
|||||||
<small className="block truncate text-muted">{w.description || ""}</small>
|
<small className="block truncate text-muted">{w.description || ""}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
className="rounded-lg border border-accent/40 bg-accent/5 px-2 py-1 text-[10.5px] font-bold text-accent"
|
||||||
|
onClick={() => navigate(`/inventory?warehouse=${w.id}`)}
|
||||||
|
title="داشبورد این انبار"
|
||||||
|
>
|
||||||
|
📊 داشبورد
|
||||||
|
</button>
|
||||||
<select
|
<select
|
||||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[11px]"
|
className="h-7 rounded-lg border border-line bg-panel px-2 text-[11px]"
|
||||||
value={w.project_id}
|
value={w.project_id}
|
||||||
|
|||||||
Reference in New Issue
Block a user