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>
599 lines
20 KiB
TypeScript
599 lines
20 KiB
TypeScript
import { useState } from 'react'
|
|
import { Link, useParams } from 'react-router-dom'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { client, errorMessage } from '../../api/client'
|
|
import type { Course, StudentDetail } from '../../api/types'
|
|
import { Icon } from '../../components/Icon'
|
|
import { Modal } from '../../components/Modal'
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
EmptyState,
|
|
ErrorState,
|
|
Field,
|
|
Select,
|
|
Spinner,
|
|
Textarea,
|
|
} from '../../components/ui'
|
|
import { useConfirm } from '../../components/Confirm'
|
|
import { useToasts } from '../../components/Toasts'
|
|
import { formatLocal } from '../../lib/dates'
|
|
import { ENROLLMENT_STATUS_LABEL, ENROLLMENT_STATUS_TONE } from './courseLabels'
|
|
import { panelPath } from '../../lib/routes'
|
|
|
|
/*
|
|
* Etykiety zgod. CONSENT_LABEL nie zna CONTACT_SALES, bo tamten enum opisuje
|
|
* zgody zapisu na webinar; tutaj przychodza wszystkie typy, wiec brakujaca
|
|
* nazwe pokazujemy wprost zamiast pustego miejsca.
|
|
*/
|
|
const CONSENT_LABELS: Record<string, string> = {
|
|
TERMS: 'Regulamin',
|
|
MARKETING_EMAIL: 'Marketing e-mail',
|
|
MARKETING_SMS: 'Marketing SMS',
|
|
CONTACT_SALES: 'Kontakt handlowy',
|
|
}
|
|
|
|
type Tab = 'kursy' | 'zgody' | 'webinary' | 'certyfikaty' | 'zamowienia'
|
|
|
|
/**
|
|
* Karta kursanta.
|
|
*
|
|
* CZEGO TU CELOWO NIE MA: podgladu hasla ani wejscia na konto kursanta.
|
|
* Haslo jest skrotem BCrypt i nie da sie go odczytac, a logowanie sie jako
|
|
* ktos inny sprawiloby, ze slad w dzienniku nie odroznia dzialania kursanta
|
|
* od dzialania administratora. Wsparcie ma tu reset hasla i ponowny link
|
|
* potwierdzajacy, czyli tyle, ile potrzeba, zeby odblokowac kogos bez
|
|
* przejmowania jego tozsamosci.
|
|
*/
|
|
export function StudentDetailPage() {
|
|
const { id } = useParams()
|
|
const contactId = Number(id)
|
|
const queryClient = useQueryClient()
|
|
const toasts = useToasts()
|
|
const confirm = useConfirm()
|
|
|
|
const [tab, setTab] = useState<Tab>('kursy')
|
|
const [enrolling, setEnrolling] = useState(false)
|
|
|
|
const key = ['student', contactId]
|
|
|
|
const { data, isPending, isError, error } = useQuery({
|
|
queryKey: key,
|
|
queryFn: async () =>
|
|
(await client.get<StudentDetail>(`/admin/academy/students/${contactId}`)).data,
|
|
enabled: Number.isFinite(contactId),
|
|
})
|
|
|
|
const setEnabled = useMutation({
|
|
mutationFn: async (value: boolean) => {
|
|
await client.post(`/admin/academy/students/${contactId}/enabled`, null, {
|
|
params: { value },
|
|
})
|
|
},
|
|
onSuccess: () => {
|
|
toasts.success('Zapisano')
|
|
queryClient.invalidateQueries({ queryKey: key })
|
|
queryClient.invalidateQueries({ queryKey: ['students'] })
|
|
},
|
|
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się zmienić stanu konta')),
|
|
})
|
|
|
|
const resendVerification = useMutation({
|
|
mutationFn: async () => {
|
|
await client.post(`/admin/academy/students/${contactId}/resend-verification`)
|
|
},
|
|
onSuccess: () => toasts.success('Link potwierdzający wysłany'),
|
|
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się wysłać linku')),
|
|
})
|
|
|
|
const resetPassword = useMutation({
|
|
mutationFn: async () => {
|
|
await client.post(`/admin/academy/students/${contactId}/password-reset`)
|
|
},
|
|
onSuccess: () => toasts.success('Link do ustawienia nowego hasła wysłany'),
|
|
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się wysłać linku')),
|
|
})
|
|
|
|
if (isPending) return <Spinner />
|
|
if (isError || !data) {
|
|
return <ErrorState message={errorMessage(error, 'Nie udało się wczytać kursanta')} />
|
|
}
|
|
|
|
const student = data.student
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Link
|
|
to={panelPath('/kursy/kursanci')}
|
|
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-blue-600"
|
|
>
|
|
<Icon name="arrow-left" size={16} />
|
|
Kursanci
|
|
</Link>
|
|
<div className="mt-1 flex flex-wrap items-center gap-3">
|
|
<h1 className="text-xl font-semibold text-slate-900">
|
|
{student.firstName} {student.lastName}
|
|
</h1>
|
|
{!student.accountEnabled && <Badge tone="alarm">Konto wyłączone</Badge>}
|
|
{!student.emailVerified && <Badge tone="warn">Adres niepotwierdzony</Badge>}
|
|
{data.unsubscribedAt && <Badge tone="neutral">Wypisany z marketingu</Badge>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* --- dane i dzialania --- */}
|
|
<Card title="Dane konta">
|
|
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
|
|
<Row label="Adres e-mail" value={student.email} mono />
|
|
<Row label="Telefon" value={student.phone || '-'} mono />
|
|
<Row label="Firma" value={student.company || '-'} />
|
|
<Row label="Rejestracja" value={formatLocal(student.registeredAt)} mono />
|
|
<Row
|
|
label="Ostatnie logowanie"
|
|
value={student.lastLoginAt ? formatLocal(student.lastLoginAt) : 'nigdy'}
|
|
mono
|
|
/>
|
|
<Row
|
|
label="Adres potwierdzony"
|
|
value={student.emailVerified ? 'tak' : 'nie'}
|
|
/>
|
|
</dl>
|
|
|
|
{data.notes && (
|
|
<div className="mt-4 rounded-md border border-slate-100 bg-slate-25 p-3">
|
|
<p className="hx-eyebrow mb-1">Notatki</p>
|
|
<p className="text-sm text-slate-700">{data.notes}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-5 flex flex-wrap gap-2">
|
|
<Button
|
|
variant="secondary"
|
|
icon="user-plus"
|
|
onClick={() => setEnrolling(true)}
|
|
>
|
|
Zapisz na kurs
|
|
</Button>
|
|
|
|
{!student.emailVerified && (
|
|
<Button
|
|
variant="secondary"
|
|
icon="mail"
|
|
onClick={() => resendVerification.mutate()}
|
|
disabled={resendVerification.isPending}
|
|
>
|
|
Wyślij link potwierdzający
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
variant="secondary"
|
|
icon="key"
|
|
onClick={async () => {
|
|
const ok = await confirm({
|
|
title: 'Wysłać link do ustawienia nowego hasła?',
|
|
message: (
|
|
<>
|
|
Link trafi na adres <strong>{student.email}</strong>. Nie poznasz jego hasła:
|
|
kursant ustawi je sam.
|
|
</>
|
|
),
|
|
confirmLabel: 'Wyślij link',
|
|
})
|
|
if (ok) resetPassword.mutate()
|
|
}}
|
|
disabled={resetPassword.isPending}
|
|
>
|
|
Reset hasła
|
|
</Button>
|
|
|
|
<Button
|
|
variant={student.accountEnabled ? 'danger' : 'primary'}
|
|
icon={student.accountEnabled ? 'user-off' : 'check'}
|
|
onClick={async () => {
|
|
const next = !student.accountEnabled
|
|
const question = next ? (
|
|
<>
|
|
Konto <strong>{student.email}</strong> odzyska możliwość zalogowania się.
|
|
</>
|
|
) : (
|
|
<>
|
|
Konto <strong>{student.email}</strong> straci możliwość zalogowania się, ale jego
|
|
zapisy, postęp i certyfikaty zostaną zachowane.
|
|
</>
|
|
)
|
|
const ok = await confirm({
|
|
title: next ? 'Włączyć konto kursanta?' : 'Wyłączyć konto kursanta?',
|
|
message: question,
|
|
confirmLabel: next ? 'Włącz konto' : 'Wyłącz konto',
|
|
})
|
|
if (ok) setEnabled.mutate(next)
|
|
}}
|
|
disabled={setEnabled.isPending}
|
|
>
|
|
{student.accountEnabled ? 'Wyłącz konto' : 'Włącz konto'}
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="mt-3 text-xs leading-relaxed text-slate-500">
|
|
Hasło jest przechowywane jako skrót i nie da się go odczytać. Nie ma też możliwości
|
|
zalogowania się jako kursant: dziennik musi odróżniać jego działania od Twoich.
|
|
</p>
|
|
</Card>
|
|
|
|
{/* --- zakladki --- */}
|
|
<nav
|
|
role="tablist"
|
|
aria-label="Zakładki kursanta"
|
|
className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1"
|
|
>
|
|
{(
|
|
[
|
|
['kursy', 'Kursy', 'graduation-cap', data.enrollments.length],
|
|
['zgody', 'Zgody', 'file-text', data.consents.length],
|
|
['webinary', 'Webinary', 'presentation', data.webinars.length],
|
|
['certyfikaty', 'Certyfikaty', 'award', data.certificates.length],
|
|
['zamowienia', 'Zamówienia', 'credit-card', data.orders.length],
|
|
] 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 > 0 && <span className="hx-data text-xs text-slate-400">{count}</span>}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
{tab === 'kursy' && <EnrollmentsTab data={data} />}
|
|
{tab === 'zgody' && <ConsentsTab data={data} />}
|
|
{tab === 'webinary' && <WebinarsTab data={data} />}
|
|
{tab === 'certyfikaty' && <CertificatesTab data={data} />}
|
|
{tab === 'zamowienia' && <OrdersTab data={data} />}
|
|
|
|
{enrolling && (
|
|
<ManualEnrollModal
|
|
contactId={contactId}
|
|
studentName={`${student.firstName} ${student.lastName}`}
|
|
onClose={() => setEnrolling(false)}
|
|
onSaved={() => {
|
|
setEnrolling(false)
|
|
queryClient.invalidateQueries({ queryKey: key })
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Row({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
|
return (
|
|
<div>
|
|
<dt className="hx-eyebrow">{label}</dt>
|
|
<dd className={`mt-0.5 text-sm text-slate-800 ${mono ? 'hx-data' : ''}`}>{value}</dd>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EnrollmentsTab({ data }: { data: StudentDetail }) {
|
|
if (data.enrollments.length === 0) {
|
|
return (
|
|
<EmptyState
|
|
icon="graduation-cap"
|
|
title="Brak zapisów na kursy"
|
|
description="Możesz zapisać kursanta ręcznie, podając uzasadnienie."
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Card title="Kursy">
|
|
<ul className="space-y-3">
|
|
{data.enrollments.map((e) => (
|
|
<li key={e.enrollmentId} className="rounded-md border border-slate-100 p-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<Link
|
|
to={panelPath(`/kursy/${e.courseId}`)}
|
|
className="text-sm font-medium text-slate-900 hover:text-blue-600"
|
|
>
|
|
{e.courseTitle}
|
|
</Link>
|
|
<Badge tone={ENROLLMENT_STATUS_TONE[e.status]}>
|
|
{ENROLLMENT_STATUS_LABEL[e.status]}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="mt-2 max-w-md">
|
|
<div className="mb-1 flex items-center justify-between text-xs text-slate-600">
|
|
<span>
|
|
Ukończono {e.completedRequired} z {e.totalRequired}
|
|
</span>
|
|
<span className="hx-data font-medium text-slate-900">{e.progressPercent}%</span>
|
|
</div>
|
|
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
|
|
<div
|
|
className="h-full rounded-full bg-blue-500"
|
|
style={{ width: `${e.progressPercent}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="hx-data mt-2 text-xs text-slate-500">
|
|
Zapisany {formatLocal(e.enrolledAt)}
|
|
{e.lastActivityAt && ` · ostatnia aktywność ${formatLocal(e.lastActivityAt)}`}
|
|
</p>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Zgody z historia.
|
|
*
|
|
* Zgody sa dopisywane, nigdy nadpisywane, wiec kursant ma ich kilka na ten sam
|
|
* typ. Pokazujemy wszystkie, bo to slad rozliczalny, a obowiazujaca oznaczamy:
|
|
* bez tego nie da sie odczytac stanu biezacego z samej listy.
|
|
*/
|
|
function ConsentsTab({ data }: { data: StudentDetail }) {
|
|
if (data.consents.length === 0) {
|
|
return <EmptyState icon="file-text" title="Brak zapisanych zgód" />
|
|
}
|
|
|
|
return (
|
|
<Card
|
|
title="Zgody"
|
|
description="Pełna historia. Zgody są dopisywane, nigdy nadpisywane, więc obowiązuje najnowsza każdego typu."
|
|
>
|
|
<ul className="space-y-2">
|
|
{data.consents.map((consent) => (
|
|
<li
|
|
key={consent.id}
|
|
className={`flex flex-wrap items-center justify-between gap-2 rounded-md border p-3 ${
|
|
consent.current ? 'border-slate-200 bg-white' : 'border-slate-100 bg-slate-25'
|
|
}`}
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<p className="text-sm font-medium text-slate-900">
|
|
{CONSENT_LABELS[consent.type] ?? consent.type}
|
|
</p>
|
|
{consent.current && <Badge tone="info">obowiązuje</Badge>}
|
|
</div>
|
|
<p className="hx-data mt-0.5 text-xs text-slate-500">
|
|
wersja {consent.version} · {formatLocal(consent.grantedAt)}
|
|
</p>
|
|
</div>
|
|
<Badge tone={consent.granted ? 'ok' : 'neutral'}>
|
|
{consent.granted ? 'udzielona' : 'odmowa'}
|
|
</Badge>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function WebinarsTab({ data }: { data: StudentDetail }) {
|
|
if (data.webinars.length === 0) {
|
|
return <EmptyState icon="presentation" title="Brak zapisów na webinary" />
|
|
}
|
|
|
|
return (
|
|
<Card title="Webinary">
|
|
<ul className="space-y-2">
|
|
{data.webinars.map((w) => (
|
|
<li
|
|
key={w.webinarId}
|
|
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
|
|
>
|
|
<div className="min-w-0">
|
|
<Link
|
|
to={panelPath(`/webinary/${w.webinarId}`)}
|
|
className="text-sm font-medium text-slate-900 hover:text-blue-600"
|
|
>
|
|
{w.title}
|
|
</Link>
|
|
<p className="hx-data mt-0.5 text-xs text-slate-500">{formatLocal(w.startsAt)}</p>
|
|
</div>
|
|
{w.attended ? (
|
|
<Badge tone="ok">obecny</Badge>
|
|
) : (
|
|
<Badge tone="neutral">nieobecny</Badge>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function CertificatesTab({ data }: { data: StudentDetail }) {
|
|
if (data.certificates.length === 0) {
|
|
return <EmptyState icon="award" title="Brak certyfikatów" />
|
|
}
|
|
|
|
return (
|
|
<Card title="Certyfikaty">
|
|
<ul className="space-y-2">
|
|
{data.certificates.map((c) => (
|
|
<li
|
|
key={c.id}
|
|
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<p className="text-sm font-medium text-slate-900">{c.subjectTitle}</p>
|
|
<Badge tone={c.forCourse ? 'brand' : 'info'}>
|
|
{c.forCourse ? 'kurs' : 'webinar'}
|
|
</Badge>
|
|
</div>
|
|
<p className="hx-data mt-0.5 text-xs text-slate-500">
|
|
nr {c.number} · {formatLocal(c.issuedAt)}
|
|
</p>
|
|
</div>
|
|
{c.revoked ? (
|
|
<Badge tone="alarm">unieważniony</Badge>
|
|
) : (
|
|
<a
|
|
href={`/api/public/certificates/${c.id}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-blue-600 hover:text-blue-700"
|
|
>
|
|
Pobierz
|
|
</a>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
function OrdersTab({ data }: { data: StudentDetail }) {
|
|
if (data.orders.length === 0) {
|
|
return <EmptyState icon="credit-card" title="Brak zamówień" />
|
|
}
|
|
|
|
return (
|
|
<Card title="Zamówienia">
|
|
<ul className="space-y-2">
|
|
{data.orders.map((o) => (
|
|
<li
|
|
key={o.id}
|
|
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
|
|
>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-slate-900">{o.courseTitle ?? 'Kurs usunięty'}</p>
|
|
<p className="hx-data mt-0.5 text-xs text-slate-500">
|
|
{o.orderNumber} · {formatLocal(o.createdAt)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{o.amount && (
|
|
<span className="hx-data text-sm text-slate-700">
|
|
{o.amount} {o.currency}
|
|
</span>
|
|
)}
|
|
<Badge tone={o.status === 'PAID' ? 'ok' : 'neutral'}>{o.status}</Badge>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Reczne zapisanie na kurs.
|
|
*
|
|
* NOTATKA JEST WYMAGANA. Zapis z pominieciem platnosci to decyzja, ktora po
|
|
* miesiacu musi dac sie wyjasnic: bez uzasadnienia nie wiadomo, czy kursant
|
|
* zaplacil przelewem, czy dostal dostep przez pomylke.
|
|
*/
|
|
function ManualEnrollModal({
|
|
contactId,
|
|
studentName,
|
|
onClose,
|
|
onSaved,
|
|
}: {
|
|
contactId: number
|
|
studentName: string
|
|
onClose: () => void
|
|
onSaved: () => void
|
|
}) {
|
|
const toasts = useToasts()
|
|
const [courseId, setCourseId] = useState('')
|
|
const [note, setNote] = useState('')
|
|
|
|
const courses = useQuery({
|
|
queryKey: ['courses', 'for-enroll'],
|
|
queryFn: async () =>
|
|
(
|
|
await client.get<{ content: Course[] }>('/admin/courses', {
|
|
params: { size: 100 },
|
|
})
|
|
).data.content,
|
|
})
|
|
|
|
const save = useMutation({
|
|
mutationFn: async () => {
|
|
await client.post(`/admin/academy/students/${contactId}/enroll`, {
|
|
courseId: Number(courseId),
|
|
note,
|
|
})
|
|
},
|
|
onSuccess: () => {
|
|
toasts.success('Kursant zapisany na kurs')
|
|
onSaved()
|
|
},
|
|
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się zapisać na kurs')),
|
|
})
|
|
|
|
return (
|
|
<Modal
|
|
title={`Zapisz na kurs: ${studentName}`}
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<Button variant="secondary" onClick={onClose}>
|
|
Anuluj
|
|
</Button>
|
|
<Button
|
|
icon="check"
|
|
onClick={() => save.mutate()}
|
|
disabled={!courseId || !note.trim() || save.isPending}
|
|
>
|
|
{save.isPending ? 'Zapisywanie...' : 'Zapisz na kurs'}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-4">
|
|
<Alert tone="warn" icon="alert-triangle">
|
|
Ręczny zapis omija płatność i akceptację regulaminu. Kursant dostanie dostęp od razu.
|
|
</Alert>
|
|
|
|
<Field label="Kurs">
|
|
<Select value={courseId} onChange={(e) => setCourseId(e.target.value)}>
|
|
<option value="">Wybierz kurs</option>
|
|
{(courses.data ?? [])
|
|
.filter((c) => c.status !== 'DRAFT')
|
|
.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.title}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</Field>
|
|
|
|
<Field
|
|
label="Uzasadnienie"
|
|
hint="Trafi do dziennika. Bez niego po miesiącu nie da się ustalić, dlaczego kursant dostał dostęp."
|
|
>
|
|
<Textarea
|
|
rows={3}
|
|
value={note}
|
|
onChange={(e) => setNote(e.target.value)}
|
|
placeholder="np. opłacone przelewem tradycyjnym, faktura FV/2026/14"
|
|
/>
|
|
</Field>
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|