Add OSHA Form 301 and Osha page components
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s

- 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.
This commit is contained in:
jayjennings committed 2026-10-06 13:09:57 -07:00
1 parent 3e964d1176
commit 61c876edec
59 files changed
+9180 -183

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
import { Bookmark, Trash2 } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import { useOutletContext, useSearchParams } from 'react-router-dom'
import type { SavedView } from '../../shared/api-types.ts'
import { hasRole } from '../../shared/enums.ts'
import { useSavedViewMutations, useSavedViews, type Me } from '../lib/queries'
import { filterBox, iconButton, primaryButton, smallButton } from './styles'
/** Pick or save a named set of the page's URL filters. */
export function SavedViews({ page, keys }: { page: SavedView['page']; keys: string[] }) {
const me = useOutletContext<Me>()
const [params, setParams] = useSearchParams()
const views = useSavedViews(page)
const { create, remove } = useSavedViewMutations(page)
const [saving, setSaving] = useState(false)
const [name, setName] = useState('')
const [shared, setShared] = useState(false)
const current = Object.fromEntries(keys.filter((key) => params.get(key)).map((key) => [key, params.get(key)!]))
// Key order isn't preserved by the database, so compare sorted entries
const canonical = (filters: Record<string, string>) => JSON.stringify(Object.entries(filters).sort(([a], [b]) => a.localeCompare(b)))
const active = views.data?.find((view) => canonical(view.filters) === canonical(current))
const apply = (id: string) => {
const view = views.data?.find((v) => v.id === id)
if (!view) return
const next = new URLSearchParams(params)
for (const key of keys) next.delete(key)
for (const [key, value] of Object.entries(view.filters)) next.set(key, value)
setParams(next, { replace: true })
}
const save = (event: FormEvent) => {
event.preventDefault()
create.mutate(
{ name, filters: current, shared },
{
onSuccess: () => {
setSaving(false)
setName('')
},
},
)
}
if (saving) {
return (
<form onSubmit={save} className="flex flex-wrap items-center gap-2">
<input className="h-[38px] rounded-[5px] border border-line bg-white px-3 text-xs outline-none" placeholder="Name this view" aria-label="View name" required value={name} onChange={(event) => setName(event.target.value)} />
{hasRole(me.user.role, 'supervisor') && (
<label className="flex items-center gap-1.5 text-2xs text-body">
<input type="checkbox" className="accent-coral" checked={shared} onChange={(event) => setShared(event.target.checked)} /> Share with everyone
</label>
)}
<button type="submit" className={primaryButton} disabled={create.isPending}>
Save
</button>
<button type="button" className={smallButton} onClick={() => setSaving(false)}>
Cancel
</button>
</form>
)
}
return (
<div className="flex items-center gap-1">
<div className={filterBox}>
<Bookmark size={15} />
<select className="bg-transparent text-xs text-ink outline-none" value={active?.id ?? ''} onChange={(event) => apply(event.target.value)} aria-label="Saved views">
<option value="" disabled>
{views.data?.length ? 'Saved views' : 'No saved views'}
</option>
{views.data?.map((view) => (
<option key={view.id} value={view.id}>
{view.name}
{view.shared ? ' (shared)' : ''}
</option>
))}
</select>
</div>
{active && (active.mine || me.user.role === 'admin') ? (
<button type="button" className={iconButton} aria-label={`Delete view ${active.name}`} onClick={() => confirm(`Delete the view “${active.name}”?`) && remove.mutate(active.id)}>
<Trash2 size={15} />
</button>
) : (
<button type="button" className={smallButton} onClick={() => setSaving(true)}>
Save view
</button>
)}
</div>
)
}
+4 -1
View File
@@ -1,5 +1,6 @@
import { Info, LogOut, Plus, Settings, ShieldCheck, X } from 'lucide-react'
import { NavLink, useNavigate } from 'react-router-dom'
import { hasRole } from '../../shared/enums.ts'
import { useI18n } from '../i18n'
import { outbox } from '../lib/outbox'
import { initials } from '../lib/initials'
@@ -57,7 +58,9 @@ export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
</div>
<nav className={navSection}>
{primaryNav.map(({ label, to, icon: Icon }) => (
{primaryNav
.filter((item) => !item.minRole || hasRole(me.user.role, item.minRole))
.map(({ label, to, icon: Icon }) => (
<NavLink key={to} to={to} end={to === '/'} onClick={onClose} className={navLink}>
<Icon size={18} />
<span>{t.nav[label]}</span>
+21
View File
@@ -0,0 +1,21 @@
import { formatNumber } from './scale'
/** Horizontal bars for ranked categories: one color, value at the tip, longest first. */
export function BarList({ items, color = 'var(--color-series-1)', ariaLabel }: { items: { label: string; value: number }[]; color?: string; ariaLabel: string }) {
const max = Math.max(1, ...items.map((item) => item.value))
return (
<ul className="space-y-2.5" aria-label={ariaLabel}>
{items.map((item) => (
<li key={item.label} className="grid grid-cols-[minmax(90px,34%)_1fr] items-center gap-3 text-2xs">
<span className="truncate text-body" title={item.label}>
{item.label}
</span>
<span className="flex items-center gap-2">
<span className="h-3.5 rounded-r-[4px]" style={{ width: `${Math.max(1.5, (item.value / max) * 85)}%`, background: color }} />
<strong className="font-semibold text-ink tabular-nums">{formatNumber(item.value)}</strong>
</span>
</li>
))}
</ul>
)
}
+105
View File
@@ -0,0 +1,105 @@
import { Table2 } from 'lucide-react'
import { useState, type ReactNode } from 'react'
import { panel } from '../styles'
export interface LegendItem {
label: string
color: string
shape?: 'rect' | 'line'
}
export interface TableData {
columns: string[]
rows: (string | number)[][]
}
/**
* Card for one chart: title, legend (2+ series), and a table view that shows every value, so
* nothing depends on hovering or on telling colors apart.
*/
export function ChartFrame({ title, subtitle, legend, table, children, className = '' }: { title: string; subtitle?: string; legend?: LegendItem[]; table: TableData; children: ReactNode; className?: string }) {
const [showTable, setShowTable] = useState(false)
return (
<figure className={`${panel} m-0 flex flex-col p-5 ${className}`}>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<figcaption className="font-display text-[17px] font-semibold text-ink">{title}</figcaption>
{subtitle && <p className="mt-0.5 text-2xs text-muted">{subtitle}</p>}
</div>
<button
type="button"
className={`inline-flex flex-none items-center gap-1 rounded-[5px] border px-2 py-1 text-3xs font-semibold print:hidden ${showTable ? 'border-ink bg-ink text-white' : 'border-line text-muted hover:text-ink'}`}
aria-pressed={showTable}
onClick={() => setShowTable(!showTable)}
>
<Table2 size={12} /> Table
</button>
</div>
{legend && legend.length > 1 && !showTable && (
<ul className="mb-3 flex flex-wrap gap-x-4 gap-y-1 text-2xs text-body" aria-label="Legend">
{legend.map((item) => (
<li key={item.label} className="inline-flex items-center gap-1.5">
{item.shape === 'line' ? <i className="h-0.5 w-3.5 rounded-full" style={{ background: item.color }} /> : <i className="size-2.5 rounded-[2px]" style={{ background: item.color }} />}
{item.label}
</li>
))}
</ul>
)}
{showTable ? <DataTable data={table} /> : children}
</figure>
)
}
export function DataTable({ data }: { data: TableData }) {
return (
<div className="max-h-[320px] overflow-auto">
<table className="w-full border-collapse text-2xs">
<thead className="sticky top-0 bg-white">
<tr>
{data.columns.map((column, i) => (
<th key={column} className={`border-b border-line px-2 py-1.5 font-semibold text-[#586762] ${i ? 'text-right' : 'text-left'}`}>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{data.rows.map((row, r) => (
<tr key={r}>
{row.map((cell, i) => (
<td key={i} className={`border-b border-line-soft px-2 py-1.5 text-body ${i ? 'text-right tabular-nums' : ''}`}>
{typeof cell === 'number' ? cell.toLocaleString('en-US', { maximumFractionDigits: 2 }) : cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
/** Tooltip: value first (strong), series second, keyed with a short line in the series color. */
export function Tooltip({ x, y, title, rows, containerWidth }: { x: number; y: number; title: string; rows: { label: string; value: string; color?: string }[]; containerWidth: number }) {
const flip = x > containerWidth - 180
return (
<div
className="pointer-events-none absolute z-10 min-w-[140px] rounded-md border border-line bg-white px-3 py-2 text-2xs shadow-[0_6px_20px_#1b252322]"
style={{ left: flip ? undefined : x + 12, right: flip ? containerWidth - x + 12 : undefined, top: Math.max(0, y - 10) }}
role="status"
>
<p className="mb-1 text-3xs text-muted">{title}</p>
{rows.map((row) => (
<p key={row.label} className="flex items-center gap-2">
{row.color && <i className="h-0.5 w-3 flex-none rounded-full" style={{ background: row.color }} />}
<strong className="font-semibold text-ink tabular-nums">{row.value}</strong>
<span className="text-body">{row.label}</span>
</p>
))}
</div>
)
}
export function ChartEmpty({ children }: { children: ReactNode }) {
return <div className="grid min-h-[160px] place-items-center px-4 text-center text-2xs text-muted">{children}</div>
}
+65
View File
@@ -0,0 +1,65 @@
import { useState } from 'react'
import { Tooltip } from './ChartFrame'
import { useWidth } from './useWidth'
const STEPS = ['var(--color-seq-100)', 'var(--color-seq-200)', 'var(--color-seq-300)', 'var(--color-seq-400)', 'var(--color-seq-500)', 'var(--color-seq-600)', 'var(--color-seq-700)']
/** Grid of counts in one hue, light to dark; empty cells stay near the surface. */
export function Heatmap({ grid, rowLabels, colLabels, ariaLabel, describe }: { grid: number[][]; rowLabels: string[]; colLabels: string[]; ariaLabel: string; describe: (row: number, col: number, value: number) => string }) {
const [ref, width] = useWidth<HTMLDivElement>()
const [hover, setHover] = useState<{ r: number; c: number } | null>(null)
const max = Math.max(1, ...grid.flat())
const step = (value: number) => (value === 0 ? 'var(--color-line-soft)' : STEPS[Math.min(STEPS.length - 1, Math.floor((value / max) * (STEPS.length - 1)))])
const labelW = 34
const cell = Math.max(10, Math.floor((width - labelW) / colLabels.length))
const height = rowLabels.length * cell + 18
return (
<div ref={ref} className="relative" onPointerLeave={() => setHover(null)}>
<svg width={width} height={height} role="img" aria-label={ariaLabel} className="block">
{rowLabels.map((label, r) => (
<text key={label} x={labelW - 6} y={r * cell + cell / 2} dy="0.32em" textAnchor="end" fontSize={10} fill="var(--color-chart-label)">
{label}
</text>
))}
{colLabels.map((label, c) =>
c % 3 === 0 ? (
<text key={c} x={labelW + c * cell + cell / 2} y={rowLabels.length * cell + 12} textAnchor="middle" fontSize={10} fill="var(--color-chart-label)">
{label}
</text>
) : null,
)}
{grid.map((row, r) =>
row.map((value, c) => (
<rect
key={`${r}-${c}`}
x={labelW + c * cell + 1}
y={r * cell + 1}
width={cell - 2}
height={cell - 2}
rx={2}
fill={step(value)}
stroke={hover?.r === r && hover.c === c ? 'var(--color-ink)' : 'none'}
strokeWidth={1.5}
tabIndex={value ? 0 : -1}
aria-label={describe(r, c, value)}
onPointerEnter={() => setHover({ r, c })}
onFocus={() => setHover({ r, c })}
className="outline-none"
/>
)),
)}
</svg>
<div className="mt-2 flex items-center gap-2 text-3xs text-muted" aria-hidden>
<span>Fewer</span>
{STEPS.map((color) => (
<i key={color} className="h-2.5 w-5 rounded-[2px]" style={{ background: color }} />
))}
<span>More ({max} max)</span>
</div>
{hover && (
<Tooltip x={labelW + hover.c * cell + cell} y={hover.r * cell} containerWidth={width} title={describe(hover.r, hover.c, grid[hover.r][hover.c]).split(':')[0]} rows={[{ label: grid[hover.r][hover.c] === 1 ? 'incident' : 'incidents', value: String(grid[hover.r][hover.c]) }]} />
)}
</div>
)
}
+119
View File
@@ -0,0 +1,119 @@
import { useState, type PointerEvent } from 'react'
import { Tooltip } from './ChartFrame'
import { formatNumber, niceTicks } from './scale'
import type { Series } from './StackedColumns'
import { useWidth } from './useWidth'
interface Props {
data: { label: string; values: Record<string, number | null> }[]
series: Series[]
height?: number
ariaLabel: string
}
const PAD = { top: 12, bottom: 26, left: 36 }
/** Multi-series line chart on one axis, with a snapping crosshair and direct end labels. */
export function LineChart({ data, series, height = 220, ariaLabel }: Props) {
const [ref, width] = useWidth<HTMLDivElement>()
const [hover, setHover] = useState<number | null>(null)
// Room on the right for the end labels ("TRIR 10.38"), sized to the longest one
const endLabel = (key: string, label: string) => {
const last = data.findLast((d) => d.values[key] !== null)?.values[key]
return last === undefined || last === null ? '' : `${label} ${formatNumber(last)}`
}
const right = 16 + Math.max(0, ...series.map((s) => endLabel(s.key, s.label).length)) * 6.2
const plotW = width - PAD.left - right
const plotH = height - PAD.top - PAD.bottom
const all = data.flatMap((d) => series.map((s) => d.values[s.key])).filter((v): v is number => v !== null)
const ticks = niceTicks(Math.max(...all, 0.1))
const max = ticks.at(-1)!
const x = (i: number) => PAD.left + (data.length === 1 ? plotW / 2 : (i / (data.length - 1)) * plotW)
const y = (v: number) => PAD.top + plotH - (v / max) * plotH
const labelEvery = Math.ceil(data.length / Math.max(1, Math.floor(plotW / 56)))
const path = (key: string) => {
let d = ''
data.forEach((point, i) => {
const v = point.values[key]
if (v === null) return
d += `${d && data[i - 1]?.values[key] !== null ? 'L' : 'M'}${x(i).toFixed(1)},${y(v).toFixed(1)}`
})
return d
}
const onMove = (event: PointerEvent<SVGRectElement>) => {
const box = event.currentTarget.getBoundingClientRect()
const ratio = (event.clientX - box.left) / box.width
setHover(Math.max(0, Math.min(data.length - 1, Math.round(ratio * (data.length - 1)))))
}
const lastIndex = (key: string) => data.findLastIndex((d) => d.values[key] !== null)
return (
<div ref={ref} className="relative">
<svg width={width} height={height} role="img" aria-label={ariaLabel} className="block">
{ticks.map((t) => (
<g key={t}>
<line x1={PAD.left} x2={width - right} y1={y(t)} y2={y(t)} stroke={t === 0 ? 'var(--color-chart-axis)' : 'var(--color-chart-grid)'} strokeWidth={1} shapeRendering="crispEdges" />
<text x={PAD.left - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize={10} fill="var(--color-chart-label)" className="tabular-nums">
{formatNumber(t)}
</text>
</g>
))}
{data.map((d, i) =>
i % labelEvery === 0 || i === data.length - 1 ? (
<text key={d.label} x={x(i)} y={height - 8} textAnchor="middle" fontSize={10} fill="var(--color-chart-label)">
{d.label}
</text>
) : null,
)}
{hover !== null && <line x1={x(hover)} x2={x(hover)} y1={PAD.top} y2={PAD.top + plotH} stroke="var(--color-chart-axis)" strokeWidth={1} />}
{series.map((s) => {
const last = lastIndex(s.key)
return (
<g key={s.key}>
<path d={path(s.key)} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
{last >= 0 && (
<>
<circle cx={x(last)} cy={y(data[last].values[s.key]!)} r={4} fill={s.color} stroke="#fff" strokeWidth={2} />
<text x={x(last) + 8} y={y(data[last].values[s.key]!)} dy="0.32em" fontSize={10} fill="var(--color-ink)" fontWeight={600}>
{s.label} {formatNumber(data[last].values[s.key]!)}
</text>
</>
)}
{hover !== null && data[hover].values[s.key] !== null && <circle cx={x(hover)} cy={y(data[hover].values[s.key]!)} r={4} fill={s.color} stroke="#fff" strokeWidth={2} />}
</g>
)
})}
<rect
x={PAD.left}
y={PAD.top}
width={plotW}
height={plotH}
fill="transparent"
tabIndex={0}
aria-label={ariaLabel}
onPointerMove={onMove}
onPointerLeave={() => setHover(null)}
onFocus={() => setHover(data.length - 1)}
onBlur={() => setHover(null)}
onKeyDown={(event) => {
if (event.key === 'ArrowLeft') setHover((h) => Math.max(0, (h ?? 0) - 1))
if (event.key === 'ArrowRight') setHover((h) => Math.min(data.length - 1, (h ?? 0) + 1))
}}
className="outline-none"
/>
</svg>
{hover !== null && (
<Tooltip
x={x(hover)}
y={PAD.top}
containerWidth={width}
title={data[hover].label}
rows={series.map((s) => ({ label: s.label, value: data[hover].values[s.key] === null ? '–' : formatNumber(data[hover].values[s.key]!), color: s.color }))}
/>
)}
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
import { useState } from 'react'
import { Tooltip } from './ChartFrame'
import { formatNumber, niceTicks } from './scale'
import { useWidth } from './useWidth'
export interface Series {
key: string
label: string
color: string
}
interface Props {
data: { label: string; values: Record<string, number> }[]
series: Series[]
height?: number
ariaLabel: string
}
const PAD = { top: 8, right: 8, bottom: 26, left: 32 }
const GAP = 2 // surface gap between stacked segments
const RADIUS = 4
/** Rounded top on the data end, square at the baseline. */
function columnPath(x: number, y: number, w: number, h: number, roundTop: boolean) {
const r = roundTop ? Math.min(RADIUS, h, w / 2) : 0
return `M${x},${y + h}V${y + r}${r ? `Q${x},${y} ${x + r},${y}` : ''}H${x + w - r}${r ? `Q${x + w},${y} ${x + w},${y + r}` : ''}V${y + h}Z`
}
export function StackedColumns({ data, series, height = 220, ariaLabel }: Props) {
const [ref, width] = useWidth<HTMLDivElement>()
const [hover, setHover] = useState<number | null>(null)
const plotW = width - PAD.left - PAD.right
const plotH = height - PAD.top - PAD.bottom
const totals = data.map((d) => series.reduce((sum, s) => sum + (d.values[s.key] ?? 0), 0))
const ticks = niceTicks(Math.max(...totals, 1))
const max = ticks.at(-1)!
const band = plotW / Math.max(1, data.length)
const barW = Math.min(24, band * 0.6)
const y = (v: number) => PAD.top + plotH - (v / max) * plotH
const labelEvery = Math.ceil(data.length / Math.max(1, Math.floor(plotW / 56)))
return (
<div ref={ref} className="relative" onPointerLeave={() => setHover(null)}>
<svg width={width} height={height} role="img" aria-label={ariaLabel} className="block">
{ticks.map((t) => (
<g key={t}>
<line x1={PAD.left} x2={width - PAD.right} y1={y(t)} y2={y(t)} stroke={t === 0 ? 'var(--color-chart-axis)' : 'var(--color-chart-grid)'} strokeWidth={1} shapeRendering="crispEdges" />
<text x={PAD.left - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize={10} fill="var(--color-chart-label)" className="tabular-nums">
{formatNumber(t)}
</text>
</g>
))}
{data.map((d, i) => {
const x = PAD.left + i * band + (band - barW) / 2
let base = 0
const visible = series.filter((s) => (d.values[s.key] ?? 0) > 0)
return (
<g key={d.label} opacity={hover === null || hover === i ? 1 : 0.55}>
{visible.map((s, j) => {
const v = d.values[s.key]
const top = y(base + v)
const bottom = y(base)
base += v
const isTop = j === visible.length - 1
const h = Math.max(0, bottom - top - (j > 0 ? GAP : 0))
return <path key={s.key} d={columnPath(x, top, barW, h, isTop)} fill={s.color} />
})}
{(i % labelEvery === 0 || i === data.length - 1) && (
<text x={x + barW / 2} y={height - 8} textAnchor="middle" fontSize={10} fill="var(--color-chart-label)">
{d.label}
</text>
)}
<rect
x={PAD.left + i * band}
y={PAD.top}
width={band}
height={plotH}
fill="transparent"
tabIndex={0}
aria-label={`${d.label}: ${series.map((s) => `${d.values[s.key] ?? 0} ${s.label}`).join(', ')}`}
onPointerEnter={() => setHover(i)}
onFocus={() => setHover(i)}
onBlur={() => setHover(null)}
className="outline-none"
/>
</g>
)
})}
</svg>
{hover !== null && (
<Tooltip
x={PAD.left + hover * band + band / 2}
y={y(totals[hover])}
containerWidth={width}
title={`${data[hover].label} · ${formatNumber(totals[hover])} total`}
rows={[...series].reverse().map((s) => ({ label: s.label, value: formatNumber(data[hover].values[s.key] ?? 0), color: s.color }))}
/>
)}
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react'
interface StatTileProps {
label: string
value: string
/** Comparison with the previous period. */
delta?: { text: string; direction: 'up' | 'down' | 'flat'; good: boolean | null }
hint?: string
}
/** A single number with its change vs the previous period (color = direction × whether up is good). */
export function StatTile({ label, value, delta, hint }: StatTileProps) {
const Icon = delta?.direction === 'up' ? ArrowUpRight : delta?.direction === 'down' ? ArrowDownRight : Minus
const tone = !delta || delta.good === null ? 'text-muted' : delta.good ? 'text-delta-good' : 'text-delta-bad'
return (
<div className="rounded-lg border border-line bg-white p-4">
<p className="text-2xs text-muted">{label}</p>
<p className="mt-1.5 font-display text-[26px] leading-none font-semibold tracking-[-0.5px] text-ink">{value}</p>
{delta && (
<p className={`mt-2 flex items-center gap-1 text-3xs font-semibold ${tone}`}>
<Icon size={13} /> {delta.text}
</p>
)}
{hint && <p className="mt-1 text-3xs leading-[1.4] text-faint">{hint}</p>}
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { niceTicks } from './scale'
describe('niceTicks', () => {
it('produces round steps that cover the maximum', () => {
expect(niceTicks(7)).toEqual([0, 2, 4, 6, 8])
expect(niceTicks(23)).toEqual([0, 10, 20, 30])
expect(niceTicks(1.3)).toEqual([0, 0.5, 1, 1.5])
expect(niceTicks(0)).toEqual([0, 1])
})
})
+12
View File
@@ -0,0 +1,12 @@
/** Round axis ticks (0, 5, 10… / 0, 0.5, 1…) covering 0..max, about `count` of them. */
export function niceTicks(max: number, count = 4): number[] {
if (!(max > 0)) return [0, 1]
const raw = max / count
const magnitude = 10 ** Math.floor(Math.log10(raw))
const step = [1, 2, 2.5, 5, 10].map((m) => m * magnitude).find((s) => s >= raw) ?? raw
const ticks: number[] = []
for (let v = 0; v <= max + step * 0.999; v += step) ticks.push(Math.round(v * 1000) / 1000)
return ticks
}
export const formatNumber = (value: number) => value.toLocaleString('en-US', { maximumFractionDigits: 2 })
+15
View File
@@ -0,0 +1,15 @@
import { useLayoutEffect, useRef, useState } from 'react'
/** Tracks an element's width so SVG charts draw at real pixels (crisp text, no stretching). */
export function useWidth<T extends HTMLElement>(fallback = 600) {
const ref = useRef<T>(null)
const [width, setWidth] = useState(fallback)
useLayoutEffect(() => {
const element = ref.current
if (!element) return
const observer = new ResizeObserver(([entry]) => setWidth(Math.max(240, Math.round(entry.contentRect.width))))
observer.observe(element)
return () => observer.disconnect()
}, [])
return [ref, width] as const
}
+442
View File
@@ -0,0 +1,442 @@
import { AlertOctagon, CheckCircle2, CircleDashed, Pencil, Printer, Trash2, UserPlus, XCircle } from 'lucide-react'
import { useState, type ChangeEvent, type FormEvent, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import type { IncidentDetail, OshaCase } from '../../../shared/api-types.ts'
import { employmentTypes, oshaCaseTypes, oshaSpecialCases } from '../../../shared/enums.ts'
import { assessRecordability, FIRST_AID, OSHA_REPORTING_CONTACT, PRIVACY_CASES, severeInjuryReport, type EmploymentType } from '../../../shared/osha.ts'
import type { OshaCaseInput } from '../../../shared/schemas.ts'
import { formatDateTime, toDateTimeLocal } from '../../lib/dates'
import { employmentTypeLabel, oshaCaseTypeLabel, oshaOutcomeLabel, oshaSpecialCaseLabel } from '../../lib/labels'
import { useOshaCaseMutations, useOshaCases } from '../../lib/queries'
import { Field } from '../FormSection'
import { ErrorMessage, Loading } from '../QueryState'
import { field, iconButton, primaryButton, secondaryButton, smallButton, textButton } from '../styles'
import { Section } from './Section'
/** Injured or ill people and their OSHA recordkeeping. Medical details are only shown to safety managers. */
export function OshaSection({ incident }: { incident: IncidentDetail }) {
const cases = useOshaCases(incident.number, incident.can.manageOsha)
const [editing, setEditing] = useState<OshaCase | 'new' | null>(null)
const { remove } = useOshaCaseMutations(incident.number)
if (!incident.can.manageOsha) {
if (incident.injuredCount === 0) return null
return (
<Section title="Injured or ill people">
<p className="text-xs text-muted">
{incident.injuredCount} {incident.injuredCount === 1 ? 'person' : 'people'} recorded. Injury and medical details are confidential and only visible to safety managers.
</p>
</Section>
)
}
return (
<Section
title="Injured or ill people"
action={
!editing && (
<button type="button" className={smallButton} onClick={() => setEditing('new')}>
<UserPlus size={13} /> Add person
</button>
)
}
>
{editing ? (
<CaseForm incident={incident} existing={editing === 'new' ? null : editing} onDone={() => setEditing(null)} />
) : cases.isPending ? (
<Loading />
) : cases.data?.length ? (
<ul className="space-y-3">
{cases.data.map((c) => (
<CaseSummary
key={c.id}
osha={c}
onEdit={() => setEditing(c)}
onRemove={() => confirm(`Remove ${c.name} (${c.caseNumber}) from this incident? This deletes their OSHA case.`) && remove.mutate(c.id)}
/>
))}
</ul>
) : (
<p className="text-xs text-faint">Nobody recorded. Add anyone who was hurt or made ill to decide whether it goes on the OSHA log.</p>
)}
<ErrorMessage error={cases.error ?? remove.error} className="mt-3" />
</Section>
)
}
function Verdict({ recordable, status, outcome }: { recordable: boolean; status?: 'incomplete'; outcome: OshaCase['outcome'] }) {
if (status === 'incomplete') {
return (
<span className="inline-flex items-center gap-1 rounded bg-line-soft px-2 py-1 text-3xs font-semibold text-muted">
<CircleDashed size={12} /> Not assessed
</span>
)
}
return recordable ? (
<span className="inline-flex items-center gap-1 rounded bg-coral-soft px-2 py-1 text-3xs font-semibold text-coral-strong">
<CheckCircle2 size={12} /> OSHA recordable · {oshaOutcomeLabel[outcome ?? 'other']}
</span>
) : (
<span className="inline-flex items-center gap-1 rounded bg-mint-soft px-2 py-1 text-3xs font-semibold text-[#2b6e4f]">
<XCircle size={12} /> Not recordable
</span>
)
}
function SevereAlert({ osha }: { osha: OshaCase }) {
if (!osha.severeReport || osha.reportedToOshaAt) return null
return (
<div className="mt-2 flex gap-2 rounded-md border border-coral bg-[#fff2ee] px-3 py-2 text-2xs text-coral-strong" role="alert">
<AlertOctagon size={15} className="mt-px flex-none" />
<span>
<strong>Report to OSHA within {osha.severeReport.withinHours} hours</strong> ({osha.severeReport.reasons.join(', ')}), by {formatDateTime(osha.severeReport.dueBy)}. {OSHA_REPORTING_CONTACT}
</span>
</div>
)
}
function CaseSummary({ osha, onEdit, onRemove }: { osha: OshaCase; onEdit: () => void; onRemove: () => void }) {
const assessed = assessRecordability(osha)
return (
<li className="rounded-md border border-line bg-field p-3">
<div className="flex flex-wrap items-start gap-2">
<div className="min-w-[200px] flex-1">
<p className="text-xs font-semibold text-ink">
{osha.name}
{osha.privacyCase && <span className="ml-2 font-mono text-3xs font-normal text-faint uppercase">Privacy case</span>}
</p>
<p className="mt-0.5 text-3xs text-muted">
Case {osha.caseNumber} · {[osha.jobTitle, osha.natureOfInjury, osha.bodyPart].filter(Boolean).join(' · ') || 'No details yet'}
</p>
</div>
<Verdict recordable={osha.recordable} status={assessed.status === 'incomplete' ? 'incomplete' : undefined} outcome={osha.outcome} />
<div className="flex items-center gap-1 print:hidden">
<button type="button" className={iconButton} aria-label={`Edit case ${osha.caseNumber}`} onClick={onEdit}>
<Pencil size={14} />
</button>
{osha.recordable && (
<Link to={`/osha/cases/${osha.id}/301`} className={iconButton} aria-label={`Form 301 for case ${osha.caseNumber}`} title="Form 301">
<Printer size={14} />
</Link>
)}
<button type="button" className={iconButton} aria-label={`Remove case ${osha.caseNumber}`} onClick={onRemove}>
<Trash2 size={14} />
</button>
</div>
</div>
{osha.reportedToOshaAt && <p className="mt-2 text-3xs text-[#2b6e4f]">Reported to OSHA {formatDateTime(osha.reportedToOshaAt)}</p>}
<SevereAlert osha={osha} />
{osha.missingFields.length > 0 && <p className="mt-2 text-3xs text-amber-strong">Still needed for Form 301 / OSHA submission: {osha.missingFields.join(', ')}.</p>}
</li>
)
}
type FormState = Required<Omit<OshaCaseInput, 'reportedToOshaAt' | 'sex'>> & { reportedToOshaAt: string; sex: '' | 'M' | 'F' }
function initialState(existing: OshaCase | null, incident: IncidentDetail): FormState {
const c = existing
return {
name: c?.name ?? '',
jobTitle: c?.jobTitle ?? '',
employmentType: c?.employmentType ?? 'employee',
bodyPart: c?.bodyPart ?? '',
natureOfInjury: c?.natureOfInjury ?? '',
caseType: c?.caseType ?? (incident.type === 'illness' ? 'other_illness' : 'injury'),
workRelated: c?.workRelated ?? null,
newCase: c?.newCase ?? null,
died: c?.died ?? false,
dateOfDeath: c?.dateOfDeath ?? '',
daysAwayFromWork: c?.daysAwayFromWork ?? false,
daysAway: c?.daysAway ?? 0,
restrictedOrTransfer: c?.restrictedOrTransfer ?? false,
daysRestricted: c?.daysRestricted ?? 0,
medicalTreatment: c?.medicalTreatment ?? false,
lossOfConsciousness: c?.lossOfConsciousness ?? false,
significantDiagnosis: c?.significantDiagnosis ?? false,
specialCase: c?.specialCase ?? 'none',
privacyCase: c?.privacyCase ?? false,
inpatientHospitalization: c?.inpatientHospitalization ?? false,
amputation: c?.amputation ?? false,
lossOfEye: c?.lossOfEye ?? false,
reportedToOshaAt: c?.reportedToOshaAt ? toDateTimeLocal(new Date(c.reportedToOshaAt)) : '',
employeeAddress: c?.employeeAddress ?? '',
dateOfBirth: c?.dateOfBirth ?? '',
dateOfHire: c?.dateOfHire ?? '',
sex: c?.sex ?? '',
physicianName: c?.physicianName ?? '',
treatmentFacility: c?.treatmentFacility ?? '',
treatedInEmergencyRoom: c?.treatedInEmergencyRoom ?? false,
timeStartedWork: c?.timeStartedWork ?? '',
activityBefore: c?.activityBefore ?? '',
whatHappened: c?.whatHappened ?? incident.description ?? '',
objectSubstance: c?.objectSubstance ?? '',
}
}
const empty = (value: string) => (value.trim() === '' ? null : value.trim())
function Group({ title, help, children }: { title: string; help?: ReactNode; children: ReactNode }) {
return (
<fieldset className="border-t border-line pt-4 first:border-0 first:pt-0">
<legend className="mb-1 text-xs font-semibold text-ink">{title}</legend>
{help && <div className="mb-3 text-2xs leading-[1.5] text-muted">{help}</div>}
{children}
</fieldset>
)
}
function YesNo({ label, value, onChange }: { label: string; value: boolean | null; onChange: (value: boolean) => void }) {
return (
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-2xs text-body">
<span className="min-w-[220px] flex-1 font-semibold text-[#586762]">{label}</span>
{[true, false].map((option) => (
<label key={String(option)} className="inline-flex items-center gap-1.5">
<input type="radio" className="accent-coral" checked={value === option} onChange={() => onChange(option)} />
{option ? 'Yes' : 'No'}
</label>
))}
</div>
)
}
function Check({ label, checked, onChange, children }: { label: ReactNode; checked: boolean; onChange: (checked: boolean) => void; children?: ReactNode }) {
return (
<div className="mb-2.5">
<label className="flex items-start gap-2 text-2xs text-body">
<input type="checkbox" className="mt-0.5 accent-coral" checked={checked} onChange={(event) => onChange(event.target.checked)} />
<span>{label}</span>
</label>
{checked && children && <div className="mt-2 ml-6">{children}</div>}
</div>
)
}
function CaseForm({ incident, existing, onDone }: { incident: IncidentDetail; existing: OshaCase | null; onDone: () => void }) {
const { create, update } = useOshaCaseMutations(incident.number)
const [form, setForm] = useState<FormState>(() => initialState(existing, incident))
const set = <K extends keyof FormState>(key: K, value: FormState[K]) => setForm((current) => ({ ...current, [key]: value }))
const text = (key: keyof FormState) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => set(key, event.target.value as never)
const verdict = assessRecordability({ ...form, employmentType: form.employmentType as EmploymentType })
const severe = severeInjuryReport(form)
const totalDays = (form.daysAwayFromWork ? Number(form.daysAway) : 0) + (form.restrictedOrTransfer ? Number(form.daysRestricted) : 0)
const mutation = existing ? update : create
const submit = (event: FormEvent) => {
event.preventDefault()
const payload: OshaCaseInput & { name: string } = {
...form,
name: form.name.trim(),
daysAway: Number(form.daysAway) || 0,
daysRestricted: Number(form.daysRestricted) || 0,
sex: form.sex || null,
reportedToOshaAt: form.reportedToOshaAt ? new Date(form.reportedToOshaAt).toISOString() : null,
...Object.fromEntries(
(['jobTitle', 'bodyPart', 'natureOfInjury', 'employeeAddress', 'physicianName', 'treatmentFacility', 'activityBefore', 'whatHappened', 'objectSubstance', 'dateOfDeath', 'dateOfBirth', 'dateOfHire', 'timeStartedWork'] as const).map((key) => [
key,
empty(String(form[key] ?? '')),
]),
),
}
if (existing) update.mutate({ id: existing.id, ...payload }, { onSuccess: onDone })
else create.mutate(payload, { onSuccess: onDone })
}
return (
<form onSubmit={submit} className="space-y-4">
<Group title="Who">
<div className="grid gap-x-4 sm:grid-cols-2">
<Field label="Name">
<input className={field} required value={form.name} onChange={text('name')} />
</Field>
<Field label="Job title" optional>
<input className={field} value={form.jobTitle ?? ''} onChange={text('jobTitle')} placeholder="e.g. Press operator" />
</Field>
<Field label="Worker type">
<select className={field} value={form.employmentType} onChange={text('employmentType')}>
{employmentTypes.map((type) => (
<option key={type} value={type}>
{employmentTypeLabel[type]}
</option>
))}
</select>
</Field>
<Field label="Injury or illness type (300 Log column M)">
<select className={field} value={form.caseType} onChange={text('caseType')}>
{oshaCaseTypes.map((type) => (
<option key={type} value={type}>
{oshaCaseTypeLabel[type]}
</option>
))}
</select>
</Field>
</div>
</Group>
<Group title="Does it go on the OSHA log?" help="Answer from what you know now; you can update the case as things change (for example if days away turn into restricted work).">
<YesNo label="Is it work-related? (an event or exposure at work caused or worsened it; 1904.5)" value={form.workRelated} onChange={(v) => set('workRelated', v)} />
<YesNo label="Is it a new case? (not a continuation of an injury already on the log; 1904.6)" value={form.newCase} onChange={(v) => set('newCase', v)} />
<p className="mt-4 mb-2 text-2xs font-semibold text-[#586762]">Did it result in any of these? (tick all that apply)</p>
<Check label="Death" checked={form.died} onChange={(v) => set('died', v)}>
<Field label="Date of death">
<input className={field} type="date" value={form.dateOfDeath ?? ''} onChange={text('dateOfDeath')} />
</Field>
</Check>
<Check label="Days away from work (count calendar days after the day of injury, including weekends)" checked={form.daysAwayFromWork} onChange={(v) => set('daysAwayFromWork', v)}>
<Field label="Days away so far">
<input className={`${field} max-w-[140px]`} type="number" min={0} max={180} value={form.daysAway} onChange={text('daysAway')} />
</Field>
</Check>
<Check label="Restricted work or transfer to another job" checked={form.restrictedOrTransfer} onChange={(v) => set('restrictedOrTransfer', v)}>
<Field label="Days restricted or transferred so far">
<input className={`${field} max-w-[140px]`} type="number" min={0} max={180} value={form.daysRestricted} onChange={text('daysRestricted')} />
</Field>
</Check>
{totalDays > 180 && <p className="mb-2 ml-6 text-3xs text-coral-strong">Stop counting once days away and restricted days reach 180 in total.</p>}
<Check
label={
<>
Medical treatment beyond first aid{' '}
<details className="mt-1 inline-block w-full text-3xs text-muted">
<summary className="cursor-pointer text-coral">What counts as first aid? (the complete list)</summary>
<ul className="mt-1 list-disc space-y-0.5 pl-4">
{FIRST_AID.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<p className="mt-1">Anything not on this list, such as prescription medication, stitches, rigid splints or physical therapy, is medical treatment.</p>
</details>
</>
}
checked={form.medicalTreatment}
onChange={(v) => set('medicalTreatment', v)}
/>
<Check label="Loss of consciousness" checked={form.lossOfConsciousness} onChange={(v) => set('lossOfConsciousness', v)} />
<Check
label="Significant injury or illness diagnosed by a doctor or licensed health care professional (e.g. fractured bone, punctured eardrum, cancer, chronic irreversible disease)"
checked={form.significantDiagnosis}
onChange={(v) => set('significantDiagnosis', v)}
/>
<Field label="Special case (recordable even without the above)">
<select className={field} value={form.specialCase} onChange={text('specialCase')}>
{oshaSpecialCases.map((value) => (
<option key={value} value={value}>
{oshaSpecialCaseLabel[value]}
</option>
))}
</select>
</Field>
<div
className={`rounded-md border px-4 py-3 text-xs ${verdict.status === 'recordable' ? 'border-coral-soft bg-[#fff6f3]' : verdict.status === 'incomplete' ? 'border-line bg-canvas' : 'border-mint-soft bg-[#f3faf6]'}`}
aria-live="polite"
>
<strong className="text-ink">
{verdict.status === 'recordable' ? `Recordable: ${oshaOutcomeLabel[verdict.outcome!]}` : verdict.status === 'incomplete' ? 'Answer the questions above' : 'Not recordable'}
</strong>
<ul className="mt-1 list-disc space-y-0.5 pl-4 text-2xs text-body">
{verdict.reasons.map((reason) => (
<li key={reason}>{reason}</li>
))}
</ul>
</div>
</Group>
<Group title="Serious injuries: report to OSHA directly (1904.39)" help="Fatalities must be reported within 8 hours; in-patient hospitalization, amputation or loss of an eye within 24 hours of learning about it.">
<Check label="Hospitalized as an in-patient (formally admitted)" checked={form.inpatientHospitalization} onChange={(v) => set('inpatientHospitalization', v)} />
<Check label="Amputation" checked={form.amputation} onChange={(v) => set('amputation', v)} />
<Check label="Loss of an eye" checked={form.lossOfEye} onChange={(v) => set('lossOfEye', v)} />
{severe && (
<div className="rounded-md border border-coral bg-[#fff2ee] px-3 py-2 text-2xs text-coral-strong">
<strong>Must be reported to OSHA within {severe.withinHours} hours.</strong> {OSHA_REPORTING_CONTACT}
<Field label="Reported to OSHA at" optional>
<input className={`${field} max-w-[240px]`} type="datetime-local" value={form.reportedToOshaAt} onChange={text('reportedToOshaAt')} />
</Field>
</div>
)}
</Group>
<Group title="Injury details (Forms 300 and 301)">
<div className="grid gap-x-4 sm:grid-cols-2">
<Field label="Injury or illness" optional>
<input className={field} value={form.natureOfInjury ?? ''} onChange={text('natureOfInjury')} placeholder="e.g. Laceration, sprain, burn" />
</Field>
<Field label="Part of body" optional>
<input className={field} value={form.bodyPart ?? ''} onChange={text('bodyPart')} placeholder="e.g. Left index finger" />
</Field>
</div>
<Field label="Object or substance that directly harmed them" optional>
<input className={field} value={form.objectSubstance ?? ''} onChange={text('objectSubstance')} placeholder="e.g. Press die, chlorine, concrete floor" />
</Field>
<Field label="What were they doing just before?" optional>
<textarea className={`${field} resize-y`} rows={2} value={form.activityBefore ?? ''} onChange={text('activityBefore')} placeholder="e.g. Clearing a jam on press 4 with the guard open" />
</Field>
<Field label="What happened? How did the injury occur?" optional>
<textarea className={`${field} resize-y`} rows={3} value={form.whatHappened ?? ''} onChange={text('whatHappened')} />
</Field>
<div className="grid gap-x-4 sm:grid-cols-2">
<Field label="Physician or health care professional" optional>
<input className={field} value={form.physicianName ?? ''} onChange={text('physicianName')} />
</Field>
<Field label="Treatment facility (name and address)" optional>
<input className={field} value={form.treatmentFacility ?? ''} onChange={text('treatmentFacility')} />
</Field>
</div>
<Check label="Treated in an emergency room" checked={form.treatedInEmergencyRoom} onChange={(v) => set('treatedInEmergencyRoom', v)} />
</Group>
<Group title="Employee details (Form 301)" help="Kept confidential. Never sent to OSHA with names; the online case submission uses dates only.">
<div className="grid gap-x-4 sm:grid-cols-3">
<Field label="Date of birth" optional>
<input className={field} type="date" value={form.dateOfBirth ?? ''} onChange={text('dateOfBirth')} />
</Field>
<Field label="Date hired" optional>
<input className={field} type="date" value={form.dateOfHire ?? ''} onChange={text('dateOfHire')} />
</Field>
<Field label="Sex" optional>
<select className={field} value={form.sex} onChange={text('sex')}>
<option value="">Not recorded</option>
<option value="M">Male</option>
<option value="F">Female</option>
</select>
</Field>
</div>
<div className="grid gap-x-4 sm:grid-cols-[1fr_180px]">
<Field label="Home address" optional>
<input className={field} value={form.employeeAddress ?? ''} onChange={text('employeeAddress')} />
</Field>
<Field label="Time they began work" optional>
<input className={field} type="time" value={form.timeStartedWork ?? ''} onChange={text('timeStartedWork')} />
</Field>
</div>
</Group>
<Group title="Privacy (1904.29)">
<Check label="Privacy case: show “Privacy case” instead of the name on the 300 Log" checked={form.privacyCase} onChange={(v) => set('privacyCase', v)} />
<details className="text-3xs text-muted">
<summary className="cursor-pointer text-coral">When is a case a privacy case?</summary>
<ul className="mt-1 list-disc space-y-0.5 pl-4">
{PRIVACY_CASES.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</details>
</Group>
<ErrorMessage error={mutation.error} />
<div className="flex justify-end gap-2">
<button type="button" className={secondaryButton} onClick={onDone}>
Cancel
</button>
<button type="submit" className={primaryButton} disabled={mutation.isPending}>
{existing ? 'Save case' : 'Add person'}
</button>
</div>
{existing?.recordable && (
<Link to={`/osha/cases/${existing.id}/301`} className={textButton}>
<Printer size={13} /> Form 301
</Link>
)}
</form>
)
}