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.
|
||||
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)}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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<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,
|
||||
};
|
||||
},
|
||||
// --- 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: {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<UserRow[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const [newWh, setNewWh] = 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 (
|
||||
<div className="min-h-screen">
|
||||
<Header title="سازماندهی" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||
<PageLayout>
|
||||
<div className="text-[13px] text-muted">این بخش فقط برای ادمین است.</div>
|
||||
<div className="text-[13px] text-muted">این بخش فقط برای ادمین / مدیر تدارکات است.</div>
|
||||
</PageLayout>
|
||||
</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 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 (
|
||||
<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">
|
||||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||
ℹ️ پروژهها از سیستم تدارکات میآیند. اینجا مشخص میکنید هر پروژه با کدام مدیر و انبار(ها) کار میکند؛
|
||||
هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرمها اعمال میشود.
|
||||
</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">
|
||||
{data.lookup.projects.map((p) => {
|
||||
const whs = data.warehouses.filter((w) => w.project_id === p.id);
|
||||
const pm = pmOf(p.id);
|
||||
return (
|
||||
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
@@ -88,14 +306,14 @@ export function OrganizationPage() {
|
||||
|
||||
<div className="mb-3">
|
||||
<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]">
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">
|
||||
مدیری تعیین نشده است — از بخش کاربران (Casdoor) نقش مدیر پروژه را به کاربر بدهید و در تنظیمات پروژه وصل کنید.
|
||||
مدیری تعیین نشده است — از جدول «کاربران و مدیران پروژه» بالا، نقش مدیر پروژه را بدهید و به این پروژه متصل کنید.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -112,6 +330,13 @@ export function OrganizationPage() {
|
||||
<small className="block truncate text-muted">{w.description || ""}</small>
|
||||
</div>
|
||||
<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
|
||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[11px]"
|
||||
value={w.project_id}
|
||||
|
||||
Reference in New Issue
Block a user