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,395 @@
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<RegistrationStatus | ''>('')
const [search, setSearch] = useState('')
/** Domyslnie najnowsze zapisy na gorze. */
const [sort, setSort] = useState<SortState>({ 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<Webinar>(`/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<Page<Registration>>(
`/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 (
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-2">
<Button variant="ghost" icon="arrow-left" onClick={() => navigate(panelPath('/webinary'))}>
Wróć
</Button>
<div>
<h1 className="text-xl font-semibold text-slate-900">
Zapisani {webinar.data ? `- ${webinar.data.title}` : ''}
</h1>
{webinar.data && (
<p className="text-sm text-slate-500">
{formatWithZone(webinar.data.startsAt, webinar.data.timezone)}
{' | '}
{webinar.data.registeredCount}
{webinar.data.capacity !== null
? ` z ${webinar.data.capacity} miejsc`
: ' zapisanych, bez limitu'}
</p>
)}
</div>
</div>
<Button icon="download" onClick={handleExport} disabled={exporting} variant="secondary">
{exporting ? 'Pobieranie...' : 'Eksport CSV'}
</Button>
</div>
<Card>
<div className="flex flex-wrap items-end gap-3">
<div className="w-full sm:w-72">
<label className="mb-1 block text-sm font-semibold text-slate-800">Szukaj</label>
<Input
value={search}
placeholder="Nazwisko, imię albo adres e-mail"
onChange={(e) => {
setSearch(e.target.value)
setPage(0)
}}
/>
</div>
<div className="w-full sm:w-56">
<label className="mb-1 block text-sm font-semibold text-slate-800">Status zapisu</label>
<Select value={status} onChange={(e) => changeStatus(e.target.value as RegistrationStatus | '')}>
<option value="">Wszystkie</option>
{(Object.keys(REGISTRATION_STATUS_LABEL) as RegistrationStatus[]).map((value) => (
<option key={value} value={value}>
{REGISTRATION_STATUS_LABEL[value]}
</option>
))}
</Select>
</div>
</div>
</Card>
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
{registrations.isPending && <Spinner />}
{registrations.isError && (
<div className="p-5">
<ErrorState message={errorMessage(registrations.error, 'Nie udało się wczytać listy zapisanych')} />
</div>
)}
{registrations.data && registrations.data.content.length === 0 && (
<EmptyState
icon="user-off"
title="Brak zapisanych"
description={status ? 'Zmień filtr statusu.' : 'Nikt jeszcze nie zapisał się na ten webinar.'}
/>
)}
{registrations.data && registrations.data.content.length > 0 && (
<>
{/* na telefonie karty, tabela dopiero od duzego ekranu */}
<ul className="divide-y divide-slate-100 lg:hidden">
{registrations.data.content.map((registration) => (
<li key={registration.id} className="space-y-2.5 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-slate-900">
{registration.firstName} {registration.lastName}
</p>
<p className="truncate text-sm text-slate-600">{registration.email}</p>
{registration.company && (
<p className="truncate text-xs text-slate-500">{registration.company}</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<Badge tone={REGISTRATION_STATUS_TONE[registration.status]}>
{REGISTRATION_STATUS_LABEL[registration.status]}
</Badge>
{registration.attended && (
<Badge tone="ok" dot>
Obecny
</Badge>
)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<p className="hx-data text-xs text-slate-500">
{formatWithZone(registration.createdAt, timezone)}
</p>
<RoomRoleToggle webinarId={Number(id)} registration={registration} />
</div>
<div className="flex flex-wrap items-center gap-3 text-xs">
{CONSENT_ORDER.map((type) => {
const consent = latestConsent(registration, type)
return (
<span key={type} className="inline-flex items-center gap-1 text-slate-600">
<Icon
name={consent?.granted ? 'check-circle' : 'x-circle'}
size={14}
className={consent?.granted ? 'text-ok-500' : 'text-slate-300'}
/>
{CONSENT_LABEL[type]}
</span>
)
})}
{registration.remindersSent > 0 && (
<span className="inline-flex items-center gap-1 text-slate-600">
<Icon name="send" size={14} className="text-ok-500" />
<span className="hx-data">{registration.remindersSent}</span>
</span>
)}
</div>
</li>
))}
</ul>
<div className="hidden overflow-x-auto lg:block">
<table className="w-full text-sm">
<thead className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<tr>
<SortableHeader field="lastName" sort={sort} onSort={changeSort}>
Uczestnik
</SortableHeader>
<SortableHeader field="email" sort={sort} onSort={changeSort}>
Kontakt
</SortableHeader>
<th className="px-4 py-3 font-semibold">Status</th>
<SortableHeader field="attended" sort={sort} onSort={changeSort}>
Obecność
</SortableHeader>
<th className="px-4 py-3 font-semibold">Głos</th>
<SortableHeader field="createdAt" sort={sort} onSort={changeSort}>
Data zapisu
</SortableHeader>
<th className="px-4 py-3 text-center font-semibold">Przypomnienia</th>
{CONSENT_ORDER.map((type) => (
<th key={type} className="px-4 py-3 text-center font-semibold">
{CONSENT_LABEL[type]}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{registrations.data.content.map((registration) => (
<tr key={registration.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<p className="font-medium text-slate-900">
{registration.firstName} {registration.lastName}
</p>
{registration.company && (
<p className="text-xs text-slate-500">{registration.company}</p>
)}
</td>
<td className="px-4 py-3 text-slate-600">
<p>{registration.email}</p>
{registration.phone && <p className="text-xs text-slate-500">{registration.phone}</p>}
</td>
<td className="px-4 py-3">
<Badge tone={REGISTRATION_STATUS_TONE[registration.status]}>
{REGISTRATION_STATUS_LABEL[registration.status]}
</Badge>
</td>
<td className="px-4 py-3">
{registration.attended ? (
<Badge tone="ok" dot>
Obecny
</Badge>
) : (
<span className="text-xs text-slate-400">brak</span>
)}
</td>
<td className="px-4 py-3">
<RoomRoleToggle webinarId={Number(id)} registration={registration} />
</td>
<td className="whitespace-nowrap px-4 py-3 text-slate-600">
{formatWithZone(registration.createdAt, timezone)}
</td>
<td className="px-4 py-3 text-center">
{registration.remindersSent > 0 ? (
<span className="inline-flex items-center gap-1 text-sm text-slate-700">
<Icon name="send" size={16} className="text-ok-500" />
{registration.remindersSent}
</span>
) : (
<span className="text-slate-300">-</span>
)}
</td>
{CONSENT_ORDER.map((type) => {
const consent = latestConsent(registration, type)
return (
<td key={type} className="px-4 py-3 text-center">
{consent ? (
<div className="flex flex-col items-center">
<span title={consent.granted ? 'Zgoda udzielona' : 'Brak zgody'}>
<Icon
name={consent.granted ? 'check-circle' : 'x-circle'}
size={18}
className={consent.granted ? 'text-ok-500' : 'text-slate-300'}
/>
</span>
<span className="text-[10px] text-slate-400">w. {consent.contentVersion}</span>
</div>
) : (
<span className="text-slate-300">-</span>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</>
)}
{registrations.data && (
<Pagination
page={registrations.data.number}
totalPages={registrations.data.totalPages}
totalElements={registrations.data.totalElements}
onChange={setPage}
itemLabel="zapisanych"
/>
)}
</div>
</div>
)
}
/**
* 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 (
<button
type="button"
onClick={() => change.mutate(speaking ? 'VIEWER' : 'SPEAKER')}
disabled={change.isPending}
title={speaking ? 'Odbierz głos' : 'Daj głos (tylko dźwięk)'}
className={`inline-flex min-h-[36px] items-center gap-1.5 rounded-full px-2.5 text-xs transition-colors duration-fast ease-standard ${
speaking
? 'bg-ok-50 text-ok-600 hover:bg-ok-100'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
<Icon name={speaking ? 'check' : 'x'} size={14} />
{speaking ? 'ma głos' : 'słuchacz'}
</button>
)
}