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

+8 -2
View File
@@ -2,7 +2,7 @@
Free, open-source accident and safety tracking for manufacturing: incident and near-miss reporting, investigations, corrective actions and (planned) OSHA record keeping. Free, open-source accident and safety tracking for manufacturing: incident and near-miss reporting, investigations, corrective actions and (planned) OSHA record keeping.
> **Status: early development (Phases 0–3 done).** Reporting (including offline, kiosk tablets, QR codes and Spanish), investigation, corrective actions, email notifications, dashboards and CSV export work end to end. OSHA record keeping is next. See [docs/ROADMAP.md](docs/ROADMAP.md) for the plan and [docs/TODO.md](docs/TODO.md) for open items. > **Status: early development (Phases 0–5 done).** Reporting (including offline, kiosk tablets, QR codes and Spanish), investigation, corrective actions, OSHA recordkeeping (Forms 300, 300A, 301, rates and electronic submission files), leading/lagging-indicator analytics with saved views, and email notifications work end to end. See [docs/ROADMAP.md](docs/ROADMAP.md) for the plan and [docs/TODO.md](docs/TODO.md) for open items.
## Getting started ## Getting started
@@ -14,7 +14,7 @@ npm run db:seed # optional: demo organization, sites, users and incidents
npm run dev # web app on http://localhost:5173, API on http://localhost:3000 npm run dev # web app on http://localhost:5173, API on http://localhost:3000
``` ```
Demo users (after seeding): `admin@example.com`, `manager@example.com`, `supervisor@example.com` and `reporter@example.com`, all with password `safetylog-demo`. Without seeding, opening the app takes you to first-run setup. `npm run db:seed` also loads a year of realistic history so the dashboards and OSHA log have data. Demo users: `admin@example.com`, `manager@example.com`, `supervisor@example.com` and `reporter@example.com`, all with password `safetylog-demo`. Without seeding, opening the app takes you to first-run setup.
In development, emails (sign-in links, invitations, notifications) are printed in the terminal running the server. In development, emails (sign-in links, invitations, notifications) are printed in the terminal running the server.
@@ -35,6 +35,12 @@ This runs the app and PostgreSQL. The app applies database migrations on startup
- Back up both volumes: `db-data` (PostgreSQL) and `app-data` (uploads). - Back up both volumes: `db-data` (PostgreSQL) and `app-data` (uploads).
- Installing the app on phones and opening it offline needs HTTPS (browsers require it for service workers). Over plain HTTP everything else works, and reports made while offline are still queued once the page is open. - Installing the app on phones and opening it offline needs HTTPS (browsers require it for service workers). Over plain HTTP everything else works, and reports made while offline are still queued once the page is open.
## OSHA recordkeeping
On an incident, safety managers add each injured or ill person and answer a short set of questions; the app decides whether the case is recordable under 29 CFR 1904 and how it's classified, flags injuries that must be reported to OSHA within 8 or 24 hours, and builds the Form 300 Log, 300A Summary and 301 reports per site. Enter monthly hours and headcount for incidence rates (TRIR, DART), add establishment details on the Locations page, and download the CSV files for OSHA's Injury Tracking Application from **OSHA log → Submit to OSHA**.
The app helps apply OSHA's rules consistently, but it is not legal advice: the employer remains responsible for its records.
## Shop-floor reporting ## Shop-floor reporting
- **Kiosk tablets and QR codes:** in **Settings → Reporting links**, create a link. Open it on a shared tablet for a kiosk, or print QR codes for it from **Locations → QR codes**. Anyone can then report without an account; the link can't read anything and can be revoked at any time. - **Kiosk tablets and QR codes:** in **Settings → Reporting links**, create a link. Open it on a shared tablet for a kiosk, or print QR codes for it from **Locations → QR codes**. Anyone can then report without an account; the link can't read anything and can be revoked at any time.
+26 -1
View File
@@ -138,12 +138,37 @@ The token goes in the `X-Kiosk-Token` header. Browsers can't send custom headers
The web app opens links at `/k/<token>`, optionally with `?site=<id>&area=<id>` to pre-fill the location (as QR codes do). The web app opens links at `/k/<token>`, optionally with `?site=<id>&area=<id>` to pre-fill the location (as QR codes do).
## OSHA recordkeeping
All endpoints need **safety_manager**: they hold confidential medical information. Other roles only see `injuredCount` on an incident. Rules are in [`shared/osha.ts`](../shared/osha.ts); section numbers refer to 29 CFR 1904.
| Method | Path | Notes |
| --- | --- | --- |
| GET | `/api/osha/incidents/:number/cases` | Injured or ill people on an incident |
| POST | `/api/osha/incidents/:number/cases` | `{ name, ...answers }`. Case number is the incident reference, with `-2`, `-3`… for further people |
| PATCH | `/api/osha/cases/:id` | Any case field. `recordable` and `outcome` are always recalculated from the answers; days away + restricted may not exceed 180 |
| DELETE | `/api/osha/cases/:id` | |
| GET | `/api/osha/cases/:id/301` | Data for Form 301 |
| GET | `/api/osha/log?siteId=&year=` | 300 Log rows, 300A totals, monthly hours, annual average employees, TRIR/DART/LTIR, ITA checks, missing establishment fields |
| GET/PUT | `/api/osha/sites/:id/hours?year=` | Monthly `{ month, hours, averageEmployees }`; `null` for both clears a month |
| GET | `/api/osha/ita/summary.csv?year=` | ITA Establishment and 300A file, one row per active site |
| GET | `/api/osha/ita/cases.csv?year=` | ITA case data (Forms 300/301) file, without names |
**Case answers:** `employmentType`, `workRelated`, `newCase`, `died`, `dateOfDeath`, `daysAwayFromWork`, `daysAway`, `restrictedOrTransfer`, `daysRestricted`, `medicalTreatment`, `lossOfConsciousness`, `significantDiagnosis`, `specialCase` (`none`, `needlestick`, `medical_removal`, `hearing_loss`, `tuberculosis`), `caseType` (300 column M), `privacyCase`, severe-injury flags (`inpatientHospitalization`, `amputation`, `lossOfEye`, `reportedToOshaAt`) and the Form 301 fields (`jobTitle`, `natureOfInjury`, `bodyPart`, `objectSubstance`, `activityBefore`, `whatHappened`, `dateOfBirth`, `dateOfHire`, `sex`, `employeeAddress`, `physicianName`, `treatmentFacility`, `treatedInEmergencyRoom`, `timeStartedWork`).
Responses include `severeReport` (`{ withinHours, reasons, dueBy }`) when 1904.39 requires a direct report, and `missingFields` listing what Form 301 and the ITA still need. A case newly needing a direct report emails safety managers and admins.
Site establishment fields (`streetAddress`, `city`, `state`, `zip`, `naicsCode`, `industryDescription`, `ein`, `establishmentType`) are set with `PATCH /api/sites/:id`.
## Stats ## Stats
| Method | Path | Who | Notes | | Method | Path | Who | Notes |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| GET | `/api/stats/overview` | signed in | Open incidents, actions due/overdue, average close time, status counts, weekly trend (12 weeks), incidents by site this year | | GET | `/api/stats/overview` | signed in | Open incidents, actions due/overdue, average close time, status counts, weekly trend (12 weeks), incidents by site this year |
| GET | `/api/stats/analytics` | signed in | `?days=` (7–730, default 90): counts by type and severity, top root-cause category, on-time action rate, monthly completions | | GET | `/api/stats/analytics` | signed in | `?from=YYYY-MM-DD&to=YYYY-MM-DD` (up to 3 years), optional `siteId`, `type`. Leading/lagging KPIs with the previous period, trend (weekly up to ~3 months, else monthly), locations, root causes, weekday × hour heatmap, actions on time vs late, and for supervisors+ rolling 12-month TRIR/DART and yearly rates |
| GET | `/api/views?page=analytics\|incidents` | signed in | Your saved views plus shared ones |
| POST | `/api/views` | signed in | `{ page, name, filters, shared }`. Sharing needs supervisor |
| DELETE | `/api/views/:id` | owner or admin | |
Stats only count what the signed-in user can see. Stats only count what the signed-in user can see.
+18 -6
View File
@@ -20,6 +20,9 @@ This is a living document. Update it as decisions are made or scope changes.
| Reporting without accounts | "Reporting links": revocable, rate-limited tokens that can only file reports; used for kiosk tablets and QR codes | Decided | | Reporting without accounts | "Reporting links": revocable, rate-limited tokens that can only file reports; used for kiosk tablets and QR codes | Decided |
| Languages | English + Spanish with typed message files; worker-facing screens first, manager screens later (see TODO) | Decided | | Languages | English + Spanish with typed message files; worker-facing screens first, manager screens later (see TODO) | Decided |
| Offline | Installable PWA; reports queued in IndexedDB and retried with a client-generated id so retries never duplicate | Decided | | Offline | Installable PWA; reports queued in IndexedDB and retried with a client-generated id so retries never duplicate | Decided |
| OSHA rules | One shared rules module (`shared/osha.ts`) used by API and UI; printable forms are OSHA-permitted equivalents (1904.29(b)(4)); ITA exports follow OSHA's published CSV specs | Decided |
| Medical privacy | Injured-person and medical details visible to safety managers and admins only; timeline and case CSV carry no names | Decided |
| Charts | Hand-built SVG components (no chart library) following the dataviz method: validated color slots, one axis, hairline grid, hover tooltips, a table view on every chart | Decided |
| CI | Gitea Actions (`.gitea/workflows`): lint, typecheck, unit tests, build, e2e | Decided | | CI | Gitea Actions (`.gitea/workflows`): lint, typecheck, unit tests, build, e2e | Decided |
| Product name | Open SafetyLog EHS (replaced placeholder "IA Tracker" and the prototype's "Safesite" branding) | Decided | | Product name | Open SafetyLog EHS (replaced placeholder "IA Tracker" and the prototype's "Safesite" branding) | Decided |
@@ -74,15 +77,24 @@ This is a living document. Update it as decisions are made or scope changes.
## Phase 4: Compliance (US OSHA) ## Phase 4: Compliance (US OSHA)
- Recordability wizard; days away / restricted / transferred tracking - [x] Injured/ill people per incident with a live recordability check (1904.4–1904.11): covered worker, work-related, new case, general criteria, special cases; most-serious-outcome classification; 14-item first-aid list and privacy-case rules shown in context
- OSHA 300, 300A and 301 form generation; privacy-case handling - [x] Day counts with the 180-day cap; days only counted when the matching outcome applies
- ITA (Injury Tracking Application) CSV export - [x] Severe injury reporting (1904.39): 8-hour/24-hour deadline banner, email alert to safety managers, "reported to OSHA at" record
- Hours worked per site per month; TRIR, DART and lost-time rates - [x] Form 300 Log (landscape print), Form 300A Summary with establishment details and certification block, printable Form 301 per case
- [x] Establishment details per site (address, NAICS, EIN, type) with validation
- [x] Monthly hours worked and headcount; TRIR, DART and LTIR
- [x] ITA CSV exports for the 300A summary and 300/301 case data (no names), plus OSHA's own upload checks run in the app
- [x] Tests: rules module unit tests, API tests incl. exact CSV output, browser test of the full recordkeeping flow
## Phase 5: Analytics ## Phase 5: Analytics
- Trends, location breakdown, top root causes, corrective-action closure time - [x] One filter row (date range presets or custom, site, incident type) scoping every number and chart on the page
- Leading vs lagging indicators; filters and saved dashboards - [x] Leading indicators: hazards & near misses reported, near misses per injury, triaged within 24 hours, actions completed on time; each compared with the previous period
- [x] Lagging indicators: injuries, OSHA-recordable cases, rolling 12-month TRIR/DART, average days to close; OSHA rates by calendar year
- [x] Charts: reports per week/month by kind, rolling OSHA rates, locations (sites, or areas within a site), root causes, weekday × hour heatmap, actions completed on time vs late
- [x] Saved views (private, or shared by supervisors and above) on Analytics and the Incidents list
- [x] Demo seed with a year of realistic history (deterministic)
- [x] Tests: analytics computations (periods, groupings, rates, scoping), saved views, chart scale helper, browser tests
## Phase 6: Release v1.0 ## Phase 6: Release v1.0
+19
View File
@@ -31,6 +31,25 @@ Open items that aren't tied to a single roadmap phase. Tick them off (or delete
- [ ] **Have Spanish reviewed by a native speaker** familiar with plant safety terms (e.g. "casi accidente" for near miss, "planta" for site). - [ ] **Have Spanish reviewed by a native speaker** familiar with plant safety terms (e.g. "casi accidente" for near miss, "planta" for site).
- [ ] **Offline incident list.** Only reporting works offline; viewing incidents and actions needs a connection. - [ ] **Offline incident list.** Only reporting works offline; viewing incidents and actions needs a connection.
## Phase 4 follow-ups
- [ ] **Expert review of the OSHA rules.** `shared/osha.ts` encodes 29 CFR 1904 as plainly as possible with section references, but it should be reviewed by a recordkeeping professional before anyone relies on it. The app should say clearly that it assists, and the employer remains responsible.
- [ ] **Deadline reminders.** Email safety managers in January to certify and post the 300A (Feb 1–Apr 30) and before the ITA submission deadline (March 2).
- [ ] **Direct ITA submission.** OSHA offers an API for the Injury Tracking Application; submitting directly would remove the CSV download/upload step.
- [ ] **Day counting from dates.** Calculate days away/restricted from start and return-to-work dates instead of typed counts.
- [ ] **Employee access (1904.35).** Employees and their representatives may request the 300 Log (and their own 301). Add a way to produce those copies with privacy cases removed.
- [ ] **OSHA rates on the dashboard.** Show TRIR/DART on Overview and Analytics, and trends over years.
- [ ] **Translate the OSHA pages** along with the other manager screens.
- [ ] **Other regions.** The data model keeps outcomes and case types separate from US-specific forms; UK RIDDOR or Canadian provincial rules could be added as further rule sets.
## Phase 5 follow-ups
- [ ] **Industry benchmarks.** Show the BLS average TRIR/DART for the site's NAICS code next to its own rates.
- [ ] **Scheduled reports.** A weekly or monthly email digest of the indicators for safety managers.
- [ ] **Chart export.** Download a chart as an image, or print the whole Analytics page as a report (print styles are in place for most of it).
- [ ] **Dark mode.** The app is light-only; if a dark theme is added, the chart colors need their dark-mode steps validated against the dark surface.
- [ ] **Translate Analytics** with the other manager screens.
## Done ## Done
- [x] Docker verified 2026-10-06 (Docker 29.8, Compose v5.5): image builds (~315 MB), `docker compose up` with PostgreSQL 18 passes setup → sign-in → report smoke test; data and sessions survive `down`/`up`; clean shutdown; healthcheck healthy; runs as non-root; standalone PGlite container works; `Secure` cookie with an `https://` PUBLIC_URL; web app loads with no errors. - [x] Docker verified 2026-10-06 (Docker 29.8, Compose v5.5): image builds (~315 MB), `docker compose up` with PostgreSQL 18 passes setup → sign-in → report smoke test; data and sessions survive `down`/`up`; clean shutdown; healthcheck healthy; runs as non-root; standalone PGlite container works; `Secure` cookie with an `https://` PUBLIC_URL; web app loads with no errors.
+38
View File
@@ -0,0 +1,38 @@
import { expect, test } from '@playwright/test'
import { signIn, unique } from './fixtures'
test('shows leading and lagging indicators with charts that have table views', async ({ page }) => {
await signIn(page, 'safety_manager')
await page.goto('/analytics?range=365')
await expect(page.getByRole('heading', { name: 'Leading indicators' })).toBeVisible()
await expect(page.getByText('Hazards & near misses reported')).toBeVisible()
await expect(page.getByRole('heading', { name: 'Lagging indicators' })).toBeVisible()
// Every chart has a table view
const trend = page.locator('figure').filter({ hasText: 'Reports per' })
await trend.getByRole('button', { name: 'Table' }).click()
await expect(trend.getByRole('columnheader', { name: 'Hazards & near misses' })).toBeVisible()
})
test('filters and saved views', async ({ page, isMobile }) => {
test.skip(isMobile, 'Covered on desktop')
await signIn(page, 'supervisor')
await page.goto('/analytics')
await page.getByLabel('Date range').selectOption('30')
await page.getByLabel('Incident type').selectOption('hazard')
await expect(page).toHaveURL(/range=30/)
await expect(page).toHaveURL(/type=hazard/)
const name = unique('Hazards this month')
await page.getByRole('button', { name: 'Save view' }).click()
await page.getByLabel('View name').fill(name)
await page.getByRole('button', { name: 'Save', exact: true }).click()
await expect(page.getByLabel('Saved views')).toHaveValue(/.+/)
// Clearing the filters and picking the view brings them back
await page.goto('/analytics')
await expect(page.getByLabel('Incident type')).toHaveValue('')
await page.getByLabel('Saved views').selectOption({ label: name })
await expect(page.getByLabel('Incident type')).toHaveValue('hazard')
await expect(page.getByLabel('Date range')).toHaveValue('30')
})
+1
View File
@@ -5,6 +5,7 @@ export const users = {
admin: { name: 'Avery Admin', email: 'admin@e2e.test' }, admin: { name: 'Avery Admin', email: 'admin@e2e.test' },
supervisor: { name: 'Sam Supervisor', email: 'supervisor@e2e.test' }, supervisor: { name: 'Sam Supervisor', email: 'supervisor@e2e.test' },
reporter: { name: 'Riley Reporter', email: 'reporter@e2e.test' }, reporter: { name: 'Riley Reporter', email: 'reporter@e2e.test' },
safety_manager: { name: 'Morgan Manager', email: 'manager@e2e.test' },
} as const } as const
export async function signIn(page: Page, who: keyof typeof users) { export async function signIn(page: Page, who: keyof typeof users) {
+1 -1
View File
@@ -18,7 +18,7 @@ export default async function globalSetup() {
process.env.E2E_KIOSK_TOKEN = kiosk.token process.env.E2E_KIOSK_TOKEN = kiosk.token
process.env.E2E_SITE_ID = site.id process.env.E2E_SITE_ID = site.id
process.env.E2E_AREA_ID = area.id process.env.E2E_AREA_ID = area.id
for (const role of ['supervisor', 'reporter'] as const) { for (const role of ['supervisor', 'reporter', 'safety_manager'] as const) {
await ok(await api.post('/api/users', { data: { ...users[role], role, password: PASSWORD } })) await ok(await api.post('/api/users', { data: { ...users[role], role, password: PASSWORD } }))
} }
await api.dispose() await api.dispose()
+64
View File
@@ -0,0 +1,64 @@
import { expect, test } from '@playwright/test'
import { signIn, unique } from './fixtures'
test('records an injury on the OSHA log and exports it', async ({ page, browser, isMobile }) => {
test.skip(isMobile, 'Recordkeeping is a desktop task')
test.setTimeout(60_000)
const title = unique('Burn from hot press platen')
// A supervisor reports it; they can't see medical details
const supervisor = await (await browser.newContext()).newPage()
await signIn(supervisor, 'supervisor')
await supervisor.goto('/report')
await supervisor.getByLabel('Incident title').fill(title)
await supervisor.getByLabel('Incident type').selectOption('injury')
await supervisor.getByRole('button', { name: 'Submit report' }).click()
await expect(supervisor.getByRole('heading', { level: 1 })).toHaveText(title)
const url = supervisor.url()
// The safety manager decides recordability
await signIn(page, 'safety_manager')
await page.goto(url)
await page.getByRole('button', { name: 'Add person' }).click()
await page.getByLabel('Name', { exact: true }).fill('Casey Worker')
await page.getByPlaceholder('e.g. Press operator').fill('Press operator')
const verdict = page.locator('[aria-live="polite"]')
await expect(verdict).toContainText('Answer the questions above')
await page.getByText('Is it work-related?').locator('..').getByLabel('Yes').check()
await page.getByText('Is it a new case?').locator('..').getByLabel('Yes').check()
await expect(verdict).toContainText('Not recordable') // first aid only
await page.getByText('Days away from work (count').click()
await page.getByLabel('Days away so far').fill('3')
await expect(verdict).toContainText('Recordable: Days away from work')
await page.getByText('Hospitalized as an in-patient').click()
await expect(page.getByText('Must be reported to OSHA within 24 hours.')).toBeVisible()
await page.getByPlaceholder('e.g. Laceration, sprain, burn').fill('Second-degree burn')
await page.getByRole('button', { name: 'Add person' }).click()
await expect(page.getByText('OSHA recordable · Days away from work')).toBeVisible()
await expect(page.getByRole('alert').filter({ hasText: 'Report to OSHA within 24 hours' })).toBeVisible()
// Supervisors see that someone was hurt, nothing more
await supervisor.reload()
await expect(supervisor.getByText('1 person recorded. Injury and medical details are confidential')).toBeVisible()
await expect(supervisor.getByText('Casey Worker')).toHaveCount(0)
// It's on the 300 Log, and the 301 is one click away
await page.getByRole('link', { name: 'OSHA log' }).click()
const row = page.getByRole('row').filter({ hasText: 'Casey Worker' })
await expect(row).toContainText('Second-degree burn')
await expect(row).toContainText('Missing:')
await page.getByRole('tab', { name: 'Submit to OSHA' }).click()
await expect(page.getByText('Enter hours worked for each month.')).toBeVisible()
const download = page.waitForEvent('download')
await page.getByRole('link', { name: /300A summary CSV/ }).click()
expect((await download).suggestedFilename()).toMatch(/^osha-ita-300a-\d{4}\.csv$/)
})
test("OSHA pages aren't offered to supervisors", async ({ page, isMobile }) => {
await signIn(page, 'supervisor')
if (isMobile) await page.getByRole('button', { name: 'Open menu' }).click()
await expect(page.getByRole('link', { name: 'Incidents' }).first()).toBeVisible()
await expect(page.getByRole('link', { name: 'OSHA log' })).toHaveCount(0)
})
+4
View File
@@ -12,11 +12,13 @@ import { attachmentRoutes } from './routes/attachments.ts'
import { authRoutes } from './routes/auth.ts' import { authRoutes } from './routes/auth.ts'
import { incidentRoutes } from './routes/incidents.ts' import { incidentRoutes } from './routes/incidents.ts'
import { kioskAdminRoutes, kioskPublicRoutes } from './routes/kiosks.ts' import { kioskAdminRoutes, kioskPublicRoutes } from './routes/kiosks.ts'
import { oshaRoutes } from './routes/osha.ts'
import { rootCauseRoutes } from './routes/root-causes.ts' import { rootCauseRoutes } from './routes/root-causes.ts'
import { setupRoutes } from './routes/setup.ts' import { setupRoutes } from './routes/setup.ts'
import { areaRoutes, siteRoutes } from './routes/sites.ts' import { areaRoutes, siteRoutes } from './routes/sites.ts'
import { statsRoutes } from './routes/stats.ts' import { statsRoutes } from './routes/stats.ts'
import { userRoutes } from './routes/users.ts' import { userRoutes } from './routes/users.ts'
import { viewRoutes } from './routes/views.ts'
import type { FileStorage } from './storage.ts' import type { FileStorage } from './storage.ts'
export interface AppDeps { export interface AppDeps {
@@ -57,6 +59,8 @@ export function createApp({ db, config, notifier, storage }: AppDeps) {
app.route('/api/stats', statsRoutes) app.route('/api/stats', statsRoutes)
app.route('/api/kiosks', kioskAdminRoutes) app.route('/api/kiosks', kioskAdminRoutes)
app.route('/api/kiosk', kioskPublicRoutes()) app.route('/api/kiosk', kioskPublicRoutes())
app.route('/api/osha', oshaRoutes)
app.route('/api/views', viewRoutes)
app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404)) app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404))
app.onError((error, c) => { app.onError((error, c) => {
@@ -0,0 +1,41 @@
CREATE TYPE "public"."osha_case_type" AS ENUM('injury', 'skin_disorder', 'respiratory', 'poisoning', 'hearing_loss', 'other_illness');--> statement-breakpoint
CREATE TYPE "public"."osha_outcome" AS ENUM('death', 'days_away', 'restricted', 'other');--> statement-breakpoint
CREATE TYPE "public"."osha_special_case" AS ENUM('none', 'needlestick', 'medical_removal', 'hearing_loss', 'tuberculosis');--> statement-breakpoint
ALTER TABLE "hours_worked" ADD COLUMN "average_employees" integer;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "case_number" text;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "work_related" boolean;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "new_case" boolean;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "died" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "date_of_death" date;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "days_away_from_work" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "restricted_or_transfer" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "medical_treatment" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "loss_of_consciousness" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "significant_diagnosis" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "special_case" "osha_special_case" DEFAULT 'none' NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "case_type" "osha_case_type" DEFAULT 'injury' NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "recordable" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "outcome" "osha_outcome";--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "inpatient_hospitalization" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "amputation" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "loss_of_eye" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "reported_to_osha_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "employee_address" text;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "date_of_birth" date;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "date_of_hire" date;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "sex" text;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "physician_name" text;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "treatment_facility" text;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "treated_in_emergency_room" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "time_started_work" text;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "activity_before" text;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "what_happened" text;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "object_substance" text;--> statement-breakpoint
ALTER TABLE "sites" ADD COLUMN "street_address" text;--> statement-breakpoint
ALTER TABLE "sites" ADD COLUMN "city" text;--> statement-breakpoint
ALTER TABLE "sites" ADD COLUMN "state" text;--> statement-breakpoint
ALTER TABLE "sites" ADD COLUMN "zip" text;--> statement-breakpoint
ALTER TABLE "sites" ADD COLUMN "naics_code" text;--> statement-breakpoint
ALTER TABLE "sites" ADD COLUMN "industry_description" text;--> statement-breakpoint
ALTER TABLE "sites" ADD COLUMN "ein" text;--> statement-breakpoint
ALTER TABLE "sites" ADD COLUMN "establishment_type" integer DEFAULT 1 NOT NULL;
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE "saved_views" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"page" text NOT NULL,
"name" text NOT NULL,
"filters" jsonb NOT NULL,
"shared" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "saved_views" ADD CONSTRAINT "saved_views_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "saved_views" ADD CONSTRAINT "saved_views_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "saved_views_organization_page_idx" ON "saved_views" USING btree ("organization_id","page");
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -22,6 +22,20 @@
"when": 1791310953907, "when": 1791310953907,
"tag": "0002_kiosks_and_offline", "tag": "0002_kiosks_and_offline",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1791312050891,
"tag": "0003_osha_recordkeeping",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1791315152348,
"tag": "0004_saved_views",
"breakpoints": true
} }
] ]
} }
+71
View File
@@ -14,6 +14,9 @@ export const actionKindEnum = pgEnum('action_kind', vocab.actionKinds)
export const actionStatusEnum = pgEnum('action_status', vocab.actionStatuses) export const actionStatusEnum = pgEnum('action_status', vocab.actionStatuses)
export const authTokenPurposeEnum = pgEnum('auth_token_purpose', ['magic_link', 'password_reset']) export const authTokenPurposeEnum = pgEnum('auth_token_purpose', ['magic_link', 'password_reset'])
export const reportChannelEnum = pgEnum('report_channel', ['web', 'kiosk']) export const reportChannelEnum = pgEnum('report_channel', ['web', 'kiosk'])
export const oshaCaseTypeEnum = pgEnum('osha_case_type', vocab.oshaCaseTypes)
export const oshaOutcomeEnum = pgEnum('osha_outcome', vocab.oshaOutcomes)
export const oshaSpecialCaseEnum = pgEnum('osha_special_case', vocab.oshaSpecialCases)
const id = () => uuid().primaryKey().defaultRandom() const id = () => uuid().primaryKey().defaultRandom()
const timestamptz = () => timestamp({ withTimezone: true }) const timestamptz = () => timestamp({ withTimezone: true })
@@ -91,6 +94,16 @@ export const sites = pgTable(
name: text().notNull(), name: text().notNull(),
address: text(), address: text(),
timezone: text().notNull().default('UTC'), timezone: text().notNull().default('UTC'),
// OSHA establishment details (Form 300A and the ITA upload)
streetAddress: text(),
city: text(),
state: text(),
zip: text(),
naicsCode: text(),
industryDescription: text(),
ein: text(),
/** ITA establishment_type: 1 private, 2 state government, 3 local government. */
establishmentType: integer().notNull().default(1),
archivedAt: timestamptz(), archivedAt: timestamptz(),
createdAt: createdAt(), createdAt: createdAt(),
updatedAt: updatedAt(), updatedAt: updatedAt(),
@@ -208,6 +221,42 @@ export const injuredPersons = pgTable(
daysRestricted: integer().notNull().default(0), daysRestricted: integer().notNull().default(0),
/** OSHA privacy case: name withheld from the 300 log. */ /** OSHA privacy case: name withheld from the 300 log. */
privacyCase: boolean().notNull().default(false), privacyCase: boolean().notNull().default(false),
// OSHA recordability (29 CFR 1904). Null answers mean "not assessed yet".
caseNumber: text(),
workRelated: boolean(),
newCase: boolean(),
died: boolean().notNull().default(false),
dateOfDeath: date({ mode: 'string' }),
daysAwayFromWork: boolean().notNull().default(false),
restrictedOrTransfer: boolean().notNull().default(false),
medicalTreatment: boolean().notNull().default(false),
lossOfConsciousness: boolean().notNull().default(false),
significantDiagnosis: boolean().notNull().default(false),
specialCase: oshaSpecialCaseEnum().notNull().default('none'),
caseType: oshaCaseTypeEnum().notNull().default('injury'),
/** Derived from the answers above with shared/osha.ts, stored for reporting. */
recordable: boolean().notNull().default(false),
outcome: oshaOutcomeEnum(),
// Severe injury reporting (1904.39)
inpatientHospitalization: boolean().notNull().default(false),
amputation: boolean().notNull().default(false),
lossOfEye: boolean().notNull().default(false),
reportedToOshaAt: timestamptz(),
// Form 301 details
employeeAddress: text(),
dateOfBirth: date({ mode: 'string' }),
dateOfHire: date({ mode: 'string' }),
sex: text(),
physicianName: text(),
treatmentFacility: text(),
treatedInEmergencyRoom: boolean().notNull().default(false),
timeStartedWork: text(),
activityBefore: text(),
whatHappened: text(),
objectSubstance: text(),
createdAt: createdAt(), createdAt: createdAt(),
updatedAt: updatedAt(), updatedAt: updatedAt(),
}, },
@@ -330,8 +379,30 @@ export const hoursWorked = pgTable(
/** First day of the month, e.g. `2026-10-01`. */ /** First day of the month, e.g. `2026-10-01`. */
month: date({ mode: 'string' }).notNull(), month: date({ mode: 'string' }).notNull(),
hours: numeric({ precision: 12, scale: 2 }).notNull(), hours: numeric({ precision: 12, scale: 2 }).notNull(),
/** Average number of employees on the payroll that month (for the annual average). */
averageEmployees: integer(),
createdAt: createdAt(), createdAt: createdAt(),
updatedAt: updatedAt(), updatedAt: updatedAt(),
}, },
(t) => [uniqueIndex('hours_worked_site_month_unique').on(t.siteId, t.month)], (t) => [uniqueIndex('hours_worked_site_month_unique').on(t.siteId, t.month)],
) )
/** Named filter sets for the Analytics and Incidents pages; private, or shared with the organization. */
export const savedViews = pgTable(
'saved_views',
{
id: id(),
organizationId: uuid()
.notNull()
.references(() => organizations.id),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
page: text().notNull(),
name: text().notNull(),
filters: jsonb().$type<Record<string, string>>().notNull(),
shared: boolean().notNull().default(false),
createdAt: createdAt(),
},
(t) => [index('saved_views_organization_page_idx').on(t.organizationId, t.page)],
)
+144 -2
View File
@@ -2,7 +2,8 @@
import { hashPassword } from '../auth/password.ts' import { hashPassword } from '../auth/password.ts'
import { loadConfig } from '../config.ts' import { loadConfig } from '../config.ts'
import { openDatabase } from './client.ts' import { openDatabase } from './client.ts'
import { auditLog, areas, incidentCounters, incidents, organizations, sites, users } from './schema.ts' import { assessRecordability } from '../../shared/osha.ts'
import { auditLog, areas, correctiveActions, hoursWorked, incidentCounters, incidents, injuredPersons, investigations, organizations, rootCauses, sites, users } from './schema.ts'
const DEMO_PASSWORD = 'safetylog-demo' const DEMO_PASSWORD = 'safetylog-demo'
@@ -79,10 +80,151 @@ try {
createdAt: incident.createdAt, createdAt: incident.createdAt,
})), })),
) )
await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber: demo.length }) // A year of history so dashboards and the OSHA log have something to show
const lastNumber = await seedHistory(tx as unknown as typeof db, org.id, demo.length, areaIds, people.map((p) => p.id))
await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber })
}) })
console.log(`Seeded demo data. Sign in as admin@example.com, manager@example.com, supervisor@example.com or reporter@example.com with password "${DEMO_PASSWORD}".`) console.log(`Seeded demo data. Sign in as admin@example.com, manager@example.com, supervisor@example.com or reporter@example.com with password "${DEMO_PASSWORD}".`)
} }
} finally { } finally {
await close() await close()
} }
/** Deterministic pseudo-random numbers, so every seed produces the same demo. */
function random(seed: number) {
let state = seed
return () => {
state = (state * 1_664_525 + 1_013_904_223) % 2 ** 32
return state / 2 ** 32
}
}
async function seedHistory(
database: typeof db,
organizationId: string,
startNumber: number,
areaIds: Record<string, { siteId: string; areaId: string }>,
userIds: string[],
): Promise<number> {
const rand = random(42)
const pick = <T>(items: readonly T[]) => items[Math.floor(rand() * items.length)]
const day = 86_400_000
const now = Date.now()
const locations = Object.values(areaIds)
// Near misses and hazards outnumber injuries, as in a healthy reporting culture
const types = ['near_miss', 'near_miss', 'near_miss', 'hazard', 'hazard', 'hazard', 'injury', 'injury', 'property_damage', 'environmental', 'illness'] as const
// Busier around shift changes (06–08, 14–16) and mid-shift
const hours = [6, 7, 7, 9, 10, 11, 13, 14, 14, 15, 15, 16, 19, 22, 2]
const titles = {
near_miss: ['Forklift nearly struck pedestrian', 'Load slipped from pallet jack', 'Unsecured ladder almost fell', 'Dropped tool from mezzanine'],
hazard: ['Oil leak under press', 'Blocked emergency exit', 'Damaged guard on conveyor', 'Frayed extension cord', 'Missing machine guard'],
injury: ['Hand laceration from sheet metal', 'Back strain lifting boxes', 'Slip on wet floor', 'Pinched finger in die'],
illness: ['Dermatitis from cutting fluid'],
property_damage: ['Forklift hit racking', 'Damaged dock door'],
environmental: ['Hydraulic fluid spill', 'Coolant leak to drain'],
} as const
const categories = ['people', 'process', 'equipment', 'materials', 'environment', 'management'] as const
let number = startNumber
for (let i = 0; i < 150; i++) {
const type = pick(types)
const occurred = new Date(now - Math.floor(rand() * 360 + 3) * day)
occurred.setHours(pick(hours), Math.floor(rand() * 60), 0, 0)
const created = new Date(occurred.getTime() + (1 + rand() * 6) * 3_600_000)
const age = (now - occurred.getTime()) / day
const status = age > 60 ? (rand() < 0.85 ? 'closed' : 'actions') : age > 20 ? pick(['investigation', 'actions', 'verification', 'closed'] as const) : pick(['reported', 'triage', 'investigation'] as const)
const closedAt = status === 'closed' ? new Date(created.getTime() + (5 + rand() * 35) * day) : null
number++
const [incident] = await database
.insert(incidents)
.values({
organizationId,
number,
title: pick(titles[type]),
type,
severity: type === 'injury' ? pick(['medium', 'high', 'high'] as const) : pick(['low', 'medium', 'medium', 'high'] as const),
status,
occurredAt: occurred,
createdAt: created,
closedAt: closedAt && closedAt.getTime() < now ? closedAt : null,
...pick(locations),
reporterId: pick(userIds),
})
.returning()
const auditRows: (typeof auditLog.$inferInsert)[] = [
{ organizationId, actorId: incident.reporterId, entityType: 'incident', entityId: incident.id, action: 'create', changes: { number, title: incident.title }, createdAt: created },
]
if (status !== 'reported') {
// Most reports are triaged within a day; the team got quicker over the year
const delayHours = rand() < 0.55 + (1 - age / 365) * 0.35 ? 2 + rand() * 18 : 26 + rand() * 70
auditRows.push({ organizationId, actorId: pick(userIds), entityType: 'incident', entityId: incident.id, action: 'status', changes: { status: ['reported', 'triage'] }, createdAt: new Date(created.getTime() + delayHours * 3_600_000) })
}
await database.insert(auditLog).values(auditRows)
if (['investigation', 'actions', 'verification', 'closed'].includes(status) && type !== 'near_miss') {
const [investigation] = await database.insert(investigations).values({ incidentId: incident.id, method: 'five_whys', startedAt: created }).returning()
const causes = await database
.insert(rootCauses)
.values(Array.from({ length: 1 + Math.floor(rand() * 2) }, () => ({ investigationId: investigation.id, category: pick(categories), description: 'Identified during investigation' })))
.returning()
const due = new Date(created.getTime() + 21 * day)
const done = status === 'closed' || status === 'verification' || rand() < 0.4
const completed = new Date(created.getTime() + (8 + rand() * 30) * day)
await database.insert(correctiveActions).values({
incidentId: incident.id,
rootCauseId: causes[0].id,
title: 'Fix the cause and brief the team',
assigneeId: pick(userIds),
dueDate: due.toISOString().slice(0, 10),
status: done && completed.getTime() < now ? (status === 'closed' ? 'verified' : 'done') : 'open',
completedAt: done && completed.getTime() < now ? completed : null,
})
}
if ((type === 'injury' || type === 'illness') && rand() < 0.6) {
const answers = {
employmentType: 'employee' as const,
workRelated: true,
newCase: true,
died: false,
daysAwayFromWork: rand() < 0.3,
restrictedOrTransfer: rand() < 0.4,
medicalTreatment: rand() < 0.7,
lossOfConsciousness: false,
significantDiagnosis: false,
specialCase: 'none' as const,
}
const verdict = assessRecordability(answers)
await database.insert(injuredPersons).values({
incidentId: incident.id,
caseNumber: `INC-${String(number).padStart(4, '0')}`,
name: pick(['Alex Moreno', 'Jamie Patel', 'Chris Nguyen', 'Taylor Brooks', 'Morgan Diaz']),
jobTitle: pick(['Press operator', 'Material handler', 'Maintenance technician', 'Assembler']),
natureOfInjury: incident.title,
caseType: type === 'illness' ? 'skin_disorder' : 'injury',
...answers,
daysAway: answers.daysAwayFromWork ? 1 + Math.floor(rand() * 10) : 0,
daysRestricted: answers.restrictedOrTransfer ? 2 + Math.floor(rand() * 14) : 0,
recordable: verdict.status === 'recordable',
outcome: verdict.outcome,
})
}
}
// Monthly hours worked and headcount for every site, for the last 13 months
const siteIds = [...new Set(locations.map((l) => l.siteId))]
const today = new Date()
for (const siteId of siteIds) {
for (let m = 12; m >= 0; m--) {
const month = new Date(today.getFullYear(), today.getMonth() - m, 1)
const employees = 70 + Math.floor(rand() * 30)
await database.insert(hoursWorked).values({
siteId,
month: `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, '0')}-01`,
hours: String(employees * 168),
averageEmployees: employees,
})
}
}
return number
}
+252
View File
@@ -0,0 +1,252 @@
import { and, eq, gte, inArray, isNotNull, lt, min } from 'drizzle-orm'
import type { AnalyticsStats, Kpi, TypeGroup } from '../../shared/api-types.ts'
import { hasRole, type IncidentType } from '../../shared/enums.ts'
import { incidenceRate } from '../../shared/osha.ts'
import type { SessionUser } from '../auth/sessions.ts'
import type { Db } from '../db/client.ts'
import { areas, auditLog, correctiveActions, hoursWorked, incidents, injuredPersons, investigations, rootCauses, sites } from '../db/schema.ts'
import { addDays, localDate } from './dates.ts'
import { visibleTo } from './incidents.ts'
const DAY_MS = 86_400_000
export const typeGroup: Record<IncidentType, TypeGroup> = {
near_miss: 'hazards',
hazard: 'hazards',
injury: 'injuries',
illness: 'injuries',
property_damage: 'damage',
environmental: 'damage',
}
export interface AnalyticsFilters {
/** Inclusive start date, `YYYY-MM-DD` (server time zone). */
from: string
/** Inclusive end date. */
to: string
siteId?: string
type?: IncidentType
}
const startOf = (isoDate: string) => new Date(`${isoDate}T00:00:00`)
const monthKey = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
const ratio = (part: number, whole: number) => (whole > 0 ? part / whole : null)
const round1 = (value: number | null) => (value === null ? null : Math.round(value * 10) / 10)
/** Monday of the week containing `date`, local time. */
function weekStart(date: Date): Date {
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate())
start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
return start
}
/** Buckets covering [from, to]: weeks for ranges up to ~3 months, otherwise months. */
export function buckets(from: string, to: string): { bucket: 'week' | 'month'; starts: Date[] } {
const start = startOf(from)
const end = startOf(to)
const days = (end.getTime() - start.getTime()) / DAY_MS + 1
const starts: Date[] = []
if (days <= 92) {
for (let d = weekStart(start); d <= end; d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 7)) starts.push(d)
return { bucket: 'week', starts }
}
for (let d = new Date(start.getFullYear(), start.getMonth(), 1); d <= end; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) starts.push(d)
return { bucket: 'month', starts }
}
function bucketIndex(starts: Date[], date: Date): number {
for (let i = starts.length - 1; i >= 0; i--) if (date >= starts[i]) return i
return -1
}
export async function computeAnalytics(db: Db, user: SessionUser, filters: AnalyticsFilters): Promise<AnalyticsStats> {
const from = startOf(filters.from)
const to = startOf(addDays(filters.to, 1)) // exclusive
const length = to.getTime() - from.getTime()
const previousFrom = new Date(from.getTime() - length)
const visible = visibleTo(db, user)
const scope = and(visible, filters.siteId ? eq(incidents.siteId, filters.siteId) : undefined, filters.type ? eq(incidents.type, filters.type) : undefined)
const occurredIn = (start: Date, end: Date) => and(scope, gte(incidents.occurredAt, start), lt(incidents.occurredAt, end))
const columns = { id: incidents.id, type: incidents.type, occurredAt: incidents.occurredAt, createdAt: incidents.createdAt, siteId: incidents.siteId, areaId: incidents.areaId }
const current = await db.select(columns).from(incidents).where(occurredIn(from, to))
const previous = await db.select(columns).from(incidents).where(occurredIn(previousFrom, from))
const countGroups = (rows: typeof current) => {
const groups = { hazards: 0, injuries: 0, damage: 0 }
for (const row of rows) groups[typeGroup[row.type]]++
return groups
}
const now = countGroups(current)
const before = countGroups(previous)
// Responsiveness: time from report to the first status change
async function triagedShare(rows: typeof current): Promise<number | null> {
if (rows.length === 0) return null
const firstMoves = await db
.select({ id: auditLog.entityId, at: min(auditLog.createdAt) })
.from(auditLog)
.where(and(eq(auditLog.entityType, 'incident'), eq(auditLog.action, 'status'), inArray(auditLog.entityId, rows.map((r) => r.id))))
.groupBy(auditLog.entityId)
const moved = new Map(firstMoves.map((m) => [m.id, m.at]))
const quick = rows.filter((r) => {
const at = moved.get(r.id)
return at && at.getTime() - r.createdAt.getTime() <= DAY_MS
})
return quick.length / rows.length
}
async function averageDaysToClose(start: Date, end: Date) {
const closed = await db
.select({ createdAt: incidents.createdAt, closedAt: incidents.closedAt })
.from(incidents)
.where(and(scope, isNotNull(incidents.closedAt), gte(incidents.closedAt, start), lt(incidents.closedAt, end)))
if (closed.length === 0) return null
return round1(closed.reduce((sum, r) => sum + (r.closedAt!.getTime() - r.createdAt.getTime()), 0) / closed.length / DAY_MS)
}
async function completedActions(start: Date, end: Date) {
return db
.select({ completedAt: correctiveActions.completedAt, dueDate: correctiveActions.dueDate })
.from(correctiveActions)
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
.where(and(scope, isNotNull(correctiveActions.completedAt), isNotNull(correctiveActions.dueDate), gte(correctiveActions.completedAt, start), lt(correctiveActions.completedAt, end)))
}
const onTime = (row: { completedAt: Date | null; dueDate: string | null }) => localDate(row.completedAt!) <= row.dueDate!
const actionsNow = await completedActions(from, to)
const actionsBefore = await completedActions(previousFrom, from)
const today = localDate()
const overdueRows = await db
.select({ id: correctiveActions.id })
.from(correctiveActions)
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
.where(and(scope, inArray(correctiveActions.status, ['open', 'in_progress']), lt(correctiveActions.dueDate, today)))
// Lagging: OSHA-recordable cases. Counts only, so supervisors may see them.
const canSeeRates = hasRole(user.role, 'supervisor')
async function recordableRows(start: Date, end: Date) {
return db
.select({ occurredAt: incidents.occurredAt, outcome: injuredPersons.outcome })
.from(injuredPersons)
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
.where(and(eq(incidents.organizationId, user.organizationId), filters.siteId ? eq(incidents.siteId, filters.siteId) : undefined, eq(injuredPersons.recordable, true), gte(incidents.occurredAt, start), lt(incidents.occurredAt, end)))
}
const kpi = (value: number | null, previousValue: number | null): Kpi => ({ value, previous: previousValue })
const ratioOf = (groups: typeof now) => (groups.injuries ? Math.round(((groups.hazards / groups.injuries) * 10)) / 10 : null)
// Trend
const { bucket, starts } = buckets(filters.from, filters.to)
const trend = starts.map((start) => ({ start: localDate(start), hazards: 0, injuries: 0, damage: 0 }))
for (const row of current) {
const i = bucketIndex(starts, row.occurredAt)
if (i >= 0) trend[i][typeGroup[row.type]]++
}
// Heatmap: weekday × hour of occurrence
const heatmap = Array.from({ length: 7 }, () => Array<number>(24).fill(0))
for (const row of current) heatmap[(row.occurredAt.getDay() + 6) % 7][row.occurredAt.getHours()]++
// Location: sites, or areas within the chosen site
let byLocation: AnalyticsStats['byLocation']
if (filters.siteId) {
const areaRows = await db.select({ id: areas.id, name: areas.name }).from(areas).where(eq(areas.siteId, filters.siteId))
const names = new Map(areaRows.map((a) => [a.id, a.name]))
const counts = new Map<string | null, number>()
for (const row of current) counts.set(row.areaId, (counts.get(row.areaId) ?? 0) + 1)
byLocation = [...counts].map(([id, count]) => ({ id, name: id ? (names.get(id) ?? 'Unknown area') : 'Area not specified', count }))
} else {
const siteRows = await db.select({ id: sites.id, name: sites.name }).from(sites).where(eq(sites.organizationId, user.organizationId))
const names = new Map(siteRows.map((s) => [s.id, s.name]))
const counts = new Map<string, number>()
for (const row of current) counts.set(row.siteId, (counts.get(row.siteId) ?? 0) + 1)
byLocation = [...counts].map(([id, count]) => ({ id, name: names.get(id) ?? 'Unknown site', count }))
}
byLocation.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
const causeRows = current.length
? await db
.select({ category: rootCauses.category })
.from(rootCauses)
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
.where(inArray(investigations.incidentId, current.map((r) => r.id)))
: []
const causeCounts = new Map<string, number>()
for (const row of causeRows) causeCounts.set(row.category, (causeCounts.get(row.category) ?? 0) + 1)
const byRootCause = [...causeCounts].map(([category, count]) => ({ category: category as AnalyticsStats['byRootCause'][number]['category'], count })).sort((a, b) => b.count - a.count)
// Actions completed per month, on time vs late
const actionsByMonth: AnalyticsStats['actionsByMonth'] = []
for (let d = new Date(from.getFullYear(), from.getMonth(), 1); d < to; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) actionsByMonth.push({ month: monthKey(d), onTime: 0, late: 0 })
for (const row of actionsNow) {
const entry = actionsByMonth.find((m) => m.month === monthKey(row.completedAt!))
if (entry) entry[onTime(row) ? 'onTime' : 'late']++
}
// Rolling 12-month OSHA rates at each of the last 12 month ends
let rates: AnalyticsStats['rates'] = null
let yearly: AnalyticsStats['yearly'] = null
let recordables: Kpi | null = null
if (canSeeRates) {
recordables = kpi((await recordableRows(from, to)).length, (await recordableRows(previousFrom, from)).length)
const endMonth = new Date(startOf(filters.to).getFullYear(), startOf(filters.to).getMonth(), 1)
const windowStart = new Date(endMonth.getFullYear() - 5, 0, 1)
const cases = await recordableRows(windowStart, new Date(endMonth.getFullYear(), endMonth.getMonth() + 1, 1))
const hours = await db
.select({ month: hoursWorked.month, hours: hoursWorked.hours })
.from(hoursWorked)
.innerJoin(sites, eq(sites.id, hoursWorked.siteId))
.where(and(eq(sites.organizationId, user.organizationId), filters.siteId ? eq(hoursWorked.siteId, filters.siteId) : undefined, gte(hoursWorked.month, localDate(windowStart))))
const hoursByMonth = new Map<string, number>()
for (const h of hours) hoursByMonth.set(h.month.slice(0, 7), (hoursByMonth.get(h.month.slice(0, 7)) ?? 0) + Number(h.hours))
const casesIn = (start: Date, end: Date) => cases.filter((c) => c.occurredAt >= start && c.occurredAt < end)
const hoursIn = (start: Date, end: Date) => {
let total = 0
for (let d = new Date(start); d < end; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) total += hoursByMonth.get(monthKey(d)) ?? 0
return total
}
const isDart = (c: { outcome: string | null }) => c.outcome === 'days_away' || c.outcome === 'restricted'
rates = Array.from({ length: 12 }, (_, i) => {
const monthStart = new Date(endMonth.getFullYear(), endMonth.getMonth() - 11 + i, 1)
const windowEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1)
const windowBegin = new Date(windowEnd.getFullYear() - 1, windowEnd.getMonth(), 1)
const inWindow = casesIn(windowBegin, windowEnd)
const h = hoursIn(windowBegin, windowEnd)
return { month: monthKey(monthStart), trir: incidenceRate(inWindow.length, h), dart: incidenceRate(inWindow.filter(isDart).length, h) }
})
yearly = Array.from({ length: 5 }, (_, i) => {
const year = endMonth.getFullYear() - 4 + i
const inYear = casesIn(new Date(year, 0, 1), new Date(year + 1, 0, 1))
const h = Math.round(hoursIn(new Date(year, 0, 1), new Date(year + 1, 0, 1)))
const dartCases = inYear.filter(isDart).length
return { year, recordables: inYear.length, dartCases, hours: h, trir: incidenceRate(inYear.length, h), dart: incidenceRate(dartCases, h) }
})
}
return {
from: filters.from,
to: filters.to,
previousFrom: localDate(previousFrom),
bucket,
kpis: {
reports: kpi(current.length, previous.length),
leadingReports: kpi(now.hazards, before.hazards),
injuries: kpi(now.injuries, before.injuries),
nearMissRatio: kpi(ratioOf(now), ratioOf(before)),
triagedWithin24h: kpi(await triagedShare(current), await triagedShare(previous)),
actionsOnTime: kpi(ratio(actionsNow.filter(onTime).length, actionsNow.length), ratio(actionsBefore.filter(onTime).length, actionsBefore.length)),
averageDaysToClose: kpi(await averageDaysToClose(from, to), await averageDaysToClose(previousFrom, from)),
recordables,
overdueActions: overdueRows.length,
},
trend,
byLocation,
byRootCause,
heatmap,
actionsByMonth,
rates,
yearly,
}
}
+2 -2
View File
@@ -3,10 +3,10 @@ import { toCsv } from './csv.ts'
describe('toCsv', () => { describe('toCsv', () => {
it('quotes commas, quotes and newlines', () => { it('quotes commas, quotes and newlines', () => {
expect(toCsv(['a', 'b'], [['x,y', 'say "hi"\nok']])).toBe('a,b\r\n"x,y","say ""hi""\nok"\r\n') expect(toCsv(['a', 'b'], [['x,y', 'say "hi"\nok']])).toBe('\uFEFFa,b\r\n"x,y","say ""hi""\nok"\r\n')
}) })
it('neutralises formula prefixes and blanks nulls', () => { it('neutralises formula prefixes and blanks nulls', () => {
expect(toCsv(['a'], [['=1+1'], ['-2'], [null], [3]])).toBe("a\r\n'=1+1\r\n'-2\r\n\r\n3\r\n") expect(toCsv(['a'], [['=1+1'], ['-2'], [null], [3]])).toBe("\uFEFFa\r\n'=1+1\r\n'-2\r\n\r\n3\r\n")
}) })
}) })
+5 -4
View File
@@ -1,14 +1,15 @@
type Cell = string | number | null | undefined type Cell = string | number | null | undefined
/** /**
* RFC 4180 CSV with a BOM so Excel opens UTF-8 correctly. Cells that a spreadsheet would treat as a * RFC 4180 CSV. By default starts with a BOM so Excel opens UTF-8 correctly (`bom: false` for machine
* formula are prefixed with an apostrophe (CSV injection). * uploads such as OSHA's ITA). Cells a spreadsheet would treat as a formula are prefixed with an
* apostrophe (CSV injection).
*/ */
export function toCsv(header: string[], rows: Cell[][]): string { export function toCsv(header: string[], rows: Cell[][], { bom = true }: { bom?: boolean } = {}): string {
const cell = (value: Cell) => { const cell = (value: Cell) => {
let text = value === null || value === undefined ? '' : String(value) let text = value === null || value === undefined ? '' : String(value)
if (/^[=+\-@\t\r]/.test(text)) text = `'${text}` if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
} }
return '' + [header, ...rows].map((row) => row.map(cell).join(',')).join('\r\n') + '\r\n' return (bom ? '\uFEFF' : '') + [header, ...rows].map((row) => row.map(cell).join(',')).join('\r\n') + '\r\n'
} }
+5 -3
View File
@@ -1,4 +1,4 @@
import { and, asc, eq, exists, or, type SQL } from 'drizzle-orm' import { and, asc, count, eq, exists, or, type SQL } from 'drizzle-orm'
import { alias } from 'drizzle-orm/pg-core' import { alias } from 'drizzle-orm/pg-core'
import type { Attachment, CorrectiveAction, Incident, IncidentDetail, Investigation, RootCause } from '../../shared/api-types.ts' import type { Attachment, CorrectiveAction, Incident, IncidentDetail, Investigation, RootCause } from '../../shared/api-types.ts'
import { hasRole } from '../../shared/enums.ts' import { hasRole } from '../../shared/enums.ts'
@@ -6,7 +6,7 @@ import { incidentReference } from '../../shared/format.ts'
import { availableTransitions } from '../../shared/workflow.ts' import { availableTransitions } from '../../shared/workflow.ts'
import type { SessionUser } from '../auth/sessions.ts' import type { SessionUser } from '../auth/sessions.ts'
import type { Db } from '../db/client.ts' import type { Db } from '../db/client.ts'
import { areas, attachments, correctiveActions, incidents, investigations, kiosks, rootCauses, sites, users } from '../db/schema.ts' import { areas, attachments, correctiveActions, incidents, injuredPersons, investigations, kiosks, rootCauses, sites, users } from '../db/schema.ts'
import { audit } from './audit.ts' import { audit } from './audit.ts'
import { iso, localDate } from './dates.ts' import { iso, localDate } from './dates.ts'
import { fail } from './http.ts' import { fail } from './http.ts'
@@ -159,6 +159,7 @@ export async function loadDetail(db: Db, user: SessionUser, number: number): Pro
: null : null
const actions = actionRows.map((action) => toAction(action)) const actions = actionRows.map((action) => toAction(action))
const manage = hasRole(user.role, 'supervisor') const manage = hasRole(user.role, 'supervisor')
const [{ injured }] = await db.select({ injured: count() }).from(injuredPersons).where(eq(injuredPersons.incidentId, incidentId))
return { return {
...toIncident(row), ...toIncident(row),
@@ -171,7 +172,8 @@ export async function loadDetail(db: Db, user: SessionUser, number: number): Pro
rootCauseCount: causes.length, rootCauseCount: causes.length,
actionStatuses: actions.map((action) => action.status), actionStatuses: actions.map((action) => action.status),
}), }),
can: { edit: manage, manageInvestigation: manage, manageActions: manage }, injuredCount: injured,
can: { edit: manage, manageInvestigation: manage, manageActions: manage, manageOsha: hasRole(user.role, 'safety_manager') },
} }
} }
+200
View File
@@ -0,0 +1,200 @@
import { and, asc, eq, gte, isNull, lt } from 'drizzle-orm'
import type { Establishment, OshaCase, OshaLogRow, OshaYear } from '../../shared/api-types.ts'
import { oshaCaseTypes } from '../../shared/enums.ts'
import { incidentReference } from '../../shared/format.ts'
import { capDays, incidenceRate, severeInjuryReport, summaryChecks, type SummaryTotals } from '../../shared/osha.ts'
import type { Db } from '../db/client.ts'
import { areas, hoursWorked, incidents, injuredPersons, organizations, sites } from '../db/schema.ts'
import { iso } from './dates.ts'
import { fail } from './http.ts'
type Person = typeof injuredPersons.$inferSelect
type IncidentInfo = { id: string; number: number; title: string; occurredAt: Date; createdAt: Date }
/** Jan 1 to Jan 1 in the server's time zone (set TZ to the plant's zone). */
export function yearRange(year: number): [Date, Date] {
return [new Date(year, 0, 1), new Date(year + 1, 0, 1)]
}
/** Fields the ITA case upload and Form 301 require that are still empty. */
export function missingFields(person: Person): string[] {
if (!person.recordable) return []
const required: [keyof Person, string][] = [
['jobTitle', 'job title'],
['dateOfBirth', 'date of birth'],
['dateOfHire', 'date hired'],
['activityBefore', 'what they were doing'],
['whatHappened', 'what happened'],
['natureOfInjury', 'the injury or illness'],
['objectSubstance', 'object or substance'],
]
const missing = required.filter(([key]) => !person[key]).map(([, label]) => label)
if (person.died && !person.dateOfDeath) missing.push('date of death')
return missing
}
export function toOshaCase(person: Person, incident: IncidentInfo): OshaCase {
const severe = severeInjuryReport(person)
return {
id: person.id,
incident: { id: incident.id, number: incident.number, reference: incidentReference(incident.number), title: incident.title, occurredAt: iso(incident.occurredAt) },
caseNumber: person.caseNumber ?? incidentReference(incident.number),
name: person.name,
jobTitle: person.jobTitle,
employmentType: person.employmentType,
bodyPart: person.bodyPart,
natureOfInjury: person.natureOfInjury,
caseType: person.caseType,
workRelated: person.workRelated,
newCase: person.newCase,
died: person.died,
dateOfDeath: person.dateOfDeath,
daysAwayFromWork: person.daysAwayFromWork,
daysAway: person.daysAway,
restrictedOrTransfer: person.restrictedOrTransfer,
daysRestricted: person.daysRestricted,
medicalTreatment: person.medicalTreatment,
lossOfConsciousness: person.lossOfConsciousness,
significantDiagnosis: person.significantDiagnosis,
specialCase: person.specialCase,
privacyCase: person.privacyCase,
recordable: person.recordable,
outcome: person.outcome,
inpatientHospitalization: person.inpatientHospitalization,
amputation: person.amputation,
lossOfEye: person.lossOfEye,
reportedToOshaAt: iso(person.reportedToOshaAt),
// The clock starts when the employer learns of it; the report date is the best record of that.
severeReport: severe && { ...severe, dueBy: new Date(incident.createdAt.getTime() + severe.withinHours * 3_600_000).toISOString() },
employeeAddress: person.employeeAddress,
dateOfBirth: person.dateOfBirth,
dateOfHire: person.dateOfHire,
sex: person.sex === 'M' || person.sex === 'F' ? person.sex : null,
physicianName: person.physicianName,
treatmentFacility: person.treatmentFacility,
treatedInEmergencyRoom: person.treatedInEmergencyRoom,
timeStartedWork: person.timeStartedWork,
activityBefore: person.activityBefore,
whatHappened: person.whatHappened,
objectSubstance: person.objectSubstance,
missingFields: missingFields(person),
updatedAt: iso(person.updatedAt),
}
}
/** Form 300 column F: injury, body part, and what caused it. */
export function describeCase(person: Pick<Person, 'natureOfInjury' | 'bodyPart' | 'objectSubstance'>): string {
const injury = [person.natureOfInjury, person.bodyPart && `(${person.bodyPart})`].filter(Boolean).join(' ')
return [injury, person.objectSubstance].filter(Boolean).join('; ')
}
export const establishmentOf = (site: typeof sites.$inferSelect): Establishment => ({
streetAddress: site.streetAddress,
city: site.city,
state: site.state,
zip: site.zip,
naicsCode: site.naicsCode,
industryDescription: site.industryDescription,
ein: site.ein,
establishmentType: site.establishmentType,
})
export function missingEstablishmentFields(site: typeof sites.$inferSelect): string[] {
const required: [keyof typeof site, string][] = [
['streetAddress', 'street address'],
['city', 'city'],
['state', 'state'],
['zip', 'ZIP code'],
['naicsCode', 'NAICS code'],
['ein', 'EIN'],
]
return required.filter(([key]) => !site[key]).map(([, label]) => label)
}
/** Everything for one establishment's year: the 300 Log, 300A totals, hours, rates and ITA checks. */
export async function buildOshaYear(db: Db, organizationId: string, siteId: string, year: number): Promise<OshaYear> {
const [site] = await db.select().from(sites).where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId)))
if (!site) throw fail(404, 'Site not found')
const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, organizationId))
const [from, to] = yearRange(year)
const caseRows = await db
.select({ person: injuredPersons, incident: incidents, areaName: areas.name })
.from(injuredPersons)
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
.leftJoin(areas, eq(areas.id, incidents.areaId))
.where(and(eq(incidents.siteId, siteId), eq(injuredPersons.recordable, true), gte(incidents.occurredAt, from), lt(incidents.occurredAt, to)))
.orderBy(asc(incidents.occurredAt), asc(injuredPersons.caseNumber))
const rows: OshaLogRow[] = caseRows.map(({ person, incident, areaName }) => {
const days = capDays(person.daysAwayFromWork ? person.daysAway : 0, person.restrictedOrTransfer ? person.daysRestricted : 0)
return {
caseId: person.id,
caseNumber: person.caseNumber ?? incidentReference(incident.number),
incidentNumber: incident.number,
employeeName: person.privacyCase ? 'Privacy case' : person.name,
jobTitle: person.jobTitle ?? '',
dateOfInjury: iso(incident.occurredAt),
location: [areaName, incident.locationDetail].filter(Boolean).join(', ') || site.name,
description: describeCase(person),
outcome: person.outcome ?? 'other',
daysAway: days.daysAway,
daysRestricted: days.daysRestricted,
caseType: person.caseType,
missingFields: missingFields(person),
}
})
const totals: SummaryTotals = {
deaths: rows.filter((r) => r.outcome === 'death').length,
daysAwayCases: rows.filter((r) => r.outcome === 'days_away').length,
restrictedCases: rows.filter((r) => r.outcome === 'restricted').length,
otherCases: rows.filter((r) => r.outcome === 'other').length,
daysAway: rows.reduce((sum, r) => sum + r.daysAway, 0),
daysRestricted: rows.reduce((sum, r) => sum + r.daysRestricted, 0),
byType: Object.fromEntries(oshaCaseTypes.map((type) => [type, rows.filter((r) => r.caseType === type).length])) as SummaryTotals['byType'],
}
const monthRows = await db
.select()
.from(hoursWorked)
.where(and(eq(hoursWorked.siteId, siteId), gte(hoursWorked.month, `${year}-01-01`), lt(hoursWorked.month, `${year + 1}-01-01`)))
const months = Array.from({ length: 12 }, (_, i) => {
const row = monthRows.find((m) => Number(m.month.slice(5, 7)) === i + 1)
return { month: i + 1, hours: row ? Number(row.hours) : null, averageEmployees: row?.averageEmployees ?? null }
})
const totalHoursWorked = Math.round(months.reduce((sum, m) => sum + (m.hours ?? 0), 0))
const headcounts = months.map((m) => m.averageEmployees).filter((n): n is number => n !== null)
// 1904.32 instructions: total of employees each pay period ÷ number of pay periods; here, months.
const annualAverageEmployees = headcounts.length ? Math.round(headcounts.reduce((a, b) => a + b, 0) / headcounts.length) : 0
const maxEmployees = headcounts.length ? Math.max(...headcounts) : 0
const recordable = rows.length
return {
site: { id: site.id, name: site.name, ...establishmentOf(site) },
companyName: organization.name,
year,
rows,
totals,
months,
totalHoursWorked,
annualAverageEmployees,
maxEmployees,
rates: {
trir: incidenceRate(recordable, totalHoursWorked),
dart: incidenceRate(totals.daysAwayCases + totals.restrictedCases, totalHoursWorked),
ltir: incidenceRate(totals.daysAwayCases, totalHoursWorked),
},
checks: summaryChecks(totals, annualAverageEmployees, totalHoursWorked),
missingEstablishmentFields: missingEstablishmentFields(site),
}
}
/** Active sites for an organization (establishments that may need a log). */
export function activeSites(db: Db, organizationId: string) {
return db
.select()
.from(sites)
.where(and(eq(sites.organizationId, organizationId), isNull(sites.archivedAt)))
.orderBy(asc(sites.name))
}
+27 -1
View File
@@ -2,7 +2,8 @@ import { and, eq, inArray, isNotNull, isNull, lt, lte, ne, or } from 'drizzle-or
import { incidentReference } from '../shared/format.ts' import { incidentReference } from '../shared/format.ts'
import type { Config } from './config.ts' import type { Config } from './config.ts'
import type { Db } from './db/client.ts' import type { Db } from './db/client.ts'
import { correctiveActions, incidents, users } from './db/schema.ts' import { OSHA_REPORTING_CONTACT, severeInjuryReport } from '../shared/osha.ts'
import { correctiveActions, incidents, injuredPersons, users } from './db/schema.ts'
import { addDays, localDate } from './lib/dates.ts' import { addDays, localDate } from './lib/dates.ts'
import type { Mailer } from './mail.ts' import type { Mailer } from './mail.ts'
@@ -93,6 +94,31 @@ export class Notifier {
}) })
} }
/** 1904.39: fatalities must be reported to OSHA within 8 hours; hospitalizations, amputations and eye losses within 24. */
severeInjury(caseId: string) {
this.dispatch(async () => {
const [row] = await this.db
.select({ person: injuredPersons, incident: incidents })
.from(injuredPersons)
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
.where(eq(injuredPersons.id, caseId))
const report = row && severeInjuryReport(row.person)
if (!report) return
const recipients = await this.db
.select({ email: users.email })
.from(users)
.where(and(eq(users.organizationId, row.incident.organizationId), eq(users.active, true), inArray(users.role, ['safety_manager', 'admin'])))
const reference = incidentReference(row.incident.number)
for (const { email } of recipients) {
await this.mailer.send({
to: email,
subject: `Report to OSHA within ${report.withinHours} hours: ${reference}`,
text: `${reference} (${row.incident.title}) was recorded with: ${report.reasons.join(', ')}. OSHA requires a direct report within ${report.withinHours} hours of learning about it (29 CFR 1904.39).\n\n${OSHA_REPORTING_CONTACT}\n\nAfter reporting, record the date and time on the case: ${this.link(`/incidents/${row.incident.number}`)}`,
})
}
})
}
actionAssigned(actionId: string, assignedBy: string) { actionAssigned(actionId: string, assignedBy: string) {
this.dispatch(async () => { this.dispatch(async () => {
const [row] = await this.db const [row] = await this.db
+130
View File
@@ -0,0 +1,130 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { buckets } from '../lib/analytics.ts'
import { localDate } from '../lib/dates.ts'
import { createTestContext } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
let manager: Awaited<ReturnType<typeof ctx.createUser>>
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
let north: { id: string }
let south: { id: string }
const daysAgo = (days: number, hour = 10) => {
const d = new Date()
d.setDate(d.getDate() - days)
d.setHours(hour, 0, 0, 0)
return d
}
const range = (fromDaysAgo: number, toDaysAgo = 0) => `from=${localDate(daysAgo(fromDaysAgo))}&to=${localDate(daysAgo(toDaysAgo))}`
async function report(type: string, when: Date, siteId = north.id, by = manager) {
const res = await by.post('/api/incidents', { title: `${type} report`, type, severity: 'low', occurredAt: when.toISOString(), siteId })
return res.body.incident.number as number
}
beforeAll(async () => {
ctx = await createTestContext()
north = await ctx.createSite('North')
south = await ctx.createSite('South')
manager = await ctx.createUser('safety_manager')
reporter = await ctx.createUser('reporter')
// Current 30-day period: 3 near misses/hazards, 1 injury, 1 spill
await report('near_miss', daysAgo(2, 14))
await report('near_miss', daysAgo(3, 14))
await report('hazard', daysAgo(5, 9), south.id)
const injury = await report('injury', daysAgo(6, 22))
await report('environmental', daysAgo(10))
// Previous period: 1 near miss, 2 injuries
await report('near_miss', daysAgo(40))
await report('injury', daysAgo(45))
await report('injury', daysAgo(50))
// Triage one report straight away
await manager.post(`/api/incidents/${injury}/transitions`, { to: 'triage' })
// A recordable case with hours worked
await manager.post(`/api/osha/incidents/${injury}/cases`, { name: 'Case', workRelated: true, newCase: true, daysAwayFromWork: true, daysAway: 2 })
const now = new Date()
await manager.put(`/api/osha/sites/${north.id}/hours`, { year: now.getFullYear(), months: [{ month: now.getMonth() + 1, hours: 10_000, averageEmployees: 60 }] })
})
afterAll(() => ctx.close())
describe('analytics', () => {
it('compares the period with the one before', async () => {
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
expect(stats.kpis.reports).toEqual({ value: 5, previous: 3 })
expect(stats.kpis.leadingReports).toEqual({ value: 3, previous: 1 })
expect(stats.kpis.injuries).toEqual({ value: 1, previous: 2 })
expect(stats.kpis.nearMissRatio).toEqual({ value: 3, previous: 0.5 })
expect(stats.kpis.triagedWithin24h.value).toBeCloseTo(0.2)
expect(stats.kpis.recordables).toEqual({ value: 1, previous: 0 })
})
it('groups the trend, locations and the time-of-day heatmap', async () => {
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
expect(stats.bucket).toBe('week')
const totals = stats.trend.reduce((sum: Record<string, number>, b: Record<string, number>) => ({ hazards: sum.hazards + b.hazards, injuries: sum.injuries + b.injuries, damage: sum.damage + b.damage }), { hazards: 0, injuries: 0, damage: 0 })
expect(totals).toEqual({ hazards: 3, injuries: 1, damage: 1 })
expect(stats.byLocation).toEqual([
{ id: north.id, name: 'North', count: 4 },
{ id: south.id, name: 'South', count: 1 },
])
expect(stats.heatmap).toHaveLength(7)
expect(stats.heatmap.flat().reduce((a: number, b: number) => a + b, 0)).toBe(5)
expect(stats.heatmap[(daysAgo(6).getDay() + 6) % 7][22]).toBeGreaterThanOrEqual(1)
})
it('narrows to a site (showing its areas) and a type', async () => {
const stats = (await manager.get(`/api/stats/analytics?${range(29)}&siteId=${north.id}&type=near_miss`)).body
expect(stats.kpis.reports.value).toBe(2)
expect(stats.byLocation).toEqual([{ id: null, name: 'Area not specified', count: 2 }])
})
it('computes rolling 12-month OSHA rates from hours worked', async () => {
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
expect(stats.rates).toHaveLength(12)
expect(stats.rates.at(-1)).toEqual({ month: localDate(new Date()).slice(0, 7), trir: 20, dart: 20 })
// Summed over the years, so the test holds even when the injury falls in last year (early January)
expect(stats.yearly.reduce((sum: number, y: { recordables: number }) => sum + y.recordables, 0)).toBe(1)
expect(stats.yearly.at(-1)).toMatchObject({ year: new Date().getFullYear(), hours: 10_000 })
})
it('scopes reporters to their own reports and hides rates', async () => {
await report('hazard', daysAgo(1), north.id, reporter)
const stats = (await reporter.get(`/api/stats/analytics?${range(29)}`)).body
expect(stats.kpis.reports.value).toBe(1)
expect(stats.rates).toBeNull()
expect(stats.kpis.recordables).toBeNull()
})
it('validates the range', async () => {
expect((await manager.get(`/api/stats/analytics?from=2026-02-01&to=2026-01-01`)).status).toBe(400)
expect((await manager.get(`/api/stats/analytics?from=2015-01-01&to=2026-01-01`)).status).toBe(400)
})
it('uses weeks for short ranges and months for long ones', () => {
expect(buckets('2026-01-01', '2026-03-31').bucket).toBe('week')
const long = buckets('2025-01-15', '2025-12-31')
expect(long.bucket).toBe('month')
expect(long.starts).toHaveLength(12)
})
})
describe('saved views', () => {
it('keeps private views private and lets supervisors share', async () => {
const supervisor = await ctx.createUser('supervisor')
const mine = await reporter.post('/api/views', { page: 'analytics', name: 'My hazards', filters: { type: 'hazard', range: '90' } })
expect(mine.status).toBe(201)
expect((await reporter.post('/api/views', { page: 'analytics', name: 'Shared?', filters: {}, shared: true })).status).toBe(403)
await supervisor.post('/api/views', { page: 'analytics', name: 'Plant review', filters: { range: '365' }, shared: true })
const reporterViews = (await reporter.get('/api/views?page=analytics')).body.views.map((v: { name: string; mine: boolean }) => [v.name, v.mine])
expect(reporterViews).toEqual([
['My hazards', true],
['Plant review', false],
])
expect((await supervisor.get('/api/views?page=analytics')).body.views.map((v: { name: string }) => v.name)).toEqual(['Plant review'])
expect((await reporter.delete(`/api/views/${(await supervisor.get('/api/views?page=analytics')).body.views[0].id}`)).status).toBe(403)
expect((await reporter.delete(`/api/views/${mine.body.view.id}`)).status).toBe(204)
})
})
+176
View File
@@ -0,0 +1,176 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestContext } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
let manager: Awaited<ReturnType<typeof ctx.createUser>>
let supervisor: Awaited<ReturnType<typeof ctx.createUser>>
let site: { id: string }
const year = new Date().getFullYear()
/** An incident earlier this year (Jan 15, local time) so it lands on this year's log. */
async function incident(title = 'Hand caught in press') {
const occurredAt = new Date(year, 0, 15, 10, 30).toISOString()
const res = await supervisor.post('/api/incidents', { title, type: 'injury', severity: 'high', occurredAt, siteId: site.id, locationDetail: 'Line 3' })
return res.body.incident.number as number
}
const recordableAnswers = {
workRelated: true,
newCase: true,
jobTitle: 'Press operator',
natureOfInjury: 'Laceration',
bodyPart: 'left hand',
objectSubstance: 'Press die',
dateOfBirth: '1990-04-02',
dateOfHire: '2019-06-10',
activityBefore: 'Clearing a jam',
whatHappened: 'Hand slipped into the die area',
sex: 'M',
timeStartedWork: '07:00',
}
beforeAll(async () => {
ctx = await createTestContext()
site = await ctx.createSite('Main Plant')
manager = await ctx.createUser('safety_manager')
supervisor = await ctx.createUser('supervisor')
})
afterAll(() => ctx.close())
describe('injured people (OSHA cases)', () => {
it('keeps medical details to safety managers', async () => {
const number = await incident()
await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Pat Doe' })
expect((await supervisor.get(`/api/osha/incidents/${number}/cases`)).status).toBe(403)
expect((await supervisor.post(`/api/osha/incidents/${number}/cases`, { name: 'X' })).status).toBe(403)
const detail = (await supervisor.get(`/api/incidents/${number}`)).body.incident
expect(detail).toMatchObject({ injuredCount: 1, can: { manageOsha: false } })
// The timeline says a person was added, without naming them
const timeline = (await supervisor.get(`/api/incidents/${number}/timeline`)).body.timeline
expect(JSON.stringify(timeline)).not.toContain('Pat Doe')
})
it('works out recordability from the answers, and re-checks on every change', async () => {
const number = await incident()
let res = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Sam Lee' })
expect(res.body.case).toMatchObject({ recordable: false, outcome: null, caseNumber: `INC-${String(number).padStart(4, '0')}` })
const id = res.body.case.id
res = await manager.patch(`/api/osha/cases/${id}`, { ...recordableAnswers })
expect(res.body.case).toMatchObject({ recordable: false }) // first aid only so far
res = await manager.patch(`/api/osha/cases/${id}`, { medicalTreatment: true })
expect(res.body.case).toMatchObject({ recordable: true, outcome: 'other' })
res = await manager.patch(`/api/osha/cases/${id}`, { daysAwayFromWork: true, daysAway: 4, restrictedOrTransfer: true, daysRestricted: 10 })
expect(res.body.case).toMatchObject({ outcome: 'days_away', daysAway: 4, daysRestricted: 10, missingFields: [] })
res = await manager.patch(`/api/osha/cases/${id}`, { workRelated: false })
expect(res.body.case.recordable).toBe(false)
await manager.patch(`/api/osha/cases/${id}`, { workRelated: true })
})
it('numbers each person on an incident and enforces the 180-day cap', async () => {
const number = await incident()
await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'First' })
const second = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Second' })
expect(second.body.case.caseNumber).toBe(`INC-${String(number).padStart(4, '0')}-2`)
const res = await manager.patch(`/api/osha/cases/${second.body.case.id}`, { daysAwayFromWork: true, daysAway: 150, restrictedOrTransfer: true, daysRestricted: 40 })
expect(res.status).toBe(400)
expect(res.body.error).toMatch(/180/)
})
it('ignores day counts when the outcome does not apply', async () => {
const number = await incident()
const created = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Counts', daysAway: 12 })
expect(created.body.case.daysAway).toBe(0)
})
it('alerts safety managers when a case must be reported to OSHA, once', async () => {
const number = await incident('Finger amputation at shear')
const created = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Alex Kim', ...recordableAnswers, amputation: true, medicalTreatment: true })
expect(created.body.case.severeReport).toMatchObject({ withinHours: 24, reasons: ['amputation'] })
await ctx.notifier.flush()
const alert = ctx.mailer.last(manager.user.email)
expect(alert?.subject).toMatch(/^Report to OSHA within 24 hours: INC-\d{4}$/)
expect(alert?.text).toContain('1-800-321-OSHA')
const before = ctx.mailer.sent.length
await manager.patch(`/api/osha/cases/${created.body.case.id}`, { reportedToOshaAt: new Date().toISOString(), lossOfEye: true })
await ctx.notifier.flush()
expect(ctx.mailer.sent.length).toBe(before)
})
})
describe('OSHA log, summary and rates', () => {
let other: { id: string }
beforeAll(async () => {
other = await ctx.createSite('Second Plant')
const occurredAt = new Date(year, 1, 3, 14, 5).toISOString()
const res = await supervisor.post('/api/incidents', { title: 'Needlestick', type: 'injury', severity: 'medium', occurredAt, siteId: other.id })
const number = res.body.incident.number
const created = await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'Jordan Private', ...recordableAnswers, specialCase: 'needlestick', privacyCase: true, restrictedOrTransfer: true, daysRestricted: 3, caseType: 'other_illness' })
if (!created.body.case.recordable) throw new Error('setup: needlestick case should be recordable')
// Not recordable: should stay off the log
await manager.post(`/api/osha/incidents/${number}/cases`, { name: 'First Aid Only', ...recordableAnswers })
await manager.put(`/api/osha/sites/${other.id}/hours`, {
year,
months: Array.from({ length: 12 }, (_, i) => ({ month: i + 1, hours: 10_000, averageEmployees: i < 6 ? 50 : 70 })),
})
})
it('builds the 300 Log with privacy cases hidden', async () => {
const log = (await manager.get(`/api/osha/log?siteId=${other.id}&year=${year}`)).body
expect(log.rows).toHaveLength(1)
expect(log.rows[0]).toMatchObject({ employeeName: 'Privacy case', jobTitle: 'Press operator', outcome: 'restricted', daysRestricted: 3, caseType: 'other_illness' })
expect(log.rows[0].description).toBe('Laceration (left hand); Press die')
})
it('totals the 300A, computes rates and runs the ITA checks', async () => {
const log = (await manager.get(`/api/osha/log?siteId=${other.id}&year=${year}`)).body
expect(log.totals).toMatchObject({ deaths: 0, daysAwayCases: 0, restrictedCases: 1, otherCases: 0, daysRestricted: 3 })
expect(log).toMatchObject({ totalHoursWorked: 120_000, annualAverageEmployees: 60, maxEmployees: 70 })
expect(log.rates).toEqual({ trir: 1.67, dart: 1.67, ltir: 0 })
expect(log.checks.errors).toEqual([])
expect(log.missingEstablishmentFields).toEqual(['street address', 'city', 'state', 'ZIP code', 'NAICS code', 'EIN'])
const empty = (await manager.get(`/api/osha/log?siteId=${site.id}&year=${year}`)).body
expect(empty.checks.errors).toContain('Enter hours worked for each month.')
})
it('validates and normalises establishment details', async () => {
expect((await manager.patch(`/api/sites/${other.id}`, { naicsCode: '33211' })).status).toBe(400)
const res = await manager.patch(`/api/sites/${other.id}`, { streetAddress: '1 Mill Rd', city: 'Dayton', state: 'oh', zip: '45402-1234', naicsCode: '332119', ein: '12-3456789' })
expect(res.body.site).toMatchObject({ state: 'OH', zip: '454021234', ein: '123456789' })
})
it('exports the ITA 300A file in OSHA’s format', async () => {
const res = await manager.get(`/api/osha/ita/summary.csv?year=${year}`)
const lines = res.body.toString('utf8').trimEnd().split('\r\n')
expect(lines[0].charCodeAt(0)).not.toBe(0xfeff)
expect(lines[0]).toBe(
'establishment_name,ein_number,company_name,street_address,city,state,zip,naics_code,industry_description,size,establishment_type,year_filing_for,annual_average_employees,total_hours_worked,no_injuries_illnesses,total_deaths,total_dafw_cases,total_djtr_cases,total_other_cases,total_dafw_days,total_djtr_days,total_injuries,total_skin_disorders,total_respiratory_conditions,total_poisonings,total_hearing_loss,total_other_illnesses,change_reason',
)
const second = lines.find((line: string) => line.startsWith('Second Plant'))
expect(second).toBe(`Second Plant,123456789,Northstar Co.,1 Mill Rd,Dayton,OH,454021234,332119,,21,1,${year},60,120000,1,0,0,1,0,0,3,0,0,0,0,0,1,`)
})
it('exports ITA case data without names', async () => {
const res = await manager.get(`/api/osha/ita/cases.csv?year=${year}`)
const csv = res.body.toString('utf8')
expect(csv).not.toContain('Jordan Private')
expect(csv).not.toContain('First Aid Only')
const [header, ...rows] = csv.trimEnd().split('\r\n')
expect(header.split(',')).toHaveLength(24)
const row = rows.find((line: string) => line.startsWith('Second Plant'))!
expect(row).toContain(`02-03-${year}`) // mm-dd-yyyy
expect(row).toContain(',3,0,3,6,04-02-1990,06-10-2019,M,0,0,07:00,14:05,,') // outcome, days, type, dates, sex, treatment, times
})
it('produces a Form 301 for a case', async () => {
const log = (await manager.get(`/api/osha/log?siteId=${other.id}&year=${year}`)).body
const form = (await manager.get(`/api/osha/cases/${log.rows[0].caseId}/301`)).body
expect(form).toMatchObject({ establishment: { name: 'Second Plant', city: 'Dayton' }, case: { name: 'Jordan Private', privacyCase: true }, incident: { title: 'Needlestick' } })
})
})
+289
View File
@@ -0,0 +1,289 @@
import { and, asc, count, eq } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
import type { Form301 } from '../../shared/api-types.ts'
import { incidentReference } from '../../shared/format.ts'
import { assessRecordability, caseTypeCode, outcomeCode, severeInjuryReport, itaSizeCode } from '../../shared/osha.ts'
import { createOshaCaseSchema, hoursWorkedSchema, updateOshaCaseSchema } from '../../shared/schemas.ts'
import { requireRole } from '../auth/middleware.ts'
import type { SessionUser } from '../auth/sessions.ts'
import type { Db } from '../db/client.ts'
import { areas, hoursWorked, incidents, injuredPersons, sites } from '../db/schema.ts'
import { toCsv } from '../lib/csv.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
import { auditIncident, findIncident } from '../lib/incidents.ts'
import { activeSites, buildOshaYear, describeCase, establishmentOf, toOshaCase, yearRange } from '../lib/osha.ts'
const numberParam = z.object({ number: z.coerce.number().int().positive() })
const idParam = z.object({ id: z.uuid() })
const yearQuery = z.object({ year: z.coerce.number().int().min(2000).max(2100) })
type Person = typeof injuredPersons.$inferSelect
/** Days only count when the matching outcome applies; recordability is always derived, never sent. */
function derive(person: Omit<Person, 'recordable' | 'outcome'>) {
if (person.daysAway + person.daysRestricted > 180) throw fail(400, 'Stop counting once days away and restricted days reach 180 in total (1904.7(b)(3)(vii)).')
const assessment = assessRecordability(person)
return {
daysAway: person.daysAwayFromWork ? person.daysAway : 0,
daysRestricted: person.restrictedOrTransfer ? person.daysRestricted : 0,
recordable: assessment.status === 'recordable',
outcome: assessment.outcome,
}
}
async function findCase(db: Db, user: SessionUser, id: string) {
const [row] = await db
.select({ person: injuredPersons, incident: incidents })
.from(injuredPersons)
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
.where(and(eq(injuredPersons.id, id), eq(incidents.organizationId, user.organizationId)))
if (!row) throw fail(404, 'Case not found')
return row
}
const dateMDY = (isoDate: string | null) => (isoDate ? `${isoDate.slice(5, 7)}-${isoDate.slice(8, 10)}-${isoDate.slice(0, 4)}` : '')
const localTime = (date: Date) => `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
const localDateMDY = (date: Date) => `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}-${date.getFullYear()}`
const clip = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
/**
* OSHA recordkeeping. Injury and medical details are limited to safety managers and admins
* (they hold the confidential records); everyone else only sees that a person was involved.
*/
export const oshaRoutes = new Hono<AppEnv>()
.get('/incidents/:number/cases', requireRole('safety_manager'), validate('param', numberParam), async (c) => {
const db = c.get('db')
const { incident } = await findIncident(db, currentUser(c), c.req.valid('param').number)
const people = await db.select().from(injuredPersons).where(eq(injuredPersons.incidentId, incident.id)).orderBy(asc(injuredPersons.createdAt))
return c.json({ cases: people.map((person) => toOshaCase(person, incident)) })
})
.post('/incidents/:number/cases', requireRole('safety_manager'), validate('param', numberParam), validate('json', createOshaCaseSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { incident } = await findIncident(db, me, c.req.valid('param').number)
const input = c.req.valid('json')
const [{ existing }] = await db.select({ existing: count() }).from(injuredPersons).where(eq(injuredPersons.incidentId, incident.id))
const reference = incidentReference(incident.number)
const draft = {
employmentType: 'employee' as const,
caseType: 'injury' as const,
workRelated: null,
newCase: null,
died: false,
daysAwayFromWork: false,
daysAway: 0,
restrictedOrTransfer: false,
daysRestricted: 0,
medicalTreatment: false,
lossOfConsciousness: false,
significantDiagnosis: false,
specialCase: 'none' as const,
...input,
reportedToOshaAt: input.reportedToOshaAt ? new Date(input.reportedToOshaAt) : null,
}
const [person] = await db
.insert(injuredPersons)
.values({ ...draft, ...derive(draft as Person), incidentId: incident.id, caseNumber: existing ? `${reference}-${existing + 1}` : reference })
.returning()
// The timeline is visible beyond the safety team, so it carries no names or medical details.
await auditIncident(db, me, me.organizationId, incident.id, 'osha_case.add', { caseNumber: person.caseNumber, recordable: person.recordable })
if (severeInjuryReport(person) && !person.reportedToOshaAt) c.get('notifier').severeInjury(person.id)
return c.json({ case: toOshaCase(person, incident) }, 201)
})
.patch('/cases/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateOshaCaseSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { person, incident } = await findCase(db, me, c.req.valid('param').id)
const input = c.req.valid('json')
const merged = { ...person, ...input, reportedToOshaAt: input.reportedToOshaAt === undefined ? person.reportedToOshaAt : input.reportedToOshaAt ? new Date(input.reportedToOshaAt) : null }
const update = { ...merged, ...derive(merged) }
const [saved] = await db
.update(injuredPersons)
.set({ ...update, id: undefined, incidentId: undefined, createdAt: undefined, updatedAt: undefined })
.where(eq(injuredPersons.id, person.id))
.returning()
await auditIncident(db, me, me.organizationId, incident.id, 'osha_case.update', {
caseNumber: saved.caseNumber,
...(saved.recordable !== person.recordable ? { recordable: [person.recordable, saved.recordable] } : {}),
})
const becameSevere = severeInjuryReport(saved) && !severeInjuryReport(person)
if (becameSevere && !saved.reportedToOshaAt) c.get('notifier').severeInjury(saved.id)
return c.json({ case: toOshaCase(saved, incident) })
})
.delete('/cases/:id', requireRole('safety_manager'), validate('param', idParam), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { person, incident } = await findCase(db, me, c.req.valid('param').id)
await db.delete(injuredPersons).where(eq(injuredPersons.id, person.id))
await auditIncident(db, me, me.organizationId, incident.id, 'osha_case.remove', { caseNumber: person.caseNumber })
return c.body(null, 204)
})
.get('/cases/:id/301', requireRole('safety_manager'), validate('param', idParam), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { person, incident } = await findCase(db, me, c.req.valid('param').id)
const [site] = await db.select().from(sites).where(eq(sites.id, incident.siteId))
const [area] = incident.areaId ? await db.select({ name: areas.name }).from(areas).where(eq(areas.id, incident.areaId)) : []
const year = await buildOshaYear(db, me.organizationId, site.id, incident.occurredAt.getFullYear())
const form: Form301 = {
case: toOshaCase(person, incident),
establishment: { name: site.name, companyName: year.companyName, ...establishmentOf(site) },
incident: {
reference: incidentReference(incident.number),
occurredAt: incident.occurredAt.toISOString(),
title: incident.title,
description: incident.description,
site: site.name,
area: area?.name ?? null,
locationDetail: incident.locationDetail,
},
}
return c.json(form)
})
.get('/log', requireRole('safety_manager'), validate('query', yearQuery.extend({ siteId: z.uuid() })), async (c) => {
const { siteId, year } = c.req.valid('query')
return c.json(await buildOshaYear(c.get('db'), currentUser(c).organizationId, siteId, year))
})
.get('/sites/:id/hours', requireRole('safety_manager'), validate('param', idParam), validate('query', yearQuery), async (c) => {
const year = await buildOshaYear(c.get('db'), currentUser(c).organizationId, c.req.valid('param').id, c.req.valid('query').year)
return c.json({ months: year.months })
})
.put('/sites/:id/hours', requireRole('safety_manager'), validate('param', idParam), validate('json', hoursWorkedSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const siteId = c.req.valid('param').id
const { year, months } = c.req.valid('json')
await buildOshaYear(db, me.organizationId, siteId, year) // 404 unless the site is ours
for (const { month, hours, averageEmployees } of months) {
const monthDate = `${year}-${String(month).padStart(2, '0')}-01`
if (hours === null && averageEmployees === null) {
await db.delete(hoursWorked).where(and(eq(hoursWorked.siteId, siteId), eq(hoursWorked.month, monthDate)))
continue
}
await db
.insert(hoursWorked)
.values({ siteId, month: monthDate, hours: String(hours ?? 0), averageEmployees })
.onConflictDoUpdate({ target: [hoursWorked.siteId, hoursWorked.month], set: { hours: String(hours ?? 0), averageEmployees } })
}
const updated = await buildOshaYear(db, me.organizationId, siteId, year)
return c.json({ months: updated.months })
})
// OSHA Injury Tracking Application uploads. Formats follow OSHA's published CSV specifications.
.get('/ita/summary.csv', requireRole('safety_manager'), validate('query', yearQuery), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { year } = c.req.valid('query')
const rows = []
for (const site of await activeSites(db, me.organizationId)) {
const y = await buildOshaYear(db, me.organizationId, site.id, year)
const t = y.totals
rows.push([
site.name,
site.ein,
y.companyName,
site.streetAddress,
site.city,
site.state,
site.zip,
site.naicsCode,
site.industryDescription,
itaSizeCode(y.maxEmployees),
site.establishmentType,
year,
y.annualAverageEmployees,
y.totalHoursWorked,
y.rows.length ? 1 : 2,
t.deaths,
t.daysAwayCases,
t.restrictedCases,
t.otherCases,
t.daysAway,
t.daysRestricted,
t.byType.injury,
t.byType.skin_disorder,
t.byType.respiratory,
t.byType.poisoning,
t.byType.hearing_loss,
t.byType.other_illness,
'',
])
}
const csv = toCsv(
[
'establishment_name', 'ein_number', 'company_name', 'street_address', 'city', 'state', 'zip', 'naics_code', 'industry_description', 'size',
'establishment_type', 'year_filing_for', 'annual_average_employees', 'total_hours_worked', 'no_injuries_illnesses', 'total_deaths',
'total_dafw_cases', 'total_djtr_cases', 'total_other_cases', 'total_dafw_days', 'total_djtr_days', 'total_injuries', 'total_skin_disorders',
'total_respiratory_conditions', 'total_poisonings', 'total_hearing_loss', 'total_other_illnesses', 'change_reason',
],
rows,
{ bom: false },
)
return c.body(csv, 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="osha-ita-300a-${year}.csv"` })
})
.get('/ita/cases.csv', requireRole('safety_manager'), validate('query', yearQuery), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { year } = c.req.valid('query')
const [from, to] = yearRange(year)
const rows = []
for (const site of await activeSites(db, me.organizationId)) {
const y = await buildOshaYear(db, me.organizationId, site.id, year)
for (const row of y.rows) {
const [{ person, incident }] = await db
.select({ person: injuredPersons, incident: incidents })
.from(injuredPersons)
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
.where(eq(injuredPersons.id, row.caseId))
if (incident.occurredAt < from || incident.occurredAt >= to) continue
// No names or other personal identifiers: OSHA publishes this data.
rows.push([
site.name,
year,
row.caseNumber,
clip(row.jobTitle, 255),
localDateMDY(incident.occurredAt),
clip(row.location, 255),
clip(describeCase(person), 255),
outcomeCode[row.outcome],
row.daysAway,
row.daysRestricted,
caseTypeCode[row.caseType],
dateMDY(person.dateOfBirth),
dateMDY(person.dateOfHire),
person.sex ?? '',
person.treatedInEmergencyRoom ? 1 : 0,
person.inpatientHospitalization ? 1 : 0,
person.timeStartedWork ?? '',
localTime(incident.occurredAt),
'',
person.activityBefore ?? '',
person.whatHappened ?? incident.description ?? '',
person.natureOfInjury ?? '',
person.objectSubstance ?? '',
dateMDY(person.dateOfDeath),
])
}
}
const csv = toCsv(
[
'establishment_name', 'year_of_filing', 'case_number', 'job_title', 'date_of_incident', 'incident_location', 'incident_description',
'incident_outcome', 'dafw_num_away', 'djtr_num_tr', 'type_of_incident', 'date_of_birth', 'date_of_hire', 'sex', 'treatment_facility_type',
'treatment_in_patient', 'time_started_work', 'time_of_incident', 'time_unknown', 'nar_before_incident', 'nar_what_happened',
'nar_injury_illness', 'nar_object_substance', 'date_of_death',
],
rows,
{ bom: false },
)
return c.body(csv, 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="osha-ita-cases-${year}.csv"` })
})
+5 -3
View File
@@ -35,9 +35,11 @@ describe('stats', () => {
{ siteId: expect.any(String), name: 'South', count: 0 }, { siteId: expect.any(String), name: 'South', count: 0 },
]) ])
const analytics = (await supervisor.get('/api/stats/analytics?days=30')).body const to = new Date().toISOString().slice(0, 10)
expect(analytics).toMatchObject({ periodDays: 30, total: 3, topRootCause: { category: 'equipment', count: 1, share: 1 } }) const from = new Date(Date.now() - 29 * 86_400_000).toISOString().slice(0, 10)
expect(analytics.byType).toEqual(expect.arrayContaining([{ type: 'hazard', count: 2 }, { type: 'injury', count: 1 }])) const analytics = (await supervisor.get(`/api/stats/analytics?from=${from}&to=${to}`)).body
expect(analytics.kpis.reports.value).toBe(3)
expect(analytics.byRootCause).toEqual([{ category: 'equipment', count: 1 }])
}) })
it("scopes a reporter's numbers to what they can see", async () => { it("scopes a reporter's numbers to what they can see", async () => {
+11 -49
View File
@@ -1,12 +1,13 @@
import { and, asc, count, desc, eq, gte, inArray, isNotNull, isNull, lt, ne, sql } from 'drizzle-orm' import { and, asc, count, desc, eq, gte, inArray, isNotNull, isNull, lt, ne, sql } from 'drizzle-orm'
import { Hono } from 'hono' import { Hono } from 'hono'
import { z } from 'zod' import type { OverviewStats } from '../../shared/api-types.ts'
import type { AnalyticsStats, OverviewStats } from '../../shared/api-types.ts'
import { incidentStatuses, type IncidentStatus } from '../../shared/enums.ts' import { incidentStatuses, type IncidentStatus } from '../../shared/enums.ts'
import { requireAuth } from '../auth/middleware.ts' import { requireAuth } from '../auth/middleware.ts'
import { correctiveActions, incidents, investigations, rootCauses, sites } from '../db/schema.ts' import { correctiveActions, incidents, sites } from '../db/schema.ts'
import { addDays, localDate } from '../lib/dates.ts' import { addDays, localDate } from '../lib/dates.ts'
import { currentUser, validate, type AppEnv } from '../lib/http.ts' import { computeAnalytics } from '../lib/analytics.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
import { analyticsQuery } from '../../shared/schemas.ts'
import { visibleTo } from '../lib/incidents.ts' import { visibleTo } from '../lib/incidents.ts'
const DAY_MS = 86_400_000 const DAY_MS = 86_400_000
@@ -97,49 +98,10 @@ export const statsRoutes = new Hono<AppEnv>()
return c.json(stats) return c.json(stats)
}) })
.get('/analytics', requireAuth, validate('query', z.object({ days: z.coerce.number().int().min(7).max(730).default(90) })), async (c) => { .get('/analytics', requireAuth, validate('query', analyticsQuery), async (c) => {
const me = currentUser(c) const filters = c.req.valid('query')
const db = c.get('db') if (filters.from > filters.to) throw fail(400, 'The start date must be before the end date')
const { days } = c.req.valid('query') const days = (Date.parse(filters.to) - Date.parse(filters.from)) / 86_400_000
const since = new Date(Date.now() - days * DAY_MS) if (days > 3 * 366) throw fail(400, 'Choose a range of up to three years')
const inPeriod = and(visibleTo(db, me), gte(incidents.occurredAt, since)) return c.json(await computeAnalytics(c.get('db'), currentUser(c), filters))
const byType = await db.select({ type: incidents.type, count: count() }).from(incidents).where(inPeriod).groupBy(incidents.type).orderBy(desc(count()))
const bySeverity = await db.select({ severity: incidents.severity, count: count() }).from(incidents).where(inPeriod).groupBy(incidents.severity)
const causes = await db
.select({ category: rootCauses.category, count: count() })
.from(rootCauses)
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
.innerJoin(incidents, eq(incidents.id, investigations.incidentId))
.where(inPeriod)
.groupBy(rootCauses.category)
.orderBy(desc(count()))
const totalCauses = causes.reduce((sum, row) => sum + row.count, 0)
const monthExpr = sql<string>`to_char(${correctiveActions.completedAt}, 'YYYY-MM')`
const onTimeExpr = sql<boolean>`(${correctiveActions.completedAt})::date <= ${correctiveActions.dueDate}`
const completed = await db
.select({ month: monthExpr, onTime: onTimeExpr, count: count() })
.from(correctiveActions)
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
.where(and(visibleTo(db, me), isNotNull(correctiveActions.completedAt), isNotNull(correctiveActions.dueDate), gte(correctiveActions.completedAt, since)))
.groupBy(monthExpr, onTimeExpr)
const onTime = completed.filter((row) => row.onTime).reduce((sum, row) => sum + row.count, 0)
const allCompleted = completed.reduce((sum, row) => sum + row.count, 0)
const months = [...new Set(completed.map((row) => row.month))].sort()
const stats: AnalyticsStats = {
periodDays: days,
total: byType.reduce((sum, row) => sum + row.count, 0),
byType,
bySeverity,
topRootCause: causes[0] ? { category: causes[0].category, count: causes[0].count, share: causes[0].count / totalCauses } : null,
onTimeActionRate: allCompleted ? onTime / allCompleted : null,
monthlyCompletedActions: months.map((month) => ({
month,
onTime: completed.filter((row) => row.month === month && row.onTime).reduce((sum, row) => sum + row.count, 0),
late: completed.filter((row) => row.month === month && !row.onTime).reduce((sum, row) => sum + row.count, 0),
})),
}
return c.json(stats)
}) })
+56
View File
@@ -0,0 +1,56 @@
import { and, asc, eq, or } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
import type { SavedView } from '../../shared/api-types.ts'
import { hasRole } from '../../shared/enums.ts'
import { savedViewSchema } from '../../shared/schemas.ts'
import { requireAuth } from '../auth/middleware.ts'
import { savedViews } from '../db/schema.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
const toView = (row: typeof savedViews.$inferSelect, userId: string): SavedView => ({
id: row.id,
page: row.page as SavedView['page'],
name: row.name,
filters: row.filters,
shared: row.shared,
mine: row.userId === userId,
})
/** Saved filter sets. Supervisors and above can share views with the whole organization. */
export const viewRoutes = new Hono<AppEnv>()
.get('/', requireAuth, validate('query', z.object({ page: z.enum(['analytics', 'incidents']) })), async (c) => {
const me = currentUser(c)
const rows = await c
.get('db')
.select()
.from(savedViews)
.where(and(eq(savedViews.organizationId, me.organizationId), eq(savedViews.page, c.req.valid('query').page), or(eq(savedViews.userId, me.id), eq(savedViews.shared, true))))
.orderBy(asc(savedViews.name))
return c.json({ views: rows.map((row) => toView(row, me.id)) })
})
.post('/', requireAuth, validate('json', savedViewSchema), async (c) => {
const me = currentUser(c)
const input = c.req.valid('json')
if (input.shared && !hasRole(me.role, 'supervisor')) throw fail(403, 'Only supervisors and above can share views')
const [row] = await c
.get('db')
.insert(savedViews)
.values({ ...input, organizationId: me.organizationId, userId: me.id })
.returning()
return c.json({ view: toView(row, me.id) }, 201)
})
.delete('/:id', requireAuth, validate('param', z.object({ id: z.uuid() })), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const [row] = await db
.select()
.from(savedViews)
.where(and(eq(savedViews.id, c.req.valid('param').id), eq(savedViews.organizationId, me.organizationId)))
if (!row || (!row.shared && row.userId !== me.id)) throw fail(404, 'View not found')
if (row.userId !== me.id && me.role !== 'admin') throw fail(403, 'Only the owner or an admin can delete this view')
await db.delete(savedViews).where(eq(savedViews.id, row.id))
return c.body(null, 204)
})
+156 -11
View File
@@ -1,9 +1,14 @@
// Response shapes returned by the API. Dates are ISO 8601 strings. // Response shapes returned by the API. Dates are ISO 8601 strings.
import type { SummaryTotals } from './osha.ts'
import type { import type {
actionKinds, actionKinds,
actionStatuses, actionStatuses,
AreaKind, AreaKind,
employmentTypes,
IncidentStatus, IncidentStatus,
OshaCaseType,
OshaOutcome,
OshaSpecialCase,
IncidentType, IncidentType,
rcaMethods, rcaMethods,
Role, Role,
@@ -45,7 +50,18 @@ export interface Area {
archivedAt: string | null archivedAt: string | null
} }
export interface Site { export interface Establishment {
streetAddress: string | null
city: string | null
state: string | null
zip: string | null
naicsCode: string | null
industryDescription: string | null
ein: string | null
establishmentType: number
}
export interface Site extends Partial<Establishment> {
id: string id: string
name: string name: string
address: string | null address: string | null
@@ -153,8 +169,10 @@ export interface IncidentDetail extends Incident {
actions: CorrectiveAction[] actions: CorrectiveAction[]
attachments: Attachment[] attachments: Attachment[]
transitions: TransitionOption[] transitions: TransitionOption[]
/** Number of injured or ill people recorded; details are only for safety managers. */
injuredCount: number
/** What the signed-in user may do on this incident. */ /** What the signed-in user may do on this incident. */
can: { edit: boolean; manageInvestigation: boolean; manageActions: boolean } can: { edit: boolean; manageInvestigation: boolean; manageActions: boolean; manageOsha: boolean }
} }
export interface Comment { export interface Comment {
@@ -193,13 +211,140 @@ export interface OverviewStats {
bySite: { siteId: string; name: string; count: number }[] bySite: { siteId: string; name: string; count: number }[]
} }
export interface AnalyticsStats { /** A number for the selected period and the same-length period before it. */
periodDays: number export interface Kpi {
total: number value: number | null
byType: { type: IncidentType; count: number }[] previous: number | null
bySeverity: { severity: Severity; count: number }[] }
topRootCause: { category: RootCauseCategory; count: number; share: number } | null
/** Share of completed actions finished on or before their due date. */ /** Incident types grouped for trend charts. */
onTimeActionRate: number | null export type TypeGroup = 'hazards' | 'injuries' | 'damage'
monthlyCompletedActions: { month: string; onTime: number; late: number }[]
export interface AnalyticsStats {
from: string
to: string
previousFrom: string
bucket: 'week' | 'month'
kpis: {
reports: Kpi
/** Leading: hazards and near misses reported. */
leadingReports: Kpi
injuries: Kpi
/** Leading: hazards and near misses per injury or illness. */
nearMissRatio: Kpi
/** Leading: share of reports moved out of "reported" within 24 hours. */
triagedWithin24h: Kpi
/** Leading: share of actions completed by their due date. */
actionsOnTime: Kpi
averageDaysToClose: Kpi
/** Lagging: OSHA-recordable cases (supervisors and above). */
recordables: Kpi | null
overdueActions: number
}
trend: ({ start: string } & Record<TypeGroup, number>)[]
byLocation: { id: string | null; name: string; count: number }[]
byRootCause: { category: RootCauseCategory; count: number }[]
/** heatmap[weekday][hour], weekday 0 = Monday, in the server's time zone. */
heatmap: number[][]
actionsByMonth: { month: string; onTime: number; late: number }[]
/** Rolling 12-month OSHA rates at each month end; null for reporters. */
rates: { month: string; trir: number | null; dart: number | null }[] | null
yearly: { year: number; recordables: number; dartCases: number; hours: number; trir: number | null; dart: number | null }[] | null
}
export interface SavedView {
id: string
page: 'analytics' | 'incidents'
name: string
filters: Record<string, string>
shared: boolean
mine: boolean
}
// ---- OSHA recordkeeping ----
export interface OshaCase {
id: string
incident: { id: string; number: number; reference: string; title: string; occurredAt: string }
caseNumber: string
name: string
jobTitle: string | null
employmentType: (typeof employmentTypes)[number]
bodyPart: string | null
natureOfInjury: string | null
caseType: OshaCaseType
workRelated: boolean | null
newCase: boolean | null
died: boolean
dateOfDeath: string | null
daysAwayFromWork: boolean
daysAway: number
restrictedOrTransfer: boolean
daysRestricted: number
medicalTreatment: boolean
lossOfConsciousness: boolean
significantDiagnosis: boolean
specialCase: OshaSpecialCase
privacyCase: boolean
recordable: boolean
outcome: OshaOutcome | null
inpatientHospitalization: boolean
amputation: boolean
lossOfEye: boolean
reportedToOshaAt: string | null
/** Set when 1904.39 requires a direct report to OSHA. */
severeReport: { withinHours: 8 | 24; reasons: string[]; dueBy: string } | null
employeeAddress: string | null
dateOfBirth: string | null
dateOfHire: string | null
sex: 'M' | 'F' | null
physicianName: string | null
treatmentFacility: string | null
treatedInEmergencyRoom: boolean
timeStartedWork: string | null
activityBefore: string | null
whatHappened: string | null
objectSubstance: string | null
/** Form 301 / ITA fields still empty for a recordable case. */
missingFields: string[]
updatedAt: string
}
/** One line of the Form 300 Log. */
export interface OshaLogRow {
caseId: string
caseNumber: string
incidentNumber: number
/** "Privacy case" for privacy concern cases. */
employeeName: string
jobTitle: string
dateOfInjury: string
location: string
description: string
outcome: OshaOutcome
daysAway: number
daysRestricted: number
caseType: OshaCaseType
missingFields: string[]
}
export interface OshaYear {
site: { id: string; name: string } & Establishment
companyName: string
year: number
rows: OshaLogRow[]
totals: SummaryTotals
months: { month: number; hours: number | null; averageEmployees: number | null }[]
totalHoursWorked: number
annualAverageEmployees: number
maxEmployees: number
rates: { trir: number | null; dart: number | null; ltir: number | null }
checks: { errors: string[]; warnings: string[] }
missingEstablishmentFields: string[]
}
export interface Form301 {
case: OshaCase
establishment: { name: string; companyName: string } & Establishment
incident: { reference: string; occurredAt: string; title: string; description: string | null; site: string; area: string | null; locationDetail: string | null }
} }
+12
View File
@@ -33,3 +33,15 @@ export const rootCauseCategories = ['people', 'process', 'equipment', 'materials
export const actionKinds = ['corrective', 'preventive'] as const export const actionKinds = ['corrective', 'preventive'] as const
export const actionStatuses = ['open', 'in_progress', 'done', 'verified', 'cancelled'] as const export const actionStatuses = ['open', 'in_progress', 'done', 'verified', 'cancelled'] as const
/** Form 300 column M: injury or illness type. */
export const oshaCaseTypes = ['injury', 'skin_disorder', 'respiratory', 'poisoning', 'hearing_loss', 'other_illness'] as const
export type OshaCaseType = (typeof oshaCaseTypes)[number]
/** Form 300 columns G–J, most serious first. */
export const oshaOutcomes = ['death', 'days_away', 'restricted', 'other'] as const
export type OshaOutcome = (typeof oshaOutcomes)[number]
/** Cases recordable under 1904.8–1904.11 regardless of the general criteria. */
export const oshaSpecialCases = ['none', 'needlestick', 'medical_removal', 'hearing_loss', 'tuberculosis'] as const
export type OshaSpecialCase = (typeof oshaSpecialCases)[number]
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import { assessRecordability, capDays, FIRST_AID, incidenceRate, itaSizeCode, severeInjuryReport, summaryChecks, type RecordabilityInput, type SummaryTotals } from './osha.ts'
const base: RecordabilityInput = {
employmentType: 'employee',
workRelated: true,
newCase: true,
died: false,
daysAwayFromWork: false,
restrictedOrTransfer: false,
medicalTreatment: false,
lossOfConsciousness: false,
significantDiagnosis: false,
specialCase: 'none',
}
const assess = (overrides: Partial<RecordabilityInput>) => assessRecordability({ ...base, ...overrides })
describe('assessRecordability', () => {
it('first aid only is not recordable', () => {
expect(assess({})).toMatchObject({ status: 'not_recordable', outcome: null })
})
it('waits for the work-related and new-case answers', () => {
expect(assess({ workRelated: null, medicalTreatment: true }).status).toBe('incomplete')
expect(assess({ newCase: null, medicalTreatment: true }).status).toBe('incomplete')
})
it('excludes non-work-related cases and recurrences', () => {
expect(assess({ workRelated: false, died: true }).status).toBe('not_recordable')
expect(assess({ newCase: false, medicalTreatment: true }).reasons[0]).toMatch(/1904\.6/)
})
it('keeps contractors and visitors off your log', () => {
expect(assess({ employmentType: 'contractor', daysAwayFromWork: true }).reasons[0]).toMatch(/1904\.31/)
expect(assess({ employmentType: 'visitor', died: true }).status).toBe('not_recordable')
expect(assess({ employmentType: 'temporary', medicalTreatment: true }).status).toBe('recordable')
})
it('classifies by the most serious outcome', () => {
expect(assess({ medicalTreatment: true }).outcome).toBe('other')
expect(assess({ medicalTreatment: true, restrictedOrTransfer: true }).outcome).toBe('restricted')
expect(assess({ restrictedOrTransfer: true, daysAwayFromWork: true }).outcome).toBe('days_away')
expect(assess({ daysAwayFromWork: true, died: true }).outcome).toBe('death')
})
it('records each general criterion on its own', () => {
for (const key of ['lossOfConsciousness', 'significantDiagnosis', 'medicalTreatment'] as const) {
expect(assess({ [key]: true })).toMatchObject({ status: 'recordable', outcome: 'other' })
}
})
it('records special cases even without general criteria', () => {
for (const specialCase of ['needlestick', 'medical_removal', 'hearing_loss', 'tuberculosis'] as const) {
expect(assess({ specialCase }).status).toBe('recordable')
}
expect(assess({ specialCase: 'needlestick' }).reasons[0]).toMatch(/1904\.8/)
})
it('lists every reason that applies', () => {
expect(assess({ medicalTreatment: true, lossOfConsciousness: true }).reasons).toHaveLength(2)
})
})
describe('severeInjuryReport', () => {
it('requires fatalities within 8 hours and other severe injuries within 24', () => {
expect(severeInjuryReport({ died: true, inpatientHospitalization: true, amputation: false, lossOfEye: false })).toEqual({ withinHours: 8, reasons: ['work-related fatality'] })
expect(severeInjuryReport({ died: false, inpatientHospitalization: false, amputation: true, lossOfEye: true })).toEqual({ withinHours: 24, reasons: ['amputation', 'loss of an eye'] })
expect(severeInjuryReport({ died: false, inpatientHospitalization: false, amputation: false, lossOfEye: false })).toBeNull()
})
})
describe('capDays', () => {
it('stops counting at 180 days in total', () => {
expect(capDays(10, 5)).toEqual({ daysAway: 10, daysRestricted: 5 })
expect(capDays(150, 60)).toEqual({ daysAway: 150, daysRestricted: 30 })
expect(capDays(200, 10)).toEqual({ daysAway: 180, daysRestricted: 0 })
})
})
describe('rates and codes', () => {
it('computes incidence rates per 200,000 hours', () => {
expect(incidenceRate(3, 200_000)).toBe(3)
expect(incidenceRate(2, 150_000)).toBe(2.67)
expect(incidenceRate(1, 0)).toBeNull()
})
it('maps headcount to ITA size codes', () => {
expect([19, 20, 99, 100, 249, 250].map(itaSizeCode)).toEqual([1, 21, 21, 22, 22, 3])
})
it('has the complete 14-item first-aid list', () => {
expect(FIRST_AID).toHaveLength(14)
})
})
describe('summaryChecks (OSHA ITA validations)', () => {
const totals = (overrides: Partial<SummaryTotals> = {}): SummaryTotals => ({
deaths: 0,
daysAwayCases: 1,
restrictedCases: 1,
otherCases: 1,
daysAway: 5,
daysRestricted: 3,
byType: { injury: 3, skin_disorder: 0, respiratory: 0, poisoning: 0, hearing_loss: 0, other_illness: 0 },
...overrides,
})
it('passes a consistent summary', () => {
expect(summaryChecks(totals(), 50, 100_000)).toEqual({ errors: [], warnings: [] })
})
it('catches the cross-field rules OSHA enforces', () => {
expect(summaryChecks(totals({ byType: { ...totals().byType, injury: 2 } }), 50, 100_000).errors[0]).toMatch(/add up to 2/)
expect(summaryChecks(totals({ daysAway: 0 }), 50, 100_000).errors).toContain('There are days-away cases (H) but no days away (K).')
expect(summaryChecks(totals({ restrictedCases: 0, daysAwayCases: 0, otherCases: 3, daysAway: 0 }), 50, 100_000).errors).toContain(
'There are restricted days (L) but no days-away or restriction cases (H or I).',
)
expect(summaryChecks(totals(), 0, 0).errors).toHaveLength(2)
expect(summaryChecks(totals(), 10, 100_000).errors[0]).toMatch(/more than the hours in a year/)
expect(summaryChecks(totals(), 50, 20_000).warnings[0]).toMatch(/Fewer than 500/)
})
})
+173
View File
@@ -0,0 +1,173 @@
// OSHA recordkeeping rules (29 CFR Part 1904), shared by the API and the web app.
// Section references are to 29 CFR 1904. This helps apply the rules consistently; it is not legal advice.
import type { OshaCaseType, OshaOutcome, OshaSpecialCase } from './enums.ts'
export type EmploymentType = 'employee' | 'temporary' | 'contractor' | 'visitor'
export interface RecordabilityInput {
employmentType: EmploymentType
/** null while not yet answered */
workRelated: boolean | null
newCase: boolean | null
died: boolean
daysAwayFromWork: boolean
restrictedOrTransfer: boolean
medicalTreatment: boolean
lossOfConsciousness: boolean
significantDiagnosis: boolean
specialCase: OshaSpecialCase
}
export interface Recordability {
status: 'recordable' | 'not_recordable' | 'incomplete'
/** Form 300 column G–J; null unless recordable. */
outcome: OshaOutcome | null
/** Plain-language reasons, with the section that applies. */
reasons: string[]
}
const specialReasons: Record<Exclude<OshaSpecialCase, 'none'>, string> = {
needlestick: 'Needlestick or sharps injury contaminated with another person’s blood or other potentially infectious material (1904.8)',
medical_removal: 'Medical removal under an OSHA standard (1904.9)',
hearing_loss: 'Standard threshold shift in hearing with overall hearing level of 25 dB or more (1904.10)',
tuberculosis: 'Tuberculosis infection after workplace exposure to active TB (1904.11)',
}
export function assessRecordability(input: RecordabilityInput): Recordability {
if (input.employmentType === 'contractor' || input.employmentType === 'visitor') {
return {
status: 'not_recordable',
outcome: null,
reasons: ['Only your employees, and temporary or contract workers you supervise day to day, go on your log (1904.31). Record supervised contractors as “temporary”.'],
}
}
if (input.workRelated === null || input.newCase === null) return { status: 'incomplete', outcome: null, reasons: ['Answer whether the case is work-related and new.'] }
if (!input.workRelated) return { status: 'not_recordable', outcome: null, reasons: ['Not work-related (1904.5).'] }
if (!input.newCase) return { status: 'not_recordable', outcome: null, reasons: ['Not a new case: update the original case on the log instead (1904.6).'] }
const reasons: string[] = []
if (input.died) reasons.push('Death (1904.7(b)(2))')
if (input.daysAwayFromWork) reasons.push('Days away from work (1904.7(b)(3))')
if (input.restrictedOrTransfer) reasons.push('Restricted work or job transfer (1904.7(b)(4))')
if (input.medicalTreatment) reasons.push('Medical treatment beyond first aid (1904.7(b)(5))')
if (input.lossOfConsciousness) reasons.push('Loss of consciousness (1904.7(b)(6))')
if (input.significantDiagnosis) reasons.push('Significant injury or illness diagnosed by a physician or other licensed health care professional (1904.7(b)(7))')
if (input.specialCase !== 'none') reasons.push(specialReasons[input.specialCase])
if (reasons.length === 0) {
return { status: 'not_recordable', outcome: null, reasons: ['No recording criteria apply: first aid only, with no time lost, restriction or other criteria (1904.7).'] }
}
// Classified by the most serious outcome (Form 300 columns G–J)
const outcome: OshaOutcome = input.died ? 'death' : input.daysAwayFromWork ? 'days_away' : input.restrictedOrTransfer ? 'restricted' : 'other'
return { status: 'recordable', outcome, reasons }
}
export interface SevereInjuryInput {
died: boolean
inpatientHospitalization: boolean
amputation: boolean
lossOfEye: boolean
}
/** Whether 1904.39 requires reporting directly to OSHA, and how fast. */
export function severeInjuryReport(input: SevereInjuryInput): { withinHours: 8 | 24; reasons: string[] } | null {
if (input.died) return { withinHours: 8, reasons: ['work-related fatality'] }
const reasons = [
input.inpatientHospitalization && 'in-patient hospitalization',
input.amputation && 'amputation',
input.lossOfEye && 'loss of an eye',
].filter((reason): reason is string => !!reason)
return reasons.length ? { withinHours: 24, reasons } : null
}
export const OSHA_REPORTING_CONTACT = 'Call 1-800-321-OSHA (6742), report online at osha.gov/report, or contact your nearest OSHA area office.'
/** 1904.7(b)(3)(vii): you may stop counting once days away plus restricted days reach 180. */
export const DAY_COUNT_CAP = 180
export function capDays(daysAway: number, daysRestricted: number): { daysAway: number; daysRestricted: number } {
const away = Math.min(daysAway, DAY_COUNT_CAP)
return { daysAway: away, daysRestricted: Math.min(daysRestricted, DAY_COUNT_CAP - away) }
}
/** The complete first-aid list of 1904.7(b)(5)(ii). Anything else is medical treatment. */
export const FIRST_AID = [
'Non-prescription medication at non-prescription strength',
'Tetanus immunizations',
'Cleaning, flushing or soaking wounds on the surface of the skin',
'Wound coverings such as bandages, Band-Aids™, gauze pads, butterfly bandages or Steri-Strips™ (not sutures, staples or other wound closing devices)',
'Hot or cold therapy',
'Non-rigid means of support, such as elastic bandages, wraps or non-rigid back belts',
'Temporary immobilization devices while transporting an accident victim (splints, slings, neck collars, back boards)',
'Drilling a fingernail or toenail to relieve pressure, or draining fluid from a blister',
'Eye patches',
'Removing foreign bodies from the eye using only irrigation or a cotton swab',
'Removing splinters or foreign material from areas other than the eye by irrigation, tweezers, cotton swabs or other simple means',
'Finger guards',
'Massages',
'Drinking fluids for relief of heat stress',
] as const
/** 1904.29(b)(7): the employee's name must not go on the 300 Log. */
export const PRIVACY_CASES = [
'Injury or illness to an intimate body part or the reproductive system',
'Injury or illness resulting from a sexual assault',
'Mental illness',
'HIV infection, hepatitis or tuberculosis',
'Needlestick or sharps injury contaminated with another person’s blood or other potentially infectious material',
'Any other illness, if the employee asks for their name to be left off',
] as const
/** Injuries and illnesses per 100 full-time workers a year: cases × 200,000 ÷ hours worked. */
export function incidenceRate(cases: number, hoursWorked: number): number | null {
if (!(hoursWorked > 0)) return null
return Math.round(((cases * 200_000) / hoursWorked) * 100) / 100
}
/** ITA `size` code from the most employees at any point in the year. */
export function itaSizeCode(maxEmployees: number): 1 | 21 | 22 | 3 {
if (maxEmployees < 20) return 1
if (maxEmployees < 100) return 21
if (maxEmployees < 250) return 22
return 3
}
export const outcomeCode: Record<OshaOutcome, 1 | 2 | 3 | 4> = { death: 1, days_away: 2, restricted: 3, other: 4 }
export const caseTypeCode: Record<OshaCaseType, 1 | 2 | 3 | 4 | 5 | 6> = { injury: 1, skin_disorder: 2, respiratory: 3, poisoning: 4, hearing_loss: 5, other_illness: 6 }
/** Form 300A totals (fields G–M). */
export interface SummaryTotals {
deaths: number
daysAwayCases: number
restrictedCases: number
otherCases: number
daysAway: number
daysRestricted: number
byType: Record<OshaCaseType, number>
}
/**
* The checks OSHA's Injury Tracking Application applies to a 300A upload, so problems are caught
* before submitting. Errors would be rejected; warnings are "should" rules.
*/
export function summaryChecks(totals: SummaryTotals, annualAverageEmployees: number, totalHoursWorked: number) {
const errors: string[] = []
const warnings: string[] = []
const cases = totals.deaths + totals.daysAwayCases + totals.restrictedCases + totals.otherCases
const types = Object.values(totals.byType).reduce((sum, n) => sum + n, 0)
if (types !== cases) errors.push(`Injury and illness types (M1–M6) add up to ${types} but there are ${cases} cases (G–J).`)
if (totals.daysAwayCases > 0 && totals.daysAway === 0) errors.push('There are days-away cases (H) but no days away (K).')
if (totals.daysAway > 0 && totals.daysAwayCases === 0) errors.push('There are days away (K) but no days-away cases (H).')
if (totals.daysAway < totals.daysAwayCases) errors.push('Days away (K) must be at least the number of days-away cases (H).')
if (totals.restrictedCases > 0 && totals.daysRestricted === 0) errors.push('There are restriction/transfer cases (I) but no restricted days (L).')
if (totals.daysRestricted > 0 && totals.daysAwayCases + totals.restrictedCases === 0) errors.push('There are restricted days (L) but no days-away or restriction cases (H or I).')
if (!(annualAverageEmployees > 0)) errors.push('Enter the average number of employees for each month.')
if (!(totalHoursWorked > 0)) errors.push('Enter hours worked for each month.')
if (annualAverageEmployees > 0 && totalHoursWorked > 0) {
const perEmployee = totalHoursWorked / annualAverageEmployees
if (perEmployee >= 8760) errors.push('Hours worked per employee is more than the hours in a year; check hours and headcount.')
if (perEmployee <= 500) warnings.push('Fewer than 500 hours worked per employee; check hours and headcount.')
}
if (annualAverageEmployees > 0 && cases >= annualAverageEmployees) warnings.push('More cases than the average number of employees; check the headcount.')
return { errors, warnings }
}
+105 -2
View File
@@ -1,6 +1,6 @@
// Request validation schemas, shared so the web app can validate forms with the same rules as the API. // Request validation schemas, shared so the web app can validate forms with the same rules as the API.
import { z } from 'zod' import { z } from 'zod'
import { actionKinds, actionStatuses, areaKinds, incidentStatuses, incidentTypes, rcaMethods, roles, rootCauseCategories, severities } from './enums.ts' import { actionKinds, actionStatuses, areaKinds, employmentTypes, incidentStatuses, incidentTypes, oshaCaseTypes, oshaSpecialCases, rcaMethods, roles, rootCauseCategories, severities } from './enums.ts'
const email = z.string().trim().toLowerCase().max(254).pipe(z.email()) const email = z.string().trim().toLowerCase().max(254).pipe(z.email())
const password = z.string().min(10, 'Use at least 10 characters').max(256) const password = z.string().min(10, 'Use at least 10 characters').max(256)
@@ -20,10 +20,97 @@ export const createUserSchema = z.object({ name, email, role: z.enum(roles), pas
/** `password` lets an admin set a new password when email isn't available. */ /** `password` lets an admin set a new password when email isn't available. */
export const updateUserSchema = z.object({ name: name.optional(), role: z.enum(roles).optional(), active: z.boolean().optional(), password: password.optional() }) export const updateUserSchema = z.object({ name: name.optional(), role: z.enum(roles).optional(), active: z.boolean().optional(), password: password.optional() })
export const createSiteSchema = z.object({ name, address: optionalText(500), timezone: z.string().trim().max(64).optional() }) const digits = (count: number[], label: string) =>
z
.string()
.trim()
.transform((value) => value.replace(/[\s-]/g, ''))
.pipe(z.string().refine((value) => value === '' || (count.includes(value.length) && /^\d+$/.test(value)), `${label} must be ${count.join(' or ')} digits`))
.transform((value) => value || null)
.nullish()
/** OSHA establishment details, as required by Form 300A and the ITA upload. */
const establishmentFields = {
streetAddress: optionalText(100),
city: optionalText(100),
state: z
.string()
.trim()
.toUpperCase()
.refine((value) => value === '' || /^[A-Z]{2}$/.test(value), 'Use the two-letter state code')
.transform((value) => value || null)
.nullish(),
zip: digits([5, 9], 'ZIP code'),
naicsCode: digits([6], 'NAICS code'),
industryDescription: optionalText(300),
ein: digits([9], 'EIN'),
establishmentType: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(),
}
export const createSiteSchema = z.object({ name, address: optionalText(500), timezone: z.string().trim().max(64).optional(), ...establishmentFields })
export const updateSiteSchema = createSiteSchema.partial().extend({ archived: z.boolean().optional() }) export const updateSiteSchema = createSiteSchema.partial().extend({ archived: z.boolean().optional() })
export const hoursWorkedSchema = z.object({
year: z.number().int().min(2000).max(2100),
months: z
.array(
z.object({
month: z.number().int().min(1).max(12),
hours: z.number().min(0).max(100_000_000).nullable(),
averageEmployees: z.number().int().min(0).max(1_000_000).nullable(),
}),
)
.max(12),
})
const dayCount = z.number().int().min(0).max(180)
const isoDateOnly = z.iso.date()
/** An injured or ill person on an incident, with the OSHA recordability answers and Form 301 details. */
const oshaCaseFields = {
name,
jobTitle: optionalText(255),
employmentType: z.enum(employmentTypes),
bodyPart: optionalText(255),
natureOfInjury: optionalText(500),
caseType: z.enum(oshaCaseTypes),
workRelated: z.boolean().nullable(),
newCase: z.boolean().nullable(),
died: z.boolean(),
dateOfDeath: isoDateOnly.nullish(),
daysAwayFromWork: z.boolean(),
daysAway: dayCount,
restrictedOrTransfer: z.boolean(),
daysRestricted: dayCount,
medicalTreatment: z.boolean(),
lossOfConsciousness: z.boolean(),
significantDiagnosis: z.boolean(),
specialCase: z.enum(oshaSpecialCases),
privacyCase: z.boolean(),
inpatientHospitalization: z.boolean(),
amputation: z.boolean(),
lossOfEye: z.boolean(),
reportedToOshaAt: isoDateTime.nullish(),
employeeAddress: optionalText(500),
dateOfBirth: isoDateOnly.nullish(),
dateOfHire: isoDateOnly.nullish(),
sex: z.enum(['M', 'F']).nullish(),
physicianName: optionalText(200),
treatmentFacility: optionalText(500),
treatedInEmergencyRoom: z.boolean(),
timeStartedWork: z
.string()
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Use 24-hour time, e.g. 07:30')
.nullish(),
activityBefore: optionalText(2_000),
whatHappened: optionalText(5_000),
objectSubstance: optionalText(500),
}
export const createOshaCaseSchema = z.object(oshaCaseFields).partial().required({ name: true })
export const updateOshaCaseSchema = z.object(oshaCaseFields).partial()
export const createAreaSchema = z.object({ name, kind: z.enum(areaKinds).default('area'), parentId: z.uuid().nullish() }) export const createAreaSchema = z.object({ name, kind: z.enum(areaKinds).default('area'), parentId: z.uuid().nullish() })
export const updateAreaSchema = z.object({ name: name.optional(), kind: z.enum(areaKinds).optional(), archived: z.boolean().optional() }) export const updateAreaSchema = z.object({ name: name.optional(), kind: z.enum(areaKinds).optional(), archived: z.boolean().optional() })
@@ -96,6 +183,20 @@ export const updateKioskSchema = z.object({ name: name.optional(), siteId: z.uui
/** Report filed through a reporting link (kiosk tablet or QR code), without an account. */ /** Report filed through a reporting link (kiosk tablet or QR code), without an account. */
export const kioskReportSchema = createIncidentSchema.omit({ anonymous: true }).extend({ reporterName: optionalText(200) }) export const kioskReportSchema = createIncidentSchema.omit({ anonymous: true }).extend({ reporterName: optionalText(200) })
export const analyticsQuery = z.object({
from: z.iso.date(),
to: z.iso.date(),
siteId: z.uuid().optional(),
type: z.enum(incidentTypes).optional(),
})
export const savedViewSchema = z.object({
page: z.enum(['analytics', 'incidents']),
name,
filters: z.record(z.string().max(50), z.string().max(200)).refine((value) => Object.keys(value).length <= 20, 'Too many filters'),
shared: z.boolean().default(false),
})
export const emailOnlySchema = z.object({ email }) export const emailOnlySchema = z.object({ email })
export const tokenSchema = z.object({ token: z.string().min(20).max(200) }) export const tokenSchema = z.object({ token: z.string().min(20).max(200) })
@@ -114,6 +215,8 @@ export const incidentListQuery = z.object({
export type SetupInput = z.infer<typeof setupSchema> export type SetupInput = z.infer<typeof setupSchema>
export type CreateIncidentInput = z.input<typeof createIncidentSchema> export type CreateIncidentInput = z.input<typeof createIncidentSchema>
export type OshaCaseInput = z.input<typeof updateOshaCaseSchema>
export type SiteInput = z.input<typeof updateSiteSchema>
export type KioskReportInput = z.input<typeof kioskReportSchema> export type KioskReportInput = z.input<typeof kioskReportSchema>
export type UpdateIncidentInput = z.infer<typeof updateIncidentSchema> export type UpdateIncidentInput = z.infer<typeof updateIncidentSchema>
export type CreateActionInput = z.input<typeof createActionSchema> export type CreateActionInput = z.input<typeof createActionSchema>
+4
View File
@@ -4,10 +4,12 @@ import { About } from './pages/About'
import { Actions } from './pages/Actions' import { Actions } from './pages/Actions'
import { Analytics } from './pages/Analytics' import { Analytics } from './pages/Analytics'
import { Login, MagicLinkLanding, ResetPassword, Setup } from './pages/Auth' import { Login, MagicLinkLanding, ResetPassword, Setup } from './pages/Auth'
import { Form301 } from './pages/Form301'
import { IncidentDetail } from './pages/IncidentDetail' import { IncidentDetail } from './pages/IncidentDetail'
import { Kiosk } from './pages/Kiosk' import { Kiosk } from './pages/Kiosk'
import { Incidents } from './pages/Incidents' import { Incidents } from './pages/Incidents'
import { Locations } from './pages/Locations' import { Locations } from './pages/Locations'
import { Osha } from './pages/Osha'
import { Overview } from './pages/Overview' import { Overview } from './pages/Overview'
import { QrSheet } from './pages/QrSheet' import { QrSheet } from './pages/QrSheet'
import { ReportIncident } from './pages/ReportIncident' import { ReportIncident } from './pages/ReportIncident'
@@ -29,6 +31,8 @@ export default function App() {
<Route path="locations" element={<Locations />} /> <Route path="locations" element={<Locations />} />
<Route path="locations/qr" element={<QrSheet />} /> <Route path="locations/qr" element={<QrSheet />} />
<Route path="analytics" element={<Analytics />} /> <Route path="analytics" element={<Analytics />} />
<Route path="osha" element={<Osha />} />
<Route path="osha/cases/:id/301" element={<Form301 />} />
<Route path="report" element={<ReportIncident />} /> <Route path="report" element={<ReportIncident />} />
<Route path="settings" element={<Settings />} /> <Route path="settings" element={<Settings />} />
<Route path="about" element={<About />} /> <Route path="about" element={<About />} />
+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 { Info, LogOut, Plus, Settings, ShieldCheck, X } from 'lucide-react'
import { NavLink, useNavigate } from 'react-router-dom' import { NavLink, useNavigate } from 'react-router-dom'
import { hasRole } from '../../shared/enums.ts'
import { useI18n } from '../i18n' import { useI18n } from '../i18n'
import { outbox } from '../lib/outbox' import { outbox } from '../lib/outbox'
import { initials } from '../lib/initials' import { initials } from '../lib/initials'
@@ -57,7 +58,9 @@ export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
</div> </div>
<nav className={navSection}> <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}> <NavLink key={to} to={to} end={to === '/'} onClick={onClose} className={navLink}>
<Icon size={18} /> <Icon size={18} />
<span>{t.nav[label]}</span> <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>
)
}
+1
View File
@@ -17,6 +17,7 @@ export const en = {
myActions: 'My actions', myActions: 'My actions',
locations: 'Locations', locations: 'Locations',
analytics: 'Analytics', analytics: 'Analytics',
osha: 'OSHA log',
manage: 'Manage', manage: 'Manage',
reportIncident: 'Report an incident', reportIncident: 'Report an incident',
about: 'About', about: 'About',
+1
View File
@@ -16,6 +16,7 @@ export const es: Messages = {
myActions: 'Mis acciones', myActions: 'Mis acciones',
locations: 'Ubicaciones', locations: 'Ubicaciones',
analytics: 'Análisis', analytics: 'Análisis',
osha: 'Registro OSHA',
manage: 'Gestionar', manage: 'Gestionar',
reportIncident: 'Reportar un incidente', reportIncident: 'Reportar un incidente',
about: 'Acerca de', about: 'Acerca de',
+19
View File
@@ -39,6 +39,25 @@
--color-mint-strong: #3d9576; --color-mint-strong: #3d9576;
--color-sky-soft: #dfedf1; --color-sky-soft: #dfedf1;
--color-sky-strong: #39819c; --color-sky-strong: #39819c;
/* Charts: validated categorical slots (scripts/validate_palette on #fff), sequential blue, status */
--color-series-1: #2a78d6;
--color-series-2: #eb6834;
--color-series-3: #1baf7a;
--color-seq-100: #cde2fb;
--color-seq-200: #9ec5f4;
--color-seq-300: #6da7ec;
--color-seq-400: #3987e5;
--color-seq-500: #256abf;
--color-seq-600: #184f95;
--color-seq-700: #0d366b;
--color-status-good: #0ca30c;
--color-status-serious: #ec835a;
--color-chart-grid: #e1e0d9;
--color-chart-axis: #c3c2b7;
--color-chart-label: #898781;
--color-delta-good: #006300;
--color-delta-bad: #b42318;
} }
@layer base { @layer base {
+39 -1
View File
@@ -1,5 +1,6 @@
import type { ActionStatus, RcaMethod, RootCauseCategory, TimelineEntry } from '../../shared/api-types.ts' import type { ActionStatus, RcaMethod, RootCauseCategory, TimelineEntry } from '../../shared/api-types.ts'
import type { AreaKind, IncidentStatus, IncidentType, Role, Severity } from '../../shared/enums.ts' import type { AreaKind, IncidentStatus, IncidentType, OshaCaseType, OshaOutcome, OshaSpecialCase, Role, Severity } from '../../shared/enums.ts'
import type { EmploymentType } from '../../shared/osha.ts'
import type { Tone } from './tones' import type { Tone } from './tones'
export const incidentTypeLabel: Record<IncidentType, string> = { export const incidentTypeLabel: Record<IncidentType, string> = {
@@ -72,6 +73,37 @@ export const rcaMethodLabel: Record<RcaMethod, string> = { five_whys: '5 Whys',
export const areaKindLabel: Record<AreaKind, string> = { building: 'Building', area: 'Area', line: 'Line', other: 'Other' } export const areaKindLabel: Record<AreaKind, string> = { building: 'Building', area: 'Area', line: 'Line', other: 'Other' }
export const oshaOutcomeLabel: Record<OshaOutcome, string> = {
death: 'Death',
days_away: 'Days away from work',
restricted: 'Job transfer or restriction',
other: 'Other recordable case',
}
export const oshaCaseTypeLabel: Record<OshaCaseType, string> = {
injury: 'Injury',
skin_disorder: 'Skin disorder',
respiratory: 'Respiratory condition',
poisoning: 'Poisoning',
hearing_loss: 'Hearing loss',
other_illness: 'All other illnesses',
}
export const oshaSpecialCaseLabel: Record<OshaSpecialCase, string> = {
none: 'None',
needlestick: 'Contaminated needlestick or sharps injury (1904.8)',
medical_removal: 'Medical removal under an OSHA standard (1904.9)',
hearing_loss: 'Work-related hearing loss / standard threshold shift (1904.10)',
tuberculosis: 'Tuberculosis after workplace exposure (1904.11)',
}
export const employmentTypeLabel: Record<EmploymentType, string> = {
employee: 'Employee',
temporary: 'Temporary or contract worker we supervise day to day',
contractor: 'Contractor (not supervised by us)',
visitor: 'Visitor or member of the public',
}
const fieldLabel: Record<string, string> = { const fieldLabel: Record<string, string> = {
title: 'title', title: 'title',
type: 'type', type: 'type',
@@ -128,6 +160,12 @@ export function describeTimelineEntry({ action, changes }: TimelineEntry): strin
return `attached ${c.fileName}` return `attached ${c.fileName}`
case 'attachment.remove': case 'attachment.remove':
return `removed ${c.fileName}` return `removed ${c.fileName}`
case 'osha_case.add':
return `added an injured or ill person (case ${c.caseNumber})`
case 'osha_case.update':
return c.recordable ? `updated case ${c.caseNumber}: ${(c.recordable as boolean[])[1] ? 'now recordable' : 'no longer recordable'}` : `updated case ${c.caseNumber}`
case 'osha_case.remove':
return `removed case ${c.caseNumber}`
default: default:
return action return action
} }
+7 -1
View File
@@ -1,4 +1,5 @@
import { BarChart3, ClipboardCheck, ClipboardList, LayoutDashboard, MapPin, type LucideIcon } from 'lucide-react' import { BarChart3, ClipboardCheck, ClipboardList, FileSpreadsheet, LayoutDashboard, MapPin, type LucideIcon } from 'lucide-react'
import type { Role } from '../../shared/enums.ts'
import type { Messages } from '../i18n/en' import type { Messages } from '../i18n/en'
@@ -6,6 +7,8 @@ export interface NavItem {
label: keyof Messages['nav'] label: keyof Messages['nav']
to: string to: string
icon: LucideIcon icon: LucideIcon
/** Hidden from roles below this. */
minRole?: Role
} }
export const primaryNav: NavItem[] = [ export const primaryNav: NavItem[] = [
@@ -14,9 +17,11 @@ export const primaryNav: NavItem[] = [
{ label: 'myActions', to: '/actions', icon: ClipboardCheck }, { label: 'myActions', to: '/actions', icon: ClipboardCheck },
{ label: 'locations', to: '/locations', icon: MapPin }, { label: 'locations', to: '/locations', icon: MapPin },
{ label: 'analytics', to: '/analytics', icon: BarChart3 }, { label: 'analytics', to: '/analytics', icon: BarChart3 },
{ label: 'osha', to: '/osha', icon: FileSpreadsheet, minRole: 'safety_manager' },
] ]
export function pageTitle(pathname: string): string { export function pageTitle(pathname: string): string {
if (/^\/osha\/cases\//.test(pathname)) return 'Form 301'
const incident = /^\/incidents\/(\d+)/.exec(pathname) const incident = /^\/incidents\/(\d+)/.exec(pathname)
if (incident) return `INC-${incident[1].padStart(4, '0')}` if (incident) return `INC-${incident[1].padStart(4, '0')}`
const titles: Record<string, string> = { const titles: Record<string, string> = {
@@ -25,6 +30,7 @@ export function pageTitle(pathname: string): string {
'/actions': 'My actions', '/actions': 'My actions',
'/locations': 'Locations', '/locations': 'Locations',
'/analytics': 'Analytics', '/analytics': 'Analytics',
'/osha': 'OSHA log',
'/report': 'Report an incident', '/report': 'Report an incident',
'/locations/qr': 'QR codes', '/locations/qr': 'QR codes',
'/settings': 'Settings', '/settings': 'Settings',
+64 -3
View File
@@ -5,18 +5,22 @@ import type {
Comment, Comment,
CorrectiveAction, CorrectiveAction,
Incident, Incident,
Form301,
IncidentDetail, IncidentDetail,
Kiosk, Kiosk,
OshaCase,
OshaYear,
Organization, Organization,
OverviewStats, OverviewStats,
Paginated, Paginated,
SavedView,
Site, Site,
TimelineEntry, TimelineEntry,
User, User,
UserRef, UserRef,
} from '../../shared/api-types.ts' } from '../../shared/api-types.ts'
import type { IncidentStatus, Role } from '../../shared/enums.ts' import type { IncidentStatus, Role } from '../../shared/enums.ts'
import type { CreateActionInput, CreateIncidentInput, InvestigationInput, RootCauseInput, UpdateActionInput, UpdateIncidentInput, UpdateRootCauseInput } from '../../shared/schemas.ts' import type { OshaCaseInput, SiteInput, CreateActionInput, CreateIncidentInput, InvestigationInput, RootCauseInput, UpdateActionInput, UpdateIncidentInput, UpdateRootCauseInput } from '../../shared/schemas.ts'
import { api, ApiError } from './api' import { api, ApiError } from './api'
// ---- Session ---- // ---- Session ----
@@ -86,7 +90,10 @@ export function useSiteMutations() {
const done = () => client.invalidateQueries({ queryKey: ['sites'] }) const done = () => client.invalidateQueries({ queryKey: ['sites'] })
return { return {
createSite: useMutation({ mutationFn: (input: { name: string; address?: string }) => api('/sites', { method: 'POST', body: input }), onSuccess: done }), createSite: useMutation({ mutationFn: (input: { name: string; address?: string }) => api('/sites', { method: 'POST', body: input }), onSuccess: done }),
updateSite: useMutation({ mutationFn: ({ id, ...input }: { id: string; name?: string; archived?: boolean }) => api(`/sites/${id}`, { method: 'PATCH', body: input }), onSuccess: done }), updateSite: useMutation({ mutationFn: ({ id, ...input }: SiteInput & { id: string }) => api(`/sites/${id}`, { method: 'PATCH', body: input }), onSuccess: () => {
done()
client.invalidateQueries({ queryKey: ['osha'] })
} }),
createArea: useMutation({ createArea: useMutation({
mutationFn: ({ siteId, ...input }: { siteId: string; name: string; kind: string; parentId?: string | null }) => api(`/sites/${siteId}/areas`, { method: 'POST', body: input }), mutationFn: ({ siteId, ...input }: { siteId: string; name: string; kind: string; parentId?: string | null }) => api(`/sites/${siteId}/areas`, { method: 'POST', body: input }),
onSuccess: done, onSuccess: done,
@@ -199,7 +206,16 @@ export function useUpdateAction() {
// ---- Stats ---- // ---- Stats ----
export const useOverviewStats = () => useQuery({ queryKey: ['stats', 'overview'], queryFn: () => api<OverviewStats>('/stats/overview') }) export const useOverviewStats = () => useQuery({ queryKey: ['stats', 'overview'], queryFn: () => api<OverviewStats>('/stats/overview') })
export const useAnalytics = (days: number) => useQuery({ queryKey: ['stats', 'analytics', days], queryFn: () => api<AnalyticsStats>(`/stats/analytics?days=${days}`) }) export interface AnalyticsFilters {
from: string
to: string
siteId?: string
type?: string
}
/** Keeps showing the previous result while new filters load, so charts don't flash. */
export const useAnalytics = (filters: AnalyticsFilters) =>
useQuery({ queryKey: ['stats', 'analytics', filters], queryFn: () => api<AnalyticsStats>(`/stats/analytics${filtersToQuery(filters)}`), placeholderData: keepPreviousData })
// ---- Users ---- // ---- Users ----
@@ -247,3 +263,48 @@ export function reportUrl(origin: string, { token, siteId, areaId }: { token?: s
const query = params.toString() ? `?${params}` : '' const query = params.toString() ? `?${params}` : ''
return `${origin}${token ? `/k/${token}` : '/report'}${query}` return `${origin}${token ? `/k/${token}` : '/report'}${query}`
} }
// ---- OSHA recordkeeping ----
export const useOshaCases = (number: number, enabled: boolean) =>
useQuery({ queryKey: ['osha', 'cases', number], queryFn: () => api<{ cases: OshaCase[] }>(`/osha/incidents/${number}/cases`).then((r) => r.cases), enabled })
export function useOshaCaseMutations(number: number) {
const client = useQueryClient()
const done = () => {
client.invalidateQueries({ queryKey: ['osha'] })
client.invalidateQueries({ queryKey: ['incident', number] })
client.invalidateQueries({ queryKey: ['timeline', number] })
}
return {
create: useMutation({ mutationFn: (input: OshaCaseInput & { name: string }) => api<{ case: OshaCase }>(`/osha/incidents/${number}/cases`, { method: 'POST', body: input }).then((r) => r.case), onSuccess: done }),
update: useMutation({ mutationFn: ({ id, ...input }: OshaCaseInput & { id: string }) => api<{ case: OshaCase }>(`/osha/cases/${id}`, { method: 'PATCH', body: input }).then((r) => r.case), onSuccess: done }),
remove: useMutation({ mutationFn: (id: string) => api(`/osha/cases/${id}`, { method: 'DELETE' }), onSuccess: done }),
}
}
export const useOshaYear = (siteId: string | undefined, year: number) =>
useQuery({ queryKey: ['osha', 'log', siteId, year], queryFn: () => api<OshaYear>(`/osha/log?siteId=${siteId}&year=${year}`), enabled: !!siteId })
export function useSaveHours(siteId: string) {
const client = useQueryClient()
return useMutation({
mutationFn: (input: { year: number; months: { month: number; hours: number | null; averageEmployees: number | null }[] }) => api(`/osha/sites/${siteId}/hours`, { method: 'PUT', body: input }),
onSuccess: () => client.invalidateQueries({ queryKey: ['osha'] }),
})
}
export const useForm301 = (caseId: string) => useQuery({ queryKey: ['osha', '301', caseId], queryFn: () => api<Form301>(`/osha/cases/${caseId}/301`) })
// ---- Saved views ----
export const useSavedViews = (page: SavedView['page']) => useQuery({ queryKey: ['views', page], queryFn: () => api<{ views: SavedView[] }>(`/views?page=${page}`).then((r) => r.views) })
export function useSavedViewMutations(page: SavedView['page']) {
const client = useQueryClient()
const done = () => client.invalidateQueries({ queryKey: ['views', page] })
return {
create: useMutation({ mutationFn: (input: { name: string; filters: Record<string, string>; shared: boolean }) => api<{ view: SavedView }>('/views', { method: 'POST', body: { ...input, page } }).then((r) => r.view), onSuccess: done }),
remove: useMutation({ mutationFn: (id: string) => api(`/views/${id}`, { method: 'DELETE' }), onSuccess: done }),
}
}
+281 -86
View File
@@ -1,112 +1,307 @@
import { Flame } from 'lucide-react' import { CalendarRange, Filter, MapPin } from 'lucide-react'
import { useState } from 'react' import { Link, useOutletContext, useSearchParams } from 'react-router-dom'
import { incidentTypes } from '../../shared/enums.ts' import type { AnalyticsStats, Kpi } from '../../shared/api-types.ts'
import { Eyebrow } from '../components/Eyebrow' import { hasRole, incidentTypes } from '../../shared/enums.ts'
import { BarList } from '../components/charts/BarList'
import { ChartEmpty, ChartFrame } from '../components/charts/ChartFrame'
import { Heatmap } from '../components/charts/Heatmap'
import { LineChart } from '../components/charts/LineChart'
import { StackedColumns, type Series } from '../components/charts/StackedColumns'
import { StatTile } from '../components/charts/StatTile'
import { Page } from '../components/Page' import { Page } from '../components/Page'
import { PageIntro } from '../components/PageIntro' import { PageIntro } from '../components/PageIntro'
import { ProgressBar } from '../components/ProgressRow'
import { ErrorMessage, Loading } from '../components/QueryState' import { ErrorMessage, Loading } from '../components/QueryState'
import { filterBox, panel } from '../components/styles' import { SavedViews } from '../components/SavedViews'
import { filterBox, panel, sectionHeading } from '../components/styles'
import { formatWeek, toIsoDate } from '../lib/dates'
import { incidentTypeLabel, rootCauseCategoryLabel } from '../lib/labels' import { incidentTypeLabel, rootCauseCategoryLabel } from '../lib/labels'
import { useAnalytics } from '../lib/queries' import { useAnalytics, useSites, type Me } from '../lib/queries'
const card = 'flex min-h-[240px] flex-col items-start p-[26px]' const select = 'bg-transparent text-xs text-ink outline-none'
const bigStat = 'mt-8 mb-[25px] font-display text-[25px] font-semibold' const ranges = [
const pct = (value: number) => `${Math.round(value * 100)}%` { value: '30', label: 'Last 30 days' },
{ value: '90', label: 'Last 90 days' },
{ value: '365', label: 'Last 12 months' },
{ value: 'ytd', label: 'Year to date' },
{ value: 'custom', label: 'Custom range' },
] as const
const FILTER_KEYS = ['range', 'from', 'to', 'site', 'type']
const reportSeries: Series[] = [
{ key: 'hazards', label: 'Hazards & near misses', color: 'var(--color-series-1)' },
{ key: 'injuries', label: 'Injuries & illnesses', color: 'var(--color-series-2)' },
{ key: 'damage', label: 'Damage & environmental', color: 'var(--color-series-3)' },
]
const rateSeries: Series[] = [
{ key: 'trir', label: 'TRIR', color: 'var(--color-series-1)' },
{ key: 'dart', label: 'DART', color: 'var(--color-series-2)' },
]
const actionSeries: Series[] = [
{ key: 'onTime', label: 'On time', color: 'var(--color-status-good)' },
{ key: 'late', label: 'Late', color: 'var(--color-status-serious)' },
]
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const hours = Array.from({ length: 24 }, (_, h) => String(h).padStart(2, '0'))
const monthLabel = (key: string) => new Date(`${key}-01T00:00:00`).toLocaleDateString('en-US', { month: 'short', year: '2-digit' })
/** Resolves the range control to concrete dates. */
function periodFrom(params: URLSearchParams): { from: string; to: string } {
const today = new Date()
const range = params.get('range') ?? '90'
if (range === 'custom' && params.get('from') && params.get('to')) return { from: params.get('from')!, to: params.get('to')! }
if (range === 'ytd') return { from: `${today.getFullYear()}-01-01`, to: toIsoDate(today) }
const days = Number(range) || 90
const start = new Date(today)
start.setDate(start.getDate() - (days - 1))
return { from: toIsoDate(start), to: toIsoDate(today) }
}
const pct = (value: number | null) => (value === null ? '–' : `${Math.round(value * 100)}%`)
const num = (value: number | null, digits = 0) => (value === null ? '–' : value.toLocaleString('en-US', { maximumFractionDigits: digits }))
/** Delta text and tone. `upIsGood` false means fewer is better (e.g. injuries). */
function delta(kpi: Kpi, upIsGood: boolean | null, format: (v: number) => string = (v) => num(v)) {
if (kpi.value === null || kpi.previous === null) return undefined
const diff = kpi.value - kpi.previous
if (Math.abs(diff) < 1e-9) return { text: 'Same as previous period', direction: 'flat' as const, good: null }
return {
text: `${diff > 0 ? '+' : '−'}${format(Math.abs(diff))} vs previous period`,
direction: diff > 0 ? ('up' as const) : ('down' as const),
good: upIsGood === null ? null : diff > 0 === upIsGood,
}
}
export function Analytics() { export function Analytics() {
const [days, setDays] = useState(90) const me = useOutletContext<Me>()
const stats = useAnalytics(days) const [params, setParams] = useSearchParams()
const sites = useSites()
const period = periodFrom(params)
const filters = { ...period, siteId: params.get('site') ?? undefined, type: params.get('type') ?? undefined }
const stats = useAnalytics(filters)
const set = (key: string, value: string) => {
const next = new URLSearchParams(params)
if (value) next.set(key, value)
else next.delete(key)
if (key === 'range' && value === 'custom') {
next.set('from', period.from)
next.set('to', period.to)
}
setParams(next, { replace: true })
}
const s = stats.data const s = stats.data
const topType = s?.byType[0]
return ( return (
<Page> <Page>
<PageIntro <PageIntro eyebrow="Program intelligence" title="Analytics" copy="Leading indicators show whether people are spotting and fixing risks; lagging indicators count the harm that happened anyway." />
eyebrow="Program intelligence"
title="Analytics" {/* One filter row scopes everything below it */}
copy="Patterns that help your team prevent the next incident." <div className="mb-6 flex flex-wrap items-center gap-2.5 print:hidden">
action={ <div className={filterBox}>
<div className={filterBox}> <CalendarRange size={15} />
<select className="bg-transparent text-xs text-ink outline-none" value={days} onChange={(event) => setDays(Number(event.target.value))} aria-label="Period"> <select className={select} value={params.get('range') ?? '90'} onChange={(event) => set('range', event.target.value)} aria-label="Date range">
<option value={30}>Last 30 days</option> {ranges.map((r) => (
<option value={90}>Last 90 days</option> <option key={r.value} value={r.value}>
<option value={180}>Last 6 months</option> {r.label}
<option value={365}>Last 12 months</option> </option>
</select> ))}
</select>
</div>
{params.get('range') === 'custom' && (
<div className={`${filterBox} gap-1`}>
<input type="date" className={select} value={period.from} max={period.to} onChange={(event) => set('from', event.target.value)} aria-label="From" />
<span className="text-muted">–</span>
<input type="date" className={select} value={period.to} min={period.from} onChange={(event) => set('to', event.target.value)} aria-label="To" />
</div> </div>
} )}
/> <div className={filterBox}>
<MapPin size={15} />
<select className={select} value={params.get('site') ?? ''} onChange={(event) => set('site', event.target.value)} aria-label="Site">
<option value="">All sites</option>
{sites.data?.map((site) => (
<option key={site.id} value={site.id}>
{site.name}
</option>
))}
</select>
</div>
<div className={filterBox}>
<Filter size={15} />
<select className={select} value={params.get('type') ?? ''} onChange={(event) => set('type', event.target.value)} aria-label="Incident type">
<option value="">All types</option>
{incidentTypes.map((type) => (
<option key={type} value={type}>
{incidentTypeLabel[type]}
</option>
))}
</select>
</div>
<div className="ml-auto">
<SavedViews page="analytics" keys={FILTER_KEYS} />
</div>
</div>
<ErrorMessage error={stats.error} className="mb-5" /> <ErrorMessage error={stats.error} className="mb-5" />
{!s ? ( {!s ? (
<Loading /> <Loading />
) : ( ) : (
<> <div className={`space-y-6 transition-opacity ${stats.isPlaceholderData ? 'opacity-50' : ''}`}>
<div className="mb-5 grid gap-5 lg:grid-cols-[1.3fr_1fr_1fr]"> <Indicators stats={s} showRates={hasRole(me.user.role, 'supervisor')} />
<div className={`${card} rounded-lg border border-forest-900 bg-forest-900 text-[#dfece5] shadow-[0_5px_20px_#1b252305]`}> <div className="grid gap-5 lg:grid-cols-2">
<Flame size={22} /> <ChartFrame
<Eyebrow className="mt-4 text-[#9dc0b3]">Most common</Eyebrow> className="lg:col-span-2"
{topType ? ( title={`Reports per ${s.bucket}`}
<> subtitle="Rising hazard and near-miss reporting with falling injuries is the pattern to aim for."
<h2 className="mt-5 mb-2.5 font-display text-2xl leading-[1.15] font-semibold">{incidentTypeLabel[topType.type]} is your most reported type.</h2> legend={reportSeries.map((x) => ({ label: x.label, color: x.color }))}
<p className="text-xs leading-[1.6] text-[#a7c0b8]"> table={{ columns: [s.bucket === 'week' ? 'Week of' : 'Month', ...reportSeries.map((x) => x.label)], rows: s.trend.map((b) => [b.start, b.hazards, b.injuries, b.damage]) }}
{topType.count} of {s.total} reports ({pct(topType.count / s.total)}) in this period. >
</p> {s.trend.some((b) => b.hazards + b.injuries + b.damage > 0) ? (
</> <StackedColumns
ariaLabel="Reports over time by kind"
series={reportSeries}
data={s.trend.map((b) => ({ label: s.bucket === 'week' ? formatWeek(b.start) : monthLabel(b.start.slice(0, 7)), values: { hazards: b.hazards, injuries: b.injuries, damage: b.damage } }))}
/>
) : ( ) : (
<h2 className="mt-5 font-display text-2xl leading-[1.15] font-semibold">No reports in this period.</h2> <ChartEmpty>No reports in this period.</ChartEmpty>
)} )}
</div> </ChartFrame>
<div className={`${panel} ${card}`}>
<Eyebrow>Most common root cause</Eyebrow> {s.rates && (
{s.topRootCause ? ( <ChartFrame
<> className="lg:col-span-2"
<strong className={bigStat}>{rootCauseCategoryLabel[s.topRootCause.category]}</strong> title="OSHA rates, rolling 12 months"
<div className="flex w-full justify-between text-2xs text-muted"> subtitle="Cases per 100 full-time workers a year, measured over the 12 months ending each month."
<span> legend={rateSeries.map((x) => ({ label: x.label, color: x.color, shape: 'line' }))}
{s.topRootCause.count} root cause{s.topRootCause.count === 1 ? '' : 's'} table={{ columns: ['12 months ending', 'TRIR', 'DART'], rows: s.rates.map((r) => [monthLabel(r.month), r.trir ?? '–', r.dart ?? '–']) }}
</span> >
<span>{pct(s.topRootCause.share)} of total</span> {s.rates.some((r) => r.trir !== null) ? (
</div> <LineChart ariaLabel="Rolling 12-month TRIR and DART" series={rateSeries} data={s.rates.map((r) => ({ label: monthLabel(r.month), values: { trir: r.trir, dart: r.dart } }))} />
<ProgressBar percent={s.topRootCause.share * 100} className="mt-2.5 w-full" /> ) : (
</> <ChartEmpty>
Rates need hours worked.{' '}
<Link to="/osha?tab=hours" className="text-coral underline">
Enter monthly hours on the OSHA log
</Link>
.
</ChartEmpty>
)}
</ChartFrame>
)}
<ChartFrame
title={params.get('site') ? 'Where in this site' : 'Where incidents happen'}
subtitle="Reports in this period"
table={{ columns: ['Location', 'Reports'], rows: s.byLocation.map((l) => [l.name, l.count]) }}
>
{s.byLocation.length ? <BarList ariaLabel="Reports by location" items={s.byLocation.slice(0, 8).map((l) => ({ label: l.name, value: l.count }))} /> : <ChartEmpty>No reports in this period.</ChartEmpty>}
</ChartFrame>
<ChartFrame title="Root causes" subtitle="From investigations of incidents in this period" table={{ columns: ['Category', 'Root causes'], rows: s.byRootCause.map((c) => [rootCauseCategoryLabel[c.category], c.count]) }}>
{s.byRootCause.length ? (
<BarList ariaLabel="Root causes by category" items={s.byRootCause.map((c) => ({ label: rootCauseCategoryLabel[c.category], value: c.count }))} />
) : ( ) : (
<p className="mt-8 text-xs text-muted">No root causes recorded in this period yet.</p> <ChartEmpty>No root causes recorded for incidents in this period yet.</ChartEmpty>
)} )}
</div> </ChartFrame>
<div className={`${panel} ${card}`}>
<Eyebrow>Actions completed on time</Eyebrow> <ChartFrame
<strong className={bigStat}>{s.onTimeActionRate === null ? '–' : pct(s.onTimeActionRate)}</strong> className="lg:col-span-2"
<p className="text-xs leading-[1.6] text-muted">of corrective actions with a due date were done by then.</p> title="When incidents happen"
{s.monthlyCompletedActions.length > 0 && ( subtitle="By day of the week and hour of the day. Clusters often point at shift changes, overtime or specific tasks."
<div className="mt-auto flex h-[70px] w-full items-end gap-[7px] pt-4" aria-label="On-time share by month"> table={{ columns: ['Day', ...hours.map((h) => `${h}:00`)], rows: s.heatmap.map((row, i) => [weekdays[i], ...row]) }}
{s.monthlyCompletedActions.map((m) => { >
const total = m.onTime + m.late {s.heatmap.flat().some((v) => v > 0) ? (
return <i key={m.month} title={`${m.month}: ${m.onTime} on time, ${m.late} late`} className="min-h-[6px] flex-1 rounded-t-[3px] bg-[#76a99d]" style={{ height: `${(m.onTime / total) * 100}%` }} /> <Heatmap
})} ariaLabel="Incidents by weekday and hour"
</div> grid={s.heatmap}
rowLabels={weekdays}
colLabels={hours}
describe={(r, c, v) => `${weekdays[r]} ${hours[c]}:00–${hours[c]}:59: ${v} incident${v === 1 ? '' : 's'}`}
/>
) : (
<ChartEmpty>No reports in this period.</ChartEmpty>
)} )}
</div> </ChartFrame>
<ChartFrame
className="lg:col-span-2"
title="Corrective actions completed"
subtitle="Actions with a due date, by the month they were completed"
legend={actionSeries.map((x) => ({ label: x.label, color: x.color }))}
table={{ columns: ['Month', 'On time', 'Late'], rows: s.actionsByMonth.map((m) => [monthLabel(m.month), m.onTime, m.late]) }}
>
{s.actionsByMonth.some((m) => m.onTime + m.late > 0) ? (
<StackedColumns ariaLabel="Corrective actions completed on time and late by month" series={actionSeries} data={s.actionsByMonth.map((m) => ({ label: monthLabel(m.month), values: { onTime: m.onTime, late: m.late } }))} />
) : (
<ChartEmpty>No corrective actions with due dates were completed in this period.</ChartEmpty>
)}
</ChartFrame>
</div> </div>
<section className={`${panel} p-[26px]`}> {s.yearly && <YearlyRates yearly={s.yearly} />}
<Eyebrow>Reports by type</Eyebrow> </div>
<ul className="mt-4 space-y-3">
{incidentTypes.map((type) => {
const count = s.byType.find((t) => t.type === type)?.count ?? 0
return (
<li key={type}>
<div className="flex justify-between text-2xs text-body">
<span>{incidentTypeLabel[type]}</span>
<strong className="font-mono font-normal text-ink">{count}</strong>
</div>
<ProgressBar percent={s.total ? (count / s.total) * 100 : 0} barClassName="bg-sage" />
</li>
)
})}
</ul>
</section>
</>
)} )}
</Page> </Page>
) )
} }
function Indicators({ stats, showRates }: { stats: AnalyticsStats; showRates: boolean }) {
const k = stats.kpis
const latestRate = stats.rates?.at(-1)
return (
<>
<section>
<h2 className={`${sectionHeading} mb-1`}>Leading indicators</h2>
<p className="mb-3 text-2xs text-muted">Proactive: are people reporting risks, and is the team acting on them quickly?</p>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile label="Hazards & near misses reported" value={num(k.leadingReports.value)} delta={delta(k.leadingReports, true)} hint="More reporting is good: it means risks are surfacing before someone is hurt." />
<StatTile label="Near misses per injury" value={num(k.nearMissRatio.value, 1)} delta={delta(k.nearMissRatio, true, (v) => num(v, 1))} hint="Hazards and near misses for each injury or illness." />
<StatTile label="Triaged within 24 hours" value={pct(k.triagedWithin24h.value)} delta={delta(k.triagedWithin24h, true, (v) => `${Math.round(v * 100)} pts`)} hint="Reports moved past “Reported” within a day." />
<StatTile label="Actions completed on time" value={pct(k.actionsOnTime.value)} delta={delta(k.actionsOnTime, true, (v) => `${Math.round(v * 100)} pts`)} hint={`${k.overdueActions} open action${k.overdueActions === 1 ? ' is' : 's are'} overdue now.`} />
</div>
</section>
<section>
<h2 className={`${sectionHeading} mb-1`}>Lagging indicators</h2>
<p className="mb-3 text-2xs text-muted">Outcomes: the harm that happened. Fewer is better.</p>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile label="Injuries & illnesses reported" value={num(k.injuries.value)} delta={delta(k.injuries, false)} />
{showRates && k.recordables && <StatTile label="OSHA-recordable cases" value={num(k.recordables.value)} delta={delta(k.recordables, false)} />}
{showRates && <StatTile label="TRIR, last 12 months" value={latestRate?.trir === null || !latestRate ? '–' : num(latestRate.trir, 2)} hint={latestRate?.trir === null ? 'Needs hours worked (OSHA log)' : `DART ${num(latestRate?.dart ?? null, 2)}`} />}
<StatTile label="Average days to close" value={num(k.averageDaysToClose.value, 1)} delta={delta(k.averageDaysToClose, false, (v) => `${num(v, 1)} d`)} hint="For incidents closed in this period" />
</div>
</section>
</>
)
}
function YearlyRates({ yearly }: { yearly: NonNullable<AnalyticsStats['yearly']> }) {
return (
<section className={`${panel} p-5`}>
<h2 className={sectionHeading}>OSHA rates by year</h2>
<p className="mt-0.5 mb-3 text-2xs text-muted">Calendar years. Compare with your industry’s published BLS rates for context.</p>
<div className="overflow-auto">
<table className="w-full border-collapse text-2xs">
<thead>
<tr className="text-[#586762]">
{['Year', 'Recordable cases', 'DART cases', 'Hours worked', 'TRIR', 'DART'].map((h, i) => (
<th key={h} className={`border-b border-line px-2 py-1.5 font-semibold ${i ? 'text-right' : 'text-left'}`}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{yearly.map((y) => (
<tr key={y.year}>
<td className="border-b border-line-soft px-2 py-1.5 text-body">{y.year}</td>
{[y.recordables, y.dartCases, y.hours || '–', y.trir ?? '–', y.dart ?? '–'].map((v, i) => (
<td key={i} className="border-b border-line-soft px-2 py-1.5 text-right text-body tabular-nums">
{typeof v === 'number' ? v.toLocaleString('en-US', { maximumFractionDigits: 2 }) : v}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
+122
View File
@@ -0,0 +1,122 @@
import { ArrowLeft, Printer } from 'lucide-react'
import type { ReactNode } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Page } from '../components/Page'
import { ErrorMessage, Loading } from '../components/QueryState'
import { panel, secondaryButton } from '../components/styles'
import { formatDate, formatDateTime, formatDay } from '../lib/dates'
import { oshaCaseTypeLabel, oshaOutcomeLabel } from '../lib/labels'
import { useForm301 } from '../lib/queries'
function Item({ n, label, children }: { n: number; label: string; children: ReactNode }) {
return (
<div className="mb-3 break-inside-avoid">
<p className="text-3xs font-semibold text-[#586762]">
{n}) {label}
</p>
<p className="mt-0.5 min-h-[1.25rem] border-b border-line pb-1 text-xs whitespace-pre-wrap text-ink">{children || <span className="text-faint">—</span>}</p>
</div>
)
}
/** Equivalent of OSHA Form 301, Injury and Illness Incident Report (1904.29(b)(4)). */
export function Form301() {
const id = useParams().id ?? ''
const form = useForm301(id)
if (form.isPending) return <Loading />
if (!form.data) return <ErrorMessage error={form.error} className="m-6" />
const { case: c, establishment: e, incident } = form.data
const time = new Date(incident.occurredAt).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
return (
<Page narrow>
<div className="mb-4 flex items-center justify-between print:hidden">
<Link to={`/incidents/${c.incident.number}`} className="inline-flex items-center gap-1 text-2xs font-semibold text-muted hover:text-ink">
<ArrowLeft size={14} /> {incident.reference}
</Link>
<button type="button" className={secondaryButton} onClick={() => window.print()}>
<Printer size={16} /> Print / PDF
</button>
</div>
<article className={`${panel} p-8 print:border-0 print:p-0 print:shadow-none`}>
<header className="mb-6 border-b-2 border-ink pb-3">
<span className="font-mono text-3xs tracking-[0.06em] text-muted uppercase">OSHA Form 301 equivalent · Confidential</span>
<h1 className="font-display text-2xl font-semibold text-ink">Injury and Illness Incident Report</h1>
<p className="mt-1 text-3xs text-muted">
This information is used for occupational safety and health purposes. Keep it for 5 years after the year the incident occurred. {e.name}
{e.city ? `, ${e.city}${e.state ? `, ${e.state}` : ''}` : ''}
</p>
</header>
<div className="grid gap-x-10 sm:grid-cols-2 print:grid-cols-2">
<section>
<h2 className="mb-3 text-xs font-semibold text-ink uppercase">Information about the employee</h2>
<Item n={1} label="Full name">
{c.name}
</Item>
<Item n={2} label="Street, city, state, ZIP">
{c.employeeAddress}
</Item>
<Item n={3} label="Date of birth">
{c.dateOfBirth && formatDay(c.dateOfBirth)}
</Item>
<Item n={4} label="Date hired">
{c.dateOfHire && formatDay(c.dateOfHire)}
</Item>
<Item n={5} label="Sex">
{c.sex === 'M' ? 'Male' : c.sex === 'F' ? 'Female' : ''}
</Item>
<h2 className="mt-6 mb-3 text-xs font-semibold text-ink uppercase">Information about the physician or health care professional</h2>
<Item n={6} label="Name of physician or other health care professional">
{c.physicianName}
</Item>
<Item n={7} label="If treatment was given away from the worksite, where was it given?">
{c.treatmentFacility}
</Item>
<Item n={8} label="Was employee treated in an emergency room?">
{c.treatedInEmergencyRoom ? 'Yes' : 'No'}
</Item>
<Item n={9} label="Was employee hospitalized overnight as an in-patient?">
{c.inpatientHospitalization ? 'Yes' : 'No'}
</Item>
</section>
<section>
<h2 className="mb-3 text-xs font-semibold text-ink uppercase">Information about the case</h2>
<Item n={10} label="Case number from the Log">
{c.caseNumber}
</Item>
<Item n={11} label="Date of injury or illness">
{formatDate(incident.occurredAt)}
</Item>
<Item n={12} label="Time employee began work">
{c.timeStartedWork}
</Item>
<Item n={13} label="Time of event">
{time}
</Item>
<Item n={14} label="What was the employee doing just before the incident occurred?">
{c.activityBefore}
</Item>
<Item n={15} label="What happened? Tell us how the injury occurred.">
{c.whatHappened ?? incident.description}
</Item>
<Item n={16} label="What was the injury or illness? Tell us the part of the body affected and how it was affected.">
{[c.natureOfInjury, c.bodyPart].filter(Boolean).join(', ')}
</Item>
<Item n={17} label="What object or substance directly harmed the employee?">
{c.objectSubstance}
</Item>
<Item n={18} label="If the employee died, when did death occur?">
{c.dateOfDeath && formatDay(c.dateOfDeath)}
</Item>
</section>
</div>
<footer className="mt-6 border-t border-line pt-3 text-3xs text-muted">
Log classification: {c.outcome ? oshaOutcomeLabel[c.outcome] : 'not recordable'} · {oshaCaseTypeLabel[c.caseType]}
{c.daysAway ? ` · ${c.daysAway} days away` : ''}
{c.daysRestricted ? ` · ${c.daysRestricted} days restricted` : ''} · Location: {[incident.site, incident.area, incident.locationDetail].filter(Boolean).join(', ')} · Last updated{' '}
{formatDateTime(c.updatedAt)}
</footer>
</article>
</Page>
)
}
+2
View File
@@ -6,6 +6,7 @@ import { ActivitySection } from '../components/incident/ActivitySection'
import { AttachmentsSection } from '../components/incident/AttachmentsSection' import { AttachmentsSection } from '../components/incident/AttachmentsSection'
import { DetailsSection } from '../components/incident/DetailsSection' import { DetailsSection } from '../components/incident/DetailsSection'
import { InvestigationSection } from '../components/incident/InvestigationSection' import { InvestigationSection } from '../components/incident/InvestigationSection'
import { OshaSection } from '../components/incident/OshaSection'
import { WorkflowPanel } from '../components/incident/WorkflowPanel' import { WorkflowPanel } from '../components/incident/WorkflowPanel'
import { Page } from '../components/Page' import { Page } from '../components/Page'
import { ErrorMessage, Loading } from '../components/QueryState' import { ErrorMessage, Loading } from '../components/QueryState'
@@ -83,6 +84,7 @@ export function IncidentDetail() {
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px] print:block"> <div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px] print:block">
<div className="min-w-0 space-y-5"> <div className="min-w-0 space-y-5">
<DetailsSection incident={incident} /> <DetailsSection incident={incident} />
<OshaSection incident={incident} />
<InvestigationSection incident={incident} /> <InvestigationSection incident={incident} />
<ActionsSection incident={incident} /> <ActionsSection incident={incident} />
<ActivitySection incident={incident} /> <ActivitySection incident={incident} />
+5 -1
View File
@@ -6,6 +6,7 @@ import { IncidentTable } from '../components/IncidentTable'
import { Page } from '../components/Page' import { Page } from '../components/Page'
import { PageIntro } from '../components/PageIntro' import { PageIntro } from '../components/PageIntro'
import { Pagination } from '../components/Pagination' import { Pagination } from '../components/Pagination'
import { SavedViews } from '../components/SavedViews'
import { Empty, ErrorMessage, Loading } from '../components/QueryState' import { Empty, ErrorMessage, Loading } from '../components/QueryState'
import { filterBox, panel, primaryButton, secondaryButton } from '../components/styles' import { filterBox, panel, primaryButton, secondaryButton } from '../components/styles'
import { incidentTypeLabel, severityLabel, statusLabel } from '../lib/labels' import { incidentTypeLabel, severityLabel, statusLabel } from '../lib/labels'
@@ -113,7 +114,10 @@ export function Incidents() {
</select> </select>
</div> </div>
)} )}
<a className={`${secondaryButton} lg:ml-auto`} href={`/api/incidents/export${filtersToQuery(filters)}`} download> <div className="lg:ml-auto">
<SavedViews page="incidents" keys={['q', 'type', 'severity', 'status', 'siteId']} />
</div>
<a className={secondaryButton} href={`/api/incidents/export${filtersToQuery(filters)}`} download>
<FileDown size={16} /> Export CSV <FileDown size={16} /> Export CSV
</a> </a>
</div> </div>
+66 -1
View File
@@ -1,4 +1,4 @@
import { Archive, Building2, Plus, QrCode } from 'lucide-react' import { Archive, Building2, FileText, Plus, QrCode } from 'lucide-react'
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { Link, useOutletContext } from 'react-router-dom' import { Link, useOutletContext } from 'react-router-dom'
import type { Area, Site } from '../../shared/api-types.ts' import type { Area, Site } from '../../shared/api-types.ts'
@@ -104,6 +104,7 @@ function SiteCard({ site, tone, count, canManage }: { site: Site; tone: Tone; co
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [form, setForm] = useState({ name: '', kind: 'building' as AreaKind, parentId: '' }) const [form, setForm] = useState({ name: '', kind: 'building' as AreaKind, parentId: '' })
const tree = flattenTree(site.areas) const tree = flattenTree(site.areas)
const [editingEstablishment, setEditingEstablishment] = useState(false)
const add = (event: FormEvent) => { const add = (event: FormEvent) => {
event.preventDefault() event.preventDefault()
@@ -130,6 +131,11 @@ function SiteCard({ site, tone, count, canManage }: { site: Site; tone: Tone; co
{count} incident{count === 1 ? '' : 's'} this year {count} incident{count === 1 ? '' : 's'} this year
</Link> </Link>
</div> </div>
{canManage && (
<button type="button" className={iconButton} aria-label={`OSHA establishment details for ${site.name}`} title="OSHA establishment details" onClick={() => setEditingEstablishment(!editingEstablishment)}>
<FileText size={16} />
</button>
)}
{canManage && ( {canManage && (
<button <button
type="button" type="button"
@@ -142,6 +148,7 @@ function SiteCard({ site, tone, count, canManage }: { site: Site; tone: Tone; co
</button> </button>
)} )}
</div> </div>
{editingEstablishment && <EstablishmentForm site={site} onDone={() => setEditingEstablishment(false)} />}
<div className="border-t border-line px-6 py-4"> <div className="border-t border-line px-6 py-4">
{tree.length === 0 ? ( {tree.length === 0 ? (
<p className="text-2xs text-faint">No buildings or areas yet.</p> <p className="text-2xs text-faint">No buildings or areas yet.</p>
@@ -210,3 +217,61 @@ function SiteCard({ site, tone, count, canManage }: { site: Site; tone: Tone; co
</section> </section>
) )
} }
/** The details OSHA Form 300A and the electronic submission need for each establishment. */
function EstablishmentForm({ site, onDone }: { site: Site; onDone: () => void }) {
const { updateSite } = useSiteMutations()
const [form, setForm] = useState({
streetAddress: site.streetAddress ?? '',
city: site.city ?? '',
state: site.state ?? '',
zip: site.zip ?? '',
naicsCode: site.naicsCode ?? '',
industryDescription: site.industryDescription ?? '',
ein: site.ein ?? '',
establishmentType: (site.establishmentType ?? 1) as 1 | 2 | 3,
})
const input = (key: Exclude<keyof typeof form, 'establishmentType'>, label: string, props: Record<string, unknown> = {}) => (
<Field label={label} optional>
<input className={field} value={form[key]} onChange={(event) => setForm({ ...form, [key]: event.target.value })} {...props} />
</Field>
)
return (
<form
className="border-t border-line bg-canvas px-6 py-5"
onSubmit={(event) => {
event.preventDefault()
updateSite.mutate({ id: site.id, ...form }, { onSuccess: onDone })
}}
>
<p className="mb-3 text-2xs leading-[1.5] text-muted">OSHA establishment details, used on Form 300A and in the electronic (ITA) submission. Each site is one establishment.</p>
{input('streetAddress', 'Street address (no PO boxes)')}
<div className="grid gap-x-3 sm:grid-cols-[1fr_80px_120px]">
{input('city', 'City')}
{input('state', 'State', { maxLength: 2, placeholder: 'OH' })}
{input('zip', 'ZIP')}
</div>
<div className="grid gap-x-3 sm:grid-cols-2">
{input('naicsCode', 'NAICS code (6 digits)', { inputMode: 'numeric', placeholder: '332119' })}
{input('ein', 'Employer Identification Number (EIN)', { placeholder: '12-3456789' })}
</div>
{input('industryDescription', 'Industry description', { placeholder: 'e.g. Metal stamping' })}
<Field label="Type of establishment">
<select className={field} value={form.establishmentType} onChange={(event) => setForm({ ...form, establishmentType: Number(event.target.value) as 1 | 2 | 3 })}>
<option value={1}>Private sector</option>
<option value={2}>State government</option>
<option value={3}>Local government</option>
</select>
</Field>
<ErrorMessage error={updateSite.error} className="mb-3" />
<div className="flex justify-end gap-2">
<button type="button" className={secondaryButton} onClick={onDone}>
Cancel
</button>
<button type="submit" className={primaryButton} disabled={updateSite.isPending}>
Save details
</button>
</div>
</form>
)
}
+433
View File
@@ -0,0 +1,433 @@
import { AlertTriangle, CheckCircle2, FileDown, Printer } from 'lucide-react'
import { useState } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import type { OshaYear } from '../../shared/api-types.ts'
import { oshaCaseTypes } from '../../shared/enums.ts'
import { Page } from '../components/Page'
import { PageIntro } from '../components/PageIntro'
import { Empty, ErrorMessage, Loading } from '../components/QueryState'
import { filterBox, panel, primaryButton, secondaryButton, sectionHeading } from '../components/styles'
import { formatDate } from '../lib/dates'
import { oshaCaseTypeLabel } from '../lib/labels'
import { useOshaYear, useSaveHours, useSites } from '../lib/queries'
const tabs = [
{ id: 'log', label: '300 Log' },
{ id: 'summary', label: '300A Summary' },
{ id: 'hours', label: 'Hours & rates' },
{ id: 'submit', label: 'Submit to OSHA' },
] as const
type Tab = (typeof tabs)[number]['id']
const select = 'bg-transparent text-xs text-ink outline-none'
const th = 'border border-line px-2 py-1.5 text-left align-bottom font-semibold'
const td = 'border border-line px-2 py-1.5 align-top'
const mark = (on: boolean) => (on ? '✓' : '')
/** Landscape pages while this screen is showing (the 300 Log is wide). */
function LandscapePrint() {
return <style>{'@page { size: letter landscape; margin: 0.4in; }'}</style>
}
export function Osha() {
const [params, setParams] = useSearchParams()
const sites = useSites()
const thisYear = new Date().getFullYear()
const year = Number(params.get('year') ?? thisYear)
const site = sites.data?.find((s) => s.id === params.get('site')) ?? sites.data?.[0]
const tab = (tabs.find((t) => t.id === params.get('tab'))?.id ?? 'log') as Tab
const data = useOshaYear(site?.id, year)
const set = (key: string, value: string) => {
const next = new URLSearchParams(params)
next.set(key, value)
setParams(next, { replace: true })
}
if (sites.isPending) return <Loading />
if (!site) return <Empty>Add a site first.</Empty>
return (
<Page>
<LandscapePrint />
<div className="print:hidden">
<PageIntro
eyebrow="OSHA recordkeeping"
title="OSHA log"
copy="Forms 300, 300A and 301 for each establishment, built from the injured and ill people recorded on incidents."
action={
<button type="button" className={secondaryButton} onClick={() => window.print()}>
<Printer size={16} /> Print
</button>
}
/>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className={filterBox}>
<select className={select} value={site.id} onChange={(event) => set('site', event.target.value)} aria-label="Establishment">
{sites.data?.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
<div className={filterBox}>
<select className={select} value={year} onChange={(event) => set('year', event.target.value)} aria-label="Year">
{Array.from({ length: 6 }, (_, i) => thisYear - i).map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
</div>
<div className="flex gap-1 rounded-md bg-line-soft p-1" role="tablist">
{tabs.map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={tab === t.id}
className={`rounded-[5px] px-3 py-1.5 text-2xs font-semibold ${tab === t.id ? 'bg-forest-950 text-white' : 'text-muted hover:bg-white hover:text-ink'}`}
onClick={() => set('tab', t.id)}
>
{t.label}
</button>
))}
</div>
</div>
</div>
<ErrorMessage error={data.error} />
{data.isPending ? (
<Loading />
) : data.data ? (
tab === 'log' ? (
<Log data={data.data} />
) : tab === 'summary' ? (
<Summary data={data.data} />
) : tab === 'hours' ? (
<Hours key={`${data.data.site.id}-${data.data.year}`} data={data.data} />
) : (
<Submit data={data.data} />
)
) : null}
</Page>
)
}
function FormHeader({ form, title, data, note }: { form: string; title: string; data: OshaYear; note?: string }) {
const s = data.site
return (
<div className="mb-3 flex flex-wrap items-end justify-between gap-2">
<div>
<span className="font-mono text-3xs tracking-[0.06em] text-muted uppercase">OSHA {form} · Year {data.year}</span>
<h2 className="font-display text-xl font-semibold text-ink">{title}</h2>
{note && <p className="mt-1 max-w-[720px] text-3xs text-muted">{note}</p>}
</div>
<div className="text-right text-2xs text-body">
<strong>{s.name}</strong>
<br />
{[s.city, s.state].filter(Boolean).join(', ') || 'Address not set'}
</div>
</div>
)
}
function Log({ data }: { data: OshaYear }) {
const t = data.totals
return (
<section className={`${panel} p-5 print:border-0 print:p-0 print:shadow-none`}>
<FormHeader
form="Form 300"
title="Log of Work-Related Injuries and Illnesses"
data={data}
note="An equivalent of OSHA Form 300 (1904.29(b)(4)). Privacy cases show “Privacy case” instead of the name. Keep the log for 5 years after the end of the year it covers."
/>
{data.rows.length === 0 ? (
<Empty>No recordable cases for this establishment in {data.year}.</Empty>
) : (
<div className="overflow-auto">
<table className="w-full border-collapse text-3xs text-body">
<thead className="bg-canvas">
<tr>
<th className={th}>(A) Case no.</th>
<th className={th}>(B) Employee</th>
<th className={th}>(C) Job title</th>
<th className={th}>(D) Date</th>
<th className={th}>(E) Where</th>
<th className={th}>(F) Injury or illness, body part, object or substance</th>
<th className={th}>(G) Death</th>
<th className={th}>(H) Days away</th>
<th className={th}>(I) Restricted / transfer</th>
<th className={th}>(J) Other</th>
<th className={th}>(K) Days away</th>
<th className={th}>(L) Days restricted</th>
<th className={th}>(M) Type</th>
</tr>
</thead>
<tbody>
{data.rows.map((row) => (
<tr key={row.caseId}>
<td className={td}>
<Link to={`/incidents/${row.incidentNumber}`} className="text-coral hover:underline print:text-ink">
{row.caseNumber}
</Link>
</td>
<td className={td}>{row.employeeName}</td>
<td className={td}>{row.jobTitle}</td>
<td className={`${td} whitespace-nowrap`}>{formatDate(row.dateOfInjury)}</td>
<td className={td}>{row.location}</td>
<td className={td}>
{row.description}
{row.missingFields.length > 0 && <span className="block text-amber-strong print:hidden">Missing: {row.missingFields.join(', ')}</span>}
</td>
<td className={`${td} text-center`}>{mark(row.outcome === 'death')}</td>
<td className={`${td} text-center`}>{mark(row.outcome === 'days_away')}</td>
<td className={`${td} text-center`}>{mark(row.outcome === 'restricted')}</td>
<td className={`${td} text-center`}>{mark(row.outcome === 'other')}</td>
<td className={`${td} text-right`}>{row.daysAway || ''}</td>
<td className={`${td} text-right`}>{row.daysRestricted || ''}</td>
<td className={td}>{oshaCaseTypeLabel[row.caseType]}</td>
</tr>
))}
</tbody>
<tfoot className="bg-canvas font-semibold">
<tr>
<td className={td} colSpan={6}>
Page totals
</td>
<td className={`${td} text-center`}>{t.deaths}</td>
<td className={`${td} text-center`}>{t.daysAwayCases}</td>
<td className={`${td} text-center`}>{t.restrictedCases}</td>
<td className={`${td} text-center`}>{t.otherCases}</td>
<td className={`${td} text-right`}>{t.daysAway}</td>
<td className={`${td} text-right`}>{t.daysRestricted}</td>
<td className={td} />
</tr>
</tfoot>
</table>
</div>
)}
</section>
)
}
function Line({ label, value }: { label: string; value: string | number }) {
return (
<div className="flex justify-between gap-4 border-b border-line-soft py-1.5 text-xs">
<span className="text-body">{label}</span>
<strong className="font-mono font-normal text-ink">{value}</strong>
</div>
)
}
function Summary({ data }: { data: OshaYear }) {
const t = data.totals
const s = data.site
return (
<section className={`${panel} p-6 print:border-0 print:p-0 print:shadow-none`}>
<FormHeader
form="Form 300A"
title="Summary of Work-Related Injuries and Illnesses"
data={data}
note="Post this summary where notices to employees are usually posted from February 1 to April 30 of the year after the year it covers, even if there were no cases."
/>
<div className="grid gap-8 lg:grid-cols-3 print:grid-cols-3">
<div>
<h3 className="mb-2 text-2xs font-semibold text-[#586762] uppercase">Number of cases</h3>
<Line label="(G) Deaths" value={t.deaths} />
<Line label="(H) Days away from work" value={t.daysAwayCases} />
<Line label="(I) Job transfer or restriction" value={t.restrictedCases} />
<Line label="(J) Other recordable cases" value={t.otherCases} />
<h3 className="mt-5 mb-2 text-2xs font-semibold text-[#586762] uppercase">Number of days</h3>
<Line label="(K) Days away from work" value={t.daysAway} />
<Line label="(L) Days of job transfer or restriction" value={t.daysRestricted} />
</div>
<div>
<h3 className="mb-2 text-2xs font-semibold text-[#586762] uppercase">Injury and illness types</h3>
{oshaCaseTypes.map((type, i) => (
<Line key={type} label={`(M${i + 1}) ${oshaCaseTypeLabel[type]}`} value={t.byType[type]} />
))}
</div>
<div>
<h3 className="mb-2 text-2xs font-semibold text-[#586762] uppercase">Establishment information</h3>
<Line label="Establishment" value={s.name} />
<Line label="Company" value={data.companyName} />
<Line label="Street" value={s.streetAddress ?? '—'} />
<Line label="City, state, ZIP" value={[s.city, s.state, s.zip].filter(Boolean).join(' ') || '—'} />
<Line label="NAICS code" value={s.naicsCode ?? '—'} />
<Line label="Industry" value={s.industryDescription ?? '—'} />
<Line label="Annual average employees" value={data.annualAverageEmployees || '—'} />
<Line label="Total hours worked" value={data.totalHoursWorked ? data.totalHoursWorked.toLocaleString('en-US') : '—'} />
</div>
</div>
<div className="mt-8 grid gap-6 border-t border-line pt-5 text-2xs text-body sm:grid-cols-2 print:grid-cols-2">
<p className="leading-[1.6]">
<strong>Certification.</strong> I certify that I have examined this document and that to the best of my knowledge the entries are true, accurate and complete. Must be
signed by a company executive (1904.32(b)(3)).
</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-6">
{['Company executive', 'Title', 'Phone', 'Date'].map((label) => (
<div key={label} className="border-b border-ink pt-6 text-3xs text-muted">
{label}
</div>
))}
</div>
</div>
{data.missingEstablishmentFields.length > 0 && (
<p className="mt-4 text-2xs text-amber-strong print:hidden">
Missing establishment details: {data.missingEstablishmentFields.join(', ')}.{' '}
<Link to="/locations" className="underline">
Add them on the Locations page
</Link>
.
</p>
)}
</section>
)
}
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
function Hours({ data }: { data: OshaYear }) {
const save = useSaveHours(data.site.id)
const [months, setMonths] = useState(() => data.months.map((m) => ({ month: m.month, hours: m.hours?.toString() ?? '', averageEmployees: m.averageEmployees?.toString() ?? '' })))
const num = (value: string) => (value.trim() === '' ? null : Number(value))
const rate = (value: number | null) => (value === null ? '–' : value.toFixed(2))
return (
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
<section className={`${panel} p-5`}>
<h2 className={sectionHeading}>Hours worked and headcount, {data.year}</h2>
<p className="mt-1 mb-4 text-2xs leading-[1.5] text-muted">
Total hours actually worked by all employees (including overtime, excluding vacation and sick leave), and the average number of employees on the payroll each month.
These give the 300A totals and the incidence rates.
</p>
<form
onSubmit={(event) => {
event.preventDefault()
save.mutate({ year: data.year, months: months.map((m) => ({ month: m.month, hours: num(m.hours), averageEmployees: num(m.averageEmployees) === null ? null : Math.round(num(m.averageEmployees)!) })) })
}}
>
<table className="w-full text-xs">
<thead>
<tr className="text-left text-2xs text-[#586762]">
<th className="pb-2 font-semibold">Month</th>
<th className="pb-2 font-semibold">Hours worked</th>
<th className="pb-2 font-semibold">Average employees</th>
</tr>
</thead>
<tbody>
{months.map((m, i) => (
<tr key={m.month}>
<td className="py-1 pr-3 text-body">{monthNames[i]}</td>
{(['hours', 'averageEmployees'] as const).map((key) => (
<td key={key} className="py-1 pr-3">
<input
className="w-full rounded-[5px] border border-line bg-field px-2 py-1.5 text-xs outline-none focus:border-[#83b3a5]"
type="number"
min={0}
step={key === 'hours' ? '0.01' : '1'}
aria-label={`${monthNames[i]} ${key === 'hours' ? 'hours worked' : 'average employees'}`}
value={m[key]}
onChange={(event) => setMonths(months.map((x, j) => (j === i ? { ...x, [key]: event.target.value } : x)))}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="mt-4 flex items-center justify-end gap-3">
{save.isSuccess && <span className="text-2xs text-mint-strong">Saved</span>}
<ErrorMessage error={save.error} />
<button type="submit" className={primaryButton} disabled={save.isPending}>
Save hours
</button>
</div>
</form>
</section>
<section className={`${panel} p-5`}>
<h2 className={sectionHeading}>Incidence rates</h2>
<p className="mt-1 mb-4 text-2xs leading-[1.5] text-muted">Cases per 100 full-time workers a year: cases × 200,000 ÷ hours worked.</p>
{[
['TRIR', 'Total recordable incident rate: all recordable cases', data.rates.trir],
['DART', 'Days away, restricted or transferred cases', data.rates.dart],
['LTIR', 'Lost time: days-away cases', data.rates.ltir],
].map(([name, help, value]) => (
<div key={name as string} className="mb-4">
<div className="flex items-baseline justify-between">
<strong className="font-display text-lg text-ink">{name}</strong>
<span className="font-display text-2xl font-semibold text-ink">{rate(value as number | null)}</span>
</div>
<p className="text-3xs text-muted">{help as string}</p>
</div>
))}
<p className="border-t border-line pt-3 text-3xs text-muted">
{data.rows.length} recordable case{data.rows.length === 1 ? '' : 's'} · {data.totalHoursWorked.toLocaleString('en-US')} hours · {data.annualAverageEmployees} average employees
</p>
</section>
</div>
)
}
function Submit({ data }: { data: OshaYear }) {
const problems = [...data.missingEstablishmentFields.map((f) => `Establishment ${f} is missing (Locations page).`), ...data.checks.errors]
const casesMissing = data.rows.filter((r) => r.missingFields.length)
return (
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_340px]">
<section className={`${panel} p-5`}>
<h2 className={sectionHeading}>Electronic submission (Injury Tracking Application)</h2>
<p className="mt-1 text-2xs leading-[1.6] text-muted">
Establishments that must submit electronically upload their data to OSHA’s Injury Tracking Application by March 2 for the previous year. Which files you need depends on
your industry and size: the 300A summary for establishments with 20–249 employees in designated industries or 250+ in any covered industry, and also the case data for
establishments with 100+ employees in designated high-hazard industries. Check{' '}
<a href="https://www.osha.gov/injuryreporting" target="_blank" rel="noreferrer" className="text-coral underline">
osha.gov/injuryreporting
</a>{' '}
for your obligations.
</p>
<div className="mt-5 flex flex-wrap gap-3">
<a className={primaryButton} href={`/api/osha/ita/summary.csv?year=${data.year}`} download>
<FileDown size={16} /> 300A summary CSV (all sites)
</a>
<a className={secondaryButton} href={`/api/osha/ita/cases.csv?year=${data.year}`} download>
<FileDown size={16} /> 300/301 case data CSV (all sites)
</a>
</div>
<p className="mt-3 text-3xs text-muted">Files follow OSHA’s published CSV formats. Case data contains no names, as OSHA requires. Establishment names must match the names registered in the ITA.</p>
</section>
<section className={`${panel} p-5`}>
<h2 className={sectionHeading}>Checks for {data.site.name}</h2>
<p className="mt-1 mb-3 text-3xs text-muted">The same checks OSHA runs on upload.</p>
{problems.length === 0 && data.checks.warnings.length === 0 && casesMissing.length === 0 ? (
<p className="flex items-center gap-2 text-xs text-mint-strong">
<CheckCircle2 size={16} /> Ready to submit
</p>
) : (
<ul className="space-y-2 text-2xs">
{problems.map((p) => (
<li key={p} className="flex gap-2 text-coral-strong">
<AlertTriangle size={14} className="mt-px flex-none" /> {p}
</li>
))}
{data.checks.warnings.map((w) => (
<li key={w} className="flex gap-2 text-amber-strong">
<AlertTriangle size={14} className="mt-px flex-none" /> {w}
</li>
))}
{casesMissing.map((r) => (
<li key={r.caseId} className="flex gap-2 text-amber-strong">
<AlertTriangle size={14} className="mt-px flex-none" />
<span>
<Link to={`/incidents/${r.incidentNumber}`} className="underline">
Case {r.caseNumber}
</Link>{' '}
is missing {r.missingFields.join(', ')}.
</span>
</li>
))}
</ul>
)}
</section>
</div>
)
}
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022", "target": "ES2023",
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom/vitest"], "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom/vitest"],