feat(org): user and PM management - list/define users, roles, project assignment (API + organization panel)

This commit is contained in:
ali_mohaghegh
2026-08-27 15:04:24 +03:30
parent 034c05c713
commit f48f3752af
4 changed files with 403 additions and 22 deletions
+27
View File
@@ -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: {
+236 -11
View File
@@ -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}