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
+21 -8
View File
@@ -3,12 +3,15 @@ import { PanelMenu, type MenuItem } from "./PanelMenu";
import { useLogout } from "../pages/Inventory/panelMenu";
import { useAuthStore } from "../store/auth";
// منوی همبرگری مشترک بین دو ماژول (پنل انبار / دفتر تدارکات) —
// منوی همبرگری مشترک بین ماژول‌ها (پنل انبار / دفتر تدارکات / مدیریت پروژه) —
// دقیقاً مثل منوی دفتر: عنوان «پنل‌ها»، ماژول فعلی با استایل فعال (پر)
// + «✓ فعلی»، ماژول دیگر به‌عنوان سوییچر، و در پایان جداکننده + خروج.
// + «✓ فعلی»، بقیه به‌عنوان سوییچر، و در پایان جداکننده + خروج.
// مدیر پروژه (1:N): زیر «مدیریت انبار» یک آکاردئون باز می‌شود که همه‌ی
// پروژه‌های تخصیص‌یافته را نشان می‌دهد؛ پروژه‌ی فعال با «✓ فعلی» مشخص است و
// انتخاب هر پروژه فقط «پروژه‌ی فعال» را عوض می‌کند (نشست/توکن دست‌نخورده).
// «مدیریت پروژه (PM)» مسیر داخلی /panel/pm است (اپ PM روی همین دامنه زیر /pm
// سرو می‌شود و داخل پوسته ERP نمایش داده می‌شود) — فقط برای ادمین و مدیر پروژه؛
// مدیر تدارکات این آیتم را نمی‌بیند.
export function AppSwitcherMenu({
activeModule,
canSwitchToProcurement = true,
@@ -23,6 +26,8 @@ export function AppSwitcherMenu({
const items: MenuItem[] = [];
const isPm = user?.role === "pm";
const isSupervisor = user?.role === "supervisor";
const canSeePmPanel = user?.role === "admin" || isPm || isSupervisor;
const pmProjects = isPm ? user?.projects ?? [] : [];
const projectChildren: MenuItem[] | undefined =
activeModule === "warehouse" && isPm
@@ -34,13 +39,21 @@ export function AppSwitcherMenu({
}))
: undefined;
if (activeModule === "procurement") {
items.push({ label: "مدیریت انبار", to: "/inventory" });
items.push({ label: "مدیریت تدارکات", to: "/", active: true });
if (isSupervisor) {
// سرپرست پروژه فقط پنل PM را می‌بیند
items.push({ label: "مدیریت پروژه (PM)", to: "/panel/pm", active: true });
} else {
items.push({ label: "مدیریت انبار", to: "/inventory", active: true, children: projectChildren });
if (canSwitchToProcurement) {
items.push({ label: "مدیریت تدارکات", to: "/" });
if (activeModule === "procurement") {
items.push({ label: "مدیریت انبار", to: "/inventory" });
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 });
+17
View File
@@ -6,6 +6,8 @@ import { cn } from "../lib/utils";
export interface MenuItem {
label?: string;
to?: string;
// لینک خارجی (اپ جداگانه، مثل سامانه PM) — در تب جدید باز می‌شود
href?: string;
active?: boolean;
divider?: boolean;
onClick?: () => void;
@@ -112,6 +114,21 @@ export function PanelMenu({ items, title }: { items: MenuItem[]; title?: string
</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 ? (
<Link
key={i}
@@ -81,6 +81,10 @@ export function MobileTabBar() {
const { pathname } = useLocation();
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 isOffice = role === "admin" || role === "procurement_manager";