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