Files
akademiawodociagowca/frontend/src/pages/courses/StudentDetailPage.tsx
T
debian 1599c7b0c1
Deploy PROD / deploy (push) Successful in 25s
Potwierdzenia operacji jako okno panelu, nie okno przegladarki
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>
2026-09-04 14:13:16 +02:00

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>
)
}