Files
Vault/app/admin/enrollments/admin-client.tsx
T
twotalesanimation 81ad7e4ea9 Initial commit
2026-06-11 10:46:09 +02:00

350 lines
11 KiB
TypeScript

'use client';
import React, { useEffect, useState } from 'react';
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { SiteHeader } from '@/components/site-header';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { Progress } from '@/components/ui/progress';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardFooter,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Field,
FieldGroup,
FieldLabel,
FieldDescription,
} from '@/components/ui/field';
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
type User = { id: number | string; name?: string | null; email: string };
type Course = { id: number | string; title: string; code: string };
type Enrollment = {
id: number;
role?: string | null;
createdAt?: string;
user: User;
course: Course;
};
function AdminUI() {
const [users, setUsers] = useState<User[]>([]);
const [courses, setCourses] = useState<Course[]>([]);
const [enrollments, setEnrollments] = useState<Enrollment[]>([]);
// placeholder sentinel
const [selectedUser, setSelectedUser] = useState<string>('none');
const [selectedCourse, setSelectedCourse] = useState<string>('none');
const [role, setRole] = useState<string>('student');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
Promise.all([
fetch('/api/users')
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
.catch(() => []),
fetch('/api/courses')
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
.catch(() => []),
fetch('/api/enrollments')
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
.catch(() => []),
])
.then(([u, c, e]) => {
setUsers(u ?? []);
setCourses(c ?? []);
setEnrollments(e ?? []);
})
.catch((err) => {
console.error('Failed to load admin data', err);
setError('Failed to load admin data');
})
.finally(() => setLoading(false));
}, []);
function extractIdString(val: unknown): string | null {
const s = val === null || val === undefined ? '' : String(val).trim();
console.debug('extractIdString raw value:', s);
if (!s) return null;
if (s.length > 0) return s;
return null;
}
async function handleCreate(e?: React.FormEvent) {
e?.preventDefault();
setError(null);
const userIdStr = extractIdString(selectedUser);
const courseIdStr = extractIdString(selectedCourse);
if (!userIdStr || !courseIdStr) {
setError(
`Invalid selection. user="${String(selectedUser)}" course="${String(
selectedCourse
)}". Expected values containing numeric ids or direct ids.`
);
return;
}
// send strings to the server (your Prisma schema expects String ids)
const payload = {
userId: String(userIdStr),
courseId: String(courseIdStr),
};
console.debug('Creating enrollment with payload:', payload);
setSaving(true);
try {
const res = await fetch('/api/enrollments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
setError(data?.error || 'Failed to create enrollment');
} else {
setEnrollments((prev) => [data, ...prev]);
setSelectedUser('none');
setSelectedCourse('none');
setRole('student');
}
} catch (err) {
console.error('Network error while creating enrollment', err);
setError('Network error while creating enrollment');
} finally {
setSaving(false);
}
}
async function handleDelete(id: number) {
if (!confirm('Remove this enrollment?')) return;
setSaving(true);
setError(null);
try {
const res = await fetch('/api/enrollments', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
const data = await res.json();
if (!res.ok && !data?.success) {
setError(data?.error || 'Failed to delete enrollment');
} else {
setEnrollments((prev) => prev.filter((en) => en.id !== id));
}
} catch (err) {
console.error('Network error while deleting enrollment', err);
setError('Network error while deleting enrollment');
} finally {
setSaving(false);
}
}
return (
<div className="flex flex-col gap-6 py-4 md:gap-6 md:py-6 px-4 lg:px-16">
<Card className="mb-6">
<CardHeader>
<CardTitle>Assign enrollment</CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={(e) => handleCreate(e)}
className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end"
>
<div className="col-span-2">
<FieldGroup>
<Field>
<FieldLabel htmlFor="student">Student</FieldLabel>
<Select
value={selectedUser}
onValueChange={(v) => {
console.debug('Select user changed ->', v);
setSelectedUser(v);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a user" />
</SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={String(u.id)} value={String(u.id)}>
{u.name ? `${u.name}${u.email}` : u.email}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup>
</div>
<div className="col-span-1">
<FieldGroup>
<Field>
<FieldLabel htmlFor="course">Course</FieldLabel>
<Select
value={selectedCourse}
onValueChange={(v) => {
console.debug('Select course changed ->', v);
setSelectedCourse(v);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a course" />
</SelectTrigger>
<SelectContent>
{courses.map((c) => (
<SelectItem key={String(c.id)} value={String(c.id)}>
{c.title} | {c.code}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup>
</div>
<div className="col-span-1 flex gap-2">
<Button
type="submit"
disabled={saving}
className="self-end w-full"
>
Add
</Button>
</div>
</form>
{error && (
<div className="mt-3 text-sm text-destructive">{error}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Current enrollments</CardTitle>
</CardHeader>
<CardContent className="overflow-x-auto">
{loading && enrollments.length === 0 ? (
<div className="p-4">Loading</div>
) : enrollments.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">
No enrollments yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">Student</TableHead>
<TableHead className="w-[300px]">Email</TableHead>
<TableHead>Course</TableHead>
<TableHead>Role</TableHead>
<TableHead>Enrolled</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{enrollments.map((en) => (
<TableRow key={en.id}>
<TableCell className="font-medium">
{en.user.name ?? en.user.email}
</TableCell>
<TableCell className="font-medium">
{en.user.email}
</TableCell>
<TableCell className="italic">
{en.course.title}{' '}
<span className="text-xs text-muted-foreground">
| {en.course.code}
</span>
</TableCell>
<TableCell>{en.role ?? 'student'}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{en.createdAt
? new Date(en.createdAt).toLocaleString()
: '—'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(en.id)}
disabled={saving}
>
Remove
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
export default function AdminClient() {
return (
<SidebarProvider
style={
{
'--sidebar-width': 'calc(var(--spacing) * 72)',
'--header-height': 'calc(var(--spacing) * 12)',
} as React.CSSProperties
}
>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader />
<div className="flex flex-1 flex-col">
<div className="@container/main flex flex-1 flex-col gap-2">
<AdminUI />
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}