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
415 lines
17 KiB
TypeScript
415 lines
17 KiB
TypeScript
import { useState } from "react";
|
|
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, DialogDescription } from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
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";
|
|
|
|
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;
|
|
}
|
|
|
|
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 [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 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: ["platform-users"] });
|
|
setCreateOpen(false);
|
|
setForm({ name: "", email: "", password: "", phone: "" });
|
|
toast({ title: "User created" });
|
|
},
|
|
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
|
|
});
|
|
|
|
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" }),
|
|
});
|
|
|
|
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", "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 = "platform_users.csv";
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
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 all platform accounts — admins, teachers, and students.
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={exportCsv}>
|
|
<Download className="h-4 w-4 mr-1" /> Export
|
|
</Button>
|
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
<Plus className="h-4 w-4 mr-1" /> Create User
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<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 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>
|
|
|
|
{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>
|
|
) : (
|
|
<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>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Create User Dialog */}
|
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
|
<DialogContent>
|
|
<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="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 (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>Password</Label>
|
|
<Input type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} placeholder="Minimum 6 characters" />
|
|
</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)}>Cancel</Button>
|
|
<Button
|
|
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 })}
|
|
>
|
|
{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>
|
|
);
|
|
}
|