feat: institutional + support + training admin sections (backend + frontend)

Ship three fully-wired admin areas end-to-end with APIs, seeds, tests and docs.

Backend (new `encoach_lms_api` addon + existing addons):
- Institutional: academic years/terms, departments, admission registers & admissions,
  courses/batches, lessons, fees (terms + student fees + invoicing with income-account
  auto-wiring), gradebook (assignments/grades), library, facilities (encoach.asset),
  student leave, result templates + marksheets (incl. delete-with-cascade).
- Support: `encoach.ticket` model + CRUD/assignee routes; payment records derived
  from `op.student.fees.details` and `account.move`; platform settings backed by
  `encoach.code` and `ir.config_parameter` (packages + grading config).
- Training: `encoach.vocab.item` + `encoach.grammar.rule` (plus progress models)
  with CRUD, pagination, search/level filters, and upsert-style progress endpoints.
  Odoo 19 compatibility: `_sql_constraints` replaced with `@api.constrains`;
  `ValidationError`/`UserError` mapped to HTTP 400.

Frontend:
- Rewire institutional admin pages (Academic Year Manager, Admissions, Courses,
  Lessons, Fees, Gradebook, Library, Facilities, Student Leave, Marksheets,
  Taxonomy, Resources) to real APIs with React Query invalidation and dialogs.
- New typed services: `payments.service.ts`, `platformSettings.service.ts`,
  `training.service.ts`. Updated `fees/gradebook/lms/courseware/taxonomy/
  resources/student-progress/generation` services + related types.
- Rewrite `VocabularyPage`, `GrammarPage`, `PaymentRecordPage`, `SettingsPage`,
  `TicketsPage` to consume live data with search/filter/progress/CRUD flows.
- New shared components: `TaxonomyCascade`, `MaterialViewer`, `teacher/TeacherLibrary`.
- Favicons/branding assets and misc. UX polish across teacher/student pages.

Tooling & QA:
- Seeders: `seed_demo.py`, `seed_demo_data.py`, `seed_institutional.py` (idempotent,
  covers institutional + support + training fixtures incl. income-account wiring).
- API write-flow test suites: `test_write_flows.py` (institutional),
  `test_support_flows.py` (support), `test_training_flows.py` (training),
  `test_ai_full.py`. All suites pass end-to-end.
- Docs: add `docs/PROJECT_SUMMARY.md` with per-section scope, artifacts and QA.
- `.gitignore`: ignore `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`,
  `frontend/node_modules/`.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-19 03:13:23 +04:00
parent b78124bb7b
commit 435930a827
71 changed files with 6677 additions and 1309 deletions

View File

@@ -3,151 +3,153 @@ import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Search, Plus, Download, MoreHorizontal } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { useStudents, useTeachers, useCreateStudent, useCreateTeacher } from "@/hooks/queries";
import { lmsService } from "@/services/lms.service";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Search, Plus, Download, Shield, Pencil, UserCog } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast";
import { ApiError } from "@/lib/api-client";
function messageFromCreateError(err: unknown): string {
if (err instanceof ApiError && err.data && typeof err.data === "object" && err.data !== null && "error" in err.data) {
const e = (err.data as { error: unknown }).error;
if (e != null && String(e).trim()) return String(e);
}
if (err instanceof Error) return err.message;
return "Something went wrong.";
interface PlatformUser {
id: number;
name: string;
email: string;
login: string;
user_type: string;
active: boolean;
phone: string;
role_ids: number[];
roles: { id: number; name: string }[];
effective_permission_count: number;
}
function toastForCreateUserError(err: unknown): { title: string; description: string; variant?: "default" | "destructive" } {
const msg = messageFromCreateError(err).toLowerCase();
const isDuplicate =
msg.includes("already exists") ||
msg.includes("duplicate") ||
msg.includes("unique") ||
msg.includes("already registered");
if (isDuplicate) {
return {
title: "Email already in use",
description:
messageFromCreateError(err) +
" Try another address, or find the existing user in the Students or Teachers tab.",
variant: "destructive",
};
}
return { title: "Could not create user", description: messageFromCreateError(err), variant: "destructive" };
interface RoleInfo {
id: number;
name: string;
description: string;
assigned: boolean;
permission_count: number;
}
const TYPE_COLORS: Record<string, string> = {
admin: "bg-red-100 text-red-800 border-red-200",
teacher: "bg-blue-100 text-blue-800 border-blue-200",
student: "bg-green-100 text-green-800 border-green-200",
user: "bg-gray-100 text-gray-800 border-gray-200",
};
export default function UsersPage() {
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ first_name: "", last_name: "", email: "", role: "student", phone: "", gender: "" });
const [editUser, setEditUser] = useState<PlatformUser | null>(null);
const [rolesUser, setRolesUser] = useState<PlatformUser | null>(null);
const [form, setForm] = useState({ name: "", email: "", password: "", phone: "" });
const { toast } = useToast();
const qc = useQueryClient();
const studentsQ = useStudents({ search: search || undefined, size: 200 });
const teachersQ = useTeachers({ search: search || undefined, size: 200 });
const createStudentMut = useCreateStudent();
const createTeacherMut = useCreateTeacher();
const students = studentsQ.data?.items ?? [];
const teachers = teachersQ.data?.items ?? [];
const deleteMut = useMutation({
mutationFn: async ({ type, id }: { type: "student" | "teacher"; id: number }) => {
if (type === "student") return lmsService.deleteStudent?.(id) ?? lmsService.updateStudent(id, { status: "inactive" });
return lmsService.updateTeacher?.(id, {}) ?? Promise.resolve();
const usersQ = useQuery({
queryKey: ["platform-users", search],
queryFn: async () => {
const params: Record<string, string | number> = { size: 200 };
if (search) params.search = search;
const res = await api.get<{ items?: PlatformUser[]; data?: PlatformUser[]; total: number }>("/users/list", params);
return (res.items ?? res.data ?? []) as PlatformUser[];
},
});
const rolesQ = useQuery({
queryKey: ["user-roles-detail", rolesUser?.id],
queryFn: async () => {
if (!rolesUser) return null;
return api.get<{ roles: RoleInfo[]; effective_permissions: unknown[] }>(`/users/${rolesUser.id}/roles`);
},
enabled: !!rolesUser,
});
const createMut = useMutation({
mutationFn: (data: { name: string; email: string; password: string; phone?: string }) =>
api.post<{ data: PlatformUser }>("/users/create", data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms"] });
toast({ title: "Done" });
qc.invalidateQueries({ queryKey: ["platform-users"] });
setCreateOpen(false);
setForm({ name: "", email: "", password: "", phone: "" });
toast({ title: "User created" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
function handleCreate() {
const email = form.email.trim();
const first_name = form.first_name.trim();
const last_name = form.last_name.trim();
const emailKey = email.toLowerCase();
const dupStudent = students.some((s) => (s.email || "").trim().toLowerCase() === emailKey);
const dupTeacher = teachers.some((t) => (t.email || "").trim().toLowerCase() === emailKey);
if (dupStudent || dupTeacher) {
toast({
title: "Email already in use",
description:
"That address matches someone already listed on this page (search may hide them). Use another email or clear search to find the user.",
variant: "destructive",
});
return;
}
const cb = {
onSuccess: () => {
setCreateOpen(false);
resetForm();
toast({ title: "User created successfully" });
},
onError: (err: unknown) => toast(toastForCreateUserError(err)),
};
if (form.role === "teacher") {
createTeacherMut.mutate(
{
first_name,
last_name,
email,
phone: form.phone?.trim() || undefined,
gender: (form.gender as "male" | "female") || undefined,
},
cb,
);
} else {
createStudentMut.mutate(
{
first_name,
last_name,
email,
phone: form.phone?.trim() || undefined,
gender: form.gender || undefined,
create_portal_user: true,
},
cb,
);
}
}
const updateMut = useMutation({
mutationFn: (data: { id: number; name?: string; email?: string; phone?: string }) =>
api.patch<{ data: PlatformUser }>("/users/update", data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platform-users"] });
setEditUser(null);
toast({ title: "User updated" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
function resetForm() {
setForm({ first_name: "", last_name: "", email: "", role: "student", phone: "", gender: "" });
}
const toggleRoleMut = useMutation({
mutationFn: ({ userId, roleId }: { userId: number; roleId: number }) =>
api.post<{ assigned: boolean }>(`/users/${userId}/roles/toggle`, { role_id: roleId }),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["platform-users"] });
qc.invalidateQueries({ queryKey: ["user-roles-detail"] });
const verb = (res as { assigned: boolean }).assigned ? "assigned" : "removed";
toast({ title: `Role ${verb}` });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const users = usersQ.data ?? [];
const filtered = users.filter((u) => {
if (typeFilter !== "all" && u.user_type !== typeFilter) return false;
return true;
});
const typeCounts = {
all: users.length,
admin: users.filter((u) => u.user_type === "admin").length,
teacher: users.filter((u) => u.user_type === "teacher").length,
student: users.filter((u) => u.user_type === "student").length,
};
function exportCsv() {
const rows = [["Name", "Email", "Role", "Status"]];
students.forEach(s => rows.push([s.name, s.email, "Student", s.status || "active"]));
teachers.forEach(t => rows.push([t.name, t.email, "Teacher", "active"]));
const csv = rows.map(r => r.join(",")).join("\n");
const rows = [["Name", "Email", "Type", "Roles", "Permissions"]];
users.forEach((u) =>
rows.push([
u.name,
u.email,
u.user_type,
u.roles.map((r) => r.name).join("; "),
String(u.effective_permission_count),
]),
);
const csv = rows.map((r) => r.map((c) => `"${c}"`).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "users_export.csv";
a.download = "platform_users.csv";
a.click();
URL.revokeObjectURL(url);
}
const isPending = createStudentMut.isPending || createTeacherMut.isPending;
const loading = studentsQ.isLoading || teachersQ.isLoading;
const rolesData = rolesQ.data as { roles: RoleInfo[]; effective_permissions: unknown[] } | null;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
<p className="text-muted-foreground">Manage platform users across all roles.</p>
<p className="text-muted-foreground">
Manage all platform accounts admins, teachers, and students.
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={exportCsv}>
@@ -159,177 +161,254 @@ export default function UsersPage() {
</div>
</div>
<div className="flex gap-3 items-center">
<div className="flex gap-3 items-center flex-wrap">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search users..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
<Input placeholder="Search by name..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Tabs value={typeFilter} onValueChange={setTypeFilter}>
<TabsList>
<TabsTrigger value="all">All ({typeCounts.all})</TabsTrigger>
<TabsTrigger value="admin">Admins ({typeCounts.admin})</TabsTrigger>
<TabsTrigger value="teacher">Teachers ({typeCounts.teacher})</TabsTrigger>
<TabsTrigger value="student">Students ({typeCounts.student})</TabsTrigger>
</TabsList>
</Tabs>
</div>
{loading ? (
{usersQ.isLoading ? (
<div className="flex items-center justify-center min-h-[300px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : (
<Tabs defaultValue="students">
<TabsList>
<TabsTrigger value="students">Students ({students.length})</TabsTrigger>
<TabsTrigger value="teachers">Teachers ({teachers.length})</TabsTrigger>
</TabsList>
<TabsContent value="students" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Batch</TableHead>
<TableHead>Status</TableHead>
<TableHead>Enrollment</TableHead>
<TableHead className="w-10" />
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Type</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Permissions</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[120px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No users found.
</TableCell>
</TableRow>
)}
{filtered.map((u) => {
const initials = u.name
.split(" ")
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase();
return (
<TableRow key={u.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
{initials}
</div>
<div>
<p className="text-sm font-medium">{u.name}</p>
<p className="text-xs text-muted-foreground">{u.email}</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className={`capitalize text-xs ${TYPE_COLORS[u.user_type] || TYPE_COLORS.user}`}>
{u.user_type}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{u.roles.length > 0
? u.roles.map((r) => (
<Badge key={r.id} variant="secondary" className="text-xs">
{r.name}
</Badge>
))
: <span className="text-xs text-muted-foreground">No roles</span>}
</div>
</TableCell>
<TableCell>
<span className="text-sm">{u.effective_permission_count}</span>
</TableCell>
<TableCell>
<Badge variant={u.active ? "default" : "secondary"}>
{u.active ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" title="Edit user" onClick={() => setEditUser(u)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" title="Manage roles" onClick={() => setRolesUser(u)}>
<UserCog className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{students.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No students found.</TableCell></TableRow>
)}
{students.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell>{s.email}</TableCell>
<TableCell>{s.phone || "—"}</TableCell>
<TableCell>{s.batch_name || "—"}</TableCell>
<TableCell>
<Badge variant={s.status === "active" ? "default" : "secondary"} className="capitalize">{s.status || "active"}</Badge>
</TableCell>
<TableCell>{s.enrollment_date || "—"}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="text-destructive" onClick={() => { if (window.confirm(`Deactivate student "${s.name}"?`)) deleteMut.mutate({ type: "student", id: s.id }); }}>
Deactivate
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="teachers" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Department</TableHead>
<TableHead>Specialization</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{teachers.length === 0 && (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No teachers found.</TableCell></TableRow>
)}
{teachers.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell>{t.email}</TableCell>
<TableCell>{t.phone || "—"}</TableCell>
<TableCell>{t.department_name || "—"}</TableCell>
<TableCell>{t.specialization || "—"}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="text-destructive" onClick={() => { if (window.confirm(`Remove teacher "${t.name}"?`)) deleteMut.mutate({ type: "teacher", id: t.id }); }}>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<Dialog open={createOpen} onOpenChange={(v) => { setCreateOpen(v); if (!v) resetForm(); }}>
{/* Create User Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create New User</DialogTitle></DialogHeader>
<DialogHeader>
<DialogTitle>Create Platform User</DialogTitle>
<DialogDescription>
Creates an internal Odoo user (res.users) with login access.
For students/teachers, use the LMS pages instead.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>First Name</Label>
<Input value={form.first_name} onChange={(e) => setForm(f => ({ ...f, first_name: e.target.value }))} placeholder="John" />
</div>
<div className="space-y-2">
<Label>Last Name</Label>
<Input value={form.last_name} onChange={(e) => setForm(f => ({ ...f, last_name: e.target.value }))} placeholder="Doe" />
</div>
<div className="space-y-2">
<Label>Full Name</Label>
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. John Admin" />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input type="email" value={form.email} onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))} placeholder="john@email.com" />
<p className="text-xs text-muted-foreground">
Must be unique in Odoo for students and teachers. If create fails, the address may already exist outside the current list.
</p>
<Label>Email (login)</Label>
<Input type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} placeholder="admin@encoach.com" />
</div>
<div className="space-y-2">
<Label>Phone</Label>
<Input value={form.phone} onChange={(e) => setForm(f => ({ ...f, phone: e.target.value }))} placeholder="+1234567890" />
<Label>Password</Label>
<Input type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} placeholder="Minimum 6 characters" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Role</Label>
<Select value={form.role} onValueChange={(v) => setForm(f => ({ ...f, role: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="student">Student</SelectItem>
<SelectItem value="teacher">Teacher</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Gender</Label>
<Select value={form.gender} onValueChange={(v) => setForm(f => ({ ...f, gender: v }))}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
<SelectItem value="male">Male</SelectItem>
<SelectItem value="female">Female</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Phone (optional)</Label>
<Input value={form.phone} onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))} placeholder="+1234567890" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setCreateOpen(false); resetForm(); }}>Cancel</Button>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button
disabled={isPending || !form.first_name.trim() || !form.last_name.trim() || !form.email.trim()}
onClick={handleCreate}
disabled={createMut.isPending || !form.name.trim() || !form.email.trim()}
onClick={() => createMut.mutate({ name: form.name.trim(), email: form.email.trim(), password: form.password || "admin123", phone: form.phone.trim() || undefined })}
>
{isPending ? "Creating..." : "Create"}
{createMut.isPending ? "Creating..." : "Create User"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit User Dialog */}
<Dialog open={!!editUser} onOpenChange={(v) => { if (!v) setEditUser(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
<DialogDescription>Update profile for {editUser?.name}</DialogDescription>
</DialogHeader>
{editUser && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={editUser.name}
onChange={(e) => setEditUser((u) => u ? { ...u, name: e.target.value } : null)}
/>
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input
type="email"
value={editUser.email}
onChange={(e) => setEditUser((u) => u ? { ...u, email: e.target.value } : null)}
/>
</div>
<div className="space-y-2">
<Label>Phone</Label>
<Input
value={editUser.phone || ""}
onChange={(e) => setEditUser((u) => u ? { ...u, phone: e.target.value } : null)}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
<Button
disabled={updateMut.isPending || !editUser?.name.trim()}
onClick={() => {
if (!editUser) return;
updateMut.mutate({
id: editUser.id,
name: editUser.name.trim(),
email: editUser.email.trim(),
phone: editUser.phone?.trim() || undefined,
});
}}
>
{updateMut.isPending ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Manage Roles Dialog */}
<Dialog open={!!rolesUser} onOpenChange={(v) => { if (!v) setRolesUser(null); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Manage Roles {rolesUser?.name}
</div>
</DialogTitle>
<DialogDescription>Toggle roles on/off for this user.</DialogDescription>
</DialogHeader>
{rolesQ.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
</div>
) : rolesData?.roles ? (
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{rolesData.roles.map((r) => (
<label
key={r.id}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
r.assigned ? "border-primary bg-primary/5" : "border-border hover:bg-muted/50"
}`}
>
<Checkbox
checked={r.assigned}
onCheckedChange={() => {
if (rolesUser) toggleRoleMut.mutate({ userId: rolesUser.id, roleId: r.id });
}}
/>
<div className="flex-1">
<p className="text-sm font-medium">{r.name}</p>
{r.description && <p className="text-xs text-muted-foreground">{r.description}</p>}
</div>
<Badge variant="outline" className="text-xs shrink-0">
{r.permission_count} perms
</Badge>
</label>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">No roles available.</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setRolesUser(null)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}