Initial commit

This commit is contained in:
2026-09-02 08:21:27 +02:00
commit 84eec0039e
609 changed files with 94880 additions and 0 deletions
@@ -0,0 +1,584 @@
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 { 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 [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={() => {
if (
window.confirm(
`Wysłać ${student.email} link do ustawienia nowego hasła? ` +
'Nie poznasz jego hasła: kursant ustawi je sam.',
)
) {
resetPassword.mutate()
}
}}
disabled={resetPassword.isPending}
>
Reset hasła
</Button>
<Button
variant={student.accountEnabled ? 'danger' : 'primary'}
icon={student.accountEnabled ? 'user-off' : 'check'}
onClick={() => {
const next = !student.accountEnabled
const question = next
? `Włączyć konto ${student.email}?`
: `Wyłączyć konto ${student.email}? Kursant straci możliwość zalogowania się, ` +
'ale jego zapisy, postęp i certyfikaty zostaną zachowane.'
if (window.confirm(question)) {
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>
)
}