Initial commit
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { LineChart } from '@mui/x-charts/LineChart'
|
||||
import { client, errorMessage } from '../api/client'
|
||||
import type { Dashboard } from '../api/types'
|
||||
import { Icon } from '../components/Icon'
|
||||
import { Card, ErrorState, Spinner } from '../components/ui'
|
||||
import { formatWithZone } from '../lib/dates'
|
||||
import { WEBINAR_STATUS_LABEL } from '../lib/labels'
|
||||
import { panelPath } from '../lib/routes'
|
||||
|
||||
/**
|
||||
* Ekran glowny panelu.
|
||||
*
|
||||
* Pierwsze, co widzi zalogowany uzytkownik, wiec pokazuje wylacznie rzeczy,
|
||||
* na ktore mozna zareagowac: co sie zbliza i co czeka na obsluge. Nie ma tu
|
||||
* liczb dla samych liczb.
|
||||
*/
|
||||
|
||||
const HYDRO_AZURE = '#2C8ABD'
|
||||
|
||||
export function DashboardPage() {
|
||||
const query = useQuery({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Dashboard>('/admin/dashboard')
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
if (query.isPending) return <Spinner />
|
||||
if (query.isError) {
|
||||
return <ErrorState message={errorMessage(query.error, 'Nie udało się wczytać podsumowania')} />
|
||||
}
|
||||
|
||||
const data = query.data
|
||||
const statuses = data.webinarsByStatus
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Podsumowanie</h1>
|
||||
<p className="text-sm text-slate-500">Co się zbliża i co czeka na obsługę</p>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Nieobsluzone zapytania na samej gorze i wyroznione: to jedyna pozycja
|
||||
na tym ekranie, ktora oznacza zalegla prace.
|
||||
*/}
|
||||
{data.pendingOffers > 0 && (
|
||||
<Link
|
||||
to={panelPath('/zapytania')}
|
||||
className="flex items-center gap-4 rounded-lg border border-warn-500/30 bg-warn-50 p-4 transition-colors duration-fast ease-standard hover:bg-warn-50/70"
|
||||
>
|
||||
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-warn-500/15">
|
||||
<Icon name="send" size={22} className="text-warn-600" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="hx-data block text-2xl font-semibold text-warn-600">
|
||||
{data.pendingOffers}
|
||||
</span>
|
||||
<span className="block text-sm text-slate-700">
|
||||
{data.pendingOffers === 1
|
||||
? 'zapytanie ofertowe czeka na obsługę'
|
||||
: 'zapytań ofertowych czeka na obsługę'}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="chevron-right" size={20} className="shrink-0 text-warn-600" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card title="Najbliższy webinar">
|
||||
{data.upcoming ? (
|
||||
<UpcomingCard upcoming={data.upcoming} />
|
||||
) : (
|
||||
<p className="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
|
||||
Nie zaplanowano żadnego webinaru.{' '}
|
||||
<Link to={panelPath('/webinary/nowy')} className="font-medium text-blue-700 hover:underline">
|
||||
Dodaj pierwszy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Webinary">
|
||||
<dl className="divide-y divide-slate-100">
|
||||
{(['PUBLISHED', 'DRAFT', 'FINISHED', 'CANCELLED'] as const).map((status) => (
|
||||
<div key={status} className="flex items-center justify-between py-2">
|
||||
<dt className="text-sm text-slate-600">{WEBINAR_STATUS_LABEL[status]}</dt>
|
||||
<dd className="hx-data font-medium text-slate-900">{statuses[status] ?? 0}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<Link
|
||||
to={panelPath('/webinary')}
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-blue-700 hover:underline"
|
||||
>
|
||||
<Icon name="presentation" size={16} />
|
||||
Pełna lista
|
||||
</Link>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Zapisy z ostatnich 30 dni" description="Liczba nowych zapisów dziennie">
|
||||
{data.dailyRegistrations.every((d) => d.count === 0) ? (
|
||||
<p className="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
|
||||
W ostatnich 30 dniach nikt się nie zapisał.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<LineChart
|
||||
height={260}
|
||||
series={[
|
||||
{
|
||||
data: data.dailyRegistrations.map((d) => d.count),
|
||||
label: 'Zapisy',
|
||||
color: HYDRO_AZURE,
|
||||
area: true,
|
||||
showMark: false,
|
||||
},
|
||||
]}
|
||||
xAxis={[
|
||||
{
|
||||
scaleType: 'point',
|
||||
data: data.dailyRegistrations.map((d) => formatDay(d.day)),
|
||||
},
|
||||
]}
|
||||
yAxis={[{ min: 0 }]}
|
||||
margin={{ left: 40, right: 16, top: 16, bottom: 40 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Najblizszy webinar wraz z odliczaniem. */
|
||||
function UpcomingCard({ upcoming }: { upcoming: NonNullable<Dashboard['upcoming']> }) {
|
||||
const [countdown, setCountdown] = useState(() => describeCountdown(upcoming.startsAt))
|
||||
|
||||
useEffect(() => {
|
||||
// odliczanie odswiezane co minute: sekundy przy odleglosci dni nic nie wnosza
|
||||
const timer = window.setInterval(() => {
|
||||
setCountdown(describeCountdown(upcoming.startsAt))
|
||||
}, 60_000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [upcoming.startsAt])
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Link
|
||||
to={panelPath(`/webinary/${upcoming.id}`)}
|
||||
className="font-medium text-slate-900 hover:text-blue-700 hover:underline"
|
||||
>
|
||||
{upcoming.title}
|
||||
</Link>
|
||||
<p className="hx-data mt-0.5 text-xs text-slate-500">
|
||||
{formatWithZone(upcoming.startsAt, upcoming.timezone)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-blue-50 p-3">
|
||||
<p className="hx-eyebrow text-blue-700">Rozpocznie się za</p>
|
||||
<p className="hx-data mt-0.5 text-xl font-semibold text-blue-700">{countdown}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<Icon name="users" size={16} className="text-slate-400" />
|
||||
<span className="hx-data font-medium text-slate-900">{upcoming.registered}</span>
|
||||
<span>
|
||||
{upcoming.capacity !== null ? `z ${upcoming.capacity} miejsc` : 'zapisanych, bez limitu'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ile zostalo do startu, zwyklym zdaniem.
|
||||
*
|
||||
* Bez sekund: przy odleglosci liczonej w dniach sekundy sa szumem, a przy
|
||||
* odleglosci minut i tak wystarcza dokladnosc do minuty.
|
||||
*/
|
||||
function describeCountdown(startsAt: string): string {
|
||||
const diffMs = new Date(startsAt).getTime() - Date.now()
|
||||
if (diffMs <= 0) return 'już trwa'
|
||||
|
||||
const minutes = Math.floor(diffMs / 60_000)
|
||||
if (minutes < 60) {
|
||||
return `${minutes} ${plural(minutes, 'minutę', 'minuty', 'minut')}`
|
||||
}
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) {
|
||||
const rest = minutes % 60
|
||||
const hourPart = `${hours} ${plural(hours, 'godzinę', 'godziny', 'godzin')}`
|
||||
return rest > 0 ? `${hourPart} i ${rest} min` : hourPart
|
||||
}
|
||||
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days} ${plural(days, 'dzień', 'dni', 'dni')}`
|
||||
}
|
||||
|
||||
/** Odmiana przez liczbę, wedlug reguly polskiej. */
|
||||
function plural(count: number, one: string, few: string, many: string): string {
|
||||
if (count === 1) return one
|
||||
const lastTwo = count % 100
|
||||
const last = count % 10
|
||||
if (lastTwo >= 12 && lastTwo <= 14) return many
|
||||
return last >= 2 && last <= 4 ? few : many
|
||||
}
|
||||
|
||||
/** Dzien i miesiac, bez roku: wykres obejmuje 30 dni. */
|
||||
function formatDay(iso: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('pl-PL', { day: '2-digit', month: '2-digit' })
|
||||
.format(new Date(iso))
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { client } from '../api/client'
|
||||
|
||||
type Health = {
|
||||
status: string
|
||||
version: string
|
||||
}
|
||||
|
||||
export function HealthPage() {
|
||||
const { data, isPending, isError, error } = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Health>('/health')
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-50 flex items-center justify-center p-6">
|
||||
<section className="w-full max-w-md rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h1 className="text-xl font-semibold text-slate-900">Platforma webinarowa</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">Stan połączenia z backendem</p>
|
||||
|
||||
<div className="mt-5">
|
||||
{isPending && (
|
||||
<p className="flex items-center gap-2 text-slate-600">
|
||||
<span className="material-symbols-outlined animate-spin">progress_activity</span>
|
||||
Sprawdzanie...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<p className="flex items-center gap-2 text-red-600">
|
||||
<span className="material-symbols-outlined">error</span>
|
||||
Backend niedostępny: {error instanceof Error ? error.message : 'nieznany błąd'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<dl className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-green-600">check_circle</span>
|
||||
<dt className="text-slate-500">Status:</dt>
|
||||
<dd className="font-medium text-slate-900">{data.status}</dd>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-slate-400">deployed_code</span>
|
||||
<dt className="text-slate-500">Wersja:</dt>
|
||||
<dd className="font-medium text-slate-900">{data.version}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { client, errorMessage } from '../api/client'
|
||||
import type { UnifiedLoginResponse } from '../api/types'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { useStudentAuth } from '../academy/StudentAuthContext'
|
||||
import { Alert, Button, Field, Input, PasswordInput } from '../components/ui'
|
||||
import { Icon } from '../components/Icon'
|
||||
import { BrandMark } from '../components/BrandMark'
|
||||
import { PANEL_HOME, STUDENT_ROUTES } from '../lib/routes'
|
||||
import { safeInternalPath } from '../lib/safePath'
|
||||
|
||||
/**
|
||||
* Jedno logowanie dla calej instancji.
|
||||
*
|
||||
* FRONT NIE ZGADUJE, DOKAD KIEROWAC.
|
||||
* ---------------------------------------------------------------------------
|
||||
* Formularz ma jedno pole adresu i jedno pole hasla. Po wyslaniu backend
|
||||
* (`POST /auth/login/unified`) sprawdza haslo **osobno** w kontach panelu
|
||||
* i w kontach kursantow, po czym odpowiada tym, co znalazl. Front czyta
|
||||
* obecnosc pol `staff` i `student`, a nie domysla sie po adresie ani po tym,
|
||||
* z jakiej strony uzytkownik przyszedl.
|
||||
*
|
||||
* Trafienie w oba swiaty naraz jest normalnym przypadkiem: administrator moze
|
||||
* byc zapisany na webinar i miec to samo haslo. Pokazujemy wtedy wybor,
|
||||
* zamiast decydowac za uzytkownika.
|
||||
*
|
||||
* POWROT DZIALA DLA OBU SWIATOW.
|
||||
* ---------------------------------------------------------------------------
|
||||
* `location.state.from` ustawia zarowno RequireAuth, jak i RequireStudent,
|
||||
* a takze przycisk zapisu na kurs. Wczesniej obsluga powrotu byla wylacznie
|
||||
* w formularzu panelu, wiec kursant, ktory kliknal zapis przed zalogowaniem,
|
||||
* ladowal na pulpicie zamiast wrocic na strone kursu.
|
||||
*/
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { adoptSession: adoptStaff } = useAuth()
|
||||
const { adoptSession: adoptStudent } = useStudentAuth()
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
|
||||
/** Cel powrotu. Wartosc ze stanu trasy jest danymi od uzytkownika. */
|
||||
const requested = safeInternalPath((location.state as { from?: string } | null)?.from, '')
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
setPending(true)
|
||||
try {
|
||||
const { data } = await client.post<UnifiedLoginResponse>('/auth/login/unified', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
// kazdy token trafia do wlasnego kontekstu, niezaleznie od drugiego
|
||||
if (data.staff) {
|
||||
adoptStaff(data.staff.token, data.staff.user)
|
||||
}
|
||||
if (data.student) {
|
||||
adoptStudent(data.student.token, data.student.student)
|
||||
}
|
||||
|
||||
/*
|
||||
* Konto w obu swiatach: pokazujemy wybor. Cel powrotu przekazujemy dalej,
|
||||
* zeby wybor "Mój pulpit" po kliknieciu zapisu na kurs nadal wracal na
|
||||
* strone kursu, a nie na pulpit.
|
||||
*/
|
||||
if (data.staff && data.student) {
|
||||
navigate(STUDENT_ROUTES.choice, { replace: true, state: { from: requested } })
|
||||
return
|
||||
}
|
||||
|
||||
/*
|
||||
* Jeden swiat: wracamy tam, skad uzytkownik przyszedl, a gdy przyszedl
|
||||
* wprost na logowanie, na ekran startowy wlasciwy dla tego konta.
|
||||
*/
|
||||
const fallback = data.staff ? PANEL_HOME : STUDENT_ROUTES.dashboard
|
||||
navigate(requested || fallback, { replace: true })
|
||||
} catch (e) {
|
||||
setError(errorMessage(e, 'Nie udało się zalogować'))
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-slate-50 p-6">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-6 flex flex-col items-center text-center">
|
||||
<Link to="/" className="rounded-md" aria-label="Strona główna">
|
||||
<BrandMark />
|
||||
</Link>
|
||||
<h1 className="mt-3 text-xl font-semibold text-slate-900">Zaloguj się</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
Jeden adres i hasło do szkoleń oraz do panelu
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
noValidate
|
||||
className="space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
{error && <Alert tone="alarm">{error}</Alert>}
|
||||
|
||||
<Field label="Adres e-mail">
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Hasło">
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="w-full"
|
||||
icon={pending ? undefined : 'log-in'}
|
||||
>
|
||||
{pending ? 'Logowanie...' : 'Zaloguj się'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/*
|
||||
Rejestracja i przypomnienie hasla dotycza WYLACZNIE kont kursantow.
|
||||
Mowimy to wprost, zeby nikt nie probowal zalozyc sobie tedy konta
|
||||
administracyjnego: konta panelu zaklada administrator instancji.
|
||||
*/}
|
||||
<div className="mt-5 space-y-3 rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
||||
<p className="text-slate-600">
|
||||
Nie masz konta?{' '}
|
||||
<Link
|
||||
to={STUDENT_ROUTES.register}
|
||||
state={requested ? { from: requested } : undefined}
|
||||
className="font-medium text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Załóż konto
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-slate-600">
|
||||
Nie pamiętasz hasła?{' '}
|
||||
<Link
|
||||
to={STUDENT_ROUTES.passwordReset}
|
||||
className="font-medium text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Przypomnij hasło
|
||||
</Link>
|
||||
</p>
|
||||
<p className="flex items-start gap-2 border-t border-slate-100 pt-3 text-xs leading-relaxed text-slate-500">
|
||||
<Icon name="circle-alert" size={14} className="mt-0.5 shrink-0" />
|
||||
<span>
|
||||
Rejestracja i przypomnienie hasła dotyczą kont uczestników szkoleń i webinarów.
|
||||
Konta do panelu administracyjnego zakłada administrator instancji.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Button } from '../components/ui'
|
||||
import { BrandMark } from '../components/BrandMark'
|
||||
import { Icon } from '../components/Icon'
|
||||
|
||||
/**
|
||||
* Nieznany adres.
|
||||
*
|
||||
* Wczesniej catch-all przekierowywal wszystko na "/", czyli na ekran logowania
|
||||
* do panelu: gosc, ktory pomylil litere w adresie kursu, dostawal formularz
|
||||
* administratora bez zadnego wyjasnienia. Strona 404 mowi wprost, co sie stalo,
|
||||
* i daje droge powrotna.
|
||||
*/
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-slate-50 p-6 text-center">
|
||||
<Link to="/" className="rounded-md" aria-label="Strona główna">
|
||||
<BrandMark />
|
||||
</Link>
|
||||
|
||||
<p className="hx-data mt-8 text-sm font-semibold uppercase tracking-caps text-slate-400">
|
||||
Błąd 404
|
||||
</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold text-slate-900">Nie ma takiej strony</h1>
|
||||
<p className="mt-3 max-w-md text-sm leading-relaxed text-slate-600">
|
||||
Adres jest nieprawidłowy albo strona została usunięta. Sprawdź, czy link jest pełny,
|
||||
albo wróć na stronę główną.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Link to="/">
|
||||
<Button icon="arrow-left">Strona główna</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 flex items-center gap-2 text-xs text-slate-500">
|
||||
<Icon name="circle-alert" size={14} />
|
||||
Jeśli trafiłeś tu z linku w wiadomości, sprawdź, czy nie został skrócony.
|
||||
</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Banner, BannerPlacement } from '../../api/types'
|
||||
import { ContentEditor } from '../../components/ContentEditor'
|
||||
import { sanitizeHtmlPreview } from '../../lib/sanitizeHtmlPreview'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { MediaField } from '../../components/MediaField'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Button, Field, Input, Select } from '../../components/ui'
|
||||
|
||||
/**
|
||||
* Formularz banera.
|
||||
*
|
||||
* DWA WARIANTY TRESCI, JEDEN NA RAZ
|
||||
* ------------------------------------------------------------------------
|
||||
* Baner ma grafike **albo** tresc tekstowa. Pozwolenie na oba naraz wymagaloby
|
||||
* decyzji, co wygrywa przy wyswietlaniu, a ta decyzja i tak bylaby arbitralna.
|
||||
* Przelacznik jest wiec jawny, zamiast zostawiac uzytkownika z dwoma polami
|
||||
* i domyslem.
|
||||
*
|
||||
* DWA WARIANTY OKNA CZASOWEGO, OBA OPCJONALNE
|
||||
* ------------------------------------------------------------------------
|
||||
* "Od kiedy do kiedy kampania trwa" i "w ktorej minucie prezentacji" to dwa
|
||||
* rozne pytania. Pierwsze ma sens przy banerze globalnym, drugie przy banerze
|
||||
* webinaru. Oba puste oznaczaja baner zawsze widoczny.
|
||||
*/
|
||||
|
||||
type ContentMode = 'media' | 'html'
|
||||
|
||||
const PLACEMENTS: { value: BannerPlacement; label: string; hint: string }[] = [
|
||||
{ value: 'SIDE', label: 'Panel boczny', hint: 'Nad zakładkami, na wąskim ekranie pod wideo' },
|
||||
{ value: 'BELOW_VIDEO', label: 'Pas pod wideo', hint: 'Na całej szerokości obrazu' },
|
||||
{ value: 'OVERLAY', label: 'Warstwa na obrazie', hint: 'W rogu wideo, uczestnik może ją zamknąć' },
|
||||
]
|
||||
|
||||
export function BannerFormDialog({
|
||||
banner,
|
||||
webinarId,
|
||||
offerTopics,
|
||||
onClose,
|
||||
}: {
|
||||
/** Null oznacza nowy baner. */
|
||||
banner: Banner | null
|
||||
/** Null oznacza baner globalny. */
|
||||
webinarId: number | null
|
||||
/** Tematy zapytan tego webinaru. Puste przy banerze globalnym. */
|
||||
offerTopics: { id: number; name: string }[]
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const isEdit = Boolean(banner)
|
||||
|
||||
const [title, setTitle] = useState(banner?.title ?? '')
|
||||
const [mode, setMode] = useState<ContentMode>(banner?.contentHtml ? 'html' : 'media')
|
||||
const [mediaId, setMediaId] = useState(banner?.mediaId ? String(banner.mediaId) : '')
|
||||
const [contentHtml, setContentHtml] = useState(banner?.contentHtml ?? '')
|
||||
const [targetUrl, setTargetUrl] = useState(banner?.targetUrl ?? '')
|
||||
const [offerTopicId, setOfferTopicId] = useState(
|
||||
banner?.offerTopicId ? String(banner.offerTopicId) : '',
|
||||
)
|
||||
const [placement, setPlacement] = useState<BannerPlacement>(banner?.placement ?? 'SIDE')
|
||||
const [startsAt, setStartsAt] = useState(toInput(banner?.startsAt))
|
||||
const [endsAt, setEndsAt] = useState(toInput(banner?.endsAt))
|
||||
const [showFrom, setShowFrom] = useState(banner?.showFromMinute?.toString() ?? '')
|
||||
const [showTo, setShowTo] = useState(banner?.showToMinute?.toString() ?? '')
|
||||
const [sortOrder, setSortOrder] = useState(banner?.sortOrder?.toString() ?? '0')
|
||||
const [active, setActive] = useState(banner?.active ?? true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
title,
|
||||
// wysylamy tylko ten wariant tresci, ktory jest wybrany: inaczej
|
||||
// przelaczenie na grafike zostawialoby w bazie stara tresc tekstowa
|
||||
mediaId: mode === 'media' && mediaId ? Number(mediaId) : null,
|
||||
contentHtml: mode === 'html' ? contentHtml : null,
|
||||
targetUrl: targetUrl.trim() || null,
|
||||
offerTopicId: offerTopicId ? Number(offerTopicId) : null,
|
||||
placement,
|
||||
startsAt: startsAt ? new Date(startsAt).toISOString() : null,
|
||||
endsAt: endsAt ? new Date(endsAt).toISOString() : null,
|
||||
showFromMinute: showFrom === '' ? null : Number(showFrom),
|
||||
showToMinute: showTo === '' ? null : Number(showTo),
|
||||
sortOrder: Number(sortOrder) || 0,
|
||||
active,
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
return client.put(`/admin/banners/${banner!.id}`, payload)
|
||||
}
|
||||
return webinarId
|
||||
? client.post(`/admin/webinars/${webinarId}/banners`, payload)
|
||||
: client.post('/admin/banners', payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toasts.success(isEdit ? 'Baner zapisany' : 'Baner został dodany')
|
||||
queryClient.invalidateQueries({ queryKey: ['banners'] })
|
||||
onClose()
|
||||
},
|
||||
onError: (e) => setError(errorMessage(e, 'Nie udało się zapisać banera')),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => client.delete(`/admin/banners/${banner!.id}`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Baner został usunięty')
|
||||
queryClient.invalidateQueries({ queryKey: ['banners'] })
|
||||
onClose()
|
||||
},
|
||||
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się usunąć banera')),
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isEdit ? 'Edycja banera' : 'Nowy baner'}
|
||||
description={
|
||||
webinarId
|
||||
? 'Baner pokazywany na tym webinarze.'
|
||||
: 'Baner globalny, pokazywany na każdym webinarze.'
|
||||
}
|
||||
onClose={onClose}
|
||||
wide
|
||||
>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
save.mutate()
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<p className="rounded-md border border-alarm-500/20 bg-alarm-50 p-3 text-sm text-alarm-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Field label="Tytuł" hint="Widoczny w panelu i jako opis grafiki dla czytników ekranu">
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
</Field>
|
||||
|
||||
{/* wybor wariantu tresci jest jawny, zamiast zostawiac dwa pola i domysl */}
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-semibold text-slate-800">Treść banera</span>
|
||||
<div className="mb-3 flex gap-2">
|
||||
{(
|
||||
[
|
||||
['media', 'Grafika', 'image'],
|
||||
['html', 'Tekst', 'file-text'],
|
||||
] as const
|
||||
).map(([value, label, icon]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setMode(value)}
|
||||
className={`inline-flex min-h-[44px] items-center gap-2 rounded-md border px-3 text-sm transition-colors duration-fast ease-standard ${
|
||||
mode === value
|
||||
? 'border-blue-500 bg-blue-50 font-medium text-blue-700'
|
||||
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === 'media' ? (
|
||||
<MediaField value={mediaId} onChange={setMediaId} />
|
||||
) : (
|
||||
<ContentEditor value={contentHtml} onChange={setContentHtml} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BannerPreview
|
||||
placement={placement}
|
||||
mediaId={mediaId}
|
||||
contentHtml={mode === 'html' ? contentHtml : null}
|
||||
mode={mode}
|
||||
/>
|
||||
|
||||
<Field label="Miejsce wyświetlenia">
|
||||
<Select
|
||||
value={placement}
|
||||
onChange={(e) => setPlacement(e.target.value as BannerPlacement)}
|
||||
>
|
||||
{PLACEMENTS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label} - {item.hint}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{/*
|
||||
Adres albo temat oferty, nie oba: baner nie moze jednoczesnie
|
||||
wychodzic na obcy serwis i otwierac formularza u nas.
|
||||
*/}
|
||||
<Field
|
||||
label="Adres docelowy"
|
||||
hint="Dozwolone wyłącznie http:// i https://. Zostaw puste, jeśli baner ma otwierać zapytanie ofertowe."
|
||||
>
|
||||
<Input
|
||||
type="url"
|
||||
value={targetUrl}
|
||||
onChange={(e) => setTargetUrl(e.target.value)}
|
||||
placeholder="https://przyklad.pl/oferta"
|
||||
disabled={Boolean(offerTopicId)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{webinarId && offerTopics.length > 0 && (
|
||||
<Field
|
||||
label="Albo: otwórz zapytanie ofertowe"
|
||||
hint="Kliknięcie otworzy formularz z tym tematem, bez opuszczania sali"
|
||||
>
|
||||
<Select
|
||||
value={offerTopicId}
|
||||
onChange={(e) => {
|
||||
setOfferTopicId(e.target.value)
|
||||
if (e.target.value) setTargetUrl('')
|
||||
}}
|
||||
>
|
||||
<option value="">Nie, baner prowadzi pod adres</option>
|
||||
{offerTopics.map((topic) => (
|
||||
<option key={topic.id} value={topic.id}>
|
||||
{topic.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border border-slate-200 bg-slate-25 p-4">
|
||||
<p className="mb-3 text-sm font-semibold text-slate-800">Kiedy pokazywać</p>
|
||||
<p className="mb-3 text-xs leading-relaxed text-slate-500">
|
||||
Puste pola oznaczają baner widoczny zawsze. Oba warunki obowiązują jednocześnie,
|
||||
gdy ustawisz oba.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Od (data i godzina)">
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={startsAt}
|
||||
onChange={(e) => setStartsAt(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Do (data i godzina)">
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={endsAt}
|
||||
onChange={(e) => setEndsAt(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Od minuty webinaru" hint="Np. 40 oznacza 40 minut po starcie">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={showFrom}
|
||||
onChange={(e) => setShowFrom(e.target.value)}
|
||||
placeholder="od początku"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Do minuty webinaru">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={showTo}
|
||||
onChange={(e) => setShowTo(e.target.value)}
|
||||
placeholder="do końca"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Kolejność" hint="Mniejsza liczba wyświetla się wcześniej w rotacji">
|
||||
<Input
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 self-end pb-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={(e) => setActive(e.target.checked)}
|
||||
className="h-4 w-4 accent-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">Baner aktywny</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-slate-100 pt-4">
|
||||
{isEdit && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Usunąć baner "${banner!.title}"?\n\n` +
|
||||
`Przepadnie także jego statystyka: ${banner!.impressions} wyświetleń ` +
|
||||
`i ${banner!.clicks} kliknięć. Tej operacji nie da się cofnąć.`,
|
||||
)
|
||||
) {
|
||||
remove.mutate()
|
||||
}
|
||||
}}
|
||||
>
|
||||
Usuń
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button type="submit" icon="save" disabled={save.isPending}>
|
||||
{save.isPending ? 'Zapisywanie...' : 'Zapisz'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Podglad w proporcji miejsca docelowego.
|
||||
*
|
||||
* Bez tego administrator dowiaduje sie, ze baner jest za wysoki albo urwany,
|
||||
* dopiero na zywym webinarze, gdy widzi go juz cala sala.
|
||||
*/
|
||||
function BannerPreview({
|
||||
placement,
|
||||
mediaId,
|
||||
contentHtml,
|
||||
mode,
|
||||
}: {
|
||||
placement: BannerPlacement
|
||||
mediaId: string
|
||||
contentHtml: string | null
|
||||
mode: ContentMode
|
||||
}) {
|
||||
const hasContent = mode === 'media' ? Boolean(mediaId) : Boolean(contentHtml?.trim())
|
||||
if (!hasContent) return null
|
||||
|
||||
// proporcje odpowiadaja faktycznym miejscom w sali
|
||||
const frame =
|
||||
placement === 'BELOW_VIDEO'
|
||||
? 'w-full max-w-2xl'
|
||||
: placement === 'OVERLAY'
|
||||
? 'w-56'
|
||||
: 'w-80'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-1.5 text-sm font-semibold text-slate-800">
|
||||
Podgląd w proporcji, w jakiej pojawi się w sali
|
||||
</p>
|
||||
<div className={`overflow-hidden rounded-lg border border-slate-200 bg-white ${frame}`}>
|
||||
{mode === 'media' ? (
|
||||
<img
|
||||
src={`/api/public/media/${mediaId}`}
|
||||
alt="Podgląd banera"
|
||||
className="block w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="prose prose-sm max-w-none p-3"
|
||||
// podglad renderuje stan edytora sprzed zapisu, wiec czyszczenie
|
||||
// robimy tu; zapisana tresc i tak przechodzi przez HtmlSanitizer
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHtmlPreview(contentHtml) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** ISO na wartosc pola datetime-local, ktore nie zna stref. */
|
||||
function toInput(iso: string | null | undefined): string {
|
||||
if (!iso) return ''
|
||||
const date = new Date(iso)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Banner } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Badge, Button, EmptyState, ErrorState, Spinner } from '../../components/ui'
|
||||
import { BannerFormDialog } from './BannerFormDialog'
|
||||
|
||||
/**
|
||||
* Lista banerow wraz ze skutecznoscia.
|
||||
*
|
||||
* TEN SAM KOMPONENT DLA BANEROW GLOBALNYCH I BANEROW WEBINARU
|
||||
* ------------------------------------------------------------------------
|
||||
* Parametryzowany webinarem, dokladnie tak jak `OfferSection`: obsluga banera
|
||||
* wyglada tak samo niezaleznie od drogi dojscia, a poprawka nie wymaga zmiany
|
||||
* w dwoch plikach. Przy banerze globalnym znika wybor tematu oferty, bo tematy
|
||||
* naleza do konkretnego webinaru.
|
||||
*
|
||||
* KLIKALNOSC LICZY BACKEND
|
||||
* ------------------------------------------------------------------------
|
||||
* Ten sam wzor ma obowiazywac tutaj i w statystykach webinaru, a dwa miejsca
|
||||
* liczenia to dwie okazje do rozjechania sie wynikow.
|
||||
*/
|
||||
|
||||
const PLACEMENT_LABEL: Record<Banner['placement'], string> = {
|
||||
SIDE: 'Panel boczny',
|
||||
BELOW_VIDEO: 'Pod wideo',
|
||||
OVERLAY: 'Na obrazie',
|
||||
}
|
||||
|
||||
export function BannerSection({ webinarId }: { webinarId?: number | null }) {
|
||||
const [editing, setEditing] = useState<Banner | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const banners = useQuery({
|
||||
queryKey: ['banners', webinarId ?? 'global'],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Banner[]>(
|
||||
webinarId ? `/admin/webinars/${webinarId}/banners` : '/admin/banners',
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
// tematy potrzebne wylacznie przy banerze webinaru
|
||||
const topics = useQuery({
|
||||
queryKey: ['offer-topics', webinarId],
|
||||
enabled: Boolean(webinarId),
|
||||
queryFn: async () => {
|
||||
const response = await client.get<{ id: number; name: string }[]>(
|
||||
`/admin/webinars/${webinarId}/offer-topics`,
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900">
|
||||
{webinarId ? 'Banery tego webinaru' : 'Banery globalne'}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{webinarId
|
||||
? 'Pokazywane w sali obok banerów globalnych.'
|
||||
: 'Pokazywane w sali każdego webinaru tej instancji.'}
|
||||
</p>
|
||||
</div>
|
||||
<Button icon="plus" onClick={() => setCreating(true)}>
|
||||
Nowy baner
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{banners.isPending && <Spinner />}
|
||||
{banners.isError && (
|
||||
<ErrorState message={errorMessage(banners.error, 'Nie udało się wczytać banerów')} />
|
||||
)}
|
||||
|
||||
{banners.data && banners.data.length === 0 && (
|
||||
<EmptyState
|
||||
icon="image"
|
||||
title="Brak banerów"
|
||||
description={
|
||||
webinarId
|
||||
? 'Ten webinar nie ma własnych banerów. Uczestnicy widzą tylko banery globalne.'
|
||||
: 'Nie ma jeszcze banerów globalnych.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{banners.data && banners.data.length > 0 && (
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white">
|
||||
{/* karty do lg, tabela od lg: zadnych tabel z min-w */}
|
||||
<ul className="divide-y divide-slate-100 lg:hidden">
|
||||
{banners.data.map((banner) => (
|
||||
<li key={banner.id} className="space-y-2 p-4">
|
||||
<BannerRow banner={banner} onEdit={() => setEditing(banner)} />
|
||||
<BannerNumbers banner={banner} />
|
||||
</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>
|
||||
<th className="px-4 py-3 font-semibold">Baner</th>
|
||||
<th className="px-4 py-3 font-semibold">Miejsce</th>
|
||||
<th className="px-4 py-3 font-semibold">Kiedy</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Wyświetlenia</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Kliknięcia</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Klikalność</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{banners.data.map((banner) => (
|
||||
<tr key={banner.id} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3">
|
||||
<BannerRow banner={banner} onEdit={() => setEditing(banner)} compact />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600">
|
||||
{PLACEMENT_LABEL[banner.placement]}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-slate-500">{describeWindow(banner)}</td>
|
||||
<td className="hx-data px-4 py-3 text-right text-slate-700">
|
||||
{banner.impressions}
|
||||
</td>
|
||||
<td className="hx-data px-4 py-3 text-right text-slate-700">{banner.clicks}</td>
|
||||
<td className="hx-data px-4 py-3 text-right font-medium text-slate-900">
|
||||
{banner.clickRate.toFixed(1)}%
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="pencil"
|
||||
onClick={() => setEditing(banner)}
|
||||
>
|
||||
Edytuj
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(creating || editing) && (
|
||||
<BannerFormDialog
|
||||
banner={editing}
|
||||
webinarId={webinarId ?? null}
|
||||
offerTopics={topics.data ?? []}
|
||||
onClose={() => {
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BannerRow({
|
||||
banner,
|
||||
onEdit,
|
||||
compact = false,
|
||||
}: {
|
||||
banner: Banner
|
||||
onEdit: () => void
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{banner.mediaUrl ? (
|
||||
<img
|
||||
src={banner.mediaUrl}
|
||||
alt=""
|
||||
className="h-10 w-16 shrink-0 rounded border border-slate-200 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-16 shrink-0 items-center justify-center rounded border border-slate-200 bg-slate-25 text-slate-400">
|
||||
<Icon name="file-text" size={16} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-slate-900">{banner.title}</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{!banner.active && <Badge tone="neutral">wyłączony</Badge>}
|
||||
{banner.offerTopicName && (
|
||||
<span className="truncate text-xs text-blue-700">
|
||||
zapytanie: {banner.offerTopicName}
|
||||
</span>
|
||||
)}
|
||||
{!banner.offerTopicName && banner.targetUrl && (
|
||||
<span className="hx-data truncate text-xs text-slate-400">{banner.targetUrl}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!compact && (
|
||||
<Button type="button" size="sm" variant="ghost" icon="pencil" onClick={onEdit}>
|
||||
Edytuj
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Liczby na karcie mobilnej: tabela od lg pokazuje je w kolumnach. */
|
||||
function BannerNumbers({ banner }: { banner: Banner }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3 text-xs text-slate-500">
|
||||
<span>
|
||||
Wyświetlenia: <strong className="hx-data text-slate-700">{banner.impressions}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Kliknięcia: <strong className="hx-data text-slate-700">{banner.clicks}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Klikalność:{' '}
|
||||
<strong className="hx-data text-slate-900">{banner.clickRate.toFixed(1)}%</strong>
|
||||
</span>
|
||||
<span>{describeWindow(banner)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Okno wyswietlania zwyklym zdaniem.
|
||||
*
|
||||
* Administrator nie ma czytac dwoch par dat i sam skladac z nich reguly:
|
||||
* to jest dokladnie ten format techniczny, ktorego w panelu nie ma byc.
|
||||
*/
|
||||
function describeWindow(banner: Banner): string {
|
||||
const parts: string[] = []
|
||||
|
||||
if (banner.startsAt || banner.endsAt) {
|
||||
const from = banner.startsAt ? new Date(banner.startsAt).toLocaleDateString('pl-PL') : null
|
||||
const to = banner.endsAt ? new Date(banner.endsAt).toLocaleDateString('pl-PL') : null
|
||||
if (from && to) parts.push(`${from} - ${to}`)
|
||||
else if (from) parts.push(`od ${from}`)
|
||||
else if (to) parts.push(`do ${to}`)
|
||||
}
|
||||
|
||||
if (banner.showFromMinute !== null || banner.showToMinute !== null) {
|
||||
const from = banner.showFromMinute
|
||||
const to = banner.showToMinute
|
||||
if (from !== null && to !== null) parts.push(`min. ${from}-${to} webinaru`)
|
||||
else if (from !== null) parts.push(`od ${from}. minuty`)
|
||||
else if (to !== null) parts.push(`do ${to}. minuty`)
|
||||
}
|
||||
|
||||
return parts.length === 0 ? 'zawsze' : parts.join(', ')
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BannerSection } from './BannerSection'
|
||||
|
||||
/**
|
||||
* Dzial banerow globalnych.
|
||||
*
|
||||
* Ekran zbiorczy istnieje **obok** zakladki na karcie webinaru, a nie zamiast
|
||||
* niej: banery instancji ustawia sie raz i dotycza wszystkich wydarzen, wiec
|
||||
* szukanie ich w konkretnym webinarze nie mialoby sensu. Ta sama zasada co przy
|
||||
* zapytaniach ofertowych, gdzie `/zapytania` zostaje dla handlowca.
|
||||
*
|
||||
* Oba miejsca pokazuja **ten sam komponent**, parametryzowany webinarem:
|
||||
* obsluga banera wyglada tak samo niezaleznie od drogi dojscia, a poprawka nie
|
||||
* wymaga zmiany w dwoch plikach.
|
||||
*/
|
||||
export function BannersPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl">Banery</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
Reklamy i komunikaty pokazywane uczestnikom w sali webinarowej
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BannerSection webinarId={null} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage, fieldErrors } from '../../api/client'
|
||||
import type { Contact, ContactGroup, ContactRequest } from '../../api/types'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Button, Field, Input, Textarea } from '../../components/ui'
|
||||
import { Modal } from '../../components/Modal'
|
||||
|
||||
/** Dodanie albo edycja kontaktu. */
|
||||
export function ContactFormDialog({
|
||||
contact,
|
||||
groups,
|
||||
onClose,
|
||||
}: {
|
||||
contact: Contact | null
|
||||
groups: ContactGroup[]
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const isEdit = contact !== null
|
||||
|
||||
const [form, setForm] = useState({
|
||||
email: contact?.email ?? '',
|
||||
firstName: contact?.firstName ?? '',
|
||||
lastName: contact?.lastName ?? '',
|
||||
phone: contact?.phone ?? '',
|
||||
company: contact?.company ?? '',
|
||||
position: contact?.position ?? '',
|
||||
notes: contact?.notes ?? '',
|
||||
active: contact?.active ?? true,
|
||||
marketingConsent: contact?.marketingConsent ?? false,
|
||||
groupIds: contact?.groupIds ?? [],
|
||||
})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (payload: ContactRequest) => {
|
||||
const response = isEdit
|
||||
? await client.put<Contact>(`/admin/contacts/${contact.id}`, payload)
|
||||
: await client.post<Contact>('/admin/contacts', payload)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toasts.success(isEdit ? 'Kontakt został zapisany' : 'Kontakt został dodany')
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['contact-groups'] })
|
||||
onClose()
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrors(fieldErrors(error))
|
||||
toasts.error(errorMessage(error, 'Nie udało się zapisać kontaktu'))
|
||||
},
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => client.delete(`/admin/contacts/${contact!.id}`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Kontakt został usunięty')
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
||||
onClose()
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć kontaktu')),
|
||||
})
|
||||
|
||||
function update<K extends keyof typeof form>(key: K, value: (typeof form)[K]) {
|
||||
setForm((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
function toggleGroup(groupId: number) {
|
||||
update(
|
||||
'groupIds',
|
||||
form.groupIds.includes(groupId)
|
||||
? form.groupIds.filter((x) => x !== groupId)
|
||||
: [...form.groupIds, groupId],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isEdit ? 'Edycja kontaktu' : 'Nowy kontakt'}
|
||||
onClose={onClose}
|
||||
description={
|
||||
contact?.unsubscribed ? 'Ten kontakt wypisał się z wiadomości marketingowych.' : undefined
|
||||
}
|
||||
>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
save.mutate({
|
||||
email: form.email.trim(),
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
phone: form.phone.trim() || null,
|
||||
company: form.company.trim() || null,
|
||||
position: form.position.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
active: form.active,
|
||||
marketingConsent: form.marketingConsent,
|
||||
groupIds: form.groupIds,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Field label="Adres e-mail" error={errors.email}>
|
||||
<Input
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={(e) => update('email', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Imię" error={errors.firstName}>
|
||||
<Input
|
||||
required
|
||||
value={form.firstName}
|
||||
onChange={(e) => update('firstName', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Nazwisko" error={errors.lastName}>
|
||||
<Input
|
||||
required
|
||||
value={form.lastName}
|
||||
onChange={(e) => update('lastName', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Telefon" error={errors.phone}>
|
||||
<Input value={form.phone} onChange={(e) => update('phone', e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Firma" error={errors.company}>
|
||||
<Input value={form.company} onChange={(e) => update('company', e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Stanowisko" error={errors.position}>
|
||||
<Input value={form.position} onChange={(e) => update('position', e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field label="Notatki" hint="Widoczne tylko w panelu">
|
||||
<Textarea rows={2} value={form.notes} onChange={(e) => update('notes', e.target.value)} />
|
||||
</Field>
|
||||
|
||||
{groups.length > 0 && (
|
||||
<Field label="Grupy">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
className={`min-h-[36px] rounded-full px-3 text-sm transition-colors duration-fast ease-standard ${
|
||||
form.groupIds.includes(group.id)
|
||||
? 'bg-blue-50 font-medium text-blue-700 ring-1 ring-inset ring-blue-200'
|
||||
: 'bg-slate-50 text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{group.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 rounded-md border border-slate-200 p-3">
|
||||
<label className="flex items-start gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 rounded border-slate-300"
|
||||
checked={form.marketingConsent}
|
||||
onChange={(e) => update('marketingConsent', e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-slate-700">
|
||||
Zgoda na informacje handlowe drogą elektroniczną
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
Bez niej kontakt nie dostanie zaproszenia. Zmiana zapisuje nowy wpis w historii
|
||||
zgód, poprzedni zostaje jako ślad.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 rounded border-slate-300"
|
||||
checked={form.active}
|
||||
onChange={(e) => update('active', e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-slate-700">
|
||||
Aktywny
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
Nieaktywny kontakt zostaje w bazie, ale nie trafia do nowych zaproszeń.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-slate-100 pt-4">
|
||||
{isEdit && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`Usunąć kontakt ${contact!.email} wraz z historią zgód?
|
||||
|
||||
` +
|
||||
'Tej operacji nie da się cofnąć.',
|
||||
)
|
||||
) {
|
||||
remove.mutate()
|
||||
}
|
||||
}}
|
||||
>
|
||||
Usuń
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button type="submit" icon="save" disabled={save.isPending}>
|
||||
{save.isPending ? 'Zapisywanie...' : 'Zapisz'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { ContactGroup, ImportMapping, ImportPreview, ImportResult } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Alert, Button, Select, Spinner } from '../../components/ui'
|
||||
import { Modal } from '../../components/Modal'
|
||||
|
||||
/**
|
||||
* Import kontaktow z pliku CSV.
|
||||
*
|
||||
* TRZY KROKI, NIE JEDEN
|
||||
* ------------------------------------------------------------------------
|
||||
* Wybor pliku, potem mapowanie kolumn z podgladem, dopiero na koncu import.
|
||||
*
|
||||
* Podglad jest tu najwazniejszy: pomylka w kolejnosci kolumn oznaczalaby setki
|
||||
* kontaktow z nazwiskiem w polu firmy, a odkrecanie tego recznie kosztuje
|
||||
* wielokrotnie wiecej niz jedno spojrzenie na piec przykladowych wierszy.
|
||||
*/
|
||||
|
||||
/** Pola, ktore da sie zmapowac. Kolejnosc jak w formularzu kontaktu. */
|
||||
const FIELDS: { key: keyof ImportMapping; label: string; required: boolean }[] = [
|
||||
{ key: 'email', label: 'Adres e-mail', required: true },
|
||||
{ key: 'firstName', label: 'Imię', required: false },
|
||||
{ key: 'lastName', label: 'Nazwisko', required: false },
|
||||
{ key: 'phone', label: 'Telefon', required: false },
|
||||
{ key: 'company', label: 'Firma', required: false },
|
||||
{ key: 'position', label: 'Stanowisko', required: false },
|
||||
]
|
||||
|
||||
export function ContactImportDialog({
|
||||
groups,
|
||||
onClose,
|
||||
}: {
|
||||
groups: ContactGroup[]
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [preview, setPreview] = useState<ImportPreview | null>(null)
|
||||
const [mapping, setMapping] = useState<ImportMapping | null>(null)
|
||||
const [groupId, setGroupId] = useState('')
|
||||
const [grantConsent, setGrantConsent] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [result, setResult] = useState<ImportResult | null>(null)
|
||||
|
||||
async function handleFile(selected: File) {
|
||||
setFile(selected)
|
||||
setBusy(true)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', selected)
|
||||
const response = await client.post<ImportPreview>('/admin/contacts/import/preview', form)
|
||||
setPreview(response.data)
|
||||
setMapping(response.data.suggested)
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się odczytać pliku'))
|
||||
setFile(null)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!file || !mapping) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const response = await client.post<ImportResult>('/admin/contacts/import', form, {
|
||||
params: {
|
||||
emailColumn: mapping.email,
|
||||
firstNameColumn: mapping.firstName,
|
||||
lastNameColumn: mapping.lastName,
|
||||
phoneColumn: mapping.phone,
|
||||
companyColumn: mapping.company,
|
||||
positionColumn: mapping.position,
|
||||
groupId: groupId || undefined,
|
||||
grantConsent,
|
||||
},
|
||||
timeout: 0,
|
||||
})
|
||||
setResult(response.data)
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['contact-groups'] })
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się zaimportować pliku'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
// --- raport po imporcie ---
|
||||
|
||||
if (result) {
|
||||
return (
|
||||
<Modal title="Import zakończony" onClose={onClose} wide>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Summary label="Dodane" value={result.added} tone="ok" />
|
||||
<Summary label="Zaktualizowane" value={result.updated} tone="info" />
|
||||
<Summary label="Pominięte" value={result.skipped.length} tone="warn" />
|
||||
</div>
|
||||
|
||||
{result.skipped.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-800">
|
||||
Wiersze pominięte wraz z powodem
|
||||
</h3>
|
||||
<div className="max-h-64 overflow-y-auto rounded-md border border-slate-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-100 bg-slate-25 text-left">
|
||||
<tr className="hx-eyebrow">
|
||||
<th className="px-3 py-2">Wiersz</th>
|
||||
<th className="px-3 py-2">Adres</th>
|
||||
<th className="px-3 py-2">Powód</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{result.skipped.map((row) => (
|
||||
<tr key={`${row.line}-${row.email}`}>
|
||||
<td className="hx-data px-3 py-2 text-slate-500">{row.line}</td>
|
||||
<td className="hx-data break-all px-3 py-2">{row.email || '-'}</td>
|
||||
<td className="px-3 py-2 text-slate-600">{row.reason}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end border-t border-slate-100 pt-4">
|
||||
<Button onClick={onClose}>Zamknij</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// --- wybor pliku i mapowanie ---
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Import kontaktów z pliku CSV"
|
||||
description="Duplikat po adresie aktualizuje istniejący kontakt, nie tworzy drugiego"
|
||||
onClose={onClose}
|
||||
wide
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const selected = e.target.files?.[0]
|
||||
if (selected) handleFile(selected)
|
||||
}}
|
||||
/>
|
||||
|
||||
{!preview && (
|
||||
<div className="rounded-md border border-dashed border-slate-300 p-8 text-center">
|
||||
<Icon name="upload" size={28} className="mx-auto text-slate-400" />
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Wybierz plik CSV z listą kontaktów.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Separatorem może być średnik albo przecinek. Pierwszy wiersz to nazwy kolumn.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button icon="upload" disabled={busy} onClick={() => fileInput.current?.click()}>
|
||||
{busy ? 'Wczytywanie...' : 'Wybierz plik'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{busy && preview && <Spinner />}
|
||||
|
||||
{preview && mapping && (
|
||||
<>
|
||||
<p className="text-sm text-slate-600">
|
||||
Plik zawiera <span className="hx-data font-semibold">{preview.totalRows}</span>{' '}
|
||||
wierszy z danymi. Sprawdź, czy kolumny zostały rozpoznane poprawnie.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{FIELDS.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800">
|
||||
{field.label}
|
||||
{field.required && <span className="text-alarm-600"> *</span>}
|
||||
</label>
|
||||
<Select
|
||||
value={String(mapping[field.key])}
|
||||
onChange={(e) =>
|
||||
setMapping({ ...mapping, [field.key]: Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
<option value="-1">Nie importuj</option>
|
||||
{preview.header.map((name, index) => (
|
||||
<option key={index} value={index}>
|
||||
{name || `Kolumna ${index + 1}`}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mapping.email < 0 && (
|
||||
<Alert tone="warn">
|
||||
Wskaż kolumnę z adresem e-mail. Bez niej nie da się rozpoznać kontaktów.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* podglad: tak zostana zrozumiane pierwsze wiersze */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-slate-800">
|
||||
Podgląd pierwszych wierszy
|
||||
</h3>
|
||||
<div className="overflow-x-auto rounded-md border border-slate-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-100 bg-slate-25 text-left">
|
||||
<tr className="hx-eyebrow">
|
||||
{FIELDS.filter((f) => mapping[f.key] >= 0).map((f) => (
|
||||
<th key={f.key} className="whitespace-nowrap px-3 py-2">
|
||||
{f.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{preview.sample.map((row, index) => (
|
||||
<tr key={index}>
|
||||
{FIELDS.filter((f) => mapping[f.key] >= 0).map((f) => (
|
||||
<td key={f.key} className="whitespace-nowrap px-3 py-2 text-slate-700">
|
||||
{row[mapping[f.key]] ?? ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-md border border-slate-200 p-3">
|
||||
<div className="w-full sm:w-64">
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800">
|
||||
Dodaj wszystkich do grupy
|
||||
</label>
|
||||
<Select value={groupId} onChange={(e) => setGroupId(e.target.value)}>
|
||||
<option value="">Bez grupy</option>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 rounded border-slate-300"
|
||||
checked={grantConsent}
|
||||
onChange={(e) => setGrantConsent(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-slate-700">
|
||||
Zapisz zgodę marketingową dla importowanych kontaktów
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
Zaznacz tylko wtedy, gdy masz podstawę prawną: zgodę zebraną wcześniej albo
|
||||
inną przesłankę. Osoby, które wcześniej się wypisały, i tak jej nie dostaną.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-slate-100 pt-4">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button
|
||||
icon="upload"
|
||||
disabled={busy || mapping.email < 0}
|
||||
onClick={handleImport}
|
||||
>
|
||||
{busy ? 'Importowanie...' : `Importuj ${preview.totalRows} wierszy`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function Summary({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
tone: 'ok' | 'info' | 'warn'
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'ok' ? 'text-ok-600' : tone === 'warn' ? 'text-warn-600' : 'text-blue-700'
|
||||
return (
|
||||
<div className="rounded-md border border-slate-200 p-3 text-center">
|
||||
<p className={`hx-data text-2xl font-semibold ${toneClass}`}>{value}</p>
|
||||
<p className="hx-eyebrow mt-0.5 text-slate-500">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Contact, ContactGroup, Page } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Input,
|
||||
Select,
|
||||
Spinner,
|
||||
} from '../../components/ui'
|
||||
import { ContactFormDialog } from './ContactFormDialog'
|
||||
import { ContactImportDialog } from './ContactImportDialog'
|
||||
import { GroupsDialog } from './GroupsDialog'
|
||||
|
||||
/**
|
||||
* Baza kontaktow.
|
||||
*
|
||||
* Kontakt jest niezalezny od pojedynczego webinaru: dostaje zaproszenia na
|
||||
* kolejne wydarzenia, ma wlasne zgody i wlasne wypisanie. Dlatego to osobny
|
||||
* dzial panelu, a nie zakladka webinaru.
|
||||
*/
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
MANUAL: 'Dodany ręcznie',
|
||||
IMPORT: 'Z importu',
|
||||
REGISTRATION: 'Z zapisu na webinar',
|
||||
}
|
||||
|
||||
export function ContactListPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [groupId, setGroupId] = useState('')
|
||||
const [consent, setConsent] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const [editing, setEditing] = useState<Contact | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [managingGroups, setManagingGroups] = useState(false)
|
||||
/** Zaznaczone kontakty, do masowego przypisania do grupy. */
|
||||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
const groups = useQuery({
|
||||
queryKey: ['contact-groups'],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<ContactGroup[]>('/admin/contacts/groups')
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
const contacts = useQuery({
|
||||
queryKey: ['contacts', search, groupId, consent, page],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Page<Contact>>('/admin/contacts', {
|
||||
params: {
|
||||
search: search || undefined,
|
||||
groupId: groupId || undefined,
|
||||
withConsent: consent === '' ? undefined : consent === 'tak',
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
const assignGroup = useMutation({
|
||||
mutationFn: (targetGroupId: number) =>
|
||||
client.post(`/admin/contacts/groups/${targetGroupId}/members`, { contactIds: selected }),
|
||||
onSuccess: (response) => {
|
||||
const added = (response.data as { added: number }).added
|
||||
toasts.success(`Dodano ${added} kontaktów do grupy`)
|
||||
setSelected([])
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['contact-groups'] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się przypisać do grupy')),
|
||||
})
|
||||
|
||||
async function handleExport() {
|
||||
setExporting(true)
|
||||
try {
|
||||
const response = await client.get('/admin/contacts/export', {
|
||||
params: { groupId: groupId || undefined },
|
||||
responseType: 'blob',
|
||||
})
|
||||
const url = URL.createObjectURL(response.data as Blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'kontakty.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)
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(id: number) {
|
||||
setSelected((current) =>
|
||||
current.includes(id) ? current.filter((x) => x !== id) : [...current, id])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Kontakty</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
Baza odbiorców zaproszeń, niezależna od pojedynczego webinaru
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" icon="users" onClick={() => setManagingGroups(true)}>
|
||||
Grupy
|
||||
</Button>
|
||||
<Button variant="secondary" icon="upload" onClick={() => setImporting(true)}>
|
||||
Import CSV
|
||||
</Button>
|
||||
<Button variant="secondary" icon="download" onClick={handleExport} disabled={exporting}>
|
||||
{exporting ? 'Pobieranie...' : 'Eksport CSV'}
|
||||
</Button>
|
||||
<Button icon="user-plus" onClick={() => setCreating(true)}>
|
||||
Dodaj kontakt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="w-full sm:w-72">
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Szukaj</label>
|
||||
<Input
|
||||
value={search}
|
||||
placeholder="Nazwisko, adres e-mail albo firma"
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full sm:w-56">
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Grupa</label>
|
||||
<Select
|
||||
value={groupId}
|
||||
onChange={(e) => {
|
||||
setGroupId(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
>
|
||||
<option value="">Wszystkie</option>
|
||||
{groups.data?.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name} ({group.contactCount})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-full sm:w-56">
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800">
|
||||
Zgoda marketingowa
|
||||
</label>
|
||||
<Select
|
||||
value={consent}
|
||||
onChange={(e) => {
|
||||
setConsent(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
>
|
||||
<option value="">Wszystkie</option>
|
||||
<option value="tak">Może dostać zaproszenie</option>
|
||||
<option value="nie">Bez zgody albo wypisany</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* pasek akcji masowych pojawia sie dopiero po zaznaczeniu */}
|
||||
{selected.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<p className="text-sm text-slate-700">
|
||||
Zaznaczono <span className="hx-data font-semibold">{selected.length}</span>
|
||||
</p>
|
||||
<div className="w-full sm:w-56">
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800">
|
||||
Dodaj do grupy
|
||||
</label>
|
||||
<Select
|
||||
value=""
|
||||
disabled={assignGroup.isPending}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) assignGroup.mutate(Number(e.target.value))
|
||||
}}
|
||||
>
|
||||
<option value="">Wybierz grupę</option>
|
||||
{groups.data?.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => setSelected([])}>
|
||||
Odznacz wszystkie
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
|
||||
{contacts.isPending && <Spinner />}
|
||||
{contacts.isError && (
|
||||
<div className="p-5">
|
||||
<ErrorState message={errorMessage(contacts.error, 'Nie udało się wczytać kontaktów')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contacts.data && contacts.data.content.length === 0 && (
|
||||
<EmptyState
|
||||
icon="users"
|
||||
title="Brak kontaktów"
|
||||
description="Dodaj pierwszy kontakt albo zaimportuj listę z pliku CSV."
|
||||
/>
|
||||
)}
|
||||
|
||||
{contacts.data && contacts.data.content.length > 0 && (
|
||||
<>
|
||||
{/* karty do lg, tabela od lg w gore */}
|
||||
<ul className="divide-y divide-slate-100 lg:hidden">
|
||||
{contacts.data.content.map((contact) => (
|
||||
<li key={contact.id} className="space-y-2 p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 text-left"
|
||||
onClick={() => setEditing(contact)}
|
||||
>
|
||||
<p className="font-medium text-slate-900">
|
||||
{contact.firstName} {contact.lastName}
|
||||
</p>
|
||||
<p className="hx-data break-all text-xs text-slate-500">{contact.email}</p>
|
||||
</button>
|
||||
<ConsentBadge contact={contact} />
|
||||
</div>
|
||||
{contact.company && (
|
||||
<p className="text-sm text-slate-500">{contact.company}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-100 bg-slate-25 text-left">
|
||||
<tr className="hx-eyebrow">
|
||||
<th className="w-10 px-4 py-3">
|
||||
<span className="sr-only">Zaznacz</span>
|
||||
</th>
|
||||
<th className="px-4 py-3">Osoba</th>
|
||||
<th className="px-4 py-3">Firma</th>
|
||||
<th className="px-4 py-3">Źródło</th>
|
||||
<th className="px-4 py-3">Zgoda</th>
|
||||
<th className="px-4 py-3 text-right">Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{contacts.data.content.map((contact) => (
|
||||
<tr key={contact.id} className="transition duration-fast ease-standard hover:bg-slate-25">
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-slate-300"
|
||||
checked={selected.includes(contact.id)}
|
||||
onChange={() => toggle(contact.id)}
|
||||
aria-label={`Zaznacz ${contact.firstName} ${contact.lastName}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-slate-900">
|
||||
{contact.firstName} {contact.lastName}
|
||||
</p>
|
||||
<p className="hx-data text-xs text-slate-500">{contact.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600">{contact.company || '-'}</td>
|
||||
<td className="px-4 py-3 text-xs text-slate-500">
|
||||
{SOURCE_LABEL[contact.source] ?? contact.source}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<ConsentBadge contact={contact} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="pencil"
|
||||
onClick={() => setEditing(contact)}
|
||||
>
|
||||
Edytuj
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{contacts.data && (
|
||||
<Pagination
|
||||
page={contacts.data.number}
|
||||
totalPages={contacts.data.totalPages}
|
||||
totalElements={contacts.data.totalElements}
|
||||
onChange={setPage}
|
||||
itemLabel="kontaktów"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(creating || editing) && (
|
||||
<ContactFormDialog
|
||||
contact={editing}
|
||||
groups={groups.data ?? []}
|
||||
onClose={() => {
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{importing && (
|
||||
<ContactImportDialog groups={groups.data ?? []} onClose={() => setImporting(false)} />
|
||||
)}
|
||||
|
||||
{managingGroups && (
|
||||
<GroupsDialog groups={groups.data ?? []} onClose={() => setManagingGroups(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stan zgody jednym spojrzeniem.
|
||||
*
|
||||
* Wypisanie pokazujemy osobno od braku zgody: to dwie rozne sytuacje.
|
||||
* Ktos bez zgody moze ja dostac, ktos wypisany sam zdecydowal inaczej.
|
||||
*/
|
||||
function ConsentBadge({ contact }: { contact: Contact }) {
|
||||
if (contact.unsubscribed) {
|
||||
return (
|
||||
<Badge tone="alarm">
|
||||
<Icon name="user-off" size={12} className="mr-1" />
|
||||
Wypisany
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (!contact.active) {
|
||||
return <Badge tone="neutral">Nieaktywny</Badge>
|
||||
}
|
||||
return contact.marketingConsent ? (
|
||||
<Badge tone="ok">Ze zgodą</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">Bez zgody</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { ContactGroup } from '../../api/types'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Button, Input } from '../../components/ui'
|
||||
import { Modal } from '../../components/Modal'
|
||||
|
||||
/** Zarzadzanie grupami kontaktow. */
|
||||
export function GroupsDialog({
|
||||
groups,
|
||||
onClose,
|
||||
}: {
|
||||
groups: ContactGroup[]
|
||||
onClose: () => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const [name, setName] = useState('')
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => client.post('/admin/contacts/groups', { name }),
|
||||
onSuccess: () => {
|
||||
toasts.success('Grupa została utworzona')
|
||||
setName('')
|
||||
queryClient.invalidateQueries({ queryKey: ['contact-groups'] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się utworzyć grupy')),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (groupId: number) => client.delete(`/admin/contacts/groups/${groupId}`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Grupa została usunięta')
|
||||
queryClient.invalidateQueries({ queryKey: ['contact-groups'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts'] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć grupy')),
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Grupy kontaktów"
|
||||
description="Grupy służą do wyboru odbiorców zaproszenia"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (name.trim()) create.mutate()
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Nazwa grupy</label>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder="np. Klienci, Newsletter"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" icon="plus" disabled={create.isPending || !name.trim()}>
|
||||
Dodaj
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
|
||||
Nie ma jeszcze żadnej grupy.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{groups.map((group) => (
|
||||
<li key={group.id} className="flex items-center justify-between gap-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-900">{group.name}</p>
|
||||
<p className="hx-data text-xs text-slate-500">{group.contactCount} kontaktów</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
// grupa znika, kontakty zostaja: to tylko etykieta
|
||||
if (confirm(`Usunąć grupę "${group.name}"? Kontakty zostaną w bazie.`)) {
|
||||
remove.mutate(group.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Usuń
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type {
|
||||
CertificateCounters,
|
||||
CertificateResendResult,
|
||||
CertificateRowAdmin,
|
||||
CertificateSubject,
|
||||
} from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import type { IconName } from '../../components/Icon'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import { Pagination, PAGE_SIZE } from '../../components/Pagination'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
Input,
|
||||
Select,
|
||||
Spinner,
|
||||
Textarea,
|
||||
} from '../../components/ui'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { usePublicUrl } from '../../lib/usePublicConfig'
|
||||
import { panelPath } from '../../lib/routes'
|
||||
|
||||
/**
|
||||
* Przekrojowa lista certyfikatow calej instancji.
|
||||
*
|
||||
* Sluzy PRZEGLADANIU I OBSLUDZE juz wydanych dokumentow. Wydawanie zostaje na
|
||||
* karcie kursu i karcie webinaru, gdzie widac kontekst: komu certyfikat sie
|
||||
* nalezy i dlaczego. Tutaj tego kontekstu nie ma, wiec uniewaznienie wymaga
|
||||
* uzasadnienia.
|
||||
*/
|
||||
export function CertificateOverviewPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const publicUrl = usePublicUrl()
|
||||
|
||||
const [source, setSource] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [subject, setSubject] = useState('')
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
const [revoking, setRevoking] = useState<CertificateRowAdmin | null>(null)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
/*
|
||||
* Filtr zrodla niesie dwie wartosci: identyfikator i to, czy chodzi o kurs.
|
||||
* Bez drugiej kursy i webinary o tym samym numerze mieszalyby sie ze soba,
|
||||
* bo obie tabele maja wlasne sekwencje.
|
||||
*/
|
||||
const [subjectId, subjectIsCourse] = subject
|
||||
? [subject.split(':')[1], subject.split(':')[0] === 'kurs']
|
||||
: [undefined, undefined]
|
||||
|
||||
const params = {
|
||||
forCourse: source === '' ? undefined : source === 'kurs',
|
||||
revoked: status === '' ? undefined : status === 'uniewazniony',
|
||||
subjectId,
|
||||
subjectIsCourse,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
search: search || undefined,
|
||||
}
|
||||
|
||||
const counters = useQuery({
|
||||
queryKey: ['certificate-counters'],
|
||||
queryFn: async () =>
|
||||
(await client.get<CertificateCounters>('/admin/certificates/counters')).data,
|
||||
})
|
||||
|
||||
const subjects = useQuery({
|
||||
queryKey: ['certificate-subjects'],
|
||||
queryFn: async () =>
|
||||
(await client.get<CertificateSubject[]>('/admin/certificates/subjects')).data,
|
||||
})
|
||||
|
||||
const { data, isPending, isError, error } = useQuery({
|
||||
queryKey: ['certificates-all', source, status, subject, from, to, search, page],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await client.get<{
|
||||
content: CertificateRowAdmin[]
|
||||
number: number
|
||||
totalPages: number
|
||||
totalElements: number
|
||||
}>('/admin/certificates', { params: { ...params, page, size: PAGE_SIZE } })
|
||||
).data,
|
||||
})
|
||||
|
||||
const resend = useMutation({
|
||||
mutationFn: async (id: number) =>
|
||||
(await client.post<CertificateResendResult>(`/admin/certificates/${id}/resend`)).data,
|
||||
onSuccess: (result) => {
|
||||
// mowimy wprost, czy poszedl zalacznik: bez tego wsparcie nie wie,
|
||||
// czy odbiorca dostal dokument, czy sam odnosnik
|
||||
if (result.withAttachment) {
|
||||
toasts.success(`Certyfikat wysłany na ${result.recipient}`)
|
||||
} else {
|
||||
toasts.error(result.message)
|
||||
}
|
||||
},
|
||||
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się wysłać certyfikatu')),
|
||||
})
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: async ({ id, note }: { id: number; note: string }) => {
|
||||
await client.post(`/admin/certificates/${id}/revoke-with-note`, { note })
|
||||
},
|
||||
onSuccess: () => {
|
||||
toasts.success('Certyfikat unieważniony')
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates-all'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['certificate-counters'] })
|
||||
setRevoking(null)
|
||||
},
|
||||
onError: (e) => toasts.error(errorMessage(e, 'Nie udało się unieważnić certyfikatu')),
|
||||
})
|
||||
|
||||
const exportCsv = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const response = await client.get('/admin/certificates/export', {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
})
|
||||
const url = URL.createObjectURL(response.data as Blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'certyfikaty.csv'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
toasts.error(errorMessage(e, 'Nie udało się pobrać pliku'))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Zmiana filtru cofa na pierwsza strone: inaczej widac pusta tabele. */
|
||||
const changeFilter = (setter: (value: string) => void) => (value: string) => {
|
||||
setter(value)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const rows = data?.content ?? []
|
||||
const filtersActive = Boolean(source || status || subject || from || to || search)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Certyfikaty</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
Wszystkie wydane dokumenty, z kursów i z webinarów.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" icon="download" onClick={exportCsv} disabled={exporting}>
|
||||
{exporting ? 'Przygotowuję...' : 'Eksport CSV'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* --- liczniki --- */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Metric
|
||||
icon="award"
|
||||
label="Wydane w tym roku"
|
||||
value={counters.data?.issuedThisYear ?? 0}
|
||||
/>
|
||||
<Metric
|
||||
icon="clock"
|
||||
label="Wydane w ostatnim miesiącu"
|
||||
value={counters.data?.issuedLastMonth ?? 0}
|
||||
/>
|
||||
<Metric
|
||||
icon="x-circle"
|
||||
label="Unieważnione"
|
||||
value={counters.data?.revoked ?? 0}
|
||||
tone={counters.data && counters.data.revoked > 0 ? 'alarm' : 'neutral'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* --- filtry --- */}
|
||||
<Card>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="cert-source">
|
||||
Źródło
|
||||
</label>
|
||||
<Select
|
||||
id="cert-source"
|
||||
className="w-full sm:w-40"
|
||||
value={source}
|
||||
onChange={(e) => changeFilter(setSource)(e.target.value)}
|
||||
>
|
||||
<option value="">Wszystkie</option>
|
||||
<option value="kurs">Kursy</option>
|
||||
<option value="webinar">Webinary</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="cert-status">
|
||||
Status
|
||||
</label>
|
||||
<Select
|
||||
id="cert-status"
|
||||
className="w-full sm:w-44"
|
||||
value={status}
|
||||
onChange={(e) => changeFilter(setStatus)(e.target.value)}
|
||||
>
|
||||
<option value="">Wszystkie</option>
|
||||
<option value="wazny">Ważne</option>
|
||||
<option value="uniewazniony">Unieważnione</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="cert-subject">
|
||||
Kurs lub webinar
|
||||
</label>
|
||||
<Select
|
||||
id="cert-subject"
|
||||
className="w-full sm:w-64"
|
||||
value={subject}
|
||||
onChange={(e) => changeFilter(setSubject)(e.target.value)}
|
||||
>
|
||||
<option value="">Wszystkie</option>
|
||||
{(subjects.data ?? []).map((s) => (
|
||||
<option key={`${s.forCourse ? 'kurs' : 'webinar'}:${s.id}`} value={`${s.forCourse ? 'kurs' : 'webinar'}:${s.id}`}>
|
||||
{s.forCourse ? 'Kurs: ' : 'Webinar: '}
|
||||
{s.title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="cert-from">
|
||||
Wydane od
|
||||
</label>
|
||||
<Input
|
||||
id="cert-from"
|
||||
type="date"
|
||||
className="w-full sm:w-40"
|
||||
value={from}
|
||||
onChange={(e) => changeFilter(setFrom)(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="cert-to">
|
||||
do
|
||||
</label>
|
||||
<Input
|
||||
id="cert-to"
|
||||
type="date"
|
||||
className="w-full sm:w-40"
|
||||
value={to}
|
||||
onChange={(e) => changeFilter(setTo)(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="cert-search">
|
||||
Szukaj
|
||||
</label>
|
||||
<Input
|
||||
id="cert-search"
|
||||
className="w-full sm:w-56"
|
||||
placeholder="Numer albo nazwisko"
|
||||
value={search}
|
||||
onChange={(e) => changeFilter(setSearch)(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="ml-auto text-sm text-slate-500">
|
||||
Znaleziono: <strong className="hx-data">{data?.totalElements ?? 0}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* --- lista --- */}
|
||||
<div className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
|
||||
{isPending && (
|
||||
<div className="p-5">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="p-5">
|
||||
<ErrorState message={errorMessage(error, 'Nie udało się wczytać certyfikatów')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && rows.length === 0 && (
|
||||
<div className="p-5">
|
||||
<EmptyState
|
||||
icon="award"
|
||||
title="Brak certyfikatów"
|
||||
description={
|
||||
filtersActive
|
||||
? 'Żaden certyfikat nie spełnia wybranych warunków.'
|
||||
: 'Certyfikaty pojawią się tutaj po wydaniu ich na karcie kursu albo webinaru.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<>
|
||||
<ul className="divide-y divide-slate-100 lg:hidden">
|
||||
{rows.map((row) => (
|
||||
<li key={row.id} className="p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="hx-data text-sm font-medium text-slate-900">{row.number}</p>
|
||||
<Badge tone={row.forCourse ? 'brand' : 'info'}>
|
||||
{row.forCourse ? 'kurs' : 'webinar'}
|
||||
</Badge>
|
||||
{row.revoked && <Badge tone="alarm">unieważniony</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-800">{row.recipientName}</p>
|
||||
<p className="text-xs text-slate-500">{row.subjectTitle}</p>
|
||||
<p className="hx-data mt-1 text-xs text-slate-500">
|
||||
{formatLocal(row.issuedAt)}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<RowActions
|
||||
row={row}
|
||||
publicUrl={publicUrl}
|
||||
onResend={() => resend.mutate(row.id)}
|
||||
onRevoke={() => setRevoking(row)}
|
||||
busy={resend.isPending}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-100 bg-slate-25 text-left">
|
||||
<tr className="hx-eyebrow">
|
||||
<th className="px-4 py-2.5">Numer</th>
|
||||
<th className="px-4 py-2.5">Odbiorca</th>
|
||||
<th className="px-4 py-2.5">Źródło</th>
|
||||
<th className="px-4 py-2.5">Wydano</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5 text-right">Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="border-b border-slate-50 transition duration-fast last:border-b-0 hover:bg-slate-25"
|
||||
>
|
||||
<td className="hx-data px-4 py-3 font-medium text-slate-900">
|
||||
{row.number}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-slate-800">{row.recipientName}</p>
|
||||
<p className="hx-data text-xs text-slate-500">{row.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone={row.forCourse ? 'brand' : 'info'}>
|
||||
{row.forCourse ? 'kurs' : 'webinar'}
|
||||
</Badge>
|
||||
<Link
|
||||
to={
|
||||
row.forCourse
|
||||
? panelPath(`/kursy/${row.subjectId}`)
|
||||
: panelPath(`/webinary/${row.subjectId}`)
|
||||
}
|
||||
className="text-slate-700 hover:text-blue-600"
|
||||
>
|
||||
{row.subjectTitle}
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
<td className="hx-data px-4 py-3 text-slate-600">
|
||||
{formatLocal(row.issuedAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{row.revoked ? (
|
||||
<Badge tone="alarm">unieważniony</Badge>
|
||||
) : (
|
||||
<Badge tone="ok">ważny</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<RowActions
|
||||
row={row}
|
||||
publicUrl={publicUrl}
|
||||
onResend={() => resend.mutate(row.id)}
|
||||
onRevoke={() => setRevoking(row)}
|
||||
busy={resend.isPending}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={data!.number}
|
||||
totalPages={data!.totalPages}
|
||||
totalElements={data!.totalElements}
|
||||
onChange={setPage}
|
||||
itemLabel="certyfikatów"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Alert tone="info" icon="circle-alert">
|
||||
Certyfikaty wydaje się na karcie kursu albo webinaru, gdzie widać, komu się należą.
|
||||
Ten ekran służy do przeglądania i obsługi już wydanych.
|
||||
</Alert>
|
||||
|
||||
{revoking && (
|
||||
<RevokeModal
|
||||
row={revoking}
|
||||
onClose={() => setRevoking(null)}
|
||||
onConfirm={(note) => revoke.mutate({ id: revoking.id, note })}
|
||||
busy={revoke.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RowActions({
|
||||
row,
|
||||
publicUrl,
|
||||
onResend,
|
||||
onRevoke,
|
||||
busy,
|
||||
}: {
|
||||
row: CertificateRowAdmin
|
||||
publicUrl: string
|
||||
onResend: () => void
|
||||
onRevoke: () => void
|
||||
busy: boolean
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href={publicUrl + row.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Pobierz
|
||||
</a>
|
||||
|
||||
{!row.revoked && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Wysłać certyfikat ${row.number} ponownie na adres ${row.email}? ` +
|
||||
'Wyślemy dokładnie ten plik, który został wydany.',
|
||||
)
|
||||
) {
|
||||
onResend()
|
||||
}
|
||||
}}
|
||||
disabled={busy}
|
||||
className="text-sm text-slate-600 hover:text-slate-900 disabled:opacity-50"
|
||||
>
|
||||
Wyślij ponownie
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRevoke}
|
||||
className="text-sm text-alarm-600 hover:text-alarm-700"
|
||||
>
|
||||
Unieważnij
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uniewaznienie z uzasadnieniem.
|
||||
*
|
||||
* Notatka jest WYMAGANA: na liscie przekrojowej nie widac kontekstu, ktorego
|
||||
* dokument dotyczyl, a uniewazniony certyfikat mogl juz trafic do pracodawcy.
|
||||
* Po miesiacu musi dac sie ustalic, dlaczego przestal byc wazny.
|
||||
*/
|
||||
function RevokeModal({
|
||||
row,
|
||||
onClose,
|
||||
onConfirm,
|
||||
busy,
|
||||
}: {
|
||||
row: CertificateRowAdmin
|
||||
onClose: () => void
|
||||
onConfirm: (note: string) => void
|
||||
busy: boolean
|
||||
}) {
|
||||
const [note, setNote] = useState('')
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`Unieważnienie certyfikatu ${row.number}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
icon="x-circle"
|
||||
onClick={() => onConfirm(note)}
|
||||
disabled={!note.trim() || busy}
|
||||
>
|
||||
{busy ? 'Unieważnianie...' : 'Unieważnij'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Alert tone="warn" icon="alert-triangle">
|
||||
Certyfikat <strong>{row.number}</strong> wydany dla{' '}
|
||||
<strong>{row.recipientName}</strong> przestanie być ważny. Dokument nie zniknie:
|
||||
weryfikacja publiczna pokaże, że został wycofany, bo mógł już trafić do pracodawcy.
|
||||
</Alert>
|
||||
|
||||
<Field
|
||||
label="Uzasadnienie"
|
||||
hint="Trafi do dziennika. Bez niego po miesiącu nie da się ustalić, dlaczego certyfikat wycofano."
|
||||
>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="np. wydany przez pomyłkę, kursant nie ukończył wymaganych lekcji"
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
icon: IconName
|
||||
label: string
|
||||
value: number
|
||||
tone?: 'neutral' | 'alarm'
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-100 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
name={icon}
|
||||
size={16}
|
||||
className={tone === 'alarm' ? 'text-alarm-500' : 'text-slate-400'}
|
||||
/>
|
||||
<p className="hx-eyebrow">{label}</p>
|
||||
</div>
|
||||
<p className="hx-data mt-1.5 text-2xl font-bold text-slate-900">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Certificate, CertificateBulkResult, Course, CourseEnrollment } from '../../api/types'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
Input,
|
||||
Spinner,
|
||||
Textarea,
|
||||
} from '../../components/ui'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { usePublicUrl } from '../../lib/usePublicConfig'
|
||||
|
||||
/**
|
||||
* Certyfikaty kursu.
|
||||
*
|
||||
* Ten sam zestaw akcji co przy webinarze (`CertificateSection`): ustawienia,
|
||||
* wydawanie pojedyncze i masowe, lista wydanych, uniewaznienie. Rozni sie
|
||||
* warunek wydania: przy webinarze decyduje obecnosc, tutaj ukonczenie kursu.
|
||||
*
|
||||
* Wydanie osobie, ktora kursu nie skonczyla, WYMAGA UZASADNIENIA. Bez notatki
|
||||
* po miesiacu nie da sie ustalic, dlaczego dokument powstal i kto o tym
|
||||
* zdecydowal; przy certyfikacie, ktory trafia do pracodawcy, to nie jest
|
||||
* szczegol.
|
||||
*/
|
||||
export function CourseCertificateSection({ course }: { course: Course }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const publicUrl = usePublicUrl()
|
||||
|
||||
const [enabled, setEnabled] = useState(course.certificateEnabled)
|
||||
const [hours, setHours] = useState(
|
||||
course.certificateHours === null || course.certificateHours === undefined
|
||||
? ''
|
||||
: String(course.certificateHours),
|
||||
)
|
||||
const [template, setTemplate] = useState(course.certificateTemplateHtml ?? '')
|
||||
const [forcing, setForcing] = useState<{ enrollmentId: number | null; name: string } | null>(null)
|
||||
|
||||
const certificates = useQuery({
|
||||
queryKey: ['course-certificates', course.id],
|
||||
queryFn: async () =>
|
||||
(await client.get<Certificate[]>(`/admin/courses/${course.id}/certificates`)).data,
|
||||
})
|
||||
|
||||
const enrollments = useQuery({
|
||||
queryKey: ['course-enrollments', course.id],
|
||||
queryFn: async () =>
|
||||
(await client.get<CourseEnrollment[]>(`/admin/courses/${course.id}/enrollments`)).data,
|
||||
})
|
||||
|
||||
const saveSettings = useMutation({
|
||||
mutationFn: async () => {
|
||||
/*
|
||||
* PUT wysyla caly obiekt kursu, tak samo jak przy webinarze: endpoint
|
||||
* edycji nie jest patchem, wiec pominiete pole zostaloby wyczyszczone.
|
||||
*/
|
||||
await client.put(`/admin/courses/${course.id}`, {
|
||||
...course,
|
||||
certificateEnabled: enabled,
|
||||
certificateHours: hours.trim() === '' ? null : Number(hours),
|
||||
certificateTemplateHtml: template.trim() === '' ? null : template,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toasts.success('Ustawienia certyfikatów zapisane')
|
||||
queryClient.invalidateQueries({ queryKey: ['course', course.id] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać ustawień')),
|
||||
})
|
||||
|
||||
const issueOne = useMutation({
|
||||
mutationFn: async (payload: { enrollmentId: number; force: boolean; note?: string }) => {
|
||||
await client.post(`/admin/courses/${course.id}/certificates`, payload)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toasts.success('Certyfikat wydany')
|
||||
queryClient.invalidateQueries({ queryKey: ['course-certificates', course.id] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wydać certyfikatu')),
|
||||
})
|
||||
|
||||
const issueAll = useMutation({
|
||||
mutationFn: async (payload: { force: boolean; note?: string }) => {
|
||||
const response = await client.post<CertificateBulkResult>(
|
||||
`/admin/courses/${course.id}/certificates/all`,
|
||||
null,
|
||||
{ params: payload },
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
toasts.success(`Wydano ${result.issued}, pominięto ${result.skipped}`)
|
||||
if (result.problems.length > 0) {
|
||||
toasts.error(`Problemy: ${result.problems.slice(0, 3).join('; ')}`)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['course-certificates', course.id] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wydać certyfikatów')),
|
||||
})
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: async (certificateId: number) => {
|
||||
await client.post(`/admin/certificates/${certificateId}/revoke`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toasts.success('Certyfikat unieważniony')
|
||||
queryClient.invalidateQueries({ queryKey: ['course-certificates', course.id] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się unieważnić certyfikatu')),
|
||||
})
|
||||
|
||||
const withCertificate = new Set(
|
||||
(certificates.data ?? []).map((c) => c.enrollmentId).filter(Boolean) as number[],
|
||||
)
|
||||
const pending = (enrollments.data ?? []).filter((e) => !withCertificate.has(e.id))
|
||||
const completedPending = pending.filter((e) => e.status === 'COMPLETED')
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* --- ustawienia --- */}
|
||||
<Card
|
||||
title="Ustawienia certyfikatów"
|
||||
description="Certyfikat wydaje się automatycznie w chwili ukończenia kursu."
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-3 focus:ring-blue-500/45"
|
||||
/>
|
||||
Wydawaj certyfikaty po ukończeniu tego kursu
|
||||
</label>
|
||||
|
||||
<Field
|
||||
label="Liczba godzin szkoleniowych"
|
||||
hint="Puste oznacza, że certyfikat nie podaje godzin."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
value={hours}
|
||||
onChange={(e) => setHours(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Własna treść certyfikatu"
|
||||
hint="Puste oznacza szablon domyślny. Dostępne znaczniki: ${participantName}, ${courseTitle}, ${completedDate}, ${hours}, ${scorePercent}, ${number}, ${brandName}."
|
||||
wide
|
||||
>
|
||||
<Textarea
|
||||
rows={6}
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
placeholder="<h2>Zaświadczenie o ukończeniu</h2>"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Button icon="save" onClick={() => saveSettings.mutate()} disabled={saveSettings.isPending}>
|
||||
{saveSettings.isPending ? 'Zapisywanie...' : 'Zapisz ustawienia'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* --- wydawanie --- */}
|
||||
<Card
|
||||
title="Wydawanie"
|
||||
description="Kursanci bez certyfikatu. Ukończenie kursu wydaje go automatycznie."
|
||||
>
|
||||
{enrollments.isPending && <Spinner />}
|
||||
{enrollments.isError && <ErrorState message="Nie udało się wczytać listy zapisanych." />}
|
||||
|
||||
{enrollments.data && (
|
||||
<>
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
icon="award"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Wydać certyfikaty wszystkim, którzy ukończyli kurs "${course.title}"? ` +
|
||||
`Dotyczy ${completedPending.length} osób.`,
|
||||
)
|
||||
) {
|
||||
issueAll.mutate({ force: false })
|
||||
}
|
||||
}}
|
||||
disabled={issueAll.isPending || completedPending.length === 0}
|
||||
>
|
||||
Wydaj tym, którzy ukończyli ({completedPending.length})
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setForcing({ enrollmentId: null, name: '' })}
|
||||
disabled={issueAll.isPending || pending.length === 0}
|
||||
>
|
||||
Wydaj wszystkim, także nieukończonym
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pending.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
Wszyscy zapisani mają już certyfikat.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{pending.map((enrollment) => (
|
||||
<li
|
||||
key={enrollment.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{enrollment.firstName} {enrollment.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{enrollment.email} · postęp {enrollment.progressPercent}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{enrollment.status !== 'COMPLETED' && (
|
||||
<Badge tone="warn">Kurs nieukończony</Badge>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (enrollment.status === 'COMPLETED') {
|
||||
issueOne.mutate({
|
||||
enrollmentId: enrollment.id,
|
||||
force: false,
|
||||
})
|
||||
} else {
|
||||
// wydanie bez ukonczenia wymaga uzasadnienia
|
||||
setForcing({
|
||||
enrollmentId: enrollment.id,
|
||||
name: `${enrollment.firstName} ${enrollment.lastName}`,
|
||||
})
|
||||
}
|
||||
}}
|
||||
disabled={issueOne.isPending}
|
||||
>
|
||||
Wydaj
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* --- wydane --- */}
|
||||
<Card title="Wydane certyfikaty">
|
||||
{certificates.isPending && <Spinner />}
|
||||
{certificates.isError && <ErrorState message="Nie udało się wczytać certyfikatów." />}
|
||||
|
||||
{certificates.data && certificates.data.length === 0 && (
|
||||
<EmptyState
|
||||
icon="award"
|
||||
title="Brak wydanych certyfikatów"
|
||||
description="Pojawią się tutaj, gdy kursanci zaczną kończyć kurs."
|
||||
/>
|
||||
)}
|
||||
|
||||
{certificates.data && certificates.data.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{certificates.data.map((certificate) => (
|
||||
<li
|
||||
key={certificate.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{certificate.participantName}
|
||||
</p>
|
||||
<p className="hx-data text-xs text-slate-500">
|
||||
{certificate.number} · {formatLocal(certificate.issuedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{certificate.valid ? (
|
||||
<Badge tone="ok">Ważny</Badge>
|
||||
) : (
|
||||
<Badge tone="alarm">Unieważniony</Badge>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={publicUrl + certificate.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Pobierz
|
||||
</a>
|
||||
|
||||
{certificate.valid && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Unieważnić certyfikat ${certificate.number} ` +
|
||||
`wydany dla ${certificate.participantName}? ` +
|
||||
'Dokument zostanie oznaczony jako nieważny przy weryfikacji publicznej, ' +
|
||||
'ale nie zniknie.',
|
||||
)
|
||||
) {
|
||||
revoke.mutate(certificate.id)
|
||||
}
|
||||
}}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
Unieważnij
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Alert tone="info" icon="circle-alert">
|
||||
Autentyczność każdego certyfikatu można sprawdzić pod adresem{' '}
|
||||
<span className="hx-data">{publicUrl}/certyfikat/NUMER</span>. Weryfikacja publiczna nie
|
||||
pokazuje danych osobowych.
|
||||
</Alert>
|
||||
|
||||
{forcing && (
|
||||
<ForceIssueModal
|
||||
name={forcing.name}
|
||||
bulk={forcing.enrollmentId === null}
|
||||
pendingCount={pending.length}
|
||||
onClose={() => setForcing(null)}
|
||||
onConfirm={(note) => {
|
||||
if (forcing.enrollmentId === null) {
|
||||
issueAll.mutate({ force: true, note })
|
||||
} else {
|
||||
issueOne.mutate({ enrollmentId: forcing.enrollmentId, force: true, note })
|
||||
}
|
||||
setForcing(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Okno wydania bez ukonczenia kursu.
|
||||
*
|
||||
* Notatka jest WYMAGANA, a nie opcjonalna: to jedyny slad, dlaczego dokument
|
||||
* powstal mimo niespelnionego warunku. Ostrzezenie mowi wprost, co sie stanie,
|
||||
* bo przy wydaniu masowym dotyczy to takze osob, ktore ledwie zaczely kurs.
|
||||
*/
|
||||
function ForceIssueModal({
|
||||
name,
|
||||
bulk,
|
||||
pendingCount,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
name: string
|
||||
bulk: boolean
|
||||
pendingCount: number
|
||||
onClose: () => void
|
||||
onConfirm: (note: string) => void
|
||||
}) {
|
||||
const [note, setNote] = useState('')
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={bulk ? 'Wydanie wszystkim, także nieukończonym' : `Certyfikat dla: ${name}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button icon="award" onClick={() => onConfirm(note)} disabled={!note.trim()}>
|
||||
Wydaj certyfikat
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Alert tone="warn" icon="alert-triangle">
|
||||
{bulk ? (
|
||||
<>
|
||||
Certyfikat dostanie <strong>{pendingCount}</strong> osób, także te, które kursu nie
|
||||
ukończyły. Dokument potwierdza ukończenie, więc wydanie go osobie, która kursu nie
|
||||
przeszła, jest świadomą decyzją.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>{name}</strong> nie ukończył(a) tego kursu. Certyfikat potwierdza
|
||||
ukończenie, więc wydanie go teraz jest świadomą decyzją.
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<Field
|
||||
label="Uzasadnienie"
|
||||
hint="Trafi do dziennika. Bez niego po miesiącu nie da się ustalić, dlaczego dokument powstał."
|
||||
>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="np. kurs zaliczony na szkoleniu stacjonarnym, potwierdzone przez prowadzącego"
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { Suspense, lazy, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Course, CourseEnrollment, EnrollmentStatus } from '../../api/types'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Select,
|
||||
Spinner,
|
||||
} from '../../components/ui'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { CurriculumSection } from './CurriculumSection'
|
||||
import { CourseCertificateSection } from './CourseCertificateSection'
|
||||
const CourseStatsSection = lazy(() =>
|
||||
import('./CourseStatsSection').then((m) => ({ default: m.CourseStatsSection })))
|
||||
import {
|
||||
COURSE_LEVEL_LABEL,
|
||||
COURSE_STATUS_LABEL,
|
||||
COURSE_STATUS_TONE,
|
||||
ENROLLMENT_STATUS_LABEL,
|
||||
ENROLLMENT_STATUS_TONE,
|
||||
formatDuration,
|
||||
formatPrice,
|
||||
} from './courseLabels'
|
||||
|
||||
import { panelPath } from '../../lib/routes'
|
||||
type Tab = 'program' | 'zapisani' | 'statystyki' | 'certyfikaty' | 'informacje'
|
||||
|
||||
/**
|
||||
* Karta kursu.
|
||||
*
|
||||
* Wszystko, co nalezy do jednego kursu, jest jego zakladka. Ta sama regula co
|
||||
* przy karcie webinaru: uzytkownik pracuje kursami, wiec szukajac czegokolwiek
|
||||
* o kursie wchodzi w ten kurs.
|
||||
*/
|
||||
export function CourseDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [tab, setTab] = useState<Tab>('program')
|
||||
|
||||
const course = useQuery({
|
||||
queryKey: ['courses', 'detail', id],
|
||||
queryFn: async () => (await client.get<Course>(`/admin/courses/${id}`)).data,
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
if (course.isPending) return <Spinner />
|
||||
if (course.isError) {
|
||||
return <ErrorState message={errorMessage(course.error, 'Nie udało się wczytać kursu')} />
|
||||
}
|
||||
|
||||
const data = course.data
|
||||
const courseId = Number(id)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<Button variant="ghost" icon="arrow-left" onClick={() => navigate(panelPath('/kursy'))}>
|
||||
Wróć
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-semibold text-slate-900">{data.title}</h1>
|
||||
<Badge tone={COURSE_STATUS_TONE[data.status]}>
|
||||
{COURSE_STATUS_LABEL[data.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
{data.subtitle && <p className="text-sm text-slate-500">{data.subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<Button icon="pencil" onClick={() => navigate(panelPath(`/kursy/${courseId}/edycja`))}>
|
||||
Edytuj kurs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
role="tablist"
|
||||
aria-label="Sekcje kursu"
|
||||
className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1"
|
||||
>
|
||||
{(
|
||||
[
|
||||
['program', 'Program kursu', 'file-text', data.lessonCount],
|
||||
['zapisani', 'Zapisani', 'users', data.enrolledCount],
|
||||
['statystyki', 'Statystyki', 'check-circle', null],
|
||||
['certyfikaty', 'Certyfikaty', 'award', null],
|
||||
['informacje', 'Informacje', 'settings', null],
|
||||
] as const
|
||||
).map(([value, label, icon, count]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === value}
|
||||
onClick={() => setTab(value)}
|
||||
className={`flex min-h-[44px] shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors duration-fast ease-standard ${
|
||||
tab === value
|
||||
? 'bg-blue-50 font-medium text-blue-700'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
{label}
|
||||
{count !== null && <span className="hx-data text-xs text-slate-400">{count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{tab === 'program' && <CurriculumSection courseId={courseId} />}
|
||||
{tab === 'zapisani' && <EnrollmentsTab courseId={courseId} />}
|
||||
{/* wykresy leniwie: @mui/x-charts wazy wiecej niz reszta panelu */}
|
||||
{tab === 'statystyki' && (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<CourseStatsSection courseId={courseId} />
|
||||
</Suspense>
|
||||
)}
|
||||
{tab === 'certyfikaty' && <CourseCertificateSection course={data} />}
|
||||
{tab === 'informacje' && <InfoTab course={data} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoTab({ course }: { course: Course }) {
|
||||
return (
|
||||
<Card title="Informacje o kursie">
|
||||
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||
<Row label="Adres strony">/kurs/{course.slug}</Row>
|
||||
<Row label="Kategoria">{course.category ?? '-'}</Row>
|
||||
<Row label="Poziom">{course.level ? COURSE_LEVEL_LABEL[course.level] : '-'}</Row>
|
||||
<Row label="Czas trwania">{formatDuration(course.durationMinutes)}</Row>
|
||||
<Row label="Cena">{formatPrice(course)}</Row>
|
||||
<Row label="Zapisy">{course.enrollmentOpen ? 'otwarte' : 'zamknięte'}</Row>
|
||||
<Row label="Certyfikat">
|
||||
{course.certificateEnabled
|
||||
? `tak${course.certificateHours ? `, ${course.certificateHours} godz.` : ''}`
|
||||
: 'nie'}
|
||||
</Row>
|
||||
<Row label="Próg zaliczenia">{course.passThresholdPercent}%</Row>
|
||||
<Row label="Utworzono">{formatLocal(course.createdAt)}</Row>
|
||||
<Row label="Zmieniono">{formatLocal(course.updatedAt)}</Row>
|
||||
</dl>
|
||||
|
||||
{course.description && (
|
||||
<div className="mt-4 border-t border-slate-100 pt-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-caps text-slate-400">Krótki opis</p>
|
||||
<p className="mt-1 text-sm text-slate-700">{course.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-caps text-slate-400">{label}</dt>
|
||||
<dd className="hx-data mt-0.5 text-sm text-slate-800">{children}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista zapisanych z postepem.
|
||||
*
|
||||
* Postep jest liczony **wylacznie z lekcji wymaganych**, co widac w kolumnie
|
||||
* obok procentu: material dodatkowy nie wplywa na wynik.
|
||||
*/
|
||||
function EnrollmentsTab({ courseId }: { courseId: number }) {
|
||||
const toasts = useToasts()
|
||||
const [status, setStatus] = useState<EnrollmentStatus | ''>('')
|
||||
|
||||
const enrollments = useQuery({
|
||||
queryKey: ['courses', 'enrollments', courseId, status],
|
||||
queryFn: async () => {
|
||||
const params = status ? `?status=${status}` : ''
|
||||
return (
|
||||
await client.get<CourseEnrollment[]>(`/admin/courses/${courseId}/enrollments${params}`)
|
||||
).data
|
||||
},
|
||||
})
|
||||
|
||||
/*
|
||||
* Eksport idzie przez axios jako blob, bo endpoint wymaga naglowka
|
||||
* Authorization: zwykly link go nie doklada.
|
||||
*/
|
||||
const download = async () => {
|
||||
try {
|
||||
const params = status ? `?status=${status}` : ''
|
||||
const response = await client.get(`/admin/courses/${courseId}/enrollments/export${params}`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
const url = URL.createObjectURL(response.data as Blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `kursanci-${courseId}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się pobrać pliku'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Select
|
||||
className="max-w-xs"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as EnrollmentStatus | '')}
|
||||
aria-label="Filtr statusu zapisu"
|
||||
>
|
||||
<option value="">Wszystkie statusy</option>
|
||||
{(Object.keys(ENROLLMENT_STATUS_LABEL) as EnrollmentStatus[]).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{ENROLLMENT_STATUS_LABEL[value]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button variant="secondary" icon="download" onClick={download} className="ml-auto">
|
||||
Eksport CSV
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{enrollments.isPending && <Spinner />}
|
||||
{enrollments.isError && (
|
||||
<ErrorState message={errorMessage(enrollments.error, 'Nie udało się wczytać listy')} />
|
||||
)}
|
||||
|
||||
{enrollments.data && enrollments.data.length === 0 && (
|
||||
<EmptyState
|
||||
icon="users"
|
||||
title="Nikt nie jest jeszcze zapisany"
|
||||
description="Zapisy kursantów dojdą w kolejnym kroku, razem z częścią publiczną."
|
||||
/>
|
||||
)}
|
||||
|
||||
{enrollments.data && enrollments.data.length > 0 && (
|
||||
<Card className="overflow-hidden">
|
||||
<ul className="divide-y divide-slate-100 lg:hidden">
|
||||
{enrollments.data.map((item) => (
|
||||
<li key={item.id} className="py-3 first:pt-0 last:pb-0">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{item.firstName} {item.lastName}
|
||||
</p>
|
||||
<p className="hx-data text-xs text-slate-500">{item.email}</p>
|
||||
</div>
|
||||
<Badge tone={ENROLLMENT_STATUS_TONE[item.status]}>
|
||||
{ENROLLMENT_STATUS_LABEL[item.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<ProgressBar value={item.progressPercent} />
|
||||
<p className="hx-data mt-1 text-xs text-slate-500">
|
||||
{item.progressPercent}% · {item.completedRequired}/{item.totalRequired} lekcji
|
||||
wymaganych
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 text-left text-xs uppercase tracking-caps text-slate-500">
|
||||
<th className="px-3 py-2 font-medium">Kursant</th>
|
||||
<th className="px-3 py-2 font-medium">Firma</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Postęp</th>
|
||||
<th className="px-3 py-2 font-medium">Zapisano</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{enrollments.data.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-slate-25">
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="font-medium text-slate-900">
|
||||
{item.firstName} {item.lastName}
|
||||
</p>
|
||||
<p className="hx-data text-xs text-slate-500">{item.email}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-slate-600">{item.company ?? '-'}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge tone={ENROLLMENT_STATUS_TONE[item.status]}>
|
||||
{ENROLLMENT_STATUS_LABEL[item.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="w-40">
|
||||
<ProgressBar value={item.progressPercent} />
|
||||
<p className="hx-data mt-1 text-xs text-slate-500">
|
||||
{item.progressPercent}% · {item.completedRequired}/{item.totalRequired}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="hx-data px-3 py-2.5 text-slate-600">
|
||||
{formatLocal(item.enrolledAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressBar({ value }: { value: number }) {
|
||||
return (
|
||||
<div
|
||||
className="h-2 w-full overflow-hidden rounded-full bg-slate-100"
|
||||
role="progressbar"
|
||||
aria-valuenow={value}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`Postęp ${value} procent`}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500 transition-all duration-base ease-standard"
|
||||
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage, fieldErrors } from '../../api/client'
|
||||
import type { Course, CourseLevel } from '../../api/types'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
ErrorState,
|
||||
Field,
|
||||
Input,
|
||||
Select,
|
||||
Spinner,
|
||||
Textarea,
|
||||
} from '../../components/ui'
|
||||
import { ContentEditor } from '../../components/ContentEditor'
|
||||
import { MediaField } from '../../components/MediaField'
|
||||
import { FormTabs } from '../../components/FormTabs'
|
||||
import type { FormTab } from '../../components/FormTabs'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { usePublicConfig } from '../../lib/usePublicConfig'
|
||||
import { COURSE_LEVEL_LABEL } from './courseLabels'
|
||||
import { panelPath } from '../../lib/routes'
|
||||
|
||||
type Form = {
|
||||
title: string
|
||||
slug: string
|
||||
subtitle: string
|
||||
description: string
|
||||
contentHtml: string
|
||||
coverMediaId: string
|
||||
category: string
|
||||
level: CourseLevel | ''
|
||||
durationMinutes: string
|
||||
priceGross: string
|
||||
currency: string
|
||||
enrollmentOpen: boolean
|
||||
certificateEnabled: boolean
|
||||
certificateHours: string
|
||||
passThresholdPercent: string
|
||||
}
|
||||
|
||||
const EMPTY: Form = {
|
||||
title: '',
|
||||
slug: '',
|
||||
subtitle: '',
|
||||
description: '',
|
||||
contentHtml: '',
|
||||
coverMediaId: '',
|
||||
category: '',
|
||||
level: '',
|
||||
durationMinutes: '',
|
||||
priceGross: '',
|
||||
currency: 'PLN',
|
||||
enrollmentOpen: true,
|
||||
certificateEnabled: false,
|
||||
certificateHours: '',
|
||||
passThresholdPercent: '75',
|
||||
}
|
||||
|
||||
/**
|
||||
* Zakladki formularza.
|
||||
*
|
||||
* Przelaczaja widocznosc, nie trasy: formularz zostaje jeden, z jednym
|
||||
* przyciskiem zapisu, wiec przejscie miedzy zakladkami nie gubi niezapisanych
|
||||
* zmian. Lista `fields` pozwala oznaczyc zakladke z bledem walidacji.
|
||||
*/
|
||||
const TABS: FormTab[] = [
|
||||
{ id: 'podstawowe', label: 'Podstawowe', icon: 'file-text', fields: ['title', 'slug', 'subtitle', 'description'] },
|
||||
{ id: 'tresc', label: 'Opis kursu', icon: 'pencil', fields: ['contentHtml'] },
|
||||
{ id: 'cennik', label: 'Cennik', icon: 'send', fields: ['priceGross', 'currency'] },
|
||||
{ id: 'certyfikat', label: 'Certyfikat', icon: 'check-circle', fields: ['certificateHours', 'passThresholdPercent'] },
|
||||
]
|
||||
|
||||
export function CourseFormPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const editing = Boolean(id)
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const config = usePublicConfig()
|
||||
|
||||
const [form, setForm] = useState<Form>(EMPTY)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [activeTab, setActiveTab] = useState<string>(TABS[0].id)
|
||||
const [slugTouched, setSlugTouched] = useState(false)
|
||||
|
||||
const course = useQuery({
|
||||
queryKey: ['courses', 'detail', id],
|
||||
queryFn: async () => (await client.get<Course>(`/admin/courses/${id}`)).data,
|
||||
enabled: editing,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!course.data) return
|
||||
const data = course.data
|
||||
setForm({
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
subtitle: data.subtitle ?? '',
|
||||
description: data.description ?? '',
|
||||
contentHtml: data.contentHtml ?? '',
|
||||
coverMediaId: data.coverMediaId ? String(data.coverMediaId) : '',
|
||||
category: data.category ?? '',
|
||||
level: data.level ?? '',
|
||||
durationMinutes: data.durationMinutes ? String(data.durationMinutes) : '',
|
||||
priceGross: data.priceGross === null ? '' : String(data.priceGross),
|
||||
currency: data.currency,
|
||||
enrollmentOpen: data.enrollmentOpen,
|
||||
certificateEnabled: data.certificateEnabled,
|
||||
certificateHours: data.certificateHours === null ? '' : String(data.certificateHours),
|
||||
passThresholdPercent: String(data.passThresholdPercent),
|
||||
})
|
||||
setSlugTouched(true)
|
||||
}, [course.data])
|
||||
|
||||
const update = <K extends keyof Form>(key: K, value: Form[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
setErrors((prev) => ({ ...prev, [key]: '' }))
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body = {
|
||||
title: form.title,
|
||||
slug: form.slug || null,
|
||||
subtitle: form.subtitle || null,
|
||||
description: form.description || null,
|
||||
contentHtml: form.contentHtml || null,
|
||||
coverMediaId: form.coverMediaId ? Number(form.coverMediaId) : null,
|
||||
category: form.category || null,
|
||||
level: form.level || null,
|
||||
durationMinutes: form.durationMinutes ? Number(form.durationMinutes) : null,
|
||||
// pusta cena oznacza kurs darmowy, a nie cene zerowa
|
||||
priceGross: form.priceGross === '' ? null : Number(form.priceGross),
|
||||
currency: form.currency || 'PLN',
|
||||
enrollmentOpen: form.enrollmentOpen,
|
||||
certificateEnabled: form.certificateEnabled,
|
||||
certificateHours: form.certificateHours === '' ? null : Number(form.certificateHours),
|
||||
passThresholdPercent: Number(form.passThresholdPercent || '75'),
|
||||
}
|
||||
return editing
|
||||
? (await client.put<Course>(`/admin/courses/${id}`, body)).data
|
||||
: (await client.post<Course>('/admin/courses', body)).data
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
toasts.success(editing ? 'Kurs zapisany' : 'Kurs utworzony')
|
||||
queryClient.invalidateQueries({ queryKey: ['courses'] })
|
||||
navigate(panelPath(`/kursy/${saved.id}`))
|
||||
},
|
||||
onError: (error) => {
|
||||
const fields = fieldErrors(error)
|
||||
setErrors(fields)
|
||||
// blad na nieaktywnej zakladce byl by niewidoczny, wiec przeskakujemy
|
||||
const faulty = TABS.find((tab) => (tab.fields ?? []).some((field) => fields[field]))
|
||||
if (faulty) setActiveTab(faulty.id)
|
||||
toasts.error(errorMessage(error, 'Nie udało się zapisać kursu'))
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const found: Record<string, string> = {}
|
||||
if (!form.title.trim()) found.title = 'Tytuł jest wymagany'
|
||||
|
||||
const threshold = Number(form.passThresholdPercent)
|
||||
if (Number.isNaN(threshold) || threshold < 0 || threshold > 100) {
|
||||
found.passThresholdPercent = 'Próg musi mieścić się między 0 a 100'
|
||||
}
|
||||
if (form.priceGross !== '' && Number(form.priceGross) < 0) {
|
||||
found.priceGross = 'Cena nie może być ujemna'
|
||||
}
|
||||
|
||||
if (Object.keys(found).length > 0) {
|
||||
setErrors(found)
|
||||
const faulty = TABS.find((tab) => (tab.fields ?? []).some((field) => found[field]))
|
||||
if (faulty) setActiveTab(faulty.id)
|
||||
toasts.error('Formularz zawiera błędy')
|
||||
return
|
||||
}
|
||||
save.mutate()
|
||||
}
|
||||
|
||||
if (editing && course.isPending) return <Spinner />
|
||||
if (editing && course.isError) {
|
||||
return <ErrorState message={errorMessage(course.error, 'Nie udało się wczytać kursu')} />
|
||||
}
|
||||
|
||||
const publicUrl = config.publicUrl ?? ''
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Button variant="ghost" icon="arrow-left" type="button" onClick={() => navigate(panelPath('/kursy'))}>
|
||||
Wróć
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">
|
||||
{editing ? 'Edycja kursu' : 'Nowy kurs'}
|
||||
</h1>
|
||||
{editing && <p className="text-sm text-slate-500">{course.data?.title}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormTabs tabs={TABS} active={activeTab} onChange={setActiveTab} errors={errors} />
|
||||
|
||||
{activeTab === 'podstawowe' && (
|
||||
<Card title="Podstawowe dane">
|
||||
<div className="space-y-4">
|
||||
<Field label="Tytuł" error={errors.title}>
|
||||
<Input
|
||||
value={form.title}
|
||||
onChange={(e) => {
|
||||
update('title', e.target.value)
|
||||
if (!slugTouched) update('slug', '')
|
||||
}}
|
||||
placeholder="np. Ocena ryzyka w wodociągach"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Adres strony kursu"
|
||||
error={errors.slug}
|
||||
hint="Puste oznacza wygenerowanie z tytułu. Konflikt rozstrzyga sufiks liczbowy."
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="hx-data shrink-0 rounded-md bg-slate-50 px-2 py-2 text-xs text-slate-500">
|
||||
{publicUrl}/kurs/
|
||||
</span>
|
||||
<Input
|
||||
className="min-w-0 flex-1"
|
||||
value={form.slug}
|
||||
onChange={(e) => {
|
||||
setSlugTouched(true)
|
||||
update('slug', e.target.value)
|
||||
}}
|
||||
placeholder="powstanie z tytułu"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="Podtytuł" error={errors.subtitle} hint="Jedno zdanie pod tytułem, na karcie kursu.">
|
||||
<Input value={form.subtitle} onChange={(e) => update('subtitle', e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Krótki opis"
|
||||
error={errors.description}
|
||||
hint="Jedno, dwa zdania. Zajawka na liście kursów. Bez formatowania."
|
||||
>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={form.description}
|
||||
onChange={(e) => update('description', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Kategoria" error={errors.category}>
|
||||
<Input
|
||||
value={form.category}
|
||||
onChange={(e) => update('category', e.target.value)}
|
||||
placeholder="np. Bezpieczeństwo wody"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Poziom" error={errors.level}>
|
||||
<Select
|
||||
value={form.level}
|
||||
onChange={(e) => update('level', e.target.value as CourseLevel | '')}
|
||||
>
|
||||
<option value="">Nie określono</option>
|
||||
{(Object.keys(COURSE_LEVEL_LABEL) as CourseLevel[]).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{COURSE_LEVEL_LABEL[value]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Deklarowany czas trwania"
|
||||
error={errors.durationMinutes}
|
||||
hint="W minutach. Wartość pokazywana na karcie, niezależna od sumy czasów lekcji."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.durationMinutes}
|
||||
onChange={(e) => update('durationMinutes', e.target.value)}
|
||||
placeholder="np. 480"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Okładka kursu" hint="Plik z biblioteki mediów. Obcy adres nie jest dozwolony.">
|
||||
<MediaField
|
||||
value={form.coverMediaId}
|
||||
onChange={(value) => update('coverMediaId', value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-200 p-3 transition-colors duration-fast ease-standard hover:bg-slate-25">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enrollmentOpen}
|
||||
onChange={(e) => update('enrollmentOpen', e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
|
||||
/>
|
||||
<span className="min-w-0 text-sm leading-relaxed text-slate-700">
|
||||
<strong className="text-slate-900">Zapisy otwarte</strong>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
To Twoja decyzja, a nie stan wyliczany. Kurs niepublikowany i tak nie przyjmuje
|
||||
zapisów.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'tresc' && (
|
||||
<Card title="Opis kursu" description="Treść strony kursu. Pojawi się nad programem.">
|
||||
<Field label="Treść" wide>
|
||||
<ContentEditor
|
||||
value={form.contentHtml}
|
||||
onChange={(html) => update('contentHtml', html)}
|
||||
/>
|
||||
</Field>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'cennik' && (
|
||||
<Card title="Cennik">
|
||||
<div className="space-y-4">
|
||||
<Alert tone="info">
|
||||
Puste pole ceny oznacza <strong>kurs darmowy</strong>. To co innego niż cena 0,00:
|
||||
pusta cena mówi, że kurs jest bezpłatny z założenia, a cena zerowa, że cennik
|
||||
ustalono na zero.
|
||||
</Alert>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Cena brutto" error={errors.priceGross} hint="Puste = kurs darmowy.">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={form.priceGross}
|
||||
onChange={(e) => update('priceGross', e.target.value)}
|
||||
placeholder="darmowy"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Waluta" error={errors.currency}>
|
||||
<Input
|
||||
value={form.currency}
|
||||
onChange={(e) => update('currency', e.target.value.toUpperCase())}
|
||||
maxLength={3}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Alert tone="warn">
|
||||
Obsługa płatności dojdzie osobno. Na razie cena jest wyłącznie informacją w cenniku,
|
||||
a nie rozliczeniem.
|
||||
</Alert>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'certyfikat' && (
|
||||
<Card title="Certyfikat i zaliczenie">
|
||||
<div className="space-y-4">
|
||||
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-200 p-3 transition-colors duration-fast ease-standard hover:bg-slate-25">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.certificateEnabled}
|
||||
onChange={(e) => update('certificateEnabled', e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
|
||||
/>
|
||||
<span className="min-w-0 text-sm leading-relaxed text-slate-700">
|
||||
<strong className="text-slate-900">Kurs wydaje certyfikat</strong>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
Po zaliczeniu kursu, czyli po przekroczeniu progu poniżej.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Field
|
||||
label="Liczba godzin szkoleniowych"
|
||||
error={errors.certificateHours}
|
||||
hint="Trafia na certyfikat. Puste oznacza, że dokument jej nie podaje."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.5"
|
||||
value={form.certificateHours}
|
||||
onChange={(e) => update('certificateHours', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Próg zaliczenia (%)"
|
||||
error={errors.passThresholdPercent}
|
||||
hint="Liczony wyłącznie z lekcji oznaczonych jako wymagane. Materiały dodatkowe nie wpływają na wynik."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={form.passThresholdPercent}
|
||||
onChange={(e) => update('passThresholdPercent', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button variant="secondary" type="button" onClick={() => navigate(panelPath('/kursy'))}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button type="submit" icon="save" disabled={save.isPending}>
|
||||
{save.isPending ? 'Zapisywanie...' : 'Zapisz'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!editing && (
|
||||
<p className="text-sm text-slate-500">
|
||||
Program kursu, czyli moduły i lekcje, dodasz po zapisaniu.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Course, CourseStatus } from '../../api/types'
|
||||
import type { Page } from '../../api/types'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Input,
|
||||
Select,
|
||||
Spinner,
|
||||
} from '../../components/ui'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||
import { SortableHeader } from '../../components/SortableHeader'
|
||||
import type { SortState } from '../../components/SortableHeader'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { COURSE_STATUS_LABEL, COURSE_STATUS_TONE, formatPrice } from './courseLabels'
|
||||
import { panelPath } from '../../lib/routes'
|
||||
|
||||
/**
|
||||
* Lista kursow.
|
||||
*
|
||||
* Filtrowanie i paginacja po stronie backendu, tak samo jak przy webinarach:
|
||||
* baza kursow rosnie, a filtrowanie pobranej calosci w przegladarce przestaje
|
||||
* dzialac dokladnie wtedy, gdy zaczyna byc potrzebne.
|
||||
*/
|
||||
export function CourseListPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
|
||||
const [page, setPage] = useState(0)
|
||||
const [status, setStatus] = useState<CourseStatus | ''>('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [debounced, setDebounced] = useState('')
|
||||
const [sort, setSort] = useState<SortState>({ field: 'createdAt', direction: 'desc' })
|
||||
|
||||
// wpisywanie frazy nie moze wysylac zadania przy kazdej literze
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(search.trim()), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [search])
|
||||
|
||||
// zmiana filtru cofa na pierwsza strone: inaczej po zawezeniu wyniku
|
||||
// uzytkownik stojacy na stronie 2 widzi pusta tabele
|
||||
useEffect(() => {
|
||||
setPage(0)
|
||||
}, [status, category, debounced, sort])
|
||||
|
||||
const courses = useQuery({
|
||||
queryKey: ['courses', page, status, category, debounced, sort],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
size: String(PAGE_SIZE),
|
||||
sort: sort.field,
|
||||
direction: sort.direction,
|
||||
})
|
||||
if (status) params.set('status', status)
|
||||
if (category) params.set('category', category)
|
||||
if (debounced) params.set('search', debounced)
|
||||
return (await client.get<Page<Course>>(`/admin/courses?${params}`)).data
|
||||
},
|
||||
})
|
||||
|
||||
const categories = useQuery({
|
||||
queryKey: ['courses', 'categories'],
|
||||
queryFn: async () => (await client.get<string[]>('/admin/courses/categories')).data,
|
||||
})
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['courses'] })
|
||||
|
||||
const publish = useMutation({
|
||||
mutationFn: (id: number) => client.post(`/admin/courses/${id}/publish`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Kurs został opublikowany')
|
||||
invalidate()
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się opublikować kursu')),
|
||||
})
|
||||
|
||||
const archive = useMutation({
|
||||
mutationFn: (id: number) => client.post(`/admin/courses/${id}/archive`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Kurs został zarchiwizowany')
|
||||
invalidate()
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zarchiwizować kursu')),
|
||||
})
|
||||
|
||||
const copy = useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
client.post<{ id: number; slug: string; title: string }>(`/admin/courses/${id}/copy`, {}),
|
||||
onSuccess: (response) => {
|
||||
toasts.success('Kurs skopiowany. Kopia jest wersją roboczą.')
|
||||
invalidate()
|
||||
// od razu do edycji kopii: zwykle trzeba poprawic tytul i program
|
||||
navigate(panelPath(`/kursy/${response.data.id}/edycja`))
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się skopiować kursu')),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Kursy</h1>
|
||||
<p className="text-sm text-slate-500">Kursy on-line Akademii</p>
|
||||
</div>
|
||||
<Button icon="plus" onClick={() => navigate(panelPath('/kursy/nowy'))}>
|
||||
Nowy kurs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Input
|
||||
placeholder="Szukaj po tytule..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
aria-label="Szukaj kursu"
|
||||
/>
|
||||
<Select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as CourseStatus | '')}
|
||||
aria-label="Filtr statusu"
|
||||
>
|
||||
<option value="">Wszystkie statusy</option>
|
||||
<option value="DRAFT">Wersja robocza</option>
|
||||
<option value="PUBLISHED">Opublikowany</option>
|
||||
<option value="ARCHIVED">Zarchiwizowany</option>
|
||||
</Select>
|
||||
<Select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
aria-label="Filtr kategorii"
|
||||
>
|
||||
<option value="">Wszystkie kategorie</option>
|
||||
{(categories.data ?? []).map((item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{courses.isPending && <Spinner />}
|
||||
{courses.isError && (
|
||||
<ErrorState message={errorMessage(courses.error, 'Nie udało się wczytać kursów')} />
|
||||
)}
|
||||
|
||||
{courses.data && courses.data.content.length === 0 && (
|
||||
<EmptyState
|
||||
icon="file-text"
|
||||
title="Nie ma jeszcze żadnego kursu"
|
||||
description="Utwórz pierwszy kurs, dodaj moduły i lekcje, a potem opublikuj."
|
||||
/>
|
||||
)}
|
||||
|
||||
{courses.data && courses.data.content.length > 0 && (
|
||||
<Card className="overflow-hidden">
|
||||
{/* karty do lg, tabela od lg: tabela z min-w rozpychalaby telefon */}
|
||||
<ul className="divide-y divide-slate-100 lg:hidden">
|
||||
{courses.data.content.map((course) => (
|
||||
<li key={course.id} className="py-3 first:pt-0 last:pb-0">
|
||||
<CourseCard
|
||||
course={course}
|
||||
onPublish={() => publish.mutate(course.id)}
|
||||
onArchive={() => archive.mutate(course.id)}
|
||||
onCopy={() => copy.mutate(course.id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 text-left text-xs uppercase tracking-caps text-slate-500">
|
||||
<SortableHeader field="title" sort={sort} onSort={setSort}>
|
||||
Tytuł
|
||||
</SortableHeader>
|
||||
<SortableHeader field="category" sort={sort} onSort={setSort}>
|
||||
Kategoria
|
||||
</SortableHeader>
|
||||
<SortableHeader field="priceGross" sort={sort} onSort={setSort}>
|
||||
Cena
|
||||
</SortableHeader>
|
||||
<SortableHeader field="status" sort={sort} onSort={setSort}>
|
||||
Status
|
||||
</SortableHeader>
|
||||
{/*
|
||||
Zapisani i Lekcje celowo nie sa sortowalne: obie liczby
|
||||
powstaja osobnym zapytaniem do juz pobranej strony, wiec
|
||||
baza nie potrafi po nich sortowac, a sortowanie samej
|
||||
biezacej strony wprowadzaloby w blad.
|
||||
*/}
|
||||
<th className="px-3 py-2 font-medium">Zapisani</th>
|
||||
<th className="px-3 py-2 font-medium">Lekcje</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{courses.data.content.map((course) => (
|
||||
<tr key={course.id} className="hover:bg-slate-25">
|
||||
<td className="px-3 py-2.5">
|
||||
<Link
|
||||
to={panelPath(`/kursy/${course.id}`)}
|
||||
className="font-medium text-slate-900 hover:text-blue-700"
|
||||
>
|
||||
{course.title}
|
||||
</Link>
|
||||
{course.subtitle && (
|
||||
<p className="mt-0.5 text-xs text-slate-500">{course.subtitle}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-slate-600">{course.category ?? '-'}</td>
|
||||
<td className="hx-data px-3 py-2.5">{formatPrice(course)}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge tone={COURSE_STATUS_TONE[course.status]}>
|
||||
{COURSE_STATUS_LABEL[course.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="hx-data px-3 py-2.5 text-slate-600">{course.enrolledCount}</td>
|
||||
<td className="hx-data px-3 py-2.5 text-slate-600">{course.lessonCount}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex justify-end gap-1">
|
||||
<RowActions
|
||||
course={course}
|
||||
onPublish={() => publish.mutate(course.id)}
|
||||
onArchive={() => archive.mutate(course.id)}
|
||||
onCopy={() => copy.mutate(course.id)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="pt-3">
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={courses.data.totalPages}
|
||||
totalElements={courses.data.totalElements}
|
||||
onChange={setPage}
|
||||
itemLabel="kursów"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CourseCard({
|
||||
course,
|
||||
onPublish,
|
||||
onArchive,
|
||||
onCopy,
|
||||
}: {
|
||||
course: Course
|
||||
onPublish: () => void
|
||||
onArchive: () => void
|
||||
onCopy: () => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={panelPath(`/kursy/${course.id}`)}
|
||||
className="text-sm font-medium text-slate-900 hover:text-blue-700"
|
||||
>
|
||||
{course.title}
|
||||
</Link>
|
||||
<p className="hx-data mt-0.5 text-xs text-slate-500">
|
||||
{formatPrice(course)} · {course.lessonCount} lekcji · {course.enrolledCount} zapisanych
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">{formatLocal(course.createdAt)}</p>
|
||||
</div>
|
||||
<Badge tone={COURSE_STATUS_TONE[course.status]}>
|
||||
{COURSE_STATUS_LABEL[course.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<RowActions
|
||||
course={course}
|
||||
onPublish={onPublish}
|
||||
onArchive={onArchive}
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RowActions({
|
||||
course,
|
||||
onPublish,
|
||||
onArchive,
|
||||
onCopy,
|
||||
}: {
|
||||
course: Course
|
||||
onPublish: () => void
|
||||
onArchive: () => void
|
||||
onCopy: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<IconLink to={panelPath(`/kursy/${course.id}/edycja`)} icon="pencil" label="Edytuj kurs" />
|
||||
|
||||
{course.status !== 'PUBLISHED' && course.status !== 'ARCHIVED' && (
|
||||
<ActionButton
|
||||
icon="upload"
|
||||
label="Publikuj kurs"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Opublikować kurs „${course.title}”?`)) {
|
||||
onPublish()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{course.status !== 'ARCHIVED' && (
|
||||
<ActionButton
|
||||
icon="history"
|
||||
label="Archiwizuj kurs"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Zarchiwizować kurs „${course.title}”?\n\n` +
|
||||
'Kurs zniknie z oferty, ale osoby już zapisane zachowają dostęp.',
|
||||
)
|
||||
) {
|
||||
onArchive()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ActionButton
|
||||
icon="copy"
|
||||
label="Kopiuj kurs"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Skopiować kurs „${course.title}”?\n\n` +
|
||||
'Kopia przejmie program, opis i ustawienia certyfikatu.\n' +
|
||||
'Nie przejdą zapisy kursantów ani ich postęp.',
|
||||
)
|
||||
) {
|
||||
onCopy()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ACTION_CLASS =
|
||||
'flex h-11 w-11 items-center justify-center rounded-md text-slate-500 transition duration-fast ease-standard hover:bg-slate-50 hover:text-slate-900'
|
||||
|
||||
function ActionButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
icon: Parameters<typeof Icon>[0]['name']
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} title={label} aria-label={label} className={ACTION_CLASS}>
|
||||
<Icon name={icon} size={18} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function IconLink({
|
||||
to,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
to: string
|
||||
icon: Parameters<typeof Icon>[0]['name']
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<Link to={to} title={label} aria-label={label} className={ACTION_CLASS}>
|
||||
<Icon name={icon} size={18} />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart } from '@mui/x-charts/BarChart'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { CourseStats } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import type { IconName } from '../../components/Icon'
|
||||
import { Card, ErrorState, Spinner } from '../../components/ui'
|
||||
|
||||
/*
|
||||
* Kolory podane wprost, bo biblioteka wykresow nie czyta klas Tailwinda.
|
||||
* Musza odpowiadac tokenom z tailwind.config.js.
|
||||
*/
|
||||
const HYDRO_AZURE = '#2C8ABD'
|
||||
const OK = '#16935C'
|
||||
const ALARM = '#D8392F'
|
||||
|
||||
/**
|
||||
* Statystyki kursu.
|
||||
*
|
||||
* Wszystkie liczby licza sie w bazie, zapytaniami agregujacymi: ten sam
|
||||
* wzorzec co przy statystykach webinaru. Ten plik importuje wykresy statycznie,
|
||||
* a leniwe ladowanie siedzi w stronie nadrzednej, dokladnie tak jak przy
|
||||
* `StatsSection`: `@mui/x-charts` wazy wiecej niz reszta panelu, a statystyki
|
||||
* ogladane sa rzadziej niz sam kurs.
|
||||
*/
|
||||
export function CourseStatsSection({ courseId }: { courseId: number }) {
|
||||
const { data, isPending, isError, error } = useQuery({
|
||||
queryKey: ['course-stats', courseId],
|
||||
queryFn: async () =>
|
||||
(await client.get<CourseStats>(`/admin/academy/courses/${courseId}/stats`)).data,
|
||||
})
|
||||
|
||||
if (isPending) return <Spinner />
|
||||
if (isError || !data) {
|
||||
return <ErrorState message={errorMessage(error, 'Nie udało się wczytać statystyk')} />
|
||||
}
|
||||
|
||||
const hasEnrollments = data.enrolled > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<Metric icon="users" label="Zapisani" value={String(data.enrolled)} />
|
||||
<Metric
|
||||
icon="check-circle"
|
||||
label="Ukończyli"
|
||||
value={`${data.completed} (${data.completionRate}%)`}
|
||||
tone={data.completed > 0 ? 'ok' : 'neutral'}
|
||||
/>
|
||||
<Metric icon="book-open" label="Średni postęp" value={`${data.averageProgress}%`} />
|
||||
<Metric icon="award" label="Certyfikaty" value={String(data.certificatesIssued)} />
|
||||
</div>
|
||||
|
||||
{/* --- rozklad postepu --- */}
|
||||
<Card
|
||||
title="Rozkład postępu"
|
||||
description="Ilu kursantów jest na jakim etapie. Liczone z lekcji wymaganych."
|
||||
>
|
||||
{hasEnrollments ? (
|
||||
<div className="overflow-x-auto">
|
||||
<BarChart
|
||||
height={240}
|
||||
xAxis={[{ scaleType: 'band', data: data.distribution.map((b) => b.label) }]}
|
||||
yAxis={[{ min: 0 }]}
|
||||
series={[
|
||||
{
|
||||
data: data.distribution.map((b) => b.count),
|
||||
label: 'Kursanci',
|
||||
color: HYDRO_AZURE,
|
||||
},
|
||||
]}
|
||||
margin={{ left: 40, right: 16, top: 16, bottom: 40 }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyChart text="Nikt jeszcze nie zapisał się na ten kurs." />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* --- quiz --- */}
|
||||
<Card
|
||||
title="Sprawdziany"
|
||||
description="Liczone wyłącznie z podejść zakończonych: trwające nie mają wyniku."
|
||||
>
|
||||
{data.quizPassRate === null ? (
|
||||
<EmptyChart text="Ten kurs nie ma sprawdzianu albo nikt jeszcze go nie rozwiązał." />
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Metric
|
||||
icon="list-checks"
|
||||
label="Zdawalność"
|
||||
value={`${data.quizPassRate}%`}
|
||||
tone={data.quizPassRate >= 50 ? 'ok' : 'warn'}
|
||||
/>
|
||||
<Metric
|
||||
icon="check"
|
||||
label="Średni wynik"
|
||||
value={`${data.quizAverageScore}%`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* --- porzucane lekcje --- */}
|
||||
<Card
|
||||
title="Lekcje, na których kursanci przestają"
|
||||
description="Kursant doszedł do tej lekcji i nie ukończył żadnej późniejszej. To one wymagają poprawy."
|
||||
>
|
||||
{data.dropOff.length === 0 || data.dropOff.every((l) => l.stoppedHere === 0) ? (
|
||||
<EmptyChart text="Nikt jeszcze nie utknął na żadnej lekcji." />
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<BarChart
|
||||
height={260}
|
||||
xAxis={[
|
||||
{
|
||||
scaleType: 'band',
|
||||
data: data.dropOff.slice(0, 8).map((l) => shorten(l.lessonTitle)),
|
||||
},
|
||||
]}
|
||||
yAxis={[{ min: 0 }]}
|
||||
series={[
|
||||
{
|
||||
data: data.dropOff.slice(0, 8).map((l) => l.stoppedHere),
|
||||
label: 'Przestali tutaj',
|
||||
color: ALARM,
|
||||
},
|
||||
{
|
||||
data: data.dropOff.slice(0, 8).map((l) => l.completedCount),
|
||||
label: 'Ukończyli',
|
||||
color: OK,
|
||||
},
|
||||
]}
|
||||
margin={{ left: 40, right: 16, top: 16, bottom: 60 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul className="mt-4 space-y-2">
|
||||
{data.dropOff
|
||||
.filter((l) => l.stoppedHere > 0)
|
||||
.slice(0, 5)
|
||||
.map((lesson) => (
|
||||
<li
|
||||
key={lesson.lessonId}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900">{lesson.lessonTitle}</p>
|
||||
<p className="text-xs text-slate-500">{lesson.moduleTitle}</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">
|
||||
przestało:{' '}
|
||||
<strong className="hx-data text-alarm-600">{lesson.stoppedHere}</strong>
|
||||
<span className="mx-2 text-slate-300">·</span>
|
||||
ukończyło: <span className="hx-data">{lesson.completedCount}</span>
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Dlugi tytul lekcji rozpycha os wykresu, wiec go skracamy. */
|
||||
function shorten(title: string) {
|
||||
return title.length > 18 ? title.slice(0, 17) + '…' : title
|
||||
}
|
||||
|
||||
function Metric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
icon: IconName
|
||||
label: string
|
||||
value: string
|
||||
tone?: 'neutral' | 'ok' | 'warn'
|
||||
}) {
|
||||
const tones = {
|
||||
neutral: 'text-slate-400',
|
||||
ok: 'text-ok-500',
|
||||
warn: 'text-warn-500',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-100 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name={icon} size={16} className={tones[tone]} />
|
||||
<p className="hx-eyebrow">{label}</p>
|
||||
</div>
|
||||
<p className="hx-data mt-1.5 text-2xl font-bold text-slate-900">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyChart({ text }: { text: string }) {
|
||||
return (
|
||||
<p className="rounded-md border border-dashed border-slate-200 p-6 text-center text-sm text-slate-500">
|
||||
{text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Curriculum, CourseModule, Lesson } from '../../api/types'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Input,
|
||||
Spinner,
|
||||
} from '../../components/ui'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { LESSON_TYPE_ICON, LESSON_TYPE_LABEL, formatDuration } from './courseLabels'
|
||||
import { LessonEditor } from './LessonEditor'
|
||||
|
||||
/**
|
||||
* Program kursu: drzewo modulow i lekcji.
|
||||
*
|
||||
* ZMIANA KOLEJNOSCI: PRZECIAGANIE **I** PRZYCISKI
|
||||
* ---------------------------------------------------------------------------
|
||||
* Przeciaganie korzysta z natywnego HTML5 drag and drop, bez zadnej
|
||||
* biblioteki: dziala w kazdej przegladarce i nic nie dokladamy do zaleznosci.
|
||||
*
|
||||
* Przyciski gora i dol nie sa dodatkiem "gdyby cos": natywne przeciaganie jest
|
||||
* **nieosiagalne z klawiatury**, a na ekranie dotykowym niewygodne. Bez nich
|
||||
* osoba nieuzywajaca myszy nie zmienilaby kolejnosci w ogole. Kazdy przycisk
|
||||
* ma etykiete dla czytnika ekranu i normalne pole dotykowe.
|
||||
*
|
||||
* Zapis idzie **cala lista naraz**, nie po jednym przesunieciu: docelowy uklad
|
||||
* i tak znamy, a zapis "przesun o jeden" wymagalby tylu zadan, ile pozycji
|
||||
* przeskoczyl element.
|
||||
*/
|
||||
export function CurriculumSection({ courseId }: { courseId: number }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
|
||||
const [newModuleTitle, setNewModuleTitle] = useState('')
|
||||
const [editingLesson, setEditingLesson] = useState<{ lesson: Lesson | null; moduleId: number } | null>(null)
|
||||
const [dragged, setDragged] = useState<{ kind: 'module' | 'lesson'; id: number; moduleId?: number } | null>(null)
|
||||
|
||||
const curriculum = useQuery({
|
||||
queryKey: ['courses', 'curriculum', courseId],
|
||||
queryFn: async () =>
|
||||
(await client.get<Curriculum>(`/admin/courses/${courseId}/curriculum`)).data,
|
||||
})
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['courses', 'curriculum', courseId] })
|
||||
// liczba lekcji jest na liscie kursow i na karcie
|
||||
queryClient.invalidateQueries({ queryKey: ['courses'] })
|
||||
}
|
||||
|
||||
const addModule = useMutation({
|
||||
mutationFn: (title: string) =>
|
||||
client.post(`/admin/courses/${courseId}/modules`, { title }),
|
||||
onSuccess: () => {
|
||||
setNewModuleTitle('')
|
||||
invalidate()
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się dodać modułu')),
|
||||
})
|
||||
|
||||
const renameModule = useMutation({
|
||||
mutationFn: ({ id, title }: { id: number; title: string }) =>
|
||||
client.put(`/admin/courses/${courseId}/modules/${id}`, { title }),
|
||||
onSuccess: invalidate,
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić nazwy')),
|
||||
})
|
||||
|
||||
const deleteModule = useMutation({
|
||||
mutationFn: (id: number) => client.delete(`/admin/courses/${courseId}/modules/${id}`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Moduł usunięty')
|
||||
invalidate()
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć modułu')),
|
||||
})
|
||||
|
||||
const reorderModules = useMutation({
|
||||
mutationFn: (ids: number[]) =>
|
||||
client.put(`/admin/courses/${courseId}/modules/order`, { ids }),
|
||||
onSuccess: invalidate,
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać kolejności')),
|
||||
})
|
||||
|
||||
const reorderLessons = useMutation({
|
||||
mutationFn: ({ moduleId, ids }: { moduleId: number; ids: number[] }) =>
|
||||
client.put(`/admin/courses/${courseId}/modules/${moduleId}/lessons/order`, { ids }),
|
||||
onSuccess: invalidate,
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać kolejności')),
|
||||
})
|
||||
|
||||
const deleteLesson = useMutation({
|
||||
mutationFn: (id: number) => client.delete(`/admin/courses/${courseId}/lessons/${id}`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Lekcja usunięta')
|
||||
invalidate()
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć lekcji')),
|
||||
})
|
||||
|
||||
/** Przenosi element listy o podana liczbe pozycji i zwraca nowa kolejnosc. */
|
||||
const movedOrder = (ids: number[], id: number, delta: number): number[] | null => {
|
||||
const from = ids.indexOf(id)
|
||||
const to = from + delta
|
||||
if (from < 0 || to < 0 || to >= ids.length) {
|
||||
return null
|
||||
}
|
||||
const next = [...ids]
|
||||
next.splice(to, 0, next.splice(from, 1)[0])
|
||||
return next
|
||||
}
|
||||
|
||||
const moveModule = (module: CourseModule, delta: number) => {
|
||||
const ids = (curriculum.data?.modules ?? []).map((m) => m.id)
|
||||
const next = movedOrder(ids, module.id, delta)
|
||||
if (next) reorderModules.mutate(next)
|
||||
}
|
||||
|
||||
const moveLesson = (module: CourseModule, lesson: Lesson, delta: number) => {
|
||||
const ids = module.lessons.map((l) => l.id)
|
||||
const next = movedOrder(ids, lesson.id, delta)
|
||||
if (next) reorderLessons.mutate({ moduleId: module.id, ids: next })
|
||||
}
|
||||
|
||||
/** Upuszczenie: przenosi przeciagany element na miejsce elementu docelowego. */
|
||||
const dropModule = (target: CourseModule) => {
|
||||
if (!dragged || dragged.kind !== 'module' || dragged.id === target.id) return
|
||||
const ids = (curriculum.data?.modules ?? []).map((m) => m.id)
|
||||
const next = ids.filter((id) => id !== dragged.id)
|
||||
next.splice(ids.indexOf(target.id), 0, dragged.id)
|
||||
reorderModules.mutate(next)
|
||||
setDragged(null)
|
||||
}
|
||||
|
||||
const dropLesson = (module: CourseModule, target: Lesson) => {
|
||||
if (!dragged || dragged.kind !== 'lesson' || dragged.id === target.id) return
|
||||
// przeciaganie miedzy modulami idzie przez edycje lekcji, nie przez upuszczenie:
|
||||
// zmiana modulu i kolejnosci naraz mylilaby sie z samym przestawieniem
|
||||
if (dragged.moduleId !== module.id) {
|
||||
toasts.error('Lekcję przenosisz między modułami w jej edycji')
|
||||
setDragged(null)
|
||||
return
|
||||
}
|
||||
const ids = module.lessons.map((l) => l.id)
|
||||
const next = ids.filter((id) => id !== dragged.id)
|
||||
next.splice(ids.indexOf(target.id), 0, dragged.id)
|
||||
reorderLessons.mutate({ moduleId: module.id, ids: next })
|
||||
setDragged(null)
|
||||
}
|
||||
|
||||
if (curriculum.isPending) return <Spinner />
|
||||
if (curriculum.isError) {
|
||||
return <ErrorState message={errorMessage(curriculum.error, 'Nie udało się wczytać programu')} />
|
||||
}
|
||||
|
||||
const modules = curriculum.data?.modules ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{modules.length === 0 && (
|
||||
<EmptyState
|
||||
icon="file-text"
|
||||
title="Program jest pusty"
|
||||
description="Dodaj pierwszy moduł, a w nim lekcje. Kursu bez lekcji nie da się opublikować."
|
||||
/>
|
||||
)}
|
||||
|
||||
{modules.map((module, moduleIndex) => (
|
||||
<Card
|
||||
key={module.id}
|
||||
className={dragged?.kind === 'module' && dragged.id === module.id ? 'opacity-50' : ''}
|
||||
>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={() => setDragged({ kind: 'module', id: module.id })}
|
||||
onDragEnd={() => setDragged(null)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => dropModule(module)}
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
title="Przeciągnij, żeby zmienić kolejność"
|
||||
className="cursor-grab text-slate-300"
|
||||
>
|
||||
<Icon name="menu" size={18} />
|
||||
</span>
|
||||
|
||||
<ModuleTitle
|
||||
title={module.title}
|
||||
onSave={(title) => renameModule.mutate({ id: module.id, title })}
|
||||
/>
|
||||
|
||||
<span className="hx-data text-xs text-slate-400">
|
||||
{module.lessons.length} {module.lessons.length === 1 ? 'lekcja' : 'lekcji'}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<MoveButtons
|
||||
label={`moduł ${module.title}`}
|
||||
canUp={moduleIndex > 0}
|
||||
canDown={moduleIndex < modules.length - 1}
|
||||
onUp={() => moveModule(module, -1)}
|
||||
onDown={() => moveModule(module, 1)}
|
||||
/>
|
||||
<IconAction
|
||||
icon="trash"
|
||||
label={`Usuń moduł ${module.title}`}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Usunąć moduł „${module.title}”?\n\n` +
|
||||
`Przepadnie razem z lekcjami: ${module.lessons.length}.`,
|
||||
)
|
||||
) {
|
||||
deleteModule.mutate(module.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{module.lessons.map((lesson, lessonIndex) => (
|
||||
<li
|
||||
key={lesson.id}
|
||||
draggable
|
||||
onDragStart={() => setDragged({ kind: 'lesson', id: lesson.id, moduleId: module.id })}
|
||||
onDragEnd={() => setDragged(null)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => dropLesson(module, lesson)}
|
||||
className={`flex flex-wrap items-center gap-2 rounded-md border border-slate-100 p-2.5 ${
|
||||
dragged?.kind === 'lesson' && dragged.id === lesson.id ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true" className="cursor-grab text-slate-300">
|
||||
<Icon name="menu" size={16} />
|
||||
</span>
|
||||
<Icon name={LESSON_TYPE_ICON[lesson.type]} size={16} className="text-slate-400" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingLesson({ lesson, moduleId: module.id })}
|
||||
className="min-w-0 flex-1 truncate text-left text-sm font-medium text-slate-900 hover:text-blue-700"
|
||||
>
|
||||
{lesson.title}
|
||||
</button>
|
||||
|
||||
<span className="hidden text-xs text-slate-400 sm:inline">
|
||||
{LESSON_TYPE_LABEL[lesson.type]}
|
||||
</span>
|
||||
{lesson.durationMinutes && (
|
||||
<span className="hx-data hidden text-xs text-slate-400 sm:inline">
|
||||
{formatDuration(lesson.durationMinutes)}
|
||||
</span>
|
||||
)}
|
||||
{!lesson.required && <Badge tone="neutral">dodatkowa</Badge>}
|
||||
{lesson.preview && <Badge tone="info">próbka</Badge>}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<MoveButtons
|
||||
label={`lekcję ${lesson.title}`}
|
||||
canUp={lessonIndex > 0}
|
||||
canDown={lessonIndex < module.lessons.length - 1}
|
||||
onUp={() => moveLesson(module, lesson, -1)}
|
||||
onDown={() => moveLesson(module, lesson, 1)}
|
||||
/>
|
||||
<IconAction
|
||||
icon="trash"
|
||||
label={`Usuń lekcję ${lesson.title}`}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Usunąć lekcję „${lesson.title}”?`)) {
|
||||
deleteLesson.mutate(lesson.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
onClick={() => setEditingLesson({ lesson: null, moduleId: module.id })}
|
||||
>
|
||||
Dodaj lekcję
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Card title="Nowy moduł">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (newModuleTitle.trim()) addModule.mutate(newModuleTitle.trim())
|
||||
}}
|
||||
className="flex flex-wrap gap-2"
|
||||
>
|
||||
<Input
|
||||
className="min-w-0 flex-1"
|
||||
value={newModuleTitle}
|
||||
onChange={(e) => setNewModuleTitle(e.target.value)}
|
||||
placeholder="np. Podstawy prawne"
|
||||
aria-label="Tytuł nowego modułu"
|
||||
/>
|
||||
<Button type="submit" icon="plus" disabled={!newModuleTitle.trim() || addModule.isPending}>
|
||||
Dodaj moduł
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{editingLesson && (
|
||||
<LessonEditor
|
||||
courseId={courseId}
|
||||
moduleId={editingLesson.moduleId}
|
||||
lesson={editingLesson.lesson}
|
||||
modules={modules}
|
||||
onClose={() => setEditingLesson(null)}
|
||||
onSaved={() => {
|
||||
setEditingLesson(null)
|
||||
invalidate()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Nazwa modulu edytowana w miejscu, bez otwierania osobnego okna. */
|
||||
function ModuleTitle({ title, onSave }: { title: string; onSave: (title: string) => void }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [value, setValue] = useState(title)
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setValue(title)
|
||||
setEditing(true)
|
||||
}}
|
||||
className="text-sm font-semibold text-slate-900 hover:text-blue-700"
|
||||
title="Kliknij, żeby zmienić nazwę"
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const commit = () => {
|
||||
const next = value.trim()
|
||||
if (next && next !== title) onSave(next)
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit()
|
||||
if (e.key === 'Escape') setEditing(false)
|
||||
}}
|
||||
aria-label="Nazwa modułu"
|
||||
className="min-h-[36px] rounded-md border border-slate-200 px-2 text-sm font-semibold text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-[3px] focus:ring-blue-500/45"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Przyciski gora i dol.
|
||||
*
|
||||
* Jedyna droga zmiany kolejnosci dla osoby nieuzywajacej myszy: natywne
|
||||
* przeciaganie jest z klawiatury nieosiagalne. Etykieta mowi wprost, co
|
||||
* przesuwa, bo sama strzalka nic czytnikowi ekranu nie mowi.
|
||||
*/
|
||||
function MoveButtons({
|
||||
label,
|
||||
canUp,
|
||||
canDown,
|
||||
onUp,
|
||||
onDown,
|
||||
}: {
|
||||
label: string
|
||||
canUp: boolean
|
||||
canDown: boolean
|
||||
onUp: () => void
|
||||
onDown: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<IconAction icon="chevron-up" label={`Przesuń ${label} w górę`} onClick={onUp} disabled={!canUp} />
|
||||
<IconAction icon="chevron-down" label={`Przesuń ${label} w dół`} onClick={onDown} disabled={!canDown} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function IconAction({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
icon: Parameters<typeof Icon>[0]['name']
|
||||
label: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-md text-slate-500 transition duration-fast ease-standard hover:bg-slate-50 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-200 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { CourseModule, Lesson, LessonType, Page, Webinar } from '../../api/types'
|
||||
import { Alert, Button, Field, Input, Select } from '../../components/ui'
|
||||
import { ContentEditor } from '../../components/ContentEditor'
|
||||
import { MediaField } from '../../components/MediaField'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { LESSON_TYPE_LABEL } from './courseLabels'
|
||||
import { QuizEditor } from './QuizEditor'
|
||||
|
||||
type Form = {
|
||||
title: string
|
||||
type: LessonType
|
||||
moduleId: number
|
||||
contentHtml: string
|
||||
mediaId: string
|
||||
externalVideoUrl: string
|
||||
webinarId: string
|
||||
durationMinutes: string
|
||||
required: boolean
|
||||
preview: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Edycja lekcji.
|
||||
*
|
||||
* POLA ZALEZA OD RODZAJU LEKCJI
|
||||
* ---------------------------------------------------------------------------
|
||||
* Pokazywanie wszystkich pol naraz zmuszaloby do zgadywania, ktore dotycza
|
||||
* wybranego rodzaju. Zamiast tego widac wylacznie to, co ma znaczenie:
|
||||
* nagranie chce pliku albo adresu, tekst edytora, webinar listy wydarzen.
|
||||
*
|
||||
* Wartosci pol nieuzywanych **nie sa czyszczone przy przelaczaniu rodzaju**:
|
||||
* ktos, kto pomylil sie w wyborze i wraca, nie traci wpisanej tresci. Backend
|
||||
* i tak zapisuje tylko to, co pasuje do rodzaju.
|
||||
*/
|
||||
export function LessonEditor({
|
||||
courseId,
|
||||
moduleId,
|
||||
lesson,
|
||||
modules,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
courseId: number
|
||||
moduleId: number
|
||||
lesson: Lesson | null
|
||||
modules: CourseModule[]
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const toasts = useToasts()
|
||||
const editing = Boolean(lesson)
|
||||
|
||||
const [form, setForm] = useState<Form>({
|
||||
title: lesson?.title ?? '',
|
||||
type: lesson?.type ?? 'TEXT',
|
||||
moduleId: lesson?.moduleId ?? moduleId,
|
||||
contentHtml: lesson?.contentHtml ?? '',
|
||||
mediaId: lesson?.mediaId ? String(lesson.mediaId) : '',
|
||||
externalVideoUrl: lesson?.externalVideoUrl ?? '',
|
||||
webinarId: lesson?.webinarId ? String(lesson.webinarId) : '',
|
||||
durationMinutes: lesson?.durationMinutes ? String(lesson.durationMinutes) : '',
|
||||
required: lesson?.required ?? true,
|
||||
preview: lesson?.preview ?? false,
|
||||
})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [quizOpen, setQuizOpen] = useState(false)
|
||||
|
||||
const update = <K extends keyof Form>(key: K, value: Form[K]) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
// lista webinarow potrzebna wylacznie przy lekcji typu WEBINAR
|
||||
const webinars = useQuery({
|
||||
queryKey: ['courses', 'webinars-for-lesson'],
|
||||
queryFn: async () =>
|
||||
(await client.get<Page<Webinar>>('/admin/webinars?size=100&sort=startsAt&direction=desc')).data,
|
||||
enabled: form.type === 'WEBINAR',
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body = {
|
||||
title: form.title,
|
||||
type: form.type,
|
||||
moduleId: form.moduleId,
|
||||
// wysylamy wylacznie to, co pasuje do rodzaju: backend i tak odrzuci
|
||||
// webinar w lekcji tekstowej, ale nie ma powodu go tam wysylac
|
||||
contentHtml: form.type === 'TEXT' ? form.contentHtml || null : null,
|
||||
mediaId:
|
||||
(form.type === 'VIDEO' || form.type === 'FILE') && form.mediaId
|
||||
? Number(form.mediaId)
|
||||
: null,
|
||||
externalVideoUrl:
|
||||
form.type === 'VIDEO' && form.externalVideoUrl ? form.externalVideoUrl : null,
|
||||
webinarId: form.type === 'WEBINAR' && form.webinarId ? Number(form.webinarId) : null,
|
||||
durationMinutes: form.durationMinutes ? Number(form.durationMinutes) : null,
|
||||
required: form.required,
|
||||
preview: form.preview,
|
||||
}
|
||||
return editing
|
||||
? client.put(`/admin/courses/${courseId}/lessons/${lesson!.id}`, body)
|
||||
: client.post(`/admin/courses/${courseId}/lessons`, body)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toasts.success(editing ? 'Lekcja zapisana' : 'Lekcja dodana')
|
||||
onSaved()
|
||||
},
|
||||
onError: (err) => setError(errorMessage(err, 'Nie udało się zapisać lekcji')),
|
||||
})
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
if (!form.title.trim()) {
|
||||
setError('Tytuł lekcji jest wymagany')
|
||||
return
|
||||
}
|
||||
save.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={editing ? 'Edycja lekcji' : 'Nowa lekcja'} onClose={onClose}>
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
{error && <Alert tone="alarm">{error}</Alert>}
|
||||
|
||||
<Field label="Tytuł lekcji">
|
||||
<Input
|
||||
value={form.title}
|
||||
onChange={(e) => update('title', e.target.value)}
|
||||
placeholder="np. Trzy warstwy oceny ryzyka"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Rodzaj">
|
||||
<Select
|
||||
value={form.type}
|
||||
onChange={(e) => update('type', e.target.value as LessonType)}
|
||||
>
|
||||
{(Object.keys(LESSON_TYPE_LABEL) as LessonType[]).map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{LESSON_TYPE_LABEL[type]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Moduł" hint="Zmiana przenosi lekcję do innego modułu.">
|
||||
<Select
|
||||
value={String(form.moduleId)}
|
||||
onChange={(e) => update('moduleId', Number(e.target.value))}
|
||||
>
|
||||
{modules.map((module) => (
|
||||
<option key={module.id} value={module.id}>
|
||||
{module.title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* --- pola zalezne od rodzaju --- */}
|
||||
|
||||
{form.type === 'TEXT' && (
|
||||
<Field label="Treść lekcji" wide>
|
||||
<ContentEditor value={form.contentHtml} onChange={(html) => update('contentHtml', html)} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{form.type === 'VIDEO' && (
|
||||
<>
|
||||
<Field
|
||||
label="Nagranie z biblioteki"
|
||||
hint="Plik we własnym składzie. Zostaw puste, jeśli podajesz adres zewnętrzny."
|
||||
>
|
||||
<MediaField value={form.mediaId} onChange={(value) => update('mediaId', value)} />
|
||||
</Field>
|
||||
<Field
|
||||
label="Albo adres zewnętrzny"
|
||||
hint="Dla nagrań trzymanych poza systemem. Wyłącznie http:// albo https://"
|
||||
>
|
||||
<Input
|
||||
type="url"
|
||||
value={form.externalVideoUrl}
|
||||
onChange={(e) => update('externalVideoUrl', e.target.value)}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form.type === 'FILE' && (
|
||||
<Field label="Plik do pobrania" hint="Wybierz z biblioteki mediów.">
|
||||
<MediaField value={form.mediaId} onChange={(value) => update('mediaId', value)} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{form.type === 'WEBINAR' && (
|
||||
<Field label="Webinar" hint="Wydarzenie na żywo będące tą lekcją.">
|
||||
{webinars.isPending ? (
|
||||
<p className="text-sm text-slate-500">Wczytywanie listy webinarów...</p>
|
||||
) : (
|
||||
<Select
|
||||
value={form.webinarId}
|
||||
onChange={(e) => update('webinarId', e.target.value)}
|
||||
>
|
||||
<option value="">Nie wybrano</option>
|
||||
{(webinars.data?.content ?? []).map((webinar) => (
|
||||
<option key={webinar.id} value={webinar.id}>
|
||||
{webinar.title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{form.type === 'QUIZ' && (
|
||||
editing ? (
|
||||
/*
|
||||
* Quiz edytuje sie po zapisaniu lekcji: zanim lekcja istnieje,
|
||||
* nie ma do czego przypiac pytan. Osobne okno, bo pytan bywa
|
||||
* kilkadziesiat i nie zmieszcza sie obok pol lekcji.
|
||||
*/
|
||||
<div className="rounded-md border border-slate-200 p-3">
|
||||
<p className="text-sm font-medium text-slate-900">Sprawdzian</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
Pytania, odpowiedzi, próg zaliczenia, limit czasu i podejść.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
icon="settings"
|
||||
className="mt-2"
|
||||
onClick={() => setQuizOpen(true)}
|
||||
>
|
||||
Otwórz edytor sprawdzianu
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Alert tone="info">
|
||||
Pytania sprawdzianu dodasz po zapisaniu lekcji: zanim lekcja istnieje, nie ma do
|
||||
czego ich przypiąć.
|
||||
</Alert>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* --- wspolne --- */}
|
||||
|
||||
<Field label="Czas trwania" hint="W minutach. Puste oznacza, że lekcja go nie podaje.">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.durationMinutes}
|
||||
onChange={(e) => update('durationMinutes', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Toggle
|
||||
checked={form.required}
|
||||
onChange={(value) => update('required', value)}
|
||||
title="Lekcja wymagana"
|
||||
description="Liczy się do progu zaliczenia kursu. Materiały dodatkowe zostaw odznaczone: nie wpływają na wynik."
|
||||
/>
|
||||
<Toggle
|
||||
checked={form.preview}
|
||||
onChange={(value) => update('preview', value)}
|
||||
title="Próbka dostępna bez zapisu"
|
||||
description="To jedyna droga, którą treść kursu wychodzi na zewnątrz. Włączaj świadomie."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-slate-100 pt-4">
|
||||
<Button variant="secondary" type="button" onClick={onClose}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button type="submit" icon="save" disabled={save.isPending}>
|
||||
{save.isPending ? 'Zapisywanie...' : 'Zapisz lekcję'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{quizOpen && lesson && (
|
||||
<QuizEditor
|
||||
courseId={courseId}
|
||||
lessonId={lesson.id}
|
||||
onClose={() => setQuizOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-200 p-3 transition-colors duration-fast ease-standard hover:bg-slate-25">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
|
||||
/>
|
||||
<span className="min-w-0 text-sm leading-relaxed text-slate-700">
|
||||
<strong className="text-slate-900">{title}</strong>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">{description}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Course, ProgressRow } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Input,
|
||||
Select,
|
||||
Spinner,
|
||||
} from '../../components/ui'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { ENROLLMENT_STATUS_LABEL, ENROLLMENT_STATUS_TONE } from './courseLabels'
|
||||
import { panelPath } from '../../lib/routes'
|
||||
|
||||
/** Gotowe przedzialy postepu, zeby nie kazac wpisywac liczb recznie. */
|
||||
const RANGES: { id: string; label: string; min?: number; max?: number }[] = [
|
||||
{ id: '', label: 'Dowolny postęp' },
|
||||
{ id: 'none', label: 'Bez postępu (0%)', min: 0, max: 0 },
|
||||
{ id: 'started', label: 'Rozpoczęte (1-49%)', min: 1, max: 49 },
|
||||
{ id: 'half', label: 'Powyżej połowy (50-99%)', min: 50, max: 99 },
|
||||
{ id: 'done', label: 'Ukończone (100%)', min: 100, max: 100 },
|
||||
]
|
||||
|
||||
/**
|
||||
* Przekrojowa lista postepow, dla wszystkich kursow naraz.
|
||||
*
|
||||
* Ekran dla prowadzacego, ktory chce zobaczyc, kto utknal: stad wyroznienie
|
||||
* zapisow bez aktywnosci i sortowanie od najnowszej aktywnosci.
|
||||
*/
|
||||
export function ProgressPage() {
|
||||
const toasts = useToasts()
|
||||
|
||||
const [courseId, setCourseId] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [range, setRange] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
const selectedRange = RANGES.find((r) => r.id === range)
|
||||
|
||||
const courses = useQuery({
|
||||
queryKey: ['courses', 'for-filter'],
|
||||
queryFn: async () =>
|
||||
(await client.get<{ content: Course[] }>('/admin/courses', { params: { size: 100 } }))
|
||||
.data.content,
|
||||
})
|
||||
|
||||
const settings = useQuery({
|
||||
queryKey: ['progress-settings'],
|
||||
queryFn: async () =>
|
||||
(await client.get<{ inactivityDays: number }>('/admin/academy/progress/settings')).data,
|
||||
})
|
||||
|
||||
const params = {
|
||||
courseId: courseId || undefined,
|
||||
status: status || undefined,
|
||||
search: search || undefined,
|
||||
minPercent: selectedRange?.min,
|
||||
maxPercent: selectedRange?.max,
|
||||
}
|
||||
|
||||
const { data, isPending, isError, error } = useQuery({
|
||||
queryKey: ['progress', courseId, status, range, search],
|
||||
queryFn: async () =>
|
||||
(await client.get<ProgressRow[]>('/admin/academy/progress', { params })).data,
|
||||
})
|
||||
|
||||
const exportCsv = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const response = await client.get('/admin/academy/progress/export', {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
})
|
||||
const url = URL.createObjectURL(response.data as Blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'postepy.csv'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
toasts.error(errorMessage(e, 'Nie udało się pobrać pliku'))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const rows = data ?? []
|
||||
const inactiveCount = rows.filter((r) => r.inactive).length
|
||||
const inactivityDays = settings.data?.inactivityDays ?? 14
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Postępy</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
Zapisy na wszystkie kursy, od ostatnio aktywnych.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" icon="download" onClick={exportCsv} disabled={exporting}>
|
||||
{exporting ? 'Przygotowuję...' : 'Eksport CSV'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="progress-course">
|
||||
Kurs
|
||||
</label>
|
||||
<Select
|
||||
id="progress-course"
|
||||
className="w-full sm:w-64"
|
||||
value={courseId}
|
||||
onChange={(e) => setCourseId(e.target.value)}
|
||||
>
|
||||
<option value="">Wszystkie kursy</option>
|
||||
{(courses.data ?? []).map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="progress-status">
|
||||
Status
|
||||
</label>
|
||||
<Select
|
||||
id="progress-status"
|
||||
className="w-full sm:w-44"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="">Wszystkie</option>
|
||||
<option value="ACTIVE">Aktywne</option>
|
||||
<option value="COMPLETED">Ukończone</option>
|
||||
<option value="CANCELLED">Anulowane</option>
|
||||
<option value="EXPIRED">Wygasłe</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="progress-range">
|
||||
Postęp
|
||||
</label>
|
||||
<Select
|
||||
id="progress-range"
|
||||
className="w-full sm:w-52"
|
||||
value={range}
|
||||
onChange={(e) => setRange(e.target.value)}
|
||||
>
|
||||
{RANGES.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="progress-search">
|
||||
Szukaj
|
||||
</label>
|
||||
<Input
|
||||
id="progress-search"
|
||||
className="w-full sm:w-56"
|
||||
placeholder="Nazwisko albo adres"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="ml-auto text-sm text-slate-500">
|
||||
Znaleziono: <strong className="hx-data">{rows.length}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{inactiveCount > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-warn-500/25 bg-warn-50 p-3 text-sm text-warn-600">
|
||||
<Icon name="alert-triangle" size={18} className="mt-0.5 shrink-0" />
|
||||
<p className="leading-relaxed">
|
||||
<strong className="hx-data">{inactiveCount}</strong>{' '}
|
||||
{inactiveCount === 1 ? 'zapis nie ma' : 'zapisów nie ma'} postępu od ponad{' '}
|
||||
<span className="hx-data">{inactivityDays}</span> dni. Próg zmienisz w ustawieniach.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
|
||||
{isPending && (
|
||||
<div className="p-5">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="p-5">
|
||||
<ErrorState message={errorMessage(error, 'Nie udało się wczytać postępów')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && rows.length === 0 && (
|
||||
<div className="p-5">
|
||||
<EmptyState
|
||||
icon="check-circle"
|
||||
title="Brak zapisów"
|
||||
description="Żaden zapis nie spełnia wybranych warunków."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<>
|
||||
<ul className="divide-y divide-slate-100 lg:hidden">
|
||||
{rows.map((row) => (
|
||||
<li key={row.enrollmentId} className={row.inactive ? 'bg-warn-50/40 p-4' : 'p-4'}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={panelPath(`/kursy/kursanci/${row.contactId}`)}
|
||||
className="text-sm font-medium text-slate-900 hover:text-blue-600"
|
||||
>
|
||||
{row.studentName}
|
||||
</Link>
|
||||
{row.inactive && <Badge tone="warn">bez aktywności</Badge>}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{row.courseTitle}</p>
|
||||
<div className="mt-2 max-w-xs">
|
||||
<ProgressBar row={row} />
|
||||
</div>
|
||||
<p className="hx-data mt-2 text-xs text-slate-500">
|
||||
{row.lastActivityAt ? formatLocal(row.lastActivityAt) : '-'}
|
||||
{row.lastQuizScore !== null && ` · quiz ${row.lastQuizScore}%`}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-100 bg-slate-25 text-left">
|
||||
<tr className="hx-eyebrow">
|
||||
<th className="px-4 py-2.5">Kursant</th>
|
||||
<th className="px-4 py-2.5">Kurs</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
{/*
|
||||
Postep i wynik quizu NIE sa sortowalne: powstaja
|
||||
z podzapytan, a sortowanie samej pobranej listy
|
||||
wprowadzaloby w blad przy wiekszej liczbie zapisow.
|
||||
*/}
|
||||
<th className="px-4 py-2.5 w-56">Postęp</th>
|
||||
<th className="px-4 py-2.5 text-right">Quiz</th>
|
||||
<th className="px-4 py-2.5">Ostatnia aktywność</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={row.enrollmentId}
|
||||
className={`border-b border-slate-50 transition duration-fast last:border-b-0 hover:bg-slate-25 ${
|
||||
row.inactive ? 'bg-warn-50/40' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
to={panelPath(`/kursy/kursanci/${row.contactId}`)}
|
||||
className="font-medium text-slate-900 hover:text-blue-600"
|
||||
>
|
||||
{row.studentName}
|
||||
</Link>
|
||||
<p className="hx-data text-xs text-slate-500">{row.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
to={panelPath(`/kursy/${row.courseId}`)}
|
||||
className="text-slate-700 hover:text-blue-600"
|
||||
>
|
||||
{row.courseTitle}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge tone={ENROLLMENT_STATUS_TONE[row.status]}>
|
||||
{ENROLLMENT_STATUS_LABEL[row.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<ProgressBar row={row} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{row.lastQuizScore === null ? (
|
||||
<span className="text-slate-400">-</span>
|
||||
) : (
|
||||
<span
|
||||
className={`hx-data ${
|
||||
row.lastQuizPassed ? 'text-ok-600' : 'text-alarm-600'
|
||||
}`}
|
||||
>
|
||||
{row.lastQuizScore}%
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="hx-data text-slate-600">
|
||||
{row.lastActivityAt ? formatLocal(row.lastActivityAt) : '-'}
|
||||
</span>
|
||||
{row.inactive && (
|
||||
<Badge tone="warn" className="ml-2">
|
||||
bez aktywności
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressBar({ row }: { row: ProgressRow }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-slate-600">
|
||||
<span>
|
||||
{row.completedRequired}/{row.totalRequired}
|
||||
</span>
|
||||
<span className="hx-data font-medium text-slate-900">{row.progressPercent}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
row.progressPercent === 100 ? 'bg-ok-500' : 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${row.progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Quiz, QuizImportResult, QuizQuestionAdmin, QuizStats } from '../../api/types'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
Input,
|
||||
Spinner,
|
||||
Textarea,
|
||||
} from '../../components/ui'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
|
||||
type Tab = 'pytania' | 'ustawienia' | 'statystyki' | 'podglad'
|
||||
|
||||
/**
|
||||
* Edytor quizu, otwierany z lekcji typu Sprawdzian.
|
||||
*
|
||||
* Cztery zakladki, bo to cztery rozne zadania: uklada sie pytania, ustawia
|
||||
* zasady, oglada wyniki albo sprawdza, jak test wyglada oczami kursanta.
|
||||
* Wszystko w jednym oknie oznaczaloby przewijanie przez kilkadziesiat pytan
|
||||
* za kazdym razem, gdy trzeba poprawic limit czasu.
|
||||
*/
|
||||
export function QuizEditor({
|
||||
courseId,
|
||||
lessonId,
|
||||
onClose,
|
||||
}: {
|
||||
courseId: number
|
||||
lessonId: number
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>('pytania')
|
||||
const base = `/admin/courses/${courseId}/lessons/${lessonId}/quiz`
|
||||
|
||||
const quiz = useQuery({
|
||||
queryKey: ['quiz', courseId, lessonId],
|
||||
queryFn: async () => (await client.get<Quiz>(base)).data,
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal title="Sprawdzian" onClose={onClose} wide>
|
||||
{quiz.isPending && <Spinner />}
|
||||
{quiz.isError && (
|
||||
<ErrorState message={errorMessage(quiz.error, 'Nie udało się wczytać quizu')} />
|
||||
)}
|
||||
|
||||
{quiz.data && (
|
||||
<div className="space-y-4">
|
||||
{quiz.data.questionsInvalid > 0 && (
|
||||
<Alert tone="warn">
|
||||
<strong>
|
||||
{quiz.data.questionsInvalid}{' '}
|
||||
{quiz.data.questionsInvalid === 1 ? 'pytanie wymaga' : 'pytania wymagają'} poprawy.
|
||||
</strong>{' '}
|
||||
Każde pytanie musi mieć dokładnie jedną odpowiedź poprawną i co najmniej dwie
|
||||
odpowiedzi. Kursu z takim pytaniem nie da się opublikować.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<nav role="tablist" aria-label="Sekcje quizu" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
|
||||
{(
|
||||
[
|
||||
['pytania', 'Pytania', 'file-text', quiz.data.questionsTotal],
|
||||
['ustawienia', 'Ustawienia', 'settings', null],
|
||||
['statystyki', 'Statystyki', 'presentation', null],
|
||||
['podglad', 'Podgląd', 'eye', null],
|
||||
] as const
|
||||
).map(([value, label, icon, count]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === value}
|
||||
onClick={() => setTab(value)}
|
||||
className={`flex min-h-[44px] shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors duration-fast ease-standard ${
|
||||
tab === value
|
||||
? 'bg-blue-50 font-medium text-blue-700'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
{label}
|
||||
{count !== null && <span className="hx-data text-xs text-slate-400">{count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{tab === 'pytania' && <QuestionsTab base={base} courseId={courseId} lessonId={lessonId} />}
|
||||
{tab === 'ustawienia' && (
|
||||
<SettingsTab base={base} courseId={courseId} lessonId={lessonId} quiz={quiz.data} />
|
||||
)}
|
||||
{tab === 'statystyki' && <StatsTab base={base} />}
|
||||
{tab === 'podglad' && <PreviewTab base={base} quiz={quiz.data} />}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// --- pytania ---
|
||||
|
||||
type AnswerDraft = { content: string; correct: boolean }
|
||||
type QuestionDraft = { content: string; explanation: string; answers: AnswerDraft[]; active: boolean }
|
||||
|
||||
const EMPTY_DRAFT: QuestionDraft = {
|
||||
content: '',
|
||||
explanation: '',
|
||||
// dwie puste odpowiedzi od razu: minimum wymagane regula, wiec zaczynamy od niego
|
||||
answers: [
|
||||
{ content: '', correct: true },
|
||||
{ content: '', correct: false },
|
||||
],
|
||||
active: true,
|
||||
}
|
||||
|
||||
function QuestionsTab({
|
||||
base,
|
||||
courseId,
|
||||
lessonId,
|
||||
}: {
|
||||
base: string
|
||||
courseId: number
|
||||
lessonId: number
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [editing, setEditing] = useState<{ id: number | null; draft: QuestionDraft } | null>(null)
|
||||
const [importResult, setImportResult] = useState<QuizImportResult | null>(null)
|
||||
|
||||
const questions = useQuery({
|
||||
queryKey: ['quiz', courseId, lessonId, 'questions'],
|
||||
queryFn: async () => (await client.get<QuizQuestionAdmin[]>(`${base}/questions`)).data,
|
||||
})
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['quiz', courseId, lessonId] })
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: ({ id, draft }: { id: number | null; draft: QuestionDraft }) => {
|
||||
const body = {
|
||||
content: draft.content,
|
||||
explanation: draft.explanation || null,
|
||||
active: draft.active,
|
||||
answers: draft.answers.map((a) => ({ content: a.content, correct: a.correct })),
|
||||
}
|
||||
return id === null
|
||||
? client.post(`${base}/questions`, body)
|
||||
: client.put(`${base}/questions/${id}`, body)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setEditing(null)
|
||||
invalidate()
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać pytania')),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => client.delete(`${base}/questions/${id}`),
|
||||
onSuccess: invalidate,
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć pytania')),
|
||||
})
|
||||
|
||||
const reorder = useMutation({
|
||||
mutationFn: (ids: number[]) => client.put(`${base}/questions/order`, { ids }),
|
||||
onSuccess: invalidate,
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać kolejności')),
|
||||
})
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return (await client.post<QuizImportResult>(`${base}/questions/import`, form)).data
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
setImportResult(result)
|
||||
invalidate()
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wczytać pliku')),
|
||||
})
|
||||
|
||||
/** Przesuwa pytanie o jedna pozycje. Jedyna droga dla osoby bez myszy. */
|
||||
const move = (id: number, delta: number) => {
|
||||
const ids = (questions.data ?? []).map((q) => q.id)
|
||||
const from = ids.indexOf(id)
|
||||
const to = from + delta
|
||||
if (from < 0 || to < 0 || to >= ids.length) return
|
||||
const next = [...ids]
|
||||
next.splice(to, 0, next.splice(from, 1)[0])
|
||||
reorder.mutate(next)
|
||||
}
|
||||
|
||||
if (questions.isPending) return <Spinner />
|
||||
if (questions.isError) {
|
||||
return <ErrorState message={errorMessage(questions.error, 'Nie udało się wczytać pytań')} />
|
||||
}
|
||||
|
||||
const list = questions.data ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button icon="plus" onClick={() => setEditing({ id: null, draft: EMPTY_DRAFT })}>
|
||||
Dodaj pytanie
|
||||
</Button>
|
||||
<Button variant="secondary" icon="upload" onClick={() => fileRef.current?.click()}>
|
||||
Import z CSV
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) upload.mutate(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<details className="rounded-md border border-slate-200 p-3 text-sm">
|
||||
<summary className="cursor-pointer font-medium text-slate-700">Format pliku CSV</summary>
|
||||
<div className="mt-2 space-y-1 text-slate-600">
|
||||
<p>Jedno pytanie w wierszu, kolumny rozdzielone średnikiem:</p>
|
||||
<p className="hx-data rounded bg-slate-50 p-2 text-xs">
|
||||
Treść pytania;odpowiedź;*odpowiedź poprawna;kolejna;#wyjaśnienie
|
||||
</p>
|
||||
<p>
|
||||
Gwiazdka na początku oznacza odpowiedź poprawną, krzyżyk wyjaśnienie. Wiersz z błędem
|
||||
jest pomijany, reszta pliku wczytuje się normalnie.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{importResult && (
|
||||
<Alert tone={importResult.skipped > 0 ? 'warn' : 'ok'}>
|
||||
<p>
|
||||
Wczytano pytań: <strong>{importResult.imported}</strong>
|
||||
{importResult.skipped > 0 && <>, pominięto: <strong>{importResult.skipped}</strong></>}
|
||||
</p>
|
||||
{importResult.problems.length > 0 && (
|
||||
<ul className="mt-1 list-disc pl-5 text-xs">
|
||||
{importResult.problems.slice(0, 5).map((problem) => (
|
||||
<li key={problem}>{problem}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{list.length === 0 && (
|
||||
<EmptyState
|
||||
icon="file-text"
|
||||
title="Quiz nie ma jeszcze pytań"
|
||||
description="Dodaj pytanie ręcznie albo wczytaj listę z pliku CSV."
|
||||
/>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{list.map((question, index) => (
|
||||
<li
|
||||
key={question.id}
|
||||
className={`rounded-md border p-3 ${
|
||||
question.valid ? 'border-slate-100' : 'border-warn-500/40 bg-warn-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<span className="hx-data mt-0.5 text-xs text-slate-400">{index + 1}.</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-slate-900">{question.content}</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{question.answers.map((answer) => (
|
||||
<li
|
||||
key={answer.id}
|
||||
className={`flex items-center gap-1.5 text-xs ${
|
||||
answer.correct ? 'font-medium text-ok-600' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<Icon name={answer.correct ? 'check-circle' : 'x'} size={13} />
|
||||
{answer.content}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!question.valid && (
|
||||
<p className="mt-1 text-xs font-medium text-warn-600">
|
||||
Wymagana dokładnie jedna odpowiedź poprawna i co najmniej dwie odpowiedzi.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{!question.active && <Badge tone="neutral">wyłączone</Badge>}
|
||||
<IconAction
|
||||
icon="chevron-up"
|
||||
label={`Przesuń pytanie ${index + 1} w górę`}
|
||||
disabled={index === 0}
|
||||
onClick={() => move(question.id, -1)}
|
||||
/>
|
||||
<IconAction
|
||||
icon="chevron-down"
|
||||
label={`Przesuń pytanie ${index + 1} w dół`}
|
||||
disabled={index === list.length - 1}
|
||||
onClick={() => move(question.id, 1)}
|
||||
/>
|
||||
<IconAction
|
||||
icon="pencil"
|
||||
label={`Edytuj pytanie ${index + 1}`}
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
id: question.id,
|
||||
draft: {
|
||||
content: question.content,
|
||||
explanation: question.explanation ?? '',
|
||||
active: question.active,
|
||||
answers: question.answers.map((a) => ({
|
||||
content: a.content,
|
||||
correct: a.correct,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<IconAction
|
||||
icon="trash"
|
||||
label={`Usuń pytanie ${index + 1}`}
|
||||
onClick={() => {
|
||||
if (window.confirm('Usunąć to pytanie?')) remove.mutate(question.id)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{editing && (
|
||||
<QuestionForm
|
||||
draft={editing.draft}
|
||||
pending={save.isPending}
|
||||
onCancel={() => setEditing(null)}
|
||||
onSave={(draft) => save.mutate({ id: editing.id, draft })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Formularz pytania. Poprawna odpowiedz wybierana jednym przelacznikiem. */
|
||||
function QuestionForm({
|
||||
draft,
|
||||
pending,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
draft: QuestionDraft
|
||||
pending: boolean
|
||||
onCancel: () => void
|
||||
onSave: (draft: QuestionDraft) => void
|
||||
}) {
|
||||
const [form, setForm] = useState<QuestionDraft>(draft)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const setAnswer = (index: number, content: string) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
answers: prev.answers.map((a, i) => (i === index ? { ...a, content } : a)),
|
||||
}))
|
||||
|
||||
/*
|
||||
* Wybor poprawnej odpowiedzi zeruje pozostale: regula dopuszcza dokladnie
|
||||
* jedna, wiec pola wyboru zachowuja sie jak przelacznik, a nie jak zestaw
|
||||
* niezaleznych pol. Bez tego uzytkownik zaznaczylby dwie i dowiedzial sie
|
||||
* o bledzie dopiero przy zapisie.
|
||||
*/
|
||||
const setCorrect = (index: number) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
answers: prev.answers.map((a, i) => ({ ...a, correct: i === index })),
|
||||
}))
|
||||
|
||||
const addAnswer = () =>
|
||||
setForm((prev) => ({ ...prev, answers: [...prev.answers, { content: '', correct: false }] }))
|
||||
|
||||
const removeAnswer = (index: number) =>
|
||||
setForm((prev) => {
|
||||
const answers = prev.answers.filter((_, i) => i !== index)
|
||||
// usuniecie poprawnej zostawiloby pytanie bez klucza: przenosimy na pierwsza
|
||||
if (!answers.some((a) => a.correct) && answers.length > 0) {
|
||||
answers[0] = { ...answers[0], correct: true }
|
||||
}
|
||||
return { ...prev, answers }
|
||||
})
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (!form.content.trim()) {
|
||||
setError('Treść pytania jest wymagana')
|
||||
return
|
||||
}
|
||||
const filled = form.answers.filter((a) => a.content.trim())
|
||||
if (filled.length < 2) {
|
||||
setError('Pytanie musi mieć co najmniej dwie odpowiedzi')
|
||||
return
|
||||
}
|
||||
if (filled.filter((a) => a.correct).length !== 1) {
|
||||
setError('Zaznacz dokładnie jedną odpowiedź poprawną')
|
||||
return
|
||||
}
|
||||
onSave({ ...form, answers: filled })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} noValidate className="space-y-3 rounded-md border border-blue-200 bg-blue-50/40 p-3">
|
||||
{error && <Alert tone="alarm">{error}</Alert>}
|
||||
|
||||
<Field label="Treść pytania">
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={form.content}
|
||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<p className="mb-1.5 text-sm font-semibold text-slate-800">
|
||||
Odpowiedzi <span className="font-normal text-slate-500">(zaznacz poprawną)</span>
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{form.answers.map((answer, index) => (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="correct-answer"
|
||||
checked={answer.correct}
|
||||
onChange={() => setCorrect(index)}
|
||||
aria-label={`Odpowiedź ${index + 1} jest poprawna`}
|
||||
className="h-4 w-4 shrink-0 accent-ok-500"
|
||||
/>
|
||||
<Input
|
||||
className="min-w-0 flex-1"
|
||||
value={answer.content}
|
||||
onChange={(e) => setAnswer(index, e.target.value)}
|
||||
placeholder={`Odpowiedź ${index + 1}`}
|
||||
aria-label={`Treść odpowiedzi ${index + 1}`}
|
||||
/>
|
||||
<IconAction
|
||||
icon="trash"
|
||||
label={`Usuń odpowiedź ${index + 1}`}
|
||||
disabled={form.answers.length <= 2}
|
||||
onClick={() => removeAnswer(index)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button type="button" variant="ghost" icon="plus" onClick={addAnswer} className="mt-1">
|
||||
Dodaj odpowiedź
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Field label="Wyjaśnienie" hint="Pokazywane po zakończeniu, gdy quiz pokazuje odpowiedzi.">
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={form.explanation}
|
||||
onChange={(e) => setForm({ ...form, explanation: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.active}
|
||||
onChange={(e) => setForm({ ...form, active: e.target.checked })}
|
||||
className="h-4 w-4 accent-blue-500"
|
||||
/>
|
||||
Pytanie aktywne, bierze udział w losowaniu
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onCancel}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button type="submit" icon="save" disabled={pending}>
|
||||
{pending ? 'Zapisywanie...' : 'Zapisz pytanie'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// --- ustawienia ---
|
||||
|
||||
function SettingsTab({
|
||||
base,
|
||||
courseId,
|
||||
lessonId,
|
||||
quiz,
|
||||
}: {
|
||||
base: string
|
||||
courseId: number
|
||||
lessonId: number
|
||||
quiz: Quiz
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
|
||||
const [form, setForm] = useState({
|
||||
title: quiz.title,
|
||||
description: quiz.description ?? '',
|
||||
questionCount: quiz.questionCount === null ? '' : String(quiz.questionCount),
|
||||
passThresholdPercent:
|
||||
quiz.passThresholdPercent === null ? '' : String(quiz.passThresholdPercent),
|
||||
timeLimitMinutes: quiz.timeLimitMinutes === null ? '' : String(quiz.timeLimitMinutes),
|
||||
maxAttempts: quiz.maxAttempts === null ? '' : String(quiz.maxAttempts),
|
||||
retryDelayMinutes: quiz.retryDelayMinutes === null ? '' : String(quiz.retryDelayMinutes),
|
||||
shuffleQuestions: quiz.shuffleQuestions,
|
||||
shuffleAnswers: quiz.shuffleAnswers,
|
||||
showCorrectAnswers: quiz.showCorrectAnswers,
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
client.put(base, {
|
||||
title: form.title,
|
||||
description: form.description || null,
|
||||
questionCount: form.questionCount === '' ? null : Number(form.questionCount),
|
||||
passThresholdPercent:
|
||||
form.passThresholdPercent === '' ? null : Number(form.passThresholdPercent),
|
||||
timeLimitMinutes: form.timeLimitMinutes === '' ? null : Number(form.timeLimitMinutes),
|
||||
maxAttempts: form.maxAttempts === '' ? null : Number(form.maxAttempts),
|
||||
retryDelayMinutes: form.retryDelayMinutes === '' ? null : Number(form.retryDelayMinutes),
|
||||
shuffleQuestions: form.shuffleQuestions,
|
||||
shuffleAnswers: form.shuffleAnswers,
|
||||
showCorrectAnswers: form.showCorrectAnswers,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toasts.success('Ustawienia zapisane')
|
||||
queryClient.invalidateQueries({ queryKey: ['quiz', courseId, lessonId] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać ustawień')),
|
||||
})
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
save.mutate()
|
||||
}}
|
||||
noValidate
|
||||
className="space-y-4"
|
||||
>
|
||||
<Field label="Tytuł sprawdzianu">
|
||||
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
|
||||
</Field>
|
||||
|
||||
<Field label="Opis" hint="Pokazywany kursantowi przed rozpoczęciem.">
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
label="Ile pytań losować"
|
||||
hint={`Puste = wszystkie aktywne (teraz ${quiz.questionsActive}).`}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.questionCount}
|
||||
onChange={(e) => setForm({ ...form, questionCount: e.target.value })}
|
||||
placeholder="wszystkie"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Próg zaliczenia (%)"
|
||||
hint={`Puste = próg kursu, czyli ${quiz.effectiveThreshold}%.`}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={form.passThresholdPercent}
|
||||
onChange={(e) => setForm({ ...form, passThresholdPercent: e.target.value })}
|
||||
placeholder={`${quiz.effectiveThreshold}`}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Limit czasu (minuty)" hint="Puste = bez limitu. Pilnuje go serwer.">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.timeLimitMinutes}
|
||||
onChange={(e) => setForm({ ...form, timeLimitMinutes: e.target.value })}
|
||||
placeholder="bez limitu"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Liczba podejść" hint="Puste = bez limitu.">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.maxAttempts}
|
||||
onChange={(e) => setForm({ ...form, maxAttempts: e.target.value })}
|
||||
placeholder="bez limitu"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Odstęp między podejściami (minuty)"
|
||||
hint="Liczony od zakończenia poprzedniego podejścia."
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.retryDelayMinutes}
|
||||
onChange={(e) => setForm({ ...form, retryDelayMinutes: e.target.value })}
|
||||
placeholder="brak"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Toggle
|
||||
checked={form.shuffleQuestions}
|
||||
onChange={(v) => setForm({ ...form, shuffleQuestions: v })}
|
||||
title="Losuj kolejność pytań"
|
||||
description="Zestaw jest losowany raz, przy starcie podejścia, i nie zmienia się po odświeżeniu."
|
||||
/>
|
||||
<Toggle
|
||||
checked={form.shuffleAnswers}
|
||||
onChange={(v) => setForm({ ...form, shuffleAnswers: v })}
|
||||
title="Losuj kolejność odpowiedzi"
|
||||
description="Utrudnia przepisywanie odpowiedzi od innego kursanta."
|
||||
/>
|
||||
<Toggle
|
||||
checked={form.showCorrectAnswers}
|
||||
onChange={(v) => setForm({ ...form, showCorrectAnswers: v })}
|
||||
title="Pokaż poprawne odpowiedzi po zakończeniu"
|
||||
description="Przy quizie z wieloma podejściami warto wyłączyć: inaczej pierwsze podejście służy do zebrania klucza."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" icon="save" disabled={save.isPending}>
|
||||
{save.isPending ? 'Zapisywanie...' : 'Zapisz ustawienia'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// --- statystyki ---
|
||||
|
||||
function StatsTab({ base }: { base: string }) {
|
||||
const stats = useQuery({
|
||||
queryKey: [base, 'stats'],
|
||||
queryFn: async () => (await client.get<QuizStats>(`${base}/stats`)).data,
|
||||
})
|
||||
|
||||
if (stats.isPending) return <Spinner />
|
||||
if (stats.isError) {
|
||||
return <ErrorState message={errorMessage(stats.error, 'Nie udało się wczytać statystyk')} />
|
||||
}
|
||||
|
||||
const data = stats.data!
|
||||
if (data.finishedAttempts === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="presentation"
|
||||
title="Nikt jeszcze nie rozwiązał sprawdzianu"
|
||||
description="Statystyki pojawią się po pierwszym zakończonym podejściu."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Stat label="Podejścia" value={String(data.attempts)} />
|
||||
<Stat label="Średni wynik" value={`${data.averageScore}%`} />
|
||||
<Stat label="Zdawalność" value={`${data.passRate}%`} />
|
||||
</div>
|
||||
|
||||
<Card title="Pytania najczęściej mylone" description="Najniższa skuteczność na górze.">
|
||||
<ul className="space-y-2">
|
||||
{data.questions.map((question) => (
|
||||
<li key={question.questionId} className="rounded-md border border-slate-100 p-2.5">
|
||||
<p className="text-sm text-slate-900">{question.content}</p>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<div className="h-2 w-32 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
question.correctRate < 50 ? 'bg-alarm-500' : 'bg-ok-500'
|
||||
}`}
|
||||
style={{ width: `${question.correctRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="hx-data text-xs text-slate-500">
|
||||
{question.correctRate}% · {question.correct}/{question.answered}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-100 bg-white p-3">
|
||||
<p className="hx-data text-xl font-semibold text-slate-900">{value}</p>
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- podglad ---
|
||||
|
||||
/**
|
||||
* Podglad oczami kursanta.
|
||||
*
|
||||
* Pokazuje pytania **bez zaznaczonej poprawnej odpowiedzi**, czyli dokladnie
|
||||
* tak, jak zobaczy je kursant. Sluzy do sprawdzenia, czy tresc jest zrozumiala
|
||||
* bez klucza: pytanie oczywiste dla autora bywa niejasne dla odbiorcy.
|
||||
*/
|
||||
function PreviewTab({ base, quiz }: { base: string; quiz: Quiz }) {
|
||||
const questions = useQuery({
|
||||
queryKey: [base, 'questions', 'preview'],
|
||||
queryFn: async () => (await client.get<QuizQuestionAdmin[]>(`${base}/questions`)).data,
|
||||
})
|
||||
|
||||
if (questions.isPending) return <Spinner />
|
||||
const active = (questions.data ?? []).filter((q) => q.active)
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Alert tone="info">
|
||||
Tak zobaczy sprawdzian kursant: bez zaznaczonych odpowiedzi poprawnych. Losowana jest{' '}
|
||||
<strong>{quiz.questionCount ?? quiz.questionsActive}</strong> z {active.length} aktywnych
|
||||
pytań, próg zaliczenia to <strong>{quiz.effectiveThreshold}%</strong>.
|
||||
</Alert>
|
||||
|
||||
{active.length === 0 && (
|
||||
<EmptyState icon="file-text" title="Brak aktywnych pytań" description="Dodaj pytania." />
|
||||
)}
|
||||
|
||||
<ol className="space-y-3">
|
||||
{active.map((question, index) => (
|
||||
<li key={question.id} className="rounded-md border border-slate-100 p-3">
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{index + 1}. {question.content}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{question.answers.map((answer) => (
|
||||
<li key={answer.id} className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<span className="h-4 w-4 shrink-0 rounded-full border-2 border-slate-300" />
|
||||
{answer.content}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- wspolne ---
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (value: boolean) => void
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-200 p-3 transition-colors duration-fast ease-standard hover:bg-slate-25">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
|
||||
/>
|
||||
<span className="min-w-0 text-sm leading-relaxed text-slate-700">
|
||||
<strong className="text-slate-900">{title}</strong>
|
||||
<span className="mt-0.5 block text-xs text-slate-500">{description}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function IconAction({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
icon: Parameters<typeof Icon>[0]['name']
|
||||
label: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-md text-slate-500 transition duration-fast ease-standard hover:bg-slate-50 hover:text-slate-900 disabled:cursor-not-allowed disabled:text-slate-200 disabled:hover:bg-transparent"
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
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 { 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<string, string> = {
|
||||
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 [tab, setTab] = useState<Tab>('kursy')
|
||||
const [enrolling, setEnrolling] = useState(false)
|
||||
|
||||
const key = ['student', contactId]
|
||||
|
||||
const { data, isPending, isError, error } = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: async () =>
|
||||
(await client.get<StudentDetail>(`/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 <Spinner />
|
||||
if (isError || !data) {
|
||||
return <ErrorState message={errorMessage(error, 'Nie udało się wczytać kursanta')} />
|
||||
}
|
||||
|
||||
const student = data.student
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Link
|
||||
to={panelPath('/kursy/kursanci')}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-blue-600"
|
||||
>
|
||||
<Icon name="arrow-left" size={16} />
|
||||
Kursanci
|
||||
</Link>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-slate-900">
|
||||
{student.firstName} {student.lastName}
|
||||
</h1>
|
||||
{!student.accountEnabled && <Badge tone="alarm">Konto wyłączone</Badge>}
|
||||
{!student.emailVerified && <Badge tone="warn">Adres niepotwierdzony</Badge>}
|
||||
{data.unsubscribedAt && <Badge tone="neutral">Wypisany z marketingu</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- dane i dzialania --- */}
|
||||
<Card title="Dane konta">
|
||||
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||
<Row label="Adres e-mail" value={student.email} mono />
|
||||
<Row label="Telefon" value={student.phone || '-'} mono />
|
||||
<Row label="Firma" value={student.company || '-'} />
|
||||
<Row label="Rejestracja" value={formatLocal(student.registeredAt)} mono />
|
||||
<Row
|
||||
label="Ostatnie logowanie"
|
||||
value={student.lastLoginAt ? formatLocal(student.lastLoginAt) : 'nigdy'}
|
||||
mono
|
||||
/>
|
||||
<Row
|
||||
label="Adres potwierdzony"
|
||||
value={student.emailVerified ? 'tak' : 'nie'}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{data.notes && (
|
||||
<div className="mt-4 rounded-md border border-slate-100 bg-slate-25 p-3">
|
||||
<p className="hx-eyebrow mb-1">Notatki</p>
|
||||
<p className="text-sm text-slate-700">{data.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="user-plus"
|
||||
onClick={() => setEnrolling(true)}
|
||||
>
|
||||
Zapisz na kurs
|
||||
</Button>
|
||||
|
||||
{!student.emailVerified && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="mail"
|
||||
onClick={() => resendVerification.mutate()}
|
||||
disabled={resendVerification.isPending}
|
||||
>
|
||||
Wyślij link potwierdzający
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="key"
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Wysłać ${student.email} link do ustawienia nowego hasła? ` +
|
||||
'Nie poznasz jego hasła: kursant ustawi je sam.',
|
||||
)
|
||||
) {
|
||||
resetPassword.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={resetPassword.isPending}
|
||||
>
|
||||
Reset hasła
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={student.accountEnabled ? 'danger' : 'primary'}
|
||||
icon={student.accountEnabled ? 'user-off' : 'check'}
|
||||
onClick={() => {
|
||||
const next = !student.accountEnabled
|
||||
const question = next
|
||||
? `Włączyć konto ${student.email}?`
|
||||
: `Wyłączyć konto ${student.email}? Kursant straci możliwość zalogowania się, ` +
|
||||
'ale jego zapisy, postęp i certyfikaty zostaną zachowane.'
|
||||
if (window.confirm(question)) {
|
||||
setEnabled.mutate(next)
|
||||
}
|
||||
}}
|
||||
disabled={setEnabled.isPending}
|
||||
>
|
||||
{student.accountEnabled ? 'Wyłącz konto' : 'Włącz konto'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs leading-relaxed text-slate-500">
|
||||
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.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* --- zakladki --- */}
|
||||
<nav
|
||||
role="tablist"
|
||||
aria-label="Zakładki kursanta"
|
||||
className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1"
|
||||
>
|
||||
{(
|
||||
[
|
||||
['kursy', 'Kursy', 'graduation-cap', data.enrollments.length],
|
||||
['zgody', 'Zgody', 'file-text', data.consents.length],
|
||||
['webinary', 'Webinary', 'presentation', data.webinars.length],
|
||||
['certyfikaty', 'Certyfikaty', 'award', data.certificates.length],
|
||||
['zamowienia', 'Zamówienia', 'credit-card', data.orders.length],
|
||||
] as const
|
||||
).map(([value, label, icon, count]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === value}
|
||||
onClick={() => setTab(value)}
|
||||
className={`flex min-h-[44px] shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors duration-fast ease-standard ${
|
||||
tab === value
|
||||
? 'bg-blue-50 font-medium text-blue-700'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
{label}
|
||||
{count > 0 && <span className="hx-data text-xs text-slate-400">{count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{tab === 'kursy' && <EnrollmentsTab data={data} />}
|
||||
{tab === 'zgody' && <ConsentsTab data={data} />}
|
||||
{tab === 'webinary' && <WebinarsTab data={data} />}
|
||||
{tab === 'certyfikaty' && <CertificatesTab data={data} />}
|
||||
{tab === 'zamowienia' && <OrdersTab data={data} />}
|
||||
|
||||
{enrolling && (
|
||||
<ManualEnrollModal
|
||||
contactId={contactId}
|
||||
studentName={`${student.firstName} ${student.lastName}`}
|
||||
onClose={() => setEnrolling(false)}
|
||||
onSaved={() => {
|
||||
setEnrolling(false)
|
||||
queryClient.invalidateQueries({ queryKey: key })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="hx-eyebrow">{label}</dt>
|
||||
<dd className={`mt-0.5 text-sm text-slate-800 ${mono ? 'hx-data' : ''}`}>{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EnrollmentsTab({ data }: { data: StudentDetail }) {
|
||||
if (data.enrollments.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="graduation-cap"
|
||||
title="Brak zapisów na kursy"
|
||||
description="Możesz zapisać kursanta ręcznie, podając uzasadnienie."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Kursy">
|
||||
<ul className="space-y-3">
|
||||
{data.enrollments.map((e) => (
|
||||
<li key={e.enrollmentId} className="rounded-md border border-slate-100 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Link
|
||||
to={panelPath(`/kursy/${e.courseId}`)}
|
||||
className="text-sm font-medium text-slate-900 hover:text-blue-600"
|
||||
>
|
||||
{e.courseTitle}
|
||||
</Link>
|
||||
<Badge tone={ENROLLMENT_STATUS_TONE[e.status]}>
|
||||
{ENROLLMENT_STATUS_LABEL[e.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 max-w-md">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-slate-600">
|
||||
<span>
|
||||
Ukończono {e.completedRequired} z {e.totalRequired}
|
||||
</span>
|
||||
<span className="hx-data font-medium text-slate-900">{e.progressPercent}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500"
|
||||
style={{ width: `${e.progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="hx-data mt-2 text-xs text-slate-500">
|
||||
Zapisany {formatLocal(e.enrolledAt)}
|
||||
{e.lastActivityAt && ` · ostatnia aktywność ${formatLocal(e.lastActivityAt)}`}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <EmptyState icon="file-text" title="Brak zapisanych zgód" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Zgody"
|
||||
description="Pełna historia. Zgody są dopisywane, nigdy nadpisywane, więc obowiązuje najnowsza każdego typu."
|
||||
>
|
||||
<ul className="space-y-2">
|
||||
{data.consents.map((consent) => (
|
||||
<li
|
||||
key={consent.id}
|
||||
className={`flex flex-wrap items-center justify-between gap-2 rounded-md border p-3 ${
|
||||
consent.current ? 'border-slate-200 bg-white' : 'border-slate-100 bg-slate-25'
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{CONSENT_LABELS[consent.type] ?? consent.type}
|
||||
</p>
|
||||
{consent.current && <Badge tone="info">obowiązuje</Badge>}
|
||||
</div>
|
||||
<p className="hx-data mt-0.5 text-xs text-slate-500">
|
||||
wersja {consent.version} · {formatLocal(consent.grantedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone={consent.granted ? 'ok' : 'neutral'}>
|
||||
{consent.granted ? 'udzielona' : 'odmowa'}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function WebinarsTab({ data }: { data: StudentDetail }) {
|
||||
if (data.webinars.length === 0) {
|
||||
return <EmptyState icon="presentation" title="Brak zapisów na webinary" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Webinary">
|
||||
<ul className="space-y-2">
|
||||
{data.webinars.map((w) => (
|
||||
<li
|
||||
key={w.webinarId}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={panelPath(`/webinary/${w.webinarId}`)}
|
||||
className="text-sm font-medium text-slate-900 hover:text-blue-600"
|
||||
>
|
||||
{w.title}
|
||||
</Link>
|
||||
<p className="hx-data mt-0.5 text-xs text-slate-500">{formatLocal(w.startsAt)}</p>
|
||||
</div>
|
||||
{w.attended ? (
|
||||
<Badge tone="ok">obecny</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">nieobecny</Badge>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function CertificatesTab({ data }: { data: StudentDetail }) {
|
||||
if (data.certificates.length === 0) {
|
||||
return <EmptyState icon="award" title="Brak certyfikatów" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Certyfikaty">
|
||||
<ul className="space-y-2">
|
||||
{data.certificates.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium text-slate-900">{c.subjectTitle}</p>
|
||||
<Badge tone={c.forCourse ? 'brand' : 'info'}>
|
||||
{c.forCourse ? 'kurs' : 'webinar'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="hx-data mt-0.5 text-xs text-slate-500">
|
||||
nr {c.number} · {formatLocal(c.issuedAt)}
|
||||
</p>
|
||||
</div>
|
||||
{c.revoked ? (
|
||||
<Badge tone="alarm">unieważniony</Badge>
|
||||
) : (
|
||||
<a
|
||||
href={`/api/public/certificates/${c.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Pobierz
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function OrdersTab({ data }: { data: StudentDetail }) {
|
||||
if (data.orders.length === 0) {
|
||||
return <EmptyState icon="credit-card" title="Brak zamówień" />
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Zamówienia">
|
||||
<ul className="space-y-2">
|
||||
{data.orders.map((o) => (
|
||||
<li
|
||||
key={o.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-slate-100 p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900">{o.courseTitle ?? 'Kurs usunięty'}</p>
|
||||
<p className="hx-data mt-0.5 text-xs text-slate-500">
|
||||
{o.orderNumber} · {formatLocal(o.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{o.amount && (
|
||||
<span className="hx-data text-sm text-slate-700">
|
||||
{o.amount} {o.currency}
|
||||
</span>
|
||||
)}
|
||||
<Badge tone={o.status === 'PAID' ? 'ok' : 'neutral'}>{o.status}</Badge>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Modal
|
||||
title={`Zapisz na kurs: ${studentName}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button
|
||||
icon="check"
|
||||
onClick={() => save.mutate()}
|
||||
disabled={!courseId || !note.trim() || save.isPending}
|
||||
>
|
||||
{save.isPending ? 'Zapisywanie...' : 'Zapisz na kurs'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Alert tone="warn" icon="alert-triangle">
|
||||
Ręczny zapis omija płatność i akceptację regulaminu. Kursant dostanie dostęp od razu.
|
||||
</Alert>
|
||||
|
||||
<Field label="Kurs">
|
||||
<Select value={courseId} onChange={(e) => setCourseId(e.target.value)}>
|
||||
<option value="">Wybierz kurs</option>
|
||||
{(courses.data ?? [])
|
||||
.filter((c) => c.status !== 'DRAFT')
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Uzasadnienie"
|
||||
hint="Trafi do dziennika. Bez niego po miesiącu nie da się ustalić, dlaczego kursant dostał dostęp."
|
||||
>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="np. opłacone przelewem tradycyjnym, faktura FV/2026/14"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { StudentRow } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Pagination, PAGE_SIZE } from '../../components/Pagination'
|
||||
import { SortableHeader } from '../../components/SortableHeader'
|
||||
import type { SortState } from '../../components/SortableHeader'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Input,
|
||||
Select,
|
||||
Spinner,
|
||||
} from '../../components/ui'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { panelPath } from '../../lib/routes'
|
||||
|
||||
/**
|
||||
* Lista kursantow, czyli kontaktow z zalozonym kontem.
|
||||
*
|
||||
* Kursant to kontakt z haslem, a nie osobna tabela z ludzmi: ta granica
|
||||
* jest jedna kolumna w bazie i tutaj widac jej skutek. Ekran pokazuje
|
||||
* wylacznie osoby, ktore konto faktycznie zalozyly.
|
||||
*/
|
||||
export function StudentListPage() {
|
||||
const toasts = useToasts()
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [verified, setVerified] = useState('')
|
||||
const [enabled, setEnabled] = useState('')
|
||||
const [withCourses, setWithCourses] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
const [sort, setSort] = useState<SortState>({ field: 'created_at', direction: 'desc' })
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
const { data, isPending, isError, error } = useQuery({
|
||||
queryKey: ['students', search, verified, enabled, withCourses, page, sort.field, sort.direction],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await client.get<{
|
||||
content: StudentRow[]
|
||||
number: number
|
||||
totalPages: number
|
||||
totalElements: number
|
||||
}>('/admin/academy/students', {
|
||||
params: {
|
||||
search: search || undefined,
|
||||
verified: verified === '' ? undefined : verified === 'tak',
|
||||
enabled: enabled === '' ? undefined : enabled === 'tak',
|
||||
withCourses: withCourses === '' ? undefined : withCourses === 'tak',
|
||||
sort: sort.field,
|
||||
direction: sort.direction,
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
},
|
||||
})
|
||||
).data,
|
||||
})
|
||||
|
||||
/** Zmiana filtru cofa na pierwsza strone: inaczej widac pusta tabele. */
|
||||
const changeFilter = (setter: (value: string) => void) => (value: string) => {
|
||||
setter(value)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const changeSort = (next: SortState) => {
|
||||
setSort(next)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const exportCsv = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
// przez axios jako blob: endpoint wymaga naglowka Authorization,
|
||||
// ktorego zwykly odnosnik nie doklada
|
||||
const response = await client.get('/admin/academy/students/export', {
|
||||
responseType: 'blob',
|
||||
})
|
||||
const url = URL.createObjectURL(response.data as Blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'kursanci.csv'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
toasts.error(errorMessage(e, 'Nie udało się pobrać pliku'))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const students = data?.content ?? []
|
||||
const filtersActive = Boolean(search || verified || enabled || withCourses)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Kursanci</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
Osoby, które założyły konto w Akademii.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" icon="download" onClick={exportCsv} disabled={exporting}>
|
||||
{exporting ? 'Przygotowuję...' : 'Eksport CSV'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="student-search">
|
||||
Szukaj
|
||||
</label>
|
||||
<Input
|
||||
id="student-search"
|
||||
className="w-full sm:w-72"
|
||||
placeholder="Nazwisko, imię albo adres"
|
||||
value={search}
|
||||
onChange={(e) => changeFilter(setSearch)(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="student-verified">
|
||||
Adres potwierdzony
|
||||
</label>
|
||||
<Select
|
||||
id="student-verified"
|
||||
className="w-full sm:w-44"
|
||||
value={verified}
|
||||
onChange={(e) => changeFilter(setVerified)(e.target.value)}
|
||||
>
|
||||
<option value="">Wszyscy</option>
|
||||
<option value="tak">Potwierdzony</option>
|
||||
<option value="nie">Niepotwierdzony</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="student-enabled">
|
||||
Konto
|
||||
</label>
|
||||
<Select
|
||||
id="student-enabled"
|
||||
className="w-full sm:w-40"
|
||||
value={enabled}
|
||||
onChange={(e) => changeFilter(setEnabled)(e.target.value)}
|
||||
>
|
||||
<option value="">Wszystkie</option>
|
||||
<option value="tak">Aktywne</option>
|
||||
<option value="nie">Wyłączone</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800" htmlFor="student-courses">
|
||||
Kursy
|
||||
</label>
|
||||
<Select
|
||||
id="student-courses"
|
||||
className="w-full sm:w-44"
|
||||
value={withCourses}
|
||||
onChange={(e) => changeFilter(setWithCourses)(e.target.value)}
|
||||
>
|
||||
<option value="">Wszyscy</option>
|
||||
<option value="tak">Z kursami</option>
|
||||
<option value="nie">Bez kursów</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<p className="ml-auto text-sm text-slate-500">
|
||||
Znaleziono: <strong className="hx-data">{data?.totalElements ?? 0}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
|
||||
{isPending && (
|
||||
<div className="p-5">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="p-5">
|
||||
<ErrorState message={errorMessage(error, 'Nie udało się wczytać listy kursantów')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && students.length === 0 && (
|
||||
<div className="p-5">
|
||||
<EmptyState
|
||||
icon="users"
|
||||
title="Brak kursantów"
|
||||
description={
|
||||
filtersActive
|
||||
? 'Żaden kursant nie spełnia wybranych warunków.'
|
||||
: 'Kursanci pojawią się tutaj, gdy ktoś założy konto w Akademii.'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{students.length > 0 && (
|
||||
<>
|
||||
{/* karty do lg, tabela od lg: tabela na telefonie rozpychalaby strone */}
|
||||
<ul className="divide-y divide-slate-100 lg:hidden">
|
||||
{students.map((student) => (
|
||||
<li key={student.id} className="p-4">
|
||||
<Link
|
||||
to={panelPath(`/kursy/kursanci/${student.id}`)}
|
||||
className="text-sm font-medium text-slate-900 hover:text-blue-600"
|
||||
>
|
||||
{student.firstName} {student.lastName}
|
||||
</Link>
|
||||
<p className="hx-data mt-0.5 text-xs text-slate-500">{student.email}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<StatusBadges student={student} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
Kursy: <span className="hx-data">{student.courseCount}</span> · ukończone:{' '}
|
||||
<span className="hx-data">{student.completedCount}</span> · certyfikaty:{' '}
|
||||
<span className="hx-data">{student.certificateCount}</span>
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-100 bg-slate-25 text-left">
|
||||
<tr className="hx-eyebrow">
|
||||
<SortableHeader field="last_name" sort={sort} onSort={changeSort}>
|
||||
Kursant
|
||||
</SortableHeader>
|
||||
<SortableHeader field="email" sort={sort} onSort={changeSort}>
|
||||
Adres
|
||||
</SortableHeader>
|
||||
<SortableHeader field="created_at" sort={sort} onSort={changeSort}>
|
||||
Rejestracja
|
||||
</SortableHeader>
|
||||
<SortableHeader field="last_login_at" sort={sort} onSort={changeSort}>
|
||||
Ostatnie logowanie
|
||||
</SortableHeader>
|
||||
<th className="px-4 py-2.5">Stan</th>
|
||||
{/*
|
||||
Liczniki NIE sa sortowalne: powstaja osobnym zapytaniem
|
||||
zbiorczym do juz pobranej strony, wiec baza nie ma po czym
|
||||
sortowac, a sortowanie samej strony wprowadzaloby w blad.
|
||||
*/}
|
||||
<th className="px-4 py-2.5 text-right">Kursy</th>
|
||||
<th className="px-4 py-2.5 text-right">Ukończone</th>
|
||||
<th className="px-4 py-2.5 text-right">Certyfikaty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{students.map((student) => (
|
||||
<tr
|
||||
key={student.id}
|
||||
className="border-b border-slate-50 transition duration-fast ease-standard last:border-b-0 hover:bg-slate-25"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
to={panelPath(`/kursy/kursanci/${student.id}`)}
|
||||
className="font-medium text-slate-900 hover:text-blue-600"
|
||||
>
|
||||
{student.firstName} {student.lastName}
|
||||
</Link>
|
||||
{student.company && (
|
||||
<p className="text-xs text-slate-500">{student.company}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="hx-data px-4 py-3 text-slate-600">{student.email}</td>
|
||||
<td className="hx-data px-4 py-3 text-slate-600">
|
||||
{formatLocal(student.registeredAt)}
|
||||
</td>
|
||||
<td className="hx-data px-4 py-3 text-slate-600">
|
||||
{student.lastLoginAt ? formatLocal(student.lastLoginAt) : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusBadges student={student} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="hx-data px-4 py-3 text-right text-slate-700">
|
||||
{student.courseCount || '-'}
|
||||
</td>
|
||||
<td className="hx-data px-4 py-3 text-right text-slate-700">
|
||||
{student.completedCount || '-'}
|
||||
</td>
|
||||
<td className="hx-data px-4 py-3 text-right text-slate-700">
|
||||
{student.certificateCount || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={data!.number}
|
||||
totalPages={data!.totalPages}
|
||||
totalElements={data!.totalElements}
|
||||
onChange={setPage}
|
||||
itemLabel="kursantów"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Odznaki stanu konta.
|
||||
*
|
||||
* Pokazujemy wylacznie to, co odbiega od normy: konto potwierdzone i aktywne
|
||||
* nie dostaje zadnej odznaki, bo to stan spodziewany, a odznaka przy kazdym
|
||||
* wierszu przestaje cokolwiek znaczyc.
|
||||
*/
|
||||
function StatusBadges({ student }: { student: StudentRow }) {
|
||||
return (
|
||||
<>
|
||||
{!student.accountEnabled && <Badge tone="alarm">Konto wyłączone</Badge>}
|
||||
{!student.emailVerified && <Badge tone="warn">Adres niepotwierdzony</Badge>}
|
||||
{student.accountEnabled && student.emailVerified && (
|
||||
<span className="flex items-center gap-1 text-xs text-ok-600">
|
||||
<Icon name="check-circle" size={14} />
|
||||
Aktywne
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Course, CourseLevel, CourseStatus, EnrollmentStatus, LessonType } from '../../api/types'
|
||||
import type { IconName } from '../../components/Icon'
|
||||
|
||||
/**
|
||||
* Etykiety i tony obszaru kursow.
|
||||
*
|
||||
* Jedno miejsce dla calego obszaru: te same nazwy pojawiaja sie na liscie,
|
||||
* na karcie kursu i w programie, wiec wpisane osobno w kazdym ekranie
|
||||
* rozjezdzalyby sie po pierwszej poprawce.
|
||||
*/
|
||||
|
||||
export const COURSE_STATUS_LABEL: Record<CourseStatus, string> = {
|
||||
DRAFT: 'Wersja robocza',
|
||||
PUBLISHED: 'Opublikowany',
|
||||
ARCHIVED: 'Zarchiwizowany',
|
||||
}
|
||||
|
||||
export const COURSE_STATUS_TONE: Record<CourseStatus, 'ok' | 'warn' | 'neutral'> = {
|
||||
DRAFT: 'neutral',
|
||||
PUBLISHED: 'ok',
|
||||
ARCHIVED: 'warn',
|
||||
}
|
||||
|
||||
export const COURSE_LEVEL_LABEL: Record<CourseLevel, string> = {
|
||||
BASIC: 'Podstawowy',
|
||||
INTERMEDIATE: 'Średnio zaawansowany',
|
||||
ADVANCED: 'Zaawansowany',
|
||||
}
|
||||
|
||||
export const LESSON_TYPE_LABEL: Record<LessonType, string> = {
|
||||
VIDEO: 'Nagranie',
|
||||
TEXT: 'Tekst',
|
||||
FILE: 'Plik do pobrania',
|
||||
WEBINAR: 'Webinar',
|
||||
QUIZ: 'Sprawdzian',
|
||||
}
|
||||
|
||||
export const LESSON_TYPE_ICON: Record<LessonType, IconName> = {
|
||||
VIDEO: 'video',
|
||||
TEXT: 'file-text',
|
||||
FILE: 'download',
|
||||
WEBINAR: 'presentation',
|
||||
QUIZ: 'check-circle',
|
||||
}
|
||||
|
||||
export const ENROLLMENT_STATUS_LABEL: Record<EnrollmentStatus, string> = {
|
||||
ACTIVE: 'Aktywny',
|
||||
COMPLETED: 'Ukończony',
|
||||
CANCELLED: 'Wycofany',
|
||||
EXPIRED: 'Wygasł',
|
||||
}
|
||||
|
||||
export const ENROLLMENT_STATUS_TONE: Record<EnrollmentStatus, 'ok' | 'info' | 'neutral' | 'warn'> = {
|
||||
ACTIVE: 'info',
|
||||
COMPLETED: 'ok',
|
||||
CANCELLED: 'neutral',
|
||||
EXPIRED: 'warn',
|
||||
}
|
||||
|
||||
/**
|
||||
* Cena kursu.
|
||||
*
|
||||
* Brak ceny oznacza kurs darmowy, co jest czym innym niz cena zerowa: kurs
|
||||
* z cena 0,00 ma ustalony cennik, a kurs bez ceny jest bezplatny z zalozenia.
|
||||
* Pokazujemy to inaczej, zeby administrator widzial, ktora decyzje podjeto.
|
||||
*/
|
||||
export function formatPrice(course: Pick<Course, 'priceGross' | 'currency' | 'free'>): string {
|
||||
if (course.free || course.priceGross === null) {
|
||||
return 'darmowy'
|
||||
}
|
||||
const value = Number(course.priceGross)
|
||||
if (Number.isNaN(value)) {
|
||||
return 'darmowy'
|
||||
}
|
||||
return `${value.toFixed(2).replace('.', ',')} ${course.currency}`
|
||||
}
|
||||
|
||||
/** Czas trwania w formie czytelnej dla czlowieka, a nie w samych minutach. */
|
||||
export function formatDuration(minutes: number | null | undefined): string {
|
||||
if (!minutes || minutes <= 0) {
|
||||
return '-'
|
||||
}
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const rest = minutes % 60
|
||||
if (hours === 0) {
|
||||
return `${rest} min`
|
||||
}
|
||||
return rest === 0 ? `${hours} godz.` : `${hours} godz. ${rest} min`
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Order, OrderDetail, OrderStatus, Page } from '../../api/types'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
Select,
|
||||
Spinner,
|
||||
Textarea,
|
||||
} from '../../components/ui'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
|
||||
const STATUS_LABEL: Record<OrderStatus, string> = {
|
||||
NEW: 'Nowe',
|
||||
PENDING: 'Oczekuje na płatność',
|
||||
PAID: 'Opłacone',
|
||||
FAILED: 'Nieudane',
|
||||
CANCELLED: 'Anulowane',
|
||||
EXPIRED: 'Wygasło',
|
||||
}
|
||||
|
||||
const STATUS_TONE: Record<OrderStatus, 'ok' | 'info' | 'warn' | 'alarm' | 'neutral'> = {
|
||||
NEW: 'neutral',
|
||||
PENDING: 'info',
|
||||
PAID: 'ok',
|
||||
FAILED: 'alarm',
|
||||
CANCELLED: 'neutral',
|
||||
EXPIRED: 'warn',
|
||||
}
|
||||
|
||||
function formatAmount(order: Pick<Order, 'amountGross' | 'currency'>): string {
|
||||
const value = Number(order.amountGross)
|
||||
return `${value.toFixed(2).replace('.', ',')} ${order.currency}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Zamowienia i platnosci.
|
||||
*
|
||||
* Lista sluzy przede wszystkim do rozstrzygania sporow o platnosc, wiec kazde
|
||||
* zamowienie prowadzi do historii komunikatow ITN wraz z ich surowa trescia.
|
||||
*/
|
||||
export function OrderListPage() {
|
||||
const [page, setPage] = useState(0)
|
||||
const [status, setStatus] = useState<OrderStatus | ''>('')
|
||||
const [openOrder, setOpenOrder] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0)
|
||||
}, [status])
|
||||
|
||||
const orders = useQuery({
|
||||
queryKey: ['orders', page, status],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({ page: String(page), size: String(PAGE_SIZE) })
|
||||
if (status) params.set('status', status)
|
||||
return (await client.get<Page<Order>>(`/admin/orders?${params}`)).data
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Zamówienia</h1>
|
||||
<p className="text-sm text-slate-500">Płatności za kursy Akademii</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Select
|
||||
className="max-w-xs"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as OrderStatus | '')}
|
||||
aria-label="Filtr statusu zamówienia"
|
||||
>
|
||||
<option value="">Wszystkie statusy</option>
|
||||
{(Object.keys(STATUS_LABEL) as OrderStatus[]).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{STATUS_LABEL[value]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Card>
|
||||
|
||||
{orders.isPending && <Spinner />}
|
||||
{orders.isError && (
|
||||
<ErrorState message={errorMessage(orders.error, 'Nie udało się wczytać zamówień')} />
|
||||
)}
|
||||
|
||||
{orders.data && orders.data.content.length === 0 && (
|
||||
<EmptyState
|
||||
icon="send"
|
||||
title="Nie ma jeszcze żadnych zamówień"
|
||||
description="Zamówienie powstaje, gdy kursant kupuje kurs płatny."
|
||||
/>
|
||||
)}
|
||||
|
||||
{orders.data && orders.data.content.length > 0 && (
|
||||
<Card className="overflow-hidden">
|
||||
{/* karty do lg, tabela od lg: tabela z min-w rozpychalaby telefon */}
|
||||
<ul className="divide-y divide-slate-100 lg:hidden">
|
||||
{orders.data.content.map((order) => (
|
||||
<li key={order.id} className="py-3 first:pt-0 last:pb-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenOrder(order.id)}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="hx-data text-sm font-medium text-slate-900">
|
||||
{order.orderNumber}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">{order.courseTitle ?? '(kurs usunięty)'}</p>
|
||||
<p className="text-xs text-slate-400">{order.contactEmail}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge tone={STATUS_TONE[order.status]}>{STATUS_LABEL[order.status]}</Badge>
|
||||
<p className="hx-data mt-1 text-sm text-slate-700">{formatAmount(order)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 text-left text-xs uppercase tracking-caps text-slate-500">
|
||||
<th className="px-3 py-2 font-medium">Numer</th>
|
||||
<th className="px-3 py-2 font-medium">Kursant</th>
|
||||
<th className="px-3 py-2 font-medium">Kurs</th>
|
||||
<th className="px-3 py-2 font-medium">Kwota</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Złożono</th>
|
||||
<th className="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{orders.data.content.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-slate-25">
|
||||
<td className="hx-data px-3 py-2.5 text-slate-900">{order.orderNumber}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<p className="text-slate-900">{order.contactName ?? '-'}</p>
|
||||
<p className="hx-data text-xs text-slate-500">{order.contactEmail}</p>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-slate-600">
|
||||
{order.courseTitle ?? '(kurs usunięty)'}
|
||||
</td>
|
||||
<td className="hx-data px-3 py-2.5">{formatAmount(order)}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge tone={STATUS_TONE[order.status]}>{STATUS_LABEL[order.status]}</Badge>
|
||||
</td>
|
||||
<td className="hx-data px-3 py-2.5 text-slate-500">
|
||||
{formatLocal(order.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenOrder(order.id)}
|
||||
title="Szczegóły zamówienia"
|
||||
aria-label={`Szczegóły zamówienia ${order.orderNumber}`}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-md text-slate-500 transition duration-fast ease-standard hover:bg-slate-50 hover:text-slate-900"
|
||||
>
|
||||
<Icon name="eye" size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="pt-3">
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={orders.data.totalPages}
|
||||
totalElements={orders.data.totalElements}
|
||||
onChange={setPage}
|
||||
itemLabel="zamówień"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{openOrder !== null && (
|
||||
<OrderDetailModal orderId={openOrder} onClose={() => setOpenOrder(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Karta zamowienia z historia komunikatow ITN.
|
||||
*
|
||||
* Surowa tresc komunikatu jest tu celowo dostepna: przy sporze o platnosc to
|
||||
* jedyny dowod na to, co bramka faktycznie przyslala.
|
||||
*/
|
||||
function OrderDetailModal({ orderId, onClose }: { orderId: number; onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const [note, setNote] = useState('')
|
||||
const [granting, setGranting] = useState(false)
|
||||
|
||||
const detail = useQuery({
|
||||
queryKey: ['orders', 'detail', orderId],
|
||||
queryFn: async () => (await client.get<OrderDetail>(`/admin/orders/${orderId}`)).data,
|
||||
})
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['orders'] })
|
||||
|
||||
const grant = useMutation({
|
||||
mutationFn: () => client.post(`/admin/orders/${orderId}/grant-access`, { note }),
|
||||
onSuccess: () => {
|
||||
toasts.success('Dostęp nadany')
|
||||
setGranting(false)
|
||||
setNote('')
|
||||
invalidate()
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się nadać dostępu')),
|
||||
})
|
||||
|
||||
const cancel = useMutation({
|
||||
mutationFn: () => client.post(`/admin/orders/${orderId}/cancel`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Zamówienie anulowane')
|
||||
invalidate()
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się anulować')),
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal title="Zamówienie" onClose={onClose} wide>
|
||||
{detail.isPending && <Spinner />}
|
||||
{detail.isError && (
|
||||
<ErrorState message={errorMessage(detail.error, 'Nie udało się wczytać zamówienia')} />
|
||||
)}
|
||||
|
||||
{detail.data && (
|
||||
<div className="space-y-4">
|
||||
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||
<Row label="Numer">{detail.data.order.orderNumber}</Row>
|
||||
<Row label="Status">
|
||||
<Badge tone={STATUS_TONE[detail.data.order.status]}>
|
||||
{STATUS_LABEL[detail.data.order.status]}
|
||||
</Badge>
|
||||
</Row>
|
||||
<Row label="Kursant">
|
||||
{detail.data.order.contactName} · {detail.data.order.contactEmail}
|
||||
</Row>
|
||||
<Row label="Kurs">{detail.data.order.courseTitle ?? '(kurs usunięty)'}</Row>
|
||||
<Row label="Kwota">{formatAmount(detail.data.order)}</Row>
|
||||
<Row label="Złożono">{formatLocal(detail.data.order.createdAt)}</Row>
|
||||
{detail.data.order.paidAt && (
|
||||
<Row label="Opłacono">{formatLocal(detail.data.order.paidAt)}</Row>
|
||||
)}
|
||||
{detail.data.order.remoteId && (
|
||||
<Row label="Identyfikator transakcji">{detail.data.order.remoteId}</Row>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{/* --- reczne nadanie dostepu --- */}
|
||||
{detail.data.order.status !== 'PAID' && (
|
||||
<Card title="Ręczne nadanie dostępu">
|
||||
{granting ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (note.trim()) grant.mutate()
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<Alert tone="warn">
|
||||
Użyj tego wyłącznie, gdy płatność wpłynęła poza bramką, na przykład przelewem
|
||||
tradycyjnym. Zamówienie zostanie oznaczone jako opłacone.
|
||||
</Alert>
|
||||
<Field
|
||||
label="Notatka"
|
||||
hint="Wymagana. Bez niej po miesiącu nie da się ustalić, na jakiej podstawie nadano dostęp."
|
||||
>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="np. przelew z 30.08, potwierdzenie w załączniku do sprawy 123"
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => setGranting(false)}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button type="submit" icon="check" disabled={!note.trim() || grant.isPending}>
|
||||
{grant.isPending ? 'Nadawanie...' : 'Nadaj dostęp'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" icon="check" onClick={() => setGranting(true)}>
|
||||
Nadaj dostęp ręcznie
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="x"
|
||||
onClick={() => {
|
||||
if (window.confirm('Anulować to zamówienie?')) cancel.mutate()
|
||||
}}
|
||||
>
|
||||
Anuluj zamówienie
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* --- historia ITN --- */}
|
||||
<Card
|
||||
title="Komunikaty od operatora płatności"
|
||||
description="Pełny dziennik, także komunikaty odrzucone. Przy sporze o płatność to jedyny dowód."
|
||||
>
|
||||
{detail.data.notifications.length === 0 && (
|
||||
<p className="text-sm text-slate-500">Nie przyszedł jeszcze żaden komunikat.</p>
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{detail.data.notifications.map((notification) => (
|
||||
<li key={notification.id} className="rounded-md border border-slate-100 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="hx-data text-sm font-medium text-slate-900">
|
||||
{notification.paymentStatus ?? '(brak statusu)'}
|
||||
</span>
|
||||
{notification.hashValid ? (
|
||||
<Badge tone="ok">podpis poprawny</Badge>
|
||||
) : (
|
||||
<Badge tone="alarm">podpis niepoprawny</Badge>
|
||||
)}
|
||||
{notification.processed && <Badge tone="info">przetworzony</Badge>}
|
||||
<span className="hx-data ml-auto text-xs text-slate-400">
|
||||
{formatLocal(notification.receivedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="hx-data mt-1 text-xs text-slate-500">
|
||||
{notification.remoteId ?? '-'} · {notification.amount ?? '-'}
|
||||
{notification.paymentStatusDetails && ` · ${notification.paymentStatusDetails}`}
|
||||
</p>
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer text-xs text-blue-600 hover:text-blue-700">
|
||||
Pokaż surową treść
|
||||
</summary>
|
||||
<pre className="hx-data mt-1 max-h-48 overflow-auto rounded bg-slate-50 p-2 text-2xs text-slate-600">
|
||||
{notification.rawXml}
|
||||
</pre>
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-caps text-slate-400">{label}</dt>
|
||||
<dd className="hx-data mt-0.5 text-sm text-slate-800">{children}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { BannerPlacement, PublicBanner } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { useRotation } from '../../lib/useBanners'
|
||||
|
||||
/**
|
||||
* Miejsce na baner w sali.
|
||||
*
|
||||
* ROTACJA JEST NA MIEJSCE, NIE NA CALA SALE
|
||||
* ------------------------------------------------------------------------
|
||||
* Kazde miejsce rotuje niezaleznie: baner boczny i pas pod wideo nie musza
|
||||
* zmieniac sie w tej samej chwili, a wspolny licznik wiazalby je bez powodu.
|
||||
*
|
||||
* KLIKNIECIE MA DWIE DROGI
|
||||
* ------------------------------------------------------------------------
|
||||
* Zwykly baner otwiera `targetUrl` w nowej karcie. Baner z tematem oferty
|
||||
* otwiera **formularz zapytania** w oknie, bez opuszczania sali: to domyka
|
||||
* droge od reklamy do kontaktu handlowego. Uczestnik, ktory wyszedl na obcy
|
||||
* serwis w trakcie prezentacji, zwykle juz nie wraca.
|
||||
*
|
||||
* `rel="noopener noreferrer"` odcina nowej karcie dostep do `window.opener`
|
||||
* i nie zdradza obcemu serwisowi, z jakiego adresu przyszedl uczestnik.
|
||||
*/
|
||||
export function BannerSlot({
|
||||
placement,
|
||||
banners,
|
||||
rotationSeconds,
|
||||
onImpression,
|
||||
onClick,
|
||||
onDismiss,
|
||||
onOfferTopic,
|
||||
className = '',
|
||||
}: {
|
||||
placement: BannerPlacement
|
||||
banners: PublicBanner[]
|
||||
rotationSeconds: number
|
||||
onImpression: (bannerId: number) => void
|
||||
onClick: (bannerId: number) => void
|
||||
/** Zamkniecie, wylacznie przy OVERLAY. */
|
||||
onDismiss?: (bannerId: number) => void
|
||||
/** Otwarcie formularza oferty z wybranym z gory tematem. */
|
||||
onOfferTopic?: (topic: string) => void
|
||||
className?: string
|
||||
}) {
|
||||
const index = useRotation(banners.length, rotationSeconds)
|
||||
const banner = banners[index]
|
||||
|
||||
/*
|
||||
* Wyswietlenie liczymy, gdy baner faktycznie wchodzi na ekran, a nie gdy
|
||||
* przychodzi z serwera: baner z rotacji, ktorego uczestnik nigdy nie
|
||||
* zobaczyl, nie zostal wyswietlony.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (banner) onImpression(banner.id)
|
||||
}, [banner, onImpression])
|
||||
|
||||
if (!banner) return null
|
||||
|
||||
const isOffer = Boolean(banner.offerTopicName)
|
||||
const clickable = isOffer || Boolean(banner.targetUrl)
|
||||
|
||||
function handleClick() {
|
||||
onClick(banner.id)
|
||||
if (isOffer && banner.offerTopicName && onOfferTopic) {
|
||||
onOfferTopic(banner.offerTopicName)
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{banner.mediaUrl ? (
|
||||
<img
|
||||
src={banner.mediaUrl}
|
||||
alt={banner.title}
|
||||
className="block w-full object-contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
/*
|
||||
Tresc jest oczyszczona sanitizerem na backendzie, tym samym co tresc
|
||||
strony zapisu, wiec nie czyscimy jej drugi raz w przegladarce.
|
||||
*/
|
||||
<div
|
||||
className="prose prose-sm max-w-none p-3"
|
||||
dangerouslySetInnerHTML={{ __html: banner.contentHtml ?? '' }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const frame =
|
||||
placement === 'OVERLAY'
|
||||
? 'relative overflow-hidden rounded-lg border border-white/20 bg-white shadow-xl'
|
||||
: 'relative overflow-hidden rounded-lg border border-slate-200 bg-white'
|
||||
|
||||
return (
|
||||
<div className={`${frame} ${className}`}>
|
||||
{/*
|
||||
Baner z adresem zewnetrznym jest prawdziwym linkiem, a nie przyciskiem:
|
||||
tylko wtedy dziala srodkowy przycisk myszy i menu kontekstowe.
|
||||
Baner otwierajacy formularz oferty musi byc przyciskiem, bo nigdzie
|
||||
nie prowadzi.
|
||||
*/}
|
||||
{isOffer ? (
|
||||
<button type="button" onClick={handleClick} className="block w-full text-left">
|
||||
{content}
|
||||
</button>
|
||||
) : banner.targetUrl ? (
|
||||
<a
|
||||
href={banner.targetUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleClick}
|
||||
className="block"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
|
||||
{isOffer && (
|
||||
<p className="border-t border-slate-100 px-3 py-1.5 text-xs font-medium text-blue-700">
|
||||
Kliknij, żeby zapytać o ofertę
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/*
|
||||
OVERLAY zaslania czesc obrazu, wiec musi dac sie zamknac: baner nie do
|
||||
usuniecia w trakcie prezentacji jest gorszy niz brak banera.
|
||||
*/}
|
||||
{placement === 'OVERLAY' && onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDismiss(banner.id)}
|
||||
aria-label="Zamknij baner"
|
||||
className="absolute right-1 top-1 flex h-8 w-8 items-center justify-center rounded-md bg-white/90 text-slate-600 shadow-sm ring-1 ring-slate-200 transition-colors duration-fast hover:bg-white hover:text-slate-900"
|
||||
>
|
||||
<Icon name="x" size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* wskaznik rotacji: uczestnik widzi, ze jest wiecej niz jeden baner */}
|
||||
{banners.length > 1 && (
|
||||
<div className="absolute bottom-1.5 left-1.5 flex gap-1">
|
||||
{banners.map((item, i) => (
|
||||
<span
|
||||
key={item.id}
|
||||
className={`h-1.5 w-1.5 rounded-full ${
|
||||
i === index ? 'bg-blue-500' : 'bg-slate-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!clickable && <span className="sr-only">{banner.title}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { publicClient } from '../../api/publicClient'
|
||||
import type { CertificateVerification } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { PublicLayout } from './PublicLayout'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
|
||||
/**
|
||||
* Weryfikacja certyfikatu.
|
||||
*
|
||||
* CO POKAZUJEMY, A CZEGO NIE
|
||||
* ------------------------------------------------------------------------
|
||||
* Strona jest otwarta dla kazdego, kto zna numer, wiec pokazuje **wylacznie**
|
||||
* to, co potrzebne do potwierdzenia autentycznosci: numer, tytul, date, imie
|
||||
* z pierwsza litera nazwiska i informacje o waznosci.
|
||||
*
|
||||
* Nie ma tu pelnego nazwiska, adresu e-mail ani mozliwosci pobrania pliku.
|
||||
* Pracodawca sprawdzajacy dokument ma potwierdzic, ze jest prawdziwy, a nie
|
||||
* poznac dane osoby, ktorej nie zna, ani pobrac cudzy certyfikat.
|
||||
*/
|
||||
export function CertificateVerifyPage() {
|
||||
/*
|
||||
* Numer odczytujemy z wildcardu, nie ze zwyklego parametru: domyslny format
|
||||
* to "2026/1", a ukosnik rozbilby parametr na dwa segmenty i trasa nigdy
|
||||
* by sie nie dopasowala. `*` lapie cala reszte sciezki wraz z ukosnikami.
|
||||
*/
|
||||
const params = useParams()
|
||||
const number = decodeURIComponent(params['*'] ?? '')
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['certificate-verify', number],
|
||||
queryFn: async () => {
|
||||
const response = await publicClient.get<CertificateVerification>(
|
||||
'/public/certificates/verify',
|
||||
// numer w parametrze, nie w sciezce: domyslny wzor zawiera ukosnik
|
||||
{ params: { number } },
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className="flex justify-center py-16">
|
||||
<Icon name="loader" size={28} className="animate-spin text-slate-400" />
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6 text-center">
|
||||
<Icon name="x-circle" size={36} className="mx-auto text-alarm-500" />
|
||||
<h1 className="mt-3 text-lg font-semibold text-slate-900">
|
||||
Nie znaleźliśmy takiego certyfikatu
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Sprawdź, czy numer został przepisany dokładnie tak, jak widnieje na dokumencie.
|
||||
</p>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const data = query.data
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<div className="text-center">
|
||||
{data.valid ? (
|
||||
<>
|
||||
<Icon name="check-circle" size={40} className="mx-auto text-ok-500" />
|
||||
<h1 className="mt-3 text-lg font-semibold text-slate-900">Certyfikat jest ważny</h1>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="x-circle" size={40} className="mx-auto text-alarm-500" />
|
||||
<h1 className="mt-3 text-lg font-semibold text-slate-900">
|
||||
Certyfikat został unieważniony
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Dokument o tym numerze istnieje, ale wystawca wycofał go z obiegu.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<dl className="mt-6 divide-y divide-slate-100 border-t border-slate-100">
|
||||
<Row label="Numer certyfikatu" value={data.number} mono />
|
||||
<Row label={data.forCourse ? 'Kurs' : 'Szkolenie'} value={data.subjectTitle} />
|
||||
<Row label="Uczestnik" value={data.participantName} />
|
||||
<Row label="Data wystawienia" value={formatLocal(data.issuedAt)} mono />
|
||||
</dl>
|
||||
|
||||
{/*
|
||||
Jawne powiedzenie, czego tu nie ma. Sprawdzajacy widzi skrocone imie
|
||||
i moze uznac to za blad; to jest decyzja, nie usterka.
|
||||
*/}
|
||||
<p className="mt-5 border-t border-slate-100 pt-4 text-xs text-slate-500">
|
||||
Ze względu na ochronę danych osobowych pokazujemy imię i pierwszą literę nazwiska.
|
||||
Pełne dane znajdują się na dokumencie, który posiada uczestnik.
|
||||
</p>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 py-3">
|
||||
<dt className="text-sm text-slate-500">{label}</dt>
|
||||
<dd className={`text-sm font-medium text-slate-900 ${mono ? 'hx-data' : ''}`}>{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { SpeakEventPayload } from '../../lib/useInteractions'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Button, Textarea } from '../../components/ui'
|
||||
import { useInteractions, type InteractionView } from '../../lib/useInteractions'
|
||||
import { OfferTab } from './OfferTab'
|
||||
import { TasksTab } from './TasksTab'
|
||||
|
||||
/**
|
||||
* Panel interakcji uczestnika: czat i pytania.
|
||||
*
|
||||
* Cztery zakladki, bo to cztery niezalezne strumienie. Czat plynie szybko
|
||||
* i jest do czytania na biezaco, pytania czekaja na odpowiedz i zbieraja glosy,
|
||||
* zadania i oferta to formularze. Mieszanie ich w jednej liscie utrudnialoby
|
||||
* kazde z osobna.
|
||||
*
|
||||
* LICZNIK PRZY ZAKLADCE NIEAKTYWNEJ
|
||||
* ------------------------------------------------------------------------
|
||||
* Licznik pokazuje **nowe pozycje od ostatniego zajrzenia**, a nie laczna
|
||||
* liczbe wpisow. Laczna liczba przy czacie rosnie caly czas i po kwadransie
|
||||
* nie niesie zadnej informacji: uczestnik nie wie, czy pojawilo sie cos, czego
|
||||
* jeszcze nie widzial. Wejscie w zakladke zeruje jej licznik.
|
||||
*/
|
||||
export function InteractionPanel({
|
||||
token,
|
||||
onSpeakEvent,
|
||||
onOpenMicChange,
|
||||
onOpenVideoChange,
|
||||
onModeration,
|
||||
onHistoryLoaded,
|
||||
}: {
|
||||
token: string
|
||||
/** Zdarzenia zgloszen do glosu ida do paska akcji nad panelem. */
|
||||
onSpeakEvent?: (payload: SpeakEventPayload) => void
|
||||
/** Przelaczenie trybu otwartego mikrofonu przez prowadzacego. */
|
||||
onOpenMicChange?: (enabled: boolean) => void
|
||||
/** Przelaczenie trybu wideo dla wszystkich przez prowadzacego. */
|
||||
onOpenVideoChange?: (enabled: boolean) => void
|
||||
/** Moderacja tego uczestnika (wyciszenie/blokada) - reaguje strona spotkania. */
|
||||
onModeration?: (event: import('../../lib/useInteractions').ModerationEvent) => void
|
||||
/**
|
||||
* Stan poczatkowy z historii.
|
||||
*
|
||||
* Historia jest pobierana tutaj, a tryby otwartego mikrofonu i wideo dla
|
||||
* wszystkich sa w niej zawarte. Drugie zapytanie o to samo z poziomu strony
|
||||
* spotkania byloby zbedne, wiec przekazujemy wartosc wyzej.
|
||||
*/
|
||||
onHistoryLoaded?: (history: { openMic: boolean; openVideo: boolean }) => void
|
||||
}) {
|
||||
// wyciszenie czatu: gdy prowadzacy wyciszy uczestnika, chowamy pole pisania
|
||||
const [chatMuted, setChatMuted] = useState(false)
|
||||
const { history, chat, questions, connected, error, clearError, sendChat, sendQuestion, vote } =
|
||||
useInteractions(token, onSpeakEvent, onOpenMicChange, (event) => {
|
||||
if (event.kind === 'MUTED') setChatMuted(true)
|
||||
if (event.kind === 'UNMUTED') setChatMuted(false)
|
||||
onModeration?.(event)
|
||||
}, onOpenVideoChange)
|
||||
|
||||
// stan poczatkowy wyciszenia z historii: uczestnik wyciszony wczesniej ma
|
||||
// zablokowane pole od wejscia, a nie dopiero po zdarzeniu
|
||||
useEffect(() => {
|
||||
if (history) setChatMuted(history.chatMuted)
|
||||
}, [history])
|
||||
const [tab, setTab] = useState<'chat' | 'questions' | 'tasks' | 'offer'>('chat')
|
||||
|
||||
/*
|
||||
* Ile pozycji bylo widocznych, gdy uczestnik ostatnio patrzyl na zakladke.
|
||||
* Roznica wobec stanu biezacego to liczba nowych.
|
||||
*/
|
||||
const [seen, setSeen] = useState({ chat: 0, questions: 0 })
|
||||
|
||||
const historyHandler = useRef(onHistoryLoaded)
|
||||
historyHandler.current = onHistoryLoaded
|
||||
useEffect(() => {
|
||||
if (history) historyHandler.current?.({ openMic: history.openMic, openVideo: history.openVideo })
|
||||
}, [history])
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'chat') setSeen((c) => ({ ...c, chat: chat.length }))
|
||||
}, [tab, chat.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'questions') setSeen((c) => ({ ...c, questions: questions.length }))
|
||||
}, [tab, questions.length])
|
||||
|
||||
const unread = {
|
||||
chat: tab === 'chat' ? 0 : Math.max(0, chat.length - seen.chat),
|
||||
questions: tab === 'questions' ? 0 : Math.max(0, questions.length - seen.questions),
|
||||
}
|
||||
|
||||
if (!history) return null
|
||||
|
||||
const chatOff = history.chatMode === 'DISABLED'
|
||||
const speakersOnly = history.chatMode === 'SPEAKERS_ONLY' && !history.speaker
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-white">
|
||||
<nav role="tablist" aria-label="Interakcje" className="flex border-b border-slate-200">
|
||||
{(
|
||||
[
|
||||
['chat', 'Czat', 'message-square', unread.chat],
|
||||
['questions', 'Pytania', 'circle-alert', unread.questions],
|
||||
['tasks', 'Zadania', 'check-circle', 0],
|
||||
['offer', 'Oferta', 'send', 0],
|
||||
] as const
|
||||
).map(([id, label, icon, count]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === id}
|
||||
onClick={() => setTab(id)}
|
||||
className={`flex min-h-[44px] flex-1 items-center justify-center gap-2 text-sm transition-colors duration-fast ease-standard ${
|
||||
tab === id
|
||||
? 'border-b-2 border-blue-500 font-medium text-blue-700'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
{label}
|
||||
{count > 0 && (
|
||||
<span className="hx-data inline-flex min-w-[18px] items-center justify-center rounded-full bg-blue-500 px-1 text-2xs font-semibold text-white">
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/*
|
||||
Utrata polaczenia jest widoczna, a wznowienie dzieje sie samo
|
||||
(`reconnectDelay` w kliencie STOMP). Uczestnik ma wiedziec, ze czat
|
||||
chwilowo nie dziala, i ze nie musi nic robic.
|
||||
*/}
|
||||
{!connected && (
|
||||
<p className="flex items-center gap-2 border-b border-warn-500/20 bg-warn-50 px-3 py-2 text-xs text-warn-600">
|
||||
<Icon name="loader" size={14} className="animate-spin" />
|
||||
Połączenie przerwane, wznawiamy...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 border-b border-alarm-500/20 bg-alarm-50 px-3 py-2">
|
||||
<Icon name="circle-alert" size={14} className="mt-0.5 shrink-0 text-alarm-600" />
|
||||
<p className="flex-1 text-xs text-alarm-600">{error}</p>
|
||||
<button type="button" onClick={clearError} aria-label="Zamknij komunikat">
|
||||
<Icon name="x" size={14} className="text-alarm-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'tasks' && <TasksTab token={token} webinarId={history.webinarId} />}
|
||||
{tab === 'offer' && <OfferTab token={token} />}
|
||||
|
||||
{tab === 'chat' ? (
|
||||
<ChatTab
|
||||
messages={chat}
|
||||
disabled={chatOff || speakersOnly || chatMuted}
|
||||
disabledReason={
|
||||
chatMuted
|
||||
? 'Prowadzący wyłączył Ci możliwość pisania na czacie.'
|
||||
: chatOff
|
||||
? 'Czat jest wyłączony na tym webinarze.'
|
||||
: 'Na tym webinarze piszą wyłącznie prowadzący.'
|
||||
}
|
||||
onSend={sendChat}
|
||||
/>
|
||||
) : tab === 'questions' ? (
|
||||
<QuestionsTab
|
||||
questions={questions}
|
||||
requiresApproval={history.questionsRequireApproval}
|
||||
canVote={!history.speaker}
|
||||
onSend={sendQuestion}
|
||||
onVote={vote}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatTab({
|
||||
messages,
|
||||
disabled,
|
||||
disabledReason,
|
||||
onSend,
|
||||
}: {
|
||||
messages: InteractionView[]
|
||||
disabled: boolean
|
||||
disabledReason: string
|
||||
onSend: (content: string) => void
|
||||
}) {
|
||||
const [text, setText] = useState('')
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// nowa wiadomosc ma byc widoczna bez przewijania recznie
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
}, [messages.length])
|
||||
|
||||
function submit() {
|
||||
const content = text.trim()
|
||||
if (!content) return
|
||||
onSend(content)
|
||||
setText('')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
|
||||
{messages.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
Nikt jeszcze nic nie napisał.
|
||||
</p>
|
||||
)}
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="text-sm">
|
||||
<span
|
||||
className={`font-medium ${message.fromSpeaker ? 'text-blue-700' : 'text-slate-700'}`}
|
||||
>
|
||||
{message.authorName}
|
||||
{message.fromSpeaker && ' (prowadzący)'}
|
||||
</span>
|
||||
{/* tresc jest oczyszczona do samego tekstu na backendzie, wiec
|
||||
wstawiamy ja jako tekst; adresy zostaja nieklikalne */}
|
||||
<p className="whitespace-pre-wrap break-words text-slate-600">{message.content}</p>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{disabled ? (
|
||||
<p className="border-t border-slate-200 bg-slate-25 p-3 text-center text-xs text-slate-500">
|
||||
{disabledReason}
|
||||
</p>
|
||||
) : (
|
||||
<div className="border-t border-slate-200 p-2">
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter wysyla, Shift+Enter robi nowa linie
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}}
|
||||
placeholder="Napisz wiadomość..."
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
icon="send"
|
||||
size="sm"
|
||||
className="mt-2 w-full"
|
||||
onClick={submit}
|
||||
disabled={!text.trim()}
|
||||
>
|
||||
Wyślij
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionsTab({
|
||||
questions,
|
||||
requiresApproval,
|
||||
canVote,
|
||||
onSend,
|
||||
onVote,
|
||||
}: {
|
||||
questions: InteractionView[]
|
||||
requiresApproval: boolean
|
||||
canVote: boolean
|
||||
onSend: (content: string) => void
|
||||
onVote: (id: number) => void
|
||||
}) {
|
||||
const [text, setText] = useState('')
|
||||
const [sent, setSent] = useState(false)
|
||||
|
||||
// przypiete na gorze, potem po liczbie glosow: prowadzacy tak wlasnie
|
||||
// ustawia kolejnosc, a uczestnik ma widziec to samo
|
||||
const sorted = [...questions].sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
|
||||
return b.votes - a.votes
|
||||
})
|
||||
|
||||
function submit() {
|
||||
const content = text.trim()
|
||||
if (!content) return
|
||||
onSend(content)
|
||||
setText('')
|
||||
setSent(true)
|
||||
window.setTimeout(() => setSent(false), 4000)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
|
||||
{sorted.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
Nie ma jeszcze pytań. Zadaj pierwsze.
|
||||
</p>
|
||||
)}
|
||||
{sorted.map((question) => (
|
||||
<div
|
||||
key={question.id}
|
||||
className={`rounded-md border p-2.5 ${
|
||||
question.pinned ? 'border-blue-300 bg-blue-50' : 'border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onVote(question.id)}
|
||||
disabled={!canVote || question.votedByMe}
|
||||
aria-label={question.votedByMe ? 'Już zagłosowano' : 'Zagłosuj na to pytanie'}
|
||||
className={`flex min-h-[44px] w-11 shrink-0 flex-col items-center justify-center rounded-md text-xs transition-colors duration-fast ${
|
||||
question.votedByMe
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 disabled:opacity-50'
|
||||
}`}
|
||||
>
|
||||
<Icon name="chevron-up" size={14} />
|
||||
<span className="hx-data">{question.votes}</span>
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-slate-700">
|
||||
{question.content}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">{question.authorName}</p>
|
||||
|
||||
{question.answer && (
|
||||
<div className="mt-2 rounded-md bg-ok-50 p-2">
|
||||
<p className="text-xs font-medium text-ok-600">Odpowiedź prowadzącego</p>
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-slate-700">
|
||||
{question.answer}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 p-2">
|
||||
{sent && (
|
||||
<p className="mb-2 rounded-md bg-ok-50 px-2 py-1.5 text-xs text-ok-600">
|
||||
{requiresApproval
|
||||
? 'Pytanie czeka na zatwierdzenie przez prowadzącego.'
|
||||
: 'Pytanie zostało dodane.'}
|
||||
</p>
|
||||
)}
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Zadaj pytanie..."
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
icon="send"
|
||||
size="sm"
|
||||
className="mt-2 w-full"
|
||||
onClick={submit}
|
||||
disabled={!text.trim()}
|
||||
>
|
||||
Wyślij pytanie
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { JitsiMeeting } from '@jitsi/react-sdk'
|
||||
|
||||
/**
|
||||
* Pokoj Jitsi osadzony w naszym ekranie spotkania.
|
||||
*
|
||||
* OGRANICZENIE INTERFEJSU, NIE SERWERA
|
||||
* ------------------------------------------------------------------------
|
||||
* **To jest najwazniejsza rzecz w tym pliku.** Ustawienia ponizej ukrywaja
|
||||
* przycisk kamery uczestnikowi bez prawa do obrazu, ale **Jitsi nie zna
|
||||
* naszych rol**. Serwer nie wie, ze ktos jest VIEWER-em, i niczego mu nie
|
||||
* zabrania: wystarczy wejsc wlasnym klientem, otworzyc ten sam pokoj
|
||||
* bezposrednio na domenie Jitsi albo podmienic konfiguracje w przegladarce,
|
||||
* zeby wlaczyc kamere mimo ukrytego przycisku.
|
||||
*
|
||||
* Przy wlaczonym JWT serwer egzekwuje **wylacznie role moderatora**. Jitsi
|
||||
* nie ma odpowiednika uprawnienia "publikuj tylko audio", wiec roznica miedzy
|
||||
* VIEWER a SPEAKER jest tu w calosci umowna.
|
||||
*
|
||||
* Gwarancje po stronie serwera daje **LiveKit**, gdzie uprawnienia sa
|
||||
* w podpisanym tokenie i uczestnik bez prawa publikacji zostanie odrzucony.
|
||||
* Panel mowi o tej roznicy wprost przy wyborze dostawcy.
|
||||
*/
|
||||
export function JitsiRoom({
|
||||
domain,
|
||||
roomName,
|
||||
displayName,
|
||||
jwt,
|
||||
canPublishVideo,
|
||||
canPublishAudio,
|
||||
onLeave,
|
||||
}: {
|
||||
domain: string
|
||||
roomName: string
|
||||
displayName: string
|
||||
/** Null, gdy serwer nie wymaga tokenu. Pokoj jest wtedy otwarty. */
|
||||
jwt: string | null
|
||||
/** Wylacznie do ustawienia interfejsu. Serwer tego nie egzekwuje. */
|
||||
canPublishVideo: boolean
|
||||
canPublishAudio: boolean
|
||||
onLeave?: () => void
|
||||
}) {
|
||||
return (
|
||||
<JitsiMeeting
|
||||
domain={domain}
|
||||
roomName={roomName}
|
||||
jwt={jwt ?? undefined}
|
||||
configOverwrite={{
|
||||
/*
|
||||
* Wchodzimy wyciszeni i z wylaczona kamera, niezaleznie od roli:
|
||||
* przypadkowo otwarty mikrofon slychac na calej sali, a obraz
|
||||
* z kamery uczestnika nigdy nie ma isc automatycznie.
|
||||
*/
|
||||
startWithAudioMuted: true,
|
||||
startWithVideoMuted: true,
|
||||
prejoinPageEnabled: false,
|
||||
disableDeepLinking: true,
|
||||
}}
|
||||
interfaceConfigOverwrite={{
|
||||
/*
|
||||
* Ukrycie przyciskow to **wygoda, nie zabezpieczenie**: uczestnik bez
|
||||
* prawa do obrazu nie widzi przycisku kamery, ale serwer Jitsi nadal
|
||||
* przyjmie od niego strumien, jesli obejdzie interfejs. Zapisujemy to
|
||||
* tutaj, zeby nikt nie wzial tej listy za kontrole dostepu.
|
||||
*/
|
||||
TOOLBAR_BUTTONS: [
|
||||
...(canPublishAudio ? ['microphone'] : []),
|
||||
...(canPublishVideo ? ['camera', 'desktop'] : []),
|
||||
'chat',
|
||||
'raisehand',
|
||||
'tileview',
|
||||
'fullscreen',
|
||||
'settings',
|
||||
'hangup',
|
||||
],
|
||||
SHOW_JITSI_WATERMARK: false,
|
||||
SHOW_BRAND_WATERMARK: false,
|
||||
MOBILE_APP_PROMO: false,
|
||||
}}
|
||||
userInfo={{ displayName, email: '' }}
|
||||
onReadyToClose={onLeave}
|
||||
getIFrameRef={(iframe) => {
|
||||
iframe.style.height = '100%'
|
||||
iframe.style.width = '100%'
|
||||
iframe.style.border = '0'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { PublicLayout } from './PublicLayout'
|
||||
import { useLegal } from '../../lib/useLegal'
|
||||
|
||||
/**
|
||||
* Strona regulaminu albo polityki prywatnosci.
|
||||
*
|
||||
* Tresc pochodzi z gotowych, wbudowanych dokumentow (backend sklada je z danych
|
||||
* firmy z ustawien). Administrator nie pisze prawa - dokumenty sa zawsze
|
||||
* obecne, a w miejsce danych firmy podstawiane sa wartosci z konfiguracji.
|
||||
* Obowiazek informacyjny RODO jest juz czescia tresci polityki, wiec strona
|
||||
* tylko ja wyswietla.
|
||||
*/
|
||||
export function LegalPage({ kind }: { kind: 'terms' | 'privacy' }) {
|
||||
const legal = useLegal()
|
||||
|
||||
const title = kind === 'terms' ? 'Regulamin' : 'Polityka prywatności'
|
||||
const html = kind === 'terms' ? legal.data?.termsHtml : legal.data?.privacyHtml
|
||||
|
||||
return (
|
||||
<PublicLayout wide>
|
||||
<article className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold text-slate-900">{title}</h1>
|
||||
|
||||
{legal.isPending ? (
|
||||
<p className="text-sm text-slate-500">Wczytywanie...</p>
|
||||
) : (
|
||||
// tresc jest skladana po stronie serwera z zaufanych stalych i
|
||||
// escapowanych danych firmy - nie przechodzi przez edytor uzytkownika
|
||||
<div
|
||||
className="prose prose-slate max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: html ?? '' }}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import '@livekit/components-styles'
|
||||
import {
|
||||
LiveKitRoom as LiveKitRoomProvider,
|
||||
RoomAudioRenderer,
|
||||
VideoConference,
|
||||
} from '@livekit/components-react'
|
||||
import { RoomOptions } from 'livekit-client'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* Pokoj LiveKit osadzony w naszym ekranie spotkania.
|
||||
*
|
||||
* GOTOWY KOMPONENT, NIE WLASNY INTERFEJS
|
||||
* ------------------------------------------------------------------------
|
||||
* `VideoConference` daje uklad kafelkow, przelaczanie na aktywnego mowce,
|
||||
* wybor urzadzen i wskazniki polaczenia. Odtwarzanie tego u siebie oznaczaloby
|
||||
* miesiace pracy nad rzecza, ktora nie jest tym, co ta platforma wnosi.
|
||||
* **Nasze zostaje to, czego dostawca nie ma**: czat, pytania, zadania,
|
||||
* zapytania ofertowe, banery i pasek akcji.
|
||||
*
|
||||
* UPRAWNIENIA POCHODZA Z TOKENU, NIE STAD
|
||||
* ------------------------------------------------------------------------
|
||||
* Ten komponent niczego nie zabrania. Uczestnik bez prawa publikacji nie ma
|
||||
* przycisku kamery, bo **serwer nie pozwoli mu publikowac**, a biblioteka to
|
||||
* odczytuje z tokenu. Ukrywanie przyciskow tutaj byloby kosmetyka.
|
||||
*
|
||||
* SUBSKRYPCJA OGRANICZONA DO AKTYWNYCH MOWCOW
|
||||
* ------------------------------------------------------------------------
|
||||
* Przy otwartym mikrofonie i stu uczestnikach kazda przegladarka odbieralaby
|
||||
* sto strumieni audio naraz, a ruch rosnie wtedy liniowo z liczba uczestnikow.
|
||||
* `adaptiveStream` i `dynacast` sprawiaja, ze klient pobiera wylacznie to, co
|
||||
* faktycznie widac i slychac: strumienie spoza kadru sa wstrzymywane, a jakosc
|
||||
* dobierana do rozmiaru kafelka.
|
||||
*/
|
||||
export function LiveKitRoom({
|
||||
serverUrl,
|
||||
token,
|
||||
onDisconnected,
|
||||
}: {
|
||||
serverUrl: string
|
||||
token: string
|
||||
onDisconnected?: () => void
|
||||
}) {
|
||||
const options = useMemo<RoomOptions>(
|
||||
() => ({
|
||||
/*
|
||||
* Klient pobiera tylko strumienie faktycznie widoczne na ekranie
|
||||
* i w jakosci dobranej do rozmiaru kafelka. Bez tego uczestnik na
|
||||
* telefonie sciagalby obraz w pelnej rozdzielczosci od kazdego mowcy.
|
||||
*/
|
||||
adaptiveStream: true,
|
||||
/*
|
||||
* Serwer wstrzymuje warstwy, ktorych nikt nie oglada. Dziala po stronie
|
||||
* nadawcy, wiec oszczedza pasmo takze prelegentowi.
|
||||
*/
|
||||
dynacast: true,
|
||||
publishDefaults: {
|
||||
// wideo uczestnika jest wylaczone na poziomie tokenu; tutaj ustawiamy
|
||||
// wylacznie parametry dla tych, ktorzy publikowac moga
|
||||
simulcast: true,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<LiveKitRoomProvider
|
||||
serverUrl={serverUrl}
|
||||
token={token}
|
||||
options={options}
|
||||
// wchodzimy z wylaczonymi urzadzeniami: uczestnik sam decyduje, kiedy
|
||||
// zaczyna mowic, a przypadkowo otwarty mikrofon slychac na calej sali
|
||||
connect
|
||||
video={false}
|
||||
audio={false}
|
||||
onDisconnected={onDisconnected}
|
||||
className="h-full w-full"
|
||||
data-lk-theme="default"
|
||||
>
|
||||
<VideoConference />
|
||||
{/* dzwiek pozostalych uczestnikow; bez tego widac obraz, ale nic nie slychac */}
|
||||
<RoomAudioRenderer />
|
||||
</LiveKitRoomProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import { OfferTab } from './OfferTab'
|
||||
import type { useSpeakRequest } from '../../lib/useSpeakRequest'
|
||||
|
||||
/**
|
||||
* Pasek akcji uczestnika.
|
||||
*
|
||||
* DLACZEGO NAD PANELEM, A NIE W ZAKLADCE
|
||||
* ------------------------------------------------------------------------
|
||||
* Te trzy akcje musza byc osiagalne **niezaleznie od tego, co uczestnik
|
||||
* ogląda**. Prosba o glos schowana w zakladce "Pytania" jest bezuzyteczna
|
||||
* dokladnie wtedy, gdy jest potrzebna: prowadzacy wlasnie zaprosil do
|
||||
* zabrania glosu, a uczestnik czyta czat.
|
||||
*
|
||||
* Zapytanie ofertowe otwiera sie w oknie, a nie przelacza zakladke: wysylajac
|
||||
* zapytanie w trakcie prezentacji uczestnik nie chce stracic z oczu tego, co
|
||||
* dzieje sie na sali.
|
||||
*
|
||||
* STAN PRZYCISKU PO WYSLANIU
|
||||
* ------------------------------------------------------------------------
|
||||
* Po wyslaniu zapytania przycisk pokazuje potwierdzenie zamiast zachety.
|
||||
* Bez tego uczestnik, ktory nie widzi zadnej zmiany, klika drugi i trzeci raz,
|
||||
* a handlowiec dostaje trzy zgloszenia od tej samej osoby. Kolejne zapytanie
|
||||
* jest mozliwe, ale wymaga swiadomego klikniecia w "Wyslij kolejne".
|
||||
*/
|
||||
export function MeetingActionBar({
|
||||
token,
|
||||
speak,
|
||||
/** Prelegent nie prosi o glos: ma go z definicji. */
|
||||
isSpeaker,
|
||||
/** Tryb otwartego mikrofonu: kazdy mowi bez proszenia o glos. */
|
||||
openMic,
|
||||
/**
|
||||
* Temat wybrany klikiem w baner.
|
||||
*
|
||||
* Baner z tematem oferty otwiera formularz **tutaj**, a nie u siebie:
|
||||
* okno oferty juz istnieje w pasku akcji wraz z obsluga zgody i stanem
|
||||
* po wyslaniu, a drugie takie okno oznaczaloby dwie kopie tej samej logiki.
|
||||
*/
|
||||
offerTopicFromBanner,
|
||||
onOfferHandled,
|
||||
}: {
|
||||
token: string
|
||||
speak: ReturnType<typeof useSpeakRequest>
|
||||
isSpeaker: boolean
|
||||
openMic: boolean
|
||||
offerTopicFromBanner?: string | null
|
||||
onOfferHandled?: () => void
|
||||
}) {
|
||||
const [offerOpen, setOfferOpen] = useState<'full' | 'callback' | null>(null)
|
||||
const [offerSent, setOfferSent] = useState(false)
|
||||
/** Temat podstawiony z banera, przekazywany do formularza. */
|
||||
const [presetTopic, setPresetTopic] = useState<string | null>(null)
|
||||
|
||||
/*
|
||||
* Klikniecie w baner z tematem otwiera pelny formularz z tym tematem.
|
||||
* Stan "wyslane" cofamy: uczestnik swiadomie kliknal reklame, wiec chce
|
||||
* wyslac kolejne zapytanie, a nie zobaczyc potwierdzenie poprzedniego.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!offerTopicFromBanner) return
|
||||
setPresetTopic(offerTopicFromBanner)
|
||||
setOfferSent(false)
|
||||
setOfferOpen('full')
|
||||
onOfferHandled?.()
|
||||
}, [offerTopicFromBanner, onOfferHandled])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-white px-3 py-2">
|
||||
{/*
|
||||
Prelegent nie zglasza sie o glos ani nie wysyla zapytan ofertowych.
|
||||
Zamiast pustego paska pokazujemy mu, gdzie moderowac: kolejka glosu,
|
||||
otwarty mikrofon i moderacja pytan sa w panelu prowadzenia, dostepnym
|
||||
dla zalogowanego prowadzacego. Sala sluzy do obrazu.
|
||||
*/}
|
||||
{isSpeaker && (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-slate-500">
|
||||
<Icon name="mic" size={16} className="text-ok-500" />
|
||||
Prowadzisz spotkanie. Zgłoszeniami do głosu i pytaniami zarządzasz w panelu
|
||||
prowadzenia (otwórz go w panelu, przy tym webinarze).
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/*
|
||||
W trybie otwartego mikrofonu przycisk zgloszenia znika: proszenie
|
||||
o cos, co juz sie ma, tylko myli. Zostaje informacja, ze mikrofon
|
||||
jest dostepny, bo uczestnik musi wiedziec, ze moze byc slyszany.
|
||||
*/}
|
||||
{!isSpeaker &&
|
||||
(openMic ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-ok-50 px-3 py-1.5 text-sm font-medium text-ok-600">
|
||||
<Icon name="mic" size={16} />
|
||||
Możesz mówić bez zgłaszania się
|
||||
</span>
|
||||
) : (
|
||||
<SpeakButton speak={speak} />
|
||||
))}
|
||||
|
||||
{/*
|
||||
Pasek oferty to akcje UCZESTNIKA (zapytanie do handlowca). Prelegent
|
||||
i prowadzacy ich nie wysylaja - kontakt handlowy zglasza osoba
|
||||
ogladajaca, nie ta, ktora prowadzi. Dlatego caly blok tylko dla
|
||||
nie-prelegenta. Moderacja glosu i pytan jest w panelu prowadzenia,
|
||||
nie tutaj.
|
||||
*/}
|
||||
{!isSpeaker && (
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
{offerSent ? (
|
||||
/*
|
||||
* Potwierdzenie zamiast przyciskow. Zachowujemy mozliwosc wyslania
|
||||
* kolejnego zapytania, ale dopiero po swiadomym kliknieciu:
|
||||
* uczestnik ma zobaczyc, ze pierwsze doszlo.
|
||||
*/
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-ok-50 px-3 py-1.5 text-sm font-medium text-ok-600">
|
||||
<Icon name="check-circle" size={16} />
|
||||
Zapytanie wysłane
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOfferSent(false)
|
||||
setOfferOpen('full')
|
||||
}}
|
||||
className="min-h-[44px] rounded-md px-2 text-sm text-slate-500 underline-offset-2 hover:text-slate-900 hover:underline"
|
||||
>
|
||||
Wyślij kolejne
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOfferOpen('callback')}
|
||||
className="inline-flex min-h-[44px] items-center gap-2 rounded-md border border-slate-200 px-3 text-sm font-medium text-slate-700 transition-colors duration-fast ease-standard hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="mail" size={16} />
|
||||
Proszę o kontakt
|
||||
</button>
|
||||
|
||||
{/* wyrozniony: to jest akcja, na ktorej zalezy organizatorowi */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOfferOpen('full')}
|
||||
className="inline-flex min-h-[44px] items-center gap-2 rounded-md bg-blue-500 px-4 text-sm font-semibold text-white shadow-sm transition-colors duration-fast ease-standard hover:bg-blue-600"
|
||||
>
|
||||
<Icon name="send" size={16} />
|
||||
Interesuje mnie oferta
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{offerOpen && (
|
||||
<Modal
|
||||
title={offerOpen === 'callback' ? 'Proszę o kontakt' : 'Zapytanie ofertowe'}
|
||||
description={
|
||||
offerOpen === 'callback'
|
||||
? 'Zostaw numer, oddzwonimy po webinarze.'
|
||||
: 'Napisz, czego potrzebujesz. Nie tracisz kontaktu ze spotkaniem.'
|
||||
}
|
||||
onClose={() => {
|
||||
setOfferOpen(null)
|
||||
setPresetTopic(null)
|
||||
}}
|
||||
>
|
||||
<OfferTab
|
||||
token={token}
|
||||
callbackOnly={offerOpen === 'callback'}
|
||||
presetTopic={offerOpen === 'full' ? presetTopic : null}
|
||||
onSent={() => {
|
||||
setOfferSent(true)
|
||||
// okno zostaje otwarte na potwierdzeniu z tresc zwrotna
|
||||
// z backendu; uczestnik zamyka je sam, gdy je przeczyta
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Przycisk prosby o glos.
|
||||
*
|
||||
* Trzy stany, bo trzy rozne sytuacje: nie prosil, czeka, ma glos. Jeden
|
||||
* przycisk przelaczajacy sie miedzy "zglos" i "wycofaj" gubilby stan
|
||||
* oczekiwania, czyli dokladnie ten, w ktorym uczestnik zastanawia sie,
|
||||
* czy zgloszenie w ogole doszlo.
|
||||
*/
|
||||
function SpeakButton({ speak }: { speak: ReturnType<typeof useSpeakRequest> }) {
|
||||
if (speak.granted) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-ok-50 px-3 py-1.5 text-sm font-semibold text-ok-600">
|
||||
<Icon name="check-circle" size={16} />
|
||||
Masz głos, mikrofon jest włączony
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={speak.lowerHand}
|
||||
disabled={speak.busy}
|
||||
className="min-h-[44px] rounded-md px-2 text-sm text-slate-500 underline-offset-2 hover:text-slate-900 hover:underline disabled:opacity-50"
|
||||
>
|
||||
Oddaj głos
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (speak.pending) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md bg-warn-50 px-3 py-1.5 text-sm font-medium text-warn-600">
|
||||
<Icon name="clock" size={16} />
|
||||
Czekasz na głos
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={speak.lowerHand}
|
||||
disabled={speak.busy}
|
||||
className="min-h-[44px] rounded-md px-2 text-sm text-slate-500 underline-offset-2 hover:text-slate-900 hover:underline disabled:opacity-50"
|
||||
>
|
||||
Wycofaj
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={speak.raiseHand}
|
||||
disabled={speak.busy}
|
||||
className="inline-flex min-h-[44px] items-center gap-2 rounded-md border border-slate-200 px-3 text-sm font-medium text-slate-700 transition-colors duration-fast ease-standard hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
<Icon name="hand" size={16} />
|
||||
Chcę zabrać głos
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { formatWithZone } from '../../lib/dates'
|
||||
|
||||
/**
|
||||
* Ekran wejsciowy, przed wejsciem na sale.
|
||||
*
|
||||
* DLACZEGO W OGOLE ISTNIEJE
|
||||
* ------------------------------------------------------------------------
|
||||
* Link z maila otwiera sie bez logowania i bez hasla, wiec uczestnik nie ma
|
||||
* zadnego potwierdzenia, **kim jest dla systemu**. Wejscie prosto do pokoju
|
||||
* oznaczaloby, ze osoba, ktora dostala link przekazany od kolegi, zaczyna
|
||||
* wystepowac pod cudzym nazwiskiem na czacie i nie ma jak tego zauwazyc.
|
||||
*
|
||||
* Ekran pokazuje wiec imie i nazwisko z zapisu **do potwierdzenia**, zanim
|
||||
* cokolwiek napisze. Przy okazji daje chwile na sprawdzenie, czy to na pewno
|
||||
* ten webinar i o ktorej sie zaczyna.
|
||||
*
|
||||
* Prelegent widzi to samo, z wyraznym oznaczeniem roli: link prelegenta daje
|
||||
* uprawnienia moderatora, wiec pomylka linkow ma byc widoczna od razu.
|
||||
*/
|
||||
export function MeetingLobby({
|
||||
webinarTitle,
|
||||
displayName,
|
||||
role,
|
||||
startsAt,
|
||||
timezone,
|
||||
onEnter,
|
||||
}: {
|
||||
webinarTitle: string
|
||||
displayName: string
|
||||
role: string
|
||||
/** Godzina rozpoczecia. Null, gdy backend jej nie podal. */
|
||||
startsAt: string | null
|
||||
timezone: string
|
||||
onEnter: () => void
|
||||
}) {
|
||||
const isPresenter = role === 'PRESENTER'
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md">
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-6 text-center shadow-sm">
|
||||
<Icon
|
||||
name={isPresenter ? 'presentation' : 'video'}
|
||||
size={28}
|
||||
className="mx-auto text-blue-500"
|
||||
/>
|
||||
|
||||
<h1 className="mt-3 text-lg font-semibold text-slate-900">{webinarTitle}</h1>
|
||||
|
||||
{startsAt && (
|
||||
<p className="hx-data mt-1 text-sm text-slate-500">
|
||||
{formatWithZone(startsAt, timezone)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* tozsamosc do potwierdzenia: pod tym nazwiskiem uczestnik wystapi na czacie */}
|
||||
<div className="mt-5 rounded-md border border-slate-200 bg-slate-25 p-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Wchodzisz jako
|
||||
</p>
|
||||
<p className="mt-1 text-base font-medium text-slate-900">{displayName}</p>
|
||||
|
||||
{isPresenter && (
|
||||
<p className="mt-2 inline-flex items-center gap-1.5 rounded-md bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700">
|
||||
<Icon name="presentation" size={14} />
|
||||
Prowadzący
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEnter}
|
||||
className="mt-5 inline-flex min-h-[52px] w-full items-center justify-center gap-2 rounded-md bg-blue-500 px-5 text-base font-semibold text-white shadow-sm transition-colors duration-fast ease-standard hover:bg-blue-600"
|
||||
>
|
||||
<Icon name="log-in" size={18} />
|
||||
{isPresenter ? 'Wejdź i rozpocznij' : 'Dołącz do webinaru'}
|
||||
</button>
|
||||
|
||||
<p className="mt-3 text-xs leading-relaxed text-slate-500">
|
||||
{isPresenter
|
||||
? 'Możesz wejść przed uczestnikami i sprawdzić sprzęt.'
|
||||
: 'Twój mikrofon i kamera są wyłączone. O głos możesz poprosić w trakcie.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { publicClient } from '../../api/publicClient'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Spinner } from '../../components/ui'
|
||||
import { useBanners } from '../../lib/useBanners'
|
||||
import { useSpeakRequest } from '../../lib/useSpeakRequest'
|
||||
import { BannerSlot } from './BannerSlot'
|
||||
|
||||
/*
|
||||
* Biblioteki wideo sa ladowane leniwie.
|
||||
*
|
||||
* `@livekit/components-react` i `@jitsi/react-sdk` wazyly razem tyle, co cala
|
||||
* reszta aplikacji: glowny pakiet urosl z 859 kB do 1552 kB. Sa potrzebne
|
||||
* **wylacznie na sali**, a panel administracyjny i strony publiczne otwiera
|
||||
* sie znacznie czesciej niz spotkanie. Bez tego podzialu kazde wejscie
|
||||
* na strone zapisu pobieralo by caly klient WebRTC.
|
||||
*
|
||||
* Ta sama regula co przy `@mui/x-charts` w statystykach.
|
||||
*/
|
||||
const LiveKitRoom = lazy(() =>
|
||||
import('./LiveKitRoom').then((m) => ({ default: m.LiveKitRoom })))
|
||||
const JitsiRoom = lazy(() =>
|
||||
import('./JitsiRoom').then((m) => ({ default: m.JitsiRoom })))
|
||||
const ZoomRoom = lazy(() =>
|
||||
import('./ZoomRoom').then((m) => ({ default: m.ZoomRoom })))
|
||||
import { InteractionPanel } from './InteractionPanel'
|
||||
import { MeetingActionBar } from './MeetingActionBar'
|
||||
import { MeetingLobby } from './MeetingLobby'
|
||||
import { PublicLayout } from './PublicLayout'
|
||||
|
||||
/**
|
||||
* Strona spotkania: jeden ekran, w ktorym wideo i wszystkie interakcje sa
|
||||
* razem.
|
||||
*
|
||||
* JEDNA TRASA DLA OBU ROL
|
||||
* ------------------------------------------------------------------------
|
||||
* Token w adresie moze nalezec do uczestnika albo do prelegenta, wiec
|
||||
* probujemy obu endpointow. Rozdzielanie tego na dwie trasy oznaczaloby, ze
|
||||
* organizator musi pamietac, ktory link komu wyslal, a pomylka konczylaby sie
|
||||
* bledem zamiast wejsciem do pokoju.
|
||||
*
|
||||
* DWA ETAPY: PRZEDPOKOJ I SALA
|
||||
* ------------------------------------------------------------------------
|
||||
* Najpierw ekran wejsciowy z potwierdzeniem tozsamosci, dopiero po kliknieciu
|
||||
* sala. Wejscie prosto do pokoju oznaczaloby, ze osoba z przekazanym linkiem
|
||||
* zaczyna wystepowac pod cudzym nazwiskiem, nie majac szansy tego zauwazyc.
|
||||
*
|
||||
* ODSWIEZENIE TOKENU PO PRZYZNANIU GLOSU
|
||||
* ------------------------------------------------------------------------
|
||||
* Mikrofon otwiera **token wydany u dostawcy**, a nie stan w przegladarce.
|
||||
* Po przyznaniu glosu pobieramy wiec token na nowo: bez tego uczestnik
|
||||
* widzialby napis "masz glos" i dalej mial zamkniety mikrofon.
|
||||
*/
|
||||
|
||||
type JoinView = {
|
||||
url: string
|
||||
token: string | null
|
||||
embedded: boolean
|
||||
role: string
|
||||
webinarTitle: string
|
||||
displayName: string
|
||||
opensAt: string
|
||||
closesAt: string
|
||||
startsAt: string | null
|
||||
timezone: string
|
||||
/** Nazwa dostawcy: front wie, ktory komponent osadzic. */
|
||||
provider: string
|
||||
roomId: string | null
|
||||
/**
|
||||
* Uprawnienia publikacji.
|
||||
*
|
||||
* Przy LiveKit powtarzaja to, czego i tak pilnuje serwer na podstawie tokenu.
|
||||
* Przy Jitsi sa jedynym, co odroznia role, bo serwer ich nie zna.
|
||||
*/
|
||||
canPublishVideo: boolean
|
||||
canPublishAudio: boolean
|
||||
/** Publiczny klucz klienta do osadzenia (Zoom SDK Key). Pusty dla innych. */
|
||||
clientKey: string | null
|
||||
}
|
||||
|
||||
type ClosedInfo = {
|
||||
message: string
|
||||
opensAt: string
|
||||
closesAt: string
|
||||
beforeStart: boolean
|
||||
}
|
||||
|
||||
/** Kolejnosc prob: uczestnikow jest wielokrotnie wiecej niz prelegentow. */
|
||||
const JOIN_PATHS = ['/public/registrations', '/public/speakers'] as const
|
||||
|
||||
export function MeetingPage() {
|
||||
const { token } = useParams<{ token: string }>()
|
||||
const [join, setJoin] = useState<JoinView | null>(null)
|
||||
const [closed, setClosed] = useState<ClosedInfo | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
/** Czy uczestnik przeszedl juz przez ekran wejsciowy. */
|
||||
const [entered, setEntered] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setError(null)
|
||||
setClosed(null)
|
||||
|
||||
for (const base of JOIN_PATHS) {
|
||||
try {
|
||||
const response = await publicClient.get<JoinView>(`${base}/${token}/join`)
|
||||
setJoin(response.data)
|
||||
setLoading(false)
|
||||
return
|
||||
} catch (e) {
|
||||
const status = (e as { response?: { status?: number; data?: ClosedInfo & { message?: string } } })
|
||||
.response
|
||||
// 409 oznacza, ze token jest poprawny, ale pokoj jeszcze zamkniety
|
||||
if (status?.status === 409 && status.data) {
|
||||
setClosed(status.data as ClosedInfo)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
// 404 przy uczestniku moze znaczyc, ze to token prelegenta: probujemy dalej
|
||||
if (status?.status !== 404) {
|
||||
setError(status?.data?.message ?? 'Nie udało się otworzyć pokoju.')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setError('Nie znaleziono takiego linku. Sprawdź, czy adres jest kompletny.')
|
||||
setLoading(false)
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<Spinner />
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (closed) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<RoomClosed info={closed} />
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<InvalidToken message={error} />
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (!join) return null
|
||||
|
||||
if (!entered) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<MeetingLobby
|
||||
webinarTitle={join.webinarTitle}
|
||||
displayName={join.displayName}
|
||||
role={join.role}
|
||||
startsAt={join.startsAt}
|
||||
timezone={join.timezone}
|
||||
onEnter={() => setEntered(true)}
|
||||
/>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return <MeetingRoom join={join} token={token ?? ''} onRefreshTicket={load} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Sala: wideo i panel interakcji na jednym ekranie.
|
||||
*
|
||||
* Na szerokim ekranie wideo po lewej, panel po prawej. Na waskim wideo u gory,
|
||||
* panel pod nim: panel obok wideo na telefonie zostawialby obrazowi pasek
|
||||
* szerokosci kilku centymetrow, na ktorym nic nie widac.
|
||||
*
|
||||
* W trybie NONE **nie przekierowujemy**. Obraz jest w zewnetrznym narzedziu,
|
||||
* ale czat, pytania, zadania i zapytania ofertowe zostaja tutaj. Wyrzucenie
|
||||
* uczestnika ze strony odcieloby go od nich calkowicie, a wielu klientow
|
||||
* zostaje na tym trybie na stale.
|
||||
*/
|
||||
function MeetingRoom({
|
||||
join,
|
||||
token,
|
||||
onRefreshTicket,
|
||||
}: {
|
||||
join: JoinView
|
||||
token: string
|
||||
onRefreshTicket: () => Promise<void>
|
||||
}) {
|
||||
const isSpeaker = join.role === 'PRESENTER'
|
||||
const speak = useSpeakRequest(isSpeaker ? undefined : token)
|
||||
// gdy prowadzacy zablokuje uczestnika, opuszcza on sale natychmiast
|
||||
const [banned, setBanned] = useState(false)
|
||||
/*
|
||||
* Tryb otwartego mikrofonu. Stan poczatkowy przychodzi z historii interakcji,
|
||||
* a zmiany w trakcie spotkania zdarzeniem z WebSocketu.
|
||||
*/
|
||||
const [openMic, setOpenMic] = useState(false)
|
||||
/*
|
||||
* Tryb "wideo dla wszystkich". Jak otwarty mikrofon, ale dla obrazu: gdy
|
||||
* prowadzacy go wlaczy, uczestnik pobiera token na nowo i dostaje kamere.
|
||||
*/
|
||||
const [openVideo, setOpenVideo] = useState(false)
|
||||
|
||||
/*
|
||||
* Banery. Prelegent ich nie potrzebuje: reklama jest kierowana do
|
||||
* uczestnikow, a prowadzacemu zabieralaby miejsce na obraz.
|
||||
*/
|
||||
const bannerState = useBanners(isSpeaker ? undefined : token)
|
||||
/** Temat oferty wybrany klikiem w baner, przekazywany do paska akcji. */
|
||||
const [offerTopicFromBanner, setOfferTopicFromBanner] = useState<string | null>(null)
|
||||
|
||||
/*
|
||||
* Przyznanie albo odebranie glosu zmienia uprawnienia w tokenie dostawcy,
|
||||
* wiec ticket trzeba pobrac na nowo. Bez tego napis "masz glos" pojawia sie,
|
||||
* a mikrofon zostaje zamkniety, bo iframe dziala na starym tokenie.
|
||||
*/
|
||||
const [voiceState, setVoiceState] = useState<'none' | 'granted' | 'video'>('none')
|
||||
useEffect(() => {
|
||||
// otwarty mikrofon i wideo dla wszystkich zmieniaja uprawnienia w tokenie
|
||||
// tak samo jak przyznany glos, wiec ticket trzeba pobrac na nowo. Wideo
|
||||
// to inny poziom niz sam glos, wiec jest osobnym stanem, zeby przelaczenie
|
||||
// miedzy nimi tez wymuszalo odswiezenie.
|
||||
const next: 'none' | 'granted' | 'video' = openVideo
|
||||
? 'video'
|
||||
: speak.granted || openMic
|
||||
? 'granted'
|
||||
: 'none'
|
||||
if (next === voiceState) return
|
||||
setVoiceState(next)
|
||||
if (join.embedded) void onRefreshTicket()
|
||||
}, [speak.granted, openMic, openVideo, voiceState, join.embedded, onRefreshTicket])
|
||||
|
||||
const src = useMemo(() => {
|
||||
if (!join.embedded) return null
|
||||
const url = new URL(join.url)
|
||||
if (join.token) url.searchParams.set('t', join.token)
|
||||
return url.toString()
|
||||
}, [join])
|
||||
|
||||
// uczestnik zablokowany przez prowadzacego opuszcza sale: pokazujemy komunikat
|
||||
// zamiast obrazu i panelu, a przy probie powrotu backend i tak go nie wpusci
|
||||
if (banned) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className="space-y-4 text-center">
|
||||
<Icon name="user-off" size={40} className="mx-auto text-alarm-500" />
|
||||
<h1 className="text-xl font-semibold text-slate-900">Dostęp do spotkania zablokowany</h1>
|
||||
<p className="text-sm text-slate-600">
|
||||
Prowadzący zablokował Ci dostęp do tego webinaru. Jeśli uważasz, że to pomyłka,
|
||||
skontaktuj się z organizatorem.
|
||||
</p>
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[100dvh] flex-col bg-navy-900">
|
||||
<header className="flex items-center justify-between gap-3 px-4 py-2 text-white">
|
||||
<p className="min-w-0 truncate text-sm font-medium">{join.webinarTitle}</p>
|
||||
<p className="hx-data shrink-0 text-xs text-white/70">
|
||||
{join.displayName}
|
||||
{isSpeaker && ' (prowadzący)'}
|
||||
{!isSpeaker && (speak.granted || openMic) && ' (głos)'}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
{/*
|
||||
Kazdy dostawca ma wlasny sposob osadzenia, wiec wybor jest tutaj,
|
||||
a nie w jednym uniwersalnym iframe. Panel interakcji, pasek akcji
|
||||
i banery zostaja nasze niezaleznie od tego wyboru.
|
||||
*/}
|
||||
<VideoArea join={join} src={src} />
|
||||
|
||||
{/*
|
||||
OVERLAY: warstwa w rogu obrazu. Zamkniety baner nie wraca do konca
|
||||
sesji, bo baner nie do usuniecia w trakcie prezentacji jest gorszy
|
||||
niz brak banera.
|
||||
*/}
|
||||
<BannerSlot
|
||||
placement="OVERLAY"
|
||||
banners={bannerState.forPlacement('OVERLAY')}
|
||||
rotationSeconds={bannerState.rotationSeconds}
|
||||
onImpression={bannerState.countImpression}
|
||||
onClick={bannerState.countClick}
|
||||
onDismiss={bannerState.dismiss}
|
||||
onOfferTopic={setOfferTopicFromBanner}
|
||||
className="absolute bottom-3 right-3 z-10 w-56 max-w-[45%]"
|
||||
/>
|
||||
|
||||
{/* BELOW_VIDEO: pas pod obrazem, na calej jego szerokosci */}
|
||||
<BannerSlot
|
||||
placement="BELOW_VIDEO"
|
||||
banners={bannerState.forPlacement('BELOW_VIDEO')}
|
||||
rotationSeconds={bannerState.rotationSeconds}
|
||||
onImpression={bannerState.countImpression}
|
||||
onClick={bannerState.countClick}
|
||||
onOfferTopic={setOfferTopicFromBanner}
|
||||
className="mx-3 mb-3 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside className="flex min-h-0 flex-1 flex-col border-t border-slate-200 bg-white lg:w-96 lg:flex-none lg:border-l lg:border-t-0">
|
||||
{/*
|
||||
Pasek akcji nad panelem, wiec widoczny niezaleznie od wybranej
|
||||
zakladki: prosba o glos schowana w zakladce jest bezuzyteczna
|
||||
dokladnie wtedy, gdy jest potrzebna.
|
||||
*/}
|
||||
{/*
|
||||
SIDE: nad zakladkami, czyli w miejscu, ktore uczestnik widzi
|
||||
niezaleznie od tego, ktora zakladke ma otwarta.
|
||||
*/}
|
||||
<BannerSlot
|
||||
placement="SIDE"
|
||||
banners={bannerState.forPlacement('SIDE')}
|
||||
rotationSeconds={bannerState.rotationSeconds}
|
||||
onImpression={bannerState.countImpression}
|
||||
onClick={bannerState.countClick}
|
||||
onOfferTopic={setOfferTopicFromBanner}
|
||||
className="m-3 shrink-0"
|
||||
/>
|
||||
|
||||
<MeetingActionBar
|
||||
token={token}
|
||||
speak={speak}
|
||||
isSpeaker={isSpeaker}
|
||||
openMic={openMic}
|
||||
offerTopicFromBanner={offerTopicFromBanner}
|
||||
onOfferHandled={() => setOfferTopicFromBanner(null)}
|
||||
/>
|
||||
|
||||
{speak.error && (
|
||||
<p className="border-b border-alarm-500/20 bg-alarm-50 px-3 py-2 text-xs text-alarm-600">
|
||||
{speak.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<InteractionPanel
|
||||
token={token}
|
||||
onSpeakEvent={speak.apply}
|
||||
onOpenMicChange={setOpenMic}
|
||||
onOpenVideoChange={setOpenVideo}
|
||||
onModeration={(event) => {
|
||||
if (event.kind === 'BANNED') setBanned(true)
|
||||
if (event.kind === 'UNBANNED') setBanned(false)
|
||||
}}
|
||||
onHistoryLoaded={(history) => {
|
||||
setOpenMic(history.openMic)
|
||||
setOpenVideo(history.openVideo)
|
||||
}}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Obszar wideo: wlasciwy komponent dla wybranego dostawcy.
|
||||
*
|
||||
* LiveKit i Jitsi maja wlasne biblioteki i wlasny sposob osadzenia, wiec nie
|
||||
* da sie ich sprowadzic do wspolnego iframe. Daily zostaje przy iframe, bo tak
|
||||
* dziala jego gotowy komponent.
|
||||
*/
|
||||
function VideoArea({ join, src }: { join: JoinView; src: string | null }) {
|
||||
// tryb NONE: obraz jest w zewnetrznym narzedziu, panel zostaje u nas
|
||||
if (!join.embedded) {
|
||||
return <ExternalVideo url={join.url} />
|
||||
}
|
||||
|
||||
if (join.provider === 'LIVEKIT' && join.token) {
|
||||
return (
|
||||
<div className="min-h-[38vh] flex-1 lg:min-h-0">
|
||||
<Suspense fallback={<VideoLoading />}>
|
||||
{/*
|
||||
key={join.token}: po przyznaniu albo odebraniu glosu backend wydaje
|
||||
NOWY token z innym uprawnieniem do mikrofonu, a `load()` go pobiera.
|
||||
LiveKitRoomProvider laczy sie z tokenem podanym przy montowaniu i nie
|
||||
renegocjuje uprawnien na zywo - wiec zmiana samego propa `token` nie
|
||||
otwiera mikrofonu, dopoki uczestnik nie przeladuje strony. Zmiana
|
||||
`key` wymusza przemontowanie providera z nowym tokenem: to samo, co
|
||||
robi przeladowanie, ale bez wychodzenia ze spotkania.
|
||||
*/}
|
||||
<LiveKitRoom key={join.token} serverUrl={join.url} token={join.token} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (join.provider === 'JITSI' && join.roomId) {
|
||||
/*
|
||||
* Adres pokoju Jitsi to `https://domena/pokoj`, wiec domene wyciagamy
|
||||
* z adresu, zamiast dopisywac kolejne pole do odpowiedzi.
|
||||
*/
|
||||
const domain = join.url.replace(/^https?:\/\//, '').split('/')[0]
|
||||
return (
|
||||
<div className="min-h-[38vh] flex-1 lg:min-h-0">
|
||||
<Suspense fallback={<VideoLoading />}>
|
||||
<JitsiRoom
|
||||
domain={domain}
|
||||
roomName={join.roomId}
|
||||
displayName={join.displayName}
|
||||
jwt={join.token}
|
||||
canPublishVideo={join.canPublishVideo}
|
||||
canPublishAudio={join.canPublishAudio}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (join.provider === 'ZOOM' && join.roomId && join.token && join.clientKey) {
|
||||
return (
|
||||
<div className="min-h-[38vh] flex-1 lg:min-h-0">
|
||||
<Suspense fallback={<VideoLoading />}>
|
||||
<ZoomRoom
|
||||
meetingNumber={join.roomId}
|
||||
signature={join.token}
|
||||
sdkKey={join.clientKey}
|
||||
displayName={join.displayName}
|
||||
isHost={join.role === 'PRESENTER'}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Daily i pozostali: gotowy komponent dostawcy w iframe
|
||||
return src ? (
|
||||
<iframe
|
||||
title="Pokój spotkania"
|
||||
src={src}
|
||||
className="min-h-[38vh] flex-1 border-0 lg:min-h-0"
|
||||
// uprawnienia przegladarki; wlasciwe uprawnienia publikacji i tak
|
||||
// wynikaja z tokenu wydanego przez backend
|
||||
allow="camera; microphone; fullscreen; speaker; display-capture; autoplay"
|
||||
/>
|
||||
) : (
|
||||
<ExternalVideo url={join.url} />
|
||||
)
|
||||
}
|
||||
|
||||
/** Zaslona na czas pobierania biblioteki wideo. */
|
||||
function VideoLoading() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-white/60">
|
||||
Ładowanie sali...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tryb bez osadzonego wideo.
|
||||
*
|
||||
* Obraz idzie zewnetrznym narzedziem, ale panel interakcji zostaje u nas,
|
||||
* wiec pokazujemy wyrazny przycisk otwierajacy spotkanie w nowej karcie
|
||||
* zamiast pustego miejsca.
|
||||
*/
|
||||
function ExternalVideo({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="flex min-h-[38vh] flex-1 flex-col items-center justify-center gap-3 p-6 text-center lg:min-h-0">
|
||||
<Icon name="video" size={32} className="text-white/60" />
|
||||
<p className="text-sm text-white/70">
|
||||
Obraz i dźwięk tego webinaru są w zewnętrznym narzędziu.
|
||||
</p>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex min-h-[48px] items-center gap-2 rounded-md bg-blue-500 px-5 text-sm font-semibold text-white transition-colors duration-fast ease-standard hover:bg-blue-600"
|
||||
>
|
||||
<Icon name="video" size={16} />
|
||||
Otwórz spotkanie w nowej karcie
|
||||
</a>
|
||||
<p className="text-xs text-white/50">
|
||||
Czat, pytania i zgłoszenia zostają na tej stronie. Wróć tutaj, żeby z nich korzystać.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Token nieznany albo uszkodzony: kierujemy tam, gdzie mozna to naprawic. */
|
||||
function InvalidToken({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-alarm-500/20 bg-alarm-50 p-6 text-center">
|
||||
<Icon name="circle-alert" size={28} className="mx-auto text-alarm-600" />
|
||||
<p className="mt-3 text-sm text-alarm-600">{message}</p>
|
||||
<p className="mt-3 text-sm text-slate-600">
|
||||
Link mógł zostać skrócony przez program pocztowy. Skopiuj go z wiadomości w całości
|
||||
albo wróć na{' '}
|
||||
<a href="/" className="font-medium text-blue-700 underline underline-offset-2">
|
||||
stronę główną
|
||||
</a>
|
||||
, żeby zapisać się ponownie.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Pokoj jeszcze zamkniety: komunikat wraz z odliczaniem do otwarcia. */
|
||||
function RoomClosed({ info }: { info: ClosedInfo }) {
|
||||
const [left, setLeft] = useState(() => remaining(info.opensAt))
|
||||
|
||||
useEffect(() => {
|
||||
if (!info.beforeStart) return
|
||||
const timer = window.setInterval(() => setLeft(remaining(info.opensAt)), 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [info])
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-6 text-center">
|
||||
<Icon name="clock" size={28} className="mx-auto text-blue-500" />
|
||||
<p className="mt-3 text-lg font-semibold text-slate-900">{info.message}</p>
|
||||
|
||||
{info.beforeStart ? (
|
||||
<>
|
||||
<p className="mt-1 text-sm text-slate-500">Pokój otworzy się za:</p>
|
||||
<p className="hx-data mt-2 text-2xl font-semibold text-slate-900">
|
||||
{left ?? 'już za chwilę'}
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-slate-400">
|
||||
Strona odświeży się sama, gdy pokój będzie gotowy.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Jeśli webinar był nagrywany, organizator udostępni nagranie osobno.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Ile zostalo do podanego momentu, w formie "2 godz. 15 min" albo "0:42". */
|
||||
function remaining(iso: string): string | null {
|
||||
const ms = new Date(iso).getTime() - Date.now()
|
||||
if (ms <= 0) {
|
||||
// pokoj wlasnie sie otworzyl: przeladowanie wchodzi do srodka
|
||||
window.location.reload()
|
||||
return null
|
||||
}
|
||||
|
||||
const total = Math.floor(ms / 1000)
|
||||
const days = Math.floor(total / 86400)
|
||||
const hours = Math.floor((total % 86400) / 3600)
|
||||
const minutes = Math.floor((total % 3600) / 60)
|
||||
const seconds = total % 60
|
||||
|
||||
if (days > 0) return `${days} dni ${hours} godz.`
|
||||
if (hours > 0) return `${hours} godz. ${minutes} min`
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { isNotFound, publicClient, publicErrorMessage } from '../../api/publicClient'
|
||||
import type { MyRegistration } from '../../api/types'
|
||||
import { formatDateOnly, formatTimeOnly } from '../../lib/dates'
|
||||
import { useDocumentMeta } from '../../lib/useDocumentMeta'
|
||||
import { PublicLayout, PublicNotice } from './PublicLayout'
|
||||
import { Icon } from '../../components/Icon'
|
||||
|
||||
export function MyRegistrationPage() {
|
||||
const { token } = useParams<{ token: string }>()
|
||||
const queryClient = useQueryClient()
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['my-registration', token],
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const response = await publicClient.get<MyRegistration>(`/public/registrations/${token}`)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
const registration = query.data
|
||||
|
||||
useDocumentMeta({
|
||||
title: registration ? `Twój zapis - ${registration.webinarTitle}` : 'Twój zapis',
|
||||
})
|
||||
|
||||
const cancel = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await publicClient.post<MyRegistration>(
|
||||
`/public/registrations/${token}/cancel`,
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['my-registration', token], data)
|
||||
setConfirmOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
/** Pobranie ICS przez fetch, zeby moc pokazac blad zamiast pustej karty. */
|
||||
async function downloadIcs() {
|
||||
setDownloadError(null)
|
||||
try {
|
||||
const response = await publicClient.get(`/public/registrations/${token}/invite.ics`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
const url = URL.createObjectURL(response.data as Blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'invite.ics'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
setDownloadError(publicErrorMessage(error, 'Nie udało się pobrać pliku kalendarza'))
|
||||
}
|
||||
}
|
||||
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<p className="flex items-center justify-center gap-2 py-16 text-sm text-slate-500">
|
||||
<Icon name="loader" className="animate-spin" />
|
||||
Wczytywanie...
|
||||
</p>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.isError && isNotFound(query.error)) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<PublicNotice
|
||||
icon="search"
|
||||
title="Nie znaleziono zapisu"
|
||||
text="Ten link jest nieprawidłowy albo stracił ważność. Sprawdź adres w wiadomości e-mail."
|
||||
/>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.isError || !registration) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<PublicNotice
|
||||
icon="circle-alert"
|
||||
title="Nie udało się wczytać zapisu"
|
||||
text={publicErrorMessage(query.error, 'Spróbuj odświeżyć stronę za chwilę.')}
|
||||
/>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const isCancelled = registration.status === 'CANCELLED'
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className="space-y-5">
|
||||
{isCancelled ? (
|
||||
<PublicNotice
|
||||
icon="x-circle"
|
||||
title="Zrezygnowałeś z udziału"
|
||||
text="Twoje miejsce zostało zwolnione. Jeśli zmienisz zdanie, możesz zapisać się ponownie, o ile są jeszcze wolne miejsca."
|
||||
>
|
||||
<Link
|
||||
to={`/w/${registration.webinarSlug}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-blue-600"
|
||||
>
|
||||
<Icon name="user-plus" size={18} />
|
||||
Zapisz się ponownie
|
||||
</Link>
|
||||
</PublicNotice>
|
||||
) : (
|
||||
<section className="rounded-lg border border-ok-500/20 bg-ok-50 p-5 text-center sm:p-6">
|
||||
<Icon name="check-circle" className="text-3xl text-ok-500" />
|
||||
<h1 className="mt-1 text-lg font-semibold text-slate-900">Jesteś zapisany</h1>
|
||||
<p className="mt-1 text-sm text-ok-600">
|
||||
Twoje zgłoszenie zostało potwierdzone.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900">{registration.webinarTitle}</h2>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
{formatDateOnly(registration.startsAt, registration.timezone)}, godz.{' '}
|
||||
{formatTimeOnly(registration.startsAt, registration.timezone)}{' '}
|
||||
<span className="text-slate-400">(strefa {registration.timezone})</span>
|
||||
</p>
|
||||
|
||||
<dl className="mt-5 space-y-3 border-t border-slate-100 pt-5 text-sm">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-slate-500">Imię i nazwisko</dt>
|
||||
<dd className="text-right font-medium text-slate-900">
|
||||
{registration.firstName} {registration.lastName}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-slate-500">Adres e-mail</dt>
|
||||
<dd className="min-w-0 break-all text-right font-medium text-slate-900">
|
||||
{registration.email}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt className="text-slate-500">Status</dt>
|
||||
<dd className="text-right">
|
||||
{isCancelled ? (
|
||||
<span className="inline-flex rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600 ring-1 ring-inset ring-slate-200">
|
||||
Rezygnacja
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex rounded-full bg-ok-50 px-2 py-0.5 text-xs font-medium text-ok-600 ring-1 ring-inset ring-ok-500/20">
|
||||
Potwierdzony
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{!isCancelled && registration.joinUrl && (
|
||||
<a
|
||||
href={registration.joinUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-5 flex w-full items-center justify-center gap-2 rounded-lg bg-blue-500 px-4 py-3.5 text-base font-semibold text-white transition hover:bg-blue-600"
|
||||
>
|
||||
<Icon name="video" size={20} />
|
||||
Dołącz do spotkania
|
||||
</a>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/*
|
||||
Nagranie i materialy po zakonczeniu webinaru.
|
||||
Backend oddaje je wylacznie przy zapisie potwierdzonym i dopiero po
|
||||
`endsAt`, wiec tutaj wystarczy sprawdzic, czy cokolwiek przyszlo.
|
||||
*/}
|
||||
{(registration.recordings.length > 0 ||
|
||||
registration.materials.length > 0 ||
|
||||
registration.certificate !== null) && (
|
||||
<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Po webinarze</h2>
|
||||
|
||||
{registration.recordings.map((recording) => (
|
||||
<div key={recording.id} className="space-y-2">
|
||||
<p className="text-sm font-medium text-slate-700">{recording.title}</p>
|
||||
{/*
|
||||
Zwykly element video z controls, bez dodatkowych bibliotek.
|
||||
Backend obsluguje naglowek Range, wiec przewijanie dziala.
|
||||
preload="metadata" pobiera sam poczatek pliku: bez tego
|
||||
przegladarka zaczynalaby sciagac cale nagranie od razu.
|
||||
*/}
|
||||
<video
|
||||
className="w-full rounded-lg bg-slate-900"
|
||||
controls
|
||||
preload="metadata"
|
||||
src={recording.url}
|
||||
>
|
||||
Twoja przeglądarka nie odtwarza wideo.{' '}
|
||||
<a href={recording.url} className="underline">
|
||||
Pobierz nagranie
|
||||
</a>
|
||||
</video>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/*
|
||||
Certyfikat: osobny, wyrozniony przycisk. To dokument, a nie
|
||||
kolejny plik do pobrania, wiec nie miesza sie z materialami.
|
||||
*/}
|
||||
{registration.certificate && (
|
||||
<div className="rounded-lg border border-ok-500/30 bg-ok-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon name="check-circle" size={22} className="shrink-0 text-ok-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-slate-900">Certyfikat uczestnictwa</p>
|
||||
<p className="hx-data mt-0.5 text-xs text-slate-600">
|
||||
Numer {registration.certificate.number}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={registration.certificate.url}
|
||||
className="mt-3 flex min-h-[48px] w-full items-center justify-center gap-2 rounded-lg bg-ok-600 px-4 text-base font-semibold text-white transition hover:bg-ok-600/90"
|
||||
>
|
||||
<Icon name="download" size={20} />
|
||||
Pobierz certyfikat
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{registration.materials.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Materiały do pobrania</h3>
|
||||
<ul className="space-y-2">
|
||||
{registration.materials.map((material) => (
|
||||
<li key={material.id}>
|
||||
<a
|
||||
href={material.url}
|
||||
className="flex min-h-[48px] items-center gap-3 rounded-lg border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="download" size={20} className="shrink-0 text-blue-600" />
|
||||
<span className="min-w-0 flex-1 break-words">{material.title}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!isCancelled && (
|
||||
<section className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadIcs}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-3.5 text-base font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="calendar-plus" size={20} />
|
||||
Dodaj do kalendarza
|
||||
</button>
|
||||
|
||||
{downloadError && (
|
||||
<p className="rounded-lg border border-alarm-500/20 bg-alarm-50 p-3 text-sm text-alarm-600">
|
||||
{downloadError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
className="w-full rounded-lg px-4 py-3 text-sm font-medium text-alarm-600 transition hover:bg-alarm-50"
|
||||
>
|
||||
Zrezygnuj z udziału
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{confirmOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-title"
|
||||
/*
|
||||
Naklatka i ramka jak w oknach panelu (`Modal`): to samo
|
||||
przycienienie, to samo zaokraglenie i odstep od brzegu ekranu
|
||||
na kazdym rozmiarze. Wlasny znacznik, a nie komponent `Modal`,
|
||||
bo strona publiczna nie ma naglowka z przyciskiem zamkniecia:
|
||||
decyzje podejmuje sie tu dwoma przyciskami, zeby rezygnacja nie
|
||||
byla mozliwa przez przypadkowe zamkniecie okna.
|
||||
*/
|
||||
className="fixed inset-0 z-50 flex items-end justify-center overflow-y-auto bg-navy-900/40 p-4 sm:items-center sm:p-6"
|
||||
onClick={() => setConfirmOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-lg border border-slate-100 bg-white p-4 shadow-xl sm:p-5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 id="confirm-title" className="text-lg font-semibold text-slate-900">
|
||||
Zrezygnować z udziału?
|
||||
</h3>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-slate-600">
|
||||
Twoje miejsce zostanie zwolnione i może je zająć ktoś inny. Ponowny zapis będzie
|
||||
możliwy tylko wtedy, gdy będą wolne miejsca.
|
||||
</p>
|
||||
|
||||
{cancel.isError && (
|
||||
<p className="mt-3 rounded-lg border border-alarm-500/20 bg-alarm-50 p-3 text-sm text-alarm-600">
|
||||
{publicErrorMessage(cancel.error, 'Nie udało się zrezygnować')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-col gap-2 sm:flex-row-reverse">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cancel.mutate()}
|
||||
disabled={cancel.isPending}
|
||||
className="flex-1 rounded-lg bg-alarm-500 px-4 py-3 text-sm font-semibold text-white transition hover:bg-alarm-600 disabled:bg-alarm-50"
|
||||
>
|
||||
{cancel.isPending ? 'Rezygnuję...' : 'Tak, rezygnuję'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmOpen(false)}
|
||||
className="flex-1 rounded-lg border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
Zostaw zapis
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { publicClient, publicErrorMessage } from '../../api/publicClient'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Button, Input, Select, Spinner, Textarea } from '../../components/ui'
|
||||
|
||||
/**
|
||||
* Zapytanie ofertowe.
|
||||
*
|
||||
* Dane kontaktowe sa podstawione z zapisu i pokazane **do potwierdzenia**:
|
||||
* uczestnik widzi, co dokladnie trafi do handlowca, i moze poprawic telefon.
|
||||
*
|
||||
* Zgoda na kontakt handlowy jest warunkiem wyslania. Nie jest zaznaczona
|
||||
* domyslnie: zaznaczenie zgody za uzytkownika jest niedopuszczalne.
|
||||
*
|
||||
* TRYB UPROSZCZONY (`callbackOnly`)
|
||||
* ------------------------------------------------------------------------
|
||||
* "Prosze o kontakt" to ta sama sciezka, ale bez pola tresci: uczestnik
|
||||
* w trakcie webinaru czesto nie ma czasu ani pomyslu, co napisac, a wymuszanie
|
||||
* tresci konczy sie tym, ze nie wysyla nic. Tresc jest wtedy podstawiana
|
||||
* zdaniem mowiacym wprost, o co chodzi, zeby handlowiec wiedzial, ze to prosba
|
||||
* o telefon, a nie puste zapytanie.
|
||||
*
|
||||
* **Zgoda jest wymagana tak samo w obu trybach.** Uproszczenie dotyczy tego,
|
||||
* ile uczestnik pisze, a nie tego, na co sie zgadza.
|
||||
*/
|
||||
|
||||
type OfferForm = {
|
||||
topics: string[]
|
||||
consentText: string
|
||||
consentVersion: string
|
||||
fullName: string
|
||||
email: string
|
||||
phone: string | null
|
||||
company: string | null
|
||||
}
|
||||
|
||||
/** Tresc podstawiana przy prosbie o kontakt, gdy uczestnik nic nie pisze. */
|
||||
const CALLBACK_CONTENT = 'Proszę o kontakt telefoniczny w sprawie oferty.'
|
||||
|
||||
export function OfferTab({
|
||||
token,
|
||||
callbackOnly = false,
|
||||
presetTopic = null,
|
||||
sourceBannerId = null,
|
||||
onSent,
|
||||
}: {
|
||||
token: string
|
||||
/** Wersja uproszczona: bez pola tresci i bez wyboru tematu. */
|
||||
callbackOnly?: boolean
|
||||
/**
|
||||
* Temat wybrany z gory, gdy formularz otworzyl baner.
|
||||
*
|
||||
* Uczestnik moze go zmienic: baner mowi, czego zapytanie **prawdopodobnie**
|
||||
* dotyczy, ale to uczestnik wie, o co chce zapytac.
|
||||
*/
|
||||
presetTopic?: string | null
|
||||
/** Baner, z ktorego przyszlo zapytanie. Sluzy wylacznie statystyce. */
|
||||
sourceBannerId?: number | null
|
||||
/** Wolane po udanej wysylce, zeby pasek akcji mogl zmienic stan przycisku. */
|
||||
onSent?: () => void
|
||||
}) {
|
||||
const [form, setForm] = useState<OfferForm | null>(null)
|
||||
const [content, setContent] = useState('')
|
||||
const [topic, setTopic] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [consent, setConsent] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [sent, setSent] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
publicClient
|
||||
.get<OfferForm>(`/public/participation/${token}/offer-form`)
|
||||
.then((response) => {
|
||||
setForm(response.data)
|
||||
setPhone(response.data.phone ?? '')
|
||||
// temat z banera podstawiamy tylko wtedy, gdy taki temat istnieje
|
||||
// w tym webinarze: lista tematow mogla sie zmienic po utworzeniu banera
|
||||
if (presetTopic && response.data.topics.includes(presetTopic)) {
|
||||
setTopic(presetTopic)
|
||||
}
|
||||
})
|
||||
.catch(() => setError('Nie udało się wczytać formularza.'))
|
||||
}, [token])
|
||||
|
||||
async function submit() {
|
||||
setSending(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await publicClient.post<{ confirmation: string }>(
|
||||
`/public/participation/${token}/offer`,
|
||||
{
|
||||
// w trybie uproszczonym uczestnik nic nie pisze, wiec tresc mowi
|
||||
// wprost, o co prosi; handlowiec nie dostaje pustego zapytania
|
||||
content: callbackOnly && !content.trim() ? CALLBACK_CONTENT : content,
|
||||
topic: topic || null,
|
||||
phone: phone || null,
|
||||
consentGranted: consent,
|
||||
sourceBannerId,
|
||||
},
|
||||
)
|
||||
setSent(response.data.confirmation)
|
||||
onSent?.()
|
||||
} catch (e) {
|
||||
setError(publicErrorMessage(e, 'Nie udało się wysłać zapytania.'))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!form) return <Spinner />
|
||||
|
||||
if (sent) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="rounded-md border border-ok-500/30 bg-ok-50 p-4 text-center">
|
||||
<Icon name="check-circle" size={24} className="mx-auto text-ok-600" />
|
||||
<p className="mt-2 text-sm text-slate-700">{sent}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-3 w-full"
|
||||
onClick={() => {
|
||||
setSent(null)
|
||||
setContent('')
|
||||
setConsent(false)
|
||||
}}
|
||||
>
|
||||
Wyślij kolejne zapytanie
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
|
||||
{error && (
|
||||
<p className="rounded-md border border-alarm-500/20 bg-alarm-50 p-2 text-xs text-alarm-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs leading-relaxed text-slate-500">
|
||||
{callbackOnly
|
||||
? 'Oddzwonimy pod podany numer po webinarze. Nie musisz nic pisać.'
|
||||
: 'Napisz, czego potrzebujesz. Skontaktujemy się z Tobą po webinarze.'}
|
||||
</p>
|
||||
|
||||
{!callbackOnly && form.topics.length > 0 && (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-semibold text-slate-700">
|
||||
Czego dotyczy zapytanie
|
||||
</span>
|
||||
<Select value={topic} onChange={(e) => setTopic(e.target.value)}>
|
||||
<option value="">Wybierz temat (opcjonalnie)</option>
|
||||
{form.topics.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{!callbackOnly && (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-semibold text-slate-700">Treść zapytania</span>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="np. Proszę o wycenę wdrożenia dla 20 użytkowników"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* dane z zapisu, pokazane do potwierdzenia */}
|
||||
<div className="rounded-md border border-slate-200 bg-slate-25 p-3">
|
||||
<p className="mb-2 text-xs font-semibold text-slate-700">Twoje dane kontaktowe</p>
|
||||
<p className="text-sm text-slate-700">{form.fullName}</p>
|
||||
<p className="hx-data break-all text-xs text-slate-500">{form.email}</p>
|
||||
{form.company && <p className="text-xs text-slate-500">{form.company}</p>}
|
||||
|
||||
<label className="mt-2 block">
|
||||
<span className="mb-1 block text-xs font-semibold text-slate-700">
|
||||
Telefon {form.phone ? '(możesz poprawić)' : '(opcjonalnie)'}
|
||||
</span>
|
||||
<Input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="np. 600100200"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded-md border border-slate-200 p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={consent}
|
||||
onChange={(e) => setConsent(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 accent-blue-500"
|
||||
/>
|
||||
<span className="text-xs leading-relaxed text-slate-600">
|
||||
<span className="font-medium text-slate-900">Wymagane: </span>
|
||||
{form.consentText}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
icon="send"
|
||||
className="w-full"
|
||||
onClick={submit}
|
||||
disabled={sending || (!callbackOnly && !content.trim()) || !consent}
|
||||
>
|
||||
{sending
|
||||
? 'Wysyłanie...'
|
||||
: callbackOnly
|
||||
? 'Poproś o kontakt'
|
||||
: 'Wyślij zapytanie'}
|
||||
</Button>
|
||||
|
||||
{!consent && (callbackOnly || content.trim()) && (
|
||||
<p className="text-xs text-warn-600">
|
||||
Bez zgody na kontakt handlowy nie możemy przyjąć zapytania.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { BrandMark } from '../../components/BrandMark'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import type { IconName } from '../../components/Icon'
|
||||
import { useInstanceFavicon } from '../../lib/usePublicConfig'
|
||||
import { useLegal } from '../../lib/useLegal'
|
||||
|
||||
/**
|
||||
* Rama strony publicznej.
|
||||
*
|
||||
* Domyslnie WASKA (`max-w-2xl`): wiekszosc stron publicznych to jeden formularz
|
||||
* albo krotki komunikat, wygodniejszy na telefonie w waskiej kolumnie.
|
||||
*
|
||||
* `wide` przelacza na szerokosc panelu (`max-w-container`, 1200px) - dla stron,
|
||||
* ktore maja miescic wiecej tresci obok siebie i wygladac jak reszta serwisu:
|
||||
* strona zapisu na webinar oraz dokumenty prawne (regulamin, polityka).
|
||||
* Szerokosc ustawia layout, a nie poszczegolna strona - zgodnie z regula, ze
|
||||
* strona nie ustawia wlasnej szerokosci.
|
||||
*/
|
||||
export function PublicLayout({ children, wide = false }: { children: ReactNode; wide?: boolean }) {
|
||||
useInstanceFavicon()
|
||||
const legal = useLegal()
|
||||
const company = legal.data?.company
|
||||
// regulamin i polityka to gotowe dokumenty - sa zawsze dostepne,
|
||||
// wiec odnosniki pokazujemy bezwarunkowo (o ile dane w ogole sie zaladowaly)
|
||||
const ready = Boolean(legal.data)
|
||||
|
||||
const widthClass = wide ? 'max-w-container' : 'max-w-2xl'
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-slate-50">
|
||||
<header className="border-b border-slate-100 bg-white">
|
||||
<div className={`mx-auto flex w-full ${widthClass} items-center px-4 py-4`}>
|
||||
<BrandMark />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={`mx-auto w-full ${widthClass} flex-1 px-4 py-6 sm:py-10`}>{children}</main>
|
||||
|
||||
{/*
|
||||
Stopka prawna: uczestnik webinaru MUSI mieć dostęp do regulaminu,
|
||||
polityki prywatności i tożsamości administratora danych (RODO).
|
||||
Puste dokumenty nie pokazują odnośnika - odnośnik donikąd jest gorszy
|
||||
niż jego brak.
|
||||
*/}
|
||||
<footer className={`mx-auto w-full ${widthClass} space-y-2 px-4 pb-8 pt-4 pb-safe`}>
|
||||
{ready && (
|
||||
<p className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs text-slate-500">
|
||||
<Link to="/regulamin" className="hover:text-slate-700 hover:underline">
|
||||
Regulamin
|
||||
</Link>
|
||||
<Link to="/polityka-prywatnosci" className="hover:text-slate-700 hover:underline">
|
||||
Polityka prywatności
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{company?.name && (
|
||||
<p className="text-center text-xs text-slate-400">
|
||||
{company.name}
|
||||
{company.address ? `, ${company.address}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Komunikat zajmujacy cala strone, na przyklad brak miejsc albo webinar odwolany. */
|
||||
export function PublicNotice({
|
||||
icon,
|
||||
title,
|
||||
text,
|
||||
tone = 'neutral',
|
||||
children,
|
||||
}: {
|
||||
icon: IconName
|
||||
title: string
|
||||
text: string
|
||||
tone?: 'neutral' | 'success' | 'warning'
|
||||
children?: ReactNode
|
||||
}) {
|
||||
const tones = {
|
||||
neutral: 'border-slate-100 bg-white',
|
||||
success: 'border-ok-500/20 bg-ok-50',
|
||||
warning: 'border-warn-500/25 bg-warn-50',
|
||||
}
|
||||
const iconTones = {
|
||||
neutral: 'text-slate-300',
|
||||
success: 'text-ok-500',
|
||||
warning: 'text-warn-500',
|
||||
}
|
||||
const textTones = {
|
||||
neutral: 'text-slate-600',
|
||||
success: 'text-ok-600',
|
||||
warning: 'text-warn-600',
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`rounded-lg border p-6 text-center shadow-sm ${tones[tone]}`}>
|
||||
<span className="mx-auto flex justify-center">
|
||||
<Icon name={icon} size={36} className={iconTones[tone]} />
|
||||
</span>
|
||||
<h2 className="mt-3 text-lg font-semibold text-slate-900">{title}</h2>
|
||||
<p className={`mx-auto mt-1.5 max-w-md text-sm leading-relaxed ${textTones[tone]}`}>{text}</p>
|
||||
{children && <div className="mt-5">{children}</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { isNotFound, publicClient, publicErrorMessage } from '../../api/publicClient'
|
||||
import type { PublicWebinar } from '../../api/types'
|
||||
import { formatDateOnly, formatDuration, formatTimeOnly } from '../../lib/dates'
|
||||
import { PUBLIC_CLOSED_MESSAGE } from '../../lib/labels'
|
||||
import { useDocumentMeta } from '../../lib/useDocumentMeta'
|
||||
import { PublicLayout, PublicNotice } from './PublicLayout'
|
||||
import { RegistrationForm } from './RegistrationForm'
|
||||
import { Icon } from '../../components/Icon'
|
||||
|
||||
export function PublicWebinarPage() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['public-webinar', slug],
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const response = await publicClient.get<PublicWebinar>(`/public/webinars/${slug}`)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
const webinar = query.data
|
||||
|
||||
useDocumentMeta({
|
||||
title: webinar ? `${webinar.title} - zapisy` : 'Zapisy na webinar',
|
||||
description: webinar?.description ?? undefined,
|
||||
})
|
||||
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<PublicLayout wide>
|
||||
<p className="flex items-center justify-center gap-2 py-16 text-sm text-slate-500">
|
||||
<Icon name="loader" className="animate-spin" />
|
||||
Wczytywanie...
|
||||
</p>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// slug nie istnieje albo webinar jest wersja robocza
|
||||
if (query.isError && isNotFound(query.error)) {
|
||||
return (
|
||||
<PublicLayout wide>
|
||||
<PublicNotice
|
||||
icon="search"
|
||||
title="Nie znaleziono webinaru"
|
||||
text="Ten adres jest nieprawidłowy albo wydarzenie nie jest już dostępne. Sprawdź link z wiadomości."
|
||||
/>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.isError || !webinar) {
|
||||
return (
|
||||
<PublicLayout wide>
|
||||
<PublicNotice
|
||||
icon="circle-alert"
|
||||
title="Nie udało się wczytać strony"
|
||||
text={publicErrorMessage(query.error, 'Spróbuj odświeżyć stronę za chwilę.')}
|
||||
/>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// ekran potwierdzenia. Nie ujawnia, czy adres byl juz zapisany.
|
||||
if (registeredEmail) {
|
||||
return (
|
||||
<PublicLayout wide>
|
||||
<PublicNotice
|
||||
tone="success"
|
||||
icon="mail-check"
|
||||
title="Dziękujemy za zgłoszenie"
|
||||
text={`Na adres ${registeredEmail} wysłaliśmy wiadomość z potwierdzeniem, linkiem do spotkania i zaproszeniem do kalendarza.`}
|
||||
>
|
||||
<div className="rounded-lg bg-white/70 p-4 text-left text-sm text-slate-600">
|
||||
<p className="font-medium text-slate-900">{webinar.title}</p>
|
||||
<p className="mt-1">
|
||||
{formatDateOnly(webinar.startsAt, webinar.timezone)}, godz.{' '}
|
||||
{formatTimeOnly(webinar.startsAt, webinar.timezone)} ({webinar.timezone})
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-slate-500">
|
||||
Jeśli wiadomość nie dotarła w ciągu kilku minut, sprawdź folder ze spamem.
|
||||
</p>
|
||||
</div>
|
||||
</PublicNotice>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const closed = !webinar.registrationOpen
|
||||
const notice = webinar.closedReason ? PUBLIC_CLOSED_MESSAGE[webinar.closedReason] : null
|
||||
|
||||
return (
|
||||
<PublicLayout wide>
|
||||
<div className="space-y-5">
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<h1 className="text-2xl font-bold leading-tight text-slate-900 sm:text-3xl">
|
||||
{webinar.title}
|
||||
</h1>
|
||||
|
||||
<dl className="mt-4 space-y-2.5 text-sm">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Icon name="clock" size={20} className="text-slate-400" />
|
||||
<div>
|
||||
<dt className="sr-only">Termin</dt>
|
||||
<dd className="font-medium text-slate-900">
|
||||
{formatDateOnly(webinar.startsAt, webinar.timezone)}
|
||||
</dd>
|
||||
<dd className="text-slate-600">
|
||||
godz. {formatTimeOnly(webinar.startsAt, webinar.timezone)}
|
||||
{' - '}
|
||||
{formatTimeOnly(webinar.endsAt, webinar.timezone)}{' '}
|
||||
<span className="text-slate-400">(strefa {webinar.timezone})</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Icon name="clock" size={20} className="text-slate-400" />
|
||||
<div>
|
||||
<dt className="sr-only">Czas trwania</dt>
|
||||
<dd className="text-slate-600">
|
||||
Czas trwania: {formatDuration(webinar.startsAt, webinar.endsAt)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{webinar.seatsLeft !== null && webinar.seatsLeft > 0 && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Icon name="users" size={20} className="text-slate-400" />
|
||||
<div>
|
||||
<dt className="sr-only">Wolne miejsca</dt>
|
||||
<dd className="text-slate-600">Wolnych miejsc: {webinar.seatsLeft}</dd>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{webinar.description && (
|
||||
<p className="mt-4 whitespace-pre-line border-t border-slate-100 pt-4 text-sm leading-relaxed text-slate-700">
|
||||
{webinar.description}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/*
|
||||
Tresc przygotowana w edytorze wizualnym.
|
||||
|
||||
HTML jest **oczyszczony przy zapisie** na backendzie (biala lista jsoup),
|
||||
wiec to, co tu trafia, przeszlo juz przez filtr. Nie czyscimy go drugi raz
|
||||
w przegladarce, bo to tylko udawaloby dodatkowe zabezpieczenie: strona
|
||||
publiczna dostaje dokladnie to, co jest w bazie.
|
||||
|
||||
Klasa `prose` daje naglowkom i listom sensowny wyglad bez wlasnego CSS.
|
||||
`prose-img:mx-auto` wysrodkowuje obrazki, a `max-w-full` w po laczeniu
|
||||
z `overflow-x-auto` na kontenerze pilnuje, zeby obrazek szerszy niz
|
||||
ekran nie rozpychal strony na telefonie.
|
||||
*/}
|
||||
{webinar.contentHtml && (
|
||||
<section className="overflow-x-auto rounded-lg border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<div
|
||||
className="prose prose-slate max-w-none prose-headings:font-semibold prose-img:mx-auto prose-img:h-auto prose-img:max-w-full prose-img:rounded-md"
|
||||
dangerouslySetInnerHTML={{ __html: webinar.contentHtml }}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* formularz jest ukryty w kazdym stanie zamknietym, nie tylko zablokowany */}
|
||||
{closed && notice ? (
|
||||
<PublicNotice tone="warning" icon={notice.icon} title={notice.title} text={notice.text} />
|
||||
) : (
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<RegistrationForm webinar={webinar} onSuccess={setRegisteredEmail} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import '@livekit/components-styles'
|
||||
import { GridLayout, LiveKitRoom, ParticipantTile, RoomAudioRenderer, useTracks } from '@livekit/components-react'
|
||||
import { RoomEvent, Track } from 'livekit-client'
|
||||
import { useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
/**
|
||||
* Strona, ktora nagrywa Egress.
|
||||
*
|
||||
* DLACZEGO WLASNY SZABLON, A NIE DOMYSLNY
|
||||
* ------------------------------------------------------------------------
|
||||
* Egress nagrywa **to, co widzi przegladarka**, wiec musi otworzyc jakas
|
||||
* strone. Domyslnie pobiera ja z `https://template.livekit.io`, czyli
|
||||
* z cudzego serwera w internecie. To oznacza dwie rzeczy, ktore w tym
|
||||
* projekcie sa nie do przyjecia:
|
||||
*
|
||||
* 1. **Nagrywanie przestaje dzialac bez dostepu do internetu.** Instancja
|
||||
* klienta stoi czesto w sieci zamknietej; Egress nie pobiera wtedy
|
||||
* szablonu i nagranie konczy sie bledem "Start signal not received".
|
||||
* 2. Wyglad nagrania zalezy od strony trzeciej, ktora moze sie zmienic.
|
||||
*
|
||||
* Wlasny szablon lezy w naszym froncie i jest osiagalny po sieci Dockera,
|
||||
* wiec nagrywanie dziala tak samo w sieci zamknietej.
|
||||
*
|
||||
* SYGNAL STARTU I KONCA
|
||||
* ------------------------------------------------------------------------
|
||||
* Egress czeka na `window.START_RECORDING()`, zanim zacznie pisac plik, i na
|
||||
* `window.END_RECORDING()`, zeby go domknac. **Bez wywolania pierwszej funkcji
|
||||
* nagranie jest przerywane po kilkunastu sekundach** z bledem 412. Wolamy ja,
|
||||
* gdy pokoj jest polaczony i sa w nim jakies sciezki: nagranie ma zaczynac sie
|
||||
* od obrazu, a nie od pustej planszy.
|
||||
*
|
||||
* Ta strona nie jest dla ludzi: nie ma tu panelu interakcji, banerow ani
|
||||
* paska akcji. Ma pokazywac obraz spotkania i nic wiecej.
|
||||
*/
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
START_RECORDING?: () => void
|
||||
END_RECORDING?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
export function RecordingTemplate() {
|
||||
const [params] = useSearchParams()
|
||||
const url = params.get('url') ?? ''
|
||||
const token = params.get('token') ?? ''
|
||||
|
||||
if (!url || !token) {
|
||||
return <div style={{ color: '#fff', padding: 24 }}>Brak parametrów nagrywania.</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100vw', height: '100vh', background: '#111' }} data-lk-theme="default">
|
||||
<LiveKitRoom serverUrl={url} token={token} connect audio={false} video={false}>
|
||||
<RecordingStage />
|
||||
<RoomAudioRenderer />
|
||||
</LiveKitRoom>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uklad kafelkow plus sygnalizacja do Egress.
|
||||
*
|
||||
* Musi byc osobnym komponentem **wewnatrz** `LiveKitRoom`: `useTracks` czyta
|
||||
* kontekst pokoju, ktorego poza nim nie ma.
|
||||
*/
|
||||
function RecordingStage() {
|
||||
const tracks = useTracks(
|
||||
[
|
||||
{ source: Track.Source.Camera, withPlaceholder: false },
|
||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||
],
|
||||
{ onlySubscribed: true, updateOnlyOn: [RoomEvent.ActiveSpeakersChanged] },
|
||||
)
|
||||
|
||||
/*
|
||||
* Sygnal startu wysylamy raz, gdy pojawi sie pierwsza sciezka. Wyslanie go
|
||||
* od razu po polaczeniu dawaloby kilka sekund czarnego obrazu na poczatku
|
||||
* kazdego nagrania.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (tracks.length > 0 && window.START_RECORDING) {
|
||||
window.START_RECORDING()
|
||||
// wolamy tylko raz: powtorne wywolanie Egress ignoruje, ale nie ma
|
||||
// powodu go zasypywac przy kazdej zmianie liczby sciezek
|
||||
window.START_RECORDING = undefined
|
||||
}
|
||||
}, [tracks.length])
|
||||
|
||||
return (
|
||||
<GridLayout tracks={tracks} style={{ height: '100vh' }}>
|
||||
<ParticipantTile />
|
||||
</GridLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { publicClient, publicErrorMessage, publicFieldErrors } from '../../api/publicClient'
|
||||
import type { ConsentText, ConsentType, PublicWebinar, RegistrationResult } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
|
||||
type FormState = {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string
|
||||
company: string
|
||||
}
|
||||
|
||||
const EMPTY: FormState = { firstName: '', lastName: '', email: '', phone: '', company: '' }
|
||||
|
||||
/**
|
||||
* Adres przeniesiony z karty webinaru na stronie glownej.
|
||||
*
|
||||
* Karta ma tam jedno pole na adres, ale zapis wymaga takze imienia, nazwiska
|
||||
* i zgody na regulamin: `first_name` i `last_name` maja NOT NULL, a zgoda musi
|
||||
* byc zapisana z wersja tresci. Dlatego karta nie zapisuje, tylko przenosi
|
||||
* tutaj to, co uzytkownik zdazyl wpisac, zeby nie musial pisac adresu drugi raz.
|
||||
*
|
||||
* Wartosc pochodzi z adresu strony, wiec jest **danymi od uzytkownika**:
|
||||
* traktujemy ja jak tresc pola formularza, nie jak zaufane wejscie. Nie ma
|
||||
* tu ryzyka przekierowania, bo nie buduje sie z niej zadnej sciezki.
|
||||
*/
|
||||
function prefilledEmail(): string {
|
||||
try {
|
||||
const value = new URLSearchParams(window.location.search).get('email')
|
||||
return value && value.length <= 255 ? value.trim() : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Prosty format adresu. Wlasciwa walidacje robi backend. */
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
|
||||
|
||||
/**
|
||||
* Pola sa wysokie i maja 16px tekstu, zeby iOS nie powiekszal widoku
|
||||
* przy wejsciu w pole, a palec trafial bez celowania.
|
||||
*/
|
||||
const INPUT_CLASS =
|
||||
'w-full min-h-[48px] rounded-md border border-slate-200 bg-white px-4 text-base text-slate-900 placeholder:text-slate-400 transition duration-fast ease-standard hover:border-slate-200 focus:border-blue-500 focus:outline-none focus:ring-[3px] focus:ring-blue-500/45'
|
||||
|
||||
export function RegistrationForm({
|
||||
webinar,
|
||||
onSuccess,
|
||||
}: {
|
||||
webinar: PublicWebinar
|
||||
onSuccess: (email: string) => void
|
||||
}) {
|
||||
const [form, setForm] = useState<FormState>(() => ({ ...EMPTY, email: prefilledEmail() }))
|
||||
// zgody zawsze startuja odznaczone, takze ta wymagana
|
||||
const [consents, setConsents] = useState<Partial<Record<ConsentType, boolean>>>({})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [generalError, setGeneralError] = useState<string | null>(null)
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await publicClient.post<RegistrationResult>(
|
||||
`/public/webinars/${webinar.slug}/register`,
|
||||
{
|
||||
email: form.email.trim(),
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
phone: form.phone.trim() || null,
|
||||
company: form.company.trim() || null,
|
||||
consents,
|
||||
/*
|
||||
* Zaproszenie, z ktorego przyszedl uczestnik. Backend sprawdza, czy
|
||||
* faktycznie dotyczy tego webinaru: identyfikator jest w adresie,
|
||||
* wiec kazdy moze go podmienic.
|
||||
*/
|
||||
invitationId: invitationIdFromUrl(),
|
||||
},
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => onSuccess(form.email.trim()),
|
||||
onError: (error) => {
|
||||
// komunikaty backendu pokazujemy wiernie, bez podmieniania na wlasne
|
||||
setErrors(publicFieldErrors(error))
|
||||
setGeneralError(publicErrorMessage(error, 'Nie udało się zapisać. Spróbuj ponownie.'))
|
||||
},
|
||||
})
|
||||
|
||||
function validate(): Record<string, string> {
|
||||
const found: Record<string, string> = {}
|
||||
if (!form.firstName.trim()) found.firstName = 'Podaj imię'
|
||||
if (!form.lastName.trim()) found.lastName = 'Podaj nazwisko'
|
||||
if (!form.email.trim()) found.email = 'Podaj adres e-mail'
|
||||
else if (!EMAIL_PATTERN.test(form.email.trim())) found.email = 'Niepoprawny adres e-mail'
|
||||
|
||||
const requiredConsent = webinar.consents.find((c) => c.required)
|
||||
if (requiredConsent && !consents[requiredConsent.type]) {
|
||||
found.consents = 'Akceptacja regulaminu jest wymagana'
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setGeneralError(null)
|
||||
const found = validate()
|
||||
setErrors(found)
|
||||
if (Object.keys(found).length > 0) return
|
||||
submit.mutate()
|
||||
}
|
||||
|
||||
function update<K extends keyof FormState>(key: K, value: string) {
|
||||
setForm((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
const fields = [
|
||||
{ id: 'firstName', label: 'Imię', required: true, autoComplete: 'given-name' },
|
||||
{ id: 'lastName', label: 'Nazwisko', required: true, autoComplete: 'family-name' },
|
||||
{ id: 'email', label: 'Adres e-mail', required: true, autoComplete: 'email', type: 'email', inputMode: 'email' as const },
|
||||
{ id: 'phone', label: 'Telefon', required: false, autoComplete: 'tel', type: 'tel', inputMode: 'tel' as const },
|
||||
{ id: 'company', label: 'Firma', required: false, autoComplete: 'organization' },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Formularz zapisu</h2>
|
||||
|
||||
{generalError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2.5 rounded-md border border-alarm-500/20 bg-alarm-50 p-3 text-sm text-alarm-600"
|
||||
>
|
||||
<Icon name="circle-alert" size={18} className="mt-px" />
|
||||
<p className="min-w-0 flex-1">{generalError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{fields.map((field) => (
|
||||
<div key={field.id}>
|
||||
<label htmlFor={field.id} className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
{field.label}{' '}
|
||||
{field.required ? (
|
||||
<span className="text-alarm-500">*</span>
|
||||
) : (
|
||||
<span className="font-normal text-slate-400">(opcjonalnie)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
id={field.id}
|
||||
type={'type' in field ? field.type : 'text'}
|
||||
inputMode={'inputMode' in field ? field.inputMode : undefined}
|
||||
className={INPUT_CLASS}
|
||||
value={form[field.id]}
|
||||
onChange={(e) => update(field.id, e.target.value)}
|
||||
autoComplete={field.autoComplete}
|
||||
/>
|
||||
{errors[field.id] && (
|
||||
<p className="mt-1.5 text-sm text-alarm-600">{errors[field.id]}</p>
|
||||
)}
|
||||
{field.id === 'email' && !errors.email && (
|
||||
<p className="mt-1.5 text-xs text-slate-500">
|
||||
Na ten adres wyślemy potwierdzenie z linkiem do spotkania.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<fieldset className="space-y-3 border-t border-slate-100 pt-5">
|
||||
<legend className="sr-only">Zgody</legend>
|
||||
|
||||
{/*
|
||||
Przelacznik "master" nad lista zgod: jednym klikiem zaznacza albo
|
||||
odznacza wszystkie ponizej. NIE jest zaznaczony domyslnie - zgoda
|
||||
musi byc swiadomym dzialaniem uzytkownika, wiec to on go klika.
|
||||
Stan liczy sie z dzieci: wszystkie zaznaczone -> zaznaczony,
|
||||
czesc -> stan posredni (indeterminate), zadne -> pusty.
|
||||
*/}
|
||||
{(() => {
|
||||
const total = webinar.consents.length
|
||||
const selected = webinar.consents.filter((c) => consents[c.type] === true).length
|
||||
const allChecked = total > 0 && selected === total
|
||||
const someChecked = selected > 0 && selected < total
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-300 bg-slate-25 p-3.5 transition duration-fast ease-standard hover:bg-slate-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChecked}
|
||||
ref={(el) => {
|
||||
// stan posredni: czesc zgod zaznaczona
|
||||
if (el) el.indeterminate = someChecked
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const value = e.target.checked
|
||||
setConsents(() => {
|
||||
const next: Partial<Record<ConsentType, boolean>> = {}
|
||||
for (const c of webinar.consents) next[c.type] = value
|
||||
return next
|
||||
})
|
||||
}}
|
||||
className="mt-0.5 h-5 w-5 shrink-0 rounded border-slate-300 text-blue-500 focus:ring-[3px] focus:ring-blue-500/45"
|
||||
/>
|
||||
<span className="min-w-0 text-sm font-semibold leading-relaxed text-slate-900">
|
||||
Zaznacz wszystkie zgody
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Dzieci: wciete i polaczone pionowa linia, uklad drzewa */}
|
||||
<div className="ml-3 space-y-3 border-l border-slate-200 pl-4">
|
||||
{webinar.consents.map((consent) => (
|
||||
<label
|
||||
key={consent.type}
|
||||
className="flex cursor-pointer items-start gap-3 rounded-md border border-slate-200 p-3.5 transition duration-fast ease-standard hover:border-slate-200 hover:bg-slate-25"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
// zgody nigdy nie sa zaznaczone domyslnie
|
||||
checked={consents[consent.type] === true}
|
||||
onChange={(e) =>
|
||||
setConsents((current) => ({ ...current, [consent.type]: e.target.checked }))
|
||||
}
|
||||
className="mt-0.5 h-5 w-5 shrink-0 rounded border-slate-200 text-blue-500 focus:ring-[3px] focus:ring-blue-500/45"
|
||||
/>
|
||||
<span className="min-w-0 text-sm leading-relaxed text-slate-700">
|
||||
{consent.required && (
|
||||
<span className="mr-1 font-semibold text-slate-900">Wymagane:</span>
|
||||
)}
|
||||
{renderConsentText(consent)}
|
||||
{!consent.required && (
|
||||
<span className="ml-1 text-xs text-slate-400">(opcjonalne)</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{errors.consents && <p className="text-sm text-alarm-600">{errors.consents}</p>}
|
||||
</fieldset>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submit.isPending}
|
||||
className="flex min-h-[52px] w-full items-center justify-center gap-2 rounded-md bg-blue-500 px-4 text-base font-semibold text-white transition duration-fast ease-standard hover:bg-blue-600 active:translate-y-px focus:outline-none focus:ring-[3px] focus:ring-blue-500/45 disabled:bg-blue-200"
|
||||
>
|
||||
{submit.isPending && <Icon name="loader" size={18} className="animate-spin" />}
|
||||
{submit.isPending ? 'Zapisywanie...' : 'Zapisz się na webinar'}
|
||||
</button>
|
||||
|
||||
<p className="hx-data text-center text-2xs text-slate-400">
|
||||
Wersja treści zgód: {webinar.consentVersion}
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Identyfikator zaproszenia z adresu strony.
|
||||
*
|
||||
* Link w zaproszeniu ma postac /w/{slug}?zaproszenie={id}. Wartosc niepoprawna
|
||||
* pomijamy po cichu: zapis ma sie udac niezaleznie od tego, czy da sie go
|
||||
* powiazac ze zrodlem.
|
||||
*/
|
||||
/**
|
||||
* Renderuje tresc zgody, zamieniajac wskazane slowo na klikalny odnosnik.
|
||||
*
|
||||
* Gdy zgoda ma `linkUrl` i `linkLabel` (np. slowo "regulamin" -> /regulamin),
|
||||
* to jedno wystapienie tego slowa staje sie odnosnikiem otwieranym w nowej
|
||||
* karcie. Reszta tresci zostaje zwyklym tekstem. Bez linku - caly tekst zwykly.
|
||||
* `rel="noopener noreferrer"` odcina nowej karcie dostep do okna zapisu.
|
||||
*/
|
||||
function renderConsentText(consent: ConsentText): ReactNode {
|
||||
const { text, linkUrl, linkLabel } = consent
|
||||
if (!linkUrl || !linkLabel) {
|
||||
return text
|
||||
}
|
||||
const index = text.toLowerCase().indexOf(linkLabel.toLowerCase())
|
||||
if (index < 0) {
|
||||
return text
|
||||
}
|
||||
const before = text.slice(0, index)
|
||||
const match = text.slice(index, index + linkLabel.length)
|
||||
const after = text.slice(index + linkLabel.length)
|
||||
return (
|
||||
<>
|
||||
{before}
|
||||
<a
|
||||
href={linkUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline"
|
||||
// klik w link nie moze przelaczac checkboxa zgody
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{match}
|
||||
</a>
|
||||
{after}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function invitationIdFromUrl(): number | null {
|
||||
const raw = new URLSearchParams(window.location.search).get('zaproszenie')
|
||||
if (!raw) return null
|
||||
const parsed = Number(raw)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Client, type IMessage } from '@stomp/stompjs'
|
||||
import { publicClient, publicErrorMessage } from '../../api/publicClient'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Button, Spinner } from '../../components/ui'
|
||||
|
||||
/**
|
||||
* Zadania powstale podczas webinaru.
|
||||
*
|
||||
* Zadanie ogloszone przez prowadzacego pojawia sie tu bez odswiezania strony,
|
||||
* a liczba wolnych miejsc zmienia sie u wszystkich, gdy ktos sie zapisze
|
||||
* albo wycofa.
|
||||
*/
|
||||
|
||||
export type TaskView = {
|
||||
id: number
|
||||
title: string
|
||||
description: string | null
|
||||
capacity: number | null
|
||||
taken: number
|
||||
open: boolean
|
||||
full: boolean
|
||||
signedUp: boolean
|
||||
deadline: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function TasksTab({ token, webinarId }: { token: string; webinarId: number }) {
|
||||
const [tasks, setTasks] = useState<TaskView[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState<number | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const response = await publicClient.get<{ tasks: TaskView[] }>(
|
||||
`/public/participation/${token}/tasks`)
|
||||
setTasks(response.data.tasks)
|
||||
} catch (e) {
|
||||
setError(publicErrorMessage(e, 'Nie udało się wczytać zadań.'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// zmiany od prowadzacego i od innych uczestnikow
|
||||
useEffect(() => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const stomp = new Client({
|
||||
brokerURL: `${protocol}//${window.location.host}/ws`,
|
||||
connectHeaders: { 'X-Join-Token': token },
|
||||
reconnectDelay: 4000,
|
||||
onConnect: () => {
|
||||
stomp.subscribe(`/topic/webinars/${webinarId}/tasks`, (message: IMessage) => {
|
||||
const event = JSON.parse(message.body) as { kind: string; payload: TaskView }
|
||||
setTasks((current) => applyTaskEvent(current, event))
|
||||
})
|
||||
},
|
||||
})
|
||||
stomp.activate()
|
||||
return () => {
|
||||
void stomp.deactivate()
|
||||
}
|
||||
}, [token, webinarId])
|
||||
|
||||
async function toggle(task: TaskView) {
|
||||
setBusy(task.id)
|
||||
setError(null)
|
||||
try {
|
||||
const path = `/public/participation/${token}/tasks/${task.id}/signup`
|
||||
const response = task.signedUp
|
||||
? await publicClient.delete<TaskView>(path)
|
||||
: await publicClient.post<TaskView>(path)
|
||||
setTasks((current) =>
|
||||
current.map((t) => (t.id === task.id ? response.data : t)))
|
||||
} catch (e) {
|
||||
setError(publicErrorMessage(e, 'Nie udało się zmienić zgłoszenia.'))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <Spinner />
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
|
||||
{error && (
|
||||
<p className="rounded-md border border-alarm-500/20 bg-alarm-50 p-2 text-xs text-alarm-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
Prowadzący nie ogłosił jeszcze żadnych zadań.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{tasks.map((task) => {
|
||||
const left = task.capacity === null ? null : task.capacity - task.taken
|
||||
const blocked = !task.open || (task.full && !task.signedUp)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className={`rounded-md border p-3 ${
|
||||
task.signedUp ? 'border-ok-500/30 bg-ok-50' : 'border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-slate-900">{task.title}</p>
|
||||
{task.description && (
|
||||
<p className="mt-0.5 whitespace-pre-wrap text-sm text-slate-600">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span className="hx-data text-xs text-slate-500">
|
||||
{task.capacity === null
|
||||
? `${task.taken} zapisanych, bez limitu`
|
||||
: `${task.taken} / ${task.capacity} miejsc`}
|
||||
</span>
|
||||
{left !== null && left > 0 && !task.signedUp && (
|
||||
<span className="text-xs text-slate-400">wolne: {left}</span>
|
||||
)}
|
||||
{!task.open && <span className="text-xs text-warn-600">zapisy zamknięte</span>}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={task.signedUp ? 'secondary' : 'primary'}
|
||||
className="ml-auto"
|
||||
disabled={busy === task.id || (blocked && !task.signedUp)}
|
||||
onClick={() => toggle(task)}
|
||||
>
|
||||
{task.signedUp ? 'Wycofaj się' : task.full ? 'Brak miejsc' : 'Zgłaszam się'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{task.signedUp && (
|
||||
<p className="mt-2 flex items-center gap-1.5 text-xs text-ok-600">
|
||||
<Icon name="check-circle" size={14} />
|
||||
Jesteś zapisany. Po webinarze dostaniesz mail z listą swoich zadań.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Nakladanie zdarzenia na liste. `signedUp` zostaje z poprzedniego stanu. */
|
||||
function applyTaskEvent(
|
||||
current: TaskView[],
|
||||
event: { kind: string; payload: TaskView },
|
||||
): TaskView[] {
|
||||
if (event.kind === 'TASK_REMOVED') {
|
||||
return current.filter((t) => t.id !== event.payload.id)
|
||||
}
|
||||
|
||||
const existing = current.find((t) => t.id === event.payload.id)
|
||||
// rozglaszana wersja jest wspolna dla wszystkich, wiec nie wie,
|
||||
// czy TA przegladarka jest zapisana
|
||||
const merged = { ...event.payload, signedUp: existing?.signedUp ?? false }
|
||||
|
||||
if (!existing) return [...current, merged]
|
||||
return current.map((t) => (t.id === merged.id ? merged : t))
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { publicClient, publicErrorMessage } from '../../api/publicClient'
|
||||
import type { UnsubscribeResult } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { PublicLayout } from './PublicLayout'
|
||||
|
||||
/**
|
||||
* Wypisanie z wiadomosci marketingowych.
|
||||
*
|
||||
* DWA KROKI, NIE JEDEN
|
||||
* ------------------------------------------------------------------------
|
||||
* Samo otwarcie linku niczego nie zmienia: skanery poczty i podglad linkow
|
||||
* w kliencie odwiedzaja adresy z wiadomosci automatycznie, wiec wypisanie
|
||||
* przy wejsciu na strone wypisywaloby ludzi, ktorzy niczego nie kliknęli.
|
||||
* Zmiana nastepuje dopiero po nacisnieciu przycisku.
|
||||
*
|
||||
* Strona mowi wprost, czego wypisanie **nie** obejmuje: maile o webinarze,
|
||||
* na ktory ktos sam sie zapisal, ida dalej, bo to obsluga zamowionej uslugi,
|
||||
* a nie marketing.
|
||||
*/
|
||||
export function UnsubscribePage() {
|
||||
const { token } = useParams<{ token: string }>()
|
||||
const [done, setDone] = useState<UnsubscribeResult | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['unsubscribe', token],
|
||||
queryFn: async () => {
|
||||
const response = await publicClient.get<UnsubscribeResult>(`/public/unsubscribe/${token}`)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
async function confirm() {
|
||||
setPending(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await publicClient.post<UnsubscribeResult>(`/public/unsubscribe/${token}`)
|
||||
setDone(response.data)
|
||||
} catch (e) {
|
||||
setError(publicErrorMessage(e, 'Nie udało się zapisać rezygnacji. Spróbuj ponownie.'))
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className="flex justify-center py-16">
|
||||
<Icon name="loader" size={28} className="animate-spin text-slate-400" />
|
||||
</div>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6 text-center">
|
||||
<Icon name="circle-alert" size={32} className="mx-auto text-warn-600" />
|
||||
<h1 className="mt-3 text-lg font-semibold text-slate-900">
|
||||
Nie znaleźliśmy takiego zapisu
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Link mógł wygasnąć albo został już wykorzystany. Jeśli nadal dostajesz wiadomości,
|
||||
odpowiedz na dowolną z nich.
|
||||
</p>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const state = done ?? query.data
|
||||
const finished = done !== null || query.data.alreadyDone
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6">
|
||||
{finished ? (
|
||||
<div className="text-center">
|
||||
<Icon name="check-circle" size={32} className="mx-auto text-ok-500" />
|
||||
<h1 className="mt-3 text-lg font-semibold text-slate-900">
|
||||
{done?.alreadyDone || query.data.alreadyDone
|
||||
? 'Już nie wysyłamy Ci wiadomości'
|
||||
: 'Rezygnacja została zapisana'}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Adres <span className="hx-data font-medium">{state.email}</span> nie będzie już
|
||||
dostawał zaproszeń ani informacji handlowych.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-slate-900">
|
||||
Rezygnacja z wiadomości handlowych
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Po potwierdzeniu adres{' '}
|
||||
<span className="hx-data font-medium">{state.email}</span> przestanie dostawać
|
||||
zaproszenia na webinary i inne informacje handlowe.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 rounded-md bg-alarm-50 p-3 text-sm text-alarm-600">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirm}
|
||||
disabled={pending}
|
||||
className="mt-5 flex w-full items-center justify-center gap-2 rounded-lg bg-blue-500 px-4 py-3.5 text-base font-semibold text-white transition hover:bg-blue-600 disabled:opacity-60"
|
||||
>
|
||||
<Icon name="user-off" size={20} />
|
||||
{pending ? 'Zapisywanie...' : 'Potwierdzam rezygnację'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Wazne rozroznienie: wypisanie dotyczy marketingu, a nie obslugi
|
||||
webinaru, na ktory ktos sam sie zapisal. Bez tego zdania uczestnik
|
||||
moglby sadzic, ze straci potwierdzenie i przypomnienie.
|
||||
*/}
|
||||
<p className="mt-5 border-t border-slate-100 pt-4 text-xs text-slate-500">
|
||||
Nadal będziesz dostawać wiadomości dotyczące webinarów, na które sam się zapisałeś:
|
||||
potwierdzenie zapisu, przypomnienie o terminie i podsumowanie po spotkaniu. To obsługa
|
||||
zamówionej usługi, a nie informacje handlowe.
|
||||
</p>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type ZoomMtgEmbedded from '@zoom/meetingsdk/embedded'
|
||||
|
||||
/** Typ klienta osadzonego, zwracany przez createClient(). */
|
||||
type EmbeddedZoomClient = ReturnType<typeof ZoomMtgEmbedded.createClient>
|
||||
|
||||
/**
|
||||
* Pokoj Zoom osadzony w oknie webinaru (Meeting SDK Web, component view).
|
||||
*
|
||||
* GOTOWY KOMPONENT, NIE WLASNY INTERFEJS
|
||||
* ------------------------------------------------------------------------
|
||||
* Tak jak przy LiveKit i Jitsi, obraz i sterowanie daje gotowy komponent
|
||||
* dostawcy. Nasze zostaje to, czego Zoom nie ma: czat, pytania, zadania,
|
||||
* zapytania ofertowe, banery i pasek akcji.
|
||||
*
|
||||
* BEZ SharedArrayBuffer
|
||||
* ------------------------------------------------------------------------
|
||||
* Osadzamy BEZ naglowkow izolacji COOP/COEP. Meeting SDK dziala wtedy
|
||||
* poprawnie, traci tylko widok galerii i wirtualne tlo, ktore na webinarze
|
||||
* z widocznym glownie prelegentem nie sa potrzebne. Wlaczenie ich w przyszlosci
|
||||
* to dodanie naglowkow wylacznie na trasie spotkania.
|
||||
*
|
||||
* UPRAWNIENIA
|
||||
* ------------------------------------------------------------------------
|
||||
* Zoom NIE egzekwuje audio/wideo per uczestnik z podpisu: rola to tylko host
|
||||
* albo uczestnik. Wyciszenie i wylaczenie obrazu ustawia backend na poziomie
|
||||
* calego spotkania przy jego zakladaniu. To slabsza gwarancja niz LiveKit,
|
||||
* blizsza Jitsi. Dlatego props `canPublish*` sluza tu wylacznie do wygody
|
||||
* poczatkowego stanu, nie sa bariera.
|
||||
*/
|
||||
export function ZoomRoom({
|
||||
meetingNumber,
|
||||
signature,
|
||||
sdkKey,
|
||||
displayName,
|
||||
isHost,
|
||||
}: {
|
||||
meetingNumber: string
|
||||
signature: string
|
||||
sdkKey: string
|
||||
displayName: string
|
||||
isHost: boolean
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
// klient Zoom jest tworzony raz i sprzatany przy odmontowaniu.
|
||||
// Typ pochodzi z SDK, a nie z wlasnej atrapy.
|
||||
const clientRef = useRef<EmbeddedZoomClient | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const container = containerRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
async function start() {
|
||||
// Meeting SDK laduje sie dynamicznie, zeby nie wchodzil do glownego
|
||||
// pakietu: potrzebny jest wylacznie na sali.
|
||||
const mod = await import('@zoom/meetingsdk/embedded')
|
||||
const ZoomMtgEmbedded = mod.default
|
||||
if (cancelled || !container) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = ZoomMtgEmbedded.createClient()
|
||||
clientRef.current = client
|
||||
|
||||
await client.init({
|
||||
zoomAppRoot: container,
|
||||
language: 'pl-PL',
|
||||
patchJsMedia: true,
|
||||
})
|
||||
|
||||
await client.join({
|
||||
sdkKey,
|
||||
signature,
|
||||
meetingNumber,
|
||||
userName: displayName,
|
||||
// rola egzekwowana przez podpis po stronie serwera; tutaj tylko
|
||||
// przekazujemy to, co jest juz w podpisie
|
||||
password: '',
|
||||
})
|
||||
}
|
||||
|
||||
void start().catch((error) => {
|
||||
// blad wejscia nie moze wywrocic calego ekranu: panel interakcji
|
||||
// (czat, pytania) ma dzialac nawet gdy obraz sie nie zestawil
|
||||
console.error('Nie udalo sie dolaczyc do spotkania Zoom', error)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
const client = clientRef.current
|
||||
if (client) {
|
||||
try {
|
||||
void client.leaveMeeting()
|
||||
} catch {
|
||||
// klient mogl nie dojsc do stanu polaczonego; sprzatanie i tak konczymy
|
||||
}
|
||||
clientRef.current = null
|
||||
}
|
||||
}
|
||||
}, [meetingNumber, signature, sdkKey, displayName, isHost])
|
||||
|
||||
return <div ref={containerRef} className="h-full w-full" />
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { SettingsPane } from './SettingsPane'
|
||||
import { SettingsSection } from './SettingsSection'
|
||||
|
||||
const KEYS = ['APP_BRAND_NAME', 'APP_PUBLIC_URL', 'APP_TIMEZONE']
|
||||
|
||||
export function AppSettingsPage() {
|
||||
return (
|
||||
<SettingsPane>
|
||||
{({ settings, onSave, saving }) => (
|
||||
<SettingsSection
|
||||
title="Aplikacja"
|
||||
description="Nazwa marki i adres publiczny używane w mailach oraz linkach"
|
||||
settings={settings}
|
||||
keys={KEYS}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
</SettingsPane>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { ImageEditor } from '../../components/ImageEditor'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Button, Card } from '../../components/ui'
|
||||
import { PUBLIC_CONFIG_KEY, usePublicConfig } from '../../lib/usePublicConfig'
|
||||
|
||||
type Kind = 'LOGO' | 'FAVICON'
|
||||
|
||||
const KINDS: Record<
|
||||
Kind,
|
||||
{ title: string; hint: string; aspect: number; output: number; preview: string; transparent: boolean }
|
||||
> = {
|
||||
LOGO: {
|
||||
title: 'Logo instancji',
|
||||
hint:
|
||||
'Pokazywane w nagłówku panelu i na stronie zapisu. Ramkę kadru ustawiasz dowolnie, '
|
||||
+ 'także w pionie, a wynik zachowuje jej kształt. Puste miejsca zostają przezroczyste.',
|
||||
// punkt wyjscia: 3:1 miesci znak kwadratowy i typowy poziomy lockup z nazwa,
|
||||
// ale uzytkownik moze zmienic proporcje ramki
|
||||
aspect: 3,
|
||||
output: 480,
|
||||
preview: 'h-10 w-24',
|
||||
transparent: true,
|
||||
},
|
||||
FAVICON: {
|
||||
title: 'Ikona karty przeglądarki',
|
||||
hint:
|
||||
'Mała ikona widoczna na karcie przeglądarki. Kadr startuje jako kwadrat, bo tak ikona '
|
||||
+ 'wygląda najlepiej, ale można go zmienić. Drobne detale bywają nieczytelne.',
|
||||
aspect: 1,
|
||||
output: 64,
|
||||
preview: 'h-8 w-8',
|
||||
transparent: true,
|
||||
},
|
||||
}
|
||||
|
||||
export function BrandingSection() {
|
||||
const config = usePublicConfig()
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
|
||||
const [editing, setEditing] = useState<{ kind: Kind; file: File } | null>(null)
|
||||
const inputs = {
|
||||
LOGO: useRef<HTMLInputElement>(null),
|
||||
FAVICON: useRef<HTMLInputElement>(null),
|
||||
}
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async ({ kind, dataUrl }: { kind: Kind; dataUrl: string }) => {
|
||||
await client.post(`/admin/branding/${kind}`, { dataUrl })
|
||||
},
|
||||
onSuccess: () => {
|
||||
toasts.success('Grafika została zapisana')
|
||||
queryClient.invalidateQueries({ queryKey: PUBLIC_CONFIG_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
setEditing(null)
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać grafiki')),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (kind: Kind) => client.delete(`/admin/branding/${kind}`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Przywrócono grafikę domyślną')
|
||||
queryClient.invalidateQueries({ queryKey: PUBLIC_CONFIG_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć grafiki')),
|
||||
})
|
||||
|
||||
function currentUrl(kind: Kind): string {
|
||||
return kind === 'LOGO' ? config.logoUrl : config.faviconUrl
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Grafika instancji</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Logo i ikona są widoczne dla uczestników, więc nie są traktowane jako dane poufne.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(Object.keys(KINDS) as Kind[]).map((kind) => {
|
||||
const spec = KINDS[kind]
|
||||
const url = currentUrl(kind)
|
||||
const isEditing = editing?.kind === kind
|
||||
|
||||
return (
|
||||
<Card key={kind} title={spec.title} description={spec.hint}>
|
||||
{isEditing ? (
|
||||
<ImageEditor
|
||||
file={editing.file}
|
||||
aspect={spec.aspect}
|
||||
outputWidth={spec.output}
|
||||
transparent={spec.transparent}
|
||||
onCancel={() => setEditing(null)}
|
||||
onConfirm={(dataUrl) => upload.mutate({ kind, dataUrl })}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div
|
||||
className={`${spec.preview} flex shrink-0 items-center justify-center overflow-hidden rounded-md border border-slate-200 bg-slate-25`}
|
||||
>
|
||||
{url ? (
|
||||
<img src={url} alt={spec.title} className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<Icon name="droplet" size={18} className="text-slate-300" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="min-w-0 flex-1 text-sm text-slate-500">
|
||||
{url ? 'Ustawiona grafika własna.' : 'Używany jest znak domyślny.'}
|
||||
</p>
|
||||
|
||||
<input
|
||||
ref={inputs[kind]}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) setEditing({ kind, file })
|
||||
// czyscimy, zeby wybor tego samego pliku ponownie wyzwolil zdarzenie
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
icon="upload"
|
||||
size="sm"
|
||||
onClick={() => inputs[kind].current?.click()}
|
||||
>
|
||||
{url ? 'Zmień' : 'Wgraj'}
|
||||
</Button>
|
||||
{url && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (window.confirm('Przywrócić grafikę domyślną?')) remove.mutate(kind)
|
||||
}}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
>
|
||||
Usuń
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BrandingSection } from './BrandingSection'
|
||||
|
||||
export function BrandingSettingsPage() {
|
||||
return <BrandingSection />
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { SettingsPane } from './SettingsPane'
|
||||
import { SettingsSection } from './SettingsSection'
|
||||
|
||||
/**
|
||||
* Dane firmy / administratora danych osobowych (RODO).
|
||||
*
|
||||
* RODO (art. 13) wymaga, zeby osoba zapisujaca sie na webinar dostala tozsamosc
|
||||
* administratora danych: pelna nazwe, adres i kontakt. Te dane trafiaja do
|
||||
* stopki, na strone zapisu i do obowiazku informacyjnego w polityce prywatnosci.
|
||||
* Kazde pole osobno, zamiast jednego wolnego akapitu, zeby latwiej bylo je
|
||||
* zlozyc w klauzule informacyjna i zeby nie zostawic wymaganego elementu.
|
||||
*/
|
||||
|
||||
const KEYS = [
|
||||
'COMPANY_NAME',
|
||||
'COMPANY_ADDRESS',
|
||||
'COMPANY_TAX_ID',
|
||||
'COMPANY_REGISTRY',
|
||||
'COMPANY_EMAIL',
|
||||
'COMPANY_PHONE',
|
||||
'DPO_CONTACT',
|
||||
]
|
||||
|
||||
export function CompanySettingsPage() {
|
||||
return (
|
||||
<SettingsPane>
|
||||
{({ settings, onSave, saving }) => (
|
||||
<SettingsSection
|
||||
title="Dane firmy / RODO"
|
||||
description="Dane administratora danych osobowych. Prawo (RODO) wymaga podania ich osobie, która zapisuje się na webinar. Pojawiają się w stopce, na stronie zapisu i w polityce prywatności."
|
||||
settings={settings}
|
||||
keys={KEYS}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
intro={
|
||||
<div className="rounded-md border border-info-500/30 bg-info-50 p-3 text-sm text-info-600">
|
||||
Uzupełnij te dane przed opublikowaniem pierwszego webinaru. Bez nich strona zapisu
|
||||
nie spełnia obowiązku informacyjnego RODO. Pole inspektora ochrony danych wypełnij
|
||||
tylko, jeśli firma go powołała.
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingsPane>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { SettingsPane } from './SettingsPane'
|
||||
import { SettingsSection } from './SettingsSection'
|
||||
|
||||
const KEYS = [
|
||||
'CONSENT_VERSION',
|
||||
'CONSENT_TERMS_TEXT',
|
||||
'CONSENT_MARKETING_EMAIL_TEXT',
|
||||
'CONSENT_MARKETING_SMS_TEXT',
|
||||
]
|
||||
|
||||
const MULTILINE = [
|
||||
'CONSENT_TERMS_TEXT',
|
||||
'CONSENT_MARKETING_EMAIL_TEXT',
|
||||
'CONSENT_MARKETING_SMS_TEXT',
|
||||
]
|
||||
|
||||
export function ConsentSettingsPage() {
|
||||
return (
|
||||
<SettingsPane>
|
||||
{({ settings, onSave, saving }) => (
|
||||
<SettingsSection
|
||||
title="Treści zgód"
|
||||
description="Po każdej zmianie treści podnieś wersję. Wersja jest utrwalana przy każdej zgodzie, więc stare zapisy zachowują treść, na którą uczestnik faktycznie się zgodził."
|
||||
settings={settings}
|
||||
keys={KEYS}
|
||||
multiline={MULTILINE}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
</SettingsPane>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { PageSection, PageSectionType } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { MediaField } from '../../components/MediaField'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
IconButton,
|
||||
Input,
|
||||
Spinner,
|
||||
Textarea,
|
||||
} from '../../components/ui'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
|
||||
/**
|
||||
* Tresc sekcji statycznych strony glownej Akademii.
|
||||
*
|
||||
* Prowadzacy, opinie i pytania FAQ sa POWTARZALNYMI REKORDAMI, wiec nie leza
|
||||
* w app_settings, tylko w tabeli `page_sections`. Dzieki temu administrator
|
||||
* dodaje i usuwa pozycje przyciskiem, zamiast wpisywac wlasny format
|
||||
* z separatorami, a liczba pozycji nie jest z gory ograniczona.
|
||||
*
|
||||
* Naglowki tych sekcji sa pojedynczymi napisami, wiec zostaja w ustawieniach
|
||||
* aplikacji, razem z reszta tresci strony glownej.
|
||||
*/
|
||||
const SECTIONS: {
|
||||
id: PageSectionType
|
||||
label: string
|
||||
description: string
|
||||
titleLabel: string
|
||||
subtitleLabel: string | null
|
||||
bodyLabel: string
|
||||
withMedia: boolean
|
||||
}[] = [
|
||||
{
|
||||
id: 'EXPERT',
|
||||
label: 'Prowadzący',
|
||||
description: 'Osoby prowadzące szkolenia, pokazywane na stronie głównej Akademii.',
|
||||
titleLabel: 'Imię i nazwisko',
|
||||
subtitleLabel: 'Specjalność',
|
||||
bodyLabel: 'Krótki opis',
|
||||
withMedia: true,
|
||||
},
|
||||
{
|
||||
id: 'REVIEW',
|
||||
label: 'Opinie',
|
||||
description: 'Opinie kursantów. Publikuj wyłącznie te, na które masz zgodę autora.',
|
||||
titleLabel: 'Autor opinii',
|
||||
subtitleLabel: 'Stanowisko i firma',
|
||||
bodyLabel: 'Treść opinii',
|
||||
withMedia: false,
|
||||
},
|
||||
{
|
||||
id: 'FAQ',
|
||||
label: 'Najczęstsze pytania',
|
||||
description: 'Pytania i odpowiedzi w rozwijanej liście na dole strony głównej.',
|
||||
titleLabel: 'Pytanie',
|
||||
subtitleLabel: null,
|
||||
bodyLabel: 'Odpowiedź',
|
||||
withMedia: false,
|
||||
},
|
||||
]
|
||||
|
||||
export function HomePageSettingsPage() {
|
||||
const [active, setActive] = useState<PageSectionType>('EXPERT')
|
||||
const section = SECTIONS.find((s) => s.id === active)!
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card
|
||||
title="Strona główna Akademii"
|
||||
description="Treść sekcji widocznych dla kursantów. Zmiany są widoczne od razu, bez wdrożenia."
|
||||
>
|
||||
{/* Pasek przewija sie w poziomie, tak samo jak w reszcie ustawien */}
|
||||
<nav className="-mx-1 mb-5 flex gap-1 overflow-x-auto px-1 pb-1">
|
||||
{SECTIONS.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setActive(s.id)}
|
||||
aria-current={active === s.id}
|
||||
className={`min-h-11 shrink-0 whitespace-nowrap rounded-md px-3 text-sm transition-colors duration-fast ease-standard ${
|
||||
active === s.id
|
||||
? 'bg-blue-50 font-medium text-blue-700'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<p className="mb-4 text-sm text-slate-600">{section.description}</p>
|
||||
<SectionEditor section={section} />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionEditor({ section }: { section: (typeof SECTIONS)[number] }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const [editing, setEditing] = useState<PageSection | 'new' | null>(null)
|
||||
|
||||
const key = ['page-sections', section.id]
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: key,
|
||||
queryFn: async () =>
|
||||
(await client.get<PageSection[]>(`/admin/page-sections/${section.id}`)).data,
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await client.delete(`/admin/page-sections/${section.id}/${id}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toasts.success('Pozycja usunięta')
|
||||
queryClient.invalidateQueries({ queryKey: key })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć pozycji')),
|
||||
})
|
||||
|
||||
/**
|
||||
* Zmiana kolejnosci przez przekazanie CALEJ listy identyfikatorow, tak samo
|
||||
* jak przy modulach kursu. Przyciski gora i dol, a nie przeciaganie: lista
|
||||
* jest krotka, a przyciski dzialaja takze z klawiatury i na ekranie
|
||||
* dotykowym.
|
||||
*/
|
||||
const reorder = useMutation({
|
||||
mutationFn: async (ids: number[]) => {
|
||||
await client.put(`/admin/page-sections/${section.id}/order`, { ids })
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: key }),
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić kolejności')),
|
||||
})
|
||||
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const items = list.data ?? []
|
||||
const target = index + direction
|
||||
if (target < 0 || target >= items.length) return
|
||||
const ids = items.map((i) => i.id)
|
||||
;[ids[index], ids[target]] = [ids[target], ids[index]]
|
||||
reorder.mutate(ids)
|
||||
}
|
||||
|
||||
if (list.isPending) return <Spinner />
|
||||
if (list.isError) {
|
||||
return <ErrorState message={errorMessage(list.error, 'Nie udało się wczytać pozycji')} />
|
||||
}
|
||||
|
||||
const items = list.data ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Button icon="plus" onClick={() => setEditing('new')}>
|
||||
Dodaj pozycję
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="file-text"
|
||||
title="Brak pozycji"
|
||||
description="Ta sekcja nie pokaże się na stronie głównej, dopóki nie dodasz pierwszej pozycji."
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex flex-col gap-3 rounded-md border border-slate-100 p-3 sm:flex-row sm:items-center"
|
||||
>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<IconButton
|
||||
icon="chevron-up"
|
||||
label={`Przesuń "${item.title}" w górę`}
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={index === 0 || reorder.isPending}
|
||||
/>
|
||||
<IconButton
|
||||
icon="chevron-down"
|
||||
label={`Przesuń "${item.title}" w dół`}
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={index === items.length - 1 || reorder.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium text-slate-900">{item.title}</p>
|
||||
{!item.enabled && <Badge tone="neutral">Ukryta</Badge>}
|
||||
</div>
|
||||
{item.subtitle && <p className="text-xs text-slate-500">{item.subtitle}</p>}
|
||||
{item.body && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-slate-600">{item.body}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<IconButton
|
||||
icon="pencil"
|
||||
label={`Edytuj "${item.title}"`}
|
||||
onClick={() => setEditing(item)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
label={`Usuń "${item.title}"`}
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
// pytanie nazywa konkretny obiekt, a nie "czy na pewno?"
|
||||
if (window.confirm(`Usunąć pozycję "${item.title}"? Tej operacji nie można cofnąć.`)) {
|
||||
remove.mutate(item.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<SectionForm
|
||||
section={section}
|
||||
item={editing === 'new' ? null : editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null)
|
||||
queryClient.invalidateQueries({ queryKey: key })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionForm({
|
||||
section,
|
||||
item,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
section: (typeof SECTIONS)[number]
|
||||
item: PageSection | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const toasts = useToasts()
|
||||
const [form, setForm] = useState({
|
||||
title: item?.title ?? '',
|
||||
subtitle: item?.subtitle ?? '',
|
||||
body: item?.body ?? '',
|
||||
mediaId: item?.mediaId ?? null,
|
||||
enabled: item?.enabled ?? true,
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
title: form.title,
|
||||
subtitle: form.subtitle || null,
|
||||
body: form.body || null,
|
||||
mediaId: form.mediaId,
|
||||
enabled: form.enabled,
|
||||
}
|
||||
if (item) {
|
||||
await client.put(`/admin/page-sections/${section.id}/${item.id}`, payload)
|
||||
} else {
|
||||
await client.post(`/admin/page-sections/${section.id}`, payload)
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toasts.success(item ? 'Pozycja zapisana' : 'Pozycja dodana')
|
||||
onSaved()
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać pozycji')),
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={item ? 'Edycja pozycji' : `Nowa pozycja: ${section.label}`}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button
|
||||
icon="save"
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending || !form.title.trim()}
|
||||
>
|
||||
{save.isPending ? 'Zapisywanie...' : 'Zapisz'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Field label={section.titleLabel}>
|
||||
<Input
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{section.subtitleLabel && (
|
||||
<Field label={section.subtitleLabel}>
|
||||
<Input
|
||||
value={form.subtitle}
|
||||
onChange={(e) => setForm({ ...form, subtitle: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label={section.bodyLabel} wide>
|
||||
<Textarea
|
||||
rows={4}
|
||||
value={form.body}
|
||||
onChange={(e) => setForm({ ...form, body: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{section.withMedia && (
|
||||
<Field label="Zdjęcie" hint="Wybierz plik z biblioteki. Bez zdjęcia pokażemy inicjały.">
|
||||
{/* MediaField operuje tekstem: pusty ciag oznacza brak wyboru,
|
||||
a do API idzie liczba albo null. */}
|
||||
<MediaField
|
||||
value={form.mediaId == null ? '' : String(form.mediaId)}
|
||||
onChange={(value) =>
|
||||
setForm({ ...form, mediaId: value.trim() ? Number(value) : null })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-sm text-slate-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-3 focus:ring-blue-500/45"
|
||||
/>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon name="eye" size={16} className="text-slate-400" />
|
||||
Pokaż na stronie głównej
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { SafeModeNotice } from './SafeModeNotice'
|
||||
import { SettingsPane } from './SettingsPane'
|
||||
import { SettingsSection } from './SettingsSection'
|
||||
import { TestBlock } from './TestBlock'
|
||||
|
||||
const KEYS = [
|
||||
'MAIL_CONTACT_RECIPIENT',
|
||||
'MAIL_RATE_LIMIT_PER_HOUR',
|
||||
'MAIL_SAFE_MODE',
|
||||
'MAIL_SAFE_RECIPIENT',
|
||||
'MAIL_FROM_NAME',
|
||||
'MAIL_HOST',
|
||||
'MAIL_PASSWORD',
|
||||
'MAIL_PORT',
|
||||
'MAIL_SSL_ENABLED',
|
||||
'MAIL_USERNAME',
|
||||
]
|
||||
|
||||
export function MailSettingsPage() {
|
||||
return (
|
||||
<SettingsPane>
|
||||
{({ settings, onSave, saving }) => (
|
||||
<SettingsSection
|
||||
title="Konto e-mail (SMTP)"
|
||||
description="Konto, z którego wychodzą potwierdzenia zapisu i przypomnienia"
|
||||
settings={settings}
|
||||
keys={KEYS}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
intro={
|
||||
<SafeModeNotice
|
||||
settings={settings}
|
||||
modeKey="MAIL_SAFE_MODE"
|
||||
recipientKey="MAIL_SAFE_RECIPIENT"
|
||||
channel="poczty"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TestBlock
|
||||
title="Test konfiguracji"
|
||||
label="Testowy e-mail na adres"
|
||||
placeholder="adres@example.com"
|
||||
endpoint="/admin/settings/test-email"
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</SettingsPane>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Setting } from '../../api/types'
|
||||
import type { NotificationChannel } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Badge, Button, Card, IconButton, Input, Select } from '../../components/ui'
|
||||
import {
|
||||
describeChannel,
|
||||
describeOffset,
|
||||
formatDefaultReminders,
|
||||
parseDefaultReminders,
|
||||
sortReminders,
|
||||
REMINDER_PRESETS,
|
||||
type DefaultReminder,
|
||||
} from '../../lib/reminders'
|
||||
import { SettingsPane } from './SettingsPane'
|
||||
|
||||
/**
|
||||
* Domyslny harmonogram przypomnien.
|
||||
*
|
||||
* Wczesniej byl to jedno pole tekstowe w formacie EMAIL:1440:reminder, wiec
|
||||
* dodanie przypomnienia wymagalo znajomosci skladni i przeliczenia dob na
|
||||
* minuty. Teraz kazde przypomnienie jest osobnym wierszem, a odstep wybiera
|
||||
* sie z listy albo podaje w wybranej jednostce. Zapis nadal idzie w starym
|
||||
* formacie, bo to kontrakt backendu.
|
||||
*/
|
||||
|
||||
const TOLERANCE_KEY = 'REMINDER_TOLERANCE_MINUTES'
|
||||
const REMINDERS_KEY = 'DEFAULT_REMINDERS'
|
||||
|
||||
export function ReminderSettingsPage() {
|
||||
return (
|
||||
<SettingsPane>
|
||||
{({ settings, onSave, saving }) => (
|
||||
<ReminderForm settings={settings} onSave={onSave} saving={saving} />
|
||||
)}
|
||||
</SettingsPane>
|
||||
)
|
||||
}
|
||||
|
||||
function ReminderForm({
|
||||
settings,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
settings: Setting[]
|
||||
onSave: (values: Record<string, string>) => void
|
||||
saving: boolean
|
||||
}) {
|
||||
const savedReminders = settings.find((s) => s.key === REMINDERS_KEY)?.value ?? ''
|
||||
const savedTolerance = settings.find((s) => s.key === TOLERANCE_KEY)?.value ?? '30'
|
||||
|
||||
const [reminders, setReminders] = useState<DefaultReminder[]>([])
|
||||
const [tolerance, setTolerance] = useState('30')
|
||||
|
||||
// po wczytaniu ustawien odtwarzamy stan z zapisanej wartosci
|
||||
useEffect(() => {
|
||||
setReminders(sortReminders(parseDefaultReminders(savedReminders)))
|
||||
setTolerance(savedTolerance)
|
||||
}, [savedReminders, savedTolerance])
|
||||
|
||||
// formularz dodawania
|
||||
const [newMinutes, setNewMinutes] = useState('1440')
|
||||
const [newChannel, setNewChannel] = useState<NotificationChannel>('EMAIL')
|
||||
const [customValue, setCustomValue] = useState('')
|
||||
const [customUnit, setCustomUnit] = useState<'minutes' | 'hours' | 'days'>('hours')
|
||||
|
||||
const usingCustom = newMinutes === 'custom'
|
||||
const customMinutes = toMinutes(customValue, customUnit)
|
||||
const minutesToAdd = usingCustom ? customMinutes : Number(newMinutes)
|
||||
|
||||
const duplicate = reminders.some(
|
||||
(r) => r.minutes === minutesToAdd && r.channel === newChannel,
|
||||
)
|
||||
const canAdd = minutesToAdd > 0 && !duplicate
|
||||
|
||||
function addReminder() {
|
||||
if (!canAdd) return
|
||||
setReminders((current) =>
|
||||
sortReminders([
|
||||
...current,
|
||||
// szablon jest dzisiaj jeden, wiec nie zawracamy nim glowy uzytkownikowi
|
||||
{ channel: newChannel, minutes: minutesToAdd, template: 'reminder' },
|
||||
]),
|
||||
)
|
||||
setCustomValue('')
|
||||
}
|
||||
|
||||
function removeReminder(index: number) {
|
||||
setReminders((current) => current.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
onSave({
|
||||
[REMINDERS_KEY]: formatDefaultReminders(reminders),
|
||||
[TOLERANCE_KEY]: tolerance,
|
||||
})
|
||||
}
|
||||
|
||||
const hasSms = reminders.some((r) => r.channel === 'SMS')
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Przypomnienia</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Ten zestaw jest zakładany każdemu nowemu webinarowi. Harmonogram już istniejącego
|
||||
zmienia się w jego formularzu, więc zmiana tutaj nie rusza zaplanowanych wydarzeń.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title="Domyślny zestaw przypomnień"
|
||||
description="Uczestnik dostanie każde z poniższych przypomnień przed startem webinaru."
|
||||
>
|
||||
{reminders.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-slate-300 p-6 text-center">
|
||||
<p className="text-sm text-slate-500">
|
||||
Brak przypomnień. Nowe webinary nie będą ich wysyłać,
|
||||
dopóki nie dodasz ich ręcznie w formularzu webinaru.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{reminders.map((reminder, index) => (
|
||||
<li
|
||||
key={`${reminder.channel}-${reminder.minutes}`}
|
||||
className="flex items-center gap-3 rounded-md border border-slate-200 bg-white p-3"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-blue-50">
|
||||
<Icon
|
||||
name={reminder.channel === 'SMS' ? 'send' : 'mail'}
|
||||
size={16}
|
||||
className="text-blue-500"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-slate-900">
|
||||
{describeOffset(reminder.minutes)}
|
||||
</span>
|
||||
<span className="block text-xs text-slate-500">
|
||||
Wysyłka przez {describeChannel(reminder.channel)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<Badge tone={reminder.channel === 'SMS' ? 'info' : 'brand'}>
|
||||
{describeChannel(reminder.channel)}
|
||||
</Badge>
|
||||
|
||||
<IconButton
|
||||
icon="trash"
|
||||
label={`Usuń przypomnienie ${describeOffset(reminder.minutes)}`}
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => removeReminder(index)}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-3 rounded-md border border-slate-200 bg-slate-25 p-4">
|
||||
<p className="text-sm font-medium text-slate-900">Dodaj przypomnienie</p>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-semibold text-slate-700">Kiedy wysłać</span>
|
||||
<Select value={newMinutes} onChange={(e) => setNewMinutes(e.target.value)}>
|
||||
{REMINDER_PRESETS.map((preset) => (
|
||||
<option key={preset.minutes} value={preset.minutes}>
|
||||
{preset.label}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">Inny odstęp...</option>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-semibold text-slate-700">Kanał</span>
|
||||
<Select
|
||||
value={newChannel}
|
||||
onChange={(e) => setNewChannel(e.target.value as NotificationChannel)}
|
||||
>
|
||||
<option value="EMAIL">E-mail</option>
|
||||
<option value="SMS">SMS</option>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{usingCustom && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-semibold text-slate-700">
|
||||
Ile przed startem
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
placeholder="np. 2"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-semibold text-slate-700">Jednostka</span>
|
||||
<Select
|
||||
value={customUnit}
|
||||
onChange={(e) => setCustomUnit(e.target.value as typeof customUnit)}
|
||||
>
|
||||
<option value="minutes">minut</option>
|
||||
<option value="hours">godzin</option>
|
||||
<option value="days">dni</option>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* podpowiedz skutku, zeby nie trzeba bylo zgadywac, co doda przycisk */}
|
||||
<p className="text-xs text-slate-500">
|
||||
{minutesToAdd > 0
|
||||
? `Zostanie dodane przypomnienie ${describeOffset(minutesToAdd)}, wysyłane przez ${describeChannel(newChannel)}.`
|
||||
: 'Podaj, ile czasu przed startem ma pójść przypomnienie.'}
|
||||
</p>
|
||||
|
||||
{duplicate && (
|
||||
<p className="text-xs text-warn-600">
|
||||
Takie przypomnienie jest już na liście.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="button" icon="plus" variant="secondary" onClick={addReminder} disabled={!canAdd}>
|
||||
Dodaj do zestawu
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasSms && (
|
||||
<p className="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3 text-xs leading-relaxed text-blue-900">
|
||||
SMS trafi wyłącznie do uczestników, którzy podali numer telefonu i wyrazili zgodę
|
||||
marketingową na SMS. Pozostali dostaną tylko przypomnienia e-mail.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Spóźnione przypomnienia"
|
||||
description="Zabezpieczenie na wypadek przerwy w działaniu aplikacji."
|
||||
>
|
||||
<label className="block max-w-xs">
|
||||
<span className="mb-1 block text-xs font-semibold text-slate-700">
|
||||
Nie wysyłaj, jeśli spóźnienie przekracza
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={tolerance}
|
||||
onChange={(e) => setTolerance(e.target.value)}
|
||||
/>
|
||||
<span className="shrink-0 text-sm text-slate-600">minut</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<p className="mt-3 text-xs leading-relaxed text-slate-500">
|
||||
Jeśli aplikacja była wyłączona w momencie, na który zaplanowano przypomnienie,
|
||||
po uruchomieniu nadrobi zaległości. Przypomnienie spóźnione bardziej niż podana
|
||||
liczba minut nie idzie w ogóle, żeby uczestnik nie dostał wiadomości o wydarzeniu,
|
||||
które już się zaczęło.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Button type="submit" icon="save" disabled={saving}>
|
||||
{saving ? 'Zapisywanie...' : 'Zapisz ustawienia'}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/** Przelicza wartosc z wybranej jednostki na minuty. */
|
||||
function toMinutes(value: string, unit: 'minutes' | 'hours' | 'days'): number {
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number) || number <= 0) return 0
|
||||
if (unit === 'days') return Math.round(number * 1440)
|
||||
if (unit === 'hours') return Math.round(number * 60)
|
||||
return Math.round(number)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Setting } from '../../api/types'
|
||||
import { Alert } from '../../components/ui'
|
||||
|
||||
/**
|
||||
* Znacznik trybu bezpiecznego.
|
||||
*
|
||||
* Pokazywany **nad** polami, a nie pod nimi: administrator ma wiedziec, dokad
|
||||
* naprawde ida wiadomosci, zanim zacznie cokolwiek ustawiac albo wysylac test.
|
||||
*
|
||||
* Trzy stany, bo kazdy znaczy co innego dla osoby patrzacej na ekran:
|
||||
* - tryb wlaczony i adres ustawiony: wszystko idzie na jeden adres, nic nie
|
||||
* dociera do uczestnikow,
|
||||
* - tryb wlaczony, ale adresu brak: wysylka jest **wstrzymana**, bo backend
|
||||
* woli nie wyslac nic niz wyslac do prawdziwych ludzi wbrew ustawieniu,
|
||||
* - tryb wylaczony: wiadomosci ida do prawdziwych odbiorcow, i to tez trzeba
|
||||
* powiedziec wprost, bo to stan, w ktorym pomylka kosztuje najwiecej.
|
||||
*/
|
||||
export function SafeModeNotice({
|
||||
settings,
|
||||
modeKey,
|
||||
recipientKey,
|
||||
channel,
|
||||
}: {
|
||||
settings: Setting[]
|
||||
modeKey: string
|
||||
recipientKey: string
|
||||
channel: 'poczty' | 'SMS'
|
||||
}) {
|
||||
const mode = settings.find((s) => s.key === modeKey)
|
||||
const recipient = settings.find((s) => s.key === recipientKey)
|
||||
|
||||
// dopoki ustawienia sie wczytuja, nie zgadujemy stanu
|
||||
if (!mode) return null
|
||||
|
||||
const enabled = mode.value === 'true'
|
||||
const target = recipient?.value?.trim() ?? ''
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<Alert tone="warn" icon="alert-triangle">
|
||||
<span className="font-semibold">Tryb bezpieczny {channel} jest wyłączony.</span>{' '}
|
||||
Wiadomości idą do prawdziwych odbiorców z bazy. Na instancji testowej włącz tryb
|
||||
bezpieczny, żeby nic nie trafiło do uczestników.
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
return (
|
||||
<Alert tone="alarm" icon="circle-alert">
|
||||
<span className="font-semibold">Tryb bezpieczny {channel} jest włączony, ale brakuje adresu zastępczego.</span>{' '}
|
||||
Wysyłka jest wstrzymana, żeby nic nie trafiło do uczestników. Uzupełnij pole poniżej.
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert tone="ok" icon="check-circle">
|
||||
<span className="font-semibold">Tryb bezpieczny {channel} jest włączony.</span>{' '}
|
||||
Wszystkie wiadomości, niezależnie od odbiorcy, trafiają na{' '}
|
||||
<span className="hx-data font-semibold">{target}</span>. Prawdziwy odbiorca jest widoczny
|
||||
w temacie, a w dzienniku wysyłek zapisywany bez zmian.
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom'
|
||||
import { Icon, type IconName } from '../../components/Icon'
|
||||
import { panelPath } from '../../lib/routes'
|
||||
|
||||
/**
|
||||
* Uklad sekcji ustawien z wlasna nawigacja.
|
||||
*
|
||||
* Wszystkie obszary byly wczesniej na jednej stronie, przez co trzeba bylo
|
||||
* dlugo przewijac, zeby dojsc do tresci zgod. Kazdy obszar ma teraz wlasny
|
||||
* adres, wiec da sie do niego wrocic zakladka i podeslac link.
|
||||
*
|
||||
* Nawigacja jest pozioma i przewijalna w poziomie **wewnatrz wlasnego
|
||||
* kontenera**, wiec na telefonie nie rozpycha strony.
|
||||
*/
|
||||
|
||||
type SettingsTab = {
|
||||
to: string
|
||||
label: string
|
||||
icon: IconName
|
||||
/** `end` tylko dla trasy indeksowej, inaczej podswietlalaby sie zawsze. */
|
||||
end?: boolean
|
||||
}
|
||||
|
||||
export const SETTINGS_TABS: SettingsTab[] = [
|
||||
// Dane firmy / RODO jako pierwsza pozycja: to od nich zalezy zgodnosc prawna
|
||||
// (stopka, obowiazek informacyjny, tresc regulaminu skladana z tych danych),
|
||||
// wiec administrator powinien wypelnic je najpierw.
|
||||
{ to: panelPath('/ustawienia/dane-firmy'), label: 'Dane firmy / RODO', icon: 'users' },
|
||||
{ to: panelPath('/ustawienia'), label: 'Aplikacja', icon: 'settings', end: true },
|
||||
{ to: panelPath('/ustawienia/grafika'), label: 'Grafika', icon: 'droplet' },
|
||||
{ to: panelPath('/ustawienia/email'), label: 'E-mail', icon: 'mail' },
|
||||
{ to: panelPath('/ustawienia/sms'), label: 'SMS', icon: 'send' },
|
||||
{ to: panelPath('/ustawienia/wideo'), label: 'Wideo', icon: 'video' },
|
||||
{ to: panelPath('/ustawienia/przypomnienia'), label: 'Przypomnienia', icon: 'clock' },
|
||||
{ to: panelPath('/ustawienia/zgody'), label: 'Zgody', icon: 'file-text' },
|
||||
{ to: panelPath('/ustawienia/strona-glowna'), label: 'Strona główna', icon: 'book-open' },
|
||||
]
|
||||
|
||||
function tabClass({ isActive }: { isActive: boolean }): string {
|
||||
const base =
|
||||
'flex min-h-[44px] shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm '
|
||||
+ 'transition-colors duration-fast ease-standard'
|
||||
return isActive
|
||||
? `${base} bg-blue-50 font-medium text-blue-700`
|
||||
: `${base} text-slate-600 hover:bg-slate-50 hover:text-slate-900`
|
||||
}
|
||||
|
||||
export function SettingsLayout() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Ustawienia</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
Wartości zapisane tutaj mają pierwszeństwo przed zmiennymi środowiskowymi. Zmiana
|
||||
działa od razu, bez restartu aplikacji.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
overflow-x-auto na samej nawigacji, a nie na stronie: pasek zakladek
|
||||
przewija sie sam, a dokument zostaje bez przewijania w poziomie.
|
||||
*/}
|
||||
<nav
|
||||
aria-label="Sekcje ustawień"
|
||||
className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1"
|
||||
>
|
||||
{SETTINGS_TABS.map((tab) => (
|
||||
<NavLink key={tab.to} to={tab.to} end={tab.end} className={tabClass}>
|
||||
<Icon name={tab.icon} size={16} />
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Setting } from '../../api/types'
|
||||
import { ErrorState, Spinner } from '../../components/ui'
|
||||
import { errorMessage, useSettings } from './useSettings'
|
||||
|
||||
/**
|
||||
* Opakowanie podstrony ustawien: wczytanie, blad, a potem tresc.
|
||||
*
|
||||
* Dzieki temu kazda podstrona zajmuje sie wylacznie swoimi polami,
|
||||
* a stany posrednie wygladaja wszedzie tak samo.
|
||||
*/
|
||||
export function SettingsPane({
|
||||
children,
|
||||
}: {
|
||||
children: (props: {
|
||||
settings: Setting[]
|
||||
onSave: (values: Record<string, string>) => void
|
||||
saving: boolean
|
||||
}) => React.ReactNode
|
||||
}) {
|
||||
const { query, save } = useSettings()
|
||||
|
||||
if (query.isPending) return <Spinner />
|
||||
if (query.isError) {
|
||||
return <ErrorState message={errorMessage(query.error, 'Nie udało się wczytać ustawień')} />
|
||||
}
|
||||
|
||||
return <>{children({ settings: query.data, onSave: save.mutate, saving: save.isPending })}</>
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Setting } from '../../api/types'
|
||||
import { MediaField } from '../../components/MediaField'
|
||||
import { Badge, Button, Field, Input, PasswordInput, Select, Textarea } from '../../components/ui'
|
||||
|
||||
/**
|
||||
* Sekcja ustawien z wlasnym przyciskiem zapisu.
|
||||
*
|
||||
* Przy kluczach typu PASSWORD backend nigdy nie zwraca wartosci, tylko flage
|
||||
* valueSet. Puste pole oznacza brak zmiany, wiec do zapisu trafiaja wylacznie
|
||||
* te sekrety, ktore administrator faktycznie wpisal.
|
||||
*/
|
||||
export function SettingsSection({
|
||||
title,
|
||||
description,
|
||||
settings,
|
||||
keys,
|
||||
multiline = [],
|
||||
onSave,
|
||||
saving,
|
||||
intro,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
settings: Setting[]
|
||||
keys: string[]
|
||||
/** Klucze prezentowane jako pole wielolinijkowe, np. tresci zgod. */
|
||||
multiline?: string[]
|
||||
onSave: (values: Record<string, string>) => void
|
||||
saving: boolean
|
||||
/** Objasnienie nad polami, np. opis formatu wartosci. */
|
||||
intro?: React.ReactNode
|
||||
/** Tresc pod przyciskiem zapisu, np. blok testu wysylki. */
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const visible = keys
|
||||
.map((key) => settings.find((setting) => setting.key === key))
|
||||
.filter((setting): setting is Setting => Boolean(setting))
|
||||
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
|
||||
// po wczytaniu ustawien wypelniamy pola, sekrety zostaja puste
|
||||
useEffect(() => {
|
||||
const initial: Record<string, string> = {}
|
||||
visible.forEach((setting) => {
|
||||
initial[setting.key] = setting.secret ? '' : (setting.value ?? '')
|
||||
})
|
||||
setValues(initial)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings])
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
const payload: Record<string, string> = {}
|
||||
visible.forEach((setting) => {
|
||||
const value = values[setting.key] ?? ''
|
||||
// pusty sekret oznacza brak zmiany, nie wysylamy go w ogole
|
||||
if (setting.secret && value.trim() === '') return
|
||||
payload[setting.key] = value
|
||||
})
|
||||
|
||||
// Sekrety czyscimy od razu po wyslaniu. Backend nigdy nie zwraca ich wartosci,
|
||||
// wiec pozostawione w polu haslo sugerowaloby, ze pokazuje stan zapisany,
|
||||
// a przy kolejnym zapisie wyslaloby je ponownie.
|
||||
setValues((current) => {
|
||||
const cleared = { ...current }
|
||||
visible.forEach((setting) => {
|
||||
if (setting.secret) cleared[setting.key] = ''
|
||||
})
|
||||
return cleared
|
||||
})
|
||||
|
||||
onSave(payload)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
||||
{description && <p className="text-sm text-slate-500">{description}</p>}
|
||||
</div>
|
||||
|
||||
{intro}
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||
{visible.map((setting) => (
|
||||
<Field
|
||||
key={setting.key}
|
||||
label={setting.description}
|
||||
envName={setting.key}
|
||||
badge={
|
||||
setting.secret && setting.valueSet ? (
|
||||
<Badge className="bg-ok-50 text-ok-600 ring-ok-500/20">ustawione</Badge>
|
||||
) : undefined
|
||||
}
|
||||
hint={setting.secret ? 'Puste pole oznacza brak zmiany zapisanej wartości' : undefined}
|
||||
>
|
||||
{setting.type === 'BOOLEAN' ? (
|
||||
<Select
|
||||
value={values[setting.key] ?? 'false'}
|
||||
onChange={(e) => setValues((c) => ({ ...c, [setting.key]: e.target.value }))}
|
||||
>
|
||||
<option value="true">Włączone (true)</option>
|
||||
<option value="false">Wyłączone (false)</option>
|
||||
</Select>
|
||||
) : setting.type === 'SELECT' ? (
|
||||
/*
|
||||
* Zbior wartosci jest zamkniety, wiec pokazujemy liste wyboru.
|
||||
* Wpisywanie recznie oznaczalo, ze literowka w nazwie dostawcy
|
||||
* wideo zamieniala instancje w tryb NONE bez zadnego sygnalu.
|
||||
* Backend odrzuca wartosc spoza listy, a panel jej nie oferuje.
|
||||
*/
|
||||
<Select
|
||||
value={values[setting.key] ?? ''}
|
||||
onChange={(e) => setValues((c) => ({ ...c, [setting.key]: e.target.value }))}
|
||||
>
|
||||
{setting.allowedValues.map((allowed) => (
|
||||
<option key={allowed} value={allowed}>
|
||||
{allowed}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
) : setting.type === 'MEDIA' ? (
|
||||
<MediaField
|
||||
value={values[setting.key] ?? ''}
|
||||
onChange={(value) => setValues((c) => ({ ...c, [setting.key]: value }))}
|
||||
/>
|
||||
) : multiline.includes(setting.key) ? (
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={values[setting.key] ?? ''}
|
||||
onChange={(e) => setValues((c) => ({ ...c, [setting.key]: e.target.value }))}
|
||||
/>
|
||||
) : setting.secret ? (
|
||||
// podglad ulatwia sprawdzenie wklejonego hasla albo klucza API
|
||||
<PasswordInput
|
||||
value={values[setting.key] ?? ''}
|
||||
onChange={(e) => setValues((c) => ({ ...c, [setting.key]: e.target.value }))}
|
||||
placeholder={setting.valueSet ? '(wpisz, aby zmienić)' : undefined}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={setting.type === 'INT' ? 'number' : 'text'}
|
||||
value={values[setting.key] ?? ''}
|
||||
onChange={(e) => setValues((c) => ({ ...c, [setting.key]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button type="submit" icon="save" disabled={saving}>
|
||||
{saving ? 'Zapisywanie...' : 'Zapisz ustawienia'}
|
||||
</Button>
|
||||
|
||||
{children}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { SafeModeNotice } from './SafeModeNotice'
|
||||
import { SettingsPane } from './SettingsPane'
|
||||
import { SettingsSection } from './SettingsSection'
|
||||
import { TestBlock } from './TestBlock'
|
||||
|
||||
const KEYS = [
|
||||
'SMS_ENABLED',
|
||||
'SMS_GATEWAY_URL',
|
||||
'SMS_API_KEY',
|
||||
'SMS_CREATOR',
|
||||
'SMS_TIMEOUT_SECONDS',
|
||||
'SMS_RATE_LIMIT_PER_HOUR',
|
||||
'SMS_SAFE_MODE',
|
||||
'SMS_SAFE_RECIPIENT',
|
||||
]
|
||||
|
||||
export function SmsSettingsPage() {
|
||||
return (
|
||||
<SettingsPane>
|
||||
{({ settings, onSave, saving }) => (
|
||||
<SettingsSection
|
||||
title="Bramka SMS"
|
||||
description="Przypomnienia SMS idą tylko do osób, które wyraziły zgodę i podały numer"
|
||||
settings={settings}
|
||||
keys={KEYS}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
intro={
|
||||
<SafeModeNotice
|
||||
settings={settings}
|
||||
modeKey="SMS_SAFE_MODE"
|
||||
recipientKey="SMS_SAFE_RECIPIENT"
|
||||
channel="SMS"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TestBlock
|
||||
title="Test konfiguracji"
|
||||
label="Testowy SMS na numer"
|
||||
placeholder="np. 600100200"
|
||||
endpoint="/admin/settings/test-sms"
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</SettingsPane>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { TestResult } from '../../api/types'
|
||||
import { Button, Input } from '../../components/ui'
|
||||
import { Icon } from '../../components/Icon'
|
||||
|
||||
/**
|
||||
* Blok testu konfiguracji: pole adresu i przycisk wysylki.
|
||||
*
|
||||
* Backend zwraca 200 takze przy niepowodzeniu, z flaga success i pelna trescia
|
||||
* bledu, wiec wynik pokazujemy na miejscu zamiast tylko w powiadomieniu.
|
||||
*/
|
||||
export function TestBlock({
|
||||
title,
|
||||
label,
|
||||
placeholder,
|
||||
endpoint,
|
||||
buttonLabel = 'Wyślij',
|
||||
}: {
|
||||
title: string
|
||||
label: string
|
||||
placeholder: string
|
||||
endpoint: string
|
||||
buttonLabel?: string
|
||||
}) {
|
||||
const [recipient, setRecipient] = useState('')
|
||||
const [result, setResult] = useState<TestResult | null>(null)
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await client.post<TestResult>(endpoint, { recipient })
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (data) => setResult(data),
|
||||
onError: (error) =>
|
||||
setResult({ success: false, message: errorMessage(error, 'Nie udało się wykonać testu') }),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-5">
|
||||
<h3 className="text-base font-semibold text-slate-900">{title}</h3>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-end gap-2">
|
||||
<div className="min-w-[240px] flex-1">
|
||||
<label className="mb-1 block text-sm font-medium text-slate-700">{label}</label>
|
||||
<Input
|
||||
value={recipient}
|
||||
onChange={(e) => setRecipient(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => send.mutate()}
|
||||
disabled={send.isPending || !recipient.trim()}
|
||||
icon={send.isPending ? undefined : 'send'}
|
||||
>
|
||||
{send.isPending ? 'Wysyłanie...' : buttonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className={`mt-3 flex items-start gap-2 rounded-md border p-3 text-sm ${
|
||||
result.success
|
||||
? 'border-ok-500/20 bg-ok-50 text-ok-600'
|
||||
: 'border-alarm-500/20 bg-alarm-50 text-alarm-600'
|
||||
}`}
|
||||
>
|
||||
<Icon name={result.success ? 'check-circle' : 'circle-alert'} size={18} className="mt-px" />
|
||||
{/* pelna tresc bledu z serwera, czesto wielolinijkowa */}
|
||||
<p className="min-w-0 flex-1 whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">
|
||||
{result.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { SettingsPane } from './SettingsPane'
|
||||
import { SettingsSection } from './SettingsSection'
|
||||
|
||||
/**
|
||||
* Ustawienia wideo instancji.
|
||||
*
|
||||
* TO JEST MIEJSCE DLA TECHNIKA, NIE DLA OSOBY ZAKLADAJACEJ WEBINAR.
|
||||
* ---------------------------------------------------------------------------
|
||||
* Klucze dostawcow (LiveKit, Zoom, Jitsi) i sekret webhooka ustawia sie tutaj
|
||||
* RAZ, przy wdrozeniu instancji u klienta. Osoba tworzaca webinar nigdy tego
|
||||
* nie dotyka: w formularzu webinaru wybiera tylko gotowego dostawce z listy
|
||||
* i klika "Utworz pokoj". Dlatego instrukcja techniczna jest tutaj, a nie
|
||||
* przy webinarze.
|
||||
*
|
||||
* Wczesniej tych kluczy NIE DALO SIE ustawic przez panel (byly wylacznie
|
||||
* w bazie/zmiennych srodowiskowych), a instrukcja "?" przy webinarze mylila,
|
||||
* bo odsylala do nieistniejacej sekcji Wideo. Ta strona domyka te luke.
|
||||
*/
|
||||
|
||||
const KEYS = [
|
||||
'VIDEO_PROVIDER',
|
||||
'VIDEO_WEBHOOK_SECRET',
|
||||
'VIDEO_DELETE_AFTER_DOWNLOAD',
|
||||
// LiveKit
|
||||
'LIVEKIT_URL',
|
||||
'LIVEKIT_API_KEY',
|
||||
'LIVEKIT_API_SECRET',
|
||||
// Jitsi
|
||||
'JITSI_DOMAIN',
|
||||
'JITSI_JWT_ENABLED',
|
||||
'JITSI_APP_ID',
|
||||
'JITSI_APP_SECRET',
|
||||
// Zoom
|
||||
'ZOOM_ACCOUNT_ID',
|
||||
'ZOOM_CLIENT_ID',
|
||||
'ZOOM_CLIENT_SECRET',
|
||||
'ZOOM_SDK_KEY',
|
||||
'ZOOM_SDK_SECRET',
|
||||
]
|
||||
|
||||
export function VideoSettingsPage() {
|
||||
return (
|
||||
<SettingsPane>
|
||||
{({ settings, onSave, saving }) => (
|
||||
<SettingsSection
|
||||
title="Wideo (konfiguracja instancji)"
|
||||
description="Dostawca wideo i jego klucze. Ustawia się raz, przy wdrożeniu. Osoba zakładająca webinar tego nie dotyka - wybiera tylko gotowego dostawcę z listy."
|
||||
settings={settings}
|
||||
keys={KEYS}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
intro={<VideoInstructions />}
|
||||
/>
|
||||
)}
|
||||
</SettingsPane>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Instrukcja techniczna, ktora WCZESNIEJ byla w oknie "?" przy webinarze.
|
||||
* Jej miejsce jest tu: to jednorazowa konfiguracja infrastruktury.
|
||||
*/
|
||||
function VideoInstructions() {
|
||||
return (
|
||||
<details className="rounded-md border border-slate-200 bg-slate-25 p-4 text-sm">
|
||||
<summary className="cursor-pointer font-semibold text-slate-800">
|
||||
Jak skonfigurować dostawcę wideo (instrukcja dla administratora)
|
||||
</summary>
|
||||
<div className="mt-3 space-y-4 text-slate-600">
|
||||
<p>
|
||||
Wybierz jednego dostawcę w polu <strong>VIDEO_PROVIDER</strong> i uzupełnij jego klucze.
|
||||
Pozostali dostawcy mogą zostać puści. Po zapisaniu konfiguracji osoba zakładająca
|
||||
webinar wybiera dostawcę z gotowej listy i klika „Utwórz pokój" - bez żadnych kroków
|
||||
technicznych.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold text-slate-800">LiveKit (zalecany)</p>
|
||||
<p>
|
||||
Serwer wideo uruchamiany na własnej infrastrukturze. Uprawnienia egzekwuje serwer na
|
||||
podstawie podpisanego tokenu, nagrywanie działa automatycznie.
|
||||
</p>
|
||||
<ol className="mt-1 list-decimal space-y-1 pl-5">
|
||||
<li>Uruchom serwer: profil Docker Compose „livekit" (<code>docker compose --profile livekit up -d</code>). Nagrywanie wymaga usług Redis i Egress z tego profilu.</li>
|
||||
<li>Wpisz parę klucz-sekret w <strong>LIVEKIT_API_KEY</strong> i <strong>LIVEKIT_API_SECRET</strong>.</li>
|
||||
<li>Podaj adres WebSocket serwera w <strong>LIVEKIT_URL</strong> (np. <code>wss://twoj-serwer</code>; lokalnie <code>ws://localhost:7880</code>).</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold text-slate-800">Zoom</p>
|
||||
<p>Obraz osadzony w oknie webinaru. Wymaga płatnego konta Zoom (min. Pro) i dwóch aplikacji na koncie dewelopera.</p>
|
||||
<ol className="mt-1 list-decimal space-y-1 pl-5">
|
||||
<li>Na marketplace.zoom.us utwórz aplikację <strong>Server-to-Server OAuth</strong> - da Account ID, Client ID, Client Secret. Dodaj zakresy: meeting:write, meeting:read, recording:read, recording:write.</li>
|
||||
<li>Wpisz je w <strong>ZOOM_ACCOUNT_ID</strong>, <strong>ZOOM_CLIENT_ID</strong>, <strong>ZOOM_CLIENT_SECRET</strong>.</li>
|
||||
<li>Utwórz drugą aplikację <strong>Meeting SDK</strong> - da SDK Key i SDK Secret. Wpisz w <strong>ZOOM_SDK_KEY</strong> i <strong>ZOOM_SDK_SECRET</strong>.</li>
|
||||
<li>W Event Subscriptions dodaj adres <code>/api/webhooks/video/zoom</code> i zdarzenia: Recording Completed, Participant joined/left. Zoom poda Secret Token - wpisz go w <strong>VIDEO_WEBHOOK_SECRET</strong>.</li>
|
||||
</ol>
|
||||
<p className="mt-1">
|
||||
Uwaga: Zoom nie egzekwuje uprawnień do obrazu i dźwięku per uczestnik - steruje nimi na
|
||||
poziomie całego spotkania (jak Jitsi, nie jak LiveKit).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold text-slate-800">Jitsi Meet</p>
|
||||
<p>
|
||||
Publiczny <code>meet.jit.si</code> działa bez konta (<strong>JITSI_DOMAIN</strong>), ale pokój jest wtedy
|
||||
otwarty dla każdego, kto zna adres. Własny serwer z modułem JWT pozwala włączyć
|
||||
<strong> JITSI_JWT_ENABLED</strong> i wpisać <strong>JITSI_APP_ID</strong> oraz <strong>JITSI_APP_SECRET</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold text-slate-800">Bez wbudowanego wideo (NONE)</p>
|
||||
<p>
|
||||
Ustaw <strong>VIDEO_PROVIDER</strong> na NONE. Obraz idzie zewnętrznym narzędziem - link
|
||||
wkleja się w formularzu każdego webinaru. Czat, pytania i zapytania ofertowe zostają
|
||||
na tej platformie.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Setting } from '../../api/types'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { PUBLIC_CONFIG_KEY } from '../../lib/usePublicConfig'
|
||||
|
||||
/**
|
||||
* Odczyt i zapis ustawien, wspolny dla wszystkich podstron.
|
||||
*
|
||||
* Kazda podstrona pobiera pelna liste ustawien i pokazuje wlasny wycinek.
|
||||
* TanStack Query trzyma jedno zapytanie pod kluczem `settings`, wiec
|
||||
* przechodzenie miedzy zakladkami nie odpytuje serwera od nowa.
|
||||
*/
|
||||
export function useSettings() {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Setting[]>('/admin/settings')
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (values: Record<string, string>) => client.put('/admin/settings', values),
|
||||
onSuccess: () => {
|
||||
toasts.success('Ustawienia zostały zapisane')
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
// jawna konfiguracja jest buforowana na czas sesji, wiec po zapisie
|
||||
// trzeba ja uniewaznic recznie, inaczej naglowek pokazywalby stara marke
|
||||
queryClient.invalidateQueries({ queryKey: PUBLIC_CONFIG_KEY })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać ustawień')),
|
||||
})
|
||||
|
||||
return { query, save }
|
||||
}
|
||||
|
||||
export { errorMessage }
|
||||
@@ -0,0 +1,401 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage, fieldErrors } from '../../api/client'
|
||||
import type { Page, User, UserRole } from '../../api/types'
|
||||
import { useAuth } from '../../auth/AuthContext'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Badge, Button, Card, ErrorState, Field, IconButton, Input, PasswordInput, Select, Spinner } from '../../components/ui'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { ROLE_LABEL } from '../../lib/labels'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||
|
||||
type FormState = {
|
||||
email: string
|
||||
fullName: string
|
||||
role: UserRole
|
||||
active: boolean
|
||||
password: string
|
||||
}
|
||||
|
||||
const EMPTY: FormState = { email: '', fullName: '', role: 'MODERATOR', active: true, password: '' }
|
||||
|
||||
export function UserListPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const { user: currentUser } = useAuth()
|
||||
|
||||
const [editing, setEditing] = useState<User | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [form, setForm] = useState<FormState>(EMPTY)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
/** Haslo po resecie, pokazywane jeden raz. */
|
||||
const [resetPassword, setResetPassword] = useState<{ email: string; password: string } | null>(null)
|
||||
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['users', page],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Page<User>>('/admin/users', {
|
||||
params: { page, size: PAGE_SIZE },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
function closeForm() {
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
setForm(EMPTY)
|
||||
setErrors({})
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (payload: FormState) => {
|
||||
const body = {
|
||||
email: payload.email.trim(),
|
||||
fullName: payload.fullName.trim(),
|
||||
role: payload.role,
|
||||
active: payload.active,
|
||||
// przy edycji puste haslo oznacza brak zmiany
|
||||
password: payload.password.trim() || undefined,
|
||||
}
|
||||
if (editing) {
|
||||
return client.put(`/admin/users/${editing.id}`, body)
|
||||
}
|
||||
return client.post('/admin/users', body)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toasts.success(editing ? 'Konto zostało zaktualizowane' : 'Konto zostało utworzone')
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
closeForm()
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrors(fieldErrors(error))
|
||||
toasts.error(errorMessage(error, 'Nie udało się zapisać konta'))
|
||||
},
|
||||
})
|
||||
|
||||
const deactivate = useMutation({
|
||||
mutationFn: (id: number) => client.post(`/admin/users/${id}/deactivate`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Konto zostało dezaktywowane')
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się dezaktywować konta')),
|
||||
})
|
||||
|
||||
const reset = useMutation({
|
||||
mutationFn: async (user: User) => {
|
||||
const response = await client.post<{ password: string; message: string }>(
|
||||
`/admin/users/${user.id}/reset-password`,
|
||||
)
|
||||
return { email: user.email, password: response.data.password }
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setResetPassword(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zresetować hasła')),
|
||||
})
|
||||
|
||||
function startEdit(user: User) {
|
||||
setEditing(user)
|
||||
setCreating(false)
|
||||
setErrors({})
|
||||
setForm({ email: user.email, fullName: user.fullName, role: user.role, active: user.active, password: '' })
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
const found: Record<string, string> = {}
|
||||
if (!form.email.trim()) found.email = 'Adres e-mail jest wymagany'
|
||||
if (!form.fullName.trim()) found.fullName = 'Imię i nazwisko jest wymagane'
|
||||
if (!editing && form.password.trim().length < 10) {
|
||||
found.password = 'Hasło musi mieć co najmniej 10 znaków'
|
||||
}
|
||||
if (editing && form.password.trim() && form.password.trim().length < 10) {
|
||||
found.password = 'Hasło musi mieć co najmniej 10 znaków'
|
||||
}
|
||||
setErrors(found)
|
||||
if (Object.keys(found).length > 0) return
|
||||
save.mutate(form)
|
||||
}
|
||||
|
||||
if (query.isPending) return <Spinner />
|
||||
if (query.isError) {
|
||||
return <ErrorState message={errorMessage(query.error, 'Nie udało się wczytać listy kont')} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Użytkownicy</h1>
|
||||
<p className="text-sm text-slate-500">Konta administracyjne panelu</p>
|
||||
</div>
|
||||
<Button
|
||||
icon="user-plus"
|
||||
onClick={() => {
|
||||
setCreating(true)
|
||||
setEditing(null)
|
||||
setForm(EMPTY)
|
||||
setErrors({})
|
||||
}}
|
||||
>
|
||||
Nowe konto
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{resetPassword && (
|
||||
<div className="rounded-lg border border-warn-500/25 bg-warn-50 p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon name="key" className="text-warn-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-warn-600">
|
||||
Nowe hasło dla {resetPassword.email}
|
||||
</p>
|
||||
<p className="mt-1 break-all rounded bg-white px-2 py-1 font-mono text-sm text-slate-900">
|
||||
{resetPassword.password}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-warn-600">
|
||||
Hasło jest pokazane tylko raz. Przekaż je użytkownikowi, będzie musiał je zmienić.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResetPassword(null)}
|
||||
className="-m-1 rounded p-1 text-warn-600 transition duration-fast ease-standard hover:text-warn-500"
|
||||
aria-label="Zamknij"
|
||||
>
|
||||
<Icon name="x" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(creating || editing) && (
|
||||
<Card title={editing ? `Edycja konta: ${editing.email}` : 'Nowe konto'}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Adres e-mail" error={errors.email}>
|
||||
<Input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm((c) => ({ ...c, email: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Imię i nazwisko" error={errors.fullName}>
|
||||
<Input
|
||||
value={form.fullName}
|
||||
onChange={(e) => setForm((c) => ({ ...c, fullName: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Rola" error={errors.role}>
|
||||
<Select
|
||||
value={form.role}
|
||||
onChange={(e) => setForm((c) => ({ ...c, role: e.target.value as UserRole }))}
|
||||
>
|
||||
{(Object.keys(ROLE_LABEL) as UserRole[]).map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{ROLE_LABEL[role]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field
|
||||
label={editing ? 'Nowe hasło' : 'Hasło'}
|
||||
error={errors.password}
|
||||
hint={editing ? 'Puste oznacza brak zmiany' : 'Minimum 10 znaków'}
|
||||
>
|
||||
<PasswordInput
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((c) => ({ ...c, password: e.target.value }))}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.active}
|
||||
onChange={(e) => setForm((c) => ({ ...c, active: e.target.checked }))}
|
||||
className="h-4 w-4 rounded border-slate-200 text-blue-700 focus:ring-blue-600"
|
||||
/>
|
||||
<span className="font-medium text-slate-700">Konto aktywne</span>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" type="button" onClick={closeForm}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button type="submit" icon="save" disabled={save.isPending}>
|
||||
{save.isPending ? 'Zapisywanie...' : 'Zapisz'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
|
||||
{/* na telefonie karty, tabela dopiero od duzego ekranu */}
|
||||
<ul className="divide-y divide-slate-100 lg:hidden">
|
||||
{query.data.content.map((user) => {
|
||||
const isSelf = user.id === currentUser?.id
|
||||
return (
|
||||
<li key={user.id} className="space-y-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-900">
|
||||
{user.fullName}
|
||||
{isSelf && <span className="ml-1.5 text-xs text-slate-400">(to Ty)</span>}
|
||||
</p>
|
||||
<p className="truncate text-sm text-slate-500">{user.email}</p>
|
||||
</div>
|
||||
<Badge tone={user.role === 'ADMIN' ? 'brand' : 'neutral'}>
|
||||
{ROLE_LABEL[user.role]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{user.active ? (
|
||||
<Badge tone="ok" dot>Aktywne</Badge>
|
||||
) : (
|
||||
<Badge tone="alarm">Nieaktywne</Badge>
|
||||
)}
|
||||
{user.mustChangePassword && <Badge tone="warn">wymaga zmiany hasła</Badge>}
|
||||
<div className="ml-auto flex gap-1">
|
||||
<IconButton icon="pencil" label="Edytuj" onClick={() => startEdit(user)} />
|
||||
<IconButton
|
||||
icon="key"
|
||||
label="Resetuj hasło"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Zresetować hasło konta ${user.email}?`)) reset.mutate(user)
|
||||
}}
|
||||
disabled={reset.isPending}
|
||||
/>
|
||||
{user.active && !isSelf && (
|
||||
<IconButton
|
||||
icon="user-off"
|
||||
label="Dezaktywuj"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Dezaktywować konto ${user.email}?`)) deactivate.mutate(user.id)
|
||||
}}
|
||||
disabled={deactivate.isPending}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
<th className="px-4 py-3 font-semibold">Konto</th>
|
||||
<th className="px-4 py-3 font-semibold">Rola</th>
|
||||
<th className="px-4 py-3 font-semibold">Stan</th>
|
||||
<th className="px-4 py-3 font-semibold">Ostatnie logowanie</th>
|
||||
<th className="px-4 py-3 text-right font-semibold">Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{query.data.content.map((user) => {
|
||||
const isSelf = user.id === currentUser?.id
|
||||
return (
|
||||
<tr key={user.id} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-slate-900">
|
||||
{user.fullName}
|
||||
{isSelf && <span className="ml-1.5 text-xs text-slate-400">(to Ty)</span>}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">{user.email}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
className={
|
||||
user.role === 'ADMIN'
|
||||
? 'bg-blue-50 text-blue-700 ring-blue-200'
|
||||
: 'bg-slate-100 text-slate-700 ring-slate-200'
|
||||
}
|
||||
>
|
||||
{ROLE_LABEL[user.role]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{user.active ? (
|
||||
<Badge className="bg-ok-50 text-ok-600 ring-ok-500/20">Aktywne</Badge>
|
||||
) : (
|
||||
<Badge className="bg-alarm-50 text-alarm-600 ring-alarm-500/20">Nieaktywne</Badge>
|
||||
)}
|
||||
{user.mustChangePassword && (
|
||||
<p className="mt-1 text-xs text-warn-600">wymaga zmiany hasła</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-slate-600">
|
||||
{formatLocal(user.lastLoginAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" icon="pencil" onClick={() => startEdit(user)} title="Edytuj">
|
||||
<span className="sr-only">Edytuj</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="key"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Zresetować hasło konta ${user.email}?`)) {
|
||||
reset.mutate(user)
|
||||
}
|
||||
}}
|
||||
disabled={reset.isPending}
|
||||
title="Resetuj hasło"
|
||||
>
|
||||
<span className="sr-only">Resetuj hasło</span>
|
||||
</Button>
|
||||
{user.active && !isSelf && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="user-off"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Dezaktywować konto ${user.email}?`)) {
|
||||
deactivate.mutate(user.id)
|
||||
}
|
||||
}}
|
||||
disabled={deactivate.isPending}
|
||||
title="Dezaktywuj"
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
>
|
||||
<span className="sr-only">Dezaktywuj</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{query.data && (
|
||||
<Pagination
|
||||
page={query.data.number}
|
||||
totalPages={query.data.totalPages}
|
||||
totalElements={query.data.totalElements}
|
||||
onChange={setPage}
|
||||
itemLabel="kont"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Certificate, CertificateBulkResult, Page, Registration, Webinar } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Alert, Badge, Button, Card, EmptyState, ErrorState, Field, Input, Spinner } from '../../components/ui'
|
||||
import { usePublicUrl } from '../../lib/usePublicConfig'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
|
||||
/**
|
||||
* Certyfikaty uczestnictwa.
|
||||
*
|
||||
* Wydanie jest **nieodwracalne w praktyce**: dokument moze juz trafic do
|
||||
* teczki uczestnika, wiec uniewaznienie zostawia slad zamiast kasowac wpis.
|
||||
* Panel mowi to wprost przy kazdej akcji.
|
||||
*/
|
||||
export function CertificateSection({ webinar }: { webinar: Webinar }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const publicUrl = usePublicUrl()
|
||||
|
||||
const [enabled, setEnabled] = useState(webinar.certificatesEnabled)
|
||||
const [hours, setHours] = useState(
|
||||
webinar.certificateHours === null ? '' : String(webinar.certificateHours),
|
||||
)
|
||||
|
||||
const certificates = useQuery({
|
||||
queryKey: ['certificates', webinar.id],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Certificate[]>(`/admin/webinars/${webinar.id}/certificates`)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
/* uczestnicy: potrzebni do wydania pojedynczego i do pokazania, kto go nie ma */
|
||||
const registrations = useQuery({
|
||||
queryKey: ['registrations', String(webinar.id), '', 0],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Page<Registration>>(
|
||||
`/admin/webinars/${webinar.id}/registrations`,
|
||||
{ params: { page: 0, size: 100 } },
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
const saveSettings = useMutation({
|
||||
mutationFn: () =>
|
||||
client.put(`/admin/webinars/${webinar.id}`, {
|
||||
title: webinar.title,
|
||||
slug: webinar.slug,
|
||||
description: webinar.description,
|
||||
contentHtml: webinar.contentHtml,
|
||||
startsAt: webinar.startsAt,
|
||||
endsAt: webinar.endsAt,
|
||||
timezone: webinar.timezone,
|
||||
registrationOpen: webinar.registrationOpen,
|
||||
registrationDeadline: webinar.registrationDeadline,
|
||||
capacity: webinar.capacity,
|
||||
joinUrl: webinar.joinUrl,
|
||||
recordingUrl: webinar.recordingUrl,
|
||||
materialsUrl: webinar.materialsUrl,
|
||||
certificatesEnabled: enabled,
|
||||
certificateHours: hours.trim() === '' ? null : Number(hours.replace(',', '.')),
|
||||
certificateTemplateHtml: webinar.certificateTemplateHtml,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toasts.success('Ustawienia certyfikatów zostały zapisane')
|
||||
queryClient.invalidateQueries({ queryKey: ['webinar', String(webinar.id)] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać ustawień')),
|
||||
})
|
||||
|
||||
const issueAll = useMutation({
|
||||
mutationFn: (force: boolean) =>
|
||||
client.post<CertificateBulkResult>(
|
||||
`/admin/webinars/${webinar.id}/certificates/all`,
|
||||
null,
|
||||
{ params: { force } },
|
||||
),
|
||||
onSuccess: (response) => {
|
||||
const { issued, skipped, problems } = response.data
|
||||
const extra = skipped > 0 ? `, pominięto ${skipped}` : ''
|
||||
toasts.success(`Wydano ${issued} certyfikatów${extra}`)
|
||||
problems.forEach((problem) => toasts.error(problem))
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates', webinar.id] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wydać certyfikatów')),
|
||||
})
|
||||
|
||||
const issueOne = useMutation({
|
||||
mutationFn: (payload: { registrationId: number; force: boolean }) =>
|
||||
client.post<Certificate>(`/admin/webinars/${webinar.id}/certificates`, payload),
|
||||
onSuccess: () => {
|
||||
toasts.success('Certyfikat został wydany')
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates', webinar.id] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wydać certyfikatu')),
|
||||
})
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: (certificateId: number) =>
|
||||
client.post(`/admin/certificates/${certificateId}/revoke`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Certyfikat został unieważniony')
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates', webinar.id] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się unieważnić certyfikatu')),
|
||||
})
|
||||
|
||||
const issued = certificates.data ?? []
|
||||
const withCertificate = new Set(issued.map((c) => c.registrationId))
|
||||
const pending = (registrations.data?.content ?? []).filter(
|
||||
(r) => r.status === 'CONFIRMED' && !withCertificate.has(r.id),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card title="Ustawienia certyfikatów">
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-start gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 rounded border-slate-300"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-slate-700">
|
||||
Wydawaj certyfikaty uczestnictwa
|
||||
<span className="mt-0.5 block text-xs text-slate-500">
|
||||
Po zakończeniu webinaru certyfikaty powstaną automatycznie dla uczestników
|
||||
z odnotowaną obecnością, a link trafi do maila podsumowującego.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Field
|
||||
label="Liczba godzin szkoleniowych"
|
||||
hint="Puste oznacza, że certyfikat nie podaje liczby godzin"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.5"
|
||||
className="max-w-[12rem]"
|
||||
value={hours}
|
||||
placeholder="np. 3"
|
||||
onChange={(e) => setHours(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button icon="save" disabled={saveSettings.isPending} onClick={() => saveSettings.mutate()}>
|
||||
{saveSettings.isPending ? 'Zapisywanie...' : 'Zapisz ustawienia'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Wydawanie"
|
||||
description="Certyfikat powstaje raz i nie zmienia się po wydaniu"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{pending.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
Wszyscy potwierdzeni uczestnicy mają już certyfikat.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-slate-700">
|
||||
Bez certyfikatu:{' '}
|
||||
<span className="hx-data font-semibold">{pending.length}</span>{' '}
|
||||
{pending.length === 1 ? 'uczestnik' : 'uczestników'}, w tym{' '}
|
||||
<span className="hx-data font-semibold">
|
||||
{pending.filter((r) => !r.attended).length}
|
||||
</span>{' '}
|
||||
bez odnotowanej obecności.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
icon="check-circle"
|
||||
disabled={issueAll.isPending}
|
||||
onClick={() => {
|
||||
if (confirm('Wydać certyfikaty wszystkim uczestnikom z odnotowaną obecnością?')) {
|
||||
issueAll.mutate(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Wydaj obecnym
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="alert-triangle"
|
||||
disabled={issueAll.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
'Wydać certyfikaty WSZYSTKIM potwierdzonym uczestnikom, także tym bez ' +
|
||||
'odnotowanej obecności? Tej operacji nie da się cofnąć, można jedynie ' +
|
||||
'unieważnić pojedyncze certyfikaty.',
|
||||
)
|
||||
) {
|
||||
issueAll.mutate(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Wydaj wszystkim, także nieobecnym
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{pending.map((registration) => (
|
||||
<li
|
||||
key={registration.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 py-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-900">
|
||||
{registration.firstName} {registration.lastName}
|
||||
</p>
|
||||
<p className="hx-data break-all text-xs text-slate-500">
|
||||
{registration.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!registration.attended && (
|
||||
<Badge tone="warn">Brak obecności</Badge>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="file-text"
|
||||
disabled={issueOne.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
registration.attended ||
|
||||
confirm(
|
||||
`${registration.firstName} ${registration.lastName} nie ma odnotowanej ` +
|
||||
'obecności. Wydać certyfikat mimo to?',
|
||||
)
|
||||
) {
|
||||
issueOne.mutate({
|
||||
registrationId: registration.id,
|
||||
force: !registration.attended,
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Wydaj
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Wydane certyfikaty">
|
||||
{certificates.isPending && <Spinner />}
|
||||
{certificates.isError && (
|
||||
<ErrorState
|
||||
message={errorMessage(certificates.error, 'Nie udało się wczytać certyfikatów')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{issued.length === 0 && !certificates.isPending && (
|
||||
<EmptyState
|
||||
icon="file-text"
|
||||
title="Brak certyfikatów"
|
||||
description="Nikt jeszcze nie dostał certyfikatu z tego webinaru."
|
||||
/>
|
||||
)}
|
||||
|
||||
{issued.length > 0 && (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{issued.map((certificate) => (
|
||||
<li
|
||||
key={certificate.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 py-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-900">{certificate.participantName}</p>
|
||||
<p className="hx-data text-xs text-slate-500">
|
||||
{certificate.number} · {formatLocal(certificate.issuedAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{certificate.valid ? (
|
||||
<Badge tone="ok">Ważny</Badge>
|
||||
) : (
|
||||
<Badge tone="alarm">Unieważniony</Badge>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={publicUrl + certificate.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex min-h-[36px] items-center gap-1.5 rounded-md border border-slate-200 px-3 text-sm font-medium text-slate-700 transition-colors duration-fast ease-standard hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="download" size={16} />
|
||||
Pobierz
|
||||
</a>
|
||||
|
||||
{certificate.valid && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="x-circle"
|
||||
disabled={revoke.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
`Unieważnić certyfikat ${certificate.number}? Plik zostanie, ale ` +
|
||||
'weryfikacja publiczna pokaże go jako nieważny.',
|
||||
)
|
||||
) {
|
||||
revoke.mutate(certificate.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Unieważnij
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Alert tone="info">
|
||||
Certyfikat można sprawdzić bez logowania pod adresem{' '}
|
||||
<span className="hx-data">{publicUrl}/certyfikat/NUMER</span>. Weryfikacja pokazuje tylko
|
||||
numer, tytuł, datę, imię z inicjałem i informację o ważności.
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Material, Recording, Visibility } from '../../api/types'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Badge, Button, Card, EmptyState, ErrorState, Select, Spinner } from '../../components/ui'
|
||||
import { usePublicUrl } from '../../lib/usePublicConfig'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
|
||||
/**
|
||||
* Nagrania i materialy webinaru w panelu.
|
||||
*
|
||||
* Pliki lezą we wlasnym skladzie (MinIO) i sa serwowane przez backend, wiec
|
||||
* kazde pobranie przechodzi przez kontrole widocznosci. Adres kopiowany
|
||||
* przyciskiem jest pelny, zeby dalo sie go wkleic w maila albo na strone.
|
||||
*/
|
||||
|
||||
const VISIBILITY_LABEL: Record<Visibility, string> = {
|
||||
PRIVATE: 'Tylko panel',
|
||||
REGISTERED: 'Uczestnicy webinaru',
|
||||
PUBLIC: 'Każdy z linkiem',
|
||||
}
|
||||
|
||||
const VISIBILITY_HINT: Record<Visibility, string> = {
|
||||
PRIVATE: 'Widoczne wyłącznie dla zalogowanych do panelu.',
|
||||
REGISTERED: 'Widoczne dla uczestników z potwierdzonym zapisem na ten webinar.',
|
||||
PUBLIC: 'Widoczne dla każdego, kto zna adres. Nagranie zawiera wypowiedzi uczestników.',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
PENDING: 'Oczekuje na pobranie',
|
||||
DOWNLOADING: 'Pobieranie w toku',
|
||||
READY: 'Gotowe',
|
||||
FAILED: 'Błąd pobierania',
|
||||
}
|
||||
|
||||
export function FilesSection({ webinarId }: { webinarId: number }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<RecordingsCard webinarId={webinarId} />
|
||||
<MaterialsCard webinarId={webinarId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- nagrania ---
|
||||
|
||||
function RecordingsCard({ webinarId }: { webinarId: number }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['recordings', webinarId],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Recording[]>(`/admin/webinars/${webinarId}/recordings`)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
|
||||
setUploading(true)
|
||||
setProgress(0)
|
||||
try {
|
||||
await client.post(`/admin/webinars/${webinarId}/recordings`, form, {
|
||||
// nagranie idzie dlugo, wiec pokazujemy postep zamiast samego kręcioła
|
||||
onUploadProgress: (event) => {
|
||||
if (event.total) {
|
||||
setProgress(Math.round((event.loaded / event.total) * 100))
|
||||
}
|
||||
},
|
||||
timeout: 0,
|
||||
})
|
||||
toasts.success('Nagranie zostało wgrane')
|
||||
queryClient.invalidateQueries({ queryKey: ['recordings', webinarId] })
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się wgrać nagrania'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
setProgress(0)
|
||||
if (fileInput.current) fileInput.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Nagrania"
|
||||
description="Pliki w składzie instancji. Odtwarzane przez naszą stronę, bez linku do dostawcy."
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="video/mp4,video/webm,.mp4,.webm"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleUpload(file)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
icon="upload"
|
||||
disabled={uploading}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
{uploading ? `Wysyłanie... ${progress}%` : 'Wgraj nagranie'}
|
||||
</Button>
|
||||
<p className="mt-1.5 text-xs text-slate-500">
|
||||
Format MP4 albo WEBM. Typ pliku jest sprawdzany po zawartości, nie po nazwie.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all duration-fast ease-standard"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query.isPending && <Spinner />}
|
||||
{query.isError && (
|
||||
<ErrorState message={errorMessage(query.error, 'Nie udało się wczytać nagrań')} />
|
||||
)}
|
||||
|
||||
{query.data && query.data.length === 0 && (
|
||||
<EmptyState
|
||||
icon="video"
|
||||
title="Brak nagrań"
|
||||
description="Wgraj plik albo poczekaj, aż dostawca zgłosi gotowe nagranie."
|
||||
/>
|
||||
)}
|
||||
|
||||
{query.data && query.data.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{query.data.map((recording) => (
|
||||
<RecordingRow key={recording.id} recording={recording} webinarId={webinarId} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RecordingRow({ recording, webinarId }: { recording: Recording; webinarId: number }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const publicUrl = usePublicUrl()
|
||||
|
||||
const changeVisibility = useMutation({
|
||||
mutationFn: (visibility: Visibility) =>
|
||||
client.put(`/admin/recordings/${recording.id}/visibility`, { visibility }),
|
||||
onSuccess: () => {
|
||||
toasts.success('Widoczność została zmieniona')
|
||||
queryClient.invalidateQueries({ queryKey: ['recordings', webinarId] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić widoczności')),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => client.delete(`/admin/recordings/${recording.id}`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Nagranie zostało usunięte')
|
||||
queryClient.invalidateQueries({ queryKey: ['recordings', webinarId] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć nagrania')),
|
||||
})
|
||||
|
||||
const ready = recording.status === 'READY'
|
||||
|
||||
return (
|
||||
<li className="rounded-md border border-slate-200 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-900">{recording.title ?? 'Nagranie webinaru'}</p>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span className="hx-data">{formatLocal(recording.createdAt)}</span>
|
||||
{recording.sizeBytes !== null && (
|
||||
<span className="hx-data">{formatSize(recording.sizeBytes)}</span>
|
||||
)}
|
||||
{recording.durationSeconds !== null && (
|
||||
<span className="hx-data">{formatDuration(recording.durationSeconds)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={ready ? 'ok' : recording.status === 'FAILED' ? 'alarm' : 'warn'}>
|
||||
{STATUS_LABEL[recording.status] ?? recording.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{recording.error && (
|
||||
<p className="mt-2 rounded-md bg-alarm-50 p-2 text-xs text-alarm-600">{recording.error}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-end gap-2">
|
||||
<div className="w-full sm:w-56">
|
||||
<label className="mb-1 block text-xs font-semibold text-slate-700">Widoczność</label>
|
||||
<Select
|
||||
value={recording.visibility}
|
||||
disabled={changeVisibility.isPending}
|
||||
onChange={(e) => changeVisibility.mutate(e.target.value as Visibility)}
|
||||
>
|
||||
{(Object.keys(VISIBILITY_LABEL) as Visibility[]).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{VISIBILITY_LABEL[value]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{ready && recording.url && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="copy"
|
||||
onClick={() => copyLink(publicUrl + recording.url, toasts)}
|
||||
>
|
||||
Kopiuj link
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
const name = recording.title ?? 'Nagranie webinaru'
|
||||
if (confirm(`Usunąć nagranie "${name}" wraz z plikiem?
|
||||
|
||||
Tej operacji nie da się cofnąć.`)) {
|
||||
remove.mutate()
|
||||
}
|
||||
}}
|
||||
>
|
||||
Usuń
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-1.5 text-xs text-slate-500">{VISIBILITY_HINT[recording.visibility]}</p>
|
||||
|
||||
{ready && recording.url && (
|
||||
<video
|
||||
className="mt-3 w-full rounded-md bg-slate-900"
|
||||
controls
|
||||
preload="metadata"
|
||||
src={recording.url}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// --- materialy ---
|
||||
|
||||
function MaterialsCard({ webinarId }: { webinarId: number }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['materials', webinarId],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Material[]>(`/admin/webinars/${webinarId}/materials`)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
await client.post(`/admin/webinars/${webinarId}/materials`, form, { timeout: 0 })
|
||||
toasts.success('Materiał został wgrany')
|
||||
queryClient.invalidateQueries({ queryKey: ['materials', webinarId] })
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się wgrać materiału'))
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (fileInput.current) fileInput.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Materiały" description="Prezentacja, arkusze i inne pliki do pobrania">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleUpload(file)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
icon="upload"
|
||||
disabled={uploading}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
{uploading ? 'Wysyłanie...' : 'Wgraj materiał'}
|
||||
</Button>
|
||||
<p className="mt-1.5 text-xs text-slate-500">
|
||||
Dowolny format poza plikami wykonywalnymi. Lista zablokowanych rozszerzeń jest
|
||||
w ustawieniach instancji.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{query.isPending && <Spinner />}
|
||||
{query.isError && (
|
||||
<ErrorState message={errorMessage(query.error, 'Nie udało się wczytać materiałów')} />
|
||||
)}
|
||||
|
||||
{query.data && query.data.length === 0 && (
|
||||
<EmptyState
|
||||
icon="file-text"
|
||||
title="Brak materiałów"
|
||||
description="Wgraj prezentację albo inne pliki dla uczestników."
|
||||
/>
|
||||
)}
|
||||
|
||||
{query.data && query.data.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{query.data.map((material) => (
|
||||
<MaterialRow key={material.id} material={material} webinarId={webinarId} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function MaterialRow({ material, webinarId }: { material: Material; webinarId: number }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const publicUrl = usePublicUrl()
|
||||
|
||||
const changeVisibility = useMutation({
|
||||
mutationFn: (visibility: Visibility) =>
|
||||
client.put(`/admin/materials/${material.id}/visibility`, { visibility }),
|
||||
onSuccess: () => {
|
||||
toasts.success('Widoczność została zmieniona')
|
||||
queryClient.invalidateQueries({ queryKey: ['materials', webinarId] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić widoczności')),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => client.delete(`/admin/materials/${material.id}`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Materiał został usunięty')
|
||||
queryClient.invalidateQueries({ queryKey: ['materials', webinarId] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć materiału')),
|
||||
})
|
||||
|
||||
return (
|
||||
<li className="rounded-md border border-slate-200 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-900">{material.title}</p>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<span className="hx-data break-all">{material.originalName}</span>
|
||||
<span className="hx-data">{formatSize(material.sizeBytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={material.visibility === 'PRIVATE' ? 'neutral' : 'ok'}>
|
||||
{VISIBILITY_LABEL[material.visibility]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-end gap-2">
|
||||
<div className="w-full sm:w-56">
|
||||
<label className="mb-1 block text-xs font-semibold text-slate-700">Widoczność</label>
|
||||
<Select
|
||||
value={material.visibility}
|
||||
disabled={changeVisibility.isPending}
|
||||
onChange={(e) => changeVisibility.mutate(e.target.value as Visibility)}
|
||||
>
|
||||
{(Object.keys(VISIBILITY_LABEL) as Visibility[]).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{VISIBILITY_LABEL[value]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="copy"
|
||||
onClick={() => copyLink(publicUrl + material.url, toasts)}
|
||||
>
|
||||
Kopiuj link
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(`Usunąć materiał "${material.title}" wraz z plikiem?
|
||||
|
||||
Tej operacji nie da się cofnąć.`)) {
|
||||
remove.mutate()
|
||||
}
|
||||
}}
|
||||
>
|
||||
Usuń
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// --- pomocnicze ---
|
||||
|
||||
async function copyLink(url: string, toasts: ReturnType<typeof useToasts>) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toasts.success('Link został skopiowany')
|
||||
} catch {
|
||||
// przegladarka moze odmowic dostepu do schowka bez HTTPS
|
||||
toasts.error('Nie udało się skopiować. Zaznacz adres i skopiuj ręcznie.')
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} kB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / 1024 / 1024)} MB`
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (hours > 0) return `${hours} godz. ${minutes} min`
|
||||
return `${minutes} min`
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Client, type IMessage } from '@stomp/stompjs'
|
||||
import { client, errorMessage, getToken } from '../../api/client'
|
||||
import type { Webinar } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Badge, Button, ErrorState, Input, Spinner, Textarea } from '../../components/ui'
|
||||
import type { InteractionView, ModeratorInteractionView } from '../../lib/useInteractions'
|
||||
import { SpeakQueue, type SpeakRequestView } from './SpeakQueue'
|
||||
import { panelPath } from '../../lib/routes'
|
||||
|
||||
/**
|
||||
* Panel prowadzacego: czat na zywo i kolejka pytan.
|
||||
*
|
||||
* Aktualizacja idzie tym samym WebSocketem co u uczestnikow, **bez odpytywania
|
||||
* serwera co kilka sekund**: prowadzacy patrzy na ten ekran przez cala godzine,
|
||||
* wiec odpytywanie oznaczaloby setki zbednych zadan.
|
||||
*
|
||||
* Prowadzacy slucha osobnego tematu moderacji, na ktory ida takze pytania
|
||||
* jeszcze niezatwierdzone. Uczestnicy tego tematu nie widza.
|
||||
*/
|
||||
|
||||
type ModerationData = {
|
||||
chat: ModeratorInteractionView[]
|
||||
questions: ModeratorInteractionView[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Sygnal dzwiekowy przy nowym zapytaniu ofertowym.
|
||||
*
|
||||
* Skladany w WebAudio, a nie wczytywany z pliku: chodzi o jeden krotki ton,
|
||||
* a plik oznaczalby dodatkowy zasob do wgrania i podania sciezki. Dzwiek jest
|
||||
* krotki i cichy: ma zwrocic uwage prowadzacego w trakcie mowienia, a nie
|
||||
* przestraszyc jego i sale.
|
||||
*
|
||||
* Przegladarki blokuja dzwiek do pierwszej interakcji uzytkownika, wiec blad
|
||||
* jest tu spodziewany i pomijany. Prowadzacy i tak zdazy kliknac w panelu.
|
||||
*/
|
||||
function playChime() {
|
||||
try {
|
||||
const Ctx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
|
||||
const ctx = new Ctx()
|
||||
const oscillator = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
|
||||
oscillator.type = 'sine'
|
||||
oscillator.frequency.value = 880
|
||||
gain.gain.setValueAtTime(0.0001, ctx.currentTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.12, ctx.currentTime + 0.02)
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.35)
|
||||
|
||||
oscillator.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
oscillator.start()
|
||||
oscillator.stop(ctx.currentTime + 0.36)
|
||||
oscillator.onended = () => void ctx.close()
|
||||
} catch {
|
||||
// dzwiek jest udogodnieniem, nie warunkiem dzialania panelu
|
||||
}
|
||||
}
|
||||
|
||||
/** Klucz zapamietanego wyboru: prowadzacy ustawia to raz, nie na kazdym webinarze. */
|
||||
const SOUND_KEY = 'host-console-sound'
|
||||
|
||||
export function HostConsolePage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const toasts = useToasts()
|
||||
|
||||
const [chat, setChat] = useState<ModeratorInteractionView[]>([])
|
||||
const [questions, setQuestions] = useState<ModeratorInteractionView[]>([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
/** Ile pozycji przyszlo od ostatniego spojrzenia. Zerowane klikiem. */
|
||||
const [newChat, setNewChat] = useState(0)
|
||||
const [newQuestions, setNewQuestions] = useState(0)
|
||||
/** Zapytania ofertowe licza sie osobno: to kontakt handlowy, nie pytanie. */
|
||||
const [newOffers, setNewOffers] = useState(0)
|
||||
|
||||
/*
|
||||
* Sygnal dzwiekowy przy zapytaniu ofertowym, wylaczalny.
|
||||
*
|
||||
* Domyslnie wlaczony, bo zapytanie ofertowe jest tym zgloszeniem, ktorego
|
||||
* prowadzacy najbardziej nie chce przegapic, a w trakcie mowienia nie patrzy
|
||||
* na panel. Wybor zapamietujemy w przegladarce: ustawia sie go raz, a nie
|
||||
* przy kazdym webinarze.
|
||||
*/
|
||||
const [sound, setSound] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(SOUND_KEY) !== 'off'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
const soundRef = useRef(sound)
|
||||
soundRef.current = sound
|
||||
|
||||
function toggleSound() {
|
||||
setSound((on) => {
|
||||
const next = !on
|
||||
try {
|
||||
window.localStorage.setItem(SOUND_KEY, next ? 'on' : 'off')
|
||||
} catch {
|
||||
// brak pamieci przegladarki nie moze zepsuc panelu
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/** Kolejka zgloszen do glosu. */
|
||||
const [speakRequests, setSpeakRequests] = useState<SpeakRequestView[]>([])
|
||||
/** Tryb otwartego mikrofonu, wczytywany razem z danymi webinaru. */
|
||||
const [openMic, setOpenMic] = useState(false)
|
||||
/** Tryb "wideo dla wszystkich", wczytywany razem z danymi webinaru. */
|
||||
const [openVideo, setOpenVideo] = useState(false)
|
||||
|
||||
const loadSpeakQueue = useCallback(async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
const response = await client.get<SpeakRequestView[]>(
|
||||
`/admin/webinars/${id}/speak-requests`,
|
||||
)
|
||||
setSpeakRequests(response.data)
|
||||
} catch {
|
||||
// kolejka jest dodatkiem do panelu: jej brak nie moze zatrzymac moderacji
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
void loadSpeakQueue()
|
||||
}, [loadSpeakQueue])
|
||||
|
||||
const webinar = useQuery({
|
||||
queryKey: ['webinar', id],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Webinar>(`/admin/webinars/${id}`)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
const initial = useQuery({
|
||||
queryKey: ['moderation', id],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<ModerationData>(`/admin/webinars/${id}/interactions`)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!initial.data) return
|
||||
setChat(initial.data.chat)
|
||||
setQuestions(initial.data.questions)
|
||||
}, [initial.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (webinar.data) {
|
||||
setOpenMic(webinar.data.openMic ?? false)
|
||||
setOpenVideo(webinar.data.openVideo ?? false)
|
||||
}
|
||||
}, [webinar.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const stomp = new Client({
|
||||
brokerURL: `${protocol}//${window.location.host}/ws`,
|
||||
// prowadzacy nie ma tokenu zapisu, bo nie jest uczestnikiem: przedstawia
|
||||
// sie tym samym JWT co przy zwyklych zadaniach panelu
|
||||
connectHeaders: { 'X-Panel-Token': getToken() ?? '' },
|
||||
reconnectDelay: 4000,
|
||||
onConnect: () => {
|
||||
setConnected(true)
|
||||
stomp.subscribe(`/topic/webinars/${id}/moderation`, (message: IMessage) => {
|
||||
const event = JSON.parse(message.body) as { kind: string; payload: InteractionView }
|
||||
apply(event)
|
||||
})
|
||||
|
||||
/*
|
||||
* Kolejka glosu odswieza sie ze zdarzenia, a nie z odpytywania serwera:
|
||||
* prowadzacy ma zobaczyc podniesiona reke od razu, a nie po minucie.
|
||||
*/
|
||||
stomp.subscribe(`/topic/webinars/${id}/speak`, () => {
|
||||
void loadSpeakQueue()
|
||||
})
|
||||
},
|
||||
onDisconnect: () => setConnected(false),
|
||||
onWebSocketClose: () => setConnected(false),
|
||||
})
|
||||
|
||||
function apply(event: { kind: string; payload: InteractionView }) {
|
||||
// zdarzenie WS niesie publiczny widok bez danych moderacji autora.
|
||||
// Uzupelniamy je do widoku moderatora wartosciami domyslnymi: przyciski
|
||||
// moderacji uzyja pelnych danych po najblizszym odswiezeniu listy (robimy
|
||||
// je po kazdej akcji mute/ban), a nowa wiadomosc i tak jest widoczna od razu.
|
||||
const item: ModeratorInteractionView = {
|
||||
...event.payload,
|
||||
authorRegistrationId: null,
|
||||
authorChatMuted: false,
|
||||
authorRoomBanned: false,
|
||||
}
|
||||
|
||||
// zapytanie ofertowe ma wlasny licznik i wlasna kolumne, zeby nie ginelo
|
||||
// w kolejce pytan: to jedyne zgloszenie, ktore jest kontaktem handlowym
|
||||
if (item.type === 'OFFER_REQUEST') {
|
||||
setNewOffers((n) => n + 1)
|
||||
// referencja, a nie stan: efekt WebSocketu widzi wartosc z chwili
|
||||
// podlaczenia, wiec przelacznik nie dzialalby bez zerwania polaczenia
|
||||
if (soundRef.current) playChime()
|
||||
return
|
||||
}
|
||||
|
||||
if (item.type === 'CHAT') {
|
||||
if (event.kind === 'REJECTED') {
|
||||
setChat((current) => current.filter((c) => c.id !== item.id))
|
||||
return
|
||||
}
|
||||
setChat((current) =>
|
||||
current.some((c) => c.id === item.id) ? current : [...current, item])
|
||||
setNewChat((n) => n + 1)
|
||||
return
|
||||
}
|
||||
|
||||
setQuestions((current) => {
|
||||
const exists = current.some((q) => q.id === item.id)
|
||||
if (!exists) {
|
||||
setNewQuestions((n) => n + 1)
|
||||
return [item, ...current]
|
||||
}
|
||||
return current.map((q) => (q.id === item.id ? item : q))
|
||||
})
|
||||
}
|
||||
|
||||
stomp.activate()
|
||||
return () => {
|
||||
void stomp.deactivate()
|
||||
}
|
||||
}, [id, loadSpeakQueue])
|
||||
|
||||
async function act(interactionId: number, action: string, body?: unknown) {
|
||||
try {
|
||||
await client.post(`/admin/webinars/${id}/interactions/${interactionId}/${action}`, body ?? {})
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się wykonać operacji'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wyciszenie/przywrocenie glosu albo blokada/odblokada uczestnika.
|
||||
*
|
||||
* Po akcji odswiezamy liste czatu, zeby przyciski przy KAZDEJ wiadomosci tego
|
||||
* autora pokazaly aktualny stan (zdarzenie WS niesie tylko publiczny widok
|
||||
* bez danych moderacji). To pojedyncze odswiezenie, nie odpytywanie w petli.
|
||||
*/
|
||||
async function moderateParticipant(
|
||||
registrationId: number,
|
||||
kind: 'mute' | 'ban',
|
||||
value: boolean,
|
||||
) {
|
||||
try {
|
||||
await client.post(`/admin/webinars/${id}/interactions/participants/${registrationId}/${kind}`, {
|
||||
value,
|
||||
})
|
||||
const fresh = await initial.refetch()
|
||||
if (fresh.data) {
|
||||
setChat(fresh.data.chat)
|
||||
setQuestions(fresh.data.questions)
|
||||
}
|
||||
toasts.success(
|
||||
kind === 'mute'
|
||||
? value ? 'Uczestnik wyciszony' : 'Przywrócono głos'
|
||||
: value ? 'Uczestnik zablokowany' : 'Odblokowano uczestnika',
|
||||
)
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się wykonać operacji'))
|
||||
}
|
||||
}
|
||||
|
||||
if (webinar.isPending || initial.isPending) return <Spinner />
|
||||
if (webinar.isError) {
|
||||
return <ErrorState message={errorMessage(webinar.error, 'Nie udało się wczytać webinaru')} />
|
||||
}
|
||||
|
||||
const pending = questions.filter((q) => q.status === 'NEW')
|
||||
const published = questions
|
||||
.filter((q) => q.status === 'APPROVED' || q.status === 'ANSWERED')
|
||||
.sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1
|
||||
return b.votes - a.votes
|
||||
})
|
||||
|
||||
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">Prowadzenie spotkania</h1>
|
||||
<p className="text-sm text-slate-500">{webinar.data.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSound}
|
||||
aria-pressed={sound}
|
||||
title={sound ? 'Wyłącz sygnał dźwiękowy' : 'Włącz sygnał dźwiękowy'}
|
||||
className={`flex h-11 w-11 items-center justify-center rounded-md transition-colors duration-fast ease-standard ${
|
||||
sound
|
||||
? 'bg-blue-50 text-blue-700 hover:bg-blue-100'
|
||||
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Icon name={sound ? 'volume' : 'volume-off'} size={18} />
|
||||
</button>
|
||||
<Badge tone={connected ? 'ok' : 'warn'}>
|
||||
{connected ? 'połączono' : 'łączenie...'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newOffers > 0 && (
|
||||
<Link
|
||||
to={panelPath('/zapytania')}
|
||||
className="flex items-center gap-2 rounded-lg border border-blue-300 bg-blue-50 p-3 text-sm text-blue-900 hover:bg-blue-100"
|
||||
>
|
||||
<Icon name="send" size={16} />
|
||||
<span className="font-medium">
|
||||
{newOffers === 1
|
||||
? 'Nowe zapytanie ofertowe'
|
||||
: `Nowe zapytania ofertowe: ${newOffers}`}
|
||||
</span>
|
||||
<span className="ml-auto text-xs">Przejdź do listy</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* trzy kolumny od xl, dwie od lg, jedna pod spodem na wezszym ekranie */}
|
||||
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<SpeakQueue
|
||||
webinarId={id ?? ''}
|
||||
requests={speakRequests}
|
||||
openMic={openMic}
|
||||
onOpenMicChange={setOpenMic}
|
||||
openVideo={openVideo}
|
||||
onOpenVideoChange={setOpenVideo}
|
||||
onChanged={() => void loadSpeakQueue()}
|
||||
/>
|
||||
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
|
||||
<header
|
||||
className="flex items-center justify-between border-b border-slate-200 px-4 py-3"
|
||||
onMouseEnter={() => setNewChat(0)}
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-slate-900">Czat na żywo</h2>
|
||||
<span className="hx-data text-xs text-slate-400">
|
||||
{chat.length}
|
||||
{newChat > 0 && <span className="ml-2 text-blue-700">+{newChat}</span>}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<ChatColumn
|
||||
messages={chat}
|
||||
onReject={(itemId) => act(itemId, 'reject')}
|
||||
onModerate={moderateParticipant}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
|
||||
<header
|
||||
className="flex items-center justify-between border-b border-slate-200 px-4 py-3"
|
||||
onMouseEnter={() => setNewQuestions(0)}
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-slate-900">Kolejka pytań</h2>
|
||||
<span className="hx-data text-xs text-slate-400">
|
||||
{pending.length} oczekuje
|
||||
{newQuestions > 0 && <span className="ml-2 text-blue-700">+{newQuestions}</span>}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<QuestionsColumn
|
||||
pending={pending}
|
||||
published={published}
|
||||
onAct={act}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
|
||||
<header className="border-b border-slate-200 px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-slate-900">Zadania</h2>
|
||||
</header>
|
||||
<TasksColumn webinarId={Number(id)} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatColumn({
|
||||
messages,
|
||||
onReject,
|
||||
onModerate,
|
||||
}: {
|
||||
messages: ModeratorInteractionView[]
|
||||
onReject: (id: number) => void
|
||||
onModerate: (registrationId: number, kind: 'mute' | 'ban', value: boolean) => void
|
||||
}) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
}, [messages.length])
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
|
||||
{messages.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-slate-400">Czat jest pusty.</p>
|
||||
)}
|
||||
{messages.map((message) => (
|
||||
<div key={message.id} className="group flex items-start gap-2 text-sm">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span
|
||||
className={`font-medium ${message.fromSpeaker ? 'text-blue-700' : 'text-slate-700'}`}
|
||||
>
|
||||
{message.authorName}
|
||||
</span>
|
||||
{message.authorChatMuted && (
|
||||
<span className="ml-1.5 rounded bg-warn-50 px-1.5 py-0.5 text-2xs font-medium text-warn-600">
|
||||
wyciszony
|
||||
</span>
|
||||
)}
|
||||
{message.authorRoomBanned && (
|
||||
<span className="ml-1.5 rounded bg-alarm-50 px-1.5 py-0.5 text-2xs font-medium text-alarm-600">
|
||||
zablokowany
|
||||
</span>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap break-words text-slate-600">{message.content}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
{/* moderacja autora tylko dla wiadomosci uczestnika (nie prelegenta) */}
|
||||
{message.authorRegistrationId !== null && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onModerate(message.authorRegistrationId!, 'mute', !message.authorChatMuted)
|
||||
}
|
||||
aria-label={message.authorChatMuted ? 'Przywróć głos na czacie' : 'Wycisz na czacie'}
|
||||
title={message.authorChatMuted ? 'Przywróć głos na czacie' : 'Wycisz na czacie'}
|
||||
className="rounded-md p-1 text-warn-600 hover:bg-warn-50"
|
||||
>
|
||||
<Icon name={message.authorChatMuted ? 'mic' : 'mic-off'} size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onModerate(message.authorRegistrationId!, 'ban', !message.authorRoomBanned)
|
||||
}
|
||||
aria-label={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj uczestnika'}
|
||||
title={message.authorRoomBanned ? 'Odblokuj uczestnika' : 'Zablokuj dostęp do spotkania'}
|
||||
className="rounded-md p-1 text-alarm-600 hover:bg-alarm-50"
|
||||
>
|
||||
<Icon name={message.authorRoomBanned ? 'user-plus' : 'user-off'} size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReject(message.id)}
|
||||
aria-label="Usuń wiadomość"
|
||||
title="Usuń wiadomość"
|
||||
className="rounded-md p-1 text-alarm-600 hover:bg-alarm-50"
|
||||
>
|
||||
<Icon name="trash" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionsColumn({
|
||||
pending,
|
||||
published,
|
||||
onAct,
|
||||
}: {
|
||||
pending: ModeratorInteractionView[]
|
||||
published: ModeratorInteractionView[]
|
||||
onAct: (id: number, action: string, body?: unknown) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
|
||||
{pending.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-warn-600">
|
||||
Oczekuje na zatwierdzenie
|
||||
</p>
|
||||
{pending.map((question) => (
|
||||
<div key={question.id} className="rounded-md border border-warn-500/30 bg-warn-50 p-2.5">
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-slate-700">
|
||||
{question.content}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{question.authorName}</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button size="sm" icon="check" onClick={() => onAct(question.id, 'approve')}>
|
||||
Zatwierdź
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="x"
|
||||
onClick={() => onAct(question.id, 'reject')}
|
||||
className="text-alarm-600 hover:bg-alarm-100"
|
||||
>
|
||||
Odrzuć
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{published.length === 0 && pending.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-slate-400">Nie ma jeszcze pytań.</p>
|
||||
)}
|
||||
|
||||
{published.map((question) => (
|
||||
<QuestionCard key={question.id} question={question} onAct={onAct} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuestionCard({
|
||||
question,
|
||||
onAct,
|
||||
}: {
|
||||
question: ModeratorInteractionView
|
||||
onAct: (id: number, action: string, body?: unknown) => void
|
||||
}) {
|
||||
const [answering, setAnswering] = useState(false)
|
||||
const [answer, setAnswer] = useState(question.answer ?? '')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-md border p-2.5 ${
|
||||
question.pinned ? 'border-blue-300 bg-blue-50' : 'border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="hx-data flex w-10 shrink-0 flex-col items-center rounded-md bg-slate-100 py-1 text-xs text-slate-600">
|
||||
<Icon name="chevron-up" size={12} />
|
||||
{question.votes}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-slate-700">
|
||||
{question.content}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">{question.authorName}</p>
|
||||
|
||||
{question.answer && !answering && (
|
||||
<div className="mt-2 rounded-md bg-ok-50 p-2">
|
||||
<p className="text-xs font-medium text-ok-600">Odpowiedziano</p>
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-slate-700">
|
||||
{question.answer}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{answering && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={answer}
|
||||
onChange={(e) => setAnswer(e.target.value)}
|
||||
placeholder="Odpowiedź widoczna dla uczestników..."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
icon="check"
|
||||
disabled={!answer.trim()}
|
||||
onClick={() => {
|
||||
onAct(question.id, 'answer', { answer })
|
||||
setAnswering(false)
|
||||
}}
|
||||
>
|
||||
Zapisz odpowiedź
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAnswering(false)}>
|
||||
Anuluj
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!answering && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onAct(question.id, 'pin', { pinned: !question.pinned })}
|
||||
>
|
||||
{question.pinned ? 'Odepnij' : 'Przypnij'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAnswering(true)}>
|
||||
{question.answer ? 'Zmień odpowiedź' : 'Odpowiedz'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onAct(question.id, 'reject')}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
>
|
||||
Ukryj
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zadania ogloszone w trakcie spotkania.
|
||||
*
|
||||
* Formularz jest krotki celowo: prowadzacy oglasza zadanie na zywo, w trakcie
|
||||
* mowienia, wiec ma miec tytul i ewentualnie limit, a nie pelen formularz.
|
||||
*/
|
||||
function TasksColumn({ webinarId }: { webinarId: number }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const [title, setTitle] = useState('')
|
||||
const [capacity, setCapacity] = useState('')
|
||||
|
||||
const tasks = useQuery({
|
||||
queryKey: ['tasks', webinarId],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<TaskRow[]>(`/admin/webinars/${webinarId}/tasks`)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
client.post(`/admin/webinars/${webinarId}/tasks`, {
|
||||
title,
|
||||
capacity: capacity ? Number(capacity) : null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setTitle('')
|
||||
setCapacity('')
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks', webinarId] })
|
||||
},
|
||||
onError: (error: unknown) => toasts.error(errorMessage(error, 'Nie udało się dodać zadania')),
|
||||
})
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: ({ id, open }: { id: number; open: boolean }) =>
|
||||
client.post(`/admin/webinars/${webinarId}/tasks/${id}/open`, { open }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks', webinarId] }),
|
||||
onError: (error: unknown) => toasts.error(errorMessage(error, 'Nie udało się zmienić zadania')),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
|
||||
{tasks.isPending && <Spinner />}
|
||||
{tasks.data?.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
Nie ogłoszono jeszcze żadnych zadań.
|
||||
</p>
|
||||
)}
|
||||
{tasks.data?.map((task) => (
|
||||
<div key={task.id} className="rounded-md border border-slate-200 p-2.5">
|
||||
<p className="text-sm font-medium text-slate-900">{task.title}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<span className="hx-data text-xs text-slate-500">
|
||||
{task.capacity === null
|
||||
? `${task.taken} zapisanych`
|
||||
: `${task.taken} / ${task.capacity}`}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ml-auto"
|
||||
onClick={() => toggle.mutate({ id: task.id, open: !task.open })}
|
||||
>
|
||||
{task.open ? 'Zamknij zapisy' : 'Otwórz zapisy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-200 p-3">
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Tytuł zadania"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={capacity}
|
||||
onChange={(e) => setCapacity(e.target.value)}
|
||||
placeholder="Limit miejsc (opcjonalnie)"
|
||||
/>
|
||||
<Button
|
||||
icon="plus"
|
||||
size="sm"
|
||||
onClick={() => create.mutate()}
|
||||
disabled={!title.trim() || create.isPending}
|
||||
>
|
||||
Ogłoś
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type TaskRow = {
|
||||
id: number
|
||||
title: string
|
||||
capacity: number | null
|
||||
taken: number
|
||||
open: boolean
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type {
|
||||
ContactGroup,
|
||||
Invitation,
|
||||
Page,
|
||||
RecipientPreview,
|
||||
Webinar,
|
||||
} from '../../api/types'
|
||||
import { ContentEditor } from '../../components/ContentEditor'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Alert, Badge, Button, Card, EmptyState, ErrorState, Spinner } from '../../components/ui'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
|
||||
/**
|
||||
* Zaproszenia na webinar.
|
||||
*
|
||||
* Pierwszy etap sciezki uczestnika: informacja z linkiem do zapisu, dopiero
|
||||
* potem rejestracja. Odbiorcami sa kontakty z bazy, nie uczestnicy: uczestnik
|
||||
* powstaje dopiero po kliknieciu w link.
|
||||
*/
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
QUEUED: 'W kolejce',
|
||||
SENT: 'Wysłane',
|
||||
FAILED: 'Błąd wysyłki',
|
||||
}
|
||||
|
||||
export function InvitationSection({ webinar }: { webinar: Webinar }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
|
||||
const [groupIds, setGroupIds] = useState<number[]>([])
|
||||
const [content, setContent] = useState(webinar.invitationContentHtml ?? '')
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const groups = useQuery({
|
||||
queryKey: ['contact-groups'],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<ContactGroup[]>('/admin/contacts/groups')
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
const invitations = useQuery({
|
||||
queryKey: ['invitations', webinar.id, page],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Page<Invitation>>(
|
||||
`/admin/webinars/${webinar.id}/invitations`,
|
||||
{ params: { page, size: PAGE_SIZE } },
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
/*
|
||||
* Podglad liczby odbiorcow po odfiltrowaniu wypisanych, bez zgody i juz
|
||||
* zaproszonych. Tylko taka liczba cos znaczy przed klinieciem "wyslij".
|
||||
*/
|
||||
const [recipients, setRecipients] = useState<RecipientPreview | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
client
|
||||
.post<RecipientPreview>(`/admin/webinars/${webinar.id}/invitations/preview`, {
|
||||
groupIds,
|
||||
contactIds: [],
|
||||
})
|
||||
.then((response) => {
|
||||
if (!cancelled) setRecipients(response.data)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRecipients(null)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [webinar.id, groupIds, invitations.data])
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: () =>
|
||||
client.post<{ queued: number; skipped: number }>(
|
||||
`/admin/webinars/${webinar.id}/invitations`,
|
||||
{ groupIds, contactIds: [], contentHtml: content },
|
||||
),
|
||||
onSuccess: (response) => {
|
||||
const { queued, skipped } = response.data
|
||||
const extra = skipped > 0 ? `, pominięto ${skipped} (już zaproszeni)` : ''
|
||||
toasts.success(`Zakolejkowano ${queued} zaproszeń${extra}`)
|
||||
queryClient.invalidateQueries({ queryKey: ['invitations', webinar.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['stats', webinar.id] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wysłać zaproszeń')),
|
||||
})
|
||||
|
||||
function toggleGroup(groupId: number) {
|
||||
setGroupIds((current) =>
|
||||
current.includes(groupId) ? current.filter((x) => x !== groupId) : [...current, groupId])
|
||||
}
|
||||
|
||||
const published = webinar.status === 'PUBLISHED'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card
|
||||
title="Wyślij zaproszenia"
|
||||
description="Odbiorcami są kontakty z bazy, ze zgodą marketingową"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{!published && (
|
||||
<Alert tone="warn">
|
||||
Zaproszenia można wysłać dopiero po opublikowaniu webinaru: odbiorca musi móc
|
||||
otworzyć stronę zapisu.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Odbiorcy</label>
|
||||
{groups.data && groups.data.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{groups.data.map((group) => (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
className={`min-h-[36px] rounded-full px-3 text-sm transition-colors duration-fast ease-standard ${
|
||||
groupIds.includes(group.id)
|
||||
? 'bg-blue-50 font-medium text-blue-700 ring-1 ring-inset ring-blue-200'
|
||||
: 'bg-slate-50 text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{group.name}
|
||||
<span className="hx-data ml-1.5 text-xs text-slate-400">
|
||||
{group.contactCount}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">
|
||||
Nie ma jeszcze żadnej grupy. Zaproszenie pójdzie do wszystkich kontaktów ze zgodą.
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1.5 text-xs text-slate-500">
|
||||
{groupIds.length === 0
|
||||
? 'Nie wybrano grupy: zaproszenie pójdzie do wszystkich kontaktów ze zgodą.'
|
||||
: `Wybrano ${groupIds.length} ${groupIds.length === 1 ? 'grupę' : 'grup'}.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{recipients && (
|
||||
<div className="rounded-md bg-slate-25 p-3 text-sm">
|
||||
<p className="text-slate-700">
|
||||
Zaproszenie trafi do{' '}
|
||||
<span className="hx-data font-semibold text-slate-900">{recipients.toSend}</span>{' '}
|
||||
{recipients.toSend === 1 ? 'osoby' : 'osób'}.
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
{recipients.alreadyInvited > 0 && (
|
||||
<>Pominiętych, bo już dostali zaproszenie: {recipients.alreadyInvited}. </>
|
||||
)}
|
||||
W całej bazie zgodę ma {recipients.totalWithConsent} kontaktów. Osoby wypisane
|
||||
i bez zgody nie są liczone.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800">
|
||||
Treść zaproszenia
|
||||
</label>
|
||||
{/* ten sam edytor co tresc strony zapisu */}
|
||||
<ContentEditor value={content} onChange={setContent} webinarId={webinar.id} />
|
||||
<p className="mt-1.5 text-xs text-slate-500">
|
||||
Imię odbiorcy i link do zapisu są dodawane automatycznie. Wiadomość zawiera też
|
||||
stopkę z odnośnikiem do wypisania, wymaganą przy wiadomościach marketingowych.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
icon="send"
|
||||
disabled={
|
||||
send.isPending || !published || !content.trim() || (recipients?.toSend ?? 0) === 0
|
||||
}
|
||||
onClick={() => {
|
||||
const count = recipients?.toSend ?? 0
|
||||
if (confirm(`Wysłać zaproszenie do ${count} odbiorców?`)) {
|
||||
send.mutate()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{send.isPending ? 'Wysyłanie...' : 'Wyślij zaproszenia'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Wysłane zaproszenia" description="Kto dostał i kto się po nich zapisał">
|
||||
{invitations.isPending && <Spinner />}
|
||||
{invitations.isError && (
|
||||
<ErrorState message={errorMessage(invitations.error, 'Nie udało się wczytać zaproszeń')} />
|
||||
)}
|
||||
|
||||
{invitations.data && invitations.data.content.length === 0 && (
|
||||
<EmptyState
|
||||
icon="send"
|
||||
title="Brak zaproszeń"
|
||||
description="Nikt jeszcze nie dostał zaproszenia na ten webinar."
|
||||
/>
|
||||
)}
|
||||
|
||||
{invitations.data && invitations.data.content.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{invitations.data.content.map((invitation) => (
|
||||
<li key={invitation.id} className="flex flex-wrap items-center justify-between gap-2 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-900">{invitation.fullName}</p>
|
||||
<p className="hx-data break-all text-xs text-slate-500">{invitation.email}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{invitation.registeredAt && (
|
||||
<Badge tone="ok">
|
||||
<Icon name="check-circle" size={12} className="mr-1" />
|
||||
Zapisał się
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
tone={
|
||||
invitation.status === 'SENT'
|
||||
? 'info'
|
||||
: invitation.status === 'FAILED'
|
||||
? 'alarm'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
{STATUS_LABEL[invitation.status] ?? invitation.status}
|
||||
</Badge>
|
||||
{invitation.sentAt && (
|
||||
<span className="hx-data text-xs text-slate-400">
|
||||
{formatLocal(invitation.sentAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Pagination
|
||||
page={invitations.data.number}
|
||||
totalPages={invitations.data.totalPages}
|
||||
totalElements={invitations.data.totalElements}
|
||||
onChange={setPage}
|
||||
itemLabel="zaproszeń"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Page } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Badge, Button, EmptyState, ErrorState, Select, Spinner, Textarea } from '../../components/ui'
|
||||
import { formatLocal } from '../../lib/dates'
|
||||
import { panelPath } from '../../lib/routes'
|
||||
|
||||
/**
|
||||
* Lista zapytan ofertowych wraz z ich obsluga.
|
||||
*
|
||||
* Jeden komponent obsluguje dwa miejsca: zakladke konkretnego webinaru
|
||||
* (`webinarId` podany) i ekran zbiorczy ze wszystkich webinarow (`webinarId`
|
||||
* puste). Dzieki temu handlowiec widzi zapytanie tak samo niezaleznie od tego,
|
||||
* ktora droga do niego trafil, a poprawka w obsludze nie wymaga zmiany
|
||||
* w dwoch plikach.
|
||||
*
|
||||
* Gdy komponent jest osadzony przy webinarze, znika filtr po webinarze
|
||||
* i nazwa webinaru przy kazdej pozycji: jedno i drugie powtarzaloby to,
|
||||
* co uzytkownik ma w naglowku strony.
|
||||
*/
|
||||
|
||||
export type OfferView = {
|
||||
id: number
|
||||
webinarId: number
|
||||
webinarTitle: string
|
||||
content: string
|
||||
topic: string | null
|
||||
status: 'NEW' | 'HANDLED' | 'REJECTED' | 'APPROVED' | 'ANSWERED'
|
||||
handlerNote: string | null
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
email: string | null
|
||||
phone: string | null
|
||||
company: string | null
|
||||
consentVersion: string | null
|
||||
consentAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
NEW: 'Nowe',
|
||||
HANDLED: 'Załatwione',
|
||||
}
|
||||
|
||||
export function OfferSection({ webinarId }: { webinarId?: number }) {
|
||||
const [status, setStatus] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const toasts = useToasts()
|
||||
|
||||
const offers = useQuery({
|
||||
queryKey: ['offers', status, webinarId ?? '', page],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Page<OfferView>>('/admin/offers', {
|
||||
params: {
|
||||
status: status || undefined,
|
||||
webinarId: webinarId ?? undefined,
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
async function handleExport() {
|
||||
setExporting(true)
|
||||
try {
|
||||
const response = await client.get('/admin/offers/export', {
|
||||
params: { status: status || undefined, webinarId: webinarId ?? undefined },
|
||||
responseType: 'blob',
|
||||
})
|
||||
const url = URL.createObjectURL(response.data as Blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'zapytania.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)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="w-full sm:w-48">
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Status</label>
|
||||
<Select
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
>
|
||||
<option value="">Wszystkie</option>
|
||||
<option value="NEW">Nowe</option>
|
||||
<option value="HANDLED">Załatwione</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button icon="download" variant="secondary" onClick={handleExport} disabled={exporting}>
|
||||
{exporting ? 'Pobieranie...' : 'Eksport CSV'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white">
|
||||
{offers.isPending && <Spinner />}
|
||||
{offers.isError && (
|
||||
<div className="p-5">
|
||||
<ErrorState message={errorMessage(offers.error, 'Nie udało się wczytać zapytań')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{offers.data && offers.data.content.length === 0 && (
|
||||
<EmptyState
|
||||
icon="send"
|
||||
title="Brak zapytań"
|
||||
description="Nikt jeszcze nie wysłał zapytania ofertowego."
|
||||
/>
|
||||
)}
|
||||
|
||||
{offers.data && offers.data.content.length > 0 && (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{offers.data.content.map((offer) => (
|
||||
<OfferRow key={offer.id} offer={offer} showWebinar={webinarId === undefined} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{offers.data && (
|
||||
<Pagination
|
||||
page={offers.data.number}
|
||||
totalPages={offers.data.totalPages}
|
||||
totalElements={offers.data.totalElements}
|
||||
onChange={setPage}
|
||||
itemLabel="zapytań"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Jedno zapytanie wraz z obsluga.
|
||||
*
|
||||
* `showWebinar` odpowiada za nazwe webinaru: przy zakladce konkretnego
|
||||
* webinaru jest zbedna, na ekranie zbiorczym niezbedna.
|
||||
*/
|
||||
function OfferRow({ offer, showWebinar }: { offer: OfferView; showWebinar: boolean }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const [noting, setNoting] = useState(false)
|
||||
const [note, setNote] = useState(offer.handlerNote ?? '')
|
||||
|
||||
const handle = useMutation({
|
||||
mutationFn: () => client.post(`/admin/offers/${offer.id}/handled`, { note }),
|
||||
onSuccess: () => {
|
||||
toasts.success('Zapytanie oznaczone jako załatwione')
|
||||
queryClient.invalidateQueries({ queryKey: ['offers'] })
|
||||
// licznik zapytan przy webinarze pochodzi z listy webinarow
|
||||
queryClient.invalidateQueries({ queryKey: ['webinars'] })
|
||||
setNoting(false)
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zapisać')),
|
||||
})
|
||||
|
||||
return (
|
||||
<li className="space-y-2 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-900">
|
||||
{offer.firstName} {offer.lastName}
|
||||
{offer.company && <span className="text-slate-500"> - {offer.company}</span>}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
<a href={`mailto:${offer.email}`} className="hx-data text-blue-700 hover:underline">
|
||||
{offer.email}
|
||||
</a>
|
||||
{offer.phone && (
|
||||
<a href={`tel:${offer.phone}`} className="hx-data text-slate-600 hover:underline">
|
||||
{offer.phone}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={offer.status === 'HANDLED' ? 'ok' : 'warn'}>
|
||||
{STATUS_LABEL[offer.status] ?? offer.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="whitespace-pre-wrap rounded-md bg-slate-25 p-2.5 text-sm text-slate-700">
|
||||
{offer.content}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-500">
|
||||
{showWebinar && (
|
||||
<Link to={panelPath(`/webinary/${offer.webinarId}`)} className="text-blue-700 hover:underline">
|
||||
{offer.webinarTitle}
|
||||
</Link>
|
||||
)}
|
||||
{offer.topic && <span>temat: {offer.topic}</span>}
|
||||
<span className="hx-data">{formatLocal(offer.createdAt)}</span>
|
||||
{offer.consentVersion && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icon name="check-circle" size={12} className="text-ok-500" />
|
||||
zgoda {offer.consentVersion}
|
||||
{offer.consentAt && <span className="hx-data"> z {formatLocal(offer.consentAt)}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{offer.handlerNote && !noting && (
|
||||
<p className="rounded-md bg-ok-50 p-2 text-xs text-slate-700">
|
||||
<span className="font-medium text-ok-600">Notatka: </span>
|
||||
{offer.handlerNote}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{noting ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Notatka handlowca, np. wysłano ofertę 12.09"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" icon="check" onClick={() => handle.mutate()} disabled={handle.isPending}>
|
||||
Oznacz jako załatwione
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setNoting(false)}>
|
||||
Anuluj
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
offer.status !== 'HANDLED' && (
|
||||
<Button size="sm" variant="secondary" icon="check" onClick={() => setNoting(true)}>
|
||||
Oznacz jako załatwione
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { client } from '../../api/client'
|
||||
import type { Page, Webinar } from '../../api/types'
|
||||
import { Card, Select } from '../../components/ui'
|
||||
import { OfferSection } from './OfferSection'
|
||||
|
||||
/**
|
||||
* Zapytania ofertowe ze wszystkich webinarow.
|
||||
*
|
||||
* Ekran zbiorczy dla handlowca, ktory obsluguje zapytania niezaleznie od tego,
|
||||
* z ktorego wydarzenia przyszly. Zapytania **jednego** webinaru sa dostepne
|
||||
* takze jako jego zakladka na /webinary/:id, i to tam prowadzi kazde
|
||||
* powiazanie z konkretnym wydarzeniem.
|
||||
*
|
||||
* Sama lista i obsluga zapytania siedza w OfferSection, wspolnym dla obu
|
||||
* miejsc. Tutaj dochodzi wylacznie wybor webinaru, ktorego na zakladce
|
||||
* konkretnego wydarzenia z definicji nie ma.
|
||||
*/
|
||||
|
||||
/** Ile webinarow wchodzi do listy wyboru. Wiecej nie miesci sie w rozwijanej liscie. */
|
||||
const WEBINAR_CHOICES = 100
|
||||
|
||||
export function OffersPage() {
|
||||
const [webinarId, setWebinarId] = useState('')
|
||||
|
||||
/*
|
||||
* Osobny klucz zapytania, nie ['webinars', ...]. Ta lista ma inne parametry
|
||||
* niz lista w panelu webinarow, wiec wspolny prefiks podstawialby jednemu
|
||||
* ekranowi wynik pobrany dla drugiego.
|
||||
*/
|
||||
const webinars = useQuery({
|
||||
queryKey: ['webinars-for-filter'],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Page<Webinar>>('/admin/webinars', {
|
||||
params: { size: WEBINAR_CHOICES, sort: 'createdAt', direction: 'desc' },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Zapytania ofertowe</h1>
|
||||
<p className="text-sm text-slate-500">Kontakty handlowe ze wszystkich webinarów</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="w-full sm:w-72">
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Webinar</label>
|
||||
<Select value={webinarId} onChange={(e) => setWebinarId(e.target.value)}>
|
||||
<option value="">Wszystkie</option>
|
||||
{webinars.data?.content.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<OfferSection webinarId={webinarId ? Number(webinarId) : undefined} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import { IconButton } from '../../components/ui'
|
||||
|
||||
/**
|
||||
* Pomoc "?" przy wyborze dostawcy wideo W FORMULARZU WEBINARU.
|
||||
*
|
||||
* TO JEST POMOC DLA OSOBY ZAKLADAJACEJ WEBINAR, NIE DLA TECHNIKA.
|
||||
* ---------------------------------------------------------------------------
|
||||
* Okno wyjasnia, co dany dostawca oznacza w praktyce: czy obraz jest osadzony
|
||||
* w oknie webinaru, czy uczestnik idzie do zewnetrznego narzedzia, i jaka jest
|
||||
* sila gwarancji uprawnien. NIE zawiera krokow technicznych (zakladanie kont,
|
||||
* klucze API, uruchamianie serwera) - te naleza do jednorazowej konfiguracji
|
||||
* instancji i sa w Ustawienia -> Wideo, dla administratora.
|
||||
*
|
||||
* Wczesniej to okno pokazywalo instrukcje infrastrukturalna, przez co osoba
|
||||
* tworzaca webinar odnosila wrazenie, ze ma sama uruchamiac serwer i wpisywac
|
||||
* klucze. To bylo mylace: przy webinarze wybiera sie tylko gotowego dostawce.
|
||||
*/
|
||||
|
||||
type Guide = {
|
||||
label: string
|
||||
what: string
|
||||
note: { text: string; tone: 'ok' | 'warn' | 'info' }
|
||||
}
|
||||
|
||||
const GUIDES: Record<string, Guide> = {
|
||||
LIVEKIT: {
|
||||
label: 'LiveKit',
|
||||
what: 'Obraz i dźwięk są osadzone bezpośrednio w oknie webinaru, razem z czatem, pytaniami i banerami. Uczestnik nie wychodzi z platformy.',
|
||||
note: {
|
||||
text: 'Najmocniejsze gwarancje: serwer pilnuje, kto może włączyć obraz i mikrofon. Nagrywanie działa automatycznie. Zalecany.',
|
||||
tone: 'ok',
|
||||
},
|
||||
},
|
||||
ZOOM: {
|
||||
label: 'Zoom',
|
||||
what: 'Obraz jest osadzony w oknie webinaru przez Zoom, a czat, pytania i zapytania ofertowe zostają na tej platformie.',
|
||||
note: {
|
||||
text: 'Uprawnienia do obrazu i dźwięku steruje ustawieniami całego spotkania, nie osobno dla każdego uczestnika (jak Jitsi, nie jak LiveKit). Wideo uczestników jest wyłączone dla całego spotkania.',
|
||||
tone: 'warn',
|
||||
},
|
||||
},
|
||||
JITSI: {
|
||||
label: 'Jitsi Meet',
|
||||
what: 'Obraz jest osadzony w oknie webinaru przez Jitsi, a czat i pytania zostają na tej platformie.',
|
||||
note: {
|
||||
text: 'Na publicznym serwerze Jitsi pokój jest otwarty dla każdego, kto zna adres, a role są tylko umowne. Nagrywanie wymaga ręcznego wgrania pliku.',
|
||||
tone: 'warn',
|
||||
},
|
||||
},
|
||||
DAILY: {
|
||||
label: 'Daily.co',
|
||||
what: 'Obraz jest osadzony w oknie webinaru przez usługę Daily, a czat i pytania zostają na tej platformie.',
|
||||
note: {
|
||||
text: 'Uprawnienia egzekwuje serwer. Nagranie pobierane jest automatycznie po zakończeniu.',
|
||||
tone: 'ok',
|
||||
},
|
||||
},
|
||||
NONE: {
|
||||
label: 'Bez wbudowanego wideo',
|
||||
what: 'Obraz idzie zewnętrznym narzędziem (własny link, który wklejasz w formularzu webinaru). Uczestnik otwiera go przyciskiem, a czat, pytania i zapytania ofertowe zostają na tej platformie.',
|
||||
note: {
|
||||
text: 'Wybierz, gdy prowadzisz spotkanie we własnym narzędziu. Nagrywanie wymaga ręcznego wgrania pliku.',
|
||||
tone: 'info',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const TONE_CLASS: Record<'ok' | 'warn' | 'info', string> = {
|
||||
ok: 'bg-ok-50 text-ok-600 border-ok-500/30',
|
||||
warn: 'bg-warn-50 text-warn-600 border-warn-500/30',
|
||||
info: 'bg-info-50 text-info-600 border-info-500/30',
|
||||
}
|
||||
|
||||
/**
|
||||
* Ikona "?" otwierajaca wyjasnienie dostawcow.
|
||||
*
|
||||
* Pokazuje wyjasnienie dla wskazanego dostawcy, z zakladkami do przelaczania
|
||||
* miedzy wszystkimi. Bez krokow technicznych - te sa w Ustawieniach.
|
||||
*/
|
||||
export function ProviderHelp({ current }: { current: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const start = GUIDES[current] ? current : 'LIVEKIT'
|
||||
const [active, setActive] = useState(start)
|
||||
|
||||
const guide = GUIDES[active] ?? GUIDES.LIVEKIT
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="help-circle"
|
||||
label="Co oznacza wybór dostawcy wideo"
|
||||
onClick={() => {
|
||||
setActive(GUIDES[current] ? current : 'LIVEKIT')
|
||||
setOpen(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
{open && (
|
||||
<Modal title="Dostawcy wideo - co oznaczają" onClose={() => setOpen(false)}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(GUIDES).map(([key, g]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setActive(key)}
|
||||
className={
|
||||
'rounded-md border px-3 py-1.5 text-sm transition-colors duration-fast ease-standard ' +
|
||||
(key === active
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: 'border-slate-200 text-slate-600 hover:bg-slate-50')
|
||||
}
|
||||
>
|
||||
{g.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-600">{guide.what}</p>
|
||||
|
||||
<div className={'rounded-md border p-3 text-sm ' + TONE_CLASS[guide.note.tone]}>
|
||||
{guide.note.text}
|
||||
</div>
|
||||
|
||||
<p className="rounded-md border border-slate-200 bg-slate-25 p-3 text-xs leading-relaxed text-slate-500">
|
||||
Nie musisz nic konfigurować przy webinarze. Wybierasz gotowego dostawcę z listy
|
||||
i klikasz „Utwórz pokój". Klucze i uruchomienie dostawcy ustawia administrator raz,
|
||||
w Ustawienia → Wideo. Jeśli wybrany dostawca nie działa, skontaktuj się z administratorem.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Button, Field, Select, Spinner } from '../../components/ui'
|
||||
import { ProviderHelp } from './ProviderHelp'
|
||||
|
||||
/**
|
||||
* Wybor dostawcy wideo dla pojedynczego webinaru.
|
||||
*
|
||||
* DLACZEGO WYBOR JEST TU, A NIE TYLKO W USTAWIENIACH
|
||||
* ------------------------------------------------------------------------
|
||||
* Instancja ma jednego dostawcy domyslnego, ale pojedyncze wydarzenie bywa
|
||||
* inne: szkolenie wewnetrzne na dziesiec osob nie potrzebuje wlasnego serwera,
|
||||
* a konferencja dla dwustu nie moze isc przez otwarty pokoj Jitsi. Wybor pusty
|
||||
* oznacza "ten sam co instancja", wiec zmiana ustawienia pociaga za soba
|
||||
* wszystkie webinary, ktore niczego nie wybraly.
|
||||
*
|
||||
* OSTRZEZENIE PRZY JITSI BEZ JWT JEST TU NAJWAZNIEJSZE
|
||||
* ------------------------------------------------------------------------
|
||||
* To jedyny wariant, w ktorym **role sa umowne**: pokoj jest otwarty dla
|
||||
* kazdego, kto zna adres, a ukrycie przycisku kamery to jedyne, co dzieli
|
||||
* uczestnika od publikacji obrazu. Administrator ma o tym wiedziec przy
|
||||
* wyborze, a nie dowiedziec sie po webinarze.
|
||||
*/
|
||||
|
||||
type ProviderState = {
|
||||
/** Kto faktycznie obsluguje ten webinar. */
|
||||
effective: string
|
||||
/** Wybor administratora. Null oznacza "z instancji". */
|
||||
chosen: string | null
|
||||
/** Dostawca z ustawien instancji. */
|
||||
instance: string
|
||||
/** U kogo pokoj faktycznie stoi. Null, gdy pokoju nie ma. */
|
||||
roomProvider: string | null
|
||||
/** Czy zmiana wyboru zostawila pokoj u poprzedniego dostawcy. */
|
||||
needsMigration: boolean
|
||||
available: string[]
|
||||
}
|
||||
|
||||
const PROVIDER_LABEL: Record<string, string> = {
|
||||
NONE: 'Bez wbudowanego wideo (własny link)',
|
||||
LIVEKIT: 'LiveKit',
|
||||
JITSI: 'Jitsi Meet',
|
||||
DAILY: 'Daily.co',
|
||||
ZOOM: 'Zoom',
|
||||
}
|
||||
|
||||
/** Co gwarantuje serwer, a co jest tylko interfejsem. */
|
||||
const PROVIDER_NOTE: Record<string, { text: string; tone: 'ok' | 'warn' | 'info' }> = {
|
||||
LIVEKIT: {
|
||||
text: 'Uprawnienia egzekwuje serwer na podstawie podpisanego tokenu. Nagrywanie działa automatycznie.',
|
||||
tone: 'ok',
|
||||
},
|
||||
DAILY: {
|
||||
text: 'Uprawnienia egzekwuje serwer. Nagranie jest pobierane od dostawcy po zakończeniu.',
|
||||
tone: 'ok',
|
||||
},
|
||||
JITSI: {
|
||||
text: 'Serwer egzekwuje wyłącznie rolę moderatora. Nagrywanie wymaga ręcznego wgrania pliku.',
|
||||
tone: 'warn',
|
||||
},
|
||||
ZOOM: {
|
||||
text: 'Obraz osadzony w oknie webinaru. Uprawnienia do obrazu i dźwięku steruje ustawieniami całego spotkania, nie tokenem uczestnika (jak Jitsi, nie jak LiveKit). Kliknij "?" po instrukcję konfiguracji.',
|
||||
tone: 'warn',
|
||||
},
|
||||
NONE: {
|
||||
text: 'Obraz idzie zewnętrznym narzędziem. Czat, pytania i zapytania ofertowe zostają na tej platformie.',
|
||||
tone: 'info',
|
||||
},
|
||||
}
|
||||
|
||||
export function ProviderPicker({
|
||||
webinarId,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
webinarId: number
|
||||
/** Pusty ciag oznacza "dostawca z instancji". */
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
|
||||
const state = useQuery({
|
||||
queryKey: ['video-provider', webinarId],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<ProviderState>(
|
||||
`/admin/webinars/${webinarId}/video-provider`,
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
const migrate = useMutation({
|
||||
mutationFn: () => client.post(`/admin/webinars/${webinarId}/video-provider/migrate`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Poprzedni pokój został zamknięty')
|
||||
queryClient.invalidateQueries({ queryKey: ['video-provider', webinarId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['webinar', String(webinarId)] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zamknąć pokoju')),
|
||||
})
|
||||
|
||||
if (state.isPending) return <Spinner />
|
||||
|
||||
const data = state.data
|
||||
const effective = value || data?.instance || 'NONE'
|
||||
const note = PROVIDER_NOTE[effective]
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Field
|
||||
label="Dostawca wideo"
|
||||
hint="Puste oznacza dostawcę z ustawień instancji. Zmiana dotyczy tylko tego webinaru."
|
||||
badge={<ProviderHelp current={effective} />}
|
||||
>
|
||||
<Select value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">
|
||||
Domyślny z instancji ({PROVIDER_LABEL[data?.instance ?? 'NONE'] ?? data?.instance})
|
||||
</option>
|
||||
{(data?.available ?? []).map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{PROVIDER_LABEL[name] ?? name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{note && (
|
||||
<p
|
||||
className={`rounded-md border p-3 text-xs leading-relaxed ${
|
||||
note.tone === 'ok'
|
||||
? 'border-ok-500/20 bg-ok-50 text-ok-600'
|
||||
: note.tone === 'warn'
|
||||
? 'border-warn-500/20 bg-warn-50 text-warn-600'
|
||||
: 'border-slate-200 bg-slate-25 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{note.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Jitsi bez JWT: pokoj otwarty, role umowne. To nie jest szczegol
|
||||
konfiguracji, tylko zupelnie inna sila gwarancji, wiec ostrzezenie
|
||||
jest osobne i wyrazniejsze niz zwykla notka o dostawcy.
|
||||
*/}
|
||||
{effective === 'JITSI' && <JitsiWarning />}
|
||||
|
||||
{/*
|
||||
Pokoj stoi u poprzedniego dostawcy. Zamkniecie jest nieodwracalne,
|
||||
a uczestnicy z linkiem sprzed zmiany trafia w puste miejsce, wiec
|
||||
pytamy wprost, zamiast robic to przy zapisie formularza.
|
||||
*/}
|
||||
{data?.needsMigration && (
|
||||
<div className="rounded-md border border-warn-500/30 bg-warn-50 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon name="alert-triangle" size={16} className="mt-0.5 shrink-0 text-warn-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-warn-600">
|
||||
Pokój stoi u poprzedniego dostawcy ({PROVIDER_LABEL[data.roomProvider ?? ''] ?? data.roomProvider})
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-slate-600">
|
||||
Ten webinar ma teraz obsługiwać {PROVIDER_LABEL[data.effective] ?? data.effective}.
|
||||
Zamknij stary pokój, żeby nie został otwarty i płatny u poprzedniego dostawcy.
|
||||
Uczestnicy z linkiem sprzed zmiany trafią w puste miejsce.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="mt-2"
|
||||
disabled={migrate.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Zamknąć pokój u dostawcy ${data.roomProvider}?\n\n` +
|
||||
'Tej operacji nie da się cofnąć. Nowy pokój założysz przyciskiem poniżej.',
|
||||
)
|
||||
) {
|
||||
migrate.mutate()
|
||||
}
|
||||
}}
|
||||
>
|
||||
Zamknij stary pokój
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ostrzezenie o Jitsi.
|
||||
*
|
||||
* Osobny komponent, bo tresc zalezy od tego, czy serwer ma wlaczone JWT,
|
||||
* a to jest ustawienie instancji, nie webinaru.
|
||||
*/
|
||||
function JitsiWarning() {
|
||||
const settings = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<{ key: string; value: string | null }[]>('/admin/settings')
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
const jwtEnabled =
|
||||
settings.data?.find((s) => s.key === 'JITSI_JWT_ENABLED')?.value === 'true'
|
||||
|
||||
if (jwtEnabled) {
|
||||
return (
|
||||
<p className="rounded-md border border-slate-200 bg-slate-25 p-3 text-xs leading-relaxed text-slate-600">
|
||||
Token JWT jest włączony, więc serwer sprawdza tożsamość uczestników i egzekwuje rolę
|
||||
moderatora. Jitsi nie rozróżnia jednak uprawnienia „tylko dźwięk”: różnica między
|
||||
uczestnikiem a uczestnikiem z głosem jest widoczna wyłącznie w interfejsie.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-alarm-500/30 bg-alarm-50 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon name="alert-triangle" size={16} className="mt-0.5 shrink-0 text-alarm-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-alarm-600">
|
||||
Pokój będzie otwarty, a role tylko umowne
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-slate-700">
|
||||
Token JWT jest wyłączony, więc <strong>każdy, kto zna adres pokoju, wejdzie do
|
||||
środka</strong> — także osoba niezapisana. Ukrycie przycisku kamery uczestnikom to
|
||||
jedyne zabezpieczenie, a wystarczy otworzyć ten sam pokój bezpośrednio na serwerze
|
||||
Jitsi, żeby je obejść.
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-slate-700">
|
||||
Jeśli potrzebujesz gwarancji po stronie serwera, włącz JWT w ustawieniach wideo
|
||||
(wymaga własnego serwera Jitsi) albo wybierz <strong>LiveKit</strong>, gdzie
|
||||
uprawnienia są w podpisanym tokenie.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { NotificationChannel, ReminderRule } from '../../api/types'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Badge, Button, Card, ErrorState, Input, Select, Spinner } from '../../components/ui'
|
||||
import { formatWithZone } from '../../lib/dates'
|
||||
import { describeOffset, REMINDER_PRESETS as PRESETS } from '../../lib/reminders'
|
||||
|
||||
export function ReminderSection({ webinarId, timezone }: { webinarId: number; timezone: string }) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const [offset, setOffset] = useState('1440')
|
||||
const [channel, setChannel] = useState<NotificationChannel>('EMAIL')
|
||||
|
||||
const rules = useQuery({
|
||||
queryKey: ['reminders', webinarId],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<ReminderRule[]>(`/admin/webinars/${webinarId}/reminders`)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
function invalidate() {
|
||||
queryClient.invalidateQueries({ queryKey: ['reminders', webinarId] })
|
||||
}
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () =>
|
||||
client.post(`/admin/webinars/${webinarId}/reminders`, {
|
||||
offsetMinutes: Number(offset),
|
||||
channel,
|
||||
template: 'reminder',
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toasts.success('Reguła została dodana')
|
||||
invalidate()
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się dodać reguły')),
|
||||
})
|
||||
|
||||
const toggle = useMutation({
|
||||
mutationFn: ({ id, value }: { id: number; value: boolean }) =>
|
||||
client.post(`/admin/webinars/${webinarId}/reminders/${id}/enabled?value=${value}`),
|
||||
onSuccess: () => invalidate(),
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się zmienić reguły')),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => client.delete(`/admin/webinars/${webinarId}/reminders/${id}`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Reguła została usunięta')
|
||||
invalidate()
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć reguły')),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Przypomnienia"
|
||||
description="Wiadomości wysyłane automatycznie przed startem webinaru"
|
||||
>
|
||||
{rules.isPending && <Spinner />}
|
||||
{rules.isError && (
|
||||
<ErrorState message={errorMessage(rules.error, 'Nie udało się wczytać reguł')} />
|
||||
)}
|
||||
|
||||
{rules.data && (
|
||||
<div className="space-y-4">
|
||||
{rules.data.length === 0 ? (
|
||||
<p className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-500">
|
||||
Brak reguł. Ten webinar nie wyśle żadnych przypomnień.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100 rounded-md border border-slate-200">
|
||||
{rules.data.map((rule) => (
|
||||
<li key={rule.id} className="flex flex-wrap items-center gap-3 p-3">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.enabled}
|
||||
onChange={(e) => toggle.mutate({ id: rule.id, value: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-slate-200 text-blue-700 focus:ring-blue-600"
|
||||
aria-label={rule.enabled ? 'Wyłącz regułę' : 'Włącz regułę'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm font-medium ${rule.enabled ? 'text-slate-900' : 'text-slate-400'}`}>
|
||||
{describeOffset(rule.offsetMinutes)}
|
||||
<span className="ml-2 text-xs font-normal text-slate-500">
|
||||
{rule.channel === 'SMS' ? 'SMS' : 'e-mail'}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{formatWithZone(rule.scheduledAt, timezone)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* liczniki pokazuja, co juz poszlo z tej reguly */}
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
{rule.sentCount > 0 && (
|
||||
<Badge className="bg-ok-50 text-ok-600 ring-ok-500/20">
|
||||
wysłano {rule.sentCount}
|
||||
</Badge>
|
||||
)}
|
||||
{rule.queuedCount > 0 && (
|
||||
<Badge className="bg-warn-50 text-warn-600 ring-warn-500/25">
|
||||
w kolejce {rule.queuedCount}
|
||||
</Badge>
|
||||
)}
|
||||
{rule.failedCount > 0 && (
|
||||
<Badge className="bg-alarm-50 text-alarm-600 ring-alarm-500/20">
|
||||
błędy {rule.failedCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
icon="trash"
|
||||
onClick={() => {
|
||||
const extra = rule.alreadySent
|
||||
? '\n\nZ tej reguły wysłano już wiadomości. Historia wysyłek zostanie usunięta.'
|
||||
: ''
|
||||
if (window.confirm(`Usunąć regułę "${describeOffset(rule.offsetMinutes)}"?${extra}`)) {
|
||||
remove.mutate(rule.id)
|
||||
}
|
||||
}}
|
||||
className="shrink-0 text-alarm-600 hover:bg-alarm-50"
|
||||
title="Usuń regułę"
|
||||
>
|
||||
<span className="sr-only">Usuń</span>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2 border-t border-slate-100 pt-4">
|
||||
<div className="w-44">
|
||||
<label className="mb-1 block text-xs font-semibold text-slate-700">Kiedy wysłać</label>
|
||||
<Select value={offset} onChange={(e) => setOffset(e.target.value)}>
|
||||
{PRESETS.map((preset) => (
|
||||
<option key={preset.minutes} value={preset.minutes}>
|
||||
{preset.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<label className="mb-1 block text-xs font-semibold text-slate-700">Kanał</label>
|
||||
<Select
|
||||
value={channel}
|
||||
onChange={(e) => setChannel(e.target.value as NotificationChannel)}
|
||||
>
|
||||
<option value="EMAIL">E-mail</option>
|
||||
<option value="SMS">SMS</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<label className="mb-1 block text-xs font-semibold text-slate-700">Własny odstęp</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={offset}
|
||||
onChange={(e) => setOffset(e.target.value)}
|
||||
placeholder="minuty"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
icon="plus"
|
||||
variant="secondary"
|
||||
onClick={() => add.mutate()}
|
||||
disabled={add.isPending || !offset || Number(offset) < 1}
|
||||
>
|
||||
Dodaj
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rules.data.some((rule) => rule.channel === 'SMS') && (
|
||||
<p className="rounded-md border border-blue-200 bg-blue-50 p-3 text-xs leading-relaxed text-blue-900">
|
||||
SMS trafi wyłącznie do uczestników, którzy podali numer telefonu
|
||||
i wyrazili zgodę marketingową na SMS.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { Modal } from '../../components/Modal'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Button } from '../../components/ui'
|
||||
|
||||
/**
|
||||
* Kolejka zgloszen do zabrania glosu, po stronie prowadzacego.
|
||||
*
|
||||
* KOLEJNOSC JEST REGULA, NIE ESTETYKA
|
||||
* ------------------------------------------------------------------------
|
||||
* Najstarsze zgloszenie na gorze: kto pierwszy podniosl reke, ten pierwszy
|
||||
* dostaje glos. Sortowanie po nazwisku albo losowe sprawialoby, ze uczestnik
|
||||
* czekajacy piec minut widzi, jak ktos zgloszony przed chwila mowi przed nim.
|
||||
* Dlatego pokazujemy takze, **od kiedy** ktos czeka.
|
||||
*
|
||||
* MAJACY GLOS ZOSTAJE NA LISCIE
|
||||
* ------------------------------------------------------------------------
|
||||
* Osoba, ktorej przyznano glos, nie znika z kolejki, tylko zmienia stan.
|
||||
* Prowadzacy musi miec jedno miejsce, w ktorym odbiera glos, a nie szukac
|
||||
* po liscie zapisanych.
|
||||
*/
|
||||
|
||||
export type SpeakRequestView = {
|
||||
id: number
|
||||
registrationId: number
|
||||
participantName: string
|
||||
status: 'PENDING' | 'GRANTED' | 'REVOKED' | 'WITHDRAWN' | 'DECLINED'
|
||||
createdAt: string
|
||||
resolvedAt: string | null
|
||||
}
|
||||
|
||||
export function SpeakQueue({
|
||||
webinarId,
|
||||
requests,
|
||||
openMic,
|
||||
onOpenMicChange,
|
||||
openVideo,
|
||||
onOpenVideoChange,
|
||||
onChanged,
|
||||
}: {
|
||||
webinarId: string
|
||||
requests: SpeakRequestView[]
|
||||
/** Czy kazdy uczestnik moze mowic bez proszenia o glos. */
|
||||
openMic: boolean
|
||||
onOpenMicChange: (enabled: boolean) => void
|
||||
/** Czy kazdy uczestnik moze wlaczyc kamere (wideo dla wszystkich). */
|
||||
openVideo: boolean
|
||||
onOpenVideoChange: (enabled: boolean) => void
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const toasts = useToasts()
|
||||
const [busy, setBusy] = useState<number | null>(null)
|
||||
const [switching, setSwitching] = useState(false)
|
||||
// okno potwierdzenia wlaczenia otwartego mikrofonu (zamiast window.confirm)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
|
||||
/**
|
||||
* Przelaczenie trybu otwartego mikrofonu.
|
||||
*
|
||||
* Wlaczenie pytamy o potwierdzenie, wylaczenie nie. Otwarcie mikrofonow
|
||||
* calej sali jest slyszalne natychmiast dla wszystkich i trudno to cofnac
|
||||
* po fakcie: ktos zdazy powiedziec cos przy wlaczonym mikrofonie, nie
|
||||
* wiedzac, ze jest slyszany. Zamkniecie mikrofonow nikomu nie szkodzi.
|
||||
*
|
||||
* Potwierdzenie wlaczenia idzie oknem modalnym (spojnym z reszta panelu),
|
||||
* a nie natywnym window.confirm: to operacja o skutku slyszalnym dla calej
|
||||
* sali, wiec pytanie ma wygladac jak swiadoma decyzja, a nie systemowy alert.
|
||||
*/
|
||||
function toggleOpenMic() {
|
||||
if (!openMic) {
|
||||
setConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
void applyOpenMic(false)
|
||||
}
|
||||
|
||||
async function applyOpenMic(enabled: boolean) {
|
||||
setSwitching(true)
|
||||
try {
|
||||
await client.put(`/admin/webinars/${webinarId}/speak-requests/open-mic`, {
|
||||
openMic: enabled,
|
||||
})
|
||||
onOpenMicChange(enabled)
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się zmienić trybu mikrofonu'))
|
||||
} finally {
|
||||
setSwitching(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Przelaczenie trybu "wideo dla wszystkich".
|
||||
*
|
||||
* Wlaczenie daje kazdemu uczestnikowi prawo do kamery. Nie pytamy o
|
||||
* potwierdzenie: wlaczenie samo w sobie niczego nie otwiera - uczestnik
|
||||
* musi swiadomie wlaczyc kamere. To inaczej niz otwarty mikrofon, gdzie
|
||||
* ktos moze byc slyszany, nie wiedzac o tym.
|
||||
*/
|
||||
async function toggleOpenVideo() {
|
||||
setSwitching(true)
|
||||
try {
|
||||
await client.put(`/admin/webinars/${webinarId}/speak-requests/open-video`, {
|
||||
openVideo: !openVideo,
|
||||
})
|
||||
onOpenVideoChange(!openVideo)
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się zmienić trybu wideo'))
|
||||
} finally {
|
||||
setSwitching(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function act(requestId: number, kind: 'grant' | 'revoke') {
|
||||
setBusy(requestId)
|
||||
try {
|
||||
if (kind === 'grant') {
|
||||
await client.post(`/admin/webinars/${webinarId}/speak-requests/${requestId}/grant`)
|
||||
} else {
|
||||
await client.delete(`/admin/webinars/${webinarId}/speak-requests/${requestId}`)
|
||||
}
|
||||
onChanged()
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się zmienić uprawnień'))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const waiting = requests.filter((r) => r.status === 'PENDING')
|
||||
const speaking = requests.filter((r) => r.status === 'GRANTED')
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmOpen && (
|
||||
<Modal title="Włączyć otwarty mikrofon?" onClose={() => setConfirmOpen(false)}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-md border border-warn-500/30 bg-warn-50 p-3">
|
||||
<Icon name="mic" size={20} className="mt-0.5 shrink-0 text-warn-600" />
|
||||
<p className="text-sm leading-relaxed text-warn-600">
|
||||
Każdy uczestnik będzie mógł mówić od razu, <strong>bez zgłaszania się</strong>.
|
||||
Przy dużej sali oznacza to wiele otwartych mikrofonów naraz - słychać je
|
||||
natychmiast i trudno to cofnąć po fakcie.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">
|
||||
Ten tryb sprawdza się na warsztatach dla kilkunastu osób. Kolejkę zgłoszeń
|
||||
możesz w każdej chwili przywrócić, wyłączając otwarty mikrofon.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => setConfirmOpen(false)}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
icon="mic"
|
||||
disabled={switching}
|
||||
onClick={() => {
|
||||
setConfirmOpen(false)
|
||||
void applyOpenMic(true)
|
||||
}}
|
||||
>
|
||||
Włącz otwarty mikrofon
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
<section className="flex max-h-[70vh] min-h-0 flex-col overflow-hidden rounded-lg border border-slate-200 bg-white">
|
||||
<header className="space-y-2 border-b border-slate-200 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-base font-semibold text-slate-900">Zgłoszenia do głosu</h2>
|
||||
{waiting.length > 0 && !openMic && (
|
||||
<span className="hx-data inline-flex min-w-[20px] items-center justify-center rounded-full bg-warn-500 px-1.5 text-xs font-semibold text-white">
|
||||
{waiting.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleOpenMic}
|
||||
disabled={switching}
|
||||
aria-pressed={openMic}
|
||||
className={`flex w-full items-center gap-2 rounded-md border px-3 py-2 text-left text-sm transition-colors duration-fast ease-standard disabled:opacity-50 ${
|
||||
openMic
|
||||
? 'border-ok-500/30 bg-ok-50 text-ok-600'
|
||||
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Icon name={openMic ? 'mic' : 'mic-off'} size={16} className="shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium">
|
||||
{openMic ? 'Otwarty mikrofon włączony' : 'Otwarty mikrofon wyłączony'}
|
||||
</span>
|
||||
<span className="block text-xs opacity-80">
|
||||
{openMic
|
||||
? 'Każdy mówi bez zgłaszania się'
|
||||
: 'Uczestnicy muszą prosić o głos'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/*
|
||||
Wideo dla wszystkich: domyslnie widoczni sa tylko prelegenci, reszta
|
||||
to ikony. Wlaczenie daje kazdemu prawo do kamery i kafelki rozkladaja
|
||||
sie rownomiernie. Aktywny mowca i tak trafia na duze okno.
|
||||
*/}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleOpenVideo}
|
||||
disabled={switching}
|
||||
aria-pressed={openVideo}
|
||||
className={`flex w-full items-center gap-2 rounded-md border px-3 py-2 text-left text-sm transition-colors duration-fast ease-standard disabled:opacity-50 ${
|
||||
openVideo
|
||||
? 'border-ok-500/30 bg-ok-50 text-ok-600'
|
||||
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Icon name={openVideo ? 'video' : 'video-off'} size={16} className="shrink-0" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium">
|
||||
{openVideo ? 'Wideo dla wszystkich włączone' : 'Widoczni tylko prelegenci'}
|
||||
</span>
|
||||
<span className="block text-xs opacity-80">
|
||||
{openVideo
|
||||
? 'Każdy uczestnik może włączyć kamerę'
|
||||
: 'Uczestnicy widoczni jako ikony, tylko prelegenci na wideo'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
|
||||
{/*
|
||||
Kolejka zostaje widoczna takze przy otwartym mikrofonie: prowadzacy
|
||||
moze wylaczyc tryb po dyskusji, a wtedy czekajacy wracaja do gry.
|
||||
Kasowanie zgloszen przy wlaczeniu trybu odbieraloby miejsce osobom,
|
||||
ktore czekaja od kwadransa.
|
||||
*/}
|
||||
{openMic && requests.length > 0 && (
|
||||
<p className="rounded-md bg-slate-50 p-2 text-xs text-slate-500">
|
||||
Mikrofon jest otwarty dla wszystkich, więc zgłoszenia poniżej czekają bezczynnie.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{requests.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
{openMic ? 'Mikrofon jest otwarty dla wszystkich.' : 'Nikt nie prosi o głos.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* majacy glos na gorze: to nimi prowadzacy zarzadza w pierwszej kolejnosci */}
|
||||
{speaking.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="rounded-md border border-ok-500/30 bg-ok-50 p-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="mic" size={16} className="shrink-0 text-ok-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-900">
|
||||
{request.participantName}
|
||||
</p>
|
||||
<p className="text-xs text-ok-600">
|
||||
Mówi od {timeOf(request.resolvedAt ?? request.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="mic-off"
|
||||
disabled={busy === request.id}
|
||||
onClick={() => act(request.id, 'revoke')}
|
||||
>
|
||||
Odbierz
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{waiting.map((request) => (
|
||||
<div key={request.id} className="rounded-md border border-slate-200 p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="hand" size={16} className="shrink-0 text-warn-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-900">
|
||||
{request.participantName}
|
||||
</p>
|
||||
<p className="hx-data text-xs text-slate-500">
|
||||
czeka od {timeOf(request.createdAt)} (<Waiting since={request.createdAt} />)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
icon="mic"
|
||||
disabled={busy === request.id}
|
||||
onClick={() => act(request.id, 'grant')}
|
||||
>
|
||||
Daj głos
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy === request.id}
|
||||
onClick={() => act(request.id, 'revoke')}
|
||||
>
|
||||
Odrzuć
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Sama godzina: prowadzacy patrzy na to w trakcie spotkania, data nic nie wnosi. */
|
||||
function timeOf(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString('pl-PL', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Ile juz czeka, odswiezane co dziesiec sekund.
|
||||
*
|
||||
* "czeka od 14:32" nie mowi prowadzacemu nic, dopoki sam nie sprawdzi zegarka.
|
||||
* "3 min" mowi wprost, ze ktos czeka za dlugo.
|
||||
*/
|
||||
function Waiting({ since }: { since: string }) {
|
||||
const [label, setLabel] = useState(() => elapsed(since))
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setLabel(elapsed(since)), 10_000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [since])
|
||||
|
||||
return <>{label}</>
|
||||
}
|
||||
|
||||
function elapsed(iso: string): string {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000))
|
||||
if (seconds < 60) return 'przed chwilą'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes} min`
|
||||
return `${Math.floor(minutes / 60)} godz. ${minutes % 60} min`
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BarChart } from '@mui/x-charts/BarChart'
|
||||
import { LineChart } from '@mui/x-charts/LineChart'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { WebinarStats } from '../../api/types'
|
||||
import { Icon, type IconName } from '../../components/Icon'
|
||||
import { Card, ErrorState, Spinner } from '../../components/ui'
|
||||
|
||||
/**
|
||||
* Statystyki webinaru.
|
||||
*
|
||||
* Wszystkie liczby przychodza gotowe z backendu, policzone zapytaniami
|
||||
* agregujacymi. Front niczego nie sumuje: przy kilkuset uczestnikach
|
||||
* przeliczanie w przegladarce byloby wolniejsze od samego pobrania.
|
||||
*
|
||||
* Wykresy z @mui/x-charts, kolory z tokenow HydroNexis podane wprost, bo
|
||||
* biblioteka nie czyta klas Tailwinda.
|
||||
*/
|
||||
|
||||
/** Kolory wykresow. Odpowiadaja tokenom z tailwind.config.js. */
|
||||
const HYDRO_AZURE = '#2C8ABD'
|
||||
const OK = '#16935C'
|
||||
const ALARM = '#D8392F'
|
||||
|
||||
export function StatsSection({ webinarId, timezone }: { webinarId: number; timezone: string }) {
|
||||
const query = useQuery({
|
||||
queryKey: ['stats', webinarId],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<WebinarStats>(`/admin/webinars/${webinarId}/stats`)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
if (query.isPending) return <Spinner />
|
||||
if (query.isError) {
|
||||
return <ErrorState message={errorMessage(query.error, 'Nie udało się wczytać statystyk')} />
|
||||
}
|
||||
|
||||
const stats = query.data
|
||||
const buckets = stats.durationBuckets
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Cztery najważniejsze liczby na wierzchu */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<Metric icon="users" label="Zapisani" value={stats.registered} />
|
||||
<Metric icon="check-circle" label="Obecni" value={stats.attended} tone="ok" />
|
||||
<Metric
|
||||
icon="presentation"
|
||||
label="Frekwencja"
|
||||
value={`${stats.attendanceRate}%`}
|
||||
tone={stats.attendanceRate >= 50 ? 'ok' : 'warn'}
|
||||
/>
|
||||
<Metric icon="send" label="Zapytania ofertowe" value={stats.interactions.offers} />
|
||||
</div>
|
||||
|
||||
<Card title="Obecni w czasie" description="Liczba osób w pokoju, co pięć minut">
|
||||
{stats.presenceTimeline.length === 0 ? (
|
||||
<EmptyChart text="Wykres pojawi się po rozpoczęciu webinaru." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<LineChart
|
||||
height={260}
|
||||
series={[
|
||||
{
|
||||
data: stats.presenceTimeline.map((p) => p.present),
|
||||
label: 'Obecni',
|
||||
color: HYDRO_AZURE,
|
||||
area: true,
|
||||
showMark: false,
|
||||
},
|
||||
]}
|
||||
xAxis={[
|
||||
{
|
||||
scaleType: 'point',
|
||||
data: stats.presenceTimeline.map((p) => formatTime(p.moment, timezone)),
|
||||
},
|
||||
]}
|
||||
/* liczba osob jest calkowita, wiec bez ulamkow na osi */
|
||||
yAxis={[{ min: 0 }]}
|
||||
margin={{ left: 40, right: 16, top: 16, bottom: 40 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Jak długo zostawali"
|
||||
description={`Podział według łącznego czasu obecności`}
|
||||
>
|
||||
{stats.attended === 0 ? (
|
||||
<EmptyChart text="Nikt jeszcze nie był obecny." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<BarChart
|
||||
height={240}
|
||||
series={[
|
||||
{
|
||||
data: [buckets.shortStay, buckets.mediumStay, buckets.longStay],
|
||||
label: 'Uczestnicy',
|
||||
color: HYDRO_AZURE,
|
||||
},
|
||||
]}
|
||||
xAxis={[
|
||||
{
|
||||
scaleType: 'band',
|
||||
data: [
|
||||
`krócej niż ${buckets.shortThresholdMinutes} min`,
|
||||
`${buckets.shortThresholdMinutes}-${buckets.longThresholdMinutes} min`,
|
||||
`dłużej niż ${buckets.longThresholdMinutes} min`,
|
||||
],
|
||||
},
|
||||
]}
|
||||
yAxis={[{ min: 0 }]}
|
||||
margin={{ left: 40, right: 16, top: 16, bottom: 40 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* skutecznosc zaproszen: ilu z zaproszonych faktycznie sie zapisalo */}
|
||||
{stats.invitations.sent > 0 && (
|
||||
<Card title="Zaproszenia" description="Ilu z zaproszonych zapisało się na webinar">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Metric icon="mail" label="Wysłane" value={stats.invitations.sent} />
|
||||
<Metric icon="user-plus" label="Zapisy z zaproszeń" value={stats.invitations.registered} tone="ok" />
|
||||
<Metric
|
||||
icon="check-circle"
|
||||
label="Skuteczność"
|
||||
value={`${stats.invitations.rate}%`}
|
||||
tone={stats.invitations.rate >= 20 ? 'ok' : 'warn'}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Banery: wyswietlenia, klikalnosc i to, co interesuje najbardziej, czyli
|
||||
ile kontaktow handlowych przyniosla reklama. Sekcja znika, gdy zadnego
|
||||
banera nie bylo: pusta tabela niczego nie wnosi.
|
||||
*/}
|
||||
{stats.banners.length > 0 && (
|
||||
<Card title="Banery" description="Skuteczność reklam pokazywanych w sali">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-semibold">Baner</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Wyświetlenia</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Kliknięcia</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Klikalność</th>
|
||||
<th className="py-2 pl-3 text-right font-semibold">Zapytania</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{stats.banners.map((banner) => (
|
||||
<tr key={banner.bannerId}>
|
||||
<td className="py-2 pr-3 text-slate-700">{banner.title}</td>
|
||||
<td className="hx-data px-3 py-2 text-right text-slate-600">
|
||||
{banner.impressions}
|
||||
</td>
|
||||
<td className="hx-data px-3 py-2 text-right text-slate-600">{banner.clicks}</td>
|
||||
<td className="hx-data px-3 py-2 text-right font-medium text-slate-900">
|
||||
{banner.clickRate.toFixed(1)}%
|
||||
</td>
|
||||
<td className="hx-data py-2 pl-3 text-right font-medium text-ok-600">
|
||||
{banner.offers}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card title="Aktywność uczestników">
|
||||
<dl className="divide-y divide-slate-100">
|
||||
<StatRow label="Pytania" value={stats.interactions.questions} />
|
||||
<StatRow label="Pytania zatwierdzone" value={stats.interactions.questionsApproved} />
|
||||
<StatRow label="Pytania z odpowiedzią" value={stats.interactions.questionsAnswered} />
|
||||
<StatRow label="Wiadomości na czacie" value={stats.interactions.chatMessages} />
|
||||
<StatRow label="Zgłoszenia do zadań" value={stats.interactions.taskSignups} />
|
||||
<StatRow label="Zapytania ofertowe" value={stats.interactions.offers} />
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card title="Wysyłki" description="Maile i SMS-y związane z tym webinarem">
|
||||
{stats.messages.emailSent + stats.messages.emailFailed +
|
||||
stats.messages.smsSent + stats.messages.smsFailed === 0 ? (
|
||||
<EmptyChart text="Nic jeszcze nie zostało wysłane." />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<BarChart
|
||||
height={220}
|
||||
series={[
|
||||
{
|
||||
data: [stats.messages.emailSent, stats.messages.smsSent],
|
||||
label: 'Wysłane',
|
||||
color: OK,
|
||||
stack: 'total',
|
||||
},
|
||||
{
|
||||
data: [stats.messages.emailFailed, stats.messages.smsFailed],
|
||||
label: 'Nieudane',
|
||||
color: ALARM,
|
||||
stack: 'total',
|
||||
},
|
||||
]}
|
||||
xAxis={[{ scaleType: 'band', data: ['E-mail', 'SMS'] }]}
|
||||
yAxis={[{ min: 0 }]}
|
||||
margin={{ left: 40, right: 16, top: 16, bottom: 40 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Metric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
icon: IconName
|
||||
label: string
|
||||
value: string | number
|
||||
tone?: 'neutral' | 'ok' | 'warn'
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'ok' ? 'text-ok-600' : tone === 'warn' ? 'text-warn-600' : 'text-slate-900'
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div className="flex items-center gap-2 text-slate-500">
|
||||
<Icon name={icon} size={16} />
|
||||
<span className="hx-eyebrow">{label}</span>
|
||||
</div>
|
||||
<p className={`hx-data mt-1.5 text-2xl font-semibold ${toneClass}`}>{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatRow({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<dt className="text-sm text-slate-600">{label}</dt>
|
||||
<dd className="hx-data font-medium text-slate-900">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyChart({ text }: { text: string }) {
|
||||
return (
|
||||
<p className="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
|
||||
{text}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/** Sama godzina, w strefie webinaru: wykres pokazuje przebieg spotkania. */
|
||||
function formatTime(iso: string, timezone: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('pl-PL', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: timezone,
|
||||
}).format(new Date(iso))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Badge, Button, Card, ErrorState, IconButton, Input, Spinner } from '../../components/ui'
|
||||
import { usePublicUrl } from '../../lib/usePublicConfig'
|
||||
import { ProviderPicker } from './ProviderPicker'
|
||||
|
||||
/**
|
||||
* Pokoj wideo i prelegenci.
|
||||
*
|
||||
* Sekcja wymaga zapisanego webinaru, bo pokoj i prelegenci wisza na jego id.
|
||||
* W trybie NONE nie ma czego zakladac: link do spotkania wkleja organizator
|
||||
* w zakladce "Zapisy", a ta sekcja tlumaczy, skad sie bierze.
|
||||
*/
|
||||
|
||||
type Speaker = {
|
||||
id: number
|
||||
email: string
|
||||
fullName: string
|
||||
accessToken: string
|
||||
joinPath: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function VideoSection({
|
||||
webinarId,
|
||||
joinUrl,
|
||||
videoProvider,
|
||||
onProviderChange,
|
||||
}: {
|
||||
webinarId: number
|
||||
joinUrl: string
|
||||
/** Wybor dostawcy dla tego webinaru. Pusty ciag oznacza dostawce instancji. */
|
||||
videoProvider: string
|
||||
onProviderChange: (value: string) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const publicUrl = usePublicUrl()
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [fullName, setFullName] = useState('')
|
||||
|
||||
const speakers = useQuery({
|
||||
queryKey: ['speakers', webinarId],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Speaker[]>(`/admin/webinars/${webinarId}/speakers`)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
/*
|
||||
* Stan pokoju u dostawcy. To jest ZRODLO PRAWDY o tym, czy pokoj istnieje,
|
||||
* a nie prop `joinUrl`: LiveKit i Jitsi NIE zapisuja join_url (pokoj powstaje
|
||||
* z room_id i room_provider), wiec po ich utworzeniu joinUrl dalej jest pusty.
|
||||
* `roomProvider` z tego zapytania mowi wprost, u kogo pokoj stoi (null = brak).
|
||||
* Wspoldzieli klucz z ProviderPicker, wiec nie ma drugiego zapytania.
|
||||
*/
|
||||
const providerState = useQuery({
|
||||
queryKey: ['video-provider', webinarId],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<{ effective: string; roomProvider: string | null }>(
|
||||
`/admin/webinars/${webinarId}/video-provider`,
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
const roomProvider = providerState.data?.roomProvider ?? null
|
||||
const hasRoom = Boolean(roomProvider) || Boolean(joinUrl)
|
||||
|
||||
const createRoom = useMutation({
|
||||
mutationFn: () => client.post(`/admin/webinars/${webinarId}/room`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Pokój został utworzony u dostawcy')
|
||||
// odswiezamy STAN POKOJU, zeby komunikat i przycisk od razu pokazaly,
|
||||
// ze pokoj juz jest - bez ponownego wchodzenia w edycje webinaru
|
||||
queryClient.invalidateQueries({ queryKey: ['video-provider', webinarId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['webinar', String(webinarId)] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się utworzyć pokoju')),
|
||||
})
|
||||
|
||||
const addSpeaker = useMutation({
|
||||
mutationFn: () =>
|
||||
client.post(`/admin/webinars/${webinarId}/speakers`, { email, fullName }),
|
||||
onSuccess: () => {
|
||||
toasts.success('Prelegent został dodany')
|
||||
setEmail('')
|
||||
setFullName('')
|
||||
queryClient.invalidateQueries({ queryKey: ['speakers', webinarId] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się dodać prelegenta')),
|
||||
})
|
||||
|
||||
const removeSpeaker = useMutation({
|
||||
mutationFn: (id: number) => client.delete(`/admin/webinars/${webinarId}/speakers/${id}`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Prelegent został usunięty')
|
||||
queryClient.invalidateQueries({ queryKey: ['speakers', webinarId] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć prelegenta')),
|
||||
})
|
||||
|
||||
function copy(text: string) {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => toasts.success('Link skopiowany'),
|
||||
() => toasts.error('Nie udało się skopiować linku'),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card
|
||||
title="Pokój wideo"
|
||||
description="Ten webinar może użyć innego dostawcy niż reszta instancji."
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ProviderPicker
|
||||
webinarId={webinarId}
|
||||
value={videoProvider}
|
||||
onChange={onProviderChange}
|
||||
/>
|
||||
|
||||
<p className="rounded-md border border-slate-200 bg-slate-25 p-3 text-sm leading-relaxed text-slate-600">
|
||||
Jeśli instancja działa bez wbudowanego wideo, wystarczy wkleić link do spotkania
|
||||
w zakładce <span className="font-medium">Zapisy</span>. Uczestnik zostanie tam
|
||||
przekierowany po kliknięciu linku z maila.
|
||||
</p>
|
||||
|
||||
{providerState.isPending ? (
|
||||
<p className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Spinner /> Sprawdzam stan pokoju...
|
||||
</p>
|
||||
) : hasRoom ? (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-ok-500/30 bg-ok-50 p-3 text-sm text-ok-600">
|
||||
<Icon name="video" size={16} className="text-ok-500" />
|
||||
{roomProvider
|
||||
? `Pokój jest utworzony u dostawcy ${roomProvider}. Uczestnicy wejdą przez link z maila potwierdzającego.`
|
||||
: 'Link do spotkania jest ustawiony. Uczestnicy wejdą przez link z maila potwierdzającego.'}
|
||||
</div>
|
||||
) : (
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-warn-600">
|
||||
<Icon name="alert-triangle" size={16} />
|
||||
Brak pokoju. Uczestnik nie będzie miał gdzie wejść. Utwórz pokój przyciskiem poniżej.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
icon="video"
|
||||
onClick={() => createRoom.mutate()}
|
||||
disabled={createRoom.isPending}
|
||||
>
|
||||
{createRoom.isPending
|
||||
? 'Tworzenie...'
|
||||
: hasRoom
|
||||
? 'Utwórz pokój ponownie'
|
||||
: 'Utwórz pokój u dostawcy'}
|
||||
</Button>
|
||||
<p className="text-xs leading-relaxed text-slate-500">
|
||||
Działa tylko, gdy w ustawieniach instancji wybrano dostawcę wideo i podano klucz API.
|
||||
W trybie bez wideo przycisk wyjaśni, czego brakuje.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Prelegenci"
|
||||
description="Osoby prowadzące, które nie zapisują się przez formularz. Wchodzą własnym linkiem, z prawem publikacji obrazu i dźwięku."
|
||||
>
|
||||
{speakers.isPending && <Spinner />}
|
||||
{speakers.isError && (
|
||||
<ErrorState message={errorMessage(speakers.error, 'Nie udało się wczytać prelegentów')} />
|
||||
)}
|
||||
|
||||
{speakers.data && speakers.data.length === 0 && (
|
||||
<p className="rounded-md border border-dashed border-slate-300 p-4 text-center text-sm text-slate-500">
|
||||
Brak prelegentów. Uczestnicy z zapisu mogą dostać głos na liście zapisanych.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{speakers.data && speakers.data.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{speakers.data.map((speaker) => {
|
||||
const link = `${publicUrl}${speaker.joinPath}`
|
||||
return (
|
||||
<li
|
||||
key={speaker.id}
|
||||
className="flex flex-wrap items-center gap-3 rounded-md border border-slate-200 p-3"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-slate-900">
|
||||
{speaker.fullName}
|
||||
</span>
|
||||
<span className="hx-data block break-all text-xs text-slate-500">
|
||||
{speaker.email}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<Badge tone="brand">prelegent</Badge>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="copy"
|
||||
onClick={() => copy(link)}
|
||||
disabled={!publicUrl}
|
||||
title={publicUrl ? link : 'Ustaw publiczny adres instancji w ustawieniach'}
|
||||
>
|
||||
Kopiuj link
|
||||
</Button>
|
||||
|
||||
<IconButton
|
||||
icon="trash"
|
||||
label={`Usuń prelegenta ${speaker.fullName}`}
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Usunąć prelegenta ${speaker.fullName}?`)) {
|
||||
removeSpeaker.mutate(speaker.id)
|
||||
}
|
||||
}}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-3 rounded-md border border-slate-200 bg-slate-25 p-4">
|
||||
<p className="text-sm font-medium text-slate-900">Dodaj prelegenta</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-semibold text-slate-700">Imię i nazwisko</span>
|
||||
<Input value={fullName} onChange={(e) => setFullName(e.target.value)} placeholder="np. Anna Kowalska" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-semibold text-slate-700">Adres e-mail</span>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="anna@example.pl"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-slate-500">
|
||||
Adres służy tylko do odróżnienia prelegentów. Zaproszenie wysyłasz sam, kopiując link.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
onClick={() => addSpeaker.mutate()}
|
||||
disabled={addSpeaker.isPending || !email.trim() || !fullName.trim()}
|
||||
>
|
||||
Dodaj prelegenta
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage, fieldErrors } from '../../api/client'
|
||||
import type { Webinar, WebinarRequest } from '../../api/types'
|
||||
import { useAuth } from '../../auth/AuthContext'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Button, Card, ErrorState, Field, Input, Select, Spinner, Textarea } from '../../components/ui'
|
||||
import { COMMON_TIMEZONES, isoToZonedInput, zonedInputToIso } from '../../lib/dates'
|
||||
import { slugify } from '../../lib/labels'
|
||||
import { usePublicUrl } from '../../lib/usePublicConfig'
|
||||
import { ReminderSection } from './ReminderSection'
|
||||
import { ContentEditor } from '../../components/ContentEditor'
|
||||
import { VideoSection } from './VideoSection'
|
||||
import { FilesSection } from './FilesSection'
|
||||
import { FormTabs, type FormTab } from '../../components/FormTabs'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { panelPath } from '../../lib/routes'
|
||||
|
||||
type FormState = {
|
||||
title: string
|
||||
slug: string
|
||||
description: string
|
||||
contentHtml: string
|
||||
startsAt: string
|
||||
endsAt: string
|
||||
timezone: string
|
||||
registrationOpen: boolean
|
||||
registrationDeadline: string
|
||||
capacity: string
|
||||
joinUrl: string
|
||||
/** Wybor dostawcy wideo. Pusty ciag oznacza dostawce instancji. */
|
||||
videoProvider: string
|
||||
recordingUrl: string
|
||||
materialsUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Zakladki formularza.
|
||||
*
|
||||
* `fields` sluzy do oznaczenia zakladki kropka, gdy walidacja znajdzie blad
|
||||
* w ktoryms z jej pol, i do przeskoczenia na pierwsza wadliwa zakladke przy
|
||||
* probie zapisu. Bez tego blad na nieaktywnej zakladce bylby niewidoczny.
|
||||
*/
|
||||
const TABS: FormTab[] = [
|
||||
{ id: 'podstawowe', label: 'Podstawowe', icon: 'file-text', fields: ['title', 'slug', 'description'] },
|
||||
{ id: 'tresc', label: 'Treść strony', icon: 'pencil', fields: ['contentHtml'] },
|
||||
{ id: 'termin', label: 'Termin', icon: 'clock', fields: ['startsAt', 'endsAt', 'timezone'] },
|
||||
{ id: 'zapisy', label: 'Zapisy', icon: 'users', fields: ['capacity', 'registrationDeadline', 'joinUrl'] },
|
||||
{
|
||||
id: 'po-webinarze',
|
||||
label: 'Po webinarze',
|
||||
icon: 'video',
|
||||
fields: ['recordingUrl', 'materialsUrl'],
|
||||
},
|
||||
{ id: 'pliki', label: 'Nagrania i materiały', icon: 'video' },
|
||||
{ id: 'przypomnienia', label: 'Przypomnienia', icon: 'send' },
|
||||
{ id: 'wideo', label: 'Wideo', icon: 'video' },
|
||||
]
|
||||
|
||||
const EMPTY: FormState = {
|
||||
title: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
contentHtml: '',
|
||||
startsAt: '',
|
||||
endsAt: '',
|
||||
timezone: 'Europe/Warsaw',
|
||||
registrationOpen: true,
|
||||
registrationDeadline: '',
|
||||
capacity: '',
|
||||
joinUrl: '',
|
||||
videoProvider: '',
|
||||
recordingUrl: '',
|
||||
materialsUrl: '',
|
||||
}
|
||||
|
||||
export function WebinarFormPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const isEdit = Boolean(id)
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
const { isAdmin } = useAuth()
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
/** Czy administrator zmienil slug recznie. Jesli nie, podpowiadamy go z tytulu. */
|
||||
const [slugTouched, setSlugTouched] = useState(false)
|
||||
/**
|
||||
* Aktywna zakladka. Stan, a nie trasa: dzieki temu przelaczanie nie gubi
|
||||
* niezapisanych zmian ani nie zapisuje formularza po kawalku.
|
||||
*/
|
||||
const [activeTab, setActiveTab] = useState<string>(TABS[0].id)
|
||||
|
||||
const existing = useQuery({
|
||||
queryKey: ['webinar', id],
|
||||
enabled: isEdit,
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Webinar>(`/admin/webinars/${id}`)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const webinar = existing.data
|
||||
if (!webinar) return
|
||||
setForm({
|
||||
title: webinar.title,
|
||||
slug: webinar.slug,
|
||||
description: webinar.description ?? '',
|
||||
contentHtml: webinar.contentHtml ?? '',
|
||||
startsAt: isoToZonedInput(webinar.startsAt, webinar.timezone),
|
||||
endsAt: isoToZonedInput(webinar.endsAt, webinar.timezone),
|
||||
timezone: webinar.timezone,
|
||||
registrationOpen: webinar.registrationOpen,
|
||||
registrationDeadline: isoToZonedInput(webinar.registrationDeadline, webinar.timezone),
|
||||
capacity: webinar.capacity === null ? '' : String(webinar.capacity),
|
||||
joinUrl: webinar.joinUrl ?? '',
|
||||
videoProvider: webinar.videoProvider ?? '',
|
||||
recordingUrl: webinar.recordingUrl ?? '',
|
||||
materialsUrl: webinar.materialsUrl ?? '',
|
||||
})
|
||||
setSlugTouched(true)
|
||||
}, [existing.data])
|
||||
|
||||
const suggestedSlug = slugify(form.title)
|
||||
const effectiveSlug = slugTouched ? form.slug : suggestedSlug
|
||||
|
||||
// ten sam adres bazowy, z ktorego powstaja linki w mailach
|
||||
const publicUrl = usePublicUrl()
|
||||
const fullPublicLink = `${publicUrl}/w/${effectiveSlug}`
|
||||
|
||||
const [linkCopied, setLinkCopied] = useState(false)
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullPublicLink)
|
||||
setLinkCopied(true)
|
||||
window.setTimeout(() => setLinkCopied(false), 2000)
|
||||
} catch {
|
||||
// przegladarka moze odmowic dostepu do schowka bez HTTPS
|
||||
toasts.error('Nie udało się skopiować. Zaznacz adres i skopiuj ręcznie.')
|
||||
}
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (payload: WebinarRequest) => {
|
||||
const response = isEdit
|
||||
? await client.put<Webinar>(`/admin/webinars/${id}`, payload)
|
||||
: await client.post<Webinar>('/admin/webinars', payload)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (webinar) => {
|
||||
toasts.success(isEdit ? 'Zmiany zostały zapisane' : 'Webinar został utworzony')
|
||||
queryClient.invalidateQueries({ queryKey: ['webinars'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['webinar', String(webinar.id)] })
|
||||
navigate(panelPath('/webinary'))
|
||||
},
|
||||
onError: (error) => {
|
||||
setErrors(fieldErrors(error))
|
||||
toasts.error(errorMessage(error, 'Nie udało się zapisać webinaru'))
|
||||
},
|
||||
})
|
||||
|
||||
/** Walidacja po stronie klienta, zanim zapytanie pojdzie do backendu. */
|
||||
function validate(): Record<string, string> {
|
||||
const found: Record<string, string> = {}
|
||||
if (!form.title.trim()) found.title = 'Tytuł jest wymagany'
|
||||
if (!form.startsAt) found.startsAt = 'Termin rozpoczęcia jest wymagany'
|
||||
if (!form.endsAt) found.endsAt = 'Termin zakończenia jest wymagany'
|
||||
if (form.startsAt && form.endsAt && form.endsAt <= form.startsAt) {
|
||||
found.endsAt = 'Termin zakończenia musi być późniejszy niż rozpoczęcia'
|
||||
}
|
||||
if (form.registrationDeadline && form.startsAt && form.registrationDeadline > form.startsAt) {
|
||||
found.registrationDeadline = 'Termin zapisów nie może być późniejszy niż początek webinaru'
|
||||
}
|
||||
if (form.capacity && (!/^\d+$/.test(form.capacity) || Number(form.capacity) < 1)) {
|
||||
found.capacity = 'Limit miejsc musi być liczbą dodatnią'
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
const found = validate()
|
||||
setErrors(found)
|
||||
if (Object.keys(found).length > 0) {
|
||||
// skaczemy na pierwsza zakladke z bledem, inaczej komunikat mowilby
|
||||
// o bledzie, ktorego nigdzie nie widac
|
||||
const faulty = TABS.find((tab) => (tab.fields ?? []).some((field) => found[field]))
|
||||
if (faulty) setActiveTab(faulty.id)
|
||||
toasts.error('Formularz zawiera błędy')
|
||||
return
|
||||
}
|
||||
|
||||
save.mutate({
|
||||
title: form.title.trim(),
|
||||
slug: effectiveSlug || undefined,
|
||||
description: form.description.trim() || null,
|
||||
contentHtml: form.contentHtml.trim() || null,
|
||||
startsAt: zonedInputToIso(form.startsAt, form.timezone)!,
|
||||
endsAt: zonedInputToIso(form.endsAt, form.timezone)!,
|
||||
timezone: form.timezone,
|
||||
registrationOpen: form.registrationOpen,
|
||||
registrationDeadline: zonedInputToIso(form.registrationDeadline, form.timezone),
|
||||
capacity: form.capacity ? Number(form.capacity) : null,
|
||||
joinUrl: form.joinUrl.trim() || null,
|
||||
videoProvider: form.videoProvider,
|
||||
recordingUrl: form.recordingUrl.trim() || null,
|
||||
materialsUrl: form.materialsUrl.trim() || null,
|
||||
})
|
||||
}
|
||||
|
||||
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
if (isEdit && existing.isPending) return <Spinner />
|
||||
if (isEdit && existing.isError) {
|
||||
return <ErrorState message={errorMessage(existing.error, 'Nie udało się wczytać webinaru')} />
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" icon="arrow-left" type="button" onClick={() => navigate(panelPath('/webinary'))}>
|
||||
Wróć
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold text-slate-900">
|
||||
{isEdit ? 'Edycja webinaru' : 'Nowy webinar'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<FormTabs tabs={TABS} active={activeTab} onChange={setActiveTab} errors={errors} />
|
||||
|
||||
{activeTab === 'podstawowe' && (
|
||||
<Card title="Podstawowe dane">
|
||||
<div className="space-y-4">
|
||||
<Field label="Tytuł" error={errors.title}>
|
||||
<Input
|
||||
value={form.title}
|
||||
onChange={(e) => update('title', e.target.value)}
|
||||
placeholder="np. Zamówienia publiczne 2026"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Adres strony zapisu"
|
||||
error={errors.slug}
|
||||
hint={
|
||||
!slugTouched && suggestedSlug
|
||||
? 'Końcówka adresu podpowiadana z tytułu. Konflikt rozstrzyga backend sufiksem liczbowym.'
|
||||
: 'Konflikt adresu rozstrzyga backend sufiksem liczbowym.'
|
||||
}
|
||||
>
|
||||
{/*
|
||||
Pelny adres skladany z APP_PUBLIC_URL z ustawien instancji, tego samego,
|
||||
ktory trafia do linkow w mailach. Administrator edytuje tylko koncowke,
|
||||
a widzi i kopiuje adres w calosci.
|
||||
*/}
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
{publicUrl && (
|
||||
<span className="inline-flex select-none items-center rounded-l-md border border-r-0 border-slate-200 bg-slate-50 px-3 text-sm text-slate-500">
|
||||
{publicUrl}/w/
|
||||
</span>
|
||||
)}
|
||||
<Input
|
||||
value={effectiveSlug}
|
||||
onChange={(e) => {
|
||||
setSlugTouched(true)
|
||||
update('slug', e.target.value)
|
||||
}}
|
||||
placeholder={suggestedSlug || 'adres-strony-zapisu'}
|
||||
className={publicUrl ? 'rounded-l-none' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Bez APP_PUBLIC_URL nie da sie zlozyc pelnego adresu. Sklejanie go
|
||||
z pustego prefiksu dawaloby link postaci "/w/slug", ktory nie dziala
|
||||
po wklejeniu w mailu ani w komunikatorze, wiec zamiast tego mowimy
|
||||
wprost, czego brakuje.
|
||||
*/}
|
||||
{!publicUrl && (
|
||||
<div className="mt-2 flex items-start gap-2 rounded-md border border-warn-500/25 bg-warn-50 p-3 text-sm text-warn-600">
|
||||
<Icon name="alert-triangle" size={18} />
|
||||
<p className="min-w-0 flex-1">
|
||||
Nie ustawiono publicznego adresu instancji, więc nie można pokazać pełnego
|
||||
linku do strony zapisu. Ten sam adres trafia do linków w mailach do uczestników.{' '}
|
||||
{isAdmin ? (
|
||||
<>
|
||||
Uzupełnij pole <strong>APP_PUBLIC_URL</strong> w{' '}
|
||||
<Link to={panelPath('/ustawienia')} className="font-medium underline">
|
||||
Ustawieniach
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Poproś administratora o uzupełnienie pola <strong>APP_PUBLIC_URL</strong>{' '}
|
||||
w ustawieniach instancji.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{publicUrl && effectiveSlug && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded bg-slate-100 px-2 py-1 text-xs text-slate-700">
|
||||
{fullPublicLink}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
icon={linkCopied ? 'check' : 'copy'}
|
||||
onClick={copyLink}
|
||||
className="shrink-0 !py-1 text-xs"
|
||||
>
|
||||
{linkCopied ? 'Skopiowano' : 'Kopiuj'}
|
||||
</Button>
|
||||
{isEdit && (
|
||||
<a
|
||||
href={fullPublicLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0 inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-blue-700 hover:bg-blue-50"
|
||||
>
|
||||
<Icon name="arrow-left" size={16} />
|
||||
Otwórz
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Krótki opis"
|
||||
error={errors.description}
|
||||
hint="Jedno, dwa zdania. Trafia do zajawki linku w komunikatorach i do maili. Bez formatowania."
|
||||
>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={form.description}
|
||||
onChange={(e) => update('description', e.target.value)}
|
||||
placeholder="np. Praktyczne szkolenie z obsługi zgłoszeń awarii"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
)}
|
||||
|
||||
{activeTab === 'tresc' && (
|
||||
<Card
|
||||
title="Treść strony zapisu"
|
||||
description="Pojawi się nad formularzem zapisu. Formularz zawsze zostaje na dole strony."
|
||||
>
|
||||
<ContentEditor
|
||||
value={form.contentHtml}
|
||||
onChange={(html) => update('contentHtml', html)}
|
||||
webinarId={isEdit && id ? Number(id) : null}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
)}
|
||||
|
||||
{activeTab === 'termin' && (
|
||||
<Card title="Termin" description="Godziny podawane są w strefie webinaru">
|
||||
<div className="space-y-4">
|
||||
<Field label="Strefa czasowa" error={errors.timezone}>
|
||||
<Select value={form.timezone} onChange={(e) => update('timezone', e.target.value)}>
|
||||
{COMMON_TIMEZONES.map((zone) => (
|
||||
<option key={zone} value={zone}>
|
||||
{zone}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Rozpoczęcie" error={errors.startsAt} hint={`w strefie ${form.timezone}`}>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={form.startsAt}
|
||||
onChange={(e) => update('startsAt', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Zakończenie" error={errors.endsAt} hint={`w strefie ${form.timezone}`}>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={form.endsAt}
|
||||
onChange={(e) => update('endsAt', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
)}
|
||||
|
||||
{activeTab === 'zapisy' && (
|
||||
<Card title="Zapisy">
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-start gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.registrationOpen}
|
||||
onChange={(e) => update('registrationOpen', e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-slate-200 text-blue-700 focus:ring-blue-600"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
<span className="font-semibold text-slate-800">Zapisy otwarte</span>
|
||||
<span className="block text-xs text-slate-500">
|
||||
Twoja decyzja. Niezależnie od niej zapisy zamykają się same po wyczerpaniu
|
||||
miejsc albo po minięciu terminu, a po rezygnacji uczestnika wracają do tego ustawienia.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
label="Termin zapisów"
|
||||
error={errors.registrationDeadline}
|
||||
hint="Puste oznacza brak terminu granicznego"
|
||||
>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={form.registrationDeadline}
|
||||
onChange={(e) => update('registrationDeadline', e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Limit miejsc" error={errors.capacity} hint="Puste oznacza brak limitu">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.capacity}
|
||||
onChange={(e) => update('capacity', e.target.value)}
|
||||
placeholder="bez limitu"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="Link do spotkania"
|
||||
error={errors.joinUrl}
|
||||
hint="Wysyłany uczestnikom w mailu potwierdzającym. Nie jest pokazywany publicznie."
|
||||
>
|
||||
<Input
|
||||
type="url"
|
||||
value={form.joinUrl}
|
||||
onChange={(e) => update('joinUrl', e.target.value)}
|
||||
placeholder="https://"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
)}
|
||||
|
||||
{activeTab === 'po-webinarze' && (
|
||||
<Card title="Po webinarze">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-500">
|
||||
Oba adresy trafiają do maila podsumowującego, wysyłanego automatycznie po
|
||||
zakończeniu webinaru. Uzupełnij je przed wysyłką podsumowania, a jeśli nagranie
|
||||
pojawi się później, użyj przycisku poniżej.
|
||||
</p>
|
||||
|
||||
<Field
|
||||
label="Adres nagrania"
|
||||
error={errors.recordingUrl}
|
||||
hint="Dołączany do podsumowania jako przycisk „Obejrzyj nagranie”. Puste oznacza brak nagrania."
|
||||
>
|
||||
<Input
|
||||
type="url"
|
||||
value={form.recordingUrl}
|
||||
onChange={(e) => update('recordingUrl', e.target.value)}
|
||||
placeholder="https://"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Adres materiałów"
|
||||
error={errors.materialsUrl}
|
||||
hint="Prezentacja, pliki do pobrania. Dołączany do podsumowania jako „Pobierz materiały”."
|
||||
>
|
||||
<Input
|
||||
type="url"
|
||||
value={form.materialsUrl}
|
||||
onChange={(e) => update('materialsUrl', e.target.value)}
|
||||
placeholder="https://"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{isEdit && id && (
|
||||
<RecordingNotice webinarId={Number(id)} savedUrl={existing.data?.recordingUrl ?? null} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Nagrania i materialy wisza na id webinaru, wiec sekcja ma sens dopiero
|
||||
po zapisaniu. Przy tworzeniu pokazujemy sama informacje.
|
||||
*/}
|
||||
{activeTab === 'pliki' && isEdit && id && <FilesSection webinarId={Number(id)} />}
|
||||
|
||||
{activeTab === 'pliki' && !isEdit && (
|
||||
<Card title="Nagrania i materiały">
|
||||
<p className="text-sm text-slate-500">
|
||||
Pliki będzie można wgrać po zapisaniu webinaru.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Sekcja przypomnien wymaga istniejacego webinaru, bo reguly wisza na jego id.
|
||||
Przy tworzeniu backend zaklada domyslny zestaw z ustawienia DEFAULT_REMINDERS,
|
||||
widoczny po zapisaniu.
|
||||
*/}
|
||||
{activeTab === 'przypomnienia' && isEdit && id && (
|
||||
<ReminderSection webinarId={Number(id)} timezone={form.timezone} />
|
||||
)}
|
||||
|
||||
{activeTab === 'wideo' && isEdit && id && (
|
||||
<VideoSection
|
||||
webinarId={Number(id)}
|
||||
joinUrl={form.joinUrl}
|
||||
videoProvider={form.videoProvider}
|
||||
onProviderChange={(value) => update('videoProvider', value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'wideo' && !isEdit && (
|
||||
<Card title="Wideo">
|
||||
<p className="text-sm text-slate-500">
|
||||
Pokój i prelegentów ustawisz po zapisaniu webinaru.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'przypomnienia' && !isEdit && (
|
||||
<Card title="Przypomnienia">
|
||||
<p className="text-sm text-slate-500">
|
||||
Po zapisaniu webinaru zostanie założony domyślny zestaw przypomnień
|
||||
z ustawień instancji. Będzie go można zmienić w tym formularzu.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" type="button" onClick={() => navigate(panelPath('/webinary'))}>
|
||||
Anuluj
|
||||
</Button>
|
||||
<Button type="submit" icon="save" disabled={save.isPending}>
|
||||
{save.isPending ? 'Zapisywanie...' : 'Zapisz'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wysylka powiadomienia o dostepnym nagraniu.
|
||||
*
|
||||
* Osobno od podsumowania, bo nagranie czesto pojawia sie dopiero po jego
|
||||
* wyslaniu: montaz trwa dluzej niz godzina, a podsumowania nie da sie
|
||||
* powtorzyc. Powiadomienie idzie wylacznie do uczestnikow ze statusem
|
||||
* CONFIRMED i tylko raz na odbiorce, czego pilnuje unikalny indeks w bazie.
|
||||
*
|
||||
* Przycisk dziala na **zapisanym** adresie nagrania, nie na tresci pola:
|
||||
* wyslanie powiadomienia o adresie, ktorego jeszcze nie zapisano, konczyloby
|
||||
* sie mailem z linkiem sprzed edycji.
|
||||
*/
|
||||
function RecordingNotice({ webinarId, savedUrl }: { webinarId: number; savedUrl: string | null }) {
|
||||
const toasts = useToasts()
|
||||
|
||||
const notify = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await client.post<{ queued: number; skipped: number }>(
|
||||
`/admin/webinars/${webinarId}/notify-recording`,
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (result.queued === 0 && result.skipped > 0) {
|
||||
toasts.success(`Wszyscy uczestnicy (${result.skipped}) już dostali to powiadomienie.`)
|
||||
} else if (result.queued === 0) {
|
||||
toasts.success('Nie ma komu wysłać powiadomienia: brak potwierdzonych zapisów.')
|
||||
} else {
|
||||
const extra = result.skipped > 0 ? `, pominięto ${result.skipped} (już powiadomieni)` : ''
|
||||
toasts.success(`Zakolejkowano ${result.queued} powiadomień o nagraniu${extra}.`)
|
||||
}
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się wysłać powiadomienia')),
|
||||
})
|
||||
|
||||
const hasSavedUrl = savedUrl !== null && savedUrl.trim() !== ''
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-25 p-4">
|
||||
<p className="text-sm font-semibold text-slate-800">Powiadom o nagraniu</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
Osobna wiadomość dla sytuacji, gdy nagranie pojawiło się po wysłaniu podsumowania.
|
||||
Trafi tylko do uczestników z potwierdzonym zapisem i tylko raz, nawet po kilku
|
||||
kliknięciach.
|
||||
</p>
|
||||
|
||||
{!hasSavedUrl && (
|
||||
<p className="mt-2 text-sm text-warn-600">
|
||||
Najpierw uzupełnij adres nagrania i zapisz webinar.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
icon="mail"
|
||||
disabled={!hasSavedUrl || notify.isPending}
|
||||
onClick={() => {
|
||||
if (confirm('Wysłać powiadomienie o nagraniu do wszystkich potwierdzonych uczestników?')) {
|
||||
notify.mutate()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{notify.isPending ? 'Wysyłanie...' : 'Wyślij powiadomienie o nagraniu'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { CopyResult, DeletionImpact, Page, Webinar, WebinarStatus } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||
import { SortableHeader, type SortState } from '../../components/SortableHeader'
|
||||
import { useToasts } from '../../components/Toasts'
|
||||
import { Badge, Button, Card, EmptyState, ErrorState, IconButton, Input, Select, Spinner } from '../../components/ui'
|
||||
import { formatDateTimeParts, formatLocal } from '../../lib/dates'
|
||||
import { CLOSED_REASON_LABEL, WEBINAR_STATUS_LABEL, WEBINAR_STATUS_TONE } from '../../lib/labels'
|
||||
import { panelPath } from '../../lib/routes'
|
||||
|
||||
|
||||
/**
|
||||
* Zakladki listy.
|
||||
*
|
||||
* Podzial idzie po **terminie zakonczenia**, a nie po statusie FINISHED:
|
||||
* nic w aplikacji tego statusu nie ustawia, wiec zakladka po nim byla by
|
||||
* zawsze pusta. Backend stosuje ta sama regule co strona publiczna.
|
||||
*/
|
||||
const SCOPES = [
|
||||
{ id: 'ACTIVE', label: 'Aktywne', icon: 'presentation' as const },
|
||||
{ id: 'FINISHED', label: 'Zakończone', icon: 'check-circle' as const },
|
||||
]
|
||||
|
||||
export function WebinarListPage() {
|
||||
const [scope, setScope] = useState<'ACTIVE' | 'FINISHED'>('ACTIVE')
|
||||
const [status, setStatus] = useState<WebinarStatus | ''>('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
/** Domyslnie najnowsze na gorze, czyli ostatnio dodane. */
|
||||
const [sort, setSort] = useState<SortState>({ field: 'createdAt', direction: 'desc' })
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const toasts = useToasts()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['webinars', scope, status, search, page, sort.field, sort.direction],
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Page<Webinar>>('/admin/webinars', {
|
||||
params: {
|
||||
scope,
|
||||
status: status || undefined,
|
||||
search: search || undefined,
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
sort: sort.field,
|
||||
direction: sort.direction,
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
/** Kazda zmiana zakresu, filtru albo sortowania cofa na pierwsza strone. */
|
||||
function changeScope(next: 'ACTIVE' | 'FINISHED') {
|
||||
setScope(next)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
function changeSort(next: SortState) {
|
||||
setSort(next)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const publish = useMutation({
|
||||
mutationFn: (id: number) => client.post(`/admin/webinars/${id}/publish`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Webinar został opublikowany')
|
||||
queryClient.invalidateQueries({ queryKey: ['webinars'] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się opublikować webinaru')),
|
||||
})
|
||||
|
||||
const cancel = useMutation({
|
||||
mutationFn: (id: number) => client.post(`/admin/webinars/${id}/cancel`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Webinar został odwołany')
|
||||
queryClient.invalidateQueries({ queryKey: ['webinars'] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się odwołać webinaru')),
|
||||
})
|
||||
|
||||
/**
|
||||
* Kopiowanie webinaru.
|
||||
*
|
||||
* Po skopiowaniu przechodzimy od razu do edycji kopii: termin jest tylko
|
||||
* zastepczy i wymaga poprawienia, zanim da sie kopie opublikowac.
|
||||
* Zostawienie uzytkownika na liscie oznaczaloby szukanie nowego wiersza
|
||||
* wsrod kilkudziesieciu innych.
|
||||
*/
|
||||
const copy = useMutation({
|
||||
mutationFn: async (webinar: Webinar) => {
|
||||
const response = await client.post<CopyResult>(`/admin/webinars/${webinar.id}/copy`, {})
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['webinars'] })
|
||||
toasts.success(
|
||||
`Utworzono kopię: ${result.reminderRules} reguł przypomnień, ` +
|
||||
`${result.offerTopics} tematów zapytań, ${result.materials} materiałów. ` +
|
||||
'Ustaw właściwy termin, bo kopia ma tymczasowy.',
|
||||
)
|
||||
navigate(panelPath(`/webinary/${result.id}/edycja`))
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się skopiować webinaru')),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => client.delete(`/admin/webinars/${id}`),
|
||||
onSuccess: () => {
|
||||
toasts.success('Webinar został usunięty')
|
||||
queryClient.invalidateQueries({ queryKey: ['webinars'] })
|
||||
},
|
||||
onError: (error) => toasts.error(errorMessage(error, 'Nie udało się usunąć webinaru')),
|
||||
})
|
||||
|
||||
/**
|
||||
* Usuniecie webinaru.
|
||||
*
|
||||
* Przed pytaniem pobieramy liczby z backendu, zeby powiedziec wprost, co
|
||||
* przepadnie. "Czy na pewno?" bez tej informacji nie daje uzytkownikowi
|
||||
* niczego, na czym moglby oprzec decyzje: przy webinarze z setka zapisow
|
||||
* i wydanymi certyfikatami odpowiedz jest zupelnie inna niz przy pustym
|
||||
* szkicu.
|
||||
*
|
||||
* Podpowiadamy odwolanie, bo w wiekszosci przypadkow to ono jest tym,
|
||||
* czego uzytkownik naprawde chce: zamyka zapisy i chowa webinar, ale
|
||||
* zostawia dane.
|
||||
*/
|
||||
async function handleDelete(webinar: Webinar) {
|
||||
let impact: DeletionImpact
|
||||
try {
|
||||
const response = await client.get<DeletionImpact>(
|
||||
`/admin/webinars/${webinar.id}/deletion-impact`,
|
||||
)
|
||||
impact = response.data
|
||||
} catch (error) {
|
||||
toasts.error(errorMessage(error, 'Nie udało się sprawdzić, co zostanie usunięte'))
|
||||
return
|
||||
}
|
||||
|
||||
const losses: string[] = []
|
||||
if (impact.registrations > 0) losses.push(`${impact.registrations} zapisów uczestników`)
|
||||
if (impact.certificates > 0) losses.push(`${impact.certificates} wydanych certyfikatów`)
|
||||
|
||||
const detail =
|
||||
losses.length > 0
|
||||
? `Bezpowrotnie przepadnie: ${losses.join(', ')}, a także obecność, wiadomości z czatu, pytania, nagrania i materiały.`
|
||||
: 'Ten webinar nie ma jeszcze zapisów ani certyfikatów.'
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Usunąć webinar "${webinar.title}"?
|
||||
|
||||
${detail}
|
||||
|
||||
` +
|
||||
'Tej operacji nie da się cofnąć. Jeśli chodzi Ci tylko o zamknięcie zapisów, użyj "Odwołaj".',
|
||||
)
|
||||
if (confirmed) remove.mutate(webinar.id)
|
||||
}
|
||||
|
||||
function handleCancel(webinar: Webinar) {
|
||||
const confirmed = window.confirm(
|
||||
`Odwołać webinar "${webinar.title}"?\n\nZapisy zostaną zamknięte, a webinar zniknie ze strony publicznej.`,
|
||||
)
|
||||
if (confirmed) cancel.mutate(webinar.id)
|
||||
}
|
||||
|
||||
/** Akcje wspolne dla widoku tabeli i kart. */
|
||||
function actions(webinar: Webinar) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
{/*
|
||||
Szczegoly otwieraja sie w nowej karcie, wiec musi to byc prawdziwy
|
||||
link, a nie przycisk: tylko wtedy dziala srodkowy przycisk myszy,
|
||||
Ctrl+klik i menu kontekstowe przegladarki.
|
||||
rel="noopener" odcina nowej karcie dostep do window.opener.
|
||||
*/}
|
||||
<a
|
||||
href={panelPath(`/webinary/${webinar.id}`)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Podgląd"
|
||||
title="Podgląd"
|
||||
className="flex h-11 w-11 items-center justify-center rounded-md text-slate-600 transition-colors duration-fast ease-standard hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
<Icon name="eye" size={18} />
|
||||
</a>
|
||||
<IconButton
|
||||
icon="pencil"
|
||||
label="Edytuj"
|
||||
onClick={() => navigate(panelPath(`/webinary/${webinar.id}/edycja`))}
|
||||
/>
|
||||
{/*
|
||||
Prowadzenie ma sens tylko dla webinaru opublikowanego. Gdy webinar
|
||||
WLASNIE TRWA (jest po starcie, a przed koncem), prowadzenie jest
|
||||
najwazniejsza akcja, wiec pokazujemy WYRAZNY przycisk z podpisem,
|
||||
a nie sama ikonke ginaca wsrod innych. Poza czasem trwania zostaje
|
||||
zwykla ikonka.
|
||||
*/}
|
||||
{webinar.status === 'PUBLISHED' && (
|
||||
isLiveNow(webinar) ? (
|
||||
<Button
|
||||
type="button"
|
||||
icon="presentation"
|
||||
onClick={() => navigate(panelPath(`/webinary/${webinar.id}/prowadzenie`))}
|
||||
>
|
||||
Prowadź
|
||||
</Button>
|
||||
) : (
|
||||
<IconButton
|
||||
icon="presentation"
|
||||
label="Prowadź spotkanie"
|
||||
onClick={() => navigate(panelPath(`/webinary/${webinar.id}/prowadzenie`))}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{webinar.status !== 'PUBLISHED' && webinar.status !== 'CANCELLED' && (
|
||||
<IconButton
|
||||
icon="upload"
|
||||
label="Publikuj"
|
||||
onClick={() => publish.mutate(webinar.id)}
|
||||
disabled={publish.isPending}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon="copy"
|
||||
label="Kopiuj"
|
||||
onClick={() => copy.mutate(webinar)}
|
||||
disabled={copy.isPending}
|
||||
/>
|
||||
{webinar.status !== 'CANCELLED' && (
|
||||
<IconButton
|
||||
icon="x-circle"
|
||||
label="Odwołaj"
|
||||
onClick={() => handleCancel(webinar)}
|
||||
disabled={cancel.isPending}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon="trash"
|
||||
label="Usuń"
|
||||
onClick={() => handleDelete(webinar)}
|
||||
disabled={remove.isPending}
|
||||
className="text-alarm-600 hover:bg-alarm-50"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function registrationBadge(webinar: Webinar) {
|
||||
return webinar.effectivelyOpen ? (
|
||||
<Badge tone="ok" dot>
|
||||
Otwarte
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">
|
||||
{webinar.closedReason ? CLOSED_REASON_LABEL[webinar.closedReason] : 'Zamknięte'}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl">Webinary</h1>
|
||||
<p className="text-sm text-slate-500">Lista wydarzeń i zapisów uczestników</p>
|
||||
</div>
|
||||
<Button icon="plus" onClick={() => navigate(panelPath('/webinary/nowy'))}>
|
||||
Nowy webinar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Zakladki przewijaja sie w poziomie wewnatrz wlasnego kontenera,
|
||||
wiec na telefonie nie rozpychaja dokumentu.
|
||||
*/}
|
||||
<div role="tablist" aria-label="Zakres listy" className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1">
|
||||
{SCOPES.map((item) => {
|
||||
const isActive = item.id === scope
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => changeScope(item.id as 'ACTIVE' | 'FINISHED')}
|
||||
className={`flex min-h-[44px] shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors duration-fast ease-standard ${
|
||||
isActive
|
||||
? 'bg-blue-50 font-medium text-blue-700'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Icon name={item.icon} size={16} />
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="w-full sm:w-72">
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Szukaj</label>
|
||||
<Input
|
||||
value={search}
|
||||
placeholder="Tytuł webinaru"
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value)
|
||||
// zmiana filtru cofa na pierwsza strone: inaczej stojac na
|
||||
// stronie 2 uzytkownik widzi pusta tabele i wyglada to jak brak danych
|
||||
setPage(0)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full sm:w-56">
|
||||
<label className="mb-1.5 block text-sm font-semibold text-slate-800">Status</label>
|
||||
<Select
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value as WebinarStatus | '')
|
||||
setPage(0)
|
||||
}}
|
||||
>
|
||||
<option value="">Wszystkie</option>
|
||||
{(Object.keys(WEBINAR_STATUS_LABEL) as WebinarStatus[]).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{WEBINAR_STATUS_LABEL[value]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
{query.data && (
|
||||
<p className="pb-2.5 text-sm text-slate-500">
|
||||
Znaleziono: <strong className="hx-data text-slate-700">{query.data.totalElements}</strong>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
|
||||
{query.isPending && <Spinner />}
|
||||
{query.isError && (
|
||||
<div className="p-5">
|
||||
<ErrorState message={errorMessage(query.error, 'Nie udało się wczytać listy webinarów')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{query.data && query.data.content.length === 0 && (
|
||||
<EmptyState
|
||||
icon="presentation"
|
||||
title="Brak webinarów"
|
||||
description={status ? 'Zmień filtr statusu albo utwórz nowy webinar.' : 'Utwórz pierwszy webinar.'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{query.data && query.data.content.length > 0 && (
|
||||
<>
|
||||
{/*
|
||||
Na telefonie lista kart, na duzym ekranie tabela.
|
||||
Tabela z minimalna szerokoscia rozpychala strone na waskim ekranie,
|
||||
a poziome przewijanie calej strony jest gorsze niz zmiana ukladu.
|
||||
*/}
|
||||
<ul className="divide-y divide-slate-100 lg:hidden">
|
||||
{query.data.content.map((webinar) => (
|
||||
<li key={webinar.id} className="space-y-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-slate-900">{webinar.title}</p>
|
||||
<p className="hx-data truncate text-xs text-slate-400">/{webinar.slug}</p>
|
||||
</div>
|
||||
<Badge tone={WEBINAR_STATUS_TONE[webinar.status]}>
|
||||
{WEBINAR_STATUS_LABEL[webinar.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<Icon name="clock" size={16} className="text-slate-400" />
|
||||
<span className="hx-data">{startsAtInline(webinar)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={panelPath(`/webinary/${webinar.id}/zapisy`)}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-700 hover:underline"
|
||||
>
|
||||
<Icon name="users" size={16} />
|
||||
<span className="hx-data">
|
||||
{webinar.registeredCount}
|
||||
{webinar.capacity !== null ? ` / ${webinar.capacity}` : ''}
|
||||
</span>
|
||||
{webinar.capacity === null && <span className="text-xs">bez limitu</span>}
|
||||
</Link>
|
||||
{/* zapytania ofertowe pokazujemy tylko gdy sa, zeby nie zasmiecac karty */}
|
||||
{webinar.offerCount > 0 && (
|
||||
<Link
|
||||
to={panelPath(`/webinary/${webinar.id}`)}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-700 hover:underline"
|
||||
>
|
||||
<Icon name="send" size={16} />
|
||||
<span className="hx-data">{webinar.offerCount}</span>
|
||||
</Link>
|
||||
)}
|
||||
{registrationBadge(webinar)}
|
||||
<div className="ml-auto">{actions(webinar)}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/*
|
||||
Przewijanie w poziomie **wewnatrz kontenera tabeli**, nie na stronie.
|
||||
Po dolozeniu kolumny "Dodano" tabela bywa szersza niz panel przy
|
||||
waskim oknie i kolumna akcji stawala sie nieosiagalna. Sama strona
|
||||
nadal nie przewija sie w poziomie, zgodnie z zasada responsywnosci.
|
||||
*/}
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-100 bg-slate-25 text-left">
|
||||
<tr className="hx-eyebrow">
|
||||
<SortableHeader field="title" sort={sort} onSort={changeSort}>
|
||||
Tytuł
|
||||
</SortableHeader>
|
||||
<SortableHeader field="startsAt" sort={sort} onSort={changeSort}>
|
||||
Termin
|
||||
</SortableHeader>
|
||||
<SortableHeader field="status" sort={sort} onSort={changeSort}>
|
||||
Status
|
||||
</SortableHeader>
|
||||
{/*
|
||||
Liczba zapisanych jest doliczana osobnym zapytaniem do juz
|
||||
pobranej strony, wiec nie da sie po niej sortowac w bazie.
|
||||
Sortowanie tylko biezacej strony wprowadzaloby w blad,
|
||||
wiec ta kolumna celowo nie jest klikalna.
|
||||
*/}
|
||||
<th className="px-4 py-3">Zapisani</th>
|
||||
{/*
|
||||
Liczba zapytan ofertowych, doliczana tym samym sposobem co
|
||||
liczba zapisanych, wiec tak samo nie jest sortowalna.
|
||||
Chowana ponizej 2xl razem z kolumna "Dodano": przy 1280px
|
||||
obie naraz rozpychaja tabele ponad szerokosc panelu.
|
||||
*/}
|
||||
<th className="hidden px-4 py-3 2xl:table-cell">Zapytania</th>
|
||||
<SortableHeader field="registrationOpen" sort={sort} onSort={changeSort}>
|
||||
Zapisy
|
||||
</SortableHeader>
|
||||
{/*
|
||||
Kolumna informacyjna, wiec chowamy ja ponizej 2xl. Zmierzone:
|
||||
przy 1280px jej dolozenie rozpycha tabele ponad szerokosc
|
||||
panelu i kolumna akcji wymaga przewijania. Sortowanie po
|
||||
dacie dodania jest i tak domyslne, wiec na wezszych ekranach
|
||||
nic sie nie traci.
|
||||
*/}
|
||||
<SortableHeader field="createdAt" sort={sort} onSort={changeSort} className="hidden 2xl:table-cell">
|
||||
Dodano
|
||||
</SortableHeader>
|
||||
<th className="px-4 py-3 text-right">Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{query.data.content.map((webinar) => (
|
||||
<tr key={webinar.id} className="transition duration-fast ease-standard hover:bg-slate-25">
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-slate-900">{webinar.title}</p>
|
||||
<p className="hx-data text-xs text-slate-400">/{webinar.slug}</p>
|
||||
</td>
|
||||
<td className="hx-data whitespace-nowrap px-4 py-3 text-slate-600">
|
||||
<StartsAt webinar={webinar} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge tone={WEBINAR_STATUS_TONE[webinar.status]}>
|
||||
{WEBINAR_STATUS_LABEL[webinar.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<Link
|
||||
to={panelPath(`/webinary/${webinar.id}/zapisy`)}
|
||||
className="hx-data font-medium text-blue-700 hover:underline"
|
||||
>
|
||||
{webinar.registeredCount}
|
||||
{webinar.capacity !== null ? ` / ${webinar.capacity}` : ' (bez limitu)'}
|
||||
</Link>
|
||||
</td>
|
||||
{/*
|
||||
Zero pokazujemy jako kreske, a nie jako "0": pusta wartosc
|
||||
czyta sie szybciej niz liczba, ktora i tak nic nie wnosi.
|
||||
Link prowadzi wprost do zakladki zapytan tego webinaru.
|
||||
*/}
|
||||
<td className="hidden whitespace-nowrap px-4 py-3 2xl:table-cell">
|
||||
{webinar.offerCount > 0 ? (
|
||||
<Link
|
||||
to={panelPath(`/webinary/${webinar.id}`)}
|
||||
className="hx-data font-medium text-blue-700 hover:underline"
|
||||
>
|
||||
{webinar.offerCount}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="hx-data text-slate-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">{registrationBadge(webinar)}</td>
|
||||
<td className="hx-data hidden whitespace-nowrap px-4 py-3 text-slate-500 2xl:table-cell">
|
||||
{formatLocal(webinar.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex justify-end">{actions(webinar)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{query.data && (
|
||||
<Pagination
|
||||
page={query.data.number}
|
||||
totalPages={query.data.totalPages}
|
||||
totalElements={query.data.totalElements}
|
||||
onChange={setPage}
|
||||
itemLabel="webinarów"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Termin w dwoch liniach: data, pod nia godzina.
|
||||
*
|
||||
* Bez nazwy strefy, bo na liscie nie ma jej po co porownywac, a w jednej linii
|
||||
* caly napis zabieral tyle miejsca, ze kolumna akcji uciekala poza ekran.
|
||||
* Strefa jest nadal jawna tam, gdzie ma znaczenie: w formularzu webinaru,
|
||||
* na stronie publicznej i na liscie zapisanych.
|
||||
*/
|
||||
function StartsAt({ webinar }: { webinar: Webinar }) {
|
||||
const { date, time } = formatDateTimeParts(webinar.startsAt, webinar.timezone)
|
||||
return (
|
||||
<>
|
||||
<span className="block">{date}</span>
|
||||
<span className="block text-xs text-slate-400">{time}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Ten sam termin w jednej linii, do karty na telefonie. */
|
||||
function startsAtInline(webinar: Webinar): string {
|
||||
const { date, time } = formatDateTimeParts(webinar.startsAt, webinar.timezone)
|
||||
return time ? `${date}, ${time}` : date
|
||||
}
|
||||
|
||||
/**
|
||||
* Czy webinar wlasnie trwa: jest po starcie i przed koncem.
|
||||
*
|
||||
* Uzywamy tego samego marginesu, co okno wejscia do pokoju (15 min przed
|
||||
* startem), zeby przycisk "Prowadz" pojawil sie wyraznie juz TUZ przed startem,
|
||||
* a nie dokladnie o godzinie zero. Porownujemy chwile UTC, wiec strefa webinaru
|
||||
* nie ma tu znaczenia.
|
||||
*/
|
||||
function isLiveNow(webinar: Webinar): boolean {
|
||||
const now = Date.now()
|
||||
const starts = new Date(webinar.startsAt).getTime()
|
||||
const ends = new Date(webinar.endsAt).getTime()
|
||||
const OPEN_BEFORE_MS = 15 * 60 * 1000
|
||||
return now >= starts - OPEN_BEFORE_MS && now <= ends
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
import { Suspense, lazy, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { client, errorMessage } from '../../api/client'
|
||||
import type { Page, Registration, Webinar } from '../../api/types'
|
||||
import { Icon } from '../../components/Icon'
|
||||
import { BannerSection } from '../banners/BannerSection'
|
||||
import { PAGE_SIZE, Pagination } from '../../components/Pagination'
|
||||
import { Badge, Card, EmptyState, ErrorState, Spinner } from '../../components/ui'
|
||||
import { formatDateTimeParts, formatWithZone } from '../../lib/dates'
|
||||
import type { InteractionView } from '../../lib/useInteractions'
|
||||
import { OfferSection } from './OfferSection'
|
||||
import { InvitationSection } from './InvitationSection'
|
||||
import { CertificateSection } from './CertificateSection'
|
||||
import {
|
||||
CONSENT_LABEL,
|
||||
REGISTRATION_STATUS_LABEL,
|
||||
REGISTRATION_STATUS_TONE,
|
||||
WEBINAR_STATUS_LABEL,
|
||||
WEBINAR_STATUS_TONE,
|
||||
} from '../../lib/labels'
|
||||
|
||||
import { panelPath } from '../../lib/routes'
|
||||
/*
|
||||
* Statystyki ladowane leniwie: ciagna za soba @mui/x-charts, ktore wazy
|
||||
* wiecej niz cala reszta panelu, a jest potrzebne wylacznie na tej jednej
|
||||
* zakladce. Bez podzialu kazde otwarcie webinaru pobieraloby wykresy.
|
||||
*/
|
||||
const StatsSection = lazy(() =>
|
||||
import('./StatsSection').then((m) => ({ default: m.StatsSection })))
|
||||
|
||||
/** Historia czatu z panelu moderacji. Ten sam ksztalt co w panelu prowadzenia. */
|
||||
type ModerationData = { chat: InteractionView[]; questions: InteractionView[] }
|
||||
|
||||
/** Zakladki szczegolow webinaru. Wartosc jest tez kluczem w TABS. */
|
||||
type PreviewTab =
|
||||
| 'tresc'
|
||||
| 'zaproszenia'
|
||||
| 'zapisani'
|
||||
| 'banery'
|
||||
| 'certyfikaty'
|
||||
| 'statystyki'
|
||||
| 'zapytania'
|
||||
| 'czat'
|
||||
|
||||
/**
|
||||
* Szczegoly webinaru: tresc strony zapisu, zapisani, zapytania ofertowe i czat.
|
||||
*
|
||||
* Osobna strona pod wlasnym adresem, a nie okno modalne. Dzieki temu da sie ja
|
||||
* otworzyc w nowej karcie, zostawic obok listy, odswiezyc i podeslac linkiem.
|
||||
*
|
||||
* **Wszystko, co dotyczy jednego webinaru, jest tutaj.** Zapytania ofertowe
|
||||
* i historia czatu naleza do konkretnego wydarzenia, wiec sa jego zakladkami,
|
||||
* a nie osobnym ekranem globalnym. Ta sama zasada obowiazuje kazde kolejne
|
||||
* powiazanie: nowy rodzaj danych webinaru dostaje zakladke tutaj.
|
||||
*
|
||||
* Podglad wylacznie czyta. Obsluga zapytania (notatka, oznaczenie jako
|
||||
* zalatwione) jest w zakladce zapytan, bo to jedyne miejsce, gdzie handlowiec
|
||||
* je oglada.
|
||||
*/
|
||||
export function WebinarPreviewPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [tab, setTab] = useState<PreviewTab>('tresc')
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
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, '', page],
|
||||
enabled: tab === 'zapisani',
|
||||
queryFn: async () => {
|
||||
const response = await client.get<Page<Registration>>(
|
||||
`/admin/webinars/${id}/registrations`,
|
||||
{ params: { page, size: PAGE_SIZE } },
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
/*
|
||||
* Historia czatu z tego samego endpointu moderacji, co panel prowadzenia.
|
||||
* Tutaj sluzy wylacznie do czytania po spotkaniu, wiec nie ma WebSocketu:
|
||||
* archiwum sie nie zmienia, a otwarte polaczenie po webinarze byloby
|
||||
* utrzymywane bez powodu.
|
||||
*/
|
||||
const moderation = useQuery({
|
||||
queryKey: ['moderation', id],
|
||||
enabled: tab === 'czat',
|
||||
queryFn: async () => {
|
||||
const response = await client.get<ModerationData>(`/admin/webinars/${id}/interactions`)
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
if (webinar.isPending) return <Spinner />
|
||||
if (webinar.isError) {
|
||||
return <ErrorState message={errorMessage(webinar.error, 'Nie udało się wczytać webinaru')} />
|
||||
}
|
||||
|
||||
const data = webinar.data
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-semibold text-slate-900">{data.title}</h1>
|
||||
<Badge tone={WEBINAR_STATUS_TONE[data.status]}>
|
||||
{WEBINAR_STATUS_LABEL[data.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="hx-data mt-1 text-xs text-slate-500">
|
||||
{formatWithZone(data.startsAt, data.timezone)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
to={panelPath(`/webinary/${data.id}/edycja`)}
|
||||
className="inline-flex min-h-[44px] items-center gap-2 rounded-md border border-slate-200 px-4 text-sm font-medium text-slate-700 transition-colors duration-fast ease-standard hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="pencil" size={16} />
|
||||
Edytuj
|
||||
</Link>
|
||||
<a
|
||||
href={`/w/${data.slug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex min-h-[44px] items-center gap-2 rounded-md border border-slate-200 px-4 text-sm font-medium text-slate-700 transition-colors duration-fast ease-standard hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="eye" size={16} />
|
||||
Strona zapisu
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
role="tablist"
|
||||
aria-label="Sekcje podglądu"
|
||||
className="-mx-1 flex gap-1 overflow-x-auto px-1 pb-1"
|
||||
>
|
||||
{(
|
||||
[
|
||||
['tresc', 'Treść strony', 'file-text', null],
|
||||
['zaproszenia', 'Zaproszenia', 'mail', null],
|
||||
['zapisani', 'Zapisani', 'users', data.registeredCount],
|
||||
['banery', 'Banery', 'image', null],
|
||||
['certyfikaty', 'Certyfikaty', 'file-text', null],
|
||||
['statystyki', 'Statystyki', 'presentation', null],
|
||||
['zapytania', 'Zapytania ofertowe', 'send', data.offerCount],
|
||||
['czat', 'Historia czatu', 'message-square', null],
|
||||
] as const
|
||||
).map(([value, label, icon, count]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === value}
|
||||
onClick={() => setTab(value)}
|
||||
className={`flex min-h-[44px] shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm transition-colors duration-fast ease-standard ${
|
||||
tab === value
|
||||
? 'bg-blue-50 font-medium text-blue-700'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<Icon name={icon} size={16} />
|
||||
{label}
|
||||
{count !== null && <span className="hx-data text-xs text-slate-400">{count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{tab === 'tresc' && <ContentTab webinar={data} />}
|
||||
|
||||
{/* banery tego webinaru: ten sam komponent co dzial globalny */}
|
||||
{tab === 'banery' && <BannerSection webinarId={data.id} />}
|
||||
|
||||
{tab === 'zapisani' && (
|
||||
<Card>
|
||||
{registrations.isPending && <Spinner />}
|
||||
{registrations.isError && (
|
||||
<ErrorState
|
||||
message={errorMessage(registrations.error, 'Nie udało się wczytać zapisanych')}
|
||||
/>
|
||||
)}
|
||||
{registrations.data && (
|
||||
<RegistrationsTab data={registrations.data} webinar={data} onPage={setPage} />
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* pierwszy etap sciezki: informacja z linkiem, dopiero potem zapis */}
|
||||
{tab === 'zaproszenia' && <InvitationSection webinar={data} />}
|
||||
|
||||
{tab === 'certyfikaty' && <CertificateSection webinar={data} />}
|
||||
|
||||
{/* wszystkie liczby przychodza policzone z bazy, front tylko rysuje */}
|
||||
{tab === 'statystyki' && (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<StatsSection webinarId={data.id} timezone={data.timezone} />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Zapytania tego jednego webinaru. Ten sam komponent obsluguje ekran
|
||||
zbiorczy, wiec obsluga zapytania wyglada tak samo niezaleznie od tego,
|
||||
skad handlowiec do niego trafil.
|
||||
*/}
|
||||
{tab === 'zapytania' && <OfferSection webinarId={data.id} />}
|
||||
|
||||
{tab === 'czat' && (
|
||||
<Card>
|
||||
{moderation.isPending && <Spinner />}
|
||||
{moderation.isError && (
|
||||
<ErrorState message={errorMessage(moderation.error, 'Nie udało się wczytać czatu')} />
|
||||
)}
|
||||
{moderation.data && <ChatHistoryTab chat={moderation.data.chat} webinar={data} />}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Tresc strony zapisu tak, jak zobaczy ja uczestnik. */
|
||||
function ContentTab({ webinar }: { webinar: Webinar }) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
{webinar.description && (
|
||||
<p className="whitespace-pre-line rounded-md border border-slate-200 bg-slate-25 p-3 text-sm text-slate-700">
|
||||
{webinar.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Tresc jest oczyszczana przy zapisie na backendzie (biala lista jsoup),
|
||||
wiec w bazie nie ma niczego, czego nie wolno wyswietlic. Nie czyscimy
|
||||
jej drugi raz w przegladarce, bo to tylko udawaloby zabezpieczenie.
|
||||
*/}
|
||||
{webinar.contentHtml ? (
|
||||
<div
|
||||
className="prose prose-slate max-w-none overflow-x-auto prose-headings:font-semibold prose-img:mx-auto prose-img:h-auto prose-img:max-w-full prose-img:rounded-md"
|
||||
dangerouslySetInnerHTML={{ __html: webinar.contentHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p className="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
|
||||
Ten webinar nie ma własnej treści strony. Uczestnik zobaczy sam formularz zapisu
|
||||
wraz z terminem i opisem.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/** Lista zapisanych wraz z danymi kontaktowymi. */
|
||||
function RegistrationsTab({
|
||||
data,
|
||||
webinar,
|
||||
onPage,
|
||||
}: {
|
||||
data: Page<Registration>
|
||||
webinar: Webinar
|
||||
onPage: (page: number) => void
|
||||
}) {
|
||||
if (data.totalElements === 0) {
|
||||
return (
|
||||
<p className="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500">
|
||||
Nikt jeszcze się nie zapisał.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-slate-500">
|
||||
Zapisanych: <span className="hx-data font-medium text-slate-900">{data.totalElements}</span>
|
||||
{webinar.capacity !== null && (
|
||||
<span className="hx-data text-slate-400"> / {webinar.capacity}</span>
|
||||
)}
|
||||
</p>
|
||||
<Link
|
||||
to={panelPath(`/webinary/${webinar.id}/zapisy`)}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-blue-700 hover:underline"
|
||||
>
|
||||
<Icon name="users" size={16} />
|
||||
Pełna lista i eksport CSV
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* karty do lg, tabela od lg w gore: zgodnie z zasada responsywnosci */}
|
||||
<ul className="space-y-2 lg:hidden">
|
||||
{data.content.map((registration) => (
|
||||
<li key={registration.id} className="rounded-md border border-slate-200 p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 font-medium text-slate-900">
|
||||
{registration.firstName} {registration.lastName}
|
||||
</p>
|
||||
<Badge tone={REGISTRATION_STATUS_TONE[registration.status]}>
|
||||
{REGISTRATION_STATUS_LABEL[registration.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<ContactLines registration={registration} />
|
||||
<ConsentChips registration={registration} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-slate-100 bg-slate-25 text-left">
|
||||
<tr className="hx-eyebrow">
|
||||
<th className="px-3 py-2">Uczestnik</th>
|
||||
<th className="px-3 py-2">Kontakt</th>
|
||||
<th className="px-3 py-2">Firma</th>
|
||||
<th className="px-3 py-2">Status</th>
|
||||
<th className="px-3 py-2">Zapisano</th>
|
||||
<th className="px-3 py-2">Zgody</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{data.content.map((registration) => {
|
||||
const { date, time } = formatDateTimeParts(registration.createdAt, webinar.timezone)
|
||||
return (
|
||||
<tr key={registration.id}>
|
||||
<td className="px-3 py-2 font-medium text-slate-900">
|
||||
{registration.firstName} {registration.lastName}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<a
|
||||
href={`mailto:${registration.email}`}
|
||||
className="hx-data block text-blue-700 hover:underline"
|
||||
>
|
||||
{registration.email}
|
||||
</a>
|
||||
{registration.phone && (
|
||||
<a
|
||||
href={`tel:${registration.phone}`}
|
||||
className="hx-data block text-xs text-slate-500 hover:underline"
|
||||
>
|
||||
{registration.phone}
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-slate-600">{registration.company || '-'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge tone={REGISTRATION_STATUS_TONE[registration.status]}>
|
||||
{REGISTRATION_STATUS_LABEL[registration.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="hx-data whitespace-nowrap px-3 py-2 text-slate-600">
|
||||
<span className="block">{date}</span>
|
||||
<span className="block text-xs text-slate-400">{time}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<ConsentChips registration={registration} />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={data.number}
|
||||
totalPages={data.totalPages}
|
||||
totalElements={data.totalElements}
|
||||
onChange={onPage}
|
||||
itemLabel="zapisanych"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContactLines({ registration }: { registration: Registration }) {
|
||||
return (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
<a
|
||||
href={`mailto:${registration.email}`}
|
||||
className="hx-data block break-all text-sm text-blue-700 hover:underline"
|
||||
>
|
||||
{registration.email}
|
||||
</a>
|
||||
{registration.phone && (
|
||||
<a href={`tel:${registration.phone}`} className="hx-data block text-sm text-slate-600">
|
||||
{registration.phone}
|
||||
</a>
|
||||
)}
|
||||
{registration.company && <p className="text-sm text-slate-500">{registration.company}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zgody uczestnika.
|
||||
*
|
||||
* Pokazujemy wylacznie **najnowsza** zgode kazdego typu: zgody sa dopisywane,
|
||||
* nigdy nadpisywane, wiec przy ponownym zapisie jest ich kilka i pokazanie
|
||||
* wszystkich mylilo by co do stanu biezacego.
|
||||
*/
|
||||
function ConsentChips({ registration }: { registration: Registration }) {
|
||||
const latest = new Map<string, boolean>()
|
||||
for (const consent of registration.consents) {
|
||||
latest.set(consent.type, consent.granted)
|
||||
}
|
||||
|
||||
if (latest.size === 0) return <span className="text-xs text-slate-400">-</span>
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-1 lg:mt-0">
|
||||
{[...latest.entries()].map(([type, granted]) => (
|
||||
<span
|
||||
key={type}
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-2xs ${
|
||||
granted ? 'bg-ok-50 text-ok-600' : 'bg-slate-100 text-slate-500'
|
||||
}`}
|
||||
title={CONSENT_LABEL[type as keyof typeof CONSENT_LABEL] ?? type}
|
||||
>
|
||||
<Icon name={granted ? 'check' : 'x'} size={12} />
|
||||
{shortConsent(type)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Historia czatu z webinaru: kto, co i kiedy napisal.
|
||||
*
|
||||
* Archiwum po spotkaniu, wiec bez WebSocketu i bez moderacji. Wiadomosci
|
||||
* usuniete przez moderatora maja status REJECTED i **zostaja na liscie**,
|
||||
* wyraznie oznaczone: prowadzacy ma widziec, co zostalo usuniete, bo to jedyny
|
||||
* slad po interwencji. Kasowanie wiersza odebraloby mu ten slad.
|
||||
*
|
||||
* Czas jest w strefie webinaru, a nie przegladarki: rozmowa toczyla sie
|
||||
* w czasie wydarzenia i tylko w tej strefie kolejnosc wypowiedzi ma sens
|
||||
* dla kogos, kto oglada to z innego kraju.
|
||||
*/
|
||||
function ChatHistoryTab({ chat, webinar }: { chat: InteractionView[]; webinar: Webinar }) {
|
||||
if (chat.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="message-square"
|
||||
title="Brak wiadomości"
|
||||
description="Podczas tego webinaru nikt nie napisał na czacie."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-500">
|
||||
Wiadomości: <span className="hx-data font-medium text-slate-900">{chat.length}</span>
|
||||
<span className="text-slate-400"> - czas w strefie {webinar.timezone}</span>
|
||||
</p>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{chat.map((message) => {
|
||||
const { date, time } = formatDateTimeParts(message.createdAt, webinar.timezone)
|
||||
const removed = message.status === 'REJECTED'
|
||||
|
||||
return (
|
||||
<li
|
||||
key={message.id}
|
||||
className={`rounded-md border p-3 ${
|
||||
removed ? 'border-slate-200 bg-slate-25' : 'border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{message.authorName}</span>
|
||||
{message.fromSpeaker && <Badge tone="brand">prowadzący</Badge>}
|
||||
{removed && <Badge tone="alarm">usunięta przez moderatora</Badge>}
|
||||
</div>
|
||||
<span className="hx-data whitespace-nowrap text-xs text-slate-400">
|
||||
{date} {time}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className={`mt-1 whitespace-pre-wrap break-words text-sm ${
|
||||
removed ? 'text-slate-400 line-through' : 'text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{message.content}
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Krotka etykieta zgody, zeby chipy nie rozpychaly kolumny. */
|
||||
function shortConsent(type: string): string {
|
||||
if (type === 'TERMS') return 'regulamin'
|
||||
if (type === 'MARKETING_EMAIL') return 'e-mail'
|
||||
if (type === 'MARKETING_SMS') return 'SMS'
|
||||
return type
|
||||
}
|
||||
Reference in New Issue
Block a user