Files
ERP/frontend/src/pages/Admin/AdminPanel.tsx
T
Milad 730d0bd496 feat: PM panel under ERP shell + project-supervisor role
- /panel/pm: the PM app (served on the same origin under /pm) is embedded as
  a full-height iframe; supervisors are funneled there from every route,
  ERP MobileTabBar hidden on that route
- Panels menu: «مدیریت پروژه (PM)» is an internal route (admin/pm/supervisor
  only — procurement managers do not see it); PM link is no longer external
- New system role 'supervisor' (user_role enum): label in users router,
  allowed in UserRoleUpdate schema, deny-by-default on all ERP routers;
  admin panel can grant/revoke it alongside procurement manager
- Removed the obsolete VITE_PM_URL build plumbing (same-origin now)
2026-09-26 01:39:48 +03:30

219 lines
9.7 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ShieldCheck, Warehouse as WarehouseIcon } from "lucide-react";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { useAuthStore } from "../../store/auth";
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
import { inventorySubheader } from "../Inventory/panelMenu";
import { apiV26, type UserRow } from "../../lib/api";
import { refreshProcurementStore } from "../../hooks";
import { faNum } from "../../lib/formatters";
const ROLE_LABELS: Record<string, string> = {
pm: "مدیر پروژه",
procurement_manager: "مدیر تدارکات",
admin: "ادمین",
supervisor: "سرپرست پروژه",
};
// پنل مدیریت ادمین (فقط ادمین) — همگام‌سازی کاربران از Casdoor و
// اعطا/لغو نقش «مدیر تدارکات». تخصیص پروژه در این پنل نیست
// (مدیریت پروژه‌ها در «سازمان‌دهی» توسط مدیر تدارکات انجام می‌شود).
export function AdminPanel() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const [users, setUsers] = useState<UserRow[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const isAdmin = user?.role === "admin";
const load = useCallback(async () => {
try {
setUsers(await apiV26.users.list());
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (isAdmin) void load();
}, [isAdmin, load]);
const sync = async () => {
setSyncing(true);
try {
const { users: synced, created } = await apiV26.users.sync();
setUsers(synced);
toast.success(
created > 0 ? `همگام‌سازی انجام شد — ${faNum(created)} کاربر جدید از Casdoor اضافه شد ✓` : "همگام‌سازی انجام شد — کاربر جدیدی یافت نشد",
);
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در همگام‌سازی با Casdoor");
} finally {
setSyncing(false);
}
};
const toggleProcurementManager = async (u: UserRow) => {
setBusyId(u.id);
const target = u.role === "procurement_manager" ? null : "procurement_manager";
try {
await apiV26.users.updateRole(u.id, target as "procurement_manager" | null);
toast.success(
target === "procurement_manager"
? `«${u.first_name} ${u.last_name}» مدیر تدارکات شد ✓`
: `نقش مدیر تدارکات از «${u.first_name} ${u.last_name}» گرفته شد`,
);
await load();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
} finally {
setBusyId(null);
}
};
// اعطا/لغو نقش «سرپرست پروژه» — کاربر فقطِ پنل مدیریت پروژه (PM)
const toggleSupervisor = async (u: UserRow) => {
setBusyId(u.id);
const target = u.role === "supervisor" ? null : "supervisor";
try {
await apiV26.users.updateRole(u.id, target as "supervisor" | null);
toast.success(
target === "supervisor"
? `«${u.first_name} ${u.last_name}» سرپرست پروژه شد ✓`
: `نقش سرپرست پروژه از «${u.first_name} ${u.last_name}» گرفته شد`,
);
await load();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
} finally {
setBusyId(null);
}
};
if (!isAdmin) {
return (
<div className="min-h-screen">
<Header title="پنل مدیریت ادمین" backTo="/inventory" backLabel="بازگشت به داشبورد انبار" />
<PageLayout>
<EmptyState emoji="🔒" text="این پنل فقط برای ادمین (سوپر یوزر) است." />
</PageLayout>
</div>
);
}
return (
<div className="min-h-screen">
<Header
title="پنل مدیریت ادمین"
sub={inventorySubheader(user)}
backTo="/inventory"
backLabel="بازگشت به داشبورد انبار"
icon={<ShieldCheck className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<AppSwitcherMenu activeModule="warehouse" />}
actions={
<button
onClick={() => void sync()}
disabled={syncing}
className="btn-line-file cursor-pointer disabled:opacity-50"
>
{syncing ? "در حال همگام‌سازی…" : "🔄 همگام‌سازی از Casdoor"}
</button>
}
/>
<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>
{loading ? (
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
) : !users.length ? (
<EmptyState emoji="👥" text="کاربری یافت نشد — دکمه‌ی «همگام‌سازی از Casdoor» را بزنید." />
) : (
<div className="overflow-hidden rounded-xl border border-line bg-panel">
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b border-line bg-panel text-right text-muted">
<th className="py-2 pe-3">نام کامل</th>
<th className="py-2 pe-3">شناسه Casdoor</th>
<th className="py-2 pe-3">نقش فعلی</th>
<th className="py-2">مدیریت نقش‌ها</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const isPMgr = u.role === "procurement_manager";
const isSup = u.role === "supervisor";
return (
<tr key={u.id} className="border-b border-line/50">
<td className="py-2 pe-3 font-semibold">
{u.first_name} {u.last_name}
</td>
<td className="py-2 pe-3 text-muted ltr">{u.casdoor_id}</td>
<td className="py-2 pe-3">
<span className={`rounded-lg px-2 py-1 text-[11px] font-bold ${u.role ? "bg-accent-bg text-accent" : "bg-slate-bg text-muted"}`}>
{u.role ? ROLE_LABELS[u.role] : "بدون نقش"}
</span>
</td>
<td className="py-2">
<div className="flex flex-wrap items-center gap-1.5">
<button
disabled={busyId === u.id}
onClick={() => void toggleProcurementManager(u)}
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
isPMgr
? "border-red/30 bg-red/5 text-red hover:bg-red/10"
: "border-green/30 bg-green/5 text-green hover:bg-green/10"
} disabled:opacity-50`}
>
{busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
</button>
<button
disabled={busyId === u.id}
onClick={() => void toggleSupervisor(u)}
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
isSup
? "border-red/30 bg-red/5 text-red hover:bg-red/10"
: "border-green/30 bg-green/5 text-green hover:bg-green/10"
} disabled:opacity-50`}
>
{busyId === u.id ? "…" : isSup ? "لغو نقش سرپرست پروژه" : "اعطای نقش سرپرست پروژه"}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="border-t border-line bg-panel px-4 py-2 text-[11px] text-muted">
{faNum(users.length)} کاربر — نقش‌ها فقط توسط ادمین قابل تغییرند (RBAC).
</div>
</div>
)}
<button
onClick={() => navigate("/inventory")}
className="flex w-fit cursor-pointer items-center gap-1.5 rounded-lg border border-line bg-panel px-3 py-1.5 text-[12px] text-muted hover:bg-slate-bg hover:text-ink"
>
<WarehouseIcon className="h-3.5 w-3.5" />
بازگشت به داشبورد انبار
</button>
</PageLayout>
</div>
);
}