From 16a7cc6ad600c1028e843555e15bd5089ca95539 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Wed, 20 May 2026 21:17:55 +0200 Subject: [PATCH] feat(frontend): admin card management with excel import/export --- .../frontend/src/components/CardTable.tsx | 47 ++++++++++++++++++ .../frontend/src/components/ImportDialog.tsx | 49 +++++++++++++++++++ packages/frontend/src/pages/AdminLesson.tsx | 31 ++++++++++++ packages/frontend/src/router.tsx | 2 + 4 files changed, 129 insertions(+) create mode 100644 packages/frontend/src/components/CardTable.tsx create mode 100644 packages/frontend/src/components/ImportDialog.tsx create mode 100644 packages/frontend/src/pages/AdminLesson.tsx diff --git a/packages/frontend/src/components/CardTable.tsx b/packages/frontend/src/components/CardTable.tsx new file mode 100644 index 0000000..432fa52 --- /dev/null +++ b/packages/frontend/src/components/CardTable.tsx @@ -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({ 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 ( + + + + + + {cards.map((c) => ( + + + + + + + ))} + + + + + + + +
VraagAntwoordHint
update(c, 'question', e.target.value)} /> update(c, 'answer', e.target.value)} /> update(c, 'hint', e.target.value)} />
setDraft({ ...draft, question: e.target.value })} /> setDraft({ ...draft, answer: e.target.value })} /> setDraft({ ...draft, hint: e.target.value })} />
+ ); +} diff --git a/packages/frontend/src/components/ImportDialog.tsx b/packages/frontend/src/components/ImportDialog.tsx new file mode 100644 index 0000000..cc8242e --- /dev/null +++ b/packages/frontend/src/components/ImportDialog.tsx @@ -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(null); + const [updateExisting, setUpdateExisting] = useState(true); + const [createMissing, setCreateMissing] = useState(false); + const [result, setResult] = useState(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 ( +
+
+

Excel import

+

Kolommen: question, answer, hint (optioneel), lesson_path (optioneel, bv. "Spaans/Begroetingen").

+ setFile(e.target.files?.[0] ?? null)} /> + + + {result && ( +
+
Toegevoegd: {result.inserted}
+
Bijgewerkt: {result.updated}
+
Overgeslagen: {result.skipped}
+ {result.errors.length > 0 && ( +
+ Fouten: {result.errors.length} +
    {result.errors.map((e, i) =>
  • rij {e.row}: {e.message}
  • )}
+
+ )} +
+ )} +
+ + +
+
+
+ ); +} diff --git a/packages/frontend/src/pages/AdminLesson.tsx b/packages/frontend/src/pages/AdminLesson.tsx new file mode 100644 index 0000000..daa6c90 --- /dev/null +++ b/packages/frontend/src/pages/AdminLesson.tsx @@ -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([]); + const [showImport, setShowImport] = useState(false); + + async function refresh() { setCards(await cardsApi.list(lessonId)); } + useEffect(() => { refresh(); }, [lessonId]); + + return ( +
+ ← Lessen +

Kaarten

+
+ + Exporteer Excel + Exporteer + sublessen + Start oefenen → +
+ + {showImport && setShowImport(false)} onDone={refresh} />} +
+ ); +} diff --git a/packages/frontend/src/router.tsx b/packages/frontend/src/router.tsx index dfe339c..637e184 100644 --- a/packages/frontend/src/router.tsx +++ b/packages/frontend/src/router.tsx @@ -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:
Dashboard placeholder
}, { path: 'admin', element: }, + { path: 'admin/lessons/:id', element: }, { path: '*', element: }, ], },