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)
This commit is contained in:
2026-09-26 01:39:48 +03:30
parent a02b3ea7cc
commit 730d0bd496
9 changed files with 132 additions and 25 deletions
+2
View File
@@ -41,6 +41,8 @@ USER_ROLE_LABELS = {
"pm": "مدیر پروژه", "pm": "مدیر پروژه",
"procurement_manager": "مدیر تدارکات", "procurement_manager": "مدیر تدارکات",
"admin": "ادمین", "admin": "ادمین",
# کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد
"supervisor": "سرپرست پروژه",
} }
+1 -1
View File
@@ -209,7 +209,7 @@ class UserCreate(APIModel):
class UserRoleUpdate(APIModel): class UserRoleUpdate(APIModel):
"""role=None revokes the role (and any project assignment).""" """role=None revokes the role (and any project assignment)."""
role: Literal["pm", "procurement_manager", "admin"] | None = None role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
class UserProjectUpdate(APIModel): class UserProjectUpdate(APIModel):
+37 -1
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense } from "react"; import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useAuthStore } from "./store/auth"; import { useAuthStore } from "./store/auth";
import { ErrorBoundary } from "./components/ErrorBoundary"; import { ErrorBoundary } from "./components/ErrorBoundary";
import { RequirePmProjects } from "./components/RequirePmProjects"; import { RequirePmProjects } from "./components/RequirePmProjects";
@@ -53,9 +53,14 @@ function Loading() {
} }
// همه‌ی صفحات پشت ورود هستند — بدون نشست، به صفحه‌ی ورود می‌رویم. // همه‌ی صفحات پشت ورود هستند — بدون نشست، به صفحه‌ی ورود می‌رویم.
// سرپرست پروژه (فقطِ پنل PM) از هر مسیری به پنل PM هدایت می‌شود.
function RequireAuth({ children }: { children: React.ReactNode }) { function RequireAuth({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const { pathname } = useLocation();
if (!user) return <Navigate to="/login" replace />; if (!user) return <Navigate to="/login" replace />;
if (user.role === "supervisor" && pathname !== "/panel/pm") {
return <Navigate to="/panel/pm" replace />;
}
return <>{children}</>; return <>{children}</>;
} }
@@ -66,6 +71,26 @@ function HomeRoute() {
return <DashboardPage />; return <DashboardPage />;
} }
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود و
// اینجا به‌صورت iframe تمام‌صفحه داخل پوسته ERP نمایش داده می‌شود.
function PmPanelPage() {
return (
<iframe
src="/pm/"
title="مدیریت پروژه (PM)"
className="h-dvh w-full border-0"
/>
);
}
// دسترسی پنل PM: ادمین، مدیر پروژه و سرپرست پروژه — مدیر تدارکات ندارد
function RequirePmPanel({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const allowed = user?.role === "admin" || user?.role === "pm" || user?.role === "supervisor";
if (!allowed) return <Navigate to="/" replace />;
return <>{children}</>;
}
export default function App() { export default function App() {
return ( return (
<ErrorBoundary> <ErrorBoundary>
@@ -207,6 +232,17 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
{/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */}
<Route
path="/panel/pm"
element={
<RequireAuth>
<RequirePmPanel>
<PmPanelPage />
</RequirePmPanel>
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Suspense> </Suspense>
+21 -8
View File
@@ -3,12 +3,15 @@ import { PanelMenu, type MenuItem } from "./PanelMenu";
import { useLogout } from "../pages/Inventory/panelMenu"; import { useLogout } from "../pages/Inventory/panelMenu";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
// منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) — // منوی همبرگری مشترک بین ماژول‌ها (پنل انبار / دفتر تدارکات / مدیریت پروژه) —
// دقیقاً مثل منوی دفتر: عنوان «پنل‌ها»، ماژول فعلی با استایل فعال (پر) // دقیقاً مثل منوی دفتر: عنوان «پنل‌ها»، ماژول فعلی با استایل فعال (پر)
// + «✓ فعلی»، ماژول دیگر به‌عنوان سوییچر، و در پایان جداکننده + خروج. // + «✓ فعلی»، بقیه به‌عنوان سوییچر، و در پایان جداکننده + خروج.
// مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز می‌شود که همه‌ی // مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز می‌شود که همه‌ی
// پروژه‌های تخصیص‌یافته را نشان می‌دهد؛ پروژه‌ی فعال با «✓ فعلی» مشخص است و // پروژه‌های تخصیص‌یافته را نشان می‌دهد؛ پروژه‌ی فعال با «✓ فعلی» مشخص است و
// انتخاب هر پروژه فقط «پروژه‌ی فعال» را عوض می‌کند (نشست/توکن دست‌نخورده). // انتخاب هر پروژه فقط «پروژه‌ی فعال» را عوض می‌کند (نشست/توکن دست‌نخورده).
// «مدیریت پروژه (PM)» مسیر داخلی /panel/pm است (اپ PM روی همین دامنه زیر /pm
// سرو می‌شود و داخل پوسته ERP نمایش داده می‌شود) — فقط برای ادمین و مدیر پروژه؛
// مدیر تدارکات این آیتم را نمی‌بیند.
export function AppSwitcherMenu({ export function AppSwitcherMenu({
activeModule, activeModule,
canSwitchToProcurement = true, canSwitchToProcurement = true,
@@ -23,6 +26,8 @@ export function AppSwitcherMenu({
const items: MenuItem[] = []; const items: MenuItem[] = [];
const isPm = user?.role === "pm"; const isPm = user?.role === "pm";
const isSupervisor = user?.role === "supervisor";
const canSeePmPanel = user?.role === "admin" || isPm || isSupervisor;
const pmProjects = isPm ? user?.projects ?? [] : []; const pmProjects = isPm ? user?.projects ?? [] : [];
const projectChildren: MenuItem[] | undefined = const projectChildren: MenuItem[] | undefined =
activeModule === "warehouse" && isPm activeModule === "warehouse" && isPm
@@ -34,13 +39,21 @@ export function AppSwitcherMenu({
})) }))
: undefined; : undefined;
if (activeModule === "procurement") { if (isSupervisor) {
items.push({ label: "مدیریت انبار", to: "/inventory" }); // سرپرست پروژه فقط پنل PM را می‌بیند
items.push({ label: "مدیریت تدارکات", to: "/", active: true }); items.push({ label: "مدیریت پروژه (PM)", to: "/panel/pm", active: true });
} else { } else {
items.push({ label: "مدیریت انبار", to: "/inventory", active: true, children: projectChildren }); if (activeModule === "procurement") {
if (canSwitchToProcurement) { items.push({ label: "مدیریت انبار", to: "/inventory" });
items.push({ label: "مدیریت تدارکات", to: "/" }); items.push({ label: "مدیریت تدارکات", to: "/", active: true });
} else {
items.push({ label: "مدیریت انبار", to: "/inventory", active: true, children: projectChildren });
if (canSwitchToProcurement) {
items.push({ label: "مدیریت تدارکات", to: "/" });
}
}
if (canSeePmPanel) {
items.push({ label: "مدیریت پروژه (PM)", to: "/panel/pm" });
} }
} }
items.push({ divider: true }); items.push({ divider: true });
+17
View File
@@ -6,6 +6,8 @@ import { cn } from "../lib/utils";
export interface MenuItem { export interface MenuItem {
label?: string; label?: string;
to?: string; to?: string;
// لینک خارجی (اپ جداگانه، مثل سامانه PM) — در تب جدید باز می‌شود
href?: string;
active?: boolean; active?: boolean;
divider?: boolean; divider?: boolean;
onClick?: () => void; onClick?: () => void;
@@ -112,6 +114,21 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
</div> </div>
)} )}
</div> </div>
) : it.href ? (
<a
key={i}
href={it.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className={cn(
"flex items-center justify-between gap-3 rounded-lg px-3 py-2 text-[13px] no-underline",
it.active ? "bg-accent font-bold text-white" : "text-ink hover:bg-slate-bg",
)}
>
{it.label}
<span aria-hidden className="text-[10px] opacity-60">↗</span>
</a>
) : it.to ? ( ) : it.to ? (
<Link <Link
key={i} key={i}
@@ -81,6 +81,10 @@ export function MobileTabBar() {
const { pathname } = useLocation(); const { pathname } = useLocation();
if (pathname === "/login" || pathname === "/admin") return null; if (pathname === "/login" || pathname === "/admin") return null;
// پنل مدیریت پروژه: iframe تمام‌صفحه‌ی PM است و خودش تب‌بار داخلی دارد
if (pathname === "/panel/pm") return null;
// سرپرست پروژه فقطِ پنل PM است و هیچ تب ERP ندارد
if (role === "supervisor") return null;
const isPM = role === "pm"; const isPM = role === "pm";
const isOffice = role === "admin" || role === "procurement_manager"; const isOffice = role === "admin" || role === "procurement_manager";
+2 -2
View File
@@ -1528,7 +1528,7 @@ export interface UserRow {
first_name: string; first_name: string;
last_name: string; last_name: string;
phone: string | null; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null; role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
role_label: string | null; role_label: string | null;
project_id: string | null; project_id: string | null;
project_name: string | null; project_name: string | null;
@@ -1620,7 +1620,7 @@ export const apiV26 = {
first_name: string; last_name?: string; phone?: string | null; first_name: string; last_name?: string; phone?: string | null;
casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null; casdoor_id?: string | null; role?: "pm" | "procurement_manager" | "admin" | null;
}): Promise<UserRow> => postData<UserRow>("/users", body), }): Promise<UserRow> => postData<UserRow>("/users", body),
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | null): Promise<UserRow> => updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | "supervisor" | null): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}`, { role }), patchData<UserRow>(`/users/${id}`, { role }),
assignProject: async (id: string, projectId: string | null): Promise<UserRow> => assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }), postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
+47 -12
View File
@@ -15,6 +15,7 @@ const ROLE_LABELS: Record<string, string> = {
pm: "مدیر پروژه", pm: "مدیر پروژه",
procurement_manager: "مدیر تدارکات", procurement_manager: "مدیر تدارکات",
admin: "ادمین", admin: "ادمین",
supervisor: "سرپرست پروژه",
}; };
// پنل مدیریت ادمین (فقط ادمین) — همگام‌سازی کاربران از Casdoor و // پنل مدیریت ادمین (فقط ادمین) — همگام‌سازی کاربران از Casdoor و
@@ -80,6 +81,26 @@ export function AdminPanel() {
} }
}; };
// اعطا/لغو نقش «سرپرست پروژه» — کاربر فقطِ پنل مدیریت پروژه (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) { if (!isAdmin) {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
@@ -129,12 +150,13 @@ export function AdminPanel() {
<th className="py-2 pe-3">نام کامل</th> <th className="py-2 pe-3">نام کامل</th>
<th className="py-2 pe-3">شناسه Casdoor</th> <th className="py-2 pe-3">شناسه Casdoor</th>
<th className="py-2 pe-3">نقش فعلی</th> <th className="py-2 pe-3">نقش فعلی</th>
<th className="py-2">مدیریت مدیر تدارکات</th> <th className="py-2">مدیریت نقش‌ها</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users.map((u) => { {users.map((u) => {
const isPMgr = u.role === "procurement_manager"; const isPMgr = u.role === "procurement_manager";
const isSup = u.role === "supervisor";
return ( return (
<tr key={u.id} className="border-b border-line/50"> <tr key={u.id} className="border-b border-line/50">
<td className="py-2 pe-3 font-semibold"> <td className="py-2 pe-3 font-semibold">
@@ -147,17 +169,30 @@ export function AdminPanel() {
</span> </span>
</td> </td>
<td className="py-2"> <td className="py-2">
<button <div className="flex flex-wrap items-center gap-1.5">
disabled={busyId === u.id} <button
onClick={() => void toggleProcurementManager(u)} disabled={busyId === u.id}
className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${ onClick={() => void toggleProcurementManager(u)}
isPMgr className={`rounded-lg border px-3 py-1 text-[11.5px] font-bold ${
? "border-red/30 bg-red/5 text-red hover:bg-red/10" isPMgr
: "border-green/30 bg-green/5 text-green hover:bg-green/10" ? "border-red/30 bg-red/5 text-red hover:bg-red/10"
} disabled:opacity-50`} : "border-green/30 bg-green/5 text-green hover:bg-green/10"
> } disabled:opacity-50`}
{busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"} >
</button> {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> </td>
</tr> </tr>
); );
+1 -1
View File
@@ -14,7 +14,7 @@ export interface AuthUser {
first_name: string; first_name: string;
last_name: string; last_name: string;
phone: string | null; phone: string | null;
role: "pm" | "procurement_manager" | "admin" | null; role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
role_label: string | null; role_label: string | null;
project_id: string | null; // پروژه‌ی فعال (PM) — با سوییچر عوض می‌شود project_id: string | null; // پروژه‌ی فعال (PM) — با سوییچر عوض می‌شود
project_name: string | null; project_name: string | null;