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.
This commit is contained in:
1 parent
3e964d1176
commit
61c876edec
59 files changed
+9180
-183
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user