import { useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { client, errorMessage } from '../../api/client' import type { Quiz, QuizImportResult, QuizQuestionAdmin, QuizStats } from '../../api/types' import { Alert, Badge, Button, Card, EmptyState, ErrorState, Field, Input, Spinner, Textarea, } from '../../components/ui' import { Icon } from '../../components/Icon' import { Modal } from '../../components/Modal' import { useConfirm } from '../../components/Confirm' import { useToasts } from '../../components/Toasts' type Tab = 'pytania' | 'ustawienia' | 'statystyki' | 'podglad' /** * Edytor quizu, otwierany z lekcji typu Sprawdzian. * * Cztery zakladki, bo to cztery rozne zadania: uklada sie pytania, ustawia * zasady, oglada wyniki albo sprawdza, jak test wyglada oczami kursanta. * Wszystko w jednym oknie oznaczaloby przewijanie przez kilkadziesiat pytan * za kazdym razem, gdy trzeba poprawic limit czasu. */ export function QuizEditor({ courseId, lessonId, onClose, }: { courseId: number lessonId: number onClose: () => void }) { const [tab, setTab] = useState('pytania') const base = `/admin/courses/${courseId}/lessons/${lessonId}/quiz` const quiz = useQuery({ queryKey: ['quiz', courseId, lessonId], queryFn: async () => (await client.get(base)).data, }) return ( {quiz.isPending && } {quiz.isError && ( )} {quiz.data && (
{quiz.data.questionsInvalid > 0 && ( {quiz.data.questionsInvalid}{' '} {quiz.data.questionsInvalid === 1 ? 'pytanie wymaga' : 'pytania wymagają'} poprawy. {' '} Każde pytanie musi mieć dokładnie jedną odpowiedź poprawną i co najmniej dwie odpowiedzi. Kursu z takim pytaniem nie da się opublikować. )} {tab === 'pytania' && } {tab === 'ustawienia' && ( )} {tab === 'statystyki' && } {tab === 'podglad' && }
)}
) } // --- pytania --- type AnswerDraft = { content: string; correct: boolean } type QuestionDraft = { content: string; explanation: string; answers: AnswerDraft[]; active: boolean } const EMPTY_DRAFT: QuestionDraft = { content: '', explanation: '', // dwie puste odpowiedzi od razu: minimum wymagane regula, wiec zaczynamy od niego answers: [ { content: '', correct: true }, { content: '', correct: false }, ], active: true, } function QuestionsTab({ base, courseId, lessonId, }: { base: string courseId: number lessonId: number }) { const queryClient = useQueryClient() const toasts = useToasts() const confirm = useConfirm() const fileRef = useRef(null) const [editing, setEditing] = useState<{ id: number | null; draft: QuestionDraft } | null>(null) const [importResult, setImportResult] = useState(null) const questions = useQuery({ queryKey: ['quiz', courseId, lessonId, 'questions'], queryFn: async () => (await client.get(`${base}/questions`)).data, }) const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['quiz', courseId, lessonId] }) } const save = useMutation({ mutationFn: ({ id, draft }: { id: number | null; draft: QuestionDraft }) => { const body = { content: draft.content, explanation: draft.explanation || null, active: draft.active, answers: draft.answers.map((a) => ({ content: a.content, correct: a.correct })), } return id === null ? client.post(`${base}/questions`, body) : client.put(`${base}/questions/${id}`, body) }, onSuccess: () => { setEditing(null) invalidate() }, onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać pytania')), }) const remove = useMutation({ mutationFn: (id: number) => client.delete(`${base}/questions/${id}`), onSuccess: invalidate, onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć pytania')), }) const reorder = useMutation({ mutationFn: (ids: number[]) => client.put(`${base}/questions/order`, { ids }), onSuccess: invalidate, onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać kolejności')), }) const upload = useMutation({ mutationFn: async (file: File) => { const form = new FormData() form.append('file', file) return (await client.post(`${base}/questions/import`, form)).data }, onSuccess: (result) => { setImportResult(result) invalidate() }, onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wczytać pliku')), }) /** Przesuwa pytanie o jedna pozycje. Jedyna droga dla osoby bez myszy. */ const move = (id: number, delta: number) => { const ids = (questions.data ?? []).map((q) => q.id) const from = ids.indexOf(id) const to = from + delta if (from < 0 || to < 0 || to >= ids.length) return const next = [...ids] next.splice(to, 0, next.splice(from, 1)[0]) reorder.mutate(next) } if (questions.isPending) return if (questions.isError) { return } const list = questions.data ?? [] return (
{ const file = e.target.files?.[0] if (file) upload.mutate(file) e.target.value = '' }} />
Format pliku CSV

Jedno pytanie w wierszu, kolumny rozdzielone średnikiem:

Treść pytania;odpowiedź;*odpowiedź poprawna;kolejna;#wyjaśnienie

Gwiazdka na początku oznacza odpowiedź poprawną, krzyżyk wyjaśnienie. Wiersz z błędem jest pomijany, reszta pliku wczytuje się normalnie.

{importResult && ( 0 ? 'warn' : 'ok'}>

Wczytano pytań: {importResult.imported} {importResult.skipped > 0 && <>, pominięto: {importResult.skipped}}

{importResult.problems.length > 0 && (
    {importResult.problems.slice(0, 5).map((problem) => (
  • {problem}
  • ))}
)}
)} {list.length === 0 && ( )}
    {list.map((question, index) => (
  • {index + 1}.

    {question.content}

      {question.answers.map((answer) => (
    • {answer.content}
    • ))}
    {!question.valid && (

    Wymagana dokładnie jedna odpowiedź poprawna i co najmniej dwie odpowiedzi.

    )}
    {!question.active && wyłączone} move(question.id, -1)} /> move(question.id, 1)} /> setEditing({ id: question.id, draft: { content: question.content, explanation: question.explanation ?? '', active: question.active, answers: question.answers.map((a) => ({ content: a.content, correct: a.correct, })), }, }) } /> { const ok = await confirm({ title: 'Usunąć pytanie?', message: ( <> {/* tresc pytania bywa dluga: w oknie pokazujemy poczatek */} Pytanie {index + 1}{' '} {question.content.length > 120 ? `${question.content.slice(0, 120).trimEnd()}...` : question.content} {' '} zniknie z quizu razem z odpowiedziami. ), confirmLabel: 'Usuń pytanie', destructive: true, }) if (ok) remove.mutate(question.id) }} />
  • ))}
{editing && ( setEditing(null)} onSave={(draft) => save.mutate({ id: editing.id, draft })} /> )}
) } /** Formularz pytania. Poprawna odpowiedz wybierana jednym przelacznikiem. */ function QuestionForm({ draft, pending, onCancel, onSave, }: { draft: QuestionDraft pending: boolean onCancel: () => void onSave: (draft: QuestionDraft) => void }) { const [form, setForm] = useState(draft) const [error, setError] = useState(null) const setAnswer = (index: number, content: string) => setForm((prev) => ({ ...prev, answers: prev.answers.map((a, i) => (i === index ? { ...a, content } : a)), })) /* * Wybor poprawnej odpowiedzi zeruje pozostale: regula dopuszcza dokladnie * jedna, wiec pola wyboru zachowuja sie jak przelacznik, a nie jak zestaw * niezaleznych pol. Bez tego uzytkownik zaznaczylby dwie i dowiedzial sie * o bledzie dopiero przy zapisie. */ const setCorrect = (index: number) => setForm((prev) => ({ ...prev, answers: prev.answers.map((a, i) => ({ ...a, correct: i === index })), })) const addAnswer = () => setForm((prev) => ({ ...prev, answers: [...prev.answers, { content: '', correct: false }] })) const removeAnswer = (index: number) => setForm((prev) => { const answers = prev.answers.filter((_, i) => i !== index) // usuniecie poprawnej zostawiloby pytanie bez klucza: przenosimy na pierwsza if (!answers.some((a) => a.correct) && answers.length > 0) { answers[0] = { ...answers[0], correct: true } } return { ...prev, answers } }) const submit = (event: React.FormEvent) => { event.preventDefault() setError(null) if (!form.content.trim()) { setError('Treść pytania jest wymagana') return } const filled = form.answers.filter((a) => a.content.trim()) if (filled.length < 2) { setError('Pytanie musi mieć co najmniej dwie odpowiedzi') return } if (filled.filter((a) => a.correct).length !== 1) { setError('Zaznacz dokładnie jedną odpowiedź poprawną') return } onSave({ ...form, answers: filled }) } return (
{error && {error}}