Merge pm (panel-based access, supervisor role) onto the restored RBAC base

Reverting 253c922 first made this merge small — only two conflicts:
- MobileTabBar: supervisor null-check combined with v7's /review hiding
- ArrivalsPage: pm's PM+supervisor scoping combined with v8's 3-way fetch
  (transfers) and its transfer-receive helpers, plus pm's canAccess gate
This commit is contained in:
MMD
2026-10-01 15:36:57 +03:30
41 changed files with 743 additions and 146 deletions
+49 -11
View File
@@ -43,6 +43,9 @@ const OrganizationPage = lazy(() =>
const AdminPanelPage = lazy(() =>
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
);
const RequesterHome = lazy(() =>
import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })),
);
function Loading() {
return (
@@ -102,20 +105,34 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
// صفحه‌ی اصلی بر اساس نقش: سرپرست پروژه → پنل مدیریت پروژه (PM)؛ مدیران و ادمین → دفتر تدارکات
// صفحه‌ی اصلی بر اساس لایه پنل‌ها (جایگزین نقش‌ها):
// انبار → پنل انبار · تدارکات limited → خانه حداقلی استعلام · بقیه → دفتر تدارکات
// نقش‌های میراث (سرپرست → PM، مدیر پروژه → انبار) برای دوره مهاجرت حفظ شده‌اند.
function HomeRoute() {
const user = useAuthStore((s) => s.user);
const panels = user?.panels ?? {};
if (panels.inventory) return <Navigate to="/inventory" replace />;
if (panels.procurement === "limited") return <RequesterHome />;
// فقط پنل PM: مستقیم به پنل مدیریت پروژه
if (panels.pm && !panels.inventory && !panels.procurement) {
return <Navigate to="/panel/pm" replace />;
}
if (user?.role === "supervisor") return <Navigate to="/panel/pm" replace />;
if (user?.role === "pm") return <Navigate to="/inventory" replace />;
return <DashboardPage />;
}
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): مدیر تدارکات، مدیر پروژه و ادمین
// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های میراث، تدارکات full
// و تدارکات limited (محدود به پروژه‌های عضویت — صفحه /requester)
function RequireProcurementWrite({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
if (user?.role !== "admin" && user?.role !== "procurement_manager" && user?.role !== "pm") {
return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
const panels = user?.panels ?? {};
if (user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm") {
return <>{children}</>;
}
return <>{children}</>;
if (panels.procurement === "full") return <>{children}</>;
if (panels.procurement === "limited") return <Navigate to="/requester" replace />;
return <Navigate to={user?.role === "supervisor" ? "/inventory" : "/"} replace />;
}
// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود
@@ -129,10 +146,16 @@ function PmPanelPage() {
);
}
// دسترسی پنل PM: همه نقش‌ها دسترسی دارند
// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقش‌های میراث pm/supervisor —
// مدیر تدارکات (یا هر کاربر بدون پنل pm) دسترسی ندارد
function RequirePmPanel({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || user?.role === "supervisor";
const panels = user?.panels ?? {};
const allowed =
user?.role === "admin" ||
Boolean(panels.pm) ||
user?.role === "pm" ||
user?.role === "supervisor";
if (!allowed) return <Navigate to="/" replace />;
return <>{children}</>;
}
@@ -145,19 +168,25 @@ function RequireOrganization({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
// دسترسی تغییر موجودی انبار: فقط سرپرست پروژه و ادمین (مدیر تدارکات و مدیر پروژه نمی‌توانند موجودی تغییر دهند)
// دسترسی تغییر موجودی انبار: ادمین، سرپرست پروژه (میراث) و پنل انبار limited —
// پنل انبار full طبق طراحی به تغییر موجودی دسترسی ندارد
function RequireInventoryChange({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
if (user?.role !== "admin" && user?.role !== "supervisor") {
const allowed =
user?.role === "admin" ||
user?.role === "supervisor" ||
user?.panels?.inventory === "limited";
if (!allowed) {
return <Navigate to="/inventory" replace />;
}
return <>{children}</>;
}
// دسترسی مدیریت انبارها: فقط مدیران و ادمین (سرپرست پروژه فقط می‌تواند موجودی تغییر دهد)
// دسترسی مدیریت انبارها: مدیران، ادمین و سرپرست پروژه (سرپرست پروژه منحصراً انبارهای پروژه خودش را می‌بیند)
function RequireWarehouseManage({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
if (user?.role === "supervisor") {
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || user?.role === "supervisor";
if (!allowed) {
return <Navigate to="/inventory" replace />;
}
return <>{children}</>;
@@ -315,6 +344,15 @@ export default function App() {
</RequireAuth>
}
/>
{/* خانه حداقلی استعلام — تدارکات سطح limited */}
<Route
path="/requester"
element={
<RequireAuth>
<RequesterHome />
</RequireAuth>
}
/>
{/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */}
<Route
path="/panel/pm"
@@ -16,11 +16,13 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
const setProjects = useAuthStore((s) => s.setProjects);
const logout = useLogout();
const isSupervisor = user?.role === "supervisor";
// پنل انبار limited هم مثل سرپرست: پروژه‌های فعالش از عضویت‌ها اعتبارسنجی می‌شود
const isSupervisor = user?.role === "supervisor" || user?.panels?.inventory === "limited";
const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok");
useEffect(() => {
if (!user || user.role !== "supervisor") {
const scoped = user?.role === "supervisor" || user?.panels?.inventory === "limited";
if (!user || !scoped) {
setState("ok");
return;
}
@@ -45,7 +47,7 @@ export function RequirePmProjects({ children }: { children: ReactNode }) {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.id, user?.role, isDev]);
}, [user?.id, user?.role, user?.panels, isDev]);
if (state === "loading") {
return (
@@ -80,18 +80,24 @@ export function MobileTabBar() {
const { pathname } = useLocation();
if (pathname === "/login" || pathname === "/admin") return null;
// خانه حداقلی استعلام (تدارکات limited): بدون تب ERP — PM هم تب‌بار خودش را دارد
if (pathname === "/requester" || pathname === "/panel/pm") return null;
// کاربر فقطِ تدارکات-limited: تب‌های انبار/دفتر ندارد
const panels = useAuthStore.getState().user?.panels ?? {};
if (panels.procurement === "limited" && !panels.inventory && !panels.pm) return null;
// پنل مدیریت پروژه: iframe تمام‌صفحه‌ی PM است و خودش تب‌بار داخلی دارد
if (pathname === "/panel/pm") return null;
// سرپرست پروژه فقطِ پنل PM است و هیچ تب ERP ندارد
if (role === "supervisor") return null;
const isPM = role === "pm";
const isSupervisor = role === "supervisor";
const isOffice = role === "admin" || role === "procurement_manager";
if (pathname.startsWith("/inventory")) {
if (isPM) return <TabBar tabs={PM_INV_TABS} />;
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />;
if (isOffice || isSupervisor) return <TabBar tabs={ADMIN_INV_TABS} />;
return null;
}
// سرپرست پروژه به بخش‌های تدارکات/دفتر دسترسی ندارد
if (isSupervisor) return null;
if (pathname === "/wizard" || pathname.startsWith("/review")) return null;
const onOfficePage =
pathname === "/" ||
+9
View File
@@ -1662,6 +1662,10 @@ export interface UserRow {
phone: string | null;
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
role_label: string | null;
// لایه پنل‌ها (جایگزین نقش‌ها): {'procurement': 'full'|'limited', ...}
panels?: Record<string, string>;
// عضویت M:N پروژه‌ای — محدوده داده سطح limited
member_project_ids?: string[];
project_id: string | null;
project_name: string | null;
// 1:N — every project this PM manages (oldest first).
@@ -1757,6 +1761,11 @@ export const apiV26 = {
}): Promise<UserRow> => postData<UserRow>("/users", body),
updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | "supervisor" | null): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}`, { role }),
// لایه پنل‌ها — تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (فقط ادمین)
setPanels: async (id: string, panels: Record<string, string>): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}/panels`, { panels }),
setPanelProjects: async (id: string, project_ids: string[]): Promise<UserRow> =>
patchData<UserRow>(`/users/${id}/panel-projects`, { project_ids }),
assignProject: async (id: string, projectId: string | null): Promise<UserRow> =>
postData<UserRow>(`/users/${id}/project`, { project_id: projectId }),
// همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند
@@ -0,0 +1,62 @@
import { Link } from "react-router-dom";
import { FilePlus2, Info } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { useProjects } from "../../hooks";
import { faNum } from "../../lib/formatters";
// خانه حداقلی کاربرِ «تدارکات — سطح limited»: فقط افزودن استعلام برای
// پروژه‌های عضویت خودش. هیچ سربرگ دیگری از پنل تدارکات دیده نمی‌شود.
export function RequesterHome() {
const user = useAuthStore((s) => s.user);
const projects = useProjects();
const memberIds = new Set(user?.member_project_ids ?? []);
const myProjects = memberIds.size
? projects.filter((p) => memberIds.has(p.id))
: [];
return (
<div className="min-h-screen">
<Header title="افزودن استعلام" brand />
<PageLayout className="flex flex-col gap-4">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
<span className="inline-flex items-center gap-1.5">
<Info className="h-3.5 w-3.5" />
شما فقط مجاز به ثبت استعلام برای پروژه‌های تخصیص‌یافته‌ی خود هستید.
</span>
</div>
{myProjects.length === 0 ? (
<div className="rounded-xl border border-dashed border-line bg-panel px-4 py-8 text-center text-[12.5px] text-muted">
هنوز پروژه‌ای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید.
</div>
) : (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{myProjects.map((p) => (
<Link
key={p.id}
to="/wizard"
className="flex items-center justify-between rounded-xl border border-line bg-panel p-4 no-underline transition-colors hover:border-accent/50"
>
<div>
<b className="text-[13.5px]">🏢 {p.name}</b>
<p className="mt-0.5 text-[11.5px] text-muted">ثبت استعلام خرید برای این پروژه</p>
</div>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-accent/10 text-accent">
<FilePlus2 className="h-5 w-5" />
</span>
</Link>
))}
</div>
<Link to="/wizard" className="btn-line-file no-underline">
<FilePlus2 className="h-3.5 w-3.5" />
افزودن استعلام جدید ({faNum(myProjects.length)} پروژه فعال)
</Link>
</>
)}
</PageLayout>
</div>
);
}
+11 -6
View File
@@ -23,6 +23,8 @@ export function ArrivalsPage() {
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const isPM = user?.role === "pm";
const isSupervisor = user?.role === "supervisor";
const canAccess = isAdmin || isPM || isSupervisor;
const [items, setItems] = useState<PendingArrival[]>([]);
const [incomingTransfers, setIncomingTransfers] = useState<ProjectTransfer[]>([]);
@@ -36,9 +38,9 @@ export function ArrivalsPage() {
const load = useCallback(async () => {
try {
const activeProject = isPM ? user?.project_id || undefined : undefined;
const activeProject = (isPM || isSupervisor) ? user?.project_id || undefined : undefined;
const [arr, whs, incoming] = await Promise.all([
// پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM را بررسی می‌کند
// پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM / سرپرست را بررسی می‌کند
apiV26.pm.onTheWay(activeProject),
apiV26.warehouses.list(activeProject),
apiV26.projectTransfers.onTheWay(activeProject),
@@ -53,13 +55,13 @@ export function ArrivalsPage() {
} finally {
setLoading(false);
}
}, [isPM, user?.project_id]);
}, [isPM, isSupervisor, user?.project_id]);
useEffect(() => {
if (!isAdmin && !isPM) return;
if (!canAccess) return;
setLoading(true);
void load();
}, [isAdmin, isPM, load]);
}, [canAccess, load]);
// سفارش‌ها: گروه‌بندی اقلام به‌ازای هر پیشنهاد (فقط سفارش‌های دارای قلمِ در راه/ناقص)
const orders = useMemo(() => {
@@ -267,11 +269,14 @@ export function ArrivalsPage() {
};
if (!isAdmin && !isPM) {
if (!canAccess) {
return (
<div className="min-h-screen">
<Header title="کالاهای در راه" backTo="/inventory" backLabel="بازگشت به داشبورد" />
<PageLayout>
<EmptyState emoji="🔒" text="این بخش فقط برای مدیر پروژه و مدیر تدارکات است." />
<EmptyState emoji="🔒" text="این بخش برای نقش کاربری شما مجاز نیست." />
</PageLayout>
</div>
);
@@ -41,10 +41,12 @@ export function DailyControlPage() {
const txByDay = useMemo(() => {
const map = new Map<string, InventoryTransaction[]>();
const allowedWhIds = new Set(data.warehouses.map((w) => w.id));
const projWhIds = projFilter
? new Set(data.warehouses.filter((w) => String(w.project_id) === String(projFilter)).map((w) => w.id))
: null;
data.transactions.forEach((t) => {
if (!isAdmin && !allowedWhIds.has(t.warehouse_id)) return;
if (projWhIds && !projWhIds.has(t.warehouse_id)) return;
const k = gregKey(new Date(t.transaction_date));
const arr = map.get(k) || [];
@@ -53,7 +55,7 @@ export function DailyControlPage() {
});
map.forEach((arr) => arr.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()));
return map;
}, [data.transactions, data.warehouses, projFilter]);
}, [data.transactions, data.warehouses, projFilter, isAdmin]);
const cells = useMemo(() => {
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
@@ -173,11 +175,15 @@ export function DailyControlPage() {
))}
</select>
) : (
<span className="font-semibold text-slate-800">پروژه فعال</span>
<span className="font-semibold text-slate-800">
{user?.project_name ? `پروژه: ${user.project_name}` : "پروژه فعال"}
</span>
)}
{isAdmin && (
<span className="text-[11.5px] text-slate-400">
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژه‌ها"}
</span>
)}
<span className="text-[11.5px] text-slate-400">
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژه‌ها"}
</span>
</div>
<Link
@@ -54,7 +54,8 @@ export function InventoryPage() {
const isManager = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
const isSupervisor = user?.role === "supervisor";
const isAdmin = user?.role === "admin";
const canModify = isSupervisor || isAdmin;
// پنل انبار limited: تغییر موجودی مجاز است؛ full: طبق طراحی ممنوع
const canModify = isSupervisor || isAdmin || user?.panels?.inventory === "limited";
const [params] = useSearchParams();
const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({
@@ -12,17 +12,12 @@ export function InventoryTopNav({
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === "admin";
const isManager = user?.role === "procurement_manager" || user?.role === "pm" || isAdmin;
const isSupervisor = user?.role === "supervisor";
const canViewTabs = isManager || isSupervisor;
const items: { label: string; to: string; active: boolean }[] = [];
// سرپرست پروژه از دکمه شناور گرد (+) در پایین صفحه استفاده می‌کند و دکمه مستطیلی بالای صفحه برای او نمایش داده نمی‌شود
if (isAdmin) {
items.push({
label: "تغییر موجودی",
to: "/inventory/change",
active: pathname.startsWith("/inventory/change"),
});
}
if (isManager) {
// برای تغییر موجودی از همان دکمه شناور دایره‌ای آبی (+) استفاده می‌شود و دکمه مستطیلی در بالای صفحه حذف شد
if (canViewTabs) {
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
@@ -14,7 +14,8 @@ export function WarehousesPage() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
const isSupervisor = user?.role === "supervisor";
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || isSupervisor;
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
@@ -32,7 +33,7 @@ export function WarehousesPage() {
<div className="text-3xl">🔒</div>
<h3 className="mt-2 text-[15px] font-bold">دسترسی محدود</h3>
<p className="mt-1 text-[12.5px] text-muted">
مدیریت و تعریف انبارها مخصوص مدیران سیستم است. سرپرست پروژه فقط به ثبت تغییرات موجودی دسترسی دارد.
دسترسی به این بخش برای حساب کاربری شما فعال نیست.
</p>
</div>
</PageLayout>
@@ -43,7 +44,7 @@ export function WarehousesPage() {
const addWarehouse = async () => {
const name = newName.trim();
if (!name) return toast.error("نام انبار الزامی است");
const projectId = selectedProjectId || user?.project_id || data.lookup.projects[0]?.id;
const projectId = isSupervisor ? user?.project_id : (selectedProjectId || user?.project_id || data.lookup.projects[0]?.id);
if (!projectId) return toast.error("پروژه انبار را انتخاب کنید");
try {
await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null });
@@ -92,7 +93,7 @@ export function WarehousesPage() {
<div className="rounded-xl border border-line bg-panel p-3">
<h3 className="mb-2 text-[13px] font-bold">+ افزودن انبار جدید</h3>
<div className="flex flex-wrap items-center gap-2">
{data.lookup.projects.length > 1 && (
{!isSupervisor && data.lookup.projects.length > 1 && (
<select
className="h-9 rounded-lg border border-line bg-panel px-3 text-sm"
value={selectedProjectId || user?.project_id || data.lookup.projects[0]?.id || ""}
+2 -2
View File
@@ -61,8 +61,8 @@ export function panelMenuItems(pathname: string, role: string | null | undefined
if (isSupervisor) {
items.push({ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") });
}
// کنترل روزانه و انبارها و کالاهای در راه برای مدیران
if (isManager) {
// کنترل روزانه و انبارها و کالاهای در راه برای مدیران و سرپرست پروژه
if (isManager || isSupervisor) {
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
@@ -122,6 +122,41 @@ export function OrganizationPage() {
}
};
// ---------- لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای ----------
const setPanel = async (u: UserRow, panel: "procurement" | "inventory", level: "" | "full" | "limited") => {
setBusyId(u.id);
const next = { ...(u.panels ?? {}) };
if (level) next[panel] = level;
else delete next[panel];
try {
await apiV26.users.setPanels(u.id, next);
toast.success(`دسترسی «${u.first_name} ${u.last_name}» به‌روزرسانی شد ✓`);
await loadUsers();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ثبت دسترسی پنل");
} finally {
setBusyId(null);
}
};
const setMemberProjects = async (u: UserRow, nextIds: string[]) => {
setBusyId(u.id);
try {
await apiV26.users.setPanelProjects(u.id, nextIds);
toast.success(`عضویت پروژه‌ای «${u.first_name} ${u.last_name}» به‌روزرسانی شد ✓`);
await loadUsers();
refreshProcurementStore();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ثبت عضویت پروژه‌ها");
} finally {
setBusyId(null);
}
};
const panelLabel = (level: string | undefined) =>
level === "full" ? "کامل" : level === "limited" ? "محدود" : "—";
// سرپرستان تخصیص‌یافته به یک پروژه
const supervisorsOf = (projectId: string) =>
users.filter(
@@ -285,6 +320,99 @@ export function OrganizationPage() {
})}
</div>
</div>
{/* ---------- بخش ۳: پنل‌ها و سطوح دسترسی (جایگزین نقش‌ها) ---------- */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-3">
<h3 className="text-[14px] font-bold">🔑 پنل‌ها و سطوح دسترسی</h3>
<p className="mt-0.5 text-[11.5px] leading-5 text-muted">
به هر شخص پنل «تدارکات» یا «انبار» را تخصیص دهید. تدارکات — کامل: تغییر در کل پنل؛ محدود: فقط افزودن
استعلام برای پروژه‌های عضویت. انبار — کامل: همه پروژه‌ها بدون تغییر موجودی؛ محدود: فقط پروژه‌های عضویت با
امکان تغییر موجودی. ادمین دست‌نخورده است.
</p>
</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) => {
if (u.role === "admin") {
return (
<tr key={u.id} className="border-b border-line/50">
<td className="py-2.5 pe-3"><b>{u.first_name} {u.last_name}</b></td>
<td colSpan={3} className="py-2.5 text-[11px] text-muted">
ادمین — دسترسی کامل به همه پنل‌ها (دست‌نخورده)
</td>
</tr>
);
}
const hasPanel = Boolean(u.panels?.procurement || u.panels?.inventory);
return (
<tr key={u.id} className="border-b border-line/50">
<td className="py-2.5 pe-3">
<b>{u.first_name} {u.last_name}</b>
<span className="ms-1 font-mono text-[10px] text-muted">({u.username || "—"})</span>
</td>
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.panels?.procurement ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "procurement", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل</option>
<option value="limited">محدود (فقط استعلام)</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.procurement)}</span>
)}
</td>
<td className="py-2.5 pe-3">
{canChangeRoles ? (
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[12px]"
value={u.panels?.inventory ?? ""}
disabled={busyId === u.id}
onChange={(e) => void setPanel(u, "inventory", e.target.value as "" | "full" | "limited")}
>
<option value="">— بدون تخصیص —</option>
<option value="full">کامل (بدون تغییر موجودی)</option>
<option value="limited">محدود (با تغییر موجودی)</option>
</select>
) : (
<span className="text-[11px]">{panelLabel(u.panels?.inventory)}</span>
)}
</td>
<td className="py-2.5">
{hasPanel ? (
<div className="w-72">
<ProjectMultiSelect
options={projects.map((p) => ({ id: p.id, name: p.name }))}
values={u.member_project_ids ?? []}
onChange={(nextIds) => void setMemberProjects(u, nextIds)}
disabled={busyId === u.id}
/>
</div>
) : (
<span className="text-[11px] text-muted">— ابتدا پنل تخصیص دهید —</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</PageLayout>
</div>
);
+5 -3
View File
@@ -14,6 +14,8 @@ export interface Step4Props {
onAssign: (tempId: string, proj: string) => void;
onUnassign: (tempId: string, proj: string) => void;
onAddProject: (name: string) => void;
/** سطح limited تدارکات: افزودن پروژه جدید ممنوع */
canAddProject?: boolean;
}
const ALL_KEY = "__all__";
@@ -111,7 +113,7 @@ function DroppableProject({
}
// مرحله ۴ — تخصیص پروژه‌ها با درگ‌اندروپ (یا کلیک-قلم بعد کلیک-پروژه)
export function Step4Projects({ items, projects, assignment, onAssign, onUnassign, onAddProject }: Step4Props) {
export function Step4Projects({ items, projects, assignment, onAssign, onUnassign, onAddProject, canAddProject = true }: Step4Props) {
const [picked, setPicked] = useState<string | null>(null);
const [newProj, setNewProj] = useState("");
@@ -213,8 +215,8 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
{!projects.length && (
<div className="dd-empty">هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید.</div>
)}
{/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها */}
<div className="mt-3 rounded-md border border-dashed border-line p-2">
{/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها (سطح limited: ممنوع) */}
<div className="mt-3 rounded-md border border-dashed border-line p-2" hidden={!canAddProject}>
<div className="mb-1.5 text-[11.5px] font-semibold text-muted">افزودن پروژه‌ی جدید:</div>
<div className="flex items-center gap-2">
<input
+19 -2
View File
@@ -36,6 +36,7 @@ import {
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog";
import { useAuthStore } from "../../store/auth";
const TOTAL_STEPS = 5;
@@ -149,6 +150,17 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
const suppliers = useSuppliers();
const materials = useMaterials();
const projects = useProjects();
// سطح limited تدارکات: استعلام فقط برای پروژه‌های عضویت — بدون افزودن پروژه جدید
const user = useAuthStore((s) => s.user);
const isLimitedProcurement =
user?.panels?.procurement === "limited" && user?.role !== "admin";
const memberProjectNames = useMemo(
() =>
projects
.filter((p) => (user?.member_project_ids ?? []).includes(p.id))
.map((p) => p.name),
[projects, user],
);
const topics = useAppStore((s) => s.topics);
const createQuote = useCreateQuote();
const addSupplier = useAddSupplier();
@@ -504,13 +516,17 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
// ---------- پروژه‌ها ----------
const projectNames = useMemo(() => {
const names = new Set<string>(projects.map((p) => p.name));
const names = new Set<string>(
isLimitedProcurement
? memberProjectNames
: projects.map((p) => p.name),
);
quotes.forEach((q) => {
if (q.project) names.add(q.project);
(q.projects || []).forEach((n) => names.add(n));
});
return [...names];
}, [projects, quotes]);
}, [projects, quotes, isLimitedProcurement, memberProjectNames]);
const assign = (tempId: string, proj: string) => {
setAssignment((a) => {
@@ -950,6 +966,7 @@ export function WizardPage({ isStandalone = false }: { isStandalone?: boolean })
assignment={assignment}
onAssign={assign}
onUnassign={unassign}
canAddProject={!isLimitedProcurement}
onAddProject={(name) => {
void addProject.mutateAsync({ id: nextId("p", projects), name });
}}
+3
View File
@@ -17,6 +17,9 @@ export interface AuthUser {
phone: string | null;
role: "pm" | "procurement_manager" | "admin" | "supervisor" | null;
role_label: string | null;
// لایه پنل‌ها (جایگزین نقش‌ها)
panels?: Record<string, string>;
member_project_ids?: string[];
project_id: string | null; // پروژه‌ی فعال (PM) — با سوییچر عوض می‌شود
project_name: string | null;
projects?: { id: string; name: string }[]; // همه‌ی پروژه‌های تخصیص‌یافته (1:N)