350 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|