feat(org,rbac): dedicate organization panel, enforce role permissions, subheader info, and fix supervisor project sync
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { apiV26, type UserRow } from "../../lib/api";
|
||||
import { refreshProcurementStore, useProjects } from "../../hooks";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { ProjectMultiSelect } from "../../components/ProjectMultiSelect";
|
||||
import { AppSwitcherMenu } from "../../components/AppSwitcherMenu";
|
||||
|
||||
type Role = "pm" | "procurement_manager" | "admin" | "supervisor" | null;
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
admin: "ادمین",
|
||||
procurement_manager: "مدیر تدارکات",
|
||||
pm: "مدیر پروژه",
|
||||
supervisor: "سرپرست پروژه",
|
||||
};
|
||||
|
||||
// پنل مستقل سازماندهی (ادمین، مدیر تدارکات، مدیر پروژه) — مدیریت کاربران، نقشها و تخصیص پروژهها به سرپرستان
|
||||
export function OrganizationPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const projects = useProjects();
|
||||
|
||||
const [users, setUsers] = useState<UserRow[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
||||
// فقط ادمین میتواند نقشها را تغییر دهد؛ مدیران میتوانند پروژهها را به سرپرستان تخصیص دهند
|
||||
const canChangeRoles = user?.role === "admin";
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
try {
|
||||
const list = await apiV26.users.list();
|
||||
setUsers(list);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در دریافت کاربران");
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (canManage) void loadUsers();
|
||||
}, [canManage, loadUsers]);
|
||||
|
||||
const syncUsers = 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);
|
||||
}
|
||||
};
|
||||
|
||||
if (!canManage) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="سازماندهی" backTo="/" backLabel="بازگشت به دفتر" />
|
||||
<PageLayout>
|
||||
<div className="text-[13px] text-muted">این بخش فقط برای ادمین، مدیر تدارکات و مدیر پروژه است.</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- actions: users ----------
|
||||
|
||||
const changeRole = async (u: UserRow, role: Role) => {
|
||||
setBusyId(u.id);
|
||||
try {
|
||||
await apiV26.users.updateRole(u.id, role);
|
||||
toast.success(`نقش «${ROLE_LABELS[role ?? ""] ?? "بدون نقش"}» برای ${u.first_name} ${u.last_name} ثبت شد ✓`);
|
||||
await loadUsers();
|
||||
refreshProcurementStore();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در تغییر نقش");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const assignedProjectsOf = (u: UserRow) =>
|
||||
u.projects ?? (u.project_id ? [{ id: u.project_id, name: u.project_name ?? "" }] : []);
|
||||
|
||||
const syncUserProjects = async (u: UserRow, nextIds: string[]) => {
|
||||
const current = assignedProjectsOf(u).map((p) => p.id);
|
||||
const added = nextIds.filter((id) => !current.includes(id));
|
||||
const removed = current.filter((id) => !nextIds.includes(id));
|
||||
if (!added.length && !removed.length) return;
|
||||
setBusyId(u.id);
|
||||
try {
|
||||
for (const pid of added) await apiV26.projects.setPm(pid, u.id);
|
||||
for (const pid of removed) await apiV26.projects.setPm(pid, null);
|
||||
const names = nextIds
|
||||
.map((id) => projects.find((p) => p.id === id)?.name)
|
||||
.filter(Boolean)
|
||||
.join("، ");
|
||||
toast.success(
|
||||
nextIds.length
|
||||
? `پروژههای «${u.first_name} ${u.last_name}» بهروزرسانی شد: ${names} ✓`
|
||||
: `«${u.first_name} ${u.last_name}» از همهی پروژهها جدا شد`,
|
||||
);
|
||||
await loadUsers();
|
||||
refreshProcurementStore();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در بهروزرسانی پروژهها");
|
||||
await loadUsers();
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// سرپرستان تخصیصیافته به یک پروژه
|
||||
const supervisorsOf = (projectId: string) =>
|
||||
users.filter(
|
||||
(u) =>
|
||||
(u.role === "supervisor" || u.role === "pm") &&
|
||||
(u.projects?.some((p) => p.id === projectId) || u.project_id === projectId),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="سازماندهی"
|
||||
brand
|
||||
menu={<AppSwitcherMenu activeModule="organization" />}
|
||||
/>
|
||||
<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">
|
||||
ℹ️ کاربران در Casdoor تعریف شده و از طریق دکمه «همگامسازی» اضافه میشوند. نقشها در این پنل توسط ادمین تعیین میگردند.
|
||||
مدیران پروژه و تدارکات میتوانند پروژهها را به سرپرستان پروژه تخصیص دهند.
|
||||
</div>
|
||||
|
||||
{/* ---------- بخش ۱: جدول کاربران و نقشها ---------- */}
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-[14px] font-bold">👥 کاربران و نقشها</h3>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted">
|
||||
تعیین نقش کاربران و تخصیص پروژهها به سرپرستان پروژه
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void syncUsers()}
|
||||
disabled={syncing}
|
||||
className="rounded-lg border border-accent/40 bg-accent/5 px-3 py-1.5 text-[12px] font-bold text-accent disabled:opacity-50"
|
||||
>
|
||||
{syncing ? "در حال همگامسازی…" : "🔄 همگامسازی از Casdoor"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingUsers ? (
|
||||
<div className="py-3 text-[12.5px] text-muted">در حال بارگذاری کاربران…</div>
|
||||
) : !users.length ? (
|
||||
<div className="rounded-lg border border-dashed border-line px-3 py-3 text-[12px] text-muted">
|
||||
کاربری ثبت نشده است — دکمهی «همگامسازی از Casdoor» را بزنید.
|
||||
</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 pe-3">پروژههای تخصیصیافته (سرپرست پروژه)</th>
|
||||
<th className="py-2">وضعیت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<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 className="py-2.5 pe-3 font-mono text-[11px] text-muted">
|
||||
{u.username || "—"}
|
||||
</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.role ?? ""}
|
||||
disabled={busyId === u.id}
|
||||
onChange={(e) => void changeRole(u, (e.target.value || null) as Role)}
|
||||
>
|
||||
<option value="">— بدون نقش —</option>
|
||||
<option value="admin">ادمین</option>
|
||||
<option value="procurement_manager">مدیر تدارکات</option>
|
||||
<option value="pm">مدیر پروژه</option>
|
||||
<option value="supervisor">سرپرست پروژه</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="rounded bg-panel px-2 py-0.5 text-[11px] font-semibold">
|
||||
{ROLE_LABELS[u.role ?? ""] ?? "بدون نقش"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 pe-3">
|
||||
{u.role === "supervisor" || u.role === "pm" ? (
|
||||
<div className="w-72">
|
||||
<ProjectMultiSelect
|
||||
options={projects.map((p) => ({ id: p.id, name: p.name }))}
|
||||
values={assignedProjectsOf(u).map((p) => p.id)}
|
||||
onChange={(nextIds) => void syncUserProjects(u, nextIds)}
|
||||
disabled={busyId === u.id}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted">
|
||||
{u.role === "admin"
|
||||
? "دسترسی کامل به همه پروژهها"
|
||||
: u.role === "procurement_manager"
|
||||
? "دسترسی کامل مدیریتی"
|
||||
: "—"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 text-[11px] text-muted">
|
||||
{busyId === u.id ? (
|
||||
<span className="text-accent">در حال ذخیره…</span>
|
||||
) : (
|
||||
<span className="text-emerald-600">✓ ثبتشده</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-2 text-[11px] text-muted">
|
||||
سرپرستان پروژهای که پروژهای به آنها تخصیص داده نشده باشد، تا زمان تخصیص پروژه اجازه ورود به سامانه را نخواهند داشت.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---------- بخش ۲: کارتهای پروژهها و سرپرستان مسئول ---------- */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-[14px] font-bold">🏢 پروژهها و سرپرستان مسئول</h3>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((p) => {
|
||||
const sups = supervisorsOf(p.id);
|
||||
return (
|
||||
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h4 className="text-[14px] font-bold">🏢 {p.name}</h4>
|
||||
<span className="rounded-lg bg-panel px-2 py-0.5 text-[11px] text-muted">
|
||||
{p.status || "پروژه فعال"}
|
||||
</span>
|
||||
</div>
|
||||
{p.description && (
|
||||
<p className="mb-3 text-[12px] text-muted line-clamp-2">{p.description}</p>
|
||||
)}
|
||||
<div>
|
||||
<h5 className="mb-1.5 text-[11.5px] font-bold text-muted">👤 سرپرست(های) پروژه:</h5>
|
||||
{!sups.length ? (
|
||||
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-amber-600">
|
||||
⚠️ سرپرستی تعیین نشده است — از جدول بالا پروژه را به سرپرست تخصیص دهید.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{sups.map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-2 rounded-lg border border-line px-2.5 py-1.5 text-[12px]">
|
||||
<span className="font-semibold">{s.first_name} {s.last_name}</span>
|
||||
<span className="text-[10px] text-muted font-mono">({s.username})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user