Files
akademiawodociagowca/frontend/src/pages/courses/CertificateOverviewPage.tsx
T
debian 1599c7b0c1
Deploy PROD / deploy (push) Successful in 25s
Potwierdzenia operacji jako okno panelu, nie okno przegladarki
Panel pytal o potwierdzenie natywnym `window.confirm`. Takie okno wyglada
inaczej w kazdej przegladarce i w kazdym systemie, wyskakuje przy gornej
krawedzi zamiast na srodku i pokazuje nad trescia adres domeny ("Komunikat
ze strony ..."). W panelu, ktory poza tym jest spojnym interfejsem, czyta sie
to jak blad strony, a nie jak pytanie aplikacji.

CLAUDE.md wymagalo juz, zeby kazde okno modalne uzywalo komponentu `Modal`,
i tlumaczylo przy otwartym mikrofonie, dlaczego nie `window.confirm`. Regula
obowiazywala w jednym miejscu, a w 42 innych nie.

HOOK, A NIE OKNO W KAZDYM MIEJSCU Z OSOBNA
Potwierdzen jest ponad czterdziesci. Wlasny stan i wlasny `Modal` do kazdego
oznaczalyby kilkanascie linii na jedno pytanie i te sama pewnosc, ze predzej
czy pozniej ktores bedzie wygladalo inaczej. `useConfirm` oddaje funkcje
o ksztalcie zblizonym do `confirm`, wiec zamiana jest jednolinijkowa. Wynik
przychodzi obietnica, bo okno musi sie najpierw wyrenderowac.

TRESC MOWI, CO SIE STANIE
Przy okazji poprawione komunikaty: zamiast "Czy na pewno?" pytania nazywaja
obiekt i skutek. Plik zniknie ze skladu instancji, odbiorca straci dostep do
API, razem z banerem przepadnie jego statystyka wraz z liczbami. Etykieta
przycisku nazywa czynnosc ("Usun nagranie"), a nie mowi "OK".

NIEODWRACALNE WYROZNIONE, ODWRACALNE NIE
`destructive` daje czerwony przycisk i ostrzezenie "Tej operacji nie da sie
cofnac". Odwolanie webinaru i dezaktywacja konta go NIE dostaja: sa
odwracalne, a straszenie tam, gdzie nic nie przepada, rozmydla sygnal tam,
gdzie strata jest prawdziwa. Zgodnie z reguła, ze usuniecie webinaru ma
podpowiadac odwolanie jako lagodniejsza alternatywe.

Sprawdzone `npx tsc -b --force` (pelne typowanie bez cache) i `npm run build`.
Przy okazji poprawione dwa em dash sprzed tej zmiany, w `ApiKeyListPage`
i `ProviderPicker`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:13:16 +02:00

600 lines
20 KiB
TypeScript

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 { useConfirm } from '../../components/Confirm'
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
}) {
const confirm = useConfirm()
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={async () => {
const ok = await confirm({
title: 'Wysłać certyfikat ponownie?',
message: (
<>
Certyfikat <strong>{row.number}</strong> trafi na adres{' '}
<strong>{row.email}</strong>. Wyślemy dokładnie ten plik, który został wydany.
</>
),
confirmLabel: 'Wyślij ponownie',
})
if (ok) 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>
)
}