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(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('/admin/certificates/counters')).data, }) const subjects = useQuery({ queryKey: ['certificate-subjects'], queryFn: async () => (await client.get('/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(`/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 (

Certyfikaty

Wszystkie wydane dokumenty, z kursów i z webinarów.

{/* --- liczniki --- */}
0 ? 'alarm' : 'neutral'} />
{/* --- filtry --- */}
changeFilter(setFrom)(e.target.value)} />
changeFilter(setTo)(e.target.value)} />
changeFilter(setSearch)(e.target.value)} />

Znaleziono: {data?.totalElements ?? 0}

{/* --- lista --- */}
{isPending && (
)} {isError && (
)} {data && rows.length === 0 && (
)} {rows.length > 0 && ( <>
    {rows.map((row) => (
  • {row.number}

    {row.forCourse ? 'kurs' : 'webinar'} {row.revoked && unieważniony}

    {row.recipientName}

    {row.subjectTitle}

    {formatLocal(row.issuedAt)}

    resend.mutate(row.id)} onRevoke={() => setRevoking(row)} busy={resend.isPending} />
  • ))}
{rows.map((row) => ( ))}
Numer Odbiorca Źródło Wydano Status Akcje
{row.number}

{row.recipientName}

{row.email}

{row.forCourse ? 'kurs' : 'webinar'} {row.subjectTitle}
{formatLocal(row.issuedAt)} {row.revoked ? ( unieważniony ) : ( ważny )}
resend.mutate(row.id)} onRevoke={() => setRevoking(row)} busy={resend.isPending} />
)}
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. {revoking && ( setRevoking(null)} onConfirm={(note) => revoke.mutate({ id: revoking.id, note })} busy={revoke.isPending} /> )}
) } function RowActions({ row, publicUrl, onResend, onRevoke, busy, }: { row: CertificateRowAdmin publicUrl: string onResend: () => void onRevoke: () => void busy: boolean }) { const confirm = useConfirm() return ( <> Pobierz {!row.revoked && ( <> )} ) } /** * 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 ( } >
Certyfikat {row.number} wydany dla{' '} {row.recipientName} przestanie być ważny. Dokument nie zniknie: weryfikacja publiczna pokaże, że został wycofany, bo mógł już trafić do pracodawcy.