- 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.
139 lines
5.9 KiB
TypeScript
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>
|
|
)
|
|
}
|