feat(frontend): admin card management with excel import/export

This commit is contained in:
2026-05-20 21:17:55 +02:00
parent 1fd31e1001
commit 16a7cc6ad6
4 changed files with 129 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
import { useState } from 'react';
import type { Card, CardCreateInput } from '@flashcard/shared';
import { cardsApi } from '../api/cards.js';
export function CardTable({ lessonId, cards, onChange }: { lessonId: number; cards: Card[]; onChange: () => void }) {
const [draft, setDraft] = useState<CardCreateInput>({ question: '', answer: '', hint: '' });
async function add() {
if (!draft.question.trim() || !draft.answer.trim()) return;
await cardsApi.create(lessonId, { question: draft.question.trim(), answer: draft.answer.trim(), hint: draft.hint?.trim() || null });
setDraft({ question: '', answer: '', hint: '' });
onChange();
}
async function update(c: Card, field: 'question' | 'answer' | 'hint', value: string) {
await cardsApi.update(c.id, { [field]: value || null });
onChange();
}
async function remove(id: number) {
if (!confirm('Verwijder kaart?')) return;
await cardsApi.remove(id);
onChange();
}
return (
<table className="w-full text-sm">
<thead><tr className="text-left text-slate-500">
<th className="py-2">Vraag</th><th>Antwoord</th><th>Hint</th><th></th>
</tr></thead>
<tbody>
{cards.map((c) => (
<tr key={c.id} className="border-t border-slate-200 dark:border-slate-800">
<td><input className="w-full bg-transparent py-1" defaultValue={c.question} onBlur={(e) => update(c, 'question', e.target.value)} /></td>
<td><input className="w-full bg-transparent py-1" defaultValue={c.answer} onBlur={(e) => update(c, 'answer', e.target.value)} /></td>
<td><input className="w-full bg-transparent py-1" defaultValue={c.hint ?? ''} onBlur={(e) => update(c, 'hint', e.target.value)} /></td>
<td><button className="text-xs text-red-600" onClick={() => remove(c.id)}>x</button></td>
</tr>
))}
<tr className="border-t border-slate-200 dark:border-slate-800">
<td><input className="w-full bg-transparent py-1" placeholder="Nieuwe vraag" value={draft.question} onChange={(e) => setDraft({ ...draft, question: e.target.value })} /></td>
<td><input className="w-full bg-transparent py-1" placeholder="Antwoord" value={draft.answer} onChange={(e) => setDraft({ ...draft, answer: e.target.value })} /></td>
<td><input className="w-full bg-transparent py-1" placeholder="Hint (optioneel)" value={draft.hint ?? ''} onChange={(e) => setDraft({ ...draft, hint: e.target.value })} /></td>
<td><button className="rounded bg-blue-600 px-2 py-1 text-xs text-white" onClick={add}>+</button></td>
</tr>
</tbody>
</table>
);
}

View File

@@ -0,0 +1,49 @@
import { useState } from 'react';
import { cardsApi, type ImportResult } from '../api/cards.js';
export function ImportDialog({ lessonId, onClose, onDone }: { lessonId: number; onClose: () => void; onDone: () => void }) {
const [file, setFile] = useState<File | null>(null);
const [updateExisting, setUpdateExisting] = useState(true);
const [createMissing, setCreateMissing] = useState(false);
const [result, setResult] = useState<ImportResult | null>(null);
const [busy, setBusy] = useState(false);
async function run() {
if (!file) return;
setBusy(true);
try {
const r = await cardsApi.importXlsx(lessonId, file, { updateExisting, createMissingLessons: createMissing });
setResult(r);
onDone();
} finally { setBusy(false); }
}
return (
<div className="fixed inset-0 flex items-center justify-center bg-black/40">
<div className="w-full max-w-md rounded bg-white p-6 dark:bg-slate-900">
<h2 className="mb-3 text-lg font-semibold">Excel import</h2>
<p className="mb-3 text-xs text-slate-500">Kolommen: <code>question</code>, <code>answer</code>, <code>hint</code> (optioneel), <code>lesson_path</code> (optioneel, bv. "Spaans/Begroetingen").</p>
<input type="file" accept=".xlsx" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<label className="mt-3 flex items-center gap-2 text-sm"><input type="checkbox" checked={updateExisting} onChange={(e) => setUpdateExisting(e.target.checked)} /> Bestaande kaarten bijwerken</label>
<label className="mt-1 flex items-center gap-2 text-sm"><input type="checkbox" checked={createMissing} onChange={(e) => setCreateMissing(e.target.checked)} /> Onbekende lessen aanmaken</label>
{result && (
<div className="mt-3 rounded bg-slate-100 p-2 text-xs dark:bg-slate-800">
<div>Toegevoegd: {result.inserted}</div>
<div>Bijgewerkt: {result.updated}</div>
<div>Overgeslagen: {result.skipped}</div>
{result.errors.length > 0 && (
<div className="text-red-600">
Fouten: {result.errors.length}
<ul>{result.errors.map((e, i) => <li key={i}>rij {e.row}: {e.message}</li>)}</ul>
</div>
)}
</div>
)}
<div className="mt-4 flex justify-end gap-2">
<button className="px-3 py-1" onClick={onClose}>Sluiten</button>
<button className="rounded bg-blue-600 px-3 py-1 text-white disabled:opacity-50" onClick={run} disabled={!file || busy}>Importeer</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import type { Card } from '@flashcard/shared';
import { cardsApi } from '../api/cards.js';
import { CardTable } from '../components/CardTable.js';
import { ImportDialog } from '../components/ImportDialog.js';
export function AdminLessonPage() {
const { id } = useParams();
const lessonId = Number(id);
const [cards, setCards] = useState<Card[]>([]);
const [showImport, setShowImport] = useState(false);
async function refresh() { setCards(await cardsApi.list(lessonId)); }
useEffect(() => { refresh(); }, [lessonId]);
return (
<div className="mx-auto max-w-4xl p-6">
<Link to="/admin" className="text-sm text-blue-600"> Lessen</Link>
<h1 className="my-3 text-2xl font-semibold">Kaarten</h1>
<div className="mb-4 flex gap-2">
<button className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" onClick={() => setShowImport(true)}>Importeer Excel</button>
<a className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" href={cardsApi.exportUrl(lessonId, false)}>Exporteer Excel</a>
<a className="rounded bg-slate-200 px-3 py-1 dark:bg-slate-800" href={cardsApi.exportUrl(lessonId, true)}>Exporteer + sublessen</a>
<Link to={`/practice/${lessonId}/setup`} className="ml-auto rounded bg-green-600 px-3 py-1 text-white">Start oefenen </Link>
</div>
<CardTable lessonId={lessonId} cards={cards} onChange={refresh} />
{showImport && <ImportDialog lessonId={lessonId} onClose={() => setShowImport(false)} onDone={refresh} />}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { Layout } from './components/Layout.js';
import { AdminPage } from './pages/Admin.js';
import { AdminLessonPage } from './pages/AdminLesson.js';
export const router = createBrowserRouter([
{
@@ -9,6 +10,7 @@ export const router = createBrowserRouter([
children: [
{ index: true, element: <div className="p-6">Dashboard placeholder</div> },
{ path: 'admin', element: <AdminPage /> },
{ path: 'admin/lessons/:id', element: <AdminLessonPage /> },
{ path: '*', element: <Navigate to="/" replace /> },
],
},