import { useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { client, errorMessage } from '../../api/client' import type { ConsentType, Page, Registration, RegistrationStatus, Webinar } from '../../api/types' import { useToasts } from '../../components/Toasts' import { Badge, Button, Card, EmptyState, ErrorState, Input, Select, Spinner } from '../../components/ui' import { formatWithZone } from '../../lib/dates' import { Icon } from '../../components/Icon' import { PAGE_SIZE, Pagination } from '../../components/Pagination' import { SortableHeader, type SortState } from '../../components/SortableHeader' import { CONSENT_LABEL, REGISTRATION_STATUS_LABEL, REGISTRATION_STATUS_TONE, } from '../../lib/labels' import { panelPath } from '../../lib/routes' const CONSENT_ORDER: ConsentType[] = ['TERMS', 'MARKETING_EMAIL', 'MARKETING_SMS'] export function RegistrationListPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const toasts = useToasts() const [status, setStatus] = useState('') const [search, setSearch] = useState('') /** Domyslnie najnowsze zapisy na gorze. */ const [sort, setSort] = useState({ field: 'createdAt', direction: 'desc' }) const [page, setPage] = useState(0) const [exporting, setExporting] = useState(false) const webinar = useQuery({ queryKey: ['webinar', id], queryFn: async () => { const response = await client.get(`/admin/webinars/${id}`) return response.data }, }) const registrations = useQuery({ queryKey: ['registrations', id, status, search, sort.field, sort.direction, page], queryFn: async () => { const response = await client.get>( `/admin/webinars/${id}/registrations`, { params: { status: status || undefined, search: search || undefined, sort: sort.field, direction: sort.direction, page, size: PAGE_SIZE, }, }, ) return response.data }, }) /** Zmiana filtru cofa na pierwsza strone, inaczej wynik bywa pusty. */ function changeSort(next: SortState) { setSort(next) setPage(0) } function changeStatus(next: RegistrationStatus | '') { setStatus(next) setPage(0) } /** * Eksport idzie przez axios, a nie zwyklym linkiem, bo endpoint wymaga * naglowka Authorization. Plik pobieramy jako blob i zapisujemy po stronie przegladarki. */ async function handleExport() { setExporting(true) try { const response = await client.get(`/admin/webinars/${id}/registrations/export`, { responseType: 'blob', }) const url = URL.createObjectURL(response.data as Blob) const link = document.createElement('a') link.href = url link.download = `zapisy-${webinar.data?.slug ?? id}.csv` document.body.appendChild(link) link.click() link.remove() URL.revokeObjectURL(url) toasts.success('Plik CSV został pobrany') } catch (error) { toasts.error(errorMessage(error, 'Nie udało się pobrać pliku CSV')) } finally { setExporting(false) } } /** Najnowsza zgoda danego typu. Ponowny zapis tworzy nowe rekordy, stare zostaja. */ function latestConsent(registration: Registration, type: ConsentType) { return registration.consents .filter((consent) => consent.type === type) .sort((a, b) => b.grantedAt.localeCompare(a.grantedAt))[0] } const timezone = webinar.data?.timezone ?? 'Europe/Warsaw' return (

Zapisani {webinar.data ? `- ${webinar.data.title}` : ''}

{webinar.data && (

{formatWithZone(webinar.data.startsAt, webinar.data.timezone)} {' | '} {webinar.data.registeredCount} {webinar.data.capacity !== null ? ` z ${webinar.data.capacity} miejsc` : ' zapisanych, bez limitu'}

)}
{ setSearch(e.target.value) setPage(0) }} />
{registrations.isPending && } {registrations.isError && (
)} {registrations.data && registrations.data.content.length === 0 && ( )} {registrations.data && registrations.data.content.length > 0 && ( <> {/* na telefonie karty, tabela dopiero od duzego ekranu */}
    {registrations.data.content.map((registration) => (
  • {registration.firstName} {registration.lastName}

    {registration.email}

    {registration.company && (

    {registration.company}

    )}
    {REGISTRATION_STATUS_LABEL[registration.status]} {registration.attended && ( Obecny )}

    {formatWithZone(registration.createdAt, timezone)}

    {CONSENT_ORDER.map((type) => { const consent = latestConsent(registration, type) return ( {CONSENT_LABEL[type]} ) })} {registration.remindersSent > 0 && ( {registration.remindersSent} )}
  • ))}
Uczestnik Kontakt Obecność Data zapisu {CONSENT_ORDER.map((type) => ( ))} {registrations.data.content.map((registration) => ( {CONSENT_ORDER.map((type) => { const consent = latestConsent(registration, type) return ( ) })} ))}
Status Głos Przypomnienia {CONSENT_LABEL[type]}

{registration.firstName} {registration.lastName}

{registration.company && (

{registration.company}

)}

{registration.email}

{registration.phone &&

{registration.phone}

}
{REGISTRATION_STATUS_LABEL[registration.status]} {registration.attended ? ( Obecny ) : ( brak )} {formatWithZone(registration.createdAt, timezone)} {registration.remindersSent > 0 ? ( {registration.remindersSent} ) : ( - )} {consent ? (
w. {consent.contentVersion}
) : ( - )}
)} {registrations.data && ( )}
) } /** * Podniesienie i odebranie glosu uczestnikowi. * * SPEAKER publikuje **wylacznie dzwiek**: wideo uczestnika jest wylaczone * uprawnieniami w tokenie i nie da sie go wlaczyc z panelu. Zmiana dziala * przy nastepnym wejsciu do pokoju, bo token jest wydawany przy wejsciu. */ function RoomRoleToggle({ webinarId, registration, }: { webinarId: number registration: Registration }) { const queryClient = useQueryClient() const toasts = useToasts() const speaking = registration.roomRole === 'SPEAKER' const change = useMutation({ mutationFn: (role: 'VIEWER' | 'SPEAKER') => client.put(`/admin/webinars/${webinarId}/registrations/${registration.id}/room-role`, { role }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['registrations'] }) }, onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić roli')), }) return ( ) }