1599c7b0c1
Deploy PROD / deploy (push) Successful in 25s
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>
600 lines
20 KiB
TypeScript
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>
|
|
)
|
|
}
|