396 lines
16 KiB
TypeScript
396 lines
16 KiB
TypeScript
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>
|
|
)
|
|
}
|