Reverting the production revert so the pm branch can merge cleanly on top of the restored RBAC base. Conflicts against newer integration work resolved by combining: - App.tsx: v7's bot-draft wizard bypass + login returnUrl kept alongside the supervisor gating (no-project block screen, panel scoping) - LoginPage: returnUrl navigation kept, supervisor routing added - migration.sql: bot_drafts table kept, unified-projects ALTERs restored
292 lines
14 KiB
TypeScript
292 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|