feat(kiosk): implement kiosk report submission functionality with offline support test(outbox): add tests for outbox functionality including report submission and error handling feat(outbox): create outbox management for queued reports with IndexedDB persistence feat(uuid): add UUID generation utility for client IDs feat(qr): implement QR code generation for reporting links by site and area feat(kiosk): create Kiosk page for reporting incidents without an account
100 lines
4.4 KiB
TypeScript
100 lines
4.4 KiB
TypeScript
import { ArrowLeft, CheckCircle2, MapPin, Printer } from 'lucide-react'
|
||
import { Link, useLocation, useParams } from 'react-router-dom'
|
||
import { SeverityLabel, StatusBadge } from '../components/Badges'
|
||
import { ActionsSection } from '../components/incident/ActionsSection'
|
||
import { ActivitySection } from '../components/incident/ActivitySection'
|
||
import { AttachmentsSection } from '../components/incident/AttachmentsSection'
|
||
import { DetailsSection } from '../components/incident/DetailsSection'
|
||
import { InvestigationSection } from '../components/incident/InvestigationSection'
|
||
import { WorkflowPanel } from '../components/incident/WorkflowPanel'
|
||
import { Page } from '../components/Page'
|
||
import { ErrorMessage, Loading } from '../components/QueryState'
|
||
import { secondaryButton } from '../components/styles'
|
||
import { ApiError } from '../lib/api'
|
||
import { formatDateTime } from '../lib/dates'
|
||
import { incidentTypeLabel } from '../lib/labels'
|
||
import { useIncident } from '../lib/queries'
|
||
|
||
export function IncidentDetail() {
|
||
const number = Number(useParams().number)
|
||
const location = useLocation()
|
||
const query = useIncident(number)
|
||
const incident = query.data
|
||
|
||
if (query.isPending) return <Loading />
|
||
if (!incident) {
|
||
return (
|
||
<Page>
|
||
{query.error instanceof ApiError && query.error.status === 404 ? (
|
||
<p className="text-sm text-muted">
|
||
This incident doesn’t exist or you don’t have access to it.{' '}
|
||
<Link to="/incidents" className="text-coral underline">
|
||
Back to incidents
|
||
</Link>
|
||
</p>
|
||
) : (
|
||
<ErrorMessage error={query.error} />
|
||
)}
|
||
</Page>
|
||
)
|
||
}
|
||
|
||
const justReported = (location.state as { justReported?: boolean } | null)?.justReported
|
||
|
||
return (
|
||
<Page>
|
||
<Link to="/incidents" className="mb-4 inline-flex items-center gap-1 text-2xs font-semibold text-muted hover:text-ink print:hidden">
|
||
<ArrowLeft size={14} /> All incidents
|
||
</Link>
|
||
{justReported && (
|
||
<div className="mb-5 flex items-start gap-2 rounded-md bg-mint-soft px-4 py-3 text-xs text-[#2b6e4f] print:hidden" role="status">
|
||
<CheckCircle2 size={16} className="mt-px flex-none" />
|
||
<span>
|
||
<strong>Thanks, {incident.reference} has been reported.</strong> The safety team has been notified{incident.severity === 'high' || incident.severity === 'critical' ? ' straight away' : ''}.
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||
<div>
|
||
<span className="font-mono text-3xs font-medium tracking-[0.09em] text-[#8da29b] uppercase">
|
||
{incident.reference} · {incidentTypeLabel[incident.type]}
|
||
</span>
|
||
<h1 className="mt-1.5 mb-3 font-display text-[26px] leading-tight font-semibold tracking-[-0.5px] text-ink sm:text-[30px]">{incident.title}</h1>
|
||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-2xs text-body">
|
||
<StatusBadge status={incident.status} />
|
||
<SeverityLabel severity={incident.severity} />
|
||
<span className="inline-flex items-center gap-1">
|
||
<MapPin size={13} />
|
||
{[incident.site.name, incident.area?.name].filter(Boolean).join(' · ')}
|
||
</span>
|
||
<span>Occurred {formatDateTime(incident.occurredAt)}</span>
|
||
<span>
|
||
Reported by {incident.anonymous ? 'anonymous' : (incident.reporter?.name ?? incident.reporterName ?? 'former user')}
|
||
{incident.kiosk && ` via ${incident.kiosk.name}`} · {formatDateTime(incident.reportedAt)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<button type="button" className={`${secondaryButton} flex-none print:hidden`} onClick={() => window.print()}>
|
||
<Printer size={16} /> Print / PDF
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px] print:block">
|
||
<div className="min-w-0 space-y-5">
|
||
<DetailsSection incident={incident} />
|
||
<InvestigationSection incident={incident} />
|
||
<ActionsSection incident={incident} />
|
||
<ActivitySection incident={incident} />
|
||
</div>
|
||
<div className="space-y-5 print:mt-5">
|
||
<div className="print:hidden">
|
||
<WorkflowPanel incident={incident} />
|
||
</div>
|
||
<AttachmentsSection incident={incident} />
|
||
</div>
|
||
</div>
|
||
</Page>
|
||
)
|
||
}
|