Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c150acf71d | ||
|
|
84b5a250e2 | ||
|
|
187681a36f | ||
|
|
dab8915375 | ||
|
|
61c876edec | ||
|
|
3e964d1176 |
No files matched your search
@@ -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"]
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
Taken with the demo data from `npm run db:seed`, signed in as the safety manager.
|
||||||
|
|
||||||
|
| Incident register | Incident workflow |
|
||||||
|
| --- | --- |
|
||||||
|
|  |  |
|
||||||
|
| **OSHA Form 300 log** | **Analytics** |
|
||||||
|
|  |  |
|
||||||
|
|
||||||
|
<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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 89 KiB |
@@ -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')
|
||||||
|
})
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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(/\/$/)
|
||||||
|
})
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
@@ -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()
|
||||||
@@ -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;
|
||||||
@@ -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");
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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)],
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -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'
|
||||||
}
|
}
|
||||||
@@ -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') },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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`)
|
return c.json({ attachment: toAttachment(row, me.name) }, 201)
|
||||||
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)
|
|
||||||
} catch (error) {
|
|
||||||
await storage.remove(storageKey)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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' } })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"` })
|
||||||
|
})
|
||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
@@ -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]
|
||||||
@@ -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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Bookmark, Trash2 } from 'lucide-react'
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { useOutletContext, useSearchParams } from 'react-router-dom'
|
||||||
|
import type { SavedView } from '../../shared/api-types.ts'
|
||||||
|
import { hasRole } from '../../shared/enums.ts'
|
||||||
|
import { useSavedViewMutations, useSavedViews, type Me } from '../lib/queries'
|
||||||
|
import { filterBox, iconButton, primaryButton, smallButton } from './styles'
|
||||||
|
|
||||||
|
/** Pick or save a named set of the page's URL filters. */
|
||||||
|
export function SavedViews({ page, keys }: { page: SavedView['page']; keys: string[] }) {
|
||||||
|
const me = useOutletContext<Me>()
|
||||||
|
const [params, setParams] = useSearchParams()
|
||||||
|
const views = useSavedViews(page)
|
||||||
|
const { create, remove } = useSavedViewMutations(page)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [shared, setShared] = useState(false)
|
||||||
|
|
||||||
|
const current = Object.fromEntries(keys.filter((key) => params.get(key)).map((key) => [key, params.get(key)!]))
|
||||||
|
// Key order isn't preserved by the database, so compare sorted entries
|
||||||
|
const canonical = (filters: Record<string, string>) => JSON.stringify(Object.entries(filters).sort(([a], [b]) => a.localeCompare(b)))
|
||||||
|
const active = views.data?.find((view) => canonical(view.filters) === canonical(current))
|
||||||
|
|
||||||
|
const apply = (id: string) => {
|
||||||
|
const view = views.data?.find((v) => v.id === id)
|
||||||
|
if (!view) return
|
||||||
|
const next = new URLSearchParams(params)
|
||||||
|
for (const key of keys) next.delete(key)
|
||||||
|
for (const [key, value] of Object.entries(view.filters)) next.set(key, value)
|
||||||
|
setParams(next, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
create.mutate(
|
||||||
|
{ name, filters: current, shared },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setSaving(false)
|
||||||
|
setName('')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saving) {
|
||||||
|
return (
|
||||||
|
<form onSubmit={save} className="flex flex-wrap items-center gap-2">
|
||||||
|
<input className="h-[38px] rounded-[5px] border border-line bg-white px-3 text-xs outline-none" placeholder="Name this view" aria-label="View name" required value={name} onChange={(event) => setName(event.target.value)} />
|
||||||
|
{hasRole(me.user.role, 'supervisor') && (
|
||||||
|
<label className="flex items-center gap-1.5 text-2xs text-body">
|
||||||
|
<input type="checkbox" className="accent-coral" checked={shared} onChange={(event) => setShared(event.target.checked)} /> Share with everyone
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<button type="submit" className={primaryButton} disabled={create.isPending}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button type="button" className={smallButton} onClick={() => setSaving(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className={filterBox}>
|
||||||
|
<Bookmark size={15} />
|
||||||
|
<select className="bg-transparent text-xs text-ink outline-none" value={active?.id ?? ''} onChange={(event) => apply(event.target.value)} aria-label="Saved views">
|
||||||
|
<option value="" disabled>
|
||||||
|
{views.data?.length ? 'Saved views' : 'No saved views'}
|
||||||
|
</option>
|
||||||
|
{views.data?.map((view) => (
|
||||||
|
<option key={view.id} value={view.id}>
|
||||||
|
{view.name}
|
||||||
|
{view.shared ? ' (shared)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{active && (active.mine || me.user.role === 'admin') ? (
|
||||||
|
<button type="button" className={iconButton} aria-label={`Delete view ${active.name}`} onClick={() => confirm(`Delete the view “${active.name}”?`) && remove.mutate(active.id)}>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className={smallButton} onClick={() => setSaving(true)}>
|
||||||
|
Save view
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,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>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { formatNumber } from './scale'
|
||||||
|
|
||||||
|
/** Horizontal bars for ranked categories: one color, value at the tip, longest first. */
|
||||||
|
export function BarList({ items, color = 'var(--color-series-1)', ariaLabel }: { items: { label: string; value: number }[]; color?: string; ariaLabel: string }) {
|
||||||
|
const max = Math.max(1, ...items.map((item) => item.value))
|
||||||
|
return (
|
||||||
|
<ul className="space-y-2.5" aria-label={ariaLabel}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.label} className="grid grid-cols-[minmax(90px,34%)_1fr] items-center gap-3 text-2xs">
|
||||||
|
<span className="truncate text-body" title={item.label}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="h-3.5 rounded-r-[4px]" style={{ width: `${Math.max(1.5, (item.value / max) * 85)}%`, background: color }} />
|
||||||
|
<strong className="font-semibold text-ink tabular-nums">{formatNumber(item.value)}</strong>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { Table2 } from 'lucide-react'
|
||||||
|
import { useState, type ReactNode } from 'react'
|
||||||
|
import { panel } from '../styles'
|
||||||
|
|
||||||
|
export interface LegendItem {
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
shape?: 'rect' | 'line'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableData {
|
||||||
|
columns: string[]
|
||||||
|
rows: (string | number)[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card for one chart: title, legend (2+ series), and a table view that shows every value, so
|
||||||
|
* nothing depends on hovering or on telling colors apart.
|
||||||
|
*/
|
||||||
|
export function ChartFrame({ title, subtitle, legend, table, children, className = '' }: { title: string; subtitle?: string; legend?: LegendItem[]; table: TableData; children: ReactNode; className?: string }) {
|
||||||
|
const [showTable, setShowTable] = useState(false)
|
||||||
|
return (
|
||||||
|
<figure className={`${panel} m-0 flex flex-col p-5 ${className}`}>
|
||||||
|
<div className="mb-3 flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<figcaption className="font-display text-[17px] font-semibold text-ink">{title}</figcaption>
|
||||||
|
{subtitle && <p className="mt-0.5 text-2xs text-muted">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`inline-flex flex-none items-center gap-1 rounded-[5px] border px-2 py-1 text-3xs font-semibold print:hidden ${showTable ? 'border-ink bg-ink text-white' : 'border-line text-muted hover:text-ink'}`}
|
||||||
|
aria-pressed={showTable}
|
||||||
|
onClick={() => setShowTable(!showTable)}
|
||||||
|
>
|
||||||
|
<Table2 size={12} /> Table
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{legend && legend.length > 1 && !showTable && (
|
||||||
|
<ul className="mb-3 flex flex-wrap gap-x-4 gap-y-1 text-2xs text-body" aria-label="Legend">
|
||||||
|
{legend.map((item) => (
|
||||||
|
<li key={item.label} className="inline-flex items-center gap-1.5">
|
||||||
|
{item.shape === 'line' ? <i className="h-0.5 w-3.5 rounded-full" style={{ background: item.color }} /> : <i className="size-2.5 rounded-[2px]" style={{ background: item.color }} />}
|
||||||
|
{item.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{showTable ? <DataTable data={table} /> : children}
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable({ data }: { data: TableData }) {
|
||||||
|
return (
|
||||||
|
<div className="max-h-[320px] overflow-auto">
|
||||||
|
<table className="w-full border-collapse text-2xs">
|
||||||
|
<thead className="sticky top-0 bg-white">
|
||||||
|
<tr>
|
||||||
|
{data.columns.map((column, i) => (
|
||||||
|
<th key={column} className={`border-b border-line px-2 py-1.5 font-semibold text-[#586762] ${i ? 'text-right' : 'text-left'}`}>
|
||||||
|
{column}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.rows.map((row, r) => (
|
||||||
|
<tr key={r}>
|
||||||
|
{row.map((cell, i) => (
|
||||||
|
<td key={i} className={`border-b border-line-soft px-2 py-1.5 text-body ${i ? 'text-right tabular-nums' : ''}`}>
|
||||||
|
{typeof cell === 'number' ? cell.toLocaleString('en-US', { maximumFractionDigits: 2 }) : cell}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tooltip: value first (strong), series second, keyed with a short line in the series color. */
|
||||||
|
export function Tooltip({ x, y, title, rows, containerWidth }: { x: number; y: number; title: string; rows: { label: string; value: string; color?: string }[]; containerWidth: number }) {
|
||||||
|
const flip = x > containerWidth - 180
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute z-10 min-w-[140px] rounded-md border border-line bg-white px-3 py-2 text-2xs shadow-[0_6px_20px_#1b252322]"
|
||||||
|
style={{ left: flip ? undefined : x + 12, right: flip ? containerWidth - x + 12 : undefined, top: Math.max(0, y - 10) }}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<p className="mb-1 text-3xs text-muted">{title}</p>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<p key={row.label} className="flex items-center gap-2">
|
||||||
|
{row.color && <i className="h-0.5 w-3 flex-none rounded-full" style={{ background: row.color }} />}
|
||||||
|
<strong className="font-semibold text-ink tabular-nums">{row.value}</strong>
|
||||||
|
<span className="text-body">{row.label}</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartEmpty({ children }: { children: ReactNode }) {
|
||||||
|
return <div className="grid min-h-[160px] place-items-center px-4 text-center text-2xs text-muted">{children}</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Tooltip } from './ChartFrame'
|
||||||
|
import { useWidth } from './useWidth'
|
||||||
|
|
||||||
|
const STEPS = ['var(--color-seq-100)', 'var(--color-seq-200)', 'var(--color-seq-300)', 'var(--color-seq-400)', 'var(--color-seq-500)', 'var(--color-seq-600)', 'var(--color-seq-700)']
|
||||||
|
|
||||||
|
/** Grid of counts in one hue, light to dark; empty cells stay near the surface. */
|
||||||
|
export function Heatmap({ grid, rowLabels, colLabels, ariaLabel, describe }: { grid: number[][]; rowLabels: string[]; colLabels: string[]; ariaLabel: string; describe: (row: number, col: number, value: number) => string }) {
|
||||||
|
const [ref, width] = useWidth<HTMLDivElement>()
|
||||||
|
const [hover, setHover] = useState<{ r: number; c: number } | null>(null)
|
||||||
|
const max = Math.max(1, ...grid.flat())
|
||||||
|
const step = (value: number) => (value === 0 ? 'var(--color-line-soft)' : STEPS[Math.min(STEPS.length - 1, Math.floor((value / max) * (STEPS.length - 1)))])
|
||||||
|
const labelW = 34
|
||||||
|
const cell = Math.max(10, Math.floor((width - labelW) / colLabels.length))
|
||||||
|
const height = rowLabels.length * cell + 18
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative" onPointerLeave={() => setHover(null)}>
|
||||||
|
<svg width={width} height={height} role="img" aria-label={ariaLabel} className="block">
|
||||||
|
{rowLabels.map((label, r) => (
|
||||||
|
<text key={label} x={labelW - 6} y={r * cell + cell / 2} dy="0.32em" textAnchor="end" fontSize={10} fill="var(--color-chart-label)">
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
{colLabels.map((label, c) =>
|
||||||
|
c % 3 === 0 ? (
|
||||||
|
<text key={c} x={labelW + c * cell + cell / 2} y={rowLabels.length * cell + 12} textAnchor="middle" fontSize={10} fill="var(--color-chart-label)">
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
{grid.map((row, r) =>
|
||||||
|
row.map((value, c) => (
|
||||||
|
<rect
|
||||||
|
key={`${r}-${c}`}
|
||||||
|
x={labelW + c * cell + 1}
|
||||||
|
y={r * cell + 1}
|
||||||
|
width={cell - 2}
|
||||||
|
height={cell - 2}
|
||||||
|
rx={2}
|
||||||
|
fill={step(value)}
|
||||||
|
stroke={hover?.r === r && hover.c === c ? 'var(--color-ink)' : 'none'}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
tabIndex={value ? 0 : -1}
|
||||||
|
aria-label={describe(r, c, value)}
|
||||||
|
onPointerEnter={() => setHover({ r, c })}
|
||||||
|
onFocus={() => setHover({ r, c })}
|
||||||
|
className="outline-none"
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-3xs text-muted" aria-hidden>
|
||||||
|
<span>Fewer</span>
|
||||||
|
{STEPS.map((color) => (
|
||||||
|
<i key={color} className="h-2.5 w-5 rounded-[2px]" style={{ background: color }} />
|
||||||
|
))}
|
||||||
|
<span>More ({max} max)</span>
|
||||||
|
</div>
|
||||||
|
{hover && (
|
||||||
|
<Tooltip x={labelW + hover.c * cell + cell} y={hover.r * cell} containerWidth={width} title={describe(hover.r, hover.c, grid[hover.r][hover.c]).split(':')[0]} rows={[{ label: grid[hover.r][hover.c] === 1 ? 'incident' : 'incidents', value: String(grid[hover.r][hover.c]) }]} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState, type PointerEvent } from 'react'
|
||||||
|
import { Tooltip } from './ChartFrame'
|
||||||
|
import { formatNumber, niceTicks } from './scale'
|
||||||
|
import type { Series } from './StackedColumns'
|
||||||
|
import { useWidth } from './useWidth'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: { label: string; values: Record<string, number | null> }[]
|
||||||
|
series: Series[]
|
||||||
|
height?: number
|
||||||
|
ariaLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAD = { top: 12, bottom: 26, left: 36 }
|
||||||
|
|
||||||
|
/** Multi-series line chart on one axis, with a snapping crosshair and direct end labels. */
|
||||||
|
export function LineChart({ data, series, height = 220, ariaLabel }: Props) {
|
||||||
|
const [ref, width] = useWidth<HTMLDivElement>()
|
||||||
|
const [hover, setHover] = useState<number | null>(null)
|
||||||
|
// Room on the right for the end labels ("TRIR 10.38"), sized to the longest one
|
||||||
|
const endLabel = (key: string, label: string) => {
|
||||||
|
const last = data.findLast((d) => d.values[key] !== null)?.values[key]
|
||||||
|
return last === undefined || last === null ? '' : `${label} ${formatNumber(last)}`
|
||||||
|
}
|
||||||
|
const right = 16 + Math.max(0, ...series.map((s) => endLabel(s.key, s.label).length)) * 6.2
|
||||||
|
const plotW = width - PAD.left - right
|
||||||
|
const plotH = height - PAD.top - PAD.bottom
|
||||||
|
const all = data.flatMap((d) => series.map((s) => d.values[s.key])).filter((v): v is number => v !== null)
|
||||||
|
const ticks = niceTicks(Math.max(...all, 0.1))
|
||||||
|
const max = ticks.at(-1)!
|
||||||
|
const x = (i: number) => PAD.left + (data.length === 1 ? plotW / 2 : (i / (data.length - 1)) * plotW)
|
||||||
|
const y = (v: number) => PAD.top + plotH - (v / max) * plotH
|
||||||
|
const labelEvery = Math.ceil(data.length / Math.max(1, Math.floor(plotW / 56)))
|
||||||
|
|
||||||
|
const path = (key: string) => {
|
||||||
|
let d = ''
|
||||||
|
data.forEach((point, i) => {
|
||||||
|
const v = point.values[key]
|
||||||
|
if (v === null) return
|
||||||
|
d += `${d && data[i - 1]?.values[key] !== null ? 'L' : 'M'}${x(i).toFixed(1)},${y(v).toFixed(1)}`
|
||||||
|
})
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMove = (event: PointerEvent<SVGRectElement>) => {
|
||||||
|
const box = event.currentTarget.getBoundingClientRect()
|
||||||
|
const ratio = (event.clientX - box.left) / box.width
|
||||||
|
setHover(Math.max(0, Math.min(data.length - 1, Math.round(ratio * (data.length - 1)))))
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastIndex = (key: string) => data.findLastIndex((d) => d.values[key] !== null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<svg width={width} height={height} role="img" aria-label={ariaLabel} className="block">
|
||||||
|
{ticks.map((t) => (
|
||||||
|
<g key={t}>
|
||||||
|
<line x1={PAD.left} x2={width - right} y1={y(t)} y2={y(t)} stroke={t === 0 ? 'var(--color-chart-axis)' : 'var(--color-chart-grid)'} strokeWidth={1} shapeRendering="crispEdges" />
|
||||||
|
<text x={PAD.left - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize={10} fill="var(--color-chart-label)" className="tabular-nums">
|
||||||
|
{formatNumber(t)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{data.map((d, i) =>
|
||||||
|
i % labelEvery === 0 || i === data.length - 1 ? (
|
||||||
|
<text key={d.label} x={x(i)} y={height - 8} textAnchor="middle" fontSize={10} fill="var(--color-chart-label)">
|
||||||
|
{d.label}
|
||||||
|
</text>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
{hover !== null && <line x1={x(hover)} x2={x(hover)} y1={PAD.top} y2={PAD.top + plotH} stroke="var(--color-chart-axis)" strokeWidth={1} />}
|
||||||
|
{series.map((s) => {
|
||||||
|
const last = lastIndex(s.key)
|
||||||
|
return (
|
||||||
|
<g key={s.key}>
|
||||||
|
<path d={path(s.key)} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
{last >= 0 && (
|
||||||
|
<>
|
||||||
|
<circle cx={x(last)} cy={y(data[last].values[s.key]!)} r={4} fill={s.color} stroke="#fff" strokeWidth={2} />
|
||||||
|
<text x={x(last) + 8} y={y(data[last].values[s.key]!)} dy="0.32em" fontSize={10} fill="var(--color-ink)" fontWeight={600}>
|
||||||
|
{s.label} {formatNumber(data[last].values[s.key]!)}
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hover !== null && data[hover].values[s.key] !== null && <circle cx={x(hover)} cy={y(data[hover].values[s.key]!)} r={4} fill={s.color} stroke="#fff" strokeWidth={2} />}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<rect
|
||||||
|
x={PAD.left}
|
||||||
|
y={PAD.top}
|
||||||
|
width={plotW}
|
||||||
|
height={plotH}
|
||||||
|
fill="transparent"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
onPointerMove={onMove}
|
||||||
|
onPointerLeave={() => setHover(null)}
|
||||||
|
onFocus={() => setHover(data.length - 1)}
|
||||||
|
onBlur={() => setHover(null)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'ArrowLeft') setHover((h) => Math.max(0, (h ?? 0) - 1))
|
||||||
|
if (event.key === 'ArrowRight') setHover((h) => Math.min(data.length - 1, (h ?? 0) + 1))
|
||||||
|
}}
|
||||||
|
className="outline-none"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{hover !== null && (
|
||||||
|
<Tooltip
|
||||||
|
x={x(hover)}
|
||||||
|
y={PAD.top}
|
||||||
|
containerWidth={width}
|
||||||
|
title={data[hover].label}
|
||||||
|
rows={series.map((s) => ({ label: s.label, value: data[hover].values[s.key] === null ? '–' : formatNumber(data[hover].values[s.key]!), color: s.color }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Tooltip } from './ChartFrame'
|
||||||
|
import { formatNumber, niceTicks } from './scale'
|
||||||
|
import { useWidth } from './useWidth'
|
||||||
|
|
||||||
|
export interface Series {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: { label: string; values: Record<string, number> }[]
|
||||||
|
series: Series[]
|
||||||
|
height?: number
|
||||||
|
ariaLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAD = { top: 8, right: 8, bottom: 26, left: 32 }
|
||||||
|
const GAP = 2 // surface gap between stacked segments
|
||||||
|
const RADIUS = 4
|
||||||
|
|
||||||
|
/** Rounded top on the data end, square at the baseline. */
|
||||||
|
function columnPath(x: number, y: number, w: number, h: number, roundTop: boolean) {
|
||||||
|
const r = roundTop ? Math.min(RADIUS, h, w / 2) : 0
|
||||||
|
return `M${x},${y + h}V${y + r}${r ? `Q${x},${y} ${x + r},${y}` : ''}H${x + w - r}${r ? `Q${x + w},${y} ${x + w},${y + r}` : ''}V${y + h}Z`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StackedColumns({ data, series, height = 220, ariaLabel }: Props) {
|
||||||
|
const [ref, width] = useWidth<HTMLDivElement>()
|
||||||
|
const [hover, setHover] = useState<number | null>(null)
|
||||||
|
const plotW = width - PAD.left - PAD.right
|
||||||
|
const plotH = height - PAD.top - PAD.bottom
|
||||||
|
const totals = data.map((d) => series.reduce((sum, s) => sum + (d.values[s.key] ?? 0), 0))
|
||||||
|
const ticks = niceTicks(Math.max(...totals, 1))
|
||||||
|
const max = ticks.at(-1)!
|
||||||
|
const band = plotW / Math.max(1, data.length)
|
||||||
|
const barW = Math.min(24, band * 0.6)
|
||||||
|
const y = (v: number) => PAD.top + plotH - (v / max) * plotH
|
||||||
|
const labelEvery = Math.ceil(data.length / Math.max(1, Math.floor(plotW / 56)))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative" onPointerLeave={() => setHover(null)}>
|
||||||
|
<svg width={width} height={height} role="img" aria-label={ariaLabel} className="block">
|
||||||
|
{ticks.map((t) => (
|
||||||
|
<g key={t}>
|
||||||
|
<line x1={PAD.left} x2={width - PAD.right} y1={y(t)} y2={y(t)} stroke={t === 0 ? 'var(--color-chart-axis)' : 'var(--color-chart-grid)'} strokeWidth={1} shapeRendering="crispEdges" />
|
||||||
|
<text x={PAD.left - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize={10} fill="var(--color-chart-label)" className="tabular-nums">
|
||||||
|
{formatNumber(t)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const x = PAD.left + i * band + (band - barW) / 2
|
||||||
|
let base = 0
|
||||||
|
const visible = series.filter((s) => (d.values[s.key] ?? 0) > 0)
|
||||||
|
return (
|
||||||
|
<g key={d.label} opacity={hover === null || hover === i ? 1 : 0.55}>
|
||||||
|
{visible.map((s, j) => {
|
||||||
|
const v = d.values[s.key]
|
||||||
|
const top = y(base + v)
|
||||||
|
const bottom = y(base)
|
||||||
|
base += v
|
||||||
|
const isTop = j === visible.length - 1
|
||||||
|
const h = Math.max(0, bottom - top - (j > 0 ? GAP : 0))
|
||||||
|
return <path key={s.key} d={columnPath(x, top, barW, h, isTop)} fill={s.color} />
|
||||||
|
})}
|
||||||
|
{(i % labelEvery === 0 || i === data.length - 1) && (
|
||||||
|
<text x={x + barW / 2} y={height - 8} textAnchor="middle" fontSize={10} fill="var(--color-chart-label)">
|
||||||
|
{d.label}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
<rect
|
||||||
|
x={PAD.left + i * band}
|
||||||
|
y={PAD.top}
|
||||||
|
width={band}
|
||||||
|
height={plotH}
|
||||||
|
fill="transparent"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${d.label}: ${series.map((s) => `${d.values[s.key] ?? 0} ${s.label}`).join(', ')}`}
|
||||||
|
onPointerEnter={() => setHover(i)}
|
||||||
|
onFocus={() => setHover(i)}
|
||||||
|
onBlur={() => setHover(null)}
|
||||||
|
className="outline-none"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
{hover !== null && (
|
||||||
|
<Tooltip
|
||||||
|
x={PAD.left + hover * band + band / 2}
|
||||||
|
y={y(totals[hover])}
|
||||||
|
containerWidth={width}
|
||||||
|
title={`${data[hover].label} · ${formatNumber(totals[hover])} total`}
|
||||||
|
rows={[...series].reverse().map((s) => ({ label: s.label, value: formatNumber(data[hover].values[s.key] ?? 0), color: s.color }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react'
|
||||||
|
|
||||||
|
interface StatTileProps {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
/** Comparison with the previous period. */
|
||||||
|
delta?: { text: string; direction: 'up' | 'down' | 'flat'; good: boolean | null }
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single number with its change vs the previous period (color = direction × whether up is good). */
|
||||||
|
export function StatTile({ label, value, delta, hint }: StatTileProps) {
|
||||||
|
const Icon = delta?.direction === 'up' ? ArrowUpRight : delta?.direction === 'down' ? ArrowDownRight : Minus
|
||||||
|
const tone = !delta || delta.good === null ? 'text-muted' : delta.good ? 'text-delta-good' : 'text-delta-bad'
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-line bg-white p-4">
|
||||||
|
<p className="text-2xs text-muted">{label}</p>
|
||||||
|
<p className="mt-1.5 font-display text-[26px] leading-none font-semibold tracking-[-0.5px] text-ink">{value}</p>
|
||||||
|
{delta && (
|
||||||
|
<p className={`mt-2 flex items-center gap-1 text-3xs font-semibold ${tone}`}>
|
||||||
|
<Icon size={13} /> {delta.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{hint && <p className="mt-1 text-3xs leading-[1.4] text-faint">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { niceTicks } from './scale'
|
||||||
|
|
||||||
|
describe('niceTicks', () => {
|
||||||
|
it('produces round steps that cover the maximum', () => {
|
||||||
|
expect(niceTicks(7)).toEqual([0, 2, 4, 6, 8])
|
||||||
|
expect(niceTicks(23)).toEqual([0, 10, 20, 30])
|
||||||
|
expect(niceTicks(1.3)).toEqual([0, 0.5, 1, 1.5])
|
||||||
|
expect(niceTicks(0)).toEqual([0, 1])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** Round axis ticks (0, 5, 10… / 0, 0.5, 1…) covering 0..max, about `count` of them. */
|
||||||
|
export function niceTicks(max: number, count = 4): number[] {
|
||||||
|
if (!(max > 0)) return [0, 1]
|
||||||
|
const raw = max / count
|
||||||
|
const magnitude = 10 ** Math.floor(Math.log10(raw))
|
||||||
|
const step = [1, 2, 2.5, 5, 10].map((m) => m * magnitude).find((s) => s >= raw) ?? raw
|
||||||
|
const ticks: number[] = []
|
||||||
|
for (let v = 0; v <= max + step * 0.999; v += step) ticks.push(Math.round(v * 1000) / 1000)
|
||||||
|
return ticks
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatNumber = (value: number) => value.toLocaleString('en-US', { maximumFractionDigits: 2 })
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
/** Tracks an element's width so SVG charts draw at real pixels (crisp text, no stretching). */
|
||||||
|
export function useWidth<T extends HTMLElement>(fallback = 600) {
|
||||||
|
const ref = useRef<T>(null)
|
||||||
|
const [width, setWidth] = useState(fallback)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const element = ref.current
|
||||||
|
if (!element) return
|
||||||
|
const observer = new ResizeObserver(([entry]) => setWidth(Math.max(240, Math.round(entry.contentRect.width))))
|
||||||
|
observer.observe(element)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
return [ref, width] as const
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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' },
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
@@ -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 }),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)}`
|
||||||
|
}
|
||||||
@@ -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}>
|
||||||
<BrowserRouter>
|
<I18nProvider>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
</BrowserRouter>
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</I18nProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,112 +1,307 @@
|
|||||||
import { Flame } from 'lucide-react'
|
import { CalendarRange, Filter, MapPin } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { Link, useOutletContext, useSearchParams } from 'react-router-dom'
|
||||||
import { incidentTypes } from '../../shared/enums.ts'
|
import type { AnalyticsStats, Kpi } from '../../shared/api-types.ts'
|
||||||
import { Eyebrow } from '../components/Eyebrow'
|
import { hasRole, incidentTypes } from '../../shared/enums.ts'
|
||||||
|
import { BarList } from '../components/charts/BarList'
|
||||||
|
import { ChartEmpty, ChartFrame } from '../components/charts/ChartFrame'
|
||||||
|
import { Heatmap } from '../components/charts/Heatmap'
|
||||||
|
import { LineChart } from '../components/charts/LineChart'
|
||||||
|
import { StackedColumns, type Series } from '../components/charts/StackedColumns'
|
||||||
|
import { StatTile } from '../components/charts/StatTile'
|
||||||
import { Page } from '../components/Page'
|
import { Page } from '../components/Page'
|
||||||
import { PageIntro } from '../components/PageIntro'
|
import { PageIntro } from '../components/PageIntro'
|
||||||
import { ProgressBar } from '../components/ProgressRow'
|
|
||||||
import { ErrorMessage, Loading } from '../components/QueryState'
|
import { ErrorMessage, Loading } from '../components/QueryState'
|
||||||
import { filterBox, panel } from '../components/styles'
|
import { SavedViews } from '../components/SavedViews'
|
||||||
|
import { filterBox, panel, sectionHeading } from '../components/styles'
|
||||||
|
import { formatWeek, toIsoDate } from '../lib/dates'
|
||||||
import { incidentTypeLabel, rootCauseCategoryLabel } from '../lib/labels'
|
import { incidentTypeLabel, rootCauseCategoryLabel } from '../lib/labels'
|
||||||
import { useAnalytics } from '../lib/queries'
|
import { useAnalytics, useSites, type Me } from '../lib/queries'
|
||||||
|
|
||||||
const card = 'flex min-h-[240px] flex-col items-start p-[26px]'
|
const select = 'bg-transparent text-xs text-ink outline-none'
|
||||||
const bigStat = 'mt-8 mb-[25px] font-display text-[25px] font-semibold'
|
const ranges = [
|
||||||
const pct = (value: number) => `${Math.round(value * 100)}%`
|
{ value: '30', label: 'Last 30 days' },
|
||||||
|
{ value: '90', label: 'Last 90 days' },
|
||||||
|
{ value: '365', label: 'Last 12 months' },
|
||||||
|
{ value: 'ytd', label: 'Year to date' },
|
||||||
|
{ value: 'custom', label: 'Custom range' },
|
||||||
|
] as const
|
||||||
|
const FILTER_KEYS = ['range', 'from', 'to', 'site', 'type']
|
||||||
|
|
||||||
|
const reportSeries: Series[] = [
|
||||||
|
{ key: 'hazards', label: 'Hazards & near misses', color: 'var(--color-series-1)' },
|
||||||
|
{ key: 'injuries', label: 'Injuries & illnesses', color: 'var(--color-series-2)' },
|
||||||
|
{ key: 'damage', label: 'Damage & environmental', color: 'var(--color-series-3)' },
|
||||||
|
]
|
||||||
|
const rateSeries: Series[] = [
|
||||||
|
{ key: 'trir', label: 'TRIR', color: 'var(--color-series-1)' },
|
||||||
|
{ key: 'dart', label: 'DART', color: 'var(--color-series-2)' },
|
||||||
|
]
|
||||||
|
const actionSeries: Series[] = [
|
||||||
|
{ key: 'onTime', label: 'On time', color: 'var(--color-status-good)' },
|
||||||
|
{ key: 'late', label: 'Late', color: 'var(--color-status-serious)' },
|
||||||
|
]
|
||||||
|
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||||
|
const hours = Array.from({ length: 24 }, (_, h) => String(h).padStart(2, '0'))
|
||||||
|
const monthLabel = (key: string) => new Date(`${key}-01T00:00:00`).toLocaleDateString('en-US', { month: 'short', year: '2-digit' })
|
||||||
|
|
||||||
|
/** Resolves the range control to concrete dates. */
|
||||||
|
function periodFrom(params: URLSearchParams): { from: string; to: string } {
|
||||||
|
const today = new Date()
|
||||||
|
const range = params.get('range') ?? '90'
|
||||||
|
if (range === 'custom' && params.get('from') && params.get('to')) return { from: params.get('from')!, to: params.get('to')! }
|
||||||
|
if (range === 'ytd') return { from: `${today.getFullYear()}-01-01`, to: toIsoDate(today) }
|
||||||
|
const days = Number(range) || 90
|
||||||
|
const start = new Date(today)
|
||||||
|
start.setDate(start.getDate() - (days - 1))
|
||||||
|
return { from: toIsoDate(start), to: toIsoDate(today) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pct = (value: number | null) => (value === null ? '–' : `${Math.round(value * 100)}%`)
|
||||||
|
const num = (value: number | null, digits = 0) => (value === null ? '–' : value.toLocaleString('en-US', { maximumFractionDigits: digits }))
|
||||||
|
|
||||||
|
/** Delta text and tone. `upIsGood` false means fewer is better (e.g. injuries). */
|
||||||
|
function delta(kpi: Kpi, upIsGood: boolean | null, format: (v: number) => string = (v) => num(v)) {
|
||||||
|
if (kpi.value === null || kpi.previous === null) return undefined
|
||||||
|
const diff = kpi.value - kpi.previous
|
||||||
|
if (Math.abs(diff) < 1e-9) return { text: 'Same as previous period', direction: 'flat' as const, good: null }
|
||||||
|
return {
|
||||||
|
text: `${diff > 0 ? '+' : '−'}${format(Math.abs(diff))} vs previous period`,
|
||||||
|
direction: diff > 0 ? ('up' as const) : ('down' as const),
|
||||||
|
good: upIsGood === null ? null : diff > 0 === upIsGood,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function Analytics() {
|
export function Analytics() {
|
||||||
const [days, setDays] = useState(90)
|
const me = useOutletContext<Me>()
|
||||||
const stats = useAnalytics(days)
|
const [params, setParams] = useSearchParams()
|
||||||
|
const sites = useSites()
|
||||||
|
const period = periodFrom(params)
|
||||||
|
const filters = { ...period, siteId: params.get('site') ?? undefined, type: params.get('type') ?? undefined }
|
||||||
|
const stats = useAnalytics(filters)
|
||||||
|
const set = (key: string, value: string) => {
|
||||||
|
const next = new URLSearchParams(params)
|
||||||
|
if (value) next.set(key, value)
|
||||||
|
else next.delete(key)
|
||||||
|
if (key === 'range' && value === 'custom') {
|
||||||
|
next.set('from', period.from)
|
||||||
|
next.set('to', period.to)
|
||||||
|
}
|
||||||
|
setParams(next, { replace: true })
|
||||||
|
}
|
||||||
const s = stats.data
|
const s = stats.data
|
||||||
|
|
||||||
const topType = s?.byType[0]
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageIntro
|
<PageIntro eyebrow="Program intelligence" title="Analytics" copy="Leading indicators show whether people are spotting and fixing risks; lagging indicators count the harm that happened anyway." />
|
||||||
eyebrow="Program intelligence"
|
|
||||||
title="Analytics"
|
{/* One filter row scopes everything below it */}
|
||||||
copy="Patterns that help your team prevent the next incident."
|
<div className="mb-6 flex flex-wrap items-center gap-2.5 print:hidden">
|
||||||
action={
|
<div className={filterBox}>
|
||||||
<div className={filterBox}>
|
<CalendarRange size={15} />
|
||||||
<select className="bg-transparent text-xs text-ink outline-none" value={days} onChange={(event) => setDays(Number(event.target.value))} aria-label="Period">
|
<select className={select} value={params.get('range') ?? '90'} onChange={(event) => set('range', event.target.value)} aria-label="Date range">
|
||||||
<option value={30}>Last 30 days</option>
|
{ranges.map((r) => (
|
||||||
<option value={90}>Last 90 days</option>
|
<option key={r.value} value={r.value}>
|
||||||
<option value={180}>Last 6 months</option>
|
{r.label}
|
||||||
<option value={365}>Last 12 months</option>
|
</option>
|
||||||
</select>
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{params.get('range') === 'custom' && (
|
||||||
|
<div className={`${filterBox} gap-1`}>
|
||||||
|
<input type="date" className={select} value={period.from} max={period.to} onChange={(event) => set('from', event.target.value)} aria-label="From" />
|
||||||
|
<span className="text-muted">–</span>
|
||||||
|
<input type="date" className={select} value={period.to} min={period.from} onChange={(event) => set('to', event.target.value)} aria-label="To" />
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
/>
|
<div className={filterBox}>
|
||||||
|
<MapPin size={15} />
|
||||||
|
<select className={select} value={params.get('site') ?? ''} onChange={(event) => set('site', event.target.value)} aria-label="Site">
|
||||||
|
<option value="">All sites</option>
|
||||||
|
{sites.data?.map((site) => (
|
||||||
|
<option key={site.id} value={site.id}>
|
||||||
|
{site.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className={filterBox}>
|
||||||
|
<Filter size={15} />
|
||||||
|
<select className={select} value={params.get('type') ?? ''} onChange={(event) => set('type', event.target.value)} aria-label="Incident type">
|
||||||
|
<option value="">All types</option>
|
||||||
|
{incidentTypes.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{incidentTypeLabel[type]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<SavedViews page="analytics" keys={FILTER_KEYS} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ErrorMessage error={stats.error} className="mb-5" />
|
<ErrorMessage error={stats.error} className="mb-5" />
|
||||||
{!s ? (
|
{!s ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className={`space-y-6 transition-opacity ${stats.isPlaceholderData ? 'opacity-50' : ''}`}>
|
||||||
<div className="mb-5 grid gap-5 lg:grid-cols-[1.3fr_1fr_1fr]">
|
<Indicators stats={s} showRates={hasRole(me.user.role, 'supervisor')} />
|
||||||
<div className={`${card} rounded-lg border border-forest-900 bg-forest-900 text-[#dfece5] shadow-[0_5px_20px_#1b252305]`}>
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
<Flame size={22} />
|
<ChartFrame
|
||||||
<Eyebrow className="mt-4 text-[#9dc0b3]">Most common</Eyebrow>
|
className="lg:col-span-2"
|
||||||
{topType ? (
|
title={`Reports per ${s.bucket}`}
|
||||||
<>
|
subtitle="Rising hazard and near-miss reporting with falling injuries is the pattern to aim for."
|
||||||
<h2 className="mt-5 mb-2.5 font-display text-2xl leading-[1.15] font-semibold">{incidentTypeLabel[topType.type]} is your most reported type.</h2>
|
legend={reportSeries.map((x) => ({ label: x.label, color: x.color }))}
|
||||||
<p className="text-xs leading-[1.6] text-[#a7c0b8]">
|
table={{ columns: [s.bucket === 'week' ? 'Week of' : 'Month', ...reportSeries.map((x) => x.label)], rows: s.trend.map((b) => [b.start, b.hazards, b.injuries, b.damage]) }}
|
||||||
{topType.count} of {s.total} reports ({pct(topType.count / s.total)}) in this period.
|
>
|
||||||
</p>
|
{s.trend.some((b) => b.hazards + b.injuries + b.damage > 0) ? (
|
||||||
</>
|
<StackedColumns
|
||||||
|
ariaLabel="Reports over time by kind"
|
||||||
|
series={reportSeries}
|
||||||
|
data={s.trend.map((b) => ({ label: s.bucket === 'week' ? formatWeek(b.start) : monthLabel(b.start.slice(0, 7)), values: { hazards: b.hazards, injuries: b.injuries, damage: b.damage } }))}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<h2 className="mt-5 font-display text-2xl leading-[1.15] font-semibold">No reports in this period.</h2>
|
<ChartEmpty>No reports in this period.</ChartEmpty>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ChartFrame>
|
||||||
<div className={`${panel} ${card}`}>
|
|
||||||
<Eyebrow>Most common root cause</Eyebrow>
|
{s.rates && (
|
||||||
{s.topRootCause ? (
|
<ChartFrame
|
||||||
<>
|
className="lg:col-span-2"
|
||||||
<strong className={bigStat}>{rootCauseCategoryLabel[s.topRootCause.category]}</strong>
|
title="OSHA rates, rolling 12 months"
|
||||||
<div className="flex w-full justify-between text-2xs text-muted">
|
subtitle="Cases per 100 full-time workers a year, measured over the 12 months ending each month."
|
||||||
<span>
|
legend={rateSeries.map((x) => ({ label: x.label, color: x.color, shape: 'line' }))}
|
||||||
{s.topRootCause.count} root cause{s.topRootCause.count === 1 ? '' : 's'}
|
table={{ columns: ['12 months ending', 'TRIR', 'DART'], rows: s.rates.map((r) => [monthLabel(r.month), r.trir ?? '–', r.dart ?? '–']) }}
|
||||||
</span>
|
>
|
||||||
<span>{pct(s.topRootCause.share)} of total</span>
|
{s.rates.some((r) => r.trir !== null) ? (
|
||||||
</div>
|
<LineChart ariaLabel="Rolling 12-month TRIR and DART" series={rateSeries} data={s.rates.map((r) => ({ label: monthLabel(r.month), values: { trir: r.trir, dart: r.dart } }))} />
|
||||||
<ProgressBar percent={s.topRootCause.share * 100} className="mt-2.5 w-full" />
|
) : (
|
||||||
</>
|
<ChartEmpty>
|
||||||
|
Rates need hours worked.{' '}
|
||||||
|
<Link to="/osha?tab=hours" className="text-coral underline">
|
||||||
|
Enter monthly hours on the OSHA log
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</ChartEmpty>
|
||||||
|
)}
|
||||||
|
</ChartFrame>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChartFrame
|
||||||
|
title={params.get('site') ? 'Where in this site' : 'Where incidents happen'}
|
||||||
|
subtitle="Reports in this period"
|
||||||
|
table={{ columns: ['Location', 'Reports'], rows: s.byLocation.map((l) => [l.name, l.count]) }}
|
||||||
|
>
|
||||||
|
{s.byLocation.length ? <BarList ariaLabel="Reports by location" items={s.byLocation.slice(0, 8).map((l) => ({ label: l.name, value: l.count }))} /> : <ChartEmpty>No reports in this period.</ChartEmpty>}
|
||||||
|
</ChartFrame>
|
||||||
|
|
||||||
|
<ChartFrame title="Root causes" subtitle="From investigations of incidents in this period" table={{ columns: ['Category', 'Root causes'], rows: s.byRootCause.map((c) => [rootCauseCategoryLabel[c.category], c.count]) }}>
|
||||||
|
{s.byRootCause.length ? (
|
||||||
|
<BarList ariaLabel="Root causes by category" items={s.byRootCause.map((c) => ({ label: rootCauseCategoryLabel[c.category], value: c.count }))} />
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-8 text-xs text-muted">No root causes recorded in this period yet.</p>
|
<ChartEmpty>No root causes recorded for incidents in this period yet.</ChartEmpty>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ChartFrame>
|
||||||
<div className={`${panel} ${card}`}>
|
|
||||||
<Eyebrow>Actions completed on time</Eyebrow>
|
<ChartFrame
|
||||||
<strong className={bigStat}>{s.onTimeActionRate === null ? '–' : pct(s.onTimeActionRate)}</strong>
|
className="lg:col-span-2"
|
||||||
<p className="text-xs leading-[1.6] text-muted">of corrective actions with a due date were done by then.</p>
|
title="When incidents happen"
|
||||||
{s.monthlyCompletedActions.length > 0 && (
|
subtitle="By day of the week and hour of the day. Clusters often point at shift changes, overtime or specific tasks."
|
||||||
<div className="mt-auto flex h-[70px] w-full items-end gap-[7px] pt-4" aria-label="On-time share by month">
|
table={{ columns: ['Day', ...hours.map((h) => `${h}:00`)], rows: s.heatmap.map((row, i) => [weekdays[i], ...row]) }}
|
||||||
{s.monthlyCompletedActions.map((m) => {
|
>
|
||||||
const total = m.onTime + m.late
|
{s.heatmap.flat().some((v) => v > 0) ? (
|
||||||
return <i key={m.month} title={`${m.month}: ${m.onTime} on time, ${m.late} late`} className="min-h-[6px] flex-1 rounded-t-[3px] bg-[#76a99d]" style={{ height: `${(m.onTime / total) * 100}%` }} />
|
<Heatmap
|
||||||
})}
|
ariaLabel="Incidents by weekday and hour"
|
||||||
</div>
|
grid={s.heatmap}
|
||||||
|
rowLabels={weekdays}
|
||||||
|
colLabels={hours}
|
||||||
|
describe={(r, c, v) => `${weekdays[r]} ${hours[c]}:00–${hours[c]}:59: ${v} incident${v === 1 ? '' : 's'}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ChartEmpty>No reports in this period.</ChartEmpty>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ChartFrame>
|
||||||
|
|
||||||
|
<ChartFrame
|
||||||
|
className="lg:col-span-2"
|
||||||
|
title="Corrective actions completed"
|
||||||
|
subtitle="Actions with a due date, by the month they were completed"
|
||||||
|
legend={actionSeries.map((x) => ({ label: x.label, color: x.color }))}
|
||||||
|
table={{ columns: ['Month', 'On time', 'Late'], rows: s.actionsByMonth.map((m) => [monthLabel(m.month), m.onTime, m.late]) }}
|
||||||
|
>
|
||||||
|
{s.actionsByMonth.some((m) => m.onTime + m.late > 0) ? (
|
||||||
|
<StackedColumns ariaLabel="Corrective actions completed on time and late by month" series={actionSeries} data={s.actionsByMonth.map((m) => ({ label: monthLabel(m.month), values: { onTime: m.onTime, late: m.late } }))} />
|
||||||
|
) : (
|
||||||
|
<ChartEmpty>No corrective actions with due dates were completed in this period.</ChartEmpty>
|
||||||
|
)}
|
||||||
|
</ChartFrame>
|
||||||
</div>
|
</div>
|
||||||
<section className={`${panel} p-[26px]`}>
|
{s.yearly && <YearlyRates yearly={s.yearly} />}
|
||||||
<Eyebrow>Reports by type</Eyebrow>
|
</div>
|
||||||
<ul className="mt-4 space-y-3">
|
|
||||||
{incidentTypes.map((type) => {
|
|
||||||
const count = s.byType.find((t) => t.type === type)?.count ?? 0
|
|
||||||
return (
|
|
||||||
<li key={type}>
|
|
||||||
<div className="flex justify-between text-2xs text-body">
|
|
||||||
<span>{incidentTypeLabel[type]}</span>
|
|
||||||
<strong className="font-mono font-normal text-ink">{count}</strong>
|
|
||||||
</div>
|
|
||||||
<ProgressBar percent={s.total ? (count / s.total) * 100 : 0} barClassName="bg-sage" />
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Indicators({ stats, showRates }: { stats: AnalyticsStats; showRates: boolean }) {
|
||||||
|
const k = stats.kpis
|
||||||
|
const latestRate = stats.rates?.at(-1)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section>
|
||||||
|
<h2 className={`${sectionHeading} mb-1`}>Leading indicators</h2>
|
||||||
|
<p className="mb-3 text-2xs text-muted">Proactive: are people reporting risks, and is the team acting on them quickly?</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<StatTile label="Hazards & near misses reported" value={num(k.leadingReports.value)} delta={delta(k.leadingReports, true)} hint="More reporting is good: it means risks are surfacing before someone is hurt." />
|
||||||
|
<StatTile label="Near misses per injury" value={num(k.nearMissRatio.value, 1)} delta={delta(k.nearMissRatio, true, (v) => num(v, 1))} hint="Hazards and near misses for each injury or illness." />
|
||||||
|
<StatTile label="Triaged within 24 hours" value={pct(k.triagedWithin24h.value)} delta={delta(k.triagedWithin24h, true, (v) => `${Math.round(v * 100)} pts`)} hint="Reports moved past “Reported” within a day." />
|
||||||
|
<StatTile label="Actions completed on time" value={pct(k.actionsOnTime.value)} delta={delta(k.actionsOnTime, true, (v) => `${Math.round(v * 100)} pts`)} hint={`${k.overdueActions} open action${k.overdueActions === 1 ? ' is' : 's are'} overdue now.`} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2 className={`${sectionHeading} mb-1`}>Lagging indicators</h2>
|
||||||
|
<p className="mb-3 text-2xs text-muted">Outcomes: the harm that happened. Fewer is better.</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<StatTile label="Injuries & illnesses reported" value={num(k.injuries.value)} delta={delta(k.injuries, false)} />
|
||||||
|
{showRates && k.recordables && <StatTile label="OSHA-recordable cases" value={num(k.recordables.value)} delta={delta(k.recordables, false)} />}
|
||||||
|
{showRates && <StatTile label="TRIR, last 12 months" value={latestRate?.trir === null || !latestRate ? '–' : num(latestRate.trir, 2)} hint={latestRate?.trir === null ? 'Needs hours worked (OSHA log)' : `DART ${num(latestRate?.dart ?? null, 2)}`} />}
|
||||||
|
<StatTile label="Average days to close" value={num(k.averageDaysToClose.value, 1)} delta={delta(k.averageDaysToClose, false, (v) => `${num(v, 1)} d`)} hint="For incidents closed in this period" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function YearlyRates({ yearly }: { yearly: NonNullable<AnalyticsStats['yearly']> }) {
|
||||||
|
return (
|
||||||
|
<section className={`${panel} p-5`}>
|
||||||
|
<h2 className={sectionHeading}>OSHA rates by year</h2>
|
||||||
|
<p className="mt-0.5 mb-3 text-2xs text-muted">Calendar years. Compare with your industry’s published BLS rates for context.</p>
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full border-collapse text-2xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-[#586762]">
|
||||||
|
{['Year', 'Recordable cases', 'DART cases', 'Hours worked', 'TRIR', 'DART'].map((h, i) => (
|
||||||
|
<th key={h} className={`border-b border-line px-2 py-1.5 font-semibold ${i ? 'text-right' : 'text-left'}`}>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{yearly.map((y) => (
|
||||||
|
<tr key={y.year}>
|
||||||
|
<td className="border-b border-line-soft px-2 py-1.5 text-body">{y.year}</td>
|
||||||
|
{[y.recordables, y.dartCases, y.hours || '–', y.trir ?? '–', y.dart ?? '–'].map((v, i) => (
|
||||||
|
<td key={i} className="border-b border-line-soft px-2 py-1.5 text-right text-body tabular-nums">
|
||||||
|
{typeof v === 'number' ? v.toLocaleString('en-US', { maximumFractionDigits: 2 }) : v}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||