1599c7b0c1
Deploy PROD / deploy (push) Successful in 25s
Panel pytal o potwierdzenie natywnym `window.confirm`. Takie okno wyglada
inaczej w kazdej przegladarce i w kazdym systemie, wyskakuje przy gornej
krawedzi zamiast na srodku i pokazuje nad trescia adres domeny ("Komunikat
ze strony ..."). W panelu, ktory poza tym jest spojnym interfejsem, czyta sie
to jak blad strony, a nie jak pytanie aplikacji.
CLAUDE.md wymagalo juz, zeby kazde okno modalne uzywalo komponentu `Modal`,
i tlumaczylo przy otwartym mikrofonie, dlaczego nie `window.confirm`. Regula
obowiazywala w jednym miejscu, a w 42 innych nie.
HOOK, A NIE OKNO W KAZDYM MIEJSCU Z OSOBNA
Potwierdzen jest ponad czterdziesci. Wlasny stan i wlasny `Modal` do kazdego
oznaczalyby kilkanascie linii na jedno pytanie i te sama pewnosc, ze predzej
czy pozniej ktores bedzie wygladalo inaczej. `useConfirm` oddaje funkcje
o ksztalcie zblizonym do `confirm`, wiec zamiana jest jednolinijkowa. Wynik
przychodzi obietnica, bo okno musi sie najpierw wyrenderowac.
TRESC MOWI, CO SIE STANIE
Przy okazji poprawione komunikaty: zamiast "Czy na pewno?" pytania nazywaja
obiekt i skutek. Plik zniknie ze skladu instancji, odbiorca straci dostep do
API, razem z banerem przepadnie jego statystyka wraz z liczbami. Etykieta
przycisku nazywa czynnosc ("Usun nagranie"), a nie mowi "OK".
NIEODWRACALNE WYROZNIONE, ODWRACALNE NIE
`destructive` daje czerwony przycisk i ostrzezenie "Tej operacji nie da sie
cofnac". Odwolanie webinaru i dezaktywacja konta go NIE dostaja: sa
odwracalne, a straszenie tam, gdzie nic nie przepada, rozmydla sygnal tam,
gdzie strata jest prawdziwa. Zgodnie z reguła, ze usuniecie webinaru ma
podpowiadac odwolanie jako lagodniejsza alternatywe.
Sprawdzone `npx tsc -b --force` (pelne typowanie bez cache) i `npm run build`.
Przy okazji poprawione dwa em dash sprzed tej zmiany, w `ApiKeyListPage`
i `ProviderPicker`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
860 lines
29 KiB
TypeScript
860 lines
29 KiB
TypeScript
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<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 confirm = useConfirm()
|
|
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={async () => {
|
|
const ok = await confirm({
|
|
title: 'Usunąć pytanie?',
|
|
message: (
|
|
<>
|
|
{/* tresc pytania bywa dluga: w oknie pokazujemy poczatek */}
|
|
Pytanie {index + 1}{' '}
|
|
<strong>
|
|
{question.content.length > 120
|
|
? `${question.content.slice(0, 120).trimEnd()}...`
|
|
: question.content}
|
|
</strong>{' '}
|
|
zniknie z quizu razem z odpowiedziami.
|
|
</>
|
|
),
|
|
confirmLabel: 'Usuń pytanie',
|
|
destructive: true,
|
|
})
|
|
if (ok) 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>
|
|
)
|
|
}
|