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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user