Files
open-safetylog-ehs/src/pages/Incidents.tsx
T
jayjennings 61c876edec
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
Add OSHA Form 301 and Osha page components
- Implemented Form301 component for displaying OSHA Form 301 equivalent, including employee and case information.
- Created Osha component to manage OSHA recordkeeping, including tabs for 300 Log, 300A Summary, Hours & rates, and Submit to OSHA.
- Added functionality for printing and downloading CSV files for OSHA submissions.
- Integrated loading and error handling states for data fetching.
2026-10-06 13:09:57 -07:00

139 lines
5.9 KiB
TypeScript

import { FileDown, Filter, Plus, Search } from 'lucide-react'
import { useEffect, useState } from 'react'
import { NavLink, useSearchParams } from 'react-router-dom'
import { incidentStatuses, incidentTypes, severities } from '../../shared/enums.ts'
import { IncidentTable } from '../components/IncidentTable'
import { Page } from '../components/Page'
import { PageIntro } from '../components/PageIntro'
import { Pagination } from '../components/Pagination'
import { SavedViews } from '../components/SavedViews'
import { Empty, ErrorMessage, Loading } from '../components/QueryState'
import { filterBox, panel, primaryButton, secondaryButton } from '../components/styles'
import { incidentTypeLabel, severityLabel, statusLabel } from '../lib/labels'
import { filtersToQuery, useIncidents, useSites, type IncidentFilters } from '../lib/queries'
const PAGE_SIZE = 25
const select = 'bg-transparent text-xs text-ink outline-none'
export function Incidents() {
const [params, setParams] = useSearchParams()
const sites = useSites()
const filters: IncidentFilters = {
q: params.get('q') ?? undefined,
type: params.get('type') ?? undefined,
severity: params.get('severity') ?? undefined,
status: params.get('status') ?? undefined,
siteId: params.get('siteId') ?? undefined,
}
const page = Number(params.get('page') ?? 1)
const incidents = useIncidents({ ...filters, page, pageSize: PAGE_SIZE })
// Search box updates the URL after a short pause
const [query, setQuery] = useState(filters.q ?? '')
useEffect(() => setQuery(params.get('q') ?? ''), [params])
useEffect(() => {
const handle = setTimeout(() => {
if ((params.get('q') ?? '') !== query.trim()) setFilter('q', query.trim())
}, 300)
return () => clearTimeout(handle)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
function setFilter(key: string, value: string) {
const next = new URLSearchParams(params)
if (value) next.set(key, value)
else next.delete(key)
next.delete('page')
setParams(next, { replace: true })
}
const setPage = (value: number) => {
const next = new URLSearchParams(params)
next.set('page', String(value))
setParams(next)
}
return (
<Page>
<PageIntro
eyebrow="Safety register"
title="Incidents"
copy="Every reported event, hazard and near miss, and where each one stands."
action={
<NavLink className={primaryButton} to="/report">
<Plus size={17} /> Report incident
</NavLink>
}
/>
<section className={`${panel} overflow-hidden`}>
<div className="flex flex-wrap items-center gap-2.5 border-b border-line px-[17px] py-3.5 sm:px-6 sm:py-[18px]">
<div className={`${filterBox} basis-full lg:max-w-[300px] lg:flex-1 lg:basis-auto`}>
<Search size={17} />
<input className="w-full bg-transparent text-xs text-ink outline-none" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search title, location or INC number" aria-label="Search incidents" />
</div>
<div className={filterBox}>
<Filter size={16} />
<select className={select} value={filters.type ?? ''} onChange={(event) => setFilter('type', event.target.value)} aria-label="Filter by type">
<option value="">All types</option>
{incidentTypes.map((type) => (
<option key={type} value={type}>
{incidentTypeLabel[type]}
</option>
))}
</select>
</div>
<div className={filterBox}>
<select className={select} value={filters.severity ?? ''} onChange={(event) => setFilter('severity', event.target.value)} aria-label="Filter by severity">
<option value="">All severities</option>
{severities.map((severity) => (
<option key={severity} value={severity}>
{severityLabel[severity]}
</option>
))}
</select>
</div>
<div className={filterBox}>
<select className={select} value={filters.status ?? ''} onChange={(event) => setFilter('status', event.target.value)} aria-label="Filter by status">
<option value="">All statuses</option>
{incidentStatuses.map((status) => (
<option key={status} value={status}>
{statusLabel[status]}
</option>
))}
</select>
</div>
{(sites.data?.length ?? 0) > 1 && (
<div className={filterBox}>
<select className={select} value={filters.siteId ?? ''} onChange={(event) => setFilter('siteId', event.target.value)} aria-label="Filter by site">
<option value="">All sites</option>
{sites.data?.map((site) => (
<option key={site.id} value={site.id}>
{site.name}
</option>
))}
</select>
</div>
)}
<div className="lg:ml-auto">
<SavedViews page="incidents" keys={['q', 'type', 'severity', 'status', 'siteId']} />
</div>
<a className={secondaryButton} href={`/api/incidents/export${filtersToQuery(filters)}`} download>
<FileDown size={16} /> Export CSV
</a>
</div>
<ErrorMessage error={incidents.error} className="m-4" />
{incidents.isPending ? (
<Loading />
) : incidents.data?.items.length ? (
<>
<IncidentTable incidents={incidents.data.items} wide />
<Pagination page={page} pageSize={PAGE_SIZE} total={incidents.data.total} onPage={setPage} />
</>
) : (
<Empty>{Object.values(filters).some(Boolean) ? 'No incidents match these filters.' : 'No incidents reported yet.'}</Empty>
)}
</section>
</Page>
)
}