feat(org): user and PM management - list/define users, roles, project assignment (API + organization panel)
This commit is contained in:
@@ -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