Compare commits

..
6 Commits
Author SHA1 Message Date
jayjennings c150acf71d docs: improve contact information for clarity and professionalism
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
2026-10-13 21:40:32 -07:00
jayjennings 84b5a250e2 docs: update contact information section for clarity and correctness
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
2026-10-13 21:26:06 -07:00
jayjennings 187681a36f docs: add contact information for setup assistance and custom builds
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
2026-10-13 21:25:26 -07:00
jayjennings dab8915375 Refactor code structure for improved readability and maintainability
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
2026-10-13 21:06:10 -07:00
jayjennings 61c876edec 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.
2026-10-06 13:09:57 -07:00
jayjennings 3e964d1176 feat(i18n): add internationalization support with English and Spanish translations
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
feat(kiosk): implement kiosk report submission functionality with offline support

test(outbox): add tests for outbox functionality including report submission and error handling

feat(outbox): create outbox management for queued reports with IndexedDB persistence

feat(uuid): add UUID generation utility for client IDs

feat(qr): implement QR code generation for reporting links by site and area

feat(kiosk): create Kiosk page for reporting incidents without an account
2026-10-06 11:38:40 -07:00
110 changed files with 19239 additions and 436 deletions

No files matched your search

+2 -1
View File
@@ -5,7 +5,8 @@
"react/rules-of-hooks": "error", "react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }], "react/only-export-components": ["warn", { "allowConstantExport": true }],
"jsx-a11y/control-has-associated-label": "off", "jsx-a11y/control-has-associated-label": "off",
"jsx-a11y/prefer-tag-over-role": "off" "jsx-a11y/prefer-tag-over-role": "off",
"jsx-a11y/label-has-associated-control": ["error", { "depth": 4 }]
}, },
"ignorePatterns": ["dist", "playwright-report", "test-results"] "ignorePatterns": ["dist", "playwright-report", "test-results"]
} }
+1
View File
@@ -39,6 +39,7 @@ CI runs the same checks on every pull request.
- **Permissions:** every API route must check the signed-in user's role and organization. Add a test showing a lower role is refused. - **Permissions:** every API route must check the signed-in user's role and organization. Add a test showing a lower role is refused.
- **Styling:** Tailwind CSS. Use the theme tokens in `src/index.css` (`text-ink`, `bg-coral`, `border-line`, …) rather than new hex values; add a token if a color is reused. - **Styling:** Tailwind CSS. Use the theme tokens in `src/index.css` (`text-ink`, `bg-coral`, `border-line`, …) rather than new hex values; add a token if a color is reused.
- **Tests:** put domain logic in `src/lib/` with a unit test next to it (`*.test.ts`). Cover user-facing flows with a component test or a Playwright test in `e2e/`. - **Tests:** put domain logic in `src/lib/` with a unit test next to it (`*.test.ts`). Cover user-facing flows with a component test or a Playwright test in `e2e/`.
- **Translations:** user-facing text on worker screens lives in `src/i18n/en.ts` with the Spanish in `src/i18n/es.ts`. TypeScript and a test fail if a Spanish entry is missing, so add both together. If you can't translate, open the pull request anyway and ask for help.
- **Accessibility:** every form control needs a label, and every icon-only button needs an `aria-label`. Shop-floor users may be on gloves-on touchscreens, so keep touch targets generous. - **Accessibility:** every form control needs a label, and every icon-only button needs an `aria-label`. Shop-floor users may be on gloves-on touchscreens, so keep touch targets generous.
- **Safety data:** incident and injury records can contain medical and personal information. Never log them, and never add third-party scripts, fonts or analytics that would send data off the server. - **Safety data:** incident and injury records can contain medical and personal information. Never log them, and never add third-party scripts, fonts or analytics that would send data off the server.
+32 -2
View File
@@ -2,7 +2,21 @@
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–2 done).** Reporting, investigation, corrective actions, email notifications, dashboards and CSV export work end to end. OSHA record keeping and offline shop-floor reporting are 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.
![Overview dashboard: open incidents, actions due and overdue, average time to close, recent incidents and where incidents are in the workflow](docs/screenshots/dashboard.png)
## Screenshots
Taken with the demo data from `npm run db:seed`, signed in as the safety manager.
| Incident register | Incident workflow |
| --- | --- |
| ![Incidents list with filters, saved views and CSV export](docs/screenshots/incidents.png) | ![An incident in triage with the workflow panel](docs/screenshots/incident-detail.png) |
| **OSHA Form 300 log** | **Analytics** |
| ![OSHA Form 300 log for one site, with missing details flagged](docs/screenshots/osha-300-log.png) | ![Reports per month chart by incident type](docs/screenshots/analytics.png) |
<p align="center"><img src="docs/screenshots/report-mobile.png" width="300" alt="Report an incident form on a phone"></p>
## Getting started ## Getting started
@@ -14,7 +28,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.
@@ -33,6 +47,19 @@ This runs the app and PostgreSQL. The app applies database migrations on startup
- Set `SMTP_URL` and `MAIL_FROM` so sign-in links, invitations and notifications are delivered. - Set `SMTP_URL` and `MAIL_FROM` so sign-in links, invitations and notifications are delivered.
- Set `TZ` to the plant's time zone (e.g. `America/Chicago`) so due dates and daily reminders follow local days. - Set `TZ` to the plant's time zone (e.g. `America/Chicago`) so due dates and daily reminders follow local days.
- 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.
## 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
- **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.
- **Offline:** reports made without a connection are kept on the device and sent automatically when it returns.
- **Spanish:** use the language button (sidebar, sign-in page or kiosk header). The choice is remembered on that device.
For a very small single-machine install, the app image also runs on its own with embedded PostgreSQL: `docker run -p 3000:3000 -v safetylog-data:/app/data <image>`. For a very small single-machine install, the app image also runs on its own with embedded PostgreSQL: `docker run -p 3000:3000 -v safetylog-data:/app/data <image>`.
@@ -79,3 +106,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). Everyone taking part follows our [Code o
## License ## License
[GNU Affero General Public License v3.0](LICENSE). If you run a modified version as a network service, you must offer its source code to its users. The app's About page links to the source; set `VITE_SOURCE_URL` at build time to point it at your fork. [GNU Affero General Public License v3.0](LICENSE). If you run a modified version as a network service, you must offer its source code to its users. The app's About page links to the source; set `VITE_SOURCE_URL` at build time to point it at your fork.
## Contact Info
Please contact me, Jay Jennings, at jay@equineform.com if you need assistance with site setup and configuration. I am also open to custom builds when you have special requirements or ideas that might better serve the safety community.
+46 -2
View File
@@ -68,6 +68,8 @@ Addressed by number (`/api/incidents/42` is `INC-0042`). Numbers are sequential
**List and export filters:** `page`, `pageSize` (1–100, default 25), `q` (title, description, location; `INC-0042` or `42` matches the number), `type`, `severity`, `status`, `siteId`. **List and export filters:** `page`, `pageSize` (1–100, default 25), `q` (title, description, location; `INC-0042` or `42` matches the number), `type`, `severity`, `status`, `siteId`.
Send a random `clientId` (UUID) with each report. Resubmitting the same `clientId` returns the original report (200 instead of 201) instead of creating a duplicate, so devices can safely retry after a dropped connection.
**Create body:** **Create body:**
```json ```json
@@ -81,7 +83,8 @@ Addressed by number (`/api/incidents/42` is `INC-0042`). Numbers are sequential
"locationDetail": "Loading bay C", "locationDetail": "Loading bay C",
"description": "…", "description": "…",
"immediateAction": "Area cordoned off", "immediateAction": "Area cordoned off",
"anonymous": false "anonymous": false,
"clientId": "6f1c…"
} }
``` ```
@@ -119,12 +122,53 @@ Addressed by number (`/api/incidents/42` is `INC-0042`). Numbers are sequential
Each action has `overdue: true` when it's open or in progress and its due date has passed (server time zone, `TZ`). Assignees get one email digest per day listing their actions due tomorrow or overdue. Each action has `overdue: true` when it's open or in progress and its due date has passed (server time zone, `TZ`). Assignees get one email digest per day listing their actions due tomorrow or overdue.
## Reporting links (kiosks and QR codes)
Reporting links let people file reports without an account. Each has a token that can **only** create reports.
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/kiosks` | safety_manager | All links, including their tokens (to reprint QR codes) |
| POST | `/api/kiosks` | safety_manager | `{ name, siteId? }`. `siteId` restricts the link to one site |
| PATCH | `/api/kiosks/:id` | safety_manager | `{ name?, siteId?, revoked? }` |
| GET | `/api/kiosk/config` | link token | Organization name, link name, and the sites/areas it may report on |
| POST | `/api/kiosk/reports` | link token | Multipart: `report` (JSON: the create body minus `anonymous`, plus optional `reporterName`) and up to 5 `files`. Returns `{ reference, failedFiles }`. 30 reports per link per 10 minutes |
The token goes in the `X-Kiosk-Token` header. Browsers can't send custom headers cross-site without a CORS preflight, which the API doesn't allow, so other websites can't submit through a link. Reports without `reporterName` are anonymous. Incidents filed this way have `channel: "kiosk"` and `kiosk: { id, name }`.
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.
+28 -10
View File
@@ -17,6 +17,12 @@ This is a living document. Update it as decisions are made or scope changes.
| Compliance scope | US OSHA first; data model kept extensible for other regions (e.g. RIDDOR) | Proposed | | Compliance scope | US OSHA first; data model kept extensible for other regions (e.g. RIDDOR) | Proposed |
| Incident PDFs | Browser print-to-PDF from a print stylesheet; server-side PDF deferred (see TODO) | Decided | | Incident PDFs | Browser print-to-PDF from a print stylesheet; server-side PDF deferred (see TODO) | Decided |
| Email | SMTP via `SMTP_URL`; without it, logged in development and dropped in production. Delivery setup deferred (see TODO) | Decided | | Email | SMTP via `SMTP_URL`; without it, logged in development and dropped in production. Delivery setup deferred (see TODO) | 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 |
| 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 |
@@ -61,22 +67,34 @@ This is a living document. Update it as decisions are made or scope changes.
## Phase 3: Shop-floor reporting ## Phase 3: Shop-floor reporting
- Installable PWA with offline queue (sync when connection returns) - [x] Installable app (web manifest, icons, service worker caching the app shell plus who-am-I and site lists)
- Printable QR codes per area that pre-fill the location - [x] Offline outbox: reports and photos saved on the device, sent automatically on reconnect; duplicate-proof retries via a client-generated id; reports only sent for the user who made them; warning on sign-out with unsent reports
- Kiosk mode and anonymous reporting - [x] Reporting links (managed in Settings): revocable tokens, optionally limited to one site, 30 reports per 10 minutes, photos included; kiosk page for shared tablets with large touch targets and auto-reset
- Multi-language support, Spanish first - [x] Printable QR code sheets per site/area, for signed-in users or through a reporting link, with bilingual captions; scanning pre-fills the location
- [x] Anonymous reporting from the app, the kiosk and QR codes
- [x] English/Spanish: sign-in pages, navigation, report form, kiosk, My actions, offline messages; language toggle remembered per device; follows the browser language by default
- [x] Tests: outbox (IndexedDB), translations complete, reporting links and duplicate-proof retries on the server, browser tests for offline sync, Spanish kiosk via QR, QR pre-fill and installability
## 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
+27
View File
@@ -23,6 +23,33 @@ Open items that aren't tied to a single roadmap phase. Tick them off (or delete
- [ ] **HEIC thumbnails.** iPhone HEIC photos are stored and downloadable but browsers can't show them inline; convert to JPEG on upload. - [ ] **HEIC thumbnails.** iPhone HEIC photos are stored and downloadable but browsers can't show them inline; convert to JPEG on upload.
- [ ] **Session check console noise.** Signed-out visitors see one 401 in the browser console from the session check; harmless, but a `200 { user: null }` endpoint would be quieter. - [ ] **Session check console noise.** Signed-out visitors see one 401 in the browser console from the session check; harmless, but a `200 { user: null }` endpoint would be quieter.
## Phase 3 follow-ups
- [ ] **Translate manager screens.** Spanish currently covers what shop-floor workers use (sign-in, report form, kiosk, My actions, navigation). The incident page, investigation, dashboards, analytics, locations and settings are still English. Add their strings to `src/i18n/en.ts` and `es.ts`.
- [ ] **Spanish emails.** Notification and sign-in emails are English only. Store a language preference per user and use it for emails.
- [ ] **HTTPS for offline use.** Browsers only allow installing the app and the offline app shell over HTTPS (or localhost). Reports still queue offline over plain HTTP once the page is open, but the page itself can't load offline. Document a simple HTTPS setup (e.g. Caddy in front of the app) in the install guide.
- [ ] **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.
## 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+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) {
+7 -2
View File
@@ -12,8 +12,13 @@ export default async function globalSetup() {
} }
await ok(await api.post('/api/setup', { data: { organizationName: 'E2E Plant', ...users.admin, password: PASSWORD } })) await ok(await api.post('/api/setup', { data: { organizationName: 'E2E Plant', ...users.admin, password: PASSWORD } }))
const { site } = await ok(await api.post('/api/sites', { data: { name: 'Main Plant' } })) const { site } = await ok(await api.post('/api/sites', { data: { name: 'Main Plant' } }))
await ok(await api.post(`/api/sites/${site.id}/areas`, { data: { name: 'Press Shop', kind: 'building' } })) const { area } = await ok(await api.post(`/api/sites/${site.id}/areas`, { data: { name: 'Press Shop', kind: 'building' } }))
for (const role of ['supervisor', 'reporter'] as const) { const { kiosk } = await ok(await api.post('/api/kiosks', { data: { name: 'Canteen tablet' }, headers: { origin: baseURL } }))
// Shared with the test workers
process.env.E2E_KIOSK_TOKEN = kiosk.token
process.env.E2E_SITE_ID = site.id
process.env.E2E_AREA_ID = area.id
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)
})
+92
View File
@@ -0,0 +1,92 @@
import { expect, test } from '@playwright/test'
import { signIn, unique, users } from './fixtures'
const token = () => process.env.E2E_KIOSK_TOKEN!
const site = () => process.env.E2E_SITE_ID!
const area = () => process.env.E2E_AREA_ID!
test('saves a report offline and sends it when the connection returns', async ({ page, context }) => {
const title = unique('Offline report: frayed sling')
await signIn(page, 'reporter')
await page.goto('/report')
await expect(page.getByLabel('Incident title')).toBeVisible()
await context.setOffline(true)
await page.getByLabel('Incident title').fill(title)
await page.getByRole('button', { name: 'Submit report' }).click()
await expect(page.getByRole('heading', { name: 'Saved on this device' })).toBeVisible()
await expect(page.getByText('1 report waiting to send')).toBeVisible()
await context.setOffline(false)
await expect(page.getByText('1 report waiting to send')).toBeHidden({ timeout: 15_000 })
await page.goto('/incidents')
await expect(page.getByRole('link', { name: title })).toBeVisible()
})
test('kiosk: reports without an account, in Spanish, from a QR code', async ({ browser, page }) => {
const title = unique('Cable suelto en el pasillo')
const kiosk = await (await browser.newContext({ locale: 'es-MX' })).newPage()
await kiosk.goto(`/k/${token()}?site=${site()}&area=${area()}`)
await expect(kiosk.getByRole('heading', { name: 'Reporte un problema de seguridad' })).toBeVisible()
await expect(kiosk.getByLabel('Edificio o área')).toHaveValue(area())
await kiosk.getByText('Peligro', { exact: true }).click()
await kiosk.getByText('Alta', { exact: true }).click()
await kiosk.getByLabel('En pocas palabras, ¿qué vio?').fill(title)
await kiosk.getByLabel('Su nombre').fill('Luis')
await kiosk.getByRole('button', { name: 'Enviar reporte' }).click()
await expect(kiosk.getByRole('heading', { name: 'Gracias' })).toBeVisible()
const reference = (await kiosk.getByText(/Su reporte INC-\d{4}/).textContent())!.match(/INC-\d{4}/)![0]
// Switching language works on the kiosk too
await kiosk.getByRole('button', { name: 'Reportar otra cosa' }).click()
await kiosk.getByRole('button', { name: 'English' }).click()
await expect(kiosk.getByRole('heading', { name: 'Report a safety concern' })).toBeVisible()
// The safety team sees who reported it and through which link
await signIn(page, 'admin')
await page.goto(`/incidents/${Number(reference.slice(4))}`)
await expect(page.getByRole('heading', { level: 1 })).toHaveText(title)
await expect(page.getByText('Reported by Luis via Canteen tablet')).toBeVisible()
await expect(page.getByText('Press Shop').first()).toBeVisible()
})
test('kiosk: a made-up or revoked link explains itself', async ({ page }) => {
await page.goto('/k/not-a-real-token')
await expect(page.getByText('This reporting link isn’t working')).toBeVisible()
})
test('QR codes open the report form with the location filled in', async ({ page, isMobile }) => {
test.skip(isMobile, 'Printing QR sheets is a desktop task')
await signIn(page, 'admin')
await page.goto(`/locations/qr?site=${site()}`)
await expect(page.getByRole('img', { name: /QR code for/ })).toHaveCount(2) // whole site + Press Shop
const url = await page.locator('main').getByText(/\/report\?site=/).nth(1).textContent()
expect(url).toContain(`area=${area()}`)
await page.goto(url!.trim())
await expect(page.getByText('Location filled in from the QR code.')).toBeVisible()
await expect(page.getByLabel('Building or area')).toHaveValue(area())
})
test('the signed-in app speaks Spanish too', async ({ page, isMobile }) => {
await signIn(page, 'reporter')
if (isMobile) await page.getByRole('button', { name: 'Open menu' }).click()
await page.getByRole('button', { name: 'Español' }).click()
if (isMobile) await page.getByRole('button', { name: 'Cerrar menú' }).click()
await page.goto('/report')
await expect(page.getByRole('heading', { name: 'Reportar un incidente' })).toBeVisible()
await expect(page.locator('html')).toHaveAttribute('lang', 'es')
await page.goto('/actions')
await expect(page.getByRole('heading', { name: 'Mis acciones' })).toBeVisible()
expect(users.reporter.email).toBeTruthy()
})
test('is installable: manifest and service worker', async ({ page }) => {
await page.goto('/login')
const manifest = await (await page.request.get('/manifest.webmanifest')).json()
expect(manifest).toMatchObject({ name: 'Open SafetyLog EHS', display: 'standalone' })
expect(manifest.icons.map((icon: { sizes: string }) => icon.sizes)).toEqual(expect.arrayContaining(['192x192', '512x512']))
const scope = await page.evaluate(async () => (await navigator.serviceWorker.ready).scope)
expect(scope).toMatch(/\/$/)
})
+2
View File
@@ -3,6 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<meta name="theme-color" content="#163c37" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Open SafetyLog EHS</title> <title>Open SafetyLog EHS</title>
<meta name="description" content="Open-source accident and safety tracking for manufacturing" /> <meta name="description" content="Open-source accident and safety tracking for manufacturing" />
+5480 -6
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -47,20 +47,25 @@
"@types/node": "^26.6.4", "@types/node": "^26.6.4",
"@types/nodemailer": "^8.0.2", "@types/nodemailer": "^8.0.2",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4", "@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.1.0", "@vitejs/plugin-react": "^6.1.0",
"concurrently": "^10.0.5", "concurrently": "^10.0.5",
"drizzle-kit": "^0.31.11", "drizzle-kit": "^0.31.11",
"fake-indexeddb": "^6.2.5",
"jsdom": "^30.1.2", "jsdom": "^30.1.2",
"lucide-react": "^1.39.0", "lucide-react": "^1.39.0",
"oxlint": "^1.79.0", "oxlint": "^1.79.0",
"qrcode": "^1.5.4",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router-dom": "^7.18.3", "react-router-dom": "^7.18.3",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "^8.2.2", "vite": "^8.2.2",
"vitest": "^5.0.3" "vite-plugin-pwa": "^2.0.0",
"vitest": "^5.0.3",
"workbox-window": "^7.4.1"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

+28
View File
@@ -0,0 +1,28 @@
// Renders the app icon to the PNG sizes browsers need to install the app.
// Run after changing the icon: node scripts/generate-icons.mjs
import { chromium } from '@playwright/test'
const shield = (stroke) => `
<path d="M16 6.5 8.5 9.5v6c0 4.6 3.2 8.6 7.5 9.9 4.3-1.3 7.5-5.3 7.5-9.9v-6L16 6.5Z" fill="none" stroke="#fff" stroke-width="${stroke}" stroke-linejoin="round"/>
<path d="m12.5 16 2.4 2.4 4.6-4.8" fill="none" stroke="#fff" stroke-width="${stroke}" stroke-linecap="round" stroke-linejoin="round"/>`
// Regular icon: rounded tile. Maskable: full-bleed square with the mark inside the safe zone.
const regular = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#ee7258"/>${shield(2.2)}</svg>`
const maskable = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#ee7258"/><g transform="translate(16 16) scale(0.72) translate(-16 -16)">${shield(2.4)}</g></svg>`
const outputs = [
['public/icons/icon-192.png', regular, 192],
['public/icons/icon-512.png', regular, 512],
['public/icons/maskable-512.png', maskable, 512],
['public/icons/apple-touch-icon.png', maskable, 180],
]
const browser = await chromium.launch()
const page = await browser.newPage()
for (const [path, svg, size] of outputs) {
await page.setViewportSize({ width: size, height: size })
await page.setContent(`<style>html,body{margin:0;background:transparent}</style><img src="data:image/svg+xml,${encodeURIComponent(svg)}" width="${size}" height="${size}">`)
await page.locator('img').screenshot({ path, omitBackground: true })
console.log('wrote', path)
}
await browser.close()
+7
View File
@@ -11,11 +11,14 @@ import { actionRoutes } from './routes/actions.ts'
import { attachmentRoutes } from './routes/attachments.ts' 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 { 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 {
@@ -54,6 +57,10 @@ export function createApp({ db, config, notifier, storage }: AppDeps) {
app.route('/api/actions', actionRoutes) app.route('/api/actions', actionRoutes)
app.route('/api/attachments', attachmentRoutes) app.route('/api/attachments', attachmentRoutes)
app.route('/api/stats', statsRoutes) app.route('/api/stats', statsRoutes)
app.route('/api/kiosks', kioskAdminRoutes)
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,24 @@
CREATE TYPE "public"."report_channel" AS ENUM('web', 'kiosk');--> statement-breakpoint
CREATE TABLE "kiosks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"name" text NOT NULL,
"token" text NOT NULL,
"site_id" uuid,
"created_by_id" uuid,
"last_used_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "incidents" ADD COLUMN "reporter_name" text;--> statement-breakpoint
ALTER TABLE "incidents" ADD COLUMN "channel" "report_channel" DEFAULT 'web' NOT NULL;--> statement-breakpoint
ALTER TABLE "incidents" ADD COLUMN "kiosk_id" uuid;--> statement-breakpoint
ALTER TABLE "incidents" ADD COLUMN "client_id" uuid;--> statement-breakpoint
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_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 "kiosks" ADD CONSTRAINT "kiosks_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "kiosks_token_unique" ON "kiosks" USING btree ("token");--> statement-breakpoint
CREATE INDEX "kiosks_organization_idx" ON "kiosks" USING btree ("organization_id");--> statement-breakpoint
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_kiosk_id_kiosks_id_fk" FOREIGN KEY ("kiosk_id") REFERENCES "public"."kiosks"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "incidents_organization_client_id_unique" ON "incidents" USING btree ("organization_id","client_id");
@@ -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
File diff suppressed because it is too large. Load diff
+21
View File
@@ -15,6 +15,27 @@
"when": 1791309275942, "when": 1791309275942,
"tag": "0001_workflow_and_email", "tag": "0001_workflow_and_email",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1791310953907,
"tag": "0002_kiosks_and_offline",
"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
} }
] ]
} }
+102
View File
@@ -13,6 +13,10 @@ export const rootCauseCategoryEnum = pgEnum('root_cause_category', vocab.rootCau
export const actionKindEnum = pgEnum('action_kind', vocab.actionKinds) 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 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 })
@@ -90,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(),
@@ -115,6 +129,29 @@ export const areas = pgTable(
(t) => [index('areas_site_idx').on(t.siteId), index('areas_parent_idx').on(t.parentId)], (t) => [index('areas_site_idx').on(t.siteId), index('areas_parent_idx').on(t.parentId)],
) )
/**
* Reporting links let people without accounts file reports: on a shared kiosk tablet, or by scanning
* a QR code. The token only allows creating reports, so it is stored as-is to let admins reprint QR codes.
*/
export const kiosks = pgTable(
'kiosks',
{
id: id(),
organizationId: uuid()
.notNull()
.references(() => organizations.id),
name: text().notNull(),
token: text().notNull(),
/** Limits the link to one site; null allows any. */
siteId: uuid().references(() => sites.id, { onDelete: 'set null' }),
createdById: uuid().references(() => users.id, { onDelete: 'set null' }),
lastUsedAt: timestamptz(),
revokedAt: timestamptz(),
createdAt: createdAt(),
},
(t) => [uniqueIndex('kiosks_token_unique').on(t.token), index('kiosks_organization_idx').on(t.organizationId)],
)
/** Per-organization counter for gap-free, human-friendly incident numbers. */ /** Per-organization counter for gap-free, human-friendly incident numbers. */
export const incidentCounters = pgTable('incident_counters', { export const incidentCounters = pgTable('incident_counters', {
organizationId: uuid() organizationId: uuid()
@@ -146,12 +183,19 @@ export const incidents = pgTable(
/** Null when reported anonymously. */ /** Null when reported anonymously. */
reporterId: uuid().references(() => users.id, { onDelete: 'set null' }), reporterId: uuid().references(() => users.id, { onDelete: 'set null' }),
anonymous: boolean().notNull().default(false), anonymous: boolean().notNull().default(false),
/** Name typed by someone reporting without an account (kiosk/QR). */
reporterName: text(),
channel: reportChannelEnum().notNull().default('web'),
kioskId: uuid().references(() => kiosks.id, { onDelete: 'set null' }),
/** Generated on the reporting device so retries after a lost response don't create duplicates. */
clientId: uuid(),
closedAt: timestamptz(), closedAt: timestamptz(),
createdAt: createdAt(), createdAt: createdAt(),
updatedAt: updatedAt(), updatedAt: updatedAt(),
}, },
(t) => [ (t) => [
uniqueIndex('incidents_organization_number_unique').on(t.organizationId, t.number), uniqueIndex('incidents_organization_number_unique').on(t.organizationId, t.number),
uniqueIndex('incidents_organization_client_id_unique').on(t.organizationId, t.clientId),
index('incidents_organization_occurred_idx').on(t.organizationId, t.occurredAt), index('incidents_organization_occurred_idx').on(t.organizationId, t.occurredAt),
index('incidents_organization_status_idx').on(t.organizationId, t.status), index('incidents_organization_status_idx').on(t.organizationId, t.status),
index('incidents_reporter_idx').on(t.reporterId), index('incidents_reporter_idx').on(t.reporterId),
@@ -177,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(),
}, },
@@ -299,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,
}
}
+39
View File
@@ -0,0 +1,39 @@
import { count, eq } from 'drizzle-orm'
import type { Config } from '../config.ts'
import type { Db } from '../db/client.ts'
import { attachments } from '../db/schema.ts'
import type { FileStorage } from '../storage.ts'
import { detectFileType } from './file-type.ts'
import { fail } from './http.ts'
export const MAX_ATTACHMENTS_PER_INCIDENT = 20
/** Validates and stores one uploaded file against an incident. */
export async function saveAttachment(
{ db, storage, config }: { db: Db; storage: FileStorage; config: Config },
incidentId: string,
file: unknown,
uploadedById: string | null,
) {
if (!(file instanceof File)) throw fail(400, 'Attach a file in the "file" field')
if (file.size > config.maxUploadBytes) throw fail(413, `Files can be at most ${Math.round(config.maxUploadBytes / 1024 / 1024)} MB`)
const [{ existing }] = await db.select({ existing: count() }).from(attachments).where(eq(attachments.incidentId, incidentId))
if (existing >= MAX_ATTACHMENTS_PER_INCIDENT) throw fail(409, `An incident can have at most ${MAX_ATTACHMENTS_PER_INCIDENT} attachments`)
const data = new Uint8Array(await file.arrayBuffer())
const type = detectFileType(data)
if (!type) throw fail(400, 'Only photos (JPEG, PNG, WebP, GIF, HEIC) and PDFs can be attached')
const storageKey = await storage.save(data, type.extension)
const fileName = (file.name || `attachment.${type.extension}`).replace(/[\r\n"\\/]/g, '_').slice(0, 200)
try {
const [row] = await db
.insert(attachments)
.values({ incidentId, fileName, contentType: type.contentType, sizeBytes: data.byteLength, storageKey, uploadedById })
.returning()
return row
} catch (error) {
await storage.remove(storageKey)
throw error
}
}
+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'
} }
+11 -5
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, 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'
@@ -15,16 +15,17 @@ const reporters = alias(users, 'reporter')
export function selectIncidents(db: Db) { export function selectIncidents(db: Db) {
return db return db
.select({ incident: incidents, siteName: sites.name, areaName: areas.name, reporterName: reporters.name }) .select({ incident: incidents, siteName: sites.name, areaName: areas.name, reporterName: reporters.name, kioskName: kiosks.name })
.from(incidents) .from(incidents)
.innerJoin(sites, eq(sites.id, incidents.siteId)) .innerJoin(sites, eq(sites.id, incidents.siteId))
.leftJoin(areas, eq(areas.id, incidents.areaId)) .leftJoin(areas, eq(areas.id, incidents.areaId))
.leftJoin(reporters, eq(reporters.id, incidents.reporterId)) .leftJoin(reporters, eq(reporters.id, incidents.reporterId))
.leftJoin(kiosks, eq(kiosks.id, incidents.kioskId))
} }
type IncidentRow = Awaited<ReturnType<ReturnType<typeof selectIncidents>['execute']>>[number] type IncidentRow = Awaited<ReturnType<ReturnType<typeof selectIncidents>['execute']>>[number]
export function toIncident({ incident, siteName, areaName, reporterName }: IncidentRow): Incident { export function toIncident({ incident, siteName, areaName, reporterName, kioskName }: IncidentRow): Incident {
return { return {
id: incident.id, id: incident.id,
number: incident.number, number: incident.number,
@@ -44,6 +45,9 @@ export function toIncident({ incident, siteName, areaName, reporterName }: Incid
immediateAction: incident.immediateAction, immediateAction: incident.immediateAction,
anonymous: incident.anonymous, anonymous: incident.anonymous,
reporter: incident.reporterId ? { id: incident.reporterId, name: reporterName ?? '' } : null, reporter: incident.reporterId ? { id: incident.reporterId, name: reporterName ?? '' } : null,
reporterName: incident.reporterName,
channel: incident.channel,
kiosk: incident.kioskId ? { id: incident.kioskId, name: kioskName ?? '' } : null,
} }
} }
@@ -155,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),
@@ -167,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))
}
+115
View File
@@ -0,0 +1,115 @@
import { and, eq, isNull, sql } from 'drizzle-orm'
import type { IncidentType, Severity } from '../../shared/enums.ts'
import type { SessionUser } from '../auth/sessions.ts'
import { isUniqueViolation, type Db } from '../db/client.ts'
import { areas, incidentCounters, incidents, sites } from '../db/schema.ts'
import type { Notifier } from '../notifications.ts'
import { fail } from './http.ts'
import { auditIncident } from './incidents.ts'
/** Reports may be dated slightly ahead to absorb clock differences between devices. */
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000
export function parseOccurredAt(value: string): Date {
const date = new Date(value)
if (date.getTime() > Date.now() + MAX_CLOCK_SKEW_MS) throw fail(400, 'Occurrence time cannot be in the future')
return date
}
export async function assertLocation(db: Db, organizationId: string, siteId: string, areaId: string | null | undefined) {
const [site] = await db
.select({ id: sites.id })
.from(sites)
.where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId), isNull(sites.archivedAt)))
if (!site) throw fail(400, 'Unknown site')
if (areaId) {
const [area] = await db
.select({ id: areas.id })
.from(areas)
.where(and(eq(areas.id, areaId), eq(areas.siteId, siteId)))
if (!area) throw fail(400, 'Area must belong to the selected site')
}
}
export interface NewReport {
organizationId: string
title: string
type: IncidentType
severity: Severity
occurredAt: string
siteId: string
areaId?: string | null
locationDetail?: string | null
description?: string | null
immediateAction?: string | null
clientId?: string
/** Signed-in reporter, or null for anonymous and kiosk reports. */
reporter: SessionUser | null
reporterName?: string | null
anonymous: boolean
kioskId?: string
}
/**
* Files a report with the next incident number. If `clientId` was already used in the organization
* (a retried submission), returns the existing incident instead of creating a duplicate.
*/
export async function fileReport(db: Db, notifier: Notifier, report: NewReport): Promise<{ id: string; number: number; created: boolean }> {
const { organizationId, reporter, anonymous, kioskId, occurredAt, clientId, ...fields } = report
await assertLocation(db, organizationId, fields.siteId, fields.areaId)
const occurred = parseOccurredAt(occurredAt)
const existing = async () => {
if (!clientId) return undefined
const [row] = await db
.select({ id: incidents.id, number: incidents.number })
.from(incidents)
.where(and(eq(incidents.organizationId, organizationId), eq(incidents.clientId, clientId)))
return row && { ...row, created: false }
}
const previous = await existing()
if (previous) return previous
try {
const created = await db.transaction(async (tx) => {
// Atomic increment: concurrent reports always get distinct, gap-free numbers.
const [{ lastNumber }] = await tx
.insert(incidentCounters)
.values({ organizationId, lastNumber: 1 })
.onConflictDoUpdate({ target: incidentCounters.organizationId, set: { lastNumber: sql`${incidentCounters.lastNumber} + 1` } })
.returning({ lastNumber: incidentCounters.lastNumber })
const [incident] = await tx
.insert(incidents)
.values({
...fields,
occurredAt: occurred,
organizationId,
number: lastNumber,
clientId,
anonymous,
reporterId: anonymous ? null : (reporter?.id ?? null),
reporterName: anonymous ? null : (fields.reporterName ?? null),
channel: kioskId ? 'kiosk' : 'web',
kioskId,
})
.returning({ id: incidents.id, number: incidents.number })
await auditIncident(tx, anonymous ? null : reporter, organizationId, incident.id, 'create', {
number: lastNumber,
title: fields.title,
type: fields.type,
severity: fields.severity,
...(kioskId ? { kioskId } : {}),
})
return incident
})
notifier.incidentReported(created.id)
return { ...created, created: true }
} catch (error) {
// Two copies of the same submission raced; the other one won.
if (clientId && isUniqueViolation(error)) {
const winner = await existing()
if (winner) return winner
}
throw error
}
}
+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)
})
})
+11 -78
View File
@@ -1,4 +1,4 @@
import { and, asc, count, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm' import { and, asc, count, desc, eq, ilike, or, type SQL } from 'drizzle-orm'
import { Hono } from 'hono' import { Hono } from 'hono'
import { bodyLimit } from 'hono/body-limit' import { bodyLimit } from 'hono/body-limit'
import { z } from 'zod' import { z } from 'zod'
@@ -19,42 +19,19 @@ import { transitionBlocker, transitions } from '../../shared/workflow.ts'
import { requireAuth, requireRole } from '../auth/middleware.ts' import { requireAuth, requireRole } from '../auth/middleware.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, auditLog, comments, correctiveActions, incidentCounters, incidents, investigations, rootCauses, sites, users } from '../db/schema.ts' import { auditLog, comments, correctiveActions, incidents, investigations, rootCauses, users } from '../db/schema.ts'
import { diff } from '../lib/audit.ts' import { diff } from '../lib/audit.ts'
import { toCsv } from '../lib/csv.ts' import { toCsv } from '../lib/csv.ts'
import { saveAttachment } from '../lib/attachments.ts'
import { iso } from '../lib/dates.ts' import { iso } from '../lib/dates.ts'
import { detectFileType } from '../lib/file-type.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts' import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
import { assertActiveUser, auditIncident, findIncident, loadDetail, selectIncidents, toAttachment, toIncident, visibleTo } from '../lib/incidents.ts' import { assertActiveUser, auditIncident, findIncident, loadDetail, selectIncidents, toAttachment, toIncident, visibleTo } from '../lib/incidents.ts'
import { assertLocation, fileReport, parseOccurredAt } from '../lib/reporting.ts'
const numberParam = z.object({ number: z.coerce.number().int().positive() }) const numberParam = z.object({ number: z.coerce.number().int().positive() })
/** Reports may be dated slightly ahead to absorb clock differences between devices. */
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000
const MAX_ATTACHMENTS_PER_INCIDENT = 20
const EXPORT_LIMIT = 10_000 const EXPORT_LIMIT = 10_000
async function assertLocation(db: Db, organizationId: string, siteId: string, areaId: string | null | undefined) {
const [site] = await db
.select({ id: sites.id })
.from(sites)
.where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId), isNull(sites.archivedAt)))
if (!site) throw fail(400, 'Unknown site')
if (areaId) {
const [area] = await db
.select({ id: areas.id })
.from(areas)
.where(and(eq(areas.id, areaId), eq(areas.siteId, siteId)))
if (!area) throw fail(400, 'Area must belong to the selected site')
}
}
function parseOccurredAt(value: string): Date {
const date = new Date(value)
if (date.getTime() > Date.now() + MAX_CLOCK_SKEW_MS) throw fail(400, 'Occurrence time cannot be in the future')
return date
}
const escapeLike = (value: string) => value.replace(/[\\%_]/g, '\\$&') const escapeLike = (value: string) => value.replace(/[\\%_]/g, '\\$&')
function listFilter(db: Db, user: SessionUser, { q, type, severity, status, siteId }: Omit<IncidentListQuery, 'page' | 'pageSize'>) { function listFilter(db: Db, user: SessionUser, { q, type, severity, status, siteId }: Omit<IncidentListQuery, 'page' | 'pageSize'>) {
@@ -122,7 +99,7 @@ export const incidentRoutes = new Hono<AppEnv>()
i.site.name, i.site.name,
i.area?.name, i.area?.name,
i.locationDetail, i.locationDetail,
i.anonymous ? 'Anonymous' : i.reporter?.name, i.anonymous ? 'Anonymous' : (i.reporter?.name ?? i.reporterName ?? (i.kiosk ? `Reporting link: ${i.kiosk.name}` : null)),
i.description, i.description,
i.immediateAction, i.immediateAction,
]), ]),
@@ -133,34 +110,11 @@ export const incidentRoutes = new Hono<AppEnv>()
.post('/', requireAuth, validate('json', createIncidentSchema), async (c) => { .post('/', requireAuth, validate('json', createIncidentSchema), async (c) => {
const me = currentUser(c) const me = currentUser(c)
const { anonymous, occurredAt, ...input } = c.req.valid('json') const input = c.req.valid('json')
const db = c.get('db') const db = c.get('db')
await assertLocation(db, me.organizationId, input.siteId, input.areaId) const { id, created } = await fileReport(db, c.get('notifier'), { ...input, organizationId: me.organizationId, reporter: me })
const occurred = parseOccurredAt(occurredAt) const [row] = await selectIncidents(db).where(eq(incidents.id, id))
return c.json({ incident: toIncident(row) }, created ? 201 : 200)
const created = await db.transaction(async (tx) => {
// Atomic increment: concurrent reports always get distinct, gap-free numbers.
const [{ lastNumber }] = await tx
.insert(incidentCounters)
.values({ organizationId: me.organizationId, lastNumber: 1 })
.onConflictDoUpdate({ target: incidentCounters.organizationId, set: { lastNumber: sql`${incidentCounters.lastNumber} + 1` } })
.returning({ lastNumber: incidentCounters.lastNumber })
const [incident] = await tx
.insert(incidents)
.values({ ...input, occurredAt: occurred, organizationId: me.organizationId, number: lastNumber, anonymous, reporterId: anonymous ? null : me.id })
.returning({ id: incidents.id, number: incidents.number })
await auditIncident(tx, anonymous ? null : me, me.organizationId, incident.id, 'create', {
number: lastNumber,
title: input.title,
type: input.type,
severity: input.severity,
})
return incident
})
c.get('notifier').incidentReported(created.id)
const [row] = await selectIncidents(db).where(eq(incidents.id, created.id))
return c.json({ incident: toIncident(row) }, 201)
}) })
.get('/:number', requireAuth, validate('param', numberParam), async (c) => { .get('/:number', requireAuth, validate('param', numberParam), async (c) => {
@@ -330,29 +284,8 @@ export const incidentRoutes = new Hono<AppEnv>()
const { incident } = await findIncident(db, me, c.req.valid('param').number) const { incident } = await findIncident(db, me, c.req.valid('param').number)
const body = await c.req.parseBody() const body = await c.req.parseBody()
const file = body.file const row = await saveAttachment({ db, storage: c.get('storage'), config }, incident.id, body.file, me.id)
if (!(file instanceof File)) throw fail(400, 'Attach a file in the "file" field') await auditIncident(db, me, me.organizationId, incident.id, 'attachment.add', { attachmentId: row.id, fileName: row.fileName })
if (file.size > config.maxUploadBytes) throw fail(413, `Files can be at most ${Math.round(config.maxUploadBytes / 1024 / 1024)} MB`)
const [{ existing }] = await db.select({ existing: count() }).from(attachments).where(eq(attachments.incidentId, incident.id))
if (existing >= MAX_ATTACHMENTS_PER_INCIDENT) throw fail(409, `An incident can have at most ${MAX_ATTACHMENTS_PER_INCIDENT} attachments`)
const data = new Uint8Array(await file.arrayBuffer())
const type = detectFileType(data)
if (!type) throw fail(400, 'Only photos (JPEG, PNG, WebP, GIF, HEIC) and PDFs can be attached')
const storage = c.get('storage')
const storageKey = await storage.save(data, type.extension)
const fileName = (file.name || `attachment.${type.extension}`).replace(/[\r\n"\\/]/g, '_').slice(0, 200)
try {
const [row] = await db
.insert(attachments)
.values({ incidentId: incident.id, fileName, contentType: type.contentType, sizeBytes: data.byteLength, storageKey, uploadedById: me.id })
.returning()
await auditIncident(db, me, me.organizationId, incident.id, 'attachment.add', { attachmentId: row.id, fileName })
return c.json({ attachment: toAttachment(row, me.name) }, 201) return c.json({ attachment: toAttachment(row, me.name) }, 201)
} catch (error) {
await storage.remove(storageKey)
throw error
}
}, },
) )
+124
View File
@@ -0,0 +1,124 @@
// oxlint-disable typescript/no-explicit-any -- tests read arbitrary JSON
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestContext, hoursAgo } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
let north: { id: string }
let south: { id: string }
const json = (res: Response): Promise<any> => res.json()
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52])
/** Submits like the kiosk page: multipart with the token header. */
function kioskReport(token: string, report: Record<string, unknown>, files: File[] = []) {
const form = new FormData()
form.append('report', JSON.stringify(report))
for (const file of files) form.append('files', file)
return ctx.app.request('/api/kiosk/reports', { method: 'POST', headers: { 'x-kiosk-token': token, origin: 'http://safety.test' }, body: form })
}
const report = (overrides: Record<string, unknown> = {}) => ({ title: 'Leaking valve', type: 'hazard', severity: 'medium', occurredAt: hoursAgo(1), siteId: north.id, ...overrides })
beforeAll(async () => {
ctx = await createTestContext()
north = await ctx.createSite('North')
south = await ctx.createSite('South')
})
afterAll(() => ctx.close())
describe('reporting links', () => {
it('are managed by safety managers', async () => {
const supervisor = await ctx.createUser('supervisor')
expect((await supervisor.post('/api/kiosks', { name: 'Canteen tablet' })).status).toBe(403)
const res = await ctx.admin.post('/api/kiosks', { name: 'Canteen tablet' })
expect(res.status).toBe(201)
expect(res.body.kiosk.token).toMatch(/^[\w-]{32}$/)
expect((await ctx.admin.get('/api/kiosks')).body.kiosks.map((k: { name: string }) => k.name)).toContain('Canteen tablet')
})
it('let anyone with the token file a report, with photos, and nothing more', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Gate 2' })).body
const config = await ctx.app.request('/api/kiosk/config', { headers: { 'x-kiosk-token': kiosk.token } })
expect(await json(config)).toMatchObject({ organization: { name: 'Northstar Co.' }, kiosk: { name: 'Gate 2' } })
const res = await kioskReport(kiosk.token, report({ reporterName: 'Luis' }), [new File([PNG], 'valve.png', { type: 'image/png' })])
expect(res.status).toBe(201)
const { reference, failedFiles } = await json(res)
expect(reference).toMatch(/^INC-\d{4}$/)
expect(failedFiles).toEqual([])
const number = Number(reference.slice(4))
const incident = (await ctx.admin.get(`/api/incidents/${number}`)).body.incident
expect(incident).toMatchObject({ channel: 'kiosk', kiosk: { name: 'Gate 2' }, reporterName: 'Luis', reporter: null, anonymous: false })
expect(incident.attachments).toHaveLength(1)
// The token is not a session
expect((await ctx.app.request('/api/incidents', { headers: { 'x-kiosk-token': kiosk.token } })).status).toBe(401)
})
it('treat reports without a name as anonymous', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Anon' })).body
const { reference } = await json(await kioskReport(kiosk.token, report()))
const incident = (await ctx.admin.get(`/api/incidents/${Number(reference.slice(4))}`)).body.incident
expect(incident).toMatchObject({ anonymous: true, reporterName: null })
})
it('stop working when revoked', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Old tablet' })).body
await ctx.admin.patch(`/api/kiosks/${kiosk.id}`, { revoked: true })
expect((await kioskReport(kiosk.token, report())).status).toBe(401)
expect((await kioskReport('made-up-token', report())).status).toBe(401)
})
it('can be limited to one site', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'North only', siteId: north.id })).body
const config = await json(await ctx.app.request('/api/kiosk/config', { headers: { 'x-kiosk-token': kiosk.token } }))
expect(config.sites.map((s: { id: string }) => s.id)).toEqual([north.id])
expect((await kioskReport(kiosk.token, report({ siteId: south.id }))).status).toBe(400)
})
it('validate reports', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Validation' })).body
const res = await kioskReport(kiosk.token, report({ type: 'explosion' }))
expect(res.status).toBe(400)
expect((await json(res)).issues[0].path).toBe('type')
})
it('are rate limited', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Busy' })).body
for (let i = 0; i < 30; i++) expect((await kioskReport(kiosk.token, report({ severity: 'low' }))).status).toBe(201)
expect((await kioskReport(kiosk.token, report({ severity: 'low' }))).status).toBe(429)
})
})
describe('resubmitting the same report (offline retries)', () => {
it('returns the original instead of creating a duplicate', async () => {
const reporter = await ctx.createUser('reporter')
const clientId = crypto.randomUUID()
const first = await reporter.post('/api/incidents', { ...report(), clientId })
const again = await reporter.post('/api/incidents', { ...report(), clientId })
expect(first.status).toBe(201)
expect(again.status).toBe(200)
expect(again.body.incident.number).toBe(first.body.incident.number)
})
it('holds under simultaneous retries', async () => {
const reporter = await ctx.createUser('reporter', 'racer@example.com')
const clientId = crypto.randomUUID()
const results = await Promise.all([1, 2, 3].map(() => reporter.post('/api/incidents', { ...report(), clientId })))
expect(new Set(results.map((r) => r.body.incident.number)).size).toBe(1)
})
it('does not attach photos twice from a kiosk retry', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Retry' })).body
const clientId = crypto.randomUUID()
const file = () => new File([PNG], 'p.png', { type: 'image/png' })
const a = await json(await kioskReport(kiosk.token, report({ clientId }), [file()]))
const b = await kioskReport(kiosk.token, report({ clientId }), [file()])
expect(b.status).toBe(200)
expect((await json(b)).reference).toBe(a.reference)
const incident = (await ctx.admin.get(`/api/incidents/${Number(a.reference.slice(4))}`)).body.incident
expect(incident.attachments).toHaveLength(1)
})
})
+177
View File
@@ -0,0 +1,177 @@
import { and, asc, eq, inArray, isNull } from 'drizzle-orm'
import { Hono, type Context } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { randomBytes } from 'node:crypto'
import { z } from 'zod'
import type { Kiosk, KioskConfig } from '../../shared/api-types.ts'
import { incidentReference } from '../../shared/format.ts'
import { createKioskSchema, kioskReportSchema, updateKioskSchema } from '../../shared/schemas.ts'
import { requireRole } from '../auth/middleware.ts'
import { RateLimiter } from '../auth/rate-limit.ts'
import type { Db } from '../db/client.ts'
import { areas, kiosks, organizations, sites } from '../db/schema.ts'
import { saveAttachment } from '../lib/attachments.ts'
import { audit, diff } from '../lib/audit.ts'
import { iso } from '../lib/dates.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
import { auditIncident } from '../lib/incidents.ts'
import { fileReport } from '../lib/reporting.ts'
export const KIOSK_HEADER = 'x-kiosk-token'
const MAX_FILES_PER_REPORT = 5
const idParam = z.object({ id: z.uuid() })
async function toKiosk(db: Db, row: typeof kiosks.$inferSelect): Promise<Kiosk> {
const [site] = row.siteId ? await db.select({ id: sites.id, name: sites.name }).from(sites).where(eq(sites.id, row.siteId)) : []
return { id: row.id, name: row.name, token: row.token, site: site ?? null, lastUsedAt: iso(row.lastUsedAt), revokedAt: iso(row.revokedAt), createdAt: iso(row.createdAt) }
}
async function assertSite(db: Db, organizationId: string, siteId: string | null | undefined) {
if (!siteId) return
const [site] = await db.select({ id: sites.id }).from(sites).where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId)))
if (!site) throw fail(400, 'Unknown site')
}
/** Managing reporting links (signed-in safety managers). */
export const kioskAdminRoutes = new Hono<AppEnv>()
.get('/', requireRole('safety_manager'), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const rows = await db.select().from(kiosks).where(eq(kiosks.organizationId, me.organizationId)).orderBy(asc(kiosks.name))
return c.json({ kiosks: await Promise.all(rows.map((row) => toKiosk(db, row))) })
})
.post('/', requireRole('safety_manager'), validate('json', createKioskSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const input = c.req.valid('json')
await assertSite(db, me.organizationId, input.siteId)
const [row] = await db
.insert(kiosks)
.values({ ...input, organizationId: me.organizationId, token: randomBytes(24).toString('base64url'), createdById: me.id })
.returning()
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'kiosk', entityId: row.id, action: 'create', changes: { name: row.name } })
return c.json({ kiosk: await toKiosk(db, row) }, 201)
})
.patch('/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateKioskSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const [current] = await db
.select()
.from(kiosks)
.where(and(eq(kiosks.id, c.req.valid('param').id), eq(kiosks.organizationId, me.organizationId)))
if (!current) throw fail(404, 'Reporting link not found')
const { revoked, ...fields } = c.req.valid('json')
await assertSite(db, me.organizationId, fields.siteId)
const update = { ...fields, revokedAt: revoked === undefined ? undefined : revoked ? (current.revokedAt ?? new Date()) : null }
const changes = diff(current, update)
if (Object.keys(changes).length === 0) return c.json({ kiosk: await toKiosk(db, current) })
const [row] = await db.update(kiosks).set(update).where(eq(kiosks.id, current.id)).returning()
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'kiosk', entityId: row.id, action: 'update', changes })
return c.json({ kiosk: await toKiosk(db, row) })
})
/**
* Endpoints used with a reporting link's token instead of a session. The token travels in a custom
* header, which browsers never send cross-site without a CORS preflight, so these can't be forged
* from another site.
*/
export function kioskPublicRoutes() {
// Each link can file 30 reports per 10 minutes: plenty for a busy shop floor, a brake on abuse.
const limiter = new RateLimiter(30, 10 * 60 * 1000)
async function kioskFrom(c: Context<AppEnv>) {
const token = c.req.header(KIOSK_HEADER)
if (!token) throw fail(401, 'Missing reporting link token')
const [row] = await c
.get('db')
.select()
.from(kiosks)
.where(and(eq(kiosks.token, token), isNull(kiosks.revokedAt)))
if (!row) throw fail(401, 'This reporting link is no longer active. Ask your safety team for a new one.')
return row
}
return new Hono<AppEnv>()
.get('/config', async (c) => {
const kiosk = await kioskFrom(c)
const db = c.get('db')
const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, kiosk.organizationId))
const siteRows = await db
.select()
.from(sites)
.where(and(eq(sites.organizationId, kiosk.organizationId), isNull(sites.archivedAt), kiosk.siteId ? eq(sites.id, kiosk.siteId) : undefined))
.orderBy(asc(sites.name))
const areaRows = siteRows.length
? await db
.select()
.from(areas)
.where(and(inArray(areas.siteId, siteRows.map((s) => s.id)), isNull(areas.archivedAt)))
.orderBy(asc(areas.name))
: []
const config: KioskConfig = {
organization,
kiosk: { name: kiosk.name },
sites: siteRows.map((site) => ({
id: site.id,
name: site.name,
address: null,
timezone: site.timezone,
archivedAt: null,
areas: areaRows
.filter((area) => area.siteId === site.id)
.map((area) => ({ id: area.id, siteId: area.siteId, parentId: area.parentId, name: area.name, kind: area.kind, archivedAt: null })),
})),
}
return c.json(config)
})
// Multipart: a `report` field holding the JSON report, plus up to 5 `files`.
.post('/reports', async (c, next) => bodyLimit({ maxSize: c.get('config').maxUploadBytes * MAX_FILES_PER_REPORT + 256 * 1024, onError: (c) => c.json({ error: 'Files are too large' }, 413) })(c, next), async (c) => {
const kiosk = await kioskFrom(c)
if (!limiter.attempt(kiosk.id)) throw fail(429, 'Too many reports from this link right now. Please try again in a few minutes.')
const db = c.get('db')
const body = await c.req.parseBody({ all: true })
let json: unknown
try {
json = JSON.parse(String(body.report ?? ''))
} catch {
throw fail(400, 'Missing report')
}
const parsed = kioskReportSchema.safeParse(json)
if (!parsed.success) {
return c.json({ error: 'Invalid request', issues: parsed.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })) }, 400)
}
if (kiosk.siteId && parsed.data.siteId !== kiosk.siteId) throw fail(400, 'This reporting link is for a different site')
const files = ([] as unknown[]).concat(body.files ?? []).filter((file) => file instanceof File)
if (files.length > MAX_FILES_PER_REPORT) throw fail(400, `Attach at most ${MAX_FILES_PER_REPORT} photos`)
const { reporterName, ...report } = parsed.data
const { id, number, created } = await fileReport(db, c.get('notifier'), {
...report,
organizationId: kiosk.organizationId,
reporter: null,
reporterName,
anonymous: !reporterName,
kioskId: kiosk.id,
})
await db.update(kiosks).set({ lastUsedAt: new Date() }).where(eq(kiosks.id, kiosk.id))
// Files are only taken with the first copy of a submission, so retries don't duplicate photos.
const failed: string[] = []
if (created) {
for (const file of files) {
try {
const row = await saveAttachment({ db, storage: c.get('storage'), config: c.get('config') }, id, file, null)
await auditIncident(db, null, kiosk.organizationId, id, 'attachment.add', { attachmentId: row.id, fileName: row.fileName })
} catch (error) {
failed.push(`${(file as File).name}: ${error instanceof Error ? error.message : 'upload failed'}`)
}
}
}
// Only the reference goes back: people using a link can't read reports.
return c.json({ reference: incidentReference(number), failedFiles: failed }, created ? 201 : 200)
})
}
+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)
})
+178 -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
@@ -73,6 +89,28 @@ export interface Incident {
immediateAction: string | null immediateAction: string | null
anonymous: boolean anonymous: boolean
reporter: UserRef | null reporter: UserRef | null
/** Name given by someone reporting without an account. */
reporterName: string | null
/** How the report arrived; `kiosk` covers kiosk tablets and QR codes on reporting links. */
channel: 'web' | 'kiosk'
kiosk: { id: string; name: string } | null
}
export interface Kiosk {
id: string
name: string
token: string
site: { id: string; name: string } | null
lastUsedAt: string | null
revokedAt: string | null
createdAt: string
}
/** What a reporting link may see: just enough to fill in the report form. */
export interface KioskConfig {
organization: { name: string }
kiosk: { name: string }
sites: Site[]
} }
export interface Investigation { export interface Investigation {
@@ -131,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 {
@@ -171,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 }
}
+116 -3
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() })
@@ -39,10 +126,12 @@ export const createIncidentSchema = z.object({
description: optionalText(10_000), description: optionalText(10_000),
immediateAction: optionalText(10_000), immediateAction: optionalText(10_000),
anonymous: z.boolean().default(false), anonymous: z.boolean().default(false),
/** Random id from the reporting device; resubmitting the same id returns the existing report. */
clientId: z.uuid().optional(),
}) })
/** Status changes go through transitions, not updates. */ /** Status changes go through transitions, not updates. */
export const updateIncidentSchema = createIncidentSchema.omit({ anonymous: true }).partial() export const updateIncidentSchema = createIncidentSchema.omit({ anonymous: true, clientId: true }).partial()
export const transitionSchema = z.object({ to: z.enum(incidentStatuses), note: optionalText(5_000) }) export const transitionSchema = z.object({ to: z.enum(incidentStatuses), note: optionalText(5_000) })
@@ -87,6 +176,27 @@ export const actionListQuery = z.object({
filter: z.enum(['open', 'overdue', 'done', 'all']).default('open'), filter: z.enum(['open', 'overdue', 'done', 'all']).default('open'),
}) })
export const createKioskSchema = z.object({ name, siteId: z.uuid().nullish() })
export const updateKioskSchema = z.object({ name: name.optional(), siteId: z.uuid().nullish(), revoked: z.boolean().optional() })
/** 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 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) })
@@ -105,6 +215,9 @@ 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 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>
export type UpdateActionInput = z.input<typeof updateActionSchema> export type UpdateActionInput = z.input<typeof updateActionSchema>
+8
View File
@@ -4,10 +4,14 @@ 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 { 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 { ReportIncident } from './pages/ReportIncident' import { ReportIncident } from './pages/ReportIncident'
import { Settings } from './pages/Settings' import { Settings } from './pages/Settings'
@@ -18,13 +22,17 @@ export default function App() {
<Route path="setup" element={<Setup />} /> <Route path="setup" element={<Setup />} />
<Route path="auth/magic" element={<MagicLinkLanding />} /> <Route path="auth/magic" element={<MagicLinkLanding />} />
<Route path="auth/reset" element={<ResetPassword />} /> <Route path="auth/reset" element={<ResetPassword />} />
<Route path="k/:token" element={<Kiosk />} />
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route index element={<Overview />} /> <Route index element={<Overview />} />
<Route path="incidents" element={<Incidents />} /> <Route path="incidents" element={<Incidents />} />
<Route path="incidents/:number" element={<IncidentDetail />} /> <Route path="incidents/:number" element={<IncidentDetail />} />
<Route path="actions" element={<Actions />} /> <Route path="actions" element={<Actions />} />
<Route path="locations" element={<Locations />} /> <Route path="locations" element={<Locations />} />
<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 />} />
+7
View File
@@ -1,7 +1,10 @@
import { useState } from 'react' import { useState } from 'react'
import { Navigate, Outlet, useLocation } from 'react-router-dom' import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useActions, useMe, useSetupStatus } from '../lib/queries' import { useActions, useMe, useSetupStatus } from '../lib/queries'
import type { QueuedItem } from '../lib/outbox'
import { useOutboxSync } from '../lib/use-outbox'
import { Header } from './Header' import { Header } from './Header'
import { OutboxBanner } from './OutboxBanner'
import { ErrorMessage, Loading } from './QueryState' import { ErrorMessage, Loading } from './QueryState'
import { Sidebar } from './Sidebar' import { Sidebar } from './Sidebar'
@@ -12,6 +15,9 @@ export function AppLayout() {
const location = useLocation() const location = useLocation()
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const myOpenActions = useActions('mine', 'open', !!me.data) const myOpenActions = useActions('mine', 'open', !!me.data)
const userId = me.data?.user.id
const belongs = userId ? (item: QueuedItem) => item.kind === 'report' && item.userId === userId : null
useOutboxSync(belongs)
if (me.isPending) return <Loading /> if (me.isPending) return <Loading />
if (me.isError) return <ErrorMessage error={me.error} className="m-6" /> if (me.isError) return <ErrorMessage error={me.error} className="m-6" />
@@ -28,6 +34,7 @@ export function AppLayout() {
<Sidebar me={me.data} open={menuOpen} onClose={() => setMenuOpen(false)} badges={{ '/actions': open.length }} /> <Sidebar me={me.data} open={menuOpen} onClose={() => setMenuOpen(false)} badges={{ '/actions': open.length }} />
<main className="min-w-0 flex-1"> <main className="min-w-0 flex-1">
<Header me={me.data} onOpenMenu={() => setMenuOpen(true)} overdueActions={overdue} /> <Header me={me.data} onOpenMenu={() => setMenuOpen(true)} overdueActions={overdue} />
<OutboxBanner belongs={belongs!} />
<Outlet context={me.data} /> <Outlet context={me.data} />
</main> </main>
</div> </div>
+4
View File
@@ -1,5 +1,6 @@
import { ShieldCheck } from 'lucide-react' import { ShieldCheck } from 'lucide-react'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { LanguageToggle } from './LanguageToggle'
import { panel } from './styles' import { panel } from './styles'
/** Centered card for sign-in, setup and email-link pages. */ /** Centered card for sign-in, setup and email-link pages. */
@@ -18,6 +19,9 @@ export function AuthLayout({ title, copy, children }: { title: string; copy?: st
{copy && <p className="mt-1.5 text-xs leading-[1.6] text-muted">{copy}</p>} {copy && <p className="mt-1.5 text-xs leading-[1.6] text-muted">{copy}</p>}
<div className="mt-6">{children}</div> <div className="mt-6">{children}</div>
</div> </div>
<div className="mt-4 flex justify-center">
<LanguageToggle className="text-muted hover:text-ink" />
</div>
</div> </div>
</div> </div>
) )
+1 -1
View File
@@ -17,4 +17,4 @@ export function SeverityLabel({ severity }: { severity: Severity }) {
) )
} }
export const OverdueBadge = () => <span className={`${chip} bg-coral-soft font-semibold text-coral-strong`}>Overdue</span> export const OverdueBadge = ({ label = 'Overdue' }: { label?: string }) => <span className={`${chip} bg-coral-soft font-semibold text-coral-strong`}>{label}</span>
+10 -2
View File
@@ -22,11 +22,19 @@ export function FormSection({ number, title, copy, children }: FormSectionProps)
) )
} }
export function Field({ label, optional = false, children }: { label: string; optional?: boolean; children: ReactNode }) { interface FieldProps {
label: string
optional?: boolean
/** Translated "Optional" marker. */
optionalLabel?: string
children: ReactNode
}
export function Field({ label, optional = false, optionalLabel = 'Optional', children }: FieldProps) {
return ( return (
<label className="mb-[17px] block text-2xs font-semibold text-[#586762]"> <label className="mb-[17px] block text-2xs font-semibold text-[#586762]">
{label} {label}
{optional && <em className="float-right font-normal text-[#9ba7a2] not-italic">Optional</em>} {optional && <em className="float-right font-normal text-[#9ba7a2] not-italic">{optionalLabel}</em>}
{children} {children}
</label> </label>
) )
+6 -4
View File
@@ -1,6 +1,7 @@
import { Bell, Menu, Search } from 'lucide-react' import { Bell, Menu, Search } from 'lucide-react'
import { useState, type FormEvent } from 'react' import { useState, type FormEvent } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useI18n } from '../i18n'
import { initials } from '../lib/initials' import { initials } from '../lib/initials'
import { pageTitle } from '../lib/navigation' import { pageTitle } from '../lib/navigation'
import type { Me } from '../lib/queries' import type { Me } from '../lib/queries'
@@ -10,6 +11,7 @@ export function Header({ me, onOpenMenu, overdueActions }: { me: Me; onOpenMenu:
const { pathname } = useLocation() const { pathname } = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const { t } = useI18n()
const search = (event: FormEvent) => { const search = (event: FormEvent) => {
event.preventDefault() event.preventDefault()
@@ -19,7 +21,7 @@ export function Header({ me, onOpenMenu, overdueActions }: { me: Me; onOpenMenu:
return ( return (
<header className="flex h-[62px] items-center justify-between gap-3 border-b border-line bg-white/60 px-[17px] sm:h-[72px] sm:px-6 lg:px-[42px] print:hidden"> <header className="flex h-[62px] items-center justify-between gap-3 border-b border-line bg-white/60 px-[17px] sm:h-[72px] sm:px-6 lg:px-[42px] print:hidden">
<button type="button" className={`${iconButton} sm:hidden`} onClick={onOpenMenu} aria-label="Open menu"> <button type="button" className={`${iconButton} sm:hidden`} onClick={onOpenMenu} aria-label={t.nav.openMenu}>
<Menu size={21} /> <Menu size={21} />
</button> </button>
<div className="flex min-w-0 items-center gap-2.5 text-xs text-[#94a09b]"> <div className="flex min-w-0 items-center gap-2.5 text-xs text-[#94a09b]">
@@ -33,14 +35,14 @@ export function Header({ me, onOpenMenu, overdueActions }: { me: Me; onOpenMenu:
<Search size={15} /> <Search size={15} />
<input <input
className="h-[34px] w-[180px] bg-transparent text-xs text-ink outline-none" className="h-[34px] w-[180px] bg-transparent text-xs text-ink outline-none"
placeholder="Search incidents" placeholder={t.nav.searchIncidents}
aria-label="Search incidents" aria-label={t.nav.searchIncidents}
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
/> />
</form> </form>
</search> </search>
<Link to="/incidents" className={`${iconButton} md:hidden`} aria-label="Search incidents"> <Link to="/incidents" className={`${iconButton} md:hidden`} aria-label={t.nav.searchIncidents}>
<Search size={19} /> <Search size={19} />
</Link> </Link>
<Link to="/actions?filter=overdue" className={`${iconButton} relative`} aria-label={overdueActions ? `${overdueActions} overdue actions` : 'My actions'}> <Link to="/actions?filter=overdue" className={`${iconButton} relative`} aria-label={overdueActions ? `${overdueActions} overdue actions` : 'My actions'}>
+1 -1
View File
@@ -45,7 +45,7 @@ export function IncidentTable({ incidents, wide = false }: { incidents: Incident
{incident.title} {incident.title}
</Link> </Link>
<small className="mt-1 block text-3xs text-faint"> <small className="mt-1 block text-3xs text-faint">
{incident.reference} · {incident.anonymous ? 'Anonymous' : (incident.reporter?.name ?? 'Unknown')} {incident.reference} · {incident.anonymous ? 'Anonymous' : (incident.reporter?.name ?? incident.reporterName ?? 'Unknown')}
</small> </small>
</div> </div>
</div> </div>
+12
View File
@@ -0,0 +1,12 @@
import { Languages } from 'lucide-react'
import { useI18n } from '../i18n'
/** Switches between English and Spanish; shows the language you'd switch to, in that language. */
export function LanguageToggle({ className = '' }: { className?: string }) {
const { lang, setLang, t } = useI18n()
return (
<button type="button" className={`inline-flex cursor-pointer items-center gap-1.5 text-2xs font-semibold ${className}`} onClick={() => setLang(lang === 'en' ? 'es' : 'en')} lang={lang === 'en' ? 'es' : 'en'}>
<Languages size={15} /> {t.language.switchTo}
</button>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { CloudOff, RefreshCw, Trash2 } from 'lucide-react'
import { useI18n } from '../i18n'
import { outbox, type QueuedItem } from '../lib/outbox'
import { useOnline, useOutbox } from '../lib/use-outbox'
/** Offline notice plus any reports still waiting on this device. */
export function OutboxBanner({ belongs }: { belongs: (item: QueuedItem) => boolean }) {
const { t } = useI18n()
const online = useOnline()
const { items, syncing } = useOutbox()
const mine = items.filter(belongs)
if (online && mine.length === 0) return null
return (
<div className="border-b border-[#f3d9a4] bg-[#fff8e6] px-[17px] py-2.5 text-2xs text-[#7a5a12] sm:px-6 lg:px-[42px] print:hidden" role="status">
{!online && (
<p className="flex items-center gap-2">
<CloudOff size={15} className="flex-none" /> {t.outbox.offline}
</p>
)}
{mine.length > 0 && (
<div className={!online ? 'mt-1.5' : ''}>
<div className="flex flex-wrap items-center gap-3">
<strong className="font-semibold">{t.outbox.waiting(mine.length)}</strong>
{online && (
<button type="button" className="inline-flex items-center gap-1 font-semibold underline disabled:opacity-50" disabled={syncing} onClick={() => void outbox.flush(belongs)}>
<RefreshCw size={13} className={syncing ? 'animate-spin' : ''} /> {syncing ? t.outbox.sending : t.outbox.retry}
</button>
)}
</div>
{mine
.filter((item) => item.rejected)
.map((item) => (
<p key={item.clientId} className="mt-1 flex items-center gap-2">
“{item.report.title}”: {t.outbox.failed(item.lastError ?? '')}
<button type="button" className="inline-flex items-center gap-1 underline" onClick={() => confirm(t.outbox.discardConfirm) && void outbox.discard(item.clientId)}>
<Trash2 size={12} /> {t.outbox.discard}
</button>
</p>
))}
</div>
)}
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
import QRCode from 'qrcode'
import { useMemo } from 'react'
/** QR code as crisp SVG squares (prints sharply at any size). */
export function QrCode({ value, size = 160, label }: { value: string; size?: number; label: string }) {
const { path, count } = useMemo(() => {
const { modules } = QRCode.create(value, { errorCorrectionLevel: 'M' })
let d = ''
for (let y = 0; y < modules.size; y++) for (let x = 0; x < modules.size; x++) if (modules.get(x, y)) d += `M${x} ${y}h1v1h-1z`
return { path: d, count: modules.size }
}, [value])
const margin = 2
return (
<svg width={size} height={size} viewBox={`${-margin} ${-margin} ${count + margin * 2} ${count + margin * 2}`} role="img" aria-label={label} shapeRendering="crispEdges">
<rect x={-margin} y={-margin} width={count + margin * 2} height={count + margin * 2} fill="#fff" />
<path d={path} fill="#000" />
</svg>
)
}
+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>
)
}
+25 -11
View File
@@ -1,10 +1,14 @@
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 { outbox } from '../lib/outbox'
import { initials } from '../lib/initials' import { initials } from '../lib/initials'
import { roleLabel } from '../lib/labels' import { roleLabel } from '../lib/labels'
import { primaryNav } from '../lib/navigation' import { primaryNav } from '../lib/navigation'
import { useLogout, type Me } from '../lib/queries' import { useLogout, type Me } from '../lib/queries'
import { Eyebrow } from './Eyebrow' import { Eyebrow } from './Eyebrow'
import { LanguageToggle } from './LanguageToggle'
import { iconButton } from './styles' import { iconButton } from './styles'
const navLink = ({ isActive }: { isActive: boolean }) => const navLink = ({ isActive }: { isActive: boolean }) =>
@@ -21,6 +25,13 @@ interface SidebarProps {
export function Sidebar({ me, open, onClose, badges }: SidebarProps) { export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
const logout = useLogout() const logout = useLogout()
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useI18n()
const signOut = () => {
const unsent = outbox.getState().items.filter((item) => item.kind === 'report' && item.userId === me.user.id).length
if (unsent && !confirm(t.outbox.signOutWarning(unsent))) return
logout.mutate(undefined, { onSuccess: () => navigate('/login') })
}
return ( return (
<aside <aside
className={`fixed inset-y-0 z-10 flex w-[260px] flex-none flex-col overflow-y-auto bg-forest-950 px-[15px] pt-7 pb-[18px] text-forest-100 shadow-[8px_0_30px_#163c3740] transition-[left] duration-200 sm:static sm:min-h-screen sm:w-[230px] sm:shadow-none lg:w-[246px] print:hidden ${open ? 'left-0' : '-left-[270px]'}`} className={`fixed inset-y-0 z-10 flex w-[260px] flex-none flex-col overflow-y-auto bg-forest-950 px-[15px] pt-7 pb-[18px] text-forest-100 shadow-[8px_0_30px_#163c3740] transition-[left] duration-200 sm:static sm:min-h-screen sm:w-[230px] sm:shadow-none lg:w-[246px] print:hidden ${open ? 'left-0' : '-left-[270px]'}`}
@@ -33,7 +44,7 @@ export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
<strong className="block font-display text-[19px] leading-tight font-bold tracking-[-0.5px] whitespace-nowrap sm:text-[17px] lg:text-[19px]">Open SafetyLog</strong> <strong className="block font-display text-[19px] leading-tight font-bold tracking-[-0.5px] whitespace-nowrap sm:text-[17px] lg:text-[19px]">Open SafetyLog</strong>
<span className="mt-0.5 block text-3xs text-forest-300">EHS · Incident intelligence</span> <span className="mt-0.5 block text-3xs text-forest-300">EHS · Incident intelligence</span>
</div> </div>
<button type="button" className={`${iconButton} absolute top-2 right-2 sm:hidden`} onClick={onClose} aria-label="Close menu"> <button type="button" className={`${iconButton} absolute top-2 right-2 sm:hidden`} onClick={onClose} aria-label={t.nav.closeMenu}>
<X size={20} /> <X size={20} />
</button> </button>
</div> </div>
@@ -41,38 +52,41 @@ export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
<div className="mb-[27px] flex items-center gap-2.5 rounded-[7px] border border-[#316158] p-[11px]"> <div className="mb-[27px] flex items-center gap-2.5 rounded-[7px] border border-[#316158] p-[11px]">
<div className="grid size-[29px] flex-none place-items-center rounded-full bg-[#f1c865] font-mono text-2xs font-bold text-[#143d37]">{initials(me.organization.name)}</div> <div className="grid size-[29px] flex-none place-items-center rounded-full bg-[#f1c865] font-mono text-2xs font-bold text-[#143d37]">{initials(me.organization.name)}</div>
<div className="min-w-0"> <div className="min-w-0">
<Eyebrow>Workspace</Eyebrow> <Eyebrow>{t.nav.workspace}</Eyebrow>
<strong className="block truncate text-[13px] text-white">{me.organization.name}</strong> <strong className="block truncate text-[13px] text-white">{me.organization.name}</strong>
</div> </div>
</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>{label}</span> <span>{t.nav[label]}</span>
{!!badges[to] && <em className="ml-auto rounded-[10px] bg-coral px-[7px] py-0.5 font-mono text-2xs text-white not-italic">{badges[to]}</em>} {!!badges[to] && <em className="ml-auto rounded-[10px] bg-coral px-[7px] py-0.5 font-mono text-2xs text-white not-italic">{badges[to]}</em>}
</NavLink> </NavLink>
))} ))}
</nav> </nav>
<div className={navSection}> <div className={navSection}>
<Eyebrow className="px-3 pb-2 text-[#8da29b]">Manage</Eyebrow> <Eyebrow className="px-3 pb-2 text-[#8da29b]">{t.nav.manage}</Eyebrow>
<NavLink to="/report" onClick={onClose} className={navLink}> <NavLink to="/report" onClick={onClose} className={navLink}>
<Plus size={18} /> <Plus size={18} />
<span>Report an incident</span> <span>{t.nav.reportIncident}</span>
</NavLink> </NavLink>
</div> </div>
<div className="mt-auto"> <div className="mt-auto">
<NavLink to="/about" onClick={onClose} className={navLink}> <NavLink to="/about" onClick={onClose} className={navLink}>
<Info size={18} /> <Info size={18} />
<span>About</span> <span>{t.nav.about}</span>
</NavLink> </NavLink>
<NavLink to="/settings" onClick={onClose} className={navLink}> <NavLink to="/settings" onClick={onClose} className={navLink}>
<Settings size={18} /> <Settings size={18} />
<span>Settings</span> <span>{t.nav.settings}</span>
</NavLink> </NavLink>
<LanguageToggle className="h-[42px] w-full px-3 text-forest-200 hover:text-white" />
<div className="mt-4 flex items-center gap-2.5 border-t border-forest-700 px-2 pt-5"> <div className="mt-4 flex items-center gap-2.5 border-t border-forest-700 px-2 pt-5">
<div className="grid size-[31px] flex-none place-items-center rounded-full bg-[#5c8274] font-mono text-2xs text-white">{initials(me.user.name)}</div> <div className="grid size-[31px] flex-none place-items-center rounded-full bg-[#5c8274] font-mono text-2xs text-white">{initials(me.user.name)}</div>
<div className="min-w-0"> <div className="min-w-0">
@@ -82,9 +96,9 @@ export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
<button <button
type="button" type="button"
className={`${iconButton} ml-auto text-[#91aaa0] hover:text-white`} className={`${iconButton} ml-auto text-[#91aaa0] hover:text-white`}
aria-label="Sign out" aria-label={t.nav.signOut}
title="Sign out" title={t.nav.signOut}
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })} onClick={signOut}
> >
<LogOut size={17} /> <LogOut size={17} />
</button> </button>
+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
}
+5 -4
View File
@@ -4,8 +4,8 @@ import { useOutletContext } from 'react-router-dom'
import type { ActionKind, ActionStatus, CorrectiveAction, IncidentDetail } from '../../../shared/api-types.ts' import type { ActionKind, ActionStatus, CorrectiveAction, IncidentDetail } from '../../../shared/api-types.ts'
import { actionKinds } from '../../../shared/enums.ts' import { actionKinds } from '../../../shared/enums.ts'
import { formatDay, toIsoDate } from '../../lib/dates' import { formatDay, toIsoDate } from '../../lib/dates'
import { actionStatusLabel } from '../../lib/labels'
import { statusOptions } from '../../lib/permissions' import { statusOptions } from '../../lib/permissions'
import { useI18n } from '../../i18n'
import { useAssignableUsers, useIncidentMutations, type Me } from '../../lib/queries' import { useAssignableUsers, useIncidentMutations, type Me } from '../../lib/queries'
import { ActionStatusBadge, OverdueBadge } from '../Badges' import { ActionStatusBadge, OverdueBadge } from '../Badges'
import { Field } from '../FormSection' import { Field } from '../FormSection'
@@ -15,6 +15,7 @@ import { Section } from './Section'
export function ActionStatusControl({ action, onChange, disabled }: { action: CorrectiveAction; onChange: (status: ActionStatus) => void; disabled?: boolean }) { export function ActionStatusControl({ action, onChange, disabled }: { action: CorrectiveAction; onChange: (status: ActionStatus) => void; disabled?: boolean }) {
const me = useOutletContext<Me>().user const me = useOutletContext<Me>().user
const { t } = useI18n()
const options = statusOptions(action, me) const options = statusOptions(action, me)
if (options.length === 0) return <ActionStatusBadge status={action.status} /> if (options.length === 0) return <ActionStatusBadge status={action.status} />
return ( return (
@@ -22,13 +23,13 @@ export function ActionStatusControl({ action, onChange, disabled }: { action: Co
className="rounded-[5px] border border-line bg-white px-2 py-1 text-2xs text-ink outline-none print:hidden" className="rounded-[5px] border border-line bg-white px-2 py-1 text-2xs text-ink outline-none print:hidden"
value={action.status} value={action.status}
disabled={disabled} disabled={disabled}
aria-label={`Status of ${action.title}`} aria-label={t.actions.statusOf(action.title)}
onChange={(event) => onChange(event.target.value as ActionStatus)} onChange={(event) => onChange(event.target.value as ActionStatus)}
> >
{!options.includes(action.status) && <option value={action.status}>{actionStatusLabel[action.status]}</option>} {!options.includes(action.status) && <option value={action.status}>{t.actionStatuses[action.status]}</option>}
{options.map((status) => ( {options.map((status) => (
<option key={status} value={status}> <option key={status} value={status}>
{actionStatusLabel[status]} {t.actionStatuses[status]}
</option> </option>
))} ))}
</select> </select>
+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>
)
}
+171
View File
@@ -0,0 +1,171 @@
import type { ActionStatus } from '../../shared/api-types.ts'
import type { IncidentType, Severity } from '../../shared/enums.ts'
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
export const en = {
language: { name: 'English', switchTo: 'Español' },
common: {
cancel: 'Cancel',
optional: 'Optional',
loading: 'Loading…',
back: 'Back',
},
nav: {
overview: 'Overview',
incidents: 'Incidents',
myActions: 'My actions',
locations: 'Locations',
analytics: 'Analytics',
osha: 'OSHA log',
manage: 'Manage',
reportIncident: 'Report an incident',
about: 'About',
settings: 'Settings',
signOut: 'Sign out',
openMenu: 'Open menu',
closeMenu: 'Close menu',
workspace: 'Workspace',
searchIncidents: 'Search incidents',
},
auth: {
signIn: 'Sign in',
email: 'Email',
password: 'Password',
resetTitle: 'Reset your password',
linkCopy: 'We’ll email you a link that signs you in.',
resetCopy: 'We’ll email you a link to choose a new password.',
sendLink: 'Email me a sign-in link',
sendReset: 'Email me a reset link',
usePassword: 'Sign in with password',
useLink: 'Email me a sign-in link instead',
forgot: 'Forgot password?',
sent: 'If that email belongs to an account, a link is on its way. It expires soon, so use it right away.',
continueWithLink: 'Continue to sign in with the link from your email.',
backToSignIn: 'Back to sign in',
choosePassword: 'Choose a password',
choosePasswordCopy: 'Set a password for your account. You’ll be signed in afterwards.',
newPassword: 'New password',
confirmPassword: 'Confirm password',
atLeast10: 'At least 10 characters.',
mismatch: 'Passwords don’t match.',
savePassword: 'Save password and sign in',
},
report: {
eyebrow: 'New report',
title: 'Report an incident',
intro: 'Capture the facts while they are fresh. Investigation details can be added later.',
whatHappened: 'What happened?',
whatHappenedCopy: 'Start with a clear, factual description.',
incidentTitle: 'Incident title',
titlePlaceholder: 'e.g. Slip near loading dock',
type: 'Incident type',
severity: 'Severity',
when: 'When',
description: 'What happened?',
descriptionPlaceholder: 'Describe what you observed, without assumptions...',
where: 'Where did it happen?',
whereCopy: 'Pinpoint the location so patterns become visible.',
site: 'Site',
area: 'Building or area',
notSpecified: 'Not specified',
exactLocation: 'Exact location',
exactLocationPlaceholder: 'e.g. Ground floor lobby, Line 3',
fromQr: 'Location filled in from the QR code.',
responseAndPhotos: 'Response and photos',
responseAndPhotosCopy: 'What was done straight away, and any pictures of the scene.',
immediateResponse: 'Immediate response',
immediateResponsePlaceholder: 'e.g. Area isolated, supervisor notified...',
photos: 'Photos',
addPhotos: 'Add photos',
removePhoto: (name: string) => `Remove ${name}`,
anonymous: 'Report anonymously.',
anonymousHelp: 'Your name won’t be stored with this report, and you won’t be able to find it in your own list afterwards.',
submit: 'Submit report',
submitting: 'Submitting…',
noSites: 'No sites have been set up yet. A safety manager needs to add a site before incidents can be reported.',
addSite: 'Add a site',
queuedTitle: 'Saved on this device',
queuedCopy: 'You’re offline, so the report hasn’t been sent yet. It will be sent automatically as soon as there’s a connection. You can close this page.',
reportAnother: 'Report something else',
photosFailed: (reference: string, details: string) => `Report ${reference} was saved, but some photos didn't upload. ${details}`,
},
kiosk: {
heading: 'Report a safety concern',
intro: 'Hazards, near misses, injuries: anything that could hurt someone. You don’t need an account.',
whatKind: 'What kind of problem?',
howSerious: 'How serious could it be?',
summary: 'In a few words, what did you see?',
summaryPlaceholder: 'e.g. Oil on the floor by press 4',
details: 'Anything else we should know?',
yourName: 'Your name',
yourNameHelp: 'Leave blank to report anonymously.',
send: 'Send report',
sending: 'Sending…',
thanksTitle: 'Thank you',
thanks: (reference: string) => `Your report ${reference} has been sent to the safety team.`,
thanksQueued: 'Your report is saved on this device and will be sent as soon as there’s a connection.',
resetIn: (seconds: number) => `This screen resets in ${seconds} s.`,
invalidLink: 'This reporting link isn’t working. Ask your safety team for a new one.',
},
actions: {
eyebrow: 'Corrective actions',
mine: 'My actions',
all: 'All actions',
mineCopy: 'Corrective actions assigned to you. Mark them done when the fix is in place.',
allCopy: 'Every corrective action across the organization.',
filterOpen: 'Open',
filterOverdue: 'Overdue',
filterDone: 'Done',
filterAll: 'All',
assignedToMe: 'Assigned to me',
everyone: 'Everyone',
nothingOpen: 'Nothing open. Nice work.',
none: 'No actions here.',
due: (date: string) => `Due ${date}`,
noDueDate: 'No due date',
overdue: 'Overdue',
unassigned: 'Unassigned',
statusOf: (title: string) => `Status of ${title}`,
},
outbox: {
offline: 'You’re offline. New reports will be saved on this device and sent when you reconnect.',
waiting: (n: number) => `${plural(n, 'report', 'reports')} waiting to send`,
sending: 'Sending…',
retry: 'Send now',
otherUser: 'Saved by another user on this device',
failed: (error: string) => `Couldn’t send: ${error}`,
discard: 'Discard',
discardConfirm: 'Discard this unsent report? This can’t be undone.',
signOutWarning: (n: number) => `${plural(n, 'report hasn’t', 'reports haven’t')} been sent yet. They’ll be sent the next time you sign in on this device. Sign out anyway?`,
},
qr: {
caption: 'Scan to report a hazard or near miss',
},
types: {
near_miss: 'Near miss',
hazard: 'Hazard',
injury: 'Injury',
illness: 'Illness',
property_damage: 'Property damage',
environmental: 'Environmental',
} satisfies Record<IncidentType, string>,
typeHints: {
near_miss: 'Something almost went wrong',
hazard: 'Something that could hurt someone',
injury: 'Someone was hurt',
illness: 'Someone became ill from work',
property_damage: 'Equipment or building damaged',
environmental: 'Spill, leak or emission',
} satisfies Record<IncidentType, string>,
severities: { low: 'Low', medium: 'Medium', high: 'High', critical: 'Critical' } satisfies Record<Severity, string>,
severityHints: {
low: 'Minor, no injury likely',
medium: 'Could cause a minor injury',
high: 'Could cause a serious injury',
critical: 'Could be life-threatening',
} satisfies Record<Severity, string>,
actionStatuses: { open: 'Open', in_progress: 'In progress', done: 'Done', verified: 'Verified', cancelled: 'Cancelled' } satisfies Record<ActionStatus, string>,
}
export type Messages = typeof en
+168
View File
@@ -0,0 +1,168 @@
import type { Messages } from './en'
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
export const es: Messages = {
language: { name: 'Español', switchTo: 'English' },
common: {
cancel: 'Cancelar',
optional: 'Opcional',
loading: 'Cargando…',
back: 'Volver',
},
nav: {
overview: 'Resumen',
incidents: 'Incidentes',
myActions: 'Mis acciones',
locations: 'Ubicaciones',
analytics: 'Análisis',
osha: 'Registro OSHA',
manage: 'Gestionar',
reportIncident: 'Reportar un incidente',
about: 'Acerca de',
settings: 'Configuración',
signOut: 'Cerrar sesión',
openMenu: 'Abrir menú',
closeMenu: 'Cerrar menú',
workspace: 'Espacio de trabajo',
searchIncidents: 'Buscar incidentes',
},
auth: {
signIn: 'Iniciar sesión',
email: 'Correo electrónico',
password: 'Contraseña',
resetTitle: 'Restablecer la contraseña',
linkCopy: 'Le enviaremos un enlace por correo para iniciar sesión.',
resetCopy: 'Le enviaremos un enlace por correo para elegir una nueva contraseña.',
sendLink: 'Enviarme un enlace de acceso',
sendReset: 'Enviarme un enlace para restablecer',
usePassword: 'Iniciar sesión con contraseña',
useLink: 'Prefiero recibir un enlace por correo',
forgot: '¿Olvidó su contraseña?',
sent: 'Si ese correo pertenece a una cuenta, el enlace ya está en camino. Caduca pronto, así que úselo enseguida.',
continueWithLink: 'Continúe para iniciar sesión con el enlace de su correo.',
backToSignIn: 'Volver a iniciar sesión',
choosePassword: 'Elija una contraseña',
choosePasswordCopy: 'Establezca una contraseña para su cuenta. Después iniciará sesión automáticamente.',
newPassword: 'Nueva contraseña',
confirmPassword: 'Confirme la contraseña',
atLeast10: 'Al menos 10 caracteres.',
mismatch: 'Las contraseñas no coinciden.',
savePassword: 'Guardar contraseña e iniciar sesión',
},
report: {
eyebrow: 'Nuevo reporte',
title: 'Reportar un incidente',
intro: 'Registre los hechos mientras están frescos. Los detalles de la investigación se pueden añadir después.',
whatHappened: '¿Qué pasó?',
whatHappenedCopy: 'Empiece con una descripción clara y objetiva.',
incidentTitle: 'Título del incidente',
titlePlaceholder: 'p. ej. Resbalón cerca del muelle de carga',
type: 'Tipo de incidente',
severity: 'Gravedad',
when: 'Cuándo',
description: '¿Qué pasó?',
descriptionPlaceholder: 'Describa lo que observó, sin suposiciones...',
where: '¿Dónde ocurrió?',
whereCopy: 'Indique el lugar exacto para que se puedan ver patrones.',
site: 'Planta',
area: 'Edificio o área',
notSpecified: 'Sin especificar',
exactLocation: 'Lugar exacto',
exactLocationPlaceholder: 'p. ej. Vestíbulo planta baja, Línea 3',
fromQr: 'Ubicación tomada del código QR.',
responseAndPhotos: 'Respuesta y fotos',
responseAndPhotosCopy: 'Lo que se hizo de inmediato y fotos del lugar.',
immediateResponse: 'Respuesta inmediata',
immediateResponsePlaceholder: 'p. ej. Área aislada, supervisor avisado...',
photos: 'Fotos',
addPhotos: 'Añadir fotos',
removePhoto: (name: string) => `Quitar ${name}`,
anonymous: 'Reportar de forma anónima.',
anonymousHelp: 'Su nombre no se guardará con este reporte y después no podrá encontrarlo en su lista.',
submit: 'Enviar reporte',
submitting: 'Enviando…',
noSites: 'Todavía no hay plantas configuradas. Un gerente de seguridad debe añadir una planta antes de poder reportar incidentes.',
addSite: 'Añadir una planta',
queuedTitle: 'Guardado en este dispositivo',
queuedCopy: 'No hay conexión, así que el reporte aún no se ha enviado. Se enviará automáticamente en cuanto haya conexión. Puede cerrar esta página.',
reportAnother: 'Reportar otra cosa',
photosFailed: (reference: string, details: string) => `El reporte ${reference} se guardó, pero algunas fotos no se subieron. ${details}`,
},
kiosk: {
heading: 'Reporte un problema de seguridad',
intro: 'Peligros, casi accidentes, lesiones: cualquier cosa que pueda lastimar a alguien. No necesita una cuenta.',
whatKind: '¿Qué tipo de problema?',
howSerious: '¿Qué tan grave podría ser?',
summary: 'En pocas palabras, ¿qué vio?',
summaryPlaceholder: 'p. ej. Aceite en el piso junto a la prensa 4',
details: '¿Algo más que debamos saber?',
yourName: 'Su nombre',
yourNameHelp: 'Déjelo en blanco para reportar de forma anónima.',
send: 'Enviar reporte',
sending: 'Enviando…',
thanksTitle: 'Gracias',
thanks: (reference: string) => `Su reporte ${reference} se envió al equipo de seguridad.`,
thanksQueued: 'Su reporte está guardado en este dispositivo y se enviará en cuanto haya conexión.',
resetIn: (seconds: number) => `Esta pantalla se reinicia en ${seconds} s.`,
invalidLink: 'Este enlace de reporte no funciona. Pida uno nuevo a su equipo de seguridad.',
},
actions: {
eyebrow: 'Acciones correctivas',
mine: 'Mis acciones',
all: 'Todas las acciones',
mineCopy: 'Acciones correctivas asignadas a usted. Márquelas como hechas cuando la solución esté en marcha.',
allCopy: 'Todas las acciones correctivas de la organización.',
filterOpen: 'Abiertas',
filterOverdue: 'Vencidas',
filterDone: 'Hechas',
filterAll: 'Todas',
assignedToMe: 'Asignadas a mí',
everyone: 'Todos',
nothingOpen: 'Nada pendiente. ¡Buen trabajo!',
none: 'No hay acciones aquí.',
due: (date: string) => `Vence ${date}`,
noDueDate: 'Sin fecha límite',
overdue: 'Vencida',
unassigned: 'Sin asignar',
statusOf: (title: string) => `Estado de ${title}`,
},
outbox: {
offline: 'Sin conexión. Los nuevos reportes se guardarán en este dispositivo y se enviarán al reconectarse.',
waiting: (n: number) => `${plural(n, 'reporte pendiente', 'reportes pendientes')} de enviar`,
sending: 'Enviando…',
retry: 'Enviar ahora',
otherUser: 'Guardado por otro usuario en este dispositivo',
failed: (error: string) => `No se pudo enviar: ${error}`,
discard: 'Descartar',
discardConfirm: '¿Descartar este reporte sin enviar? No se puede deshacer.',
signOutWarning: (n: number) => `${plural(n, 'reporte aún no se ha', 'reportes aún no se han')} enviado. Se enviarán la próxima vez que inicie sesión en este dispositivo. ¿Cerrar sesión de todos modos?`,
},
qr: {
caption: 'Escanee para reportar un peligro o casi accidente',
},
types: {
near_miss: 'Casi accidente',
hazard: 'Peligro',
injury: 'Lesión',
illness: 'Enfermedad',
property_damage: 'Daño a la propiedad',
environmental: 'Ambiental',
},
typeHints: {
near_miss: 'Algo casi salió mal',
hazard: 'Algo que podría lastimar a alguien',
injury: 'Alguien se lastimó',
illness: 'Alguien se enfermó por el trabajo',
property_damage: 'Equipo o edificio dañado',
environmental: 'Derrame, fuga o emisión',
},
severities: { low: 'Baja', medium: 'Media', high: 'Alta', critical: 'Crítica' },
severityHints: {
low: 'Menor, sin probabilidad de lesión',
medium: 'Podría causar una lesión menor',
high: 'Podría causar una lesión grave',
critical: 'Podría poner en riesgo la vida',
},
actionStatuses: { open: 'Abierta', in_progress: 'En curso', done: 'Hecha', verified: 'Verificada', cancelled: 'Cancelada' },
}
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { en } from './en'
import { es } from './es'
/** Every leaf path in a messages object, e.g. "report.title". */
function paths(value: unknown, prefix = ''): string[] {
if (value && typeof value === 'object') return Object.entries(value).flatMap(([key, child]) => paths(child, prefix ? `${prefix}.${key}` : key))
return [prefix]
}
describe('translations', () => {
it('Spanish covers every English message', () => {
expect(paths(es).sort()).toEqual(paths(en).sort())
})
it('has no untranslated copies of English text', () => {
const allowed = new Set(['language.switchTo'])
const same = paths(en).filter((path) => {
const get = (root: unknown) => path.split('.').reduce((node: unknown, key) => (node as Record<string, unknown>)[key], root)
const a = get(en)
return typeof a === 'string' && a === get(es) && !allowed.has(path)
})
expect(same).toEqual([])
})
it('interpolates', () => {
expect(es.kiosk.thanks('INC-0042')).toBe('Su reporte INC-0042 se envió al equipo de seguridad.')
expect(en.outbox.waiting(1)).toBe('1 report waiting to send')
expect(es.outbox.waiting(3)).toBe('3 reportes pendientes de enviar')
})
})
+20
View File
@@ -0,0 +1,20 @@
import { createContext, useContext } from 'react'
import type { Messages } from './en'
export type Language = 'en' | 'es'
export interface I18n {
lang: Language
t: Messages
setLang: (lang: Language) => void
/** Locale-aware calendar date, e.g. "6 oct 2026". */
formatDay: (isoDate: string) => string
}
export const I18nContext = createContext<I18n | null>(null)
export function useI18n(): I18n {
const context = useContext(I18nContext)
if (!context) throw new Error('useI18n must be used inside I18nProvider')
return context
}
+41
View File
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { en, type Messages } from './en'
import { es } from './es'
import { I18nContext, type I18n, type Language } from './index'
const messages: Record<Language, Messages> = { en, es }
const STORAGE_KEY = 'safetylog.language'
function initialLanguage(): Language {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved === 'en' || saved === 'es') return saved
} catch {
// storage unavailable (private mode); fall through to the browser language
}
return navigator.language?.toLowerCase().startsWith('es') ? 'es' : 'en'
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Language>(initialLanguage)
useEffect(() => {
document.documentElement.lang = lang
}, [lang])
const setLang = useCallback((next: Language) => {
setLangState(next)
try {
localStorage.setItem(STORAGE_KEY, next)
} catch {
// not persisted; still switches for this visit
}
}, [])
const value = useMemo<I18n>(() => {
const dayFormat = new Intl.DateTimeFormat(lang === 'es' ? 'es' : 'en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
return { lang, t: messages[lang], setLang, formatDay: (isoDate) => dayFormat.format(new Date(`${isoDate}T00:00:00Z`)) }
}, [lang, setLang])
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
}
+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 {
+20
View File
@@ -0,0 +1,20 @@
import type { KioskConfig } from '../../shared/api-types.ts'
import type { KioskReportInput } from '../../shared/schemas.ts'
import { ApiError } from './api'
const HEADER = 'x-kiosk-token'
async function parse<T>(res: Response): Promise<T> {
const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null
if (!res.ok) throw new ApiError(res.status, data?.error ?? `Request failed (${res.status})`, data?.issues ?? [])
return data as T
}
export const fetchKioskConfig = (token: string) => fetch('/api/kiosk/config', { headers: { [HEADER]: token } }).then((res) => parse<KioskConfig>(res))
export function sendKioskReport(token: string, report: KioskReportInput, files: { name: string; type: string; blob: Blob }[]) {
const form = new FormData()
form.append('report', JSON.stringify(report))
for (const file of files) form.append('files', new File([file.blob], file.name, { type: file.type }))
return fetch('/api/kiosk/reports', { method: 'POST', headers: { [HEADER]: token }, body: form }).then((res) => parse<{ reference: string; failedFiles: string[] }>(res))
}
+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
} }
+16 -7
View File
@@ -1,20 +1,27 @@
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'
export interface NavItem { export interface NavItem {
label: string 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[] = [
{ label: 'Overview', to: '/', icon: LayoutDashboard }, { label: 'overview', to: '/', icon: LayoutDashboard },
{ label: 'Incidents', to: '/incidents', icon: ClipboardList }, { label: 'incidents', to: '/incidents', icon: ClipboardList },
{ label: 'My actions', 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> = {
@@ -23,7 +30,9 @@ 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',
'/settings': 'Settings', '/settings': 'Settings',
'/about': 'About', '/about': 'About',
} }
+79
View File
@@ -0,0 +1,79 @@
import 'fake-indexeddb/auto'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { outbox, type QueuedReport } from './outbox'
const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
function item(overrides: Partial<QueuedReport> = {}): QueuedReport {
const clientId = crypto.randomUUID()
return {
kind: 'report',
clientId,
userId: 'u1',
createdAt: new Date().toISOString(),
attempts: 0,
files: [],
report: { title: 'Spill', type: 'hazard', severity: 'low', occurredAt: new Date().toISOString(), siteId: crypto.randomUUID(), clientId },
...overrides,
}
}
const online = (value: boolean) => vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(value)
beforeEach(async () => {
online(true)
for (const queued of outbox.getState().items) await outbox.discard(queued.clientId)
})
afterEach(() => vi.restoreAllMocks())
describe('outbox', () => {
it('sends straight away when online and keeps nothing', async () => {
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json(201, { incident: { number: 7 } }))
const result = await outbox.submit(item())
expect(result).toEqual({ status: 'sent', reference: 'INC-0007', number: 7, failedFiles: [] })
expect(fetch).toHaveBeenCalledOnce()
expect(outbox.getState().items).toEqual([])
})
it('keeps the report on the device while offline, then sends it on flush', async () => {
online(false)
const queued = item()
expect(await outbox.submit(queued)).toEqual({ status: 'queued' })
expect(outbox.getState().items.map((i) => i.clientId)).toEqual([queued.clientId])
online(true)
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json(201, { incident: { number: 8 } }))
await outbox.flush((i) => i.kind === 'report' && i.userId === 'u1')
expect(JSON.parse(fetch.mock.calls[0][1]!.body as string).clientId).toBe(queued.clientId)
expect(outbox.getState().items).toEqual([])
})
it('keeps reports when the network drops mid-send', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch'))
expect(await outbox.submit(item())).toEqual({ status: 'queued' })
expect(outbox.getState().items[0]).toMatchObject({ attempts: 1, lastError: 'Failed to fetch' })
})
it('hands validation errors back instead of retrying', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(json(400, { error: 'Unknown site' }))
const result = await outbox.submit(item())
expect(result.status).toBe('rejected')
expect(outbox.getState().items).toEqual([])
})
it("only sends a user's own reports", async () => {
online(false)
await outbox.submit(item({ userId: 'someone-else' }))
online(true)
const fetch = vi.spyOn(globalThis, 'fetch')
await outbox.flush((i) => i.kind === 'report' && i.userId === 'u1')
expect(fetch).not.toHaveBeenCalled()
expect(outbox.getState().items).toHaveLength(1)
})
it('waits for sign-in when the session has expired', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(json(401, { error: 'Sign in required' }))
expect(await outbox.submit(item())).toEqual({ status: 'queued' })
expect(outbox.getState().items[0].lastError).toBe('Sign in to send')
})
})
+213
View File
@@ -0,0 +1,213 @@
// Reports waiting to be sent, stored in IndexedDB so they survive going offline, closing the tab and
// restarting the device. Each has a client-generated id that the server uses to ignore duplicates,
// so retrying is always safe.
import type { CreateIncidentInput, KioskReportInput } from '../../shared/schemas.ts'
import type { Incident } from '../../shared/api-types.ts'
import { incidentReference } from '../../shared/format.ts'
import { api, ApiError, errorMessage } from './api'
import { sendKioskReport } from './kiosk'
import { uploadAttachment } from './queries'
export interface QueuedFile {
name: string
type: string
blob: Blob
/** Set once uploaded, so a retry after a partial upload doesn't send it twice. */
uploaded?: boolean
}
interface QueuedBase {
clientId: string
createdAt: string
files: QueuedFile[]
attempts: number
lastError?: string
/** The server refused it for a reason retrying won't fix; waits for the user to discard it. */
rejected?: boolean
}
export interface QueuedReport extends QueuedBase {
kind: 'report'
/** Only sent while this user is signed in. */
userId: string
report: CreateIncidentInput & { clientId: string }
/** Known once the report itself is saved, while photos may still be pending. */
incidentNumber?: number
}
export interface QueuedKioskReport extends QueuedBase {
kind: 'kiosk'
token: string
report: KioskReportInput & { clientId: string }
}
export type QueuedItem = QueuedReport | QueuedKioskReport
export type SendResult =
| { status: 'sent'; reference: string; number?: number; failedFiles: string[] }
| { status: 'queued' }
| { status: 'rejected'; error: ApiError }
// ---- IndexedDB ----
const DB_NAME = 'safetylog'
const STORE = 'outbox'
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1)
request.onupgradeneeded = () => request.result.createObjectStore(STORE, { keyPath: 'clientId' })
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
async function withStore<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
const db = await openDb()
try {
return await new Promise<T>((resolve, reject) => {
const request = run(db.transaction(STORE, mode).objectStore(STORE))
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
} finally {
db.close()
}
}
// ---- Sending ----
/** Worth retrying later: no connection, server down or overloaded, or signed out. */
export function isTransient(error: unknown): boolean {
if (error instanceof TypeError) return true // fetch() network failure
return error instanceof ApiError && (error.status >= 500 || error.status === 401 || error.status === 408 || error.status === 429)
}
async function sendReport(item: QueuedReport, save: (item: QueuedItem) => Promise<void>): Promise<SendResult> {
if (item.incidentNumber === undefined) {
const { incident } = await api<{ incident: Incident }>('/incidents', { method: 'POST', body: item.report })
item.incidentNumber = incident.number
await save(item)
}
const failedFiles: string[] = []
for (const file of item.files.filter((f) => !f.uploaded)) {
try {
await uploadAttachment(item.incidentNumber, new File([file.blob], file.name, { type: file.type }))
} catch (error) {
if (isTransient(error)) throw error
failedFiles.push(`${file.name}: ${errorMessage(error)}`) // e.g. unsupported type; don't retry forever
}
file.uploaded = true
await save(item)
}
return { status: 'sent', reference: incidentReference(item.incidentNumber), number: item.incidentNumber, failedFiles }
}
async function sendKiosk(item: QueuedKioskReport): Promise<SendResult> {
const result = await sendKioskReport(item.token, item.report, item.files)
return { status: 'sent', reference: result.reference, failedFiles: result.failedFiles }
}
// ---- Store with subscribers ----
export interface OutboxState {
items: QueuedItem[]
syncing: boolean
}
class Outbox {
private state: OutboxState = { items: [], syncing: false }
private listeners = new Set<() => void>()
private loaded: Promise<void> | undefined
getState = () => this.state
subscribe = (listener: () => void) => {
this.listeners.add(listener)
void this.load()
return () => this.listeners.delete(listener)
}
private set(partial: Partial<OutboxState>) {
this.state = { ...this.state, ...partial }
for (const listener of this.listeners) listener()
}
load(): Promise<void> {
this.loaded ??= this.refresh()
return this.loaded
}
private async refresh() {
try {
const items = await withStore<QueuedItem[]>('readonly', (store) => store.getAll())
this.set({ items: items.sort((a, b) => a.createdAt.localeCompare(b.createdAt)) })
} catch (error) {
console.warn('[outbox] storage unavailable', error)
}
}
private save = async (item: QueuedItem) => {
await withStore('readwrite', (store) => store.put(item))
await this.refresh()
}
private async remove(clientId: string) {
await withStore('readwrite', (store) => store.delete(clientId))
await this.refresh()
}
discard = (clientId: string) => this.remove(clientId)
private async attempt(item: QueuedItem): Promise<SendResult> {
try {
const result = item.kind === 'report' ? await sendReport(item, this.save) : await sendKiosk(item)
await this.remove(item.clientId)
return result
} catch (error) {
if (isTransient(error)) {
item.attempts += 1
item.lastError = error instanceof ApiError && error.status === 401 ? 'Sign in to send' : errorMessage(error)
await this.save(item)
return { status: 'queued' }
}
const apiError = error instanceof ApiError ? error : new ApiError(0, errorMessage(error))
item.lastError = apiError.message
item.rejected = true
await this.save(item)
return { status: 'rejected', error: apiError }
}
}
/**
* Saves the report on the device first, then tries to send it straight away. Rejected reports
* (e.g. a validation error the user can fix) are removed again so the form can show the error.
*/
async submit(item: QueuedItem): Promise<SendResult> {
await this.load()
await this.save(item)
if (!navigator.onLine) return { status: 'queued' }
const result = await this.attempt(item)
if (result.status === 'rejected') await this.remove(item.clientId)
return result
}
/** Sends every waiting item that `belongs` returns true for, oldest first, stopping when offline. */
async flush(belongs: (item: QueuedItem) => boolean): Promise<void> {
await this.load()
if (this.state.syncing || !navigator.onLine) return
const pending = this.state.items.filter((item) => belongs(item) && !item.rejected)
if (pending.length === 0) return
this.set({ syncing: true })
try {
for (const item of pending) {
const result = await this.attempt(item)
if (result.status === 'queued') break
}
} finally {
this.set({ syncing: false })
}
}
}
export const outbox = new Outbox()
+90 -4
View File
@@ -5,17 +5,22 @@ import type {
Comment, Comment,
CorrectiveAction, CorrectiveAction,
Incident, Incident,
Form301,
IncidentDetail, IncidentDetail,
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 ----
@@ -60,9 +65,11 @@ export function useLogout() {
const client = useQueryClient() const client = useQueryClient()
return useMutation({ return useMutation({
mutationFn: () => api('/auth/logout', { method: 'POST' }), mutationFn: () => api('/auth/logout', { method: 'POST' }),
onSuccess: () => { onSuccess: async () => {
client.clear() client.clear()
client.setQueryData(['me'], null) client.setQueryData(['me'], null)
// Offline copies of "who am I" and site lists belong to the signed-out user
if ('caches' in window) await caches.delete('api-essentials')
}, },
}) })
} }
@@ -83,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,
@@ -196,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 ----
@@ -222,3 +241,70 @@ export function useUserMutations() {
}), }),
} }
} }
// ---- Reporting links (kiosks / QR) ----
export const useKiosks = (enabled = true) => useQuery({ queryKey: ['kiosks'], queryFn: () => api<{ kiosks: Kiosk[] }>('/kiosks').then((r) => r.kiosks), enabled })
export function useKioskMutations() {
const client = useQueryClient()
const done = () => client.invalidateQueries({ queryKey: ['kiosks'] })
return {
create: useMutation({ mutationFn: (input: { name: string; siteId?: string | null }) => api<{ kiosk: Kiosk }>('/kiosks', { method: 'POST', body: input }), onSuccess: done }),
update: useMutation({ mutationFn: ({ id, ...input }: { id: string; name?: string; revoked?: boolean }) => api(`/kiosks/${id}`, { method: 'PATCH', body: input }), onSuccess: done }),
}
}
/** Link that opens the report form, optionally through a reporting link and pre-filled with a location. */
export function reportUrl(origin: string, { token, siteId, areaId }: { token?: string; siteId?: string; areaId?: string }) {
const params = new URLSearchParams()
if (siteId) params.set('site', siteId)
if (areaId) params.set('area', areaId)
const query = params.toString() ? `?${params}` : ''
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 }),
}
}
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useSyncExternalStore } from 'react'
import { outbox, type QueuedItem } from './outbox'
export function useOutbox() {
return useSyncExternalStore(outbox.subscribe, outbox.getState)
}
const RETRY_MS = 30_000
/** Sends matching waiting reports now, whenever the connection returns, and every 30 s while any wait. */
export function useOutboxSync(belongs: ((item: QueuedItem) => boolean) | null) {
const { items } = useOutbox()
const waiting = belongs ? items.filter(belongs).length : 0
useEffect(() => {
if (!belongs) return
const flush = () => void outbox.flush(belongs)
flush()
window.addEventListener('online', flush)
return () => window.removeEventListener('online', flush)
// `belongs` is recreated each render; the filter it encodes is stable per user/token
// oxlint-disable-next-line react/exhaustive-deps
}, [!!belongs])
useEffect(() => {
if (!belongs || waiting === 0) return
const timer = setInterval(() => void outbox.flush(belongs), RETRY_MS)
return () => clearInterval(timer)
// oxlint-disable-next-line react/exhaustive-deps
}, [waiting, !!belongs])
}
export function useOnline() {
return useSyncExternalStore(
(onChange) => {
window.addEventListener('online', onChange)
window.addEventListener('offline', onChange)
return () => {
window.removeEventListener('online', onChange)
window.removeEventListener('offline', onChange)
}
},
() => navigator.onLine,
)
}
+15
View File
@@ -0,0 +1,15 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { uuid } from './uuid'
describe('uuid', () => {
afterEach(() => vi.unstubAllGlobals())
it('makes v4 UUIDs even where crypto.randomUUID is missing (plain-HTTP LAN installs)', () => {
const real = globalThis.crypto
vi.stubGlobal('crypto', { getRandomValues: real.getRandomValues.bind(real) })
expect(globalThis.crypto.randomUUID).toBeUndefined()
const id = uuid()
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
expect(uuid()).not.toBe(id)
})
})
+12
View File
@@ -0,0 +1,12 @@
/**
* Random UUID v4. `crypto.randomUUID` only exists on HTTPS/localhost, and plant servers are often
* reached over plain HTTP on the LAN, so fall back to getRandomValues (available everywhere).
*/
export function uuid(): string {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
const bytes = crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
+3
View File
@@ -8,6 +8,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import App from './App' import App from './App'
import { I18nProvider } from './i18n/provider'
import { ApiError } from './lib/api' import { ApiError } from './lib/api'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -19,9 +20,11 @@ const queryClient = new QueryClient({
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<I18nProvider>
<BrowserRouter> <BrowserRouter>
<App /> <App />
</BrowserRouter> </BrowserRouter>
</I18nProvider>
</QueryClientProvider> </QueryClientProvider>
</StrictMode>, </StrictMode>,
) )
+16 -15
View File
@@ -6,20 +6,21 @@ import { Page } from '../components/Page'
import { PageIntro } from '../components/PageIntro' import { PageIntro } from '../components/PageIntro'
import { Empty, ErrorMessage, Loading } from '../components/QueryState' import { Empty, ErrorMessage, Loading } from '../components/QueryState'
import { panel } from '../components/styles' import { panel } from '../components/styles'
import { formatDay } from '../lib/dates' import { useI18n } from '../i18n'
import { useActions, useUpdateAction, type Me } from '../lib/queries' import { useActions, useUpdateAction, type Me } from '../lib/queries'
const filters = [ const filters = [
{ value: 'open', label: 'Open' }, { value: 'open', label: 'filterOpen' },
{ value: 'overdue', label: 'Overdue' }, { value: 'overdue', label: 'filterOverdue' },
{ value: 'done', label: 'Done' }, { value: 'done', label: 'filterDone' },
{ value: 'all', label: 'All' }, { value: 'all', label: 'filterAll' },
] as const ] as const
const tab = (active: boolean) => `rounded-[5px] px-3 py-1.5 text-2xs font-semibold ${active ? 'bg-forest-950 text-white' : 'text-muted hover:bg-white hover:text-ink'}` const tab = (active: boolean) => `rounded-[5px] px-3 py-1.5 text-2xs font-semibold ${active ? 'bg-forest-950 text-white' : 'text-muted hover:bg-white hover:text-ink'}`
export function Actions() { export function Actions() {
const me = useOutletContext<Me>() const me = useOutletContext<Me>()
const { t, formatDay } = useI18n()
const [params, setParams] = useSearchParams() const [params, setParams] = useSearchParams()
const canSeeAll = hasRole(me.user.role, 'supervisor') const canSeeAll = hasRole(me.user.role, 'supervisor')
const scope = canSeeAll && params.get('scope') === 'all' ? 'all' : 'mine' const scope = canSeeAll && params.get('scope') === 'all' ? 'all' : 'mine'
@@ -36,25 +37,25 @@ export function Actions() {
return ( return (
<Page> <Page>
<PageIntro <PageIntro
eyebrow="Corrective actions" eyebrow={t.actions.eyebrow}
title={scope === 'mine' ? 'My actions' : 'All actions'} title={scope === 'mine' ? t.actions.mine : t.actions.all}
copy={scope === 'mine' ? 'Corrective actions assigned to you. Mark them done when the fix is in place.' : 'Every corrective action across the organization.'} copy={scope === 'mine' ? t.actions.mineCopy : t.actions.allCopy}
/> />
<div className="mb-4 flex flex-wrap items-center justify-between gap-3"> <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex gap-1 rounded-md bg-line-soft p-1" role="tablist" aria-label="Filter actions"> <div className="flex gap-1 rounded-md bg-line-soft p-1" role="tablist" aria-label="Filter actions">
{filters.map((f) => ( {filters.map((f) => (
<button key={f.value} type="button" role="tab" aria-selected={filter === f.value} className={tab(filter === f.value)} onClick={() => set('filter', f.value)}> <button key={f.value} type="button" role="tab" aria-selected={filter === f.value} className={tab(filter === f.value)} onClick={() => set('filter', f.value)}>
{f.label} {t.actions[f.label]}
</button> </button>
))} ))}
</div> </div>
{canSeeAll && ( {canSeeAll && (
<div className="flex gap-1 rounded-md bg-line-soft p-1" role="tablist" aria-label="Whose actions"> <div className="flex gap-1 rounded-md bg-line-soft p-1" role="tablist" aria-label="Whose actions">
<button type="button" role="tab" aria-selected={scope === 'mine'} className={tab(scope === 'mine')} onClick={() => set('scope', 'mine')}> <button type="button" role="tab" aria-selected={scope === 'mine'} className={tab(scope === 'mine')} onClick={() => set('scope', 'mine')}>
Assigned to me {t.actions.assignedToMe}
</button> </button>
<button type="button" role="tab" aria-selected={scope === 'all'} className={tab(scope === 'all')} onClick={() => set('scope', 'all')}> <button type="button" role="tab" aria-selected={scope === 'all'} className={tab(scope === 'all')} onClick={() => set('scope', 'all')}>
Everyone {t.actions.everyone}
</button> </button>
</div> </div>
)} )}
@@ -64,7 +65,7 @@ export function Actions() {
{actions.isPending ? ( {actions.isPending ? (
<Loading /> <Loading />
) : !actions.data?.length ? ( ) : !actions.data?.length ? (
<Empty>{filter === 'open' ? 'Nothing open. Nice work.' : 'No actions here.'}</Empty> <Empty>{filter === 'open' ? t.actions.nothingOpen : t.actions.none}</Empty>
) : ( ) : (
<ul className="divide-y divide-line-soft"> <ul className="divide-y divide-line-soft">
{actions.data.map((action) => ( {actions.data.map((action) => (
@@ -76,12 +77,12 @@ export function Actions() {
{action.incident.reference} {action.incident.reference}
</Link>{' '} </Link>{' '}
{action.incident.title} {action.incident.title}
{scope === 'all' && ` · ${action.assignee?.name ?? 'Unassigned'}`} {scope === 'all' && ` · ${action.assignee?.name ?? t.actions.unassigned}`}
</p> </p>
</div> </div>
<div className="flex items-center gap-3 text-2xs text-body"> <div className="flex items-center gap-3 text-2xs text-body">
{action.overdue && <OverdueBadge />} {action.overdue && <OverdueBadge label={t.actions.overdue} />}
<span className="w-[100px] text-right">{action.dueDate ? `Due ${formatDay(action.dueDate)}` : 'No due date'}</span> <span className="w-[100px] text-right">{action.dueDate ? t.actions.due(formatDay(action.dueDate)) : t.actions.noDueDate}</span>
<ActionStatusControl action={action} disabled={update.isPending} onChange={(status) => update.mutate({ id: action.id, status })} /> <ActionStatusControl action={action} disabled={update.isPending} onChange={(status) => update.mutate({ id: action.id, status })} />
</div> </div>
</li> </li>
+278 -83
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}>
<select className="bg-transparent text-xs text-ink outline-none" value={days} onChange={(event) => setDays(Number(event.target.value))} aria-label="Period"> <CalendarRange size={15} />
<option value={30}>Last 30 days</option> <select className={select} value={params.get('range') ?? '90'} onChange={(event) => set('range', event.target.value)} aria-label="Date range">
<option value={90}>Last 90 days</option> {ranges.map((r) => (
<option value={180}>Last 6 months</option> <option key={r.value} value={r.value}>
<option value={365}>Last 12 months</option> {r.label}
</option>
))}
</select> </select>
</div> </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 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" />
</>
) : ( ) : (
<p className="mt-8 text-xs text-muted">No root causes recorded in this period yet.</p> <ChartEmpty>
Rates need hours worked.{' '}
<Link to="/osha?tab=hours" className="text-coral underline">
Enter monthly hours on the OSHA log
</Link>
.
</ChartEmpty>
)} )}
</div> </ChartFrame>
<div className={`${panel} ${card}`}>
<Eyebrow>Actions completed on time</Eyebrow>
<strong className={bigStat}>{s.onTimeActionRate === null ? '–' : pct(s.onTimeActionRate)}</strong>
<p className="text-xs leading-[1.6] text-muted">of corrective actions with a due date were done by then.</p>
{s.monthlyCompletedActions.length > 0 && (
<div className="mt-auto flex h-[70px] w-full items-end gap-[7px] pt-4" aria-label="On-time share by month">
{s.monthlyCompletedActions.map((m) => {
const total = m.onTime + m.late
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}%` }} />
})}
</div>
)} )}
<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 }))} />
) : (
<ChartEmpty>No root causes recorded for incidents in this period yet.</ChartEmpty>
)}
</ChartFrame>
<ChartFrame
className="lg:col-span-2"
title="When incidents happen"
subtitle="By day of the week and hour of the day. Clusters often point at shift changes, overtime or specific tasks."
table={{ columns: ['Day', ...hours.map((h) => `${h}:00`)], rows: s.heatmap.map((row, i) => [weekdays[i], ...row]) }}
>
{s.heatmap.flat().some((v) => v > 0) ? (
<Heatmap
ariaLabel="Incidents by weekday and hour"
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>
)}
</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>
{s.yearly && <YearlyRates yearly={s.yearly} />}
</div> </div>
<section className={`${panel} p-[26px]`}>
<Eyebrow>Reports by type</Eyebrow>
<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>
)
}
+23 -19
View File
@@ -5,6 +5,7 @@ import { AuthLayout } from '../components/AuthLayout'
import { Field } from '../components/FormSection' import { Field } from '../components/FormSection'
import { ErrorMessage, Loading } from '../components/QueryState' import { ErrorMessage, Loading } from '../components/QueryState'
import { field, primaryButton, textButton } from '../components/styles' import { field, primaryButton, textButton } from '../components/styles'
import { useI18n } from '../i18n'
import { useMe, useRequestEmail, useSetupStatus, useSignIn } from '../lib/queries' import { useMe, useRequestEmail, useSetupStatus, useSignIn } from '../lib/queries'
const fullWidth = `${primaryButton} w-full` const fullWidth = `${primaryButton} w-full`
@@ -19,6 +20,7 @@ function Sent({ children }: { children: string }) {
} }
export function Login() { export function Login() {
const { t } = useI18n()
const location = useLocation() const location = useLocation()
const me = useMe() const me = useMe()
const setup = useSetupStatus() const setup = useSetupStatus()
@@ -50,41 +52,41 @@ export function Login() {
return ( return (
<AuthLayout <AuthLayout
title={mode === 'reset' ? 'Reset your password' : 'Sign in'} title={mode === 'reset' ? t.auth.resetTitle : t.auth.signIn}
copy={mode === 'password' ? undefined : mode === 'link' ? 'We’ll email you a link that signs you in.' : 'We’ll email you a link to choose a new password.'} copy={mode === 'password' ? undefined : mode === 'link' ? t.auth.linkCopy : t.auth.resetCopy}
> >
<form onSubmit={submit} className="space-y-1"> <form onSubmit={submit} className="space-y-1">
<Field label="Email"> <Field label={t.auth.email}>
<input className={field} type="email" autoComplete="email" required value={email} onChange={(event) => setEmail(event.target.value)} /> <input className={field} type="email" autoComplete="email" required value={email} onChange={(event) => setEmail(event.target.value)} />
</Field> </Field>
{mode === 'password' && ( {mode === 'password' && (
<Field label="Password"> <Field label={t.auth.password}>
<input className={field} type="password" autoComplete="current-password" required value={password} onChange={(event) => setPassword(event.target.value)} /> <input className={field} type="password" autoComplete="current-password" required value={password} onChange={(event) => setPassword(event.target.value)} />
</Field> </Field>
)} )}
<ErrorMessage error={signIn.error ?? emailRequest.error} className="mb-4" /> <ErrorMessage error={signIn.error ?? emailRequest.error} className="mb-4" />
{emailRequest.isSuccess ? ( {emailRequest.isSuccess ? (
<Sent>If that email belongs to an account, a link is on its way. It expires soon, so use it right away.</Sent> <Sent>{t.auth.sent}</Sent>
) : ( ) : (
<button className={fullWidth} type="submit" disabled={signIn.isPending || emailRequest.isPending}> <button className={fullWidth} type="submit" disabled={signIn.isPending || emailRequest.isPending}>
{mode === 'password' ? 'Sign in' : mode === 'link' ? 'Email me a sign-in link' : 'Email me a reset link'} {mode === 'password' ? t.auth.signIn : mode === 'link' ? t.auth.sendLink : t.auth.sendReset}
</button> </button>
)} )}
</form> </form>
<div className="mt-5 flex flex-wrap justify-between gap-2"> <div className="mt-5 flex flex-wrap justify-between gap-2">
{mode !== 'password' && ( {mode !== 'password' && (
<button type="button" className={textButton} onClick={() => switchMode('password')}> <button type="button" className={textButton} onClick={() => switchMode('password')}>
Sign in with password {t.auth.usePassword}
</button> </button>
)} )}
{mode !== 'link' && ( {mode !== 'link' && (
<button type="button" className={textButton} onClick={() => switchMode('link')}> <button type="button" className={textButton} onClick={() => switchMode('link')}>
Email me a sign-in link instead {t.auth.useLink}
</button> </button>
)} )}
{mode !== 'reset' && ( {mode !== 'reset' && (
<button type="button" className={textButton} onClick={() => switchMode('reset')}> <button type="button" className={textButton} onClick={() => switchMode('reset')}>
Forgot password? {t.auth.forgot}
</button> </button>
)} )}
</div> </div>
@@ -133,6 +135,7 @@ export function Setup() {
} }
export function MagicLinkLanding() { export function MagicLinkLanding() {
const { t } = useI18n()
const navigate = useNavigate() const navigate = useNavigate()
const [params] = useSearchParams() const [params] = useSearchParams()
const verify = useSignIn<{ token: string }>('/auth/magic-link/verify') const verify = useSignIn<{ token: string }>('/auth/magic-link/verify')
@@ -140,19 +143,20 @@ export function MagicLinkLanding() {
// A button rather than signing in on load, so email link scanners can't use up the link. // A button rather than signing in on load, so email link scanners can't use up the link.
return ( return (
<AuthLayout title="Sign in" copy="Continue to sign in with the link from your email."> <AuthLayout title={t.auth.signIn} copy={t.auth.continueWithLink}>
<ErrorMessage error={verify.error} className="mb-4" /> <ErrorMessage error={verify.error} className="mb-4" />
<button type="button" className={fullWidth} disabled={!token || verify.isPending} onClick={() => verify.mutate({ token }, { onSuccess: () => navigate('/', { replace: true }) })}> <button type="button" className={fullWidth} disabled={!token || verify.isPending} onClick={() => verify.mutate({ token }, { onSuccess: () => navigate('/', { replace: true }) })}>
Sign in {t.auth.signIn}
</button> </button>
<Link to="/login" className={`${textButton} mt-5`}> <Link to="/login" className={`${textButton} mt-5`}>
Back to sign in {t.auth.backToSignIn}
</Link> </Link>
</AuthLayout> </AuthLayout>
) )
} }
export function ResetPassword() { export function ResetPassword() {
const { t } = useI18n()
const navigate = useNavigate() const navigate = useNavigate()
const [params] = useSearchParams() const [params] = useSearchParams()
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@@ -161,28 +165,28 @@ export function ResetPassword() {
const mismatch = confirm.length > 0 && password !== confirm const mismatch = confirm.length > 0 && password !== confirm
return ( return (
<AuthLayout title="Choose a password" copy="Set a password for your account. You’ll be signed in afterwards."> <AuthLayout title={t.auth.choosePassword} copy={t.auth.choosePasswordCopy}>
<form <form
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault() event.preventDefault()
if (!mismatch) reset.mutate({ token: params.get('token') ?? '', newPassword: password }, { onSuccess: () => navigate('/', { replace: true }) }) if (!mismatch) reset.mutate({ token: params.get('token') ?? '', newPassword: password }, { onSuccess: () => navigate('/', { replace: true }) })
}} }}
> >
<Field label="New password"> <Field label={t.auth.newPassword}>
<input className={field} type="password" required minLength={10} autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} /> <input className={field} type="password" required minLength={10} autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} />
<span className="mt-1 block font-normal text-faint">At least 10 characters.</span> <span className="mt-1 block font-normal text-faint">{t.auth.atLeast10}</span>
</Field> </Field>
<Field label="Confirm password"> <Field label={t.auth.confirmPassword}>
<input className={field} type="password" required autoComplete="new-password" value={confirm} onChange={(event) => setConfirm(event.target.value)} /> <input className={field} type="password" required autoComplete="new-password" value={confirm} onChange={(event) => setConfirm(event.target.value)} />
{mismatch && <span className="mt-1 block font-normal text-coral-strong">Passwords don’t match.</span>} {mismatch && <span className="mt-1 block font-normal text-coral-strong">{t.auth.mismatch}</span>}
</Field> </Field>
<ErrorMessage error={reset.error} className="mb-4" /> <ErrorMessage error={reset.error} className="mb-4" />
<button className={fullWidth} type="submit" disabled={reset.isPending || mismatch}> <button className={fullWidth} type="submit" disabled={reset.isPending || mismatch}>
Save password and sign in {t.auth.savePassword}
</button> </button>
</form> </form>
<Link to="/login" className={`${textButton} mt-5`}> <Link to="/login" className={`${textButton} mt-5`}>
Back to sign in {t.auth.backToSignIn}
</Link> </Link>
</AuthLayout> </AuthLayout>
) )
+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>
)
}
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more