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:
@@ -41,6 +41,8 @@ USER_ROLE_LABELS = {
|
|||||||
"pm": "مدیر پروژه",
|
"pm": "مدیر پروژه",
|
||||||
"procurement_manager": "مدیر تدارکات",
|
"procurement_manager": "مدیر تدارکات",
|
||||||
"admin": "ادمین",
|
"admin": "ادمین",
|
||||||
|
# کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد
|
||||||
|
"supervisor": "سرپرست پروژه",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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,6 +39,10 @@ export function AppSwitcherMenu({
|
|||||||
}))
|
}))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
if (isSupervisor) {
|
||||||
|
// سرپرست پروژه فقط پنل PM را میبیند
|
||||||
|
items.push({ label: "مدیریت پروژه (PM)", to: "/panel/pm", active: true });
|
||||||
|
} else {
|
||||||
if (activeModule === "procurement") {
|
if (activeModule === "procurement") {
|
||||||
items.push({ label: "مدیریت انبار", to: "/inventory" });
|
items.push({ label: "مدیریت انبار", to: "/inventory" });
|
||||||
items.push({ label: "مدیریت تدارکات", to: "/", active: true });
|
items.push({ label: "مدیریت تدارکات", to: "/", active: true });
|
||||||
@@ -43,6 +52,10 @@ export function AppSwitcherMenu({
|
|||||||
items.push({ label: "مدیریت تدارکات", to: "/" });
|
items.push({ label: "مدیریت تدارکات", to: "/" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (canSeePmPanel) {
|
||||||
|
items.push({ label: "مدیریت پروژه (PM)", to: "/panel/pm" });
|
||||||
|
}
|
||||||
|
}
|
||||||
items.push({ divider: true });
|
items.push({ divider: true });
|
||||||
items.push({ label: "خروج از حساب", onClick: logout });
|
items.push({ label: "خروج از حساب", onClick: logout });
|
||||||
return <PanelMenu title="پنلها" items={items} />;
|
return <PanelMenu title="پنلها" items={items} />;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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,6 +169,7 @@ export function AdminPanel() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
disabled={busyId === u.id}
|
disabled={busyId === u.id}
|
||||||
onClick={() => void toggleProcurementManager(u)}
|
onClick={() => void toggleProcurementManager(u)}
|
||||||
@@ -158,6 +181,18 @@ export function AdminPanel() {
|
|||||||
>
|
>
|
||||||
{busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
|
{busyId === u.id ? "…" : isPMgr ? "لغو نقش مدیر تدارکات" : "اعطای نقش مدیر تدارکات"}
|
||||||
</button>
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user