Initial commit
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user