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 = { 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('kursy') const [enrolling, setEnrolling] = useState(false) const key = ['student', contactId] const { data, isPending, isError, error } = useQuery({ queryKey: key, queryFn: async () => (await client.get(`/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 if (isError || !data) { return } const student = data.student return (
Kursanci

{student.firstName} {student.lastName}

{!student.accountEnabled && Konto wyłączone} {!student.emailVerified && Adres niepotwierdzony} {data.unsubscribedAt && Wypisany z marketingu}
{/* --- dane i dzialania --- */}
{data.notes && (

Notatki

{data.notes}

)}
{!student.emailVerified && ( )}

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.

{/* --- zakladki --- */} {tab === 'kursy' && } {tab === 'zgody' && } {tab === 'webinary' && } {tab === 'certyfikaty' && } {tab === 'zamowienia' && } {enrolling && ( setEnrolling(false)} onSaved={() => { setEnrolling(false) queryClient.invalidateQueries({ queryKey: key }) }} /> )}
) } function Row({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { return (
{label}
{value}
) } function EnrollmentsTab({ data }: { data: StudentDetail }) { if (data.enrollments.length === 0) { return ( ) } return (
    {data.enrollments.map((e) => (
  • {e.courseTitle} {ENROLLMENT_STATUS_LABEL[e.status]}
    Ukończono {e.completedRequired} z {e.totalRequired} {e.progressPercent}%

    Zapisany {formatLocal(e.enrolledAt)} {e.lastActivityAt && ` · ostatnia aktywność ${formatLocal(e.lastActivityAt)}`}

  • ))}
) } /** * 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 } return (
    {data.consents.map((consent) => (
  • {CONSENT_LABELS[consent.type] ?? consent.type}

    {consent.current && obowiązuje}

    wersja {consent.version} · {formatLocal(consent.grantedAt)}

    {consent.granted ? 'udzielona' : 'odmowa'}
  • ))}
) } function WebinarsTab({ data }: { data: StudentDetail }) { if (data.webinars.length === 0) { return } return (
    {data.webinars.map((w) => (
  • {w.title}

    {formatLocal(w.startsAt)}

    {w.attended ? ( obecny ) : ( nieobecny )}
  • ))}
) } function CertificatesTab({ data }: { data: StudentDetail }) { if (data.certificates.length === 0) { return } return (
    {data.certificates.map((c) => (
  • {c.subjectTitle}

    {c.forCourse ? 'kurs' : 'webinar'}

    nr {c.number} · {formatLocal(c.issuedAt)}

    {c.revoked ? ( unieważniony ) : ( Pobierz )}
  • ))}
) } function OrdersTab({ data }: { data: StudentDetail }) { if (data.orders.length === 0) { return } return (
    {data.orders.map((o) => (
  • {o.courseTitle ?? 'Kurs usunięty'}

    {o.orderNumber} · {formatLocal(o.createdAt)}

    {o.amount && ( {o.amount} {o.currency} )} {o.status}
  • ))}
) } /** * 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 ( } >
Ręczny zapis omija płatność i akceptację regulaminu. Kursant dostanie dostęp od razu.