Initial commit
This commit is contained in:
@@ -0,0 +1,840 @@
|
||||
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 { 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<Tab>('pytania')
|
||||
const base = `/admin/courses/${courseId}/lessons/${lessonId}/quiz`
|
||||
|
||||
const quiz = useQuery({
|
||||
queryKey: ['quiz', courseId, lessonId],
|
||||
queryFn: async () => (await client.get<Quiz>(base)).data,
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal title="Sprawdzian" onClose={onClose} wide>
|
||||
{quiz.isPending && <Spinner />}
|
||||
{quiz.isError && (
|
||||
<ErrorState message={errorMessage(quiz.error, 'Nie udało się wczytać quizu')} />
|
||||
)}
|
||||
|
||||
{quiz.data && (
|
||||
<div className="space-y-4">
|
||||
{quiz.data.questionsInvalid > 0 && (
|
||||
<Alert tone="warn">
|
||||
<strong>
|
||||
{quiz.data.questionsInvalid}{' '}
|
||||
{quiz.data.questionsInvalid === 1 ? 'pytanie wymaga' : 'pytania wymagają'} poprawy.
|
||||
</strong>{' '}
|
||||
Każde pytanie musi mieć dokładnie jedną odpowiedź poprawną i co najmniej dwie
|
||||
odpowiedzi. Kursu z takim pytaniem nie da się opublikować.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<nav role="tablist" aria-label="Sekcje quizu" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
|
||||
{(
|
||||
[
|
||||
['pytania', 'Pytania', 'file-text', quiz.data.questionsTotal],
|
||||
['ustawienia', 'Ustawienia', 'settings', null],
|
||||
['statystyki', 'Statystyki', 'presentation', null],
|
||||
['podglad', 'Podgląd', 'eye', null],
|
||||
] as const
|
||||
).map(([value, label, icon, count]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === value}
|
||||
onClick={() => setTab(value)}
|
||||
className={`flex min-h-[44px] shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors duration-fast ease-standard ${
|
||||
tab === value
|
||||
? 'bg-blue-50 font-medium text-blue-700'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
{label}
|
||||
{count !== null && <span className="hx-data text-xs text-slate-400">{count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{tab === 'pytania' && <QuestionsTab base={base} courseId={courseId} lessonId={lessonId} />}
|
||||
{tab === 'ustawienia' && (
|
||||
<SettingsTab base={base} courseId={courseId} lessonId={lessonId} quiz={quiz.data} />
|
||||
)}
|
||||
{tab === 'statystyki' && <StatsTab base={base} />}
|
||||
{tab === 'podglad' && <PreviewTab base={base} quiz={quiz.data} />}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// --- 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 fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [editing, setEditing] = useState<{ id: number | null; draft: QuestionDraft } | null>(null)
|
||||
const [importResult, setImportResult] = useState<QuizImportResult | null>(null)
|
||||
|
||||
const questions = useQuery({
|
||||
queryKey: ['quiz', courseId, lessonId, 'questions'],
|
||||
queryFn: async () => (await client.get<QuizQuestionAdmin[]>(`${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<QuizImportResult>(`${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 <Spinner />
|
||||
if (questions.isError) {
|
||||
return <ErrorState message={errorMessage(questions.error, 'Nie udało się wczytać pytań')} />
|
||||
}
|
||||
|
||||
const list = questions.data ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button icon="plus" onClick={() => setEditing({ id: null, draft: EMPTY_DRAFT })}>
|
||||
Dodaj pytanie
|
||||
</Button>
|
||||
<Button variant="secondary" icon="upload" onClick={() => fileRef.current?.click()}>
|
||||
Import z CSV
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) upload.mutate(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<details className="rounded-md border border-slate-200 p-3 text-sm">
|
||||
<summary className="cursor-pointer font-medium text-slate-700">Format pliku CSV</summary>
|
||||
<div className="mt-2 space-y-1 text-slate-600">
|
||||
<p>Jedno pytanie w wierszu, kolumny rozdzielone średnikiem:</p>
|
||||
<p className="hx-data rounded bg-slate-50 p-2 text-xs">
|
||||
Treść pytania;odpowiedź;*odpowiedź poprawna;kolejna;#wyjaśnienie
|
||||
</p>
|
||||
<p>
|
||||
Gwiazdka na początku oznacza odpowiedź poprawną, krzyżyk wyjaśnienie. Wiersz z błędem
|
||||
jest pomijany, reszta pliku wczytuje się normalnie.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{importResult && (
|
||||
<Alert tone={importResult.skipped > 0 ? 'warn' : 'ok'}>
|
||||
<p>
|
||||
Wczytano pytań: <strong>{importResult.imported}</strong>
|
||||
{importResult.skipped > 0 && <>, pominięto: <strong>{importResult.skipped}</strong></>}
|
||||
</p>
|
||||
{importResult.problems.length > 0 && (
|
||||
<ul className="mt-1 list-disc pl-5 text-xs">
|
||||
{importResult.problems.slice(0, 5).map((problem) => (
|
||||
<li key={problem}>{problem}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{list.length === 0 && (
|
||||
<EmptyState
|
||||
icon="file-text"
|
||||
title="Quiz nie ma jeszcze pytań"
|
||||
description="Dodaj pytanie ręcznie albo wczytaj listę z pliku CSV."
|
||||
/>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{list.map((question, index) => (
|
||||
<li
|
||||
key={question.id}
|
||||
className={`rounded-md border p-3 ${
|
||||
question.valid ? 'border-slate-100' : 'border-warn-500/40 bg-warn-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<span className="hx-data mt-0.5 text-xs text-slate-400">{index + 1}.</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-900">{question.content}</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{question.answers.map((answer) => (
|
||||
<li
|
||||
key={answer.id}
|
||||
className={`flex items-center gap-1.5 text-xs ${
|
||||
answer.correct ? 'font-medium text-ok-600' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<Icon name={answer.correct ? 'check-circle' : 'x'} size={13} />
|
||||
{answer.content}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!question.valid && (
|
||||
<p className="mt-1 text-xs font-medium text-warn-600">
|
||||
Wymagana dokładnie jedna odpowiedź poprawna i co najmniej dwie odpowiedzi.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{!question.active && <Badge tone="neutral">wyłączone</Badge>}
|
||||
<IconAction
|
||||
icon="chevron-up"
|
||||
label={`Przesuń pytanie ${index + 1} w górę`}
|
||||
disabled={index === 0}
|
||||
onClick={() => move(question.id, -1)}
|
||||
/>
|
||||
<IconAction
|
||||
icon="chevron-down"
|
||||
label={`Przesuń pytanie ${index + 1} w dół`}
|
||||
disabled={index === list.length - 1}
|
||||
onClick={() => move(question.id, 1)}
|
||||
/>
|
||||
<IconAction
|
||||
icon="pencil"
|
||||
label={`Edytuj pytanie ${index + 1}`}
|
||||
onClick={() =>
|
||||
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,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<IconAction
|
||||
icon="trash"
|
||||
label={`Usuń pytanie ${index + 1}`}
|
||||
onClick={() => {
|
||||
if (window.confirm('Usunąć to pytanie?')) remove.mutate(question.id)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{editing && (
|
||||
<QuestionForm
|
||||
draft={editing.draft}
|
||||
pending={save.isPending}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSave={(draft) => save.mutate({ id: editing.id, draft })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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<QuestionDraft>(draft)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<form onSubmit={submit} noValidate className="space-y-3 rounded-md border border-blue-200 bg-blue-50/40 p-3">
|
||||
{error && <Alert tone="alarm">{error}</Alert>}
|
||||
|
||||
<Field label="Treść pytania">
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={form.content}
|
||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<p className="mb-1.5 text-sm font-semibold text-slate-800">
|
||||
Odpowiedzi <span className="font-normal text-slate-500">(zaznacz poprawną)</span>
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{form.answers.map((answer, index) => (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="correct-answer"
|
||||
checked={answer.correct}
|
||||
onChange={() => setCorrect(index)}
|
||||
aria-label={`Odpowiedź ${index + 1} jest poprawna`}
|
||||
className="h-4 w-4 shrink-0 accent-ok-500"
|
||||
/>
|
||||
<Input
|
||||
className="min-w-0 flex-1"
|
||||
value={answer.content}
|
||||
onChange={(e) => setAnswer(index, e.target.value)}
|
||||
placeholder={`Odpowiedź ${index + 1}`}
|
||||
aria-label={`Treść odpowiedzi ${index + 1}`}
|
||||
/>
|
||||
<IconAction
|
||||
icon="trash"
|
||||
label={`Usuń odpowiedź ${index + 1}`}
|
||||
disabled={form.answers.length <= 2}
|
||||
onClick={() => removeAnswer(index)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button type="button" variant="ghost" icon="plus" onClick={addAnswer} className="mt-1">
|
||||
Dodaj odpowiedź
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Field label="Wyjaśnienie" hint="Pokazywane po zakończeniu, gdy quiz pokazuje odpowiedzi.">
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={form.explanation}
|
||||
onChange={(e) => setForm({ ...form, explanation: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.active}
|
||||
onChange={(e) => setForm({ ...form, active: e.target.checked })}
|
||||
className="h-4 w-4 accent-blue-500"
|
||||
/>
|
||||
Pytanie aktywne, bierze udział w losowaniu
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onCancel}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button type="submit" icon="save" disabled={pending}>
|
||||
{pending ? 'Zapisywanie...' : 'Zapisz pytanie'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// --- ustawienia ---
|
||||
|
||||
function SettingsTab({
|
||||
base,
|
||||
courseId,
|
||||
lessonId,
|
||||
quiz,
|
||||
}: {
|
||||
base: string
|
||||
courseId: number
|
||||
lessonId: number
|
||||
quiz: Quiz
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
|
||||
const [form, setForm] = useState({
|
||||
title: quiz.title,
|
||||
description: quiz.description ?? '',
|
||||
questionCount: quiz.questionCount === null ? '' : String(quiz.questionCount),
|
||||
passThresholdPercent:
|
||||
quiz.passThresholdPercent === null ? '' : String(quiz.passThresholdPercent),
|
||||
timeLimitMinutes: quiz.timeLimitMinutes === null ? '' : String(quiz.timeLimitMinutes),
|
||||
maxAttempts: quiz.maxAttempts === null ? '' : String(quiz.maxAttempts),
|
||||
retryDelayMinutes: quiz.retryDelayMinutes === null ? '' : String(quiz.retryDelayMinutes),
|
||||
shuffleQuestions: quiz.shuffleQuestions,
|
||||
shuffleAnswers: quiz.shuffleAnswers,
|
||||
showCorrectAnswers: quiz.showCorrectAnswers,
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
client.put(base, {
|
||||
title: form.title,
|
||||
description: form.description || null,
|
||||
questionCount: form.questionCount === '' ? null : Number(form.questionCount),
|
||||
passThresholdPercent:
|
||||
form.passThresholdPercent === '' ? null : Number(form.passThresholdPercent),
|
||||
timeLimitMinutes: form.timeLimitMinutes === '' ? null : Number(form.timeLimitMinutes),
|
||||
maxAttempts: form.maxAttempts === '' ? null : Number(form.maxAttempts),
|
||||
retryDelayMinutes: form.retryDelayMinutes === '' ? null : Number(form.retryDelayMinutes),
|
||||
shuffleQuestions: form.shuffleQuestions,
|
||||
shuffleAnswers: form.shuffleAnswers,
|
||||
showCorrectAnswers: form.showCorrectAnswers,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toasts.success('Ustawienia zapisane')
|
||||
queryClient.invalidateQueries({ queryKey: ['quiz', courseId, lessonId] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać ustawień')),
|
||||
})
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
save.mutate()
|
||||
}}
|
||||
noValidate
|
||||
className="space-y-4"
|
||||
>
|
||||
<Field label="Tytuł sprawdzianu">
|
||||
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
||||
</Field>
|
||||
|
||||
<Field label="Opis" hint="Pokazywany kursantowi przed rozpoczęciem.">
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
label="Ile pytań losować"
|
||||
hint={`Puste = wszystkie aktywne (teraz ${quiz.questionsActive}).`}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.questionCount}
|
||||
onChange={(e) => setForm({ ...form, questionCount: e.target.value })}
|
||||
placeholder="wszystkie"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Próg zaliczenia (%)"
|
||||
hint={`Puste = próg kursu, czyli ${quiz.effectiveThreshold}%.`}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={form.passThresholdPercent}
|
||||
onChange={(e) => setForm({ ...form, passThresholdPercent: e.target.value })}
|
||||
placeholder={`${quiz.effectiveThreshold}`}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Limit czasu (minuty)" hint="Puste = bez limitu. Pilnuje go serwer.">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.timeLimitMinutes}
|
||||
onChange={(e) => setForm({ ...form, timeLimitMinutes: e.target.value })}
|
||||
placeholder="bez limitu"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Liczba podejść" hint="Puste = bez limitu.">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxAttempts}
|
||||
onChange={(e) => setForm({ ...form, maxAttempts: e.target.value })}
|
||||
placeholder="bez limitu"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Odstęp między podejściami (minuty)"
|
||||
hint="Liczony od zakończenia poprzedniego podejścia."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.retryDelayMinutes}
|
||||
onChange={(e) => setForm({ ...form, retryDelayMinutes: e.target.value })}
|
||||
placeholder="brak"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Toggle
|
||||
checked={form.shuffleQuestions}
|
||||
onChange={(v) => setForm({ ...form, shuffleQuestions: v })}
|
||||
title="Losuj kolejność pytań"
|
||||
description="Zestaw jest losowany raz, przy starcie podejścia, i nie zmienia się po odświeżeniu."
|
||||
/>
|
||||
<Toggle
|
||||
checked={form.shuffleAnswers}
|
||||
onChange={(v) => setForm({ ...form, shuffleAnswers: v })}
|
||||
title="Losuj kolejność odpowiedzi"
|
||||
description="Utrudnia przepisywanie odpowiedzi od innego kursanta."
|
||||
/>
|
||||
<Toggle
|
||||
checked={form.showCorrectAnswers}
|
||||
onChange={(v) => setForm({ ...form, showCorrectAnswers: v })}
|
||||
title="Pokaż poprawne odpowiedzi po zakończeniu"
|
||||
description="Przy quizie z wieloma podejściami warto wyłączyć: inaczej pierwsze podejście służy do zebrania klucza."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" icon="save" disabled={save.isPending}>
|
||||
{save.isPending ? 'Zapisywanie...' : 'Zapisz ustawienia'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// --- statystyki ---
|
||||
|
||||
function StatsTab({ base }: { base: string }) {
|
||||
const stats = useQuery({
|
||||
queryKey: [base, 'stats'],
|
||||
queryFn: async () => (await client.get<QuizStats>(`${base}/stats`)).data,
|
||||
})
|
||||
|
||||
if (stats.isPending) return <Spinner />
|
||||
if (stats.isError) {
|
||||
return <ErrorState message={errorMessage(stats.error, 'Nie udało się wczytać statystyk')} />
|
||||
}
|
||||
|
||||
const data = stats.data!
|
||||
if (data.finishedAttempts === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="presentation"
|
||||
title="Nikt jeszcze nie rozwiązał sprawdzianu"
|
||||
description="Statystyki pojawią się po pierwszym zakończonym podejściu."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Stat label="Podejścia" value={String(data.attempts)} />
|
||||
<Stat label="Średni wynik" value={`${data.averageScore}%`} />
|
||||
<Stat label="Zdawalność" value={`${data.passRate}%`} />
|
||||
</div>
|
||||
|
||||
<Card title="Pytania najczęściej mylone" description="Najniższa skuteczność na górze.">
|
||||
<ul className="space-y-2">
|
||||
{data.questions.map((question) => (
|
||||
<li key={question.questionId} className="rounded-md border border-slate-100 p-2.5">
|
||||
<p className="text-sm text-slate-900">{question.content}</p>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<div className="h-2 w-32 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
question.correctRate < 50 ? 'bg-alarm-500' : 'bg-ok-500'
|
||||
}`}
|
||||
style={{ width: `${question.correctRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="hx-data text-xs text-slate-500">
|
||||
{question.correctRate}% · {question.correct}/{question.answered}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-100 bg-white p-3">
|
||||
<p className="hx-data text-xl font-semibold text-slate-900">{value}</p>
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- podglad ---
|
||||
|
||||
/**
|
||||
* Podglad oczami kursanta.
|
||||
*
|
||||
* Pokazuje pytania **bez zaznaczonej poprawnej odpowiedzi**, czyli dokladnie
|
||||
* tak, jak zobaczy je kursant. Sluzy do sprawdzenia, czy tresc jest zrozumiala
|
||||
* bez klucza: pytanie oczywiste dla autora bywa niejasne dla odbiorcy.
|
||||
*/
|
||||
function PreviewTab({ base, quiz }: { base: string; quiz: Quiz }) {
|
||||
const questions = useQuery({
|
||||
queryKey: [base, 'questions', 'preview'],
|
||||
queryFn: async () => (await client.get<QuizQuestionAdmin[]>(`${base}/questions`)).data,
|
||||
})
|
||||
|
||||
if (questions.isPending) return <Spinner />
|
||||
const active = (questions.data ?? []).filter((q) => q.active)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Alert tone="info">
|
||||
Tak zobaczy sprawdzian kursant: bez zaznaczonych odpowiedzi poprawnych. Losowana jest{' '}
|
||||
<strong>{quiz.questionCount ?? quiz.questionsActive}</strong> z {active.length} aktywnych
|
||||
pytań, próg zaliczenia to <strong>{quiz.effectiveThreshold}%</strong>.
|
||||
</Alert>
|
||||
|
||||
{active.length === 0 && (
|
||||
<EmptyState icon="file-text" title="Brak aktywnych pytań" description="Dodaj pytania." />
|
||||
)}
|
||||
|
||||
<ol className="space-y-3">
|
||||
{active.map((question, index) => (
|
||||
<li key={question.id} className="rounded-md border border-slate-100 p-3">
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{index + 1}. {question.content}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{question.answers.map((answer) => (
|
||||
<li key={answer.id} className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<span className="h-4 w-4 shrink-0 rounded-full border-2 border-slate-300" />
|
||||
{answer.content}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- wspolne ---
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-200 p-3 transition-colors duration-fast ease-standard hover:bg-slate-25">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
|
||||
/>
|
||||
<span className="min-w-0 text-sm leading-relaxed text-slate-700">
|
||||
<strong className="text-slate-900">{title}</strong>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">{description}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function IconAction({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
icon: Parameters<typeof Icon>[0]['name']
|
||||
label: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-md text-slate-500 transition duration-fast ease-standard hover:bg-slate-50 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-200 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user