Initial commit
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user