Compare commits

..
2 Commits
Author SHA1 Message Date
jayjennings 3f6dff5f24 feat: implement permissions and actions management
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
- Add permissions logic for status options in corrective actions.
- Create tests for permissions functionality.
- Implement queries for user authentication and actions management.
- Develop Actions page to display and manage corrective actions.
- Create authentication pages for login, setup, and password reset.
- Add IncidentDetail page to view incident details and associated actions.
- Implement Settings page for user management and password changes.
2026-10-06 11:20:44 -07:00
jayjennings 708da6b9f7 refactor: remove incident management code and related tests 2026-10-06 11:20:32 -07:00
98 changed files with 7213 additions and 789 deletions

No files matched your search

+12
View File
@@ -10,6 +10,18 @@
# The URL people open in their browser. Use https:// in production so session cookies are marked Secure.
# PUBLIC_URL=http://localhost:3000
# Email (sign-in links, password resets, invitations, notifications). Without it, emails are
# printed to the server log in development and dropped in production.
# SMTP_URL=smtps://user:password@smtp.example.com:465
# MAIL_FROM=Open SafetyLog EHS <safety@example.com>
# Time zone used for due dates and daily reminders, e.g. America/Chicago.
# TZ=UTC
# Where uploaded photos are stored, and the per-file size limit.
# UPLOAD_DIR=./data/uploads
# MAX_UPLOAD_MB=10
# Docker Compose only: password for the bundled PostgreSQL container.
# Use letters and numbers only (it is embedded in a connection URL).
# POSTGRES_PASSWORD=change-me
+2 -1
View File
@@ -4,7 +4,8 @@
"rules": {
"react/rules-of-hooks": "error",
"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"
},
"ignorePatterns": ["dist", "playwright-report", "test-results"]
}
+2
View File
@@ -14,6 +14,8 @@ ENV NODE_ENV=production \
PORT=3000 \
DATABASE_URL=pglite:///app/data/pglite
WORKDIR /app
# Time-zone data so TZ (e.g. America/Chicago) sets local days for due dates and reminders
RUN apk add --no-cache tzdata
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY server ./server
+10 -3
View File
@@ -2,7 +2,7 @@
Free, open-source accident and safety tracking for manufacturing: incident and near-miss reporting, investigations, corrective actions and (planned) OSHA record keeping.
> **Status: early development.** The API and database are in place; the web app still shows demo data until it is connected in Phase 2. 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–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.
## Getting started
@@ -14,7 +14,9 @@ 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
```
Demo users (after seeding): `admin@example.com`, `manager@example.com`, `supervisor@example.com` and `reporter@example.com`, all with password `safetylog-demo`. Without seeding, call `POST /api/setup` to create your organization and first admin (see [docs/API.md](docs/API.md)).
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.
In development, emails (sign-in links, invitations, notifications) are printed in the terminal running the server.
Settings are read from environment variables or a `.env` file; see [.env.example](.env.example).
@@ -25,7 +27,12 @@ cp .env.example .env # set POSTGRES_PASSWORD, and PUBLIC_URL if not localhost
docker compose up -d --build
```
This runs the app and PostgreSQL. The app applies database migrations on startup and serves the web app and API on port 3000. Use HTTPS (for example behind a reverse proxy) and set `PUBLIC_URL` to the `https://` address so session cookies are marked secure.
This runs the app and PostgreSQL. The app applies database migrations on startup and serves the web app and API on port 3000. Uploaded photos are kept in the `app-data` volume.
- Use HTTPS (for example behind a reverse proxy) and set `PUBLIC_URL` to the `https://` address so session cookies are marked secure and email links point to the right place.
- 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.
- Back up both volumes: `db-data` (PostgreSQL) and `app-data` (uploads).
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>`.
+6
View File
@@ -26,9 +26,15 @@ services:
environment:
DATABASE_URL: postgres://safetylog:${POSTGRES_PASSWORD}@db:5432/safetylog
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000}
SMTP_URL: ${SMTP_URL:-}
MAIL_FROM: ${MAIL_FROM:-Open SafetyLog EHS <no-reply@localhost>}
TZ: ${TZ:-UTC}
volumes:
- app-data:/app/data # uploaded photos and files
ports:
- "${PORT:-3000}:3000"
restart: unless-stopped
volumes:
db-data:
app-data:
+70 -16
View File
@@ -1,11 +1,13 @@
# API reference
JSON over HTTP under `/api`. Authentication uses a session cookie (`safetylog_session`, http-only) set by setup, sign-in and password change.
JSON over HTTP under `/api`. Authentication uses a session cookie (`safetylog_session`, http-only) set by setup, sign-in, emailed links and password changes.
Request bodies are validated against the schemas in [`shared/schemas.ts`](../shared/schemas.ts). Errors look like `{ "error": "message" }`; validation errors (400) also include `issues: [{ path, message }]`.
Request bodies are validated against [`shared/schemas.ts`](../shared/schemas.ts); response shapes are in [`shared/api-types.ts`](../shared/api-types.ts). Errors look like `{ "error": "message" }`; validation errors (400) also include `issues: [{ path, message }]`.
Roles, from least to most privileged: `reporter` < `supervisor` < `safety_manager` < `admin`. A role can do everything the roles below it can.
Browsers must send an `Origin` header matching the site on form/multipart and body-less requests (all browsers do); JSON requests are protected by CORS.
## Setup and authentication
| Method | Path | Who | Notes |
@@ -14,19 +16,24 @@ Roles, from least to most privileged: `reporter` < `supervisor` < `safety_manage
| GET | `/api/setup` | anyone | `{ needsSetup }`: true until the first admin exists |
| POST | `/api/setup` | anyone, once | `{ organizationName, name, email, password }`. Creates the organization and an admin, signs them in. 409 afterwards |
| POST | `/api/auth/login` | anyone | `{ email, password }`. 401 on bad credentials, 429 after 10 attempts per email in 15 minutes |
| POST | `/api/auth/magic-link` | anyone | `{ email }`. Emails a single-use sign-in link (15 minutes). Always 204 |
| POST | `/api/auth/magic-link/verify` | anyone | `{ token }`. Signs in |
| POST | `/api/auth/password-reset` | anyone | `{ email }`. Emails a reset link (1 hour). Always 204 |
| POST | `/api/auth/password-reset/confirm` | anyone | `{ token, newPassword }`. Sets the password, signs out other sessions, signs in. Also completes invitations |
| POST | `/api/auth/logout` | anyone | Ends the session. 204 |
| GET | `/api/auth/me` | signed in | `{ user, organization }` |
| POST | `/api/auth/password` | signed in | `{ currentPassword, newPassword }`. Signs out all other sessions. 204 |
Passwords need at least 10 characters.
Passwords need at least 10 characters. Email requests are limited to 5 per address per 15 minutes. Emailed links land on web pages (`/auth/magic`, `/auth/reset`) that POST the token, so link scanners in email systems can't use them up.
## Users
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/users` | safety_manager | All users in the organization |
| POST | `/api/users` | admin | `{ name, email, role, password }`. 409 if the email exists |
| PATCH | `/api/users/:id` | admin | `{ name?, role?, active? }`. Deactivating signs the user out. The last active admin can't be demoted or deactivated |
| GET | `/api/users/assignable` | supervisor | Active users `{ id, name, role }`, for assigning leads and actions |
| POST | `/api/users` | admin | `{ name, email, role, password? }`. Without `password` an invitation email is sent (link valid 7 days). Returns `{ user, invited }` |
| PATCH | `/api/users/:id` | admin | `{ name?, role?, active?, password? }`. Deactivating or setting a password signs the user out. The last active admin can't be demoted or deactivated |
## Sites and areas
@@ -35,21 +42,31 @@ Passwords need at least 10 characters.
| GET | `/api/sites` | signed in | Sites with their `areas` (flat list; build the tree from `parentId`). `?includeArchived=true` to include archived |
| POST | `/api/sites` | safety_manager | `{ name, address?, timezone? }` |
| PATCH | `/api/sites/:id` | safety_manager | `{ name?, address?, timezone?, archived? }` |
| POST | `/api/sites/:id/areas` | safety_manager | `{ name, kind?, parentId? }`. `kind`: `building`, `area` (default), `line`, `other`. Parent must be in the same site |
| POST | `/api/sites/:id/areas` | safety_manager | `{ name, kind?, parentId? }`. `kind`: `building`, `area` (default), `line`, `other` |
| PATCH | `/api/areas/:id` | safety_manager | `{ name?, kind?, archived? }` |
## Incidents
Incidents are addressed by their number (`/api/incidents/42` is `INC-0042`). Numbers are sequential per organization with no gaps.
Addressed by number (`/api/incidents/42` is `INC-0042`). Numbers are sequential per organization with no gaps.
**Visibility:** supervisors and above see every incident. Reporters see incidents they reported plus any where they're assigned a corrective action. Anything else answers 404.
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| GET | `/api/incidents` | signed in | Paginated `{ items, page, pageSize, total }`, newest first. Reporters see only their own reports |
| POST | `/api/incidents` | signed in | Create a report (below). 201 |
| GET | `/api/incidents/:number` | signed in | 404 if it doesn't exist or isn't visible to you |
| PATCH | `/api/incidents/:number` | supervisor | Any create field plus `status`. Closing sets `closedAt`; reopening clears it. Moving to another site clears the area unless one is given |
| GET | `/api/incidents` | signed in | `{ items, page, pageSize, total }`, newest first |
| GET | `/api/incidents/export` | signed in | CSV of the same filters (no paging, up to 10,000 rows) |
| POST | `/api/incidents` | signed in | Create a report (below). High/critical reports email safety managers and admins |
| GET | `/api/incidents/:number` | signed in | Full detail: investigation, root causes, actions, attachments, the `transitions` you may perform (with `blockedReason` when not yet possible) and `can` flags |
| PATCH | `/api/incidents/:number` | supervisor | Any create field except `anonymous`. Status changes use transitions |
| POST | `/api/incidents/:number/transitions` | see below | `{ to, note? }`. The note is also posted as a comment |
| GET/POST | `/api/incidents/:number/comments` | can see it | POST `{ body }` |
| GET | `/api/incidents/:number/timeline` | can see it | Audit entries `{ id, at, actor, action, changes }` |
| PUT | `/api/incidents/:number/investigation` | supervisor | `{ leadId?, method?, summary?, completed? }`. Creates the investigation if needed |
| POST | `/api/incidents/:number/root-causes` | supervisor | `{ category, description, whys? }` |
| POST | `/api/incidents/:number/actions` | supervisor | `{ title, description?, kind?, assigneeId?, dueDate?, rootCauseId? }`. Not on closed incidents. Emails the assignee |
| POST | `/api/incidents/:number/attachments` | can see it | Multipart, field `file`. Photos (JPEG, PNG, WebP, GIF, HEIC) and PDFs only, detected from content; `MAX_UPLOAD_MB` each (default 10), 20 per incident |
**List query parameters:** `page` (default 1), `pageSize` (1–100, default 25), `q` (searches title, description and 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`.
**Create body:**
@@ -70,8 +87,45 @@ Incidents are addressed by their number (`/api/incidents/42` is `INC-0042`). Num
- `type`: `near_miss`, `hazard`, `injury`, `illness`, `property_damage`, `environmental`
- `severity`: `low`, `medium`, `high`, `critical`
- `status`: `reported`, `triage`, `investigation`, `actions`, `verification`, `closed`
- `occurredAt` must include a time-zone offset and can't be in the future.
- Anonymous reports don't record the reporter at all, so they won't appear in the reporter's own list.
- `occurredAt` needs a time-zone offset and can't be in the future. Anonymous reports don't record the reporter at all.
Every create and update is recorded in the audit log (`audit_log` table) with who made it and what changed.
### Workflow
`reported` → `triage` → `investigation` → `actions` → `verification` → `closed`. The rules live in [`shared/workflow.ts`](../shared/workflow.ts) and are enforced by the API.
| From | To | Who | Condition |
| --- | --- | --- | --- |
| reported | triage | supervisor | |
| triage | investigation | supervisor | |
| triage | actions | supervisor | |
| triage | closed | supervisor | Low or medium severity only |
| investigation | actions | supervisor | At least one root cause |
| actions | investigation | supervisor | |
| actions | verification | supervisor | At least one action, none open or in progress |
| verification | actions | safety_manager | |
| verification | closed | safety_manager | Every (non-cancelled) action verified |
| closed | investigation | safety_manager | Reopen |
## Root causes, actions, attachments
| Method | Path | Who | Notes |
| --- | --- | --- | --- |
| PATCH | `/api/root-causes/:id` | supervisor | `{ category?, description?, whys? }` |
| DELETE | `/api/root-causes/:id` | supervisor | |
| GET | `/api/actions` | signed in | `?scope=mine\|all` (`all` needs supervisor) and `?filter=open\|overdue\|done\|all`. Sorted by due date |
| PATCH | `/api/actions/:id` | see notes | Supervisors: any field and status except `verified`. Safety managers: also `verified`. Assignees: only `status` among `open`, `in_progress`, `done` |
| GET | `/api/attachments/:id` | can see the incident | The file, with a sandboxing Content-Security-Policy |
| DELETE | `/api/attachments/:id` | uploader or supervisor | |
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.
## Stats
| 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/analytics` | signed in | `?days=` (7–730, default 90): counts by type and severity, top root-cause category, on-time action rate, monthly completions |
Stats only count what the signed-in user can see.
Every create and update on an incident, its investigation, actions and files is recorded in the audit log with who made it and what changed.
+12 -14
View File
@@ -15,6 +15,8 @@ This is a living document. Update it as decisions are made or scope changes.
| Auth | Email + password (scrypt), server-side sessions in an http-only cookie, four roles: reporter < supervisor < safety manager < admin | Decided |
| Deployment | Self-hosted, single `docker compose up` (app + Postgres); file storage added with photo upload | Decided |
| 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 |
| Email | SMTP via `SMTP_URL`; without it, logged in development and dropped in production. Delivery setup deferred (see TODO) | 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 |
@@ -46,15 +48,16 @@ This is a living document. Update it as decisions are made or scope changes.
## Phase 2: Core workflow (MVP)
- Connect the web app to the API: sign-in and first-run setup screens, incident list and report form using real data
- Photo upload (attachments)
- Magic-link sign-in and password reset by email
- Incident detail page: timeline, investigation, root-cause tools (5 Whys, fishbone), corrective actions (CAPA)
- Status workflow: Reported → Triage → Investigation → Corrective actions → Verification → Closed, with role-based transitions
- "My actions" list and overdue tracking
- Email notifications: assignment, due dates, high-severity alerts
- CSV and PDF export
- Dashboard metrics computed from real data
- [x] Web app connected to the API: sign-in, first-run setup, every page on live data (demo data removed from the frontend)
- [x] Photo and PDF upload, checked by file content (not name), size-limited, served with a sandboxing security policy
- [x] Magic-link sign-in, password reset by email, email invitations for new users (admins can also set passwords when there's no email)
- [x] Incident detail page: details editing, activity log (comments + audit trail), investigation with 5 Whys and fishbone (6M) views, corrective actions (CAPA)
- [x] Status workflow Reported → Triage → Investigation → Corrective actions → Verification → Closed, with role rules and guards (e.g. high severity can't skip investigation; closing needs every action verified), shared between API and UI
- [x] "My actions" and "All actions" lists with overdue tracking; assignees can update progress on their own actions
- [x] Email notifications: high/critical alerts to safety managers, action assignment, daily due/overdue digest
- [x] CSV export of the filtered register (protected against spreadsheet formula injection); print-friendly incident page for PDF via the browser
- [x] Overview dashboard and Analytics computed from real data; Locations and Settings (people, roles, passwords) pages
- [x] End-to-end tests run against the real server: full three-role lifecycle, sign-in, visibility rules, mobile reporting
## Phase 3: Shop-floor reporting
@@ -85,8 +88,3 @@ This is a living document. Update it as decisions are made or scope changes.
Inspections and audit checklists, job safety analysis (JSA), training and certification records, management of change, SDS links, public API and webhooks.
## Known prototype gaps (to be resolved in Phase 2)
- The web app still uses in-memory demo data; the API exists but isn't connected yet
- Dashboard, analytics and location figures are hardcoded
- Export, Add location, Settings, Resources, search, notifications and workspace switcher are non-functional
+9
View File
@@ -14,6 +14,15 @@ Open items that aren't tied to a single roadmap phase. Tick them off (or delete
- [ ] **Docker build in CI.** Build the image in CI once a runner with Docker is available.
- [ ] **Shared login rate limit.** The login limiter is in memory, so it resets on restart and isn't shared between processes. Fine for one container; move it to the database before running several app instances.
## Phase 2 follow-ups
- [ ] **Server-side PDF (later).** Decided 2026-10-06 to keep browser-generated PDFs for now: the "Print / PDF" button and print stylesheet are simpler and add no dependencies. Revisit if PDFs need to be produced automatically, e.g. attached to emails or archived on close.
- [ ] **Email delivery (later).** Decided 2026-10-06 that the current behavior is fine for now: without `SMTP_URL`, emails print to the server log in development and are dropped (with a startup warning) in production. Later: configure SMTP for real deployments, add a "send test email" button for admins, and consider showing admins a warning in the app when email isn't set up.
- [ ] **Notification preferences.** Everyone gets every email that applies to them. Add per-user opt-outs, and an alert-recipients setting per site.
- [ ] **Edit corrective actions in the UI.** The API supports changing an action's title, assignee and due date; the incident page only offers status changes and adding new actions.
- [ ] **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.
## 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.
+47
View File
@@ -0,0 +1,47 @@
import { expect, test } from '@playwright/test'
import { PASSWORD, signIn, users } from './fixtures'
test('sends visitors to sign in and back after', async ({ page }) => {
await page.goto('/incidents')
await expect(page).toHaveURL(/\/login$/)
await page.getByLabel('Email').fill(users.reporter.email)
await page.getByLabel('Password').fill(PASSWORD)
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
await expect(page).toHaveURL(/\/incidents$/)
})
test('shows an error for a wrong password', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill(users.reporter.email)
await page.getByLabel('Password').fill('not the password')
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
await expect(page.getByRole('alert')).toHaveText('Incorrect email or password')
})
test('offers an emailed sign-in link without revealing accounts', async ({ page }) => {
await page.goto('/login')
await page.getByRole('button', { name: 'Email me a sign-in link instead' }).click()
await page.getByLabel('Email').fill('nobody@e2e.test')
await page.getByRole('button', { name: 'Email me a sign-in link' }).click()
await expect(page.getByRole('status')).toContainText('If that email belongs to an account')
})
test('signs out', async ({ page, isMobile }) => {
await signIn(page, 'reporter')
if (isMobile) await page.getByRole('button', { name: 'Open menu' }).click()
await page.getByRole('button', { name: 'Sign out' }).click()
await expect(page).toHaveURL(/\/login$/)
await page.goto('/')
await expect(page).toHaveURL(/\/login$/)
})
test('loads with no third-party requests', async ({ page }) => {
const external: string[] = []
page.on('request', (request) => {
if (!request.url().startsWith('http://localhost')) external.push(request.url())
})
await signIn(page, 'reporter')
await page.evaluate(() => document.fonts.ready)
expect(await page.evaluate(() => document.fonts.check('16px "DM Sans Variable"'))).toBe(true)
expect(external).toEqual([])
})
+26
View File
@@ -0,0 +1,26 @@
import { expect, type Page } from '@playwright/test'
export const PASSWORD = 'e2e test password'
export const users = {
admin: { name: 'Avery Admin', email: 'admin@e2e.test' },
supervisor: { name: 'Sam Supervisor', email: 'supervisor@e2e.test' },
reporter: { name: 'Riley Reporter', email: 'reporter@e2e.test' },
} as const
export async function signIn(page: Page, who: keyof typeof users) {
await page.goto('/login')
await page.getByLabel('Email').fill(users[who].email)
await page.getByLabel('Password').fill(PASSWORD)
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
await expect(page.getByRole('heading', { level: 1 })).toContainText(users[who].name.split(' ')[0])
}
/** A unique title so parallel tests (and the desktop/mobile projects) never collide. */
export const unique = (title: string) => `${title} ${Math.random().toString(36).slice(2, 7)}`
/** 1×1 PNG. */
export const photo = {
name: 'scene.png',
mimeType: 'image/png',
buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64'),
}
+20
View File
@@ -0,0 +1,20 @@
import { request } from '@playwright/test'
import { baseURL } from '../playwright.config'
import { PASSWORD, users } from './fixtures'
/** Creates the workspace, a site and one user per role in the fresh server. */
export default async function globalSetup() {
const api = await request.newContext({ baseURL })
// oxlint-disable-next-line typescript/no-explicit-any -- loosely typed setup calls
const ok = async (res: Awaited<ReturnType<typeof api.post>>): Promise<any> => {
if (!res.ok()) throw new Error(`${res.url()} → ${res.status()} ${await res.text()}`)
return res.json()
}
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' } }))
await ok(await api.post(`/api/sites/${site.id}/areas`, { data: { name: 'Press Shop', kind: 'building' } }))
for (const role of ['supervisor', 'reporter'] as const) {
await ok(await api.post('/api/users', { data: { ...users[role], role, password: PASSWORD } }))
}
await api.dispose()
}
+79
View File
@@ -0,0 +1,79 @@
import { expect, test, type Page } from '@playwright/test'
import { photo, signIn, unique, users } from './fixtures'
test.describe.configure({ mode: 'serial' })
/** The incident page section with this heading. */
const section = (page: Page, heading: string) => page.locator('section').filter({ has: page.getByRole('heading', { name: heading, exact: true }) })
async function move(page: Page, button: string) {
await page.getByRole('button', { name: button }).click()
await expect(page.getByRole('button', { name: button })).toHaveCount(0)
}
test('takes an incident from report to close across three roles', async ({ browser, isMobile }) => {
test.skip(isMobile, 'Long multi-role flow runs on desktop only')
test.setTimeout(90_000)
const title = unique('Guard missing on press 4')
// Reporter files the report with a photo
const reporter = await (await browser.newContext()).newPage()
await signIn(reporter, 'reporter')
await reporter.getByRole('link', { name: 'Report incident' }).click()
await reporter.getByLabel('Incident title').fill(title)
await reporter.getByLabel('Incident type').selectOption('hazard')
await reporter.getByLabel('Severity').selectOption('high')
await reporter.getByLabel('Building or area').selectOption({ label: 'Press Shop' })
await reporter.getByLabel('What happened?').fill('Safety guard removed and left off after maintenance.')
await reporter.locator('input[type=file]').setInputFiles(photo)
await reporter.getByRole('button', { name: 'Submit report' }).click()
await expect(reporter.getByText(/has been reported/)).toBeVisible()
await expect(reporter.getByRole('heading', { level: 1 })).toHaveText(title)
await expect(reporter.getByRole('img', { name: 'scene.png' })).toBeVisible()
const url = reporter.url()
// Supervisor triages, investigates and assigns an action back to the reporter
const supervisor = await (await browser.newContext()).newPage()
await signIn(supervisor, 'supervisor')
await supervisor.goto(url)
await move(supervisor, 'Start triage')
await move(supervisor, 'Start investigation')
await supervisor.getByRole('button', { name: 'Add root cause' }).click()
await supervisor.getByLabel('Category').selectOption('process')
await supervisor.getByLabel('Root cause').fill('No step to refit guards after maintenance')
await supervisor.getByLabel('Why 1').fill('Guard was not refitted')
await supervisor.getByRole('button', { name: 'Add root cause' }).click()
await expect(section(supervisor, 'Investigation').getByText('No step to refit guards after maintenance')).toBeVisible()
await move(supervisor, 'Move to corrective actions')
await supervisor.getByRole('button', { name: 'Add action' }).click()
await supervisor.getByLabel('Action', { exact: true }).fill('Add guard check to maintenance sign-off')
await supervisor.getByLabel('Assign to').selectOption({ label: users.reporter.name })
await supervisor.getByRole('button', { name: 'Add action' }).click()
await expect(section(supervisor, 'Corrective actions').getByText(`Assigned to ${users.reporter.name}`)).toBeVisible()
await expect(supervisor.getByRole('button', { name: 'Send for verification' })).toBeDisabled()
// Reporter completes the action from My actions
await reporter.goto('/actions')
const row = reporter.getByRole('listitem').filter({ hasText: 'Add guard check to maintenance sign-off' })
await row.getByRole('combobox').selectOption('done')
await expect(row).toHaveCount(0) // leaves the Open list
await reporter.getByRole('tab', { name: 'Done' }).click()
await expect(row.getByRole('combobox')).toHaveValue('done')
// Supervisor sends it for verification; admin verifies and closes
await supervisor.reload()
await move(supervisor, 'Send for verification')
const admin = await (await browser.newContext()).newPage()
await signIn(admin, 'admin')
await admin.goto(url)
await expect(admin.getByRole('button', { name: 'Verify and close' })).toBeDisabled()
await admin.getByRole('combobox', { name: /Status of Add guard check/ }).selectOption('verified')
await admin.getByLabel('Note').fill('Checked on the floor: guard refitted and checklist updated.')
await move(admin, 'Verify and close')
// The record shows the whole story
await expect(admin.getByText('Closed', { exact: true }).first()).toBeVisible()
const activity = section(admin, 'Activity')
await expect(activity.getByText('moved it from Verification to Closed')).toBeVisible()
await expect(activity.getByText('Checked on the floor')).toBeVisible()
})
+44
View File
@@ -0,0 +1,44 @@
import { expect, test } from '@playwright/test'
import { signIn, unique } from './fixtures'
test('reports from a phone and finds the incident again', async ({ page, isMobile }) => {
const title = unique(isMobile ? 'Phone report: oil on floor' : 'Desktop report: oil on floor')
await signIn(page, 'reporter')
if (isMobile) {
await page.getByRole('button', { name: 'Open menu' }).click()
await page.getByRole('link', { name: 'Report an incident' }).click()
} else {
await page.getByRole('link', { name: 'Report incident' }).click()
}
await page.getByLabel('Incident title').fill(title)
await page.getByRole('button', { name: 'Submit report' }).click()
await expect(page.getByRole('heading', { level: 1 })).toHaveText(title)
const reference = (await page.locator('text=/INC-\\d{4}/').first().textContent())!.match(/INC-\d{4}/)![0]
await page.goto('/incidents')
await page.getByLabel('Search incidents').last().fill(reference)
await expect(page.getByRole('link', { name: title })).toBeVisible()
await expect(page.getByRole('row')).toHaveCount(2) // header + match
})
test('filters the register', async ({ page }) => {
await signIn(page, 'supervisor')
await page.goto('/incidents?status=closed&type=environmental')
await expect(page.getByText('No incidents match these filters.')).toBeVisible()
await expect(page.getByLabel('Filter by status')).toHaveValue('closed')
})
test("reporters can't see other people's reports", async ({ page, browser }) => {
const title = unique('Supervisor-only report')
const supervisor = await (await browser.newContext()).newPage()
await signIn(supervisor, 'supervisor')
await supervisor.goto('/report')
await supervisor.getByLabel('Incident title').fill(title)
await supervisor.getByRole('button', { name: 'Submit report' }).click()
await expect(supervisor.getByRole('heading', { level: 1 })).toHaveText(title)
const url = new URL(supervisor.url()).pathname
await signIn(page, 'reporter')
await page.goto(url)
await expect(page.getByText('doesn’t exist or you don’t have access')).toBeVisible()
})
-33
View File
@@ -1,33 +0,0 @@
import { expect, test } from '@playwright/test'
test('navigates between the main pages', async ({ page, isMobile }) => {
await page.goto('/')
await expect(page.getByRole('heading', { level: 1 })).toContainText('Jordan')
for (const name of ['Incidents', 'Locations', 'Analytics']) {
if (isMobile) await page.getByRole('button', { name: 'Open menu' }).click()
await page.getByRole('navigation').getByRole('link', { name }).click()
await expect(page.getByRole('heading', { level: 1, name })).toBeVisible()
}
})
test('reports an incident', async ({ page }) => {
await page.goto('/report')
await page.getByLabel('Incident title').fill('Blocked fire exit in warehouse')
await page.getByLabel('Specific location').fill('North exit')
await page.getByRole('button', { name: /submit report/i }).click()
await expect(page).toHaveURL(/\/incidents$/)
await expect(page.getByText('Blocked fire exit in warehouse')).toBeVisible()
})
test('loads bundled fonts without third-party requests', async ({ page }) => {
const external: string[] = []
page.on('request', (request) => {
if (!request.url().startsWith('http://localhost')) external.push(request.url())
})
await page.goto('/')
await page.evaluate(() => document.fonts.ready)
expect(await page.evaluate(() => document.fonts.check('16px "DM Sans Variable"'))).toBe(true)
expect(external).toEqual([])
})
+50
View File
@@ -14,6 +14,7 @@
"@hono/zod-validator": "^0.9.1",
"drizzle-orm": "^0.45.3",
"hono": "^4.13.13",
"nodemailer": "^10.0.15",
"pg": "^8.23.1",
"zod": "^4.6.5"
},
@@ -23,10 +24,12 @@
"@fontsource/dm-mono": "^5.3.0",
"@playwright/test": "^1.63.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.104.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^26.6.4",
"@types/nodemailer": "^8.0.2",
"@types/pg": "^8.23.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
@@ -2499,6 +2502,34 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz",
"integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz",
"integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.104.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.2",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
@@ -2641,6 +2672,16 @@
"undici-types": "~8.9.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.2.tgz",
"integrity": "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": {
"version": "8.23.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
@@ -4572,6 +4613,15 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nodemailer": {
"version": "10.0.15",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.15.tgz",
"integrity": "sha512-EUqp5PhtcsYXs9Fq/lS7s/8zlTrnBqmzZWzFFROrNikMiz+om/YRKMwqN907t+0A1KKyT8jd39CBUbFZmaZmcA==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+3
View File
@@ -30,6 +30,7 @@
"@hono/zod-validator": "^0.9.1",
"drizzle-orm": "^0.45.3",
"hono": "^4.13.13",
"nodemailer": "^10.0.15",
"pg": "^8.23.1",
"zod": "^4.6.5"
},
@@ -39,10 +40,12 @@
"@fontsource/dm-mono": "^5.3.0",
"@playwright/test": "^1.63.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.104.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^26.6.4",
"@types/nodemailer": "^8.0.2",
"@types/pg": "^8.23.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
+6 -2
View File
@@ -1,24 +1,28 @@
import { defineConfig, devices } from '@playwright/test'
const port = 4173
export const baseURL = `http://localhost:${port}`
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL: `http://localhost:${port}`,
baseURL,
trace: 'on-first-retry',
},
projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
],
// The real server with a throwaway in-memory database, serving the production build
webServer: {
command: `npm run build && npm run preview -- --port ${port} --strictPort`,
command: 'npm run build && node server/index.ts',
port,
reuseExistingServer: !process.env.CI,
env: { DATABASE_URL: 'memory://', PORT: String(port), PUBLIC_URL: baseURL, UPLOAD_DIR: 'test-results/uploads', STATIC_DIR: 'dist' },
},
})
+25 -2
View File
@@ -6,19 +6,38 @@ import { loadSession } from './auth/middleware.ts'
import type { Config } from './config.ts'
import type { Db } from './db/client.ts'
import type { AppEnv } from './lib/http.ts'
import type { Notifier } from './notifications.ts'
import { actionRoutes } from './routes/actions.ts'
import { attachmentRoutes } from './routes/attachments.ts'
import { authRoutes } from './routes/auth.ts'
import { incidentRoutes } from './routes/incidents.ts'
import { rootCauseRoutes } from './routes/root-causes.ts'
import { setupRoutes } from './routes/setup.ts'
import { areaRoutes, siteRoutes } from './routes/sites.ts'
import { statsRoutes } from './routes/stats.ts'
import { userRoutes } from './routes/users.ts'
import type { FileStorage } from './storage.ts'
export function createApp({ db, config }: { db: Db; config: Config }) {
export interface AppDeps {
db: Db
config: Config
notifier: Notifier
storage: FileStorage
}
export function createApp({ db, config, notifier, storage }: AppDeps) {
const app = new Hono<AppEnv>()
app.use('*', secureHeaders())
app.use('/api/*', csrf(), async (c, next) => {
const publicOrigin = new URL(config.publicUrl).origin
// Form/multipart posts must come from our own pages. JSON requests are already protected by CORS preflight.
const sameSite = csrf({ origin: (origin, c) => origin === publicOrigin || origin === new URL(c.req.url).origin })
app.use('/api/*', sameSite, async (c, next) => {
c.set('db', db)
c.set('config', config)
c.set('notifier', notifier)
c.set('storage', storage)
c.header('Cache-Control', 'no-store')
await next()
})
@@ -31,6 +50,10 @@ export function createApp({ db, config }: { db: Db; config: Config }) {
app.route('/api/sites', siteRoutes)
app.route('/api/areas', areaRoutes)
app.route('/api/incidents', incidentRoutes)
app.route('/api/root-causes', rootCauseRoutes)
app.route('/api/actions', actionRoutes)
app.route('/api/attachments', attachmentRoutes)
app.route('/api/stats', statsRoutes)
app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404))
app.onError((error, c) => {
+29
View File
@@ -0,0 +1,29 @@
import { and, eq, gt, isNull } from 'drizzle-orm'
import { createHash, randomBytes } from 'node:crypto'
import type { Db } from '../db/client.ts'
import { authTokens, users } from '../db/schema.ts'
export type TokenPurpose = 'magic_link' | 'password_reset'
const hash = (token: string) => createHash('sha256').update(token).digest('hex')
export async function createAuthToken(db: Db, userId: string, purpose: TokenPurpose, ttlMs: number): Promise<string> {
const token = randomBytes(32).toString('base64url')
await db.insert(authTokens).values({ id: hash(token), userId, purpose, expiresAt: new Date(Date.now() + ttlMs) })
return token
}
/** Marks the token used and returns its (active) user's id, or null if invalid, expired or already used. */
export async function consumeAuthToken(db: Db, token: string, purpose: TokenPurpose): Promise<string | null> {
const [row] = await db
.update(authTokens)
.set({ usedAt: new Date() })
.where(and(eq(authTokens.id, hash(token)), eq(authTokens.purpose, purpose), isNull(authTokens.usedAt), gt(authTokens.expiresAt, new Date())))
.returning({ userId: authTokens.userId })
if (!row) return null
const [user] = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.id, row.userId), eq(users.active, true)))
return user?.id ?? null
}
+9
View File
@@ -7,6 +7,11 @@ export interface Config {
production: boolean
/** Built web app to serve, if present. */
staticDir: string
/** e.g. `smtps://user:pass@smtp.example.com:465`. Without it, emails are printed to the log (development only). */
smtpUrl: string | undefined
mailFrom: string
uploadDir: string
maxUploadBytes: number
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
@@ -18,5 +23,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
secureCookies: publicUrl.startsWith('https://'),
production: env.NODE_ENV === 'production',
staticDir: env.STATIC_DIR ?? 'dist',
smtpUrl: env.SMTP_URL || undefined,
mailFrom: env.MAIL_FROM ?? 'Open SafetyLog EHS <no-reply@localhost>',
uploadDir: env.UPLOAD_DIR ?? './data/uploads',
maxUploadBytes: Number(env.MAX_UPLOAD_MB ?? 10) * 1024 * 1024,
}
}
@@ -0,0 +1,13 @@
CREATE TYPE "public"."auth_token_purpose" AS ENUM('magic_link', 'password_reset');--> statement-breakpoint
CREATE TABLE "auth_tokens" (
"id" text PRIMARY KEY NOT NULL,
"user_id" uuid NOT NULL,
"purpose" "auth_token_purpose" NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "corrective_actions" ADD COLUMN "reminder_sent_on" date;--> statement-breakpoint
ALTER TABLE "auth_tokens" ADD CONSTRAINT "auth_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "auth_tokens_user_idx" ON "auth_tokens" USING btree ("user_id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -8,6 +8,13 @@
"when": 1791306770507,
"tag": "0000_initial",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1791309275942,
"tag": "0001_workflow_and_email",
"breakpoints": true
}
]
}
+20
View File
@@ -12,6 +12,7 @@ export const rcaMethodEnum = pgEnum('rca_method', vocab.rcaMethods)
export const rootCauseCategoryEnum = pgEnum('root_cause_category', vocab.rootCauseCategories)
export const actionKindEnum = pgEnum('action_kind', vocab.actionKinds)
export const actionStatusEnum = pgEnum('action_status', vocab.actionStatuses)
export const authTokenPurposeEnum = pgEnum('auth_token_purpose', ['magic_link', 'password_reset'])
const id = () => uuid().primaryKey().defaultRandom()
const timestamptz = () => timestamp({ withTimezone: true })
@@ -62,6 +63,23 @@ export const sessions = pgTable(
(t) => [index('sessions_user_idx').on(t.userId)],
)
/** Single-use links sent by email (magic sign-in, password reset/invite). */
export const authTokens = pgTable(
'auth_tokens',
{
/** SHA-256 of the token sent in the link. */
id: text().primaryKey(),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
purpose: authTokenPurposeEnum().notNull(),
expiresAt: timestamptz().notNull(),
usedAt: timestamptz(),
createdAt: createdAt(),
},
(t) => [index('auth_tokens_user_idx').on(t.userId)],
)
export const sites = pgTable(
'sites',
{
@@ -230,6 +248,8 @@ export const correctiveActions = pgTable(
completedAt: timestamptz(),
verifiedById: uuid().references(() => users.id, { onDelete: 'set null' }),
verifiedAt: timestamptz(),
/** Date the last due/overdue reminder was emailed, so it goes out at most once a day. */
reminderSentOn: date({ mode: 'string' }),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
+18 -3
View File
@@ -2,7 +2,7 @@
import { hashPassword } from '../auth/password.ts'
import { loadConfig } from '../config.ts'
import { openDatabase } from './client.ts'
import { areas, incidentCounters, incidents, organizations, sites, users } from './schema.ts'
import { auditLog, areas, incidentCounters, incidents, organizations, sites, users } from './schema.ts'
const DEMO_PASSWORD = 'safetylog-demo'
@@ -49,8 +49,10 @@ try {
{ title: 'Wet floor near employee entrance', type: 'hazard', severity: 'medium', status: 'actions', building: 'Building A', locationDetail: 'Ground floor lobby', reporter: 'reporter@example.com', days: 6 },
{ title: 'Forklift reversed into pallet stack', type: 'near_miss', severity: 'high', status: 'triage', building: 'Warehouse 04', locationDetail: 'Loading bay C', reporter: 'admin@example.com', days: 5 },
] as const
await tx.insert(incidents).values(
demo.map((item, index) => ({
const created = await tx
.insert(incidents)
.values(
demo.map((item, index) => ({
organizationId: org.id,
number: index + 1,
title: item.title,
@@ -58,10 +60,23 @@ try {
severity: item.severity,
status: item.status,
occurredAt: daysAgo(item.days),
createdAt: new Date(daysAgo(item.days).getTime() + 2 * 3_600_000),
...areaIds[item.building],
locationDetail: item.locationDetail,
reporterId: person(item.reporter),
closedAt: item.status === 'closed' ? daysAgo(item.days - 3) : null,
})),
)
.returning()
await tx.insert(auditLog).values(
created.map((incident) => ({
organizationId: org.id,
actorId: incident.reporterId,
entityType: 'incident',
entityId: incident.id,
action: 'create',
changes: { number: incident.number, title: incident.title },
createdAt: incident.createdAt,
})),
)
await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber: demo.length })
+12 -1
View File
@@ -5,10 +5,19 @@ import { join } from 'node:path'
import { createApp } from './app.ts'
import { loadConfig } from './config.ts'
import { openDatabase } from './db/client.ts'
import { createMailer } from './mail.ts'
import { Notifier } from './notifications.ts'
import { FileStorage } from './storage.ts'
const config = loadConfig()
const database = await openDatabase(config.databaseUrl)
const app = createApp({ db: database.db, config })
const notifier = new Notifier(database.db, createMailer(config), config)
const app = createApp({ db: database.db, config, notifier, storage: new FileStorage(config.uploadDir) })
// Hourly check for corrective actions that are due or overdue; each gets at most one reminder a day.
const sendReminders = () => notifier.sendDueReminders().catch((error) => console.error('[reminders] failed:', error))
const reminderTimer = setInterval(sendReminders, 60 * 60 * 1000)
setTimeout(sendReminders, 30_000).unref()
// In production the server also serves the built web app.
if (existsSync(join(config.staticDir, 'index.html'))) {
@@ -30,7 +39,9 @@ const server = serve({ fetch: app.fetch, port: config.port }, ({ port }) => {
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
clearInterval(reminderTimer)
server.close(async () => {
await notifier.flush()
await database.close()
process.exit(0)
})
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { toCsv } from './csv.ts'
describe('toCsv', () => {
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')
})
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")
})
})
+14
View File
@@ -0,0 +1,14 @@
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
* formula are prefixed with an apostrophe (CSV injection).
*/
export function toCsv(header: string[], rows: Cell[][]): string {
const cell = (value: Cell) => {
let text = value === null || value === undefined ? '' : String(value)
if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
}
return '' + [header, ...rows].map((row) => row.map(cell).join(',')).join('\r\n') + '\r\n'
}
+14
View File
@@ -0,0 +1,14 @@
/** ISO string for a Date, keeping nulls. */
export const iso = <T extends Date | null>(date: T): T extends Date ? string : string | null => (date ? date.toISOString() : null) as never
/** Calendar date in the server's time zone (set TZ to the plant's zone), e.g. `2026-10-06`. */
export function localDate(date = new Date()): string {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
export function addDays(isoDate: string, days: number): string {
const date = new Date(`${isoDate}T12:00:00`)
date.setDate(date.getDate() + days)
return localDate(date)
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { detectFileType } from './file-type.ts'
const bytes = (...values: (number | string)[]) => new Uint8Array(values.flatMap((v) => (typeof v === 'string' ? [...v].map((ch) => ch.charCodeAt(0)) : [v])))
describe('detectFileType', () => {
it('recognises supported formats', () => {
expect(detectFileType(bytes(0xff, 0xd8, 0xff, 0xe0))?.contentType).toBe('image/jpeg')
expect(detectFileType(bytes('RIFF', 0, 0, 0, 0, 'WEBP'))?.contentType).toBe('image/webp')
expect(detectFileType(bytes(0, 0, 0, 24, 'ftypheic'))?.contentType).toBe('image/heic')
expect(detectFileType(bytes('%PDF-1.7'))?.contentType).toBe('application/pdf')
})
it('rejects everything else', () => {
expect(detectFileType(bytes('<svg xmlns="http://www.w3.org/2000/svg">'))).toBeNull()
expect(detectFileType(bytes('PK', 3, 4))).toBeNull()
expect(detectFileType(new Uint8Array())).toBeNull()
})
})
+21
View File
@@ -0,0 +1,21 @@
export interface DetectedType {
contentType: string
extension: string
}
const startsWith = (bytes: Uint8Array, signature: number[], offset = 0) => signature.every((byte, i) => bytes[offset + i] === byte)
const ascii = (bytes: Uint8Array, from: number, to: number) => String.fromCharCode(...bytes.subarray(from, to))
/**
* Identifies photos and PDFs from their first bytes, ignoring the client's claimed type and file name.
* Returns null for anything else, so only these formats are ever stored and served.
*/
export function detectFileType(bytes: Uint8Array): DetectedType | null {
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return { contentType: 'image/jpeg', extension: 'jpg' }
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return { contentType: 'image/png', extension: 'png' }
if (ascii(bytes, 0, 6) === 'GIF87a' || ascii(bytes, 0, 6) === 'GIF89a') return { contentType: 'image/gif', extension: 'gif' }
if (ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 12) === 'WEBP') return { contentType: 'image/webp', extension: 'webp' }
if (ascii(bytes, 4, 8) === 'ftyp' && ['heic', 'heix', 'mif1', 'msf1'].includes(ascii(bytes, 8, 12))) return { contentType: 'image/heic', extension: 'heic' }
if (ascii(bytes, 0, 5) === '%PDF-') return { contentType: 'application/pdf', extension: 'pdf' }
return null
}
+5 -1
View File
@@ -5,12 +5,16 @@ import type { ZodType } from 'zod'
import type { SessionUser } from '../auth/sessions.ts'
import type { Config } from '../config.ts'
import type { Db } from '../db/client.ts'
import type { Notifier } from '../notifications.ts'
import type { FileStorage } from '../storage.ts'
export interface AppEnv {
Variables: {
db: Db
config: Config
user: SessionUser | null
notifier: Notifier
storage: FileStorage
}
}
@@ -23,7 +27,7 @@ export function validate<Target extends keyof ValidationTargets, Schema extends
})
}
export const fail = (status: 400 | 401 | 403 | 404 | 409 | 429, message: string) => new HTTPException(status, { message })
export const fail = (status: 400 | 401 | 403 | 404 | 409 | 413 | 429, message: string) => new HTTPException(status, { message })
/** The signed-in user; use after `requireAuth`. */
export function currentUser(c: Context<AppEnv>): SessionUser {
+187
View File
@@ -0,0 +1,187 @@
import { and, asc, eq, exists, or, type SQL } from 'drizzle-orm'
import { alias } from 'drizzle-orm/pg-core'
import type { Attachment, CorrectiveAction, Incident, IncidentDetail, Investigation, RootCause } from '../../shared/api-types.ts'
import { hasRole } from '../../shared/enums.ts'
import { incidentReference } from '../../shared/format.ts'
import { availableTransitions } from '../../shared/workflow.ts'
import type { SessionUser } from '../auth/sessions.ts'
import type { Db } from '../db/client.ts'
import { areas, attachments, correctiveActions, incidents, investigations, rootCauses, sites, users } from '../db/schema.ts'
import { audit } from './audit.ts'
import { iso, localDate } from './dates.ts'
import { fail } from './http.ts'
const reporters = alias(users, 'reporter')
export function selectIncidents(db: Db) {
return db
.select({ incident: incidents, siteName: sites.name, areaName: areas.name, reporterName: reporters.name })
.from(incidents)
.innerJoin(sites, eq(sites.id, incidents.siteId))
.leftJoin(areas, eq(areas.id, incidents.areaId))
.leftJoin(reporters, eq(reporters.id, incidents.reporterId))
}
type IncidentRow = Awaited<ReturnType<ReturnType<typeof selectIncidents>['execute']>>[number]
export function toIncident({ incident, siteName, areaName, reporterName }: IncidentRow): Incident {
return {
id: incident.id,
number: incident.number,
reference: incidentReference(incident.number),
title: incident.title,
type: incident.type,
severity: incident.severity,
status: incident.status,
occurredAt: iso(incident.occurredAt),
reportedAt: iso(incident.createdAt),
updatedAt: iso(incident.updatedAt),
closedAt: iso(incident.closedAt),
site: { id: incident.siteId, name: siteName },
area: incident.areaId ? { id: incident.areaId, name: areaName ?? '' } : null,
locationDetail: incident.locationDetail,
description: incident.description,
immediateAction: incident.immediateAction,
anonymous: incident.anonymous,
reporter: incident.reporterId ? { id: incident.reporterId, name: reporterName ?? '' } : null,
}
}
/**
* Supervisors and above see every incident in their organization. Reporters see incidents they
* reported, plus any where they are assigned a corrective action.
*/
export function visibleTo(db: Db, user: SessionUser): SQL | undefined {
const inOrg = eq(incidents.organizationId, user.organizationId)
if (hasRole(user.role, 'supervisor')) return inOrg
const assigned = db
.select({ id: correctiveActions.id })
.from(correctiveActions)
.where(and(eq(correctiveActions.incidentId, incidents.id), eq(correctiveActions.assigneeId, user.id)))
return and(inOrg, or(eq(incidents.reporterId, user.id), exists(assigned)))
}
export async function findIncident(db: Db, user: SessionUser, number: number) {
const [row] = await selectIncidents(db).where(and(visibleTo(db, user), eq(incidents.number, number)))
if (!row) throw fail(404, 'Incident not found')
return row
}
const assignees = alias(users, 'assignee')
const verifiers = alias(users, 'verifier')
export function selectActions(db: Db) {
return db
.select({
action: correctiveActions,
incidentNumber: incidents.number,
incidentTitle: incidents.title,
assigneeName: assignees.name,
verifierName: verifiers.name,
})
.from(correctiveActions)
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
.leftJoin(assignees, eq(assignees.id, correctiveActions.assigneeId))
.leftJoin(verifiers, eq(verifiers.id, correctiveActions.verifiedById))
}
type ActionRow = Awaited<ReturnType<ReturnType<typeof selectActions>['execute']>>[number]
export const isOpenAction = (status: string) => status === 'open' || status === 'in_progress'
export function toAction({ action, incidentNumber, incidentTitle, assigneeName, verifierName }: ActionRow, today = localDate()): CorrectiveAction {
return {
id: action.id,
incident: { number: incidentNumber, reference: incidentReference(incidentNumber), title: incidentTitle },
rootCauseId: action.rootCauseId,
kind: action.kind,
title: action.title,
description: action.description,
assignee: action.assigneeId ? { id: action.assigneeId, name: assigneeName ?? '' } : null,
dueDate: action.dueDate,
status: action.status,
overdue: isOpenAction(action.status) && !!action.dueDate && action.dueDate < today,
completedAt: iso(action.completedAt),
verifiedBy: action.verifiedById ? { id: action.verifiedById, name: verifierName ?? '' } : null,
verifiedAt: iso(action.verifiedAt),
createdAt: iso(action.createdAt),
}
}
export function toAttachment(row: typeof attachments.$inferSelect, uploaderName: string | null): Attachment {
return {
id: row.id,
fileName: row.fileName,
contentType: row.contentType,
sizeBytes: row.sizeBytes,
uploadedBy: row.uploadedById ? { id: row.uploadedById, name: uploaderName ?? '' } : null,
createdAt: iso(row.createdAt),
url: `/api/attachments/${row.id}`,
}
}
export async function loadDetail(db: Db, user: SessionUser, number: number): Promise<IncidentDetail> {
const row = await findIncident(db, user, number)
const incidentId = row.incident.id
const leads = alias(users, 'lead')
const [investigationRow] = await db
.select({ investigation: investigations, leadName: leads.name })
.from(investigations)
.leftJoin(leads, eq(leads.id, investigations.leadId))
.where(eq(investigations.incidentId, incidentId))
const causes = investigationRow
? await db.select().from(rootCauses).where(eq(rootCauses.investigationId, investigationRow.investigation.id)).orderBy(asc(rootCauses.createdAt))
: []
const actionRows = await selectActions(db).where(eq(correctiveActions.incidentId, incidentId)).orderBy(asc(correctiveActions.createdAt))
const uploaders = alias(users, 'uploader')
const files = await db
.select({ file: attachments, uploaderName: uploaders.name })
.from(attachments)
.leftJoin(uploaders, eq(uploaders.id, attachments.uploadedById))
.where(eq(attachments.incidentId, incidentId))
.orderBy(asc(attachments.createdAt))
const investigation: Investigation | null = investigationRow
? {
id: investigationRow.investigation.id,
lead: investigationRow.investigation.leadId ? { id: investigationRow.investigation.leadId, name: investigationRow.leadName ?? '' } : null,
method: investigationRow.investigation.method,
summary: investigationRow.investigation.summary,
startedAt: iso(investigationRow.investigation.startedAt),
completedAt: iso(investigationRow.investigation.completedAt),
}
: null
const actions = actionRows.map((action) => toAction(action))
const manage = hasRole(user.role, 'supervisor')
return {
...toIncident(row),
investigation,
rootCauses: causes.map((cause): RootCause => ({ id: cause.id, category: cause.category, description: cause.description, whys: cause.whys })),
actions,
attachments: files.map(({ file, uploaderName }) => toAttachment(file, uploaderName)),
transitions: availableTransitions(row.incident.status, user.role, {
severity: row.incident.severity,
rootCauseCount: causes.length,
actionStatuses: actions.map((action) => action.status),
}),
can: { edit: manage, manageInvestigation: manage, manageActions: manage },
}
}
/** Records an event on an incident's timeline. */
export function auditIncident(db: Db, user: SessionUser | null, organizationId: string, incidentId: string, action: string, changes?: Record<string, unknown>) {
return audit(db, { organizationId, actorId: user?.id ?? null, entityType: 'incident', entityId: incidentId, action, changes })
}
/** Throws 400 unless the user is active in the organization. */
export async function assertActiveUser(db: Db, organizationId: string, userId: string) {
const [user] = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.id, userId), eq(users.organizationId, organizationId), eq(users.active, true)))
if (!user) throw fail(400, 'Unknown or inactive user')
}
+39
View File
@@ -0,0 +1,39 @@
import nodemailer from 'nodemailer'
import type { Config } from './config.ts'
export interface Mail {
to: string
subject: string
text: string
}
export interface Mailer {
send(mail: Mail): Promise<void>
}
export function createMailer(config: Config): Mailer {
if (config.smtpUrl) {
const transport = nodemailer.createTransport(config.smtpUrl)
return { send: async (mail) => void (await transport.sendMail({ from: config.mailFrom, ...mail })) }
}
if (config.production) {
console.warn('[mail] SMTP_URL is not set: emails (sign-in links, password resets, notifications) will not be sent.')
return { send: async (mail) => console.warn(`[mail] Not sent (no SMTP): "${mail.subject}" to ${mail.to}`) }
}
// Development: print the email so links can be clicked from the terminal.
return { send: async (mail) => console.log(`\n[mail] To: ${mail.to}\n[mail] Subject: ${mail.subject}\n\n${mail.text}\n`) }
}
/** Collects emails instead of sending them (tests). */
export class MemoryMailer implements Mailer {
sent: Mail[] = []
async send(mail: Mail) {
this.sent.push(mail)
}
/** Most recent email to `to`. */
last(to: string): Mail | undefined {
return this.sent.findLast((mail) => mail.to === to)
}
}
+156
View File
@@ -0,0 +1,156 @@
import { and, eq, inArray, isNotNull, isNull, lt, lte, ne, or } from 'drizzle-orm'
import { incidentReference } from '../shared/format.ts'
import type { Config } from './config.ts'
import type { Db } from './db/client.ts'
import { correctiveActions, incidents, users } from './db/schema.ts'
import { addDays, localDate } from './lib/dates.ts'
import type { Mailer } from './mail.ts'
const severityLabel = { low: 'Low', medium: 'Medium', high: 'High', critical: 'Critical' } as const
/** Sends emails in the background so slow mail servers never delay or fail a request. */
export class Notifier {
private readonly pending = new Set<Promise<void>>()
private readonly db: Db
private readonly mailer: Mailer
private readonly config: Config
constructor(db: Db, mailer: Mailer, config: Config) {
this.db = db
this.mailer = mailer
this.config = config
}
/** Waits for queued emails (tests, shutdown). */
async flush(): Promise<void> {
await Promise.all([...this.pending])
}
private dispatch(task: () => Promise<void>) {
const run = task()
.catch((error) => console.error('[notify] failed:', error))
.finally(() => this.pending.delete(run))
this.pending.add(run)
}
private link(path: string) {
return new URL(path, this.config.publicUrl).toString()
}
magicLink(email: string, token: string) {
this.dispatch(() =>
this.mailer.send({
to: email,
subject: 'Your sign-in link',
text: `Use this link to sign in to Open SafetyLog EHS. It works once and expires in 15 minutes.\n\n${this.link(`/auth/magic?token=${token}`)}\n\nIf you didn't ask for it, you can ignore this email.`,
}),
)
}
passwordReset(email: string, token: string, invite: { organization: string; invitedBy: string } | null) {
const link = this.link(`/auth/reset?token=${token}`)
this.dispatch(() =>
this.mailer.send(
invite
? {
to: email,
subject: `You've been invited to ${invite.organization} on Open SafetyLog EHS`,
text: `${invite.invitedBy} added you to ${invite.organization}'s safety workspace.\n\nSet your password to get started (link valid for 7 days):\n\n${link}`,
}
: {
to: email,
subject: 'Reset your password',
text: `Use this link to choose a new password. It works once and expires in 1 hour.\n\n${link}\n\nIf you didn't ask for it, you can ignore this email; your password hasn't changed.`,
},
),
)
}
/** Alerts safety managers and admins about high and critical reports. */
incidentReported(incidentId: string) {
this.dispatch(async () => {
const [incident] = await this.db.select().from(incidents).where(eq(incidents.id, incidentId))
if (!incident || (incident.severity !== 'high' && incident.severity !== 'critical')) return
const recipients = await this.db
.select({ email: users.email })
.from(users)
.where(
and(
eq(users.organizationId, incident.organizationId),
eq(users.active, true),
inArray(users.role, ['safety_manager', 'admin']),
incident.reporterId ? ne(users.id, incident.reporterId) : undefined,
),
)
const reference = incidentReference(incident.number)
for (const { email } of recipients) {
await this.mailer.send({
to: email,
subject: `[${severityLabel[incident.severity]}] ${reference}: ${incident.title}`,
text: `A ${severityLabel[incident.severity].toLowerCase()}-severity incident was reported.\n\n${reference}: ${incident.title}\nOccurred: ${incident.occurredAt.toLocaleString('en-US')}\n\n${this.link(`/incidents/${incident.number}`)}`,
})
}
})
}
actionAssigned(actionId: string, assignedBy: string) {
this.dispatch(async () => {
const [row] = await this.db
.select({ action: correctiveActions, email: users.email, number: incidents.number, incidentTitle: incidents.title })
.from(correctiveActions)
.innerJoin(users, eq(users.id, correctiveActions.assigneeId))
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
.where(and(eq(correctiveActions.id, actionId), eq(users.active, true)))
if (!row) return
const due = row.action.dueDate ? `Due: ${row.action.dueDate}\n` : ''
await this.mailer.send({
to: row.email,
subject: `Action assigned: ${row.action.title}`,
text: `${assignedBy} assigned you a corrective action on ${incidentReference(row.number)} (${row.incidentTitle}).\n\n${row.action.title}\n${due}\n${this.link(`/incidents/${row.number}`)}`,
})
})
}
/**
* Emails each assignee a digest of open actions due tomorrow or earlier, at most once a day per action.
* Returns how many emails were queued.
*/
async sendDueReminders(today = localDate()): Promise<number> {
const rows = await this.db
.select({ action: correctiveActions, email: users.email, number: incidents.number })
.from(correctiveActions)
.innerJoin(users, eq(users.id, correctiveActions.assigneeId))
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
.where(
and(
inArray(correctiveActions.status, ['open', 'in_progress']),
eq(users.active, true),
isNotNull(correctiveActions.dueDate),
lte(correctiveActions.dueDate, addDays(today, 1)),
or(isNull(correctiveActions.reminderSentOn), lt(correctiveActions.reminderSentOn, today)),
),
)
const byEmail = Map.groupBy(rows, (row) => row.email)
for (const [email, items] of byEmail) {
const lines = items.map(({ action, number }) => {
const when = action.dueDate! < today ? `OVERDUE since ${action.dueDate}` : action.dueDate === today ? 'due today' : 'due tomorrow'
return `- ${action.title} (${incidentReference(number)}), ${when}\n ${this.link(`/incidents/${number}`)}`
})
const overdue = items.filter(({ action }) => action.dueDate! < today).length
this.dispatch(() =>
this.mailer.send({
to: email,
subject: overdue ? `${overdue} overdue corrective action${overdue === 1 ? '' : 's'}` : 'Corrective actions due soon',
text: `These corrective actions assigned to you need attention:\n\n${lines.join('\n')}\n\nAll your actions: ${this.link('/actions')}`,
}),
)
}
if (rows.length) {
await this.db
.update(correctiveActions)
.set({ reminderSentOn: today })
.where(inArray(correctiveActions.id, rows.map(({ action }) => action.id)))
}
return byEmail.size
}
}
+87
View File
@@ -0,0 +1,87 @@
import { and, asc, eq, inArray, lt, sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
import { hasRole } from '../../shared/enums.ts'
import { actionListQuery, updateActionSchema } from '../../shared/schemas.ts'
import { requireAuth } from '../auth/middleware.ts'
import { correctiveActions, incidents, investigations, rootCauses } from '../db/schema.ts'
import { diff } from '../lib/audit.ts'
import { localDate } from '../lib/dates.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
import { assertActiveUser, auditIncident, selectActions, toAction } from '../lib/incidents.ts'
const idParam = z.object({ id: z.uuid() })
const OPEN = ['open', 'in_progress'] as const
export const actionRoutes = new Hono<AppEnv>()
.get('/', requireAuth, validate('query', actionListQuery), async (c) => {
const me = currentUser(c)
const { scope, filter } = c.req.valid('query')
if (scope === 'all' && !hasRole(me.role, 'supervisor')) throw fail(403, 'You do not have permission to do that')
const today = localDate()
const rows = await selectActions(c.get('db'))
.where(
and(
eq(incidents.organizationId, me.organizationId),
scope === 'mine' ? eq(correctiveActions.assigneeId, me.id) : undefined,
filter === 'open' ? inArray(correctiveActions.status, OPEN) : undefined,
filter === 'overdue' ? and(inArray(correctiveActions.status, OPEN), lt(correctiveActions.dueDate, today)) : undefined,
filter === 'done' ? inArray(correctiveActions.status, ['done', 'verified']) : undefined,
),
)
.orderBy(sql`${correctiveActions.dueDate} asc nulls last`, asc(correctiveActions.createdAt))
.limit(500)
return c.json({ actions: rows.map((row) => toAction(row, today)) })
})
.patch('/:id', requireAuth, validate('param', idParam), validate('json', updateActionSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const [row] = await selectActions(db).where(and(eq(correctiveActions.id, c.req.valid('param').id), eq(incidents.organizationId, me.organizationId)))
if (!row) throw fail(404, 'Action not found')
const { action } = row
const input = c.req.valid('json')
const isManager = hasRole(me.role, 'supervisor')
const isAssignee = action.assigneeId === me.id
if (!isManager) {
// Assignees can report progress on their own actions, nothing else.
const onlyStatus = Object.keys(input).every((key) => key === 'status')
if (!isAssignee || !onlyStatus || (input.status && !['open', 'in_progress', 'done'].includes(input.status))) {
throw fail(403, 'You can only update the progress of actions assigned to you')
}
}
if (input.status === 'verified' && !hasRole(me.role, 'safety_manager')) throw fail(403, 'Only safety managers can verify actions')
if (action.status === 'verified' && input.status && input.status !== 'verified' && !hasRole(me.role, 'safety_manager')) {
throw fail(403, 'Only safety managers can reopen a verified action')
}
if (input.assigneeId) await assertActiveUser(db, me.organizationId, input.assigneeId)
if (input.rootCauseId) {
const [cause] = await db
.select({ id: rootCauses.id })
.from(rootCauses)
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
.where(and(eq(rootCauses.id, input.rootCauseId), eq(investigations.incidentId, action.incidentId)))
if (!cause) throw fail(400, 'Root cause must belong to this incident')
}
const now = new Date()
const status = input.status ?? action.status
const update = {
...input,
completedAt: status === 'done' || status === 'verified' ? (action.completedAt ?? now) : null,
verifiedAt: status === 'verified' ? (action.verifiedAt ?? now) : null,
verifiedById: status === 'verified' ? (action.verifiedById ?? me.id) : null,
// A new due date deserves a fresh reminder
reminderSentOn: input.dueDate !== undefined && input.dueDate !== action.dueDate ? null : undefined,
}
const changes = diff(action, update)
delete changes.reminderSentOn
if (Object.keys(changes).length > 0) {
await db.update(correctiveActions).set(update).where(eq(correctiveActions.id, action.id))
await auditIncident(db, me, me.organizationId, action.incidentId, 'action.update', { actionId: action.id, title: action.title, ...changes })
if (input.assigneeId && input.assigneeId !== action.assigneeId && input.assigneeId !== me.id) c.get('notifier').actionAssigned(action.id, me.name)
}
const [updated] = await selectActions(db).where(eq(correctiveActions.id, action.id))
return c.json({ action: toAction(updated) })
})
+74
View File
@@ -0,0 +1,74 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestContext, hoursAgo } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
let number: number
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52])
const form = (bytes: Uint8Array<ArrayBuffer>, name: string, type = 'image/png') => {
const data = new FormData()
data.append('file', new File([bytes], name, { type }))
return data
}
beforeAll(async () => {
ctx = await createTestContext()
const site = await ctx.createSite()
reporter = await ctx.createUser('reporter')
const res = await reporter.post('/api/incidents', { title: 'Spill', type: 'hazard', severity: 'low', occurredAt: hoursAgo(1), siteId: site.id })
number = res.body.incident.number
})
afterAll(() => ctx.close())
describe('attachments', () => {
it('stores a photo and serves it back safely', async () => {
const res = await reporter.post(`/api/incidents/${number}/attachments`, form(PNG, 'spill.png'))
expect(res.status).toBe(201)
expect(res.body.attachment).toMatchObject({ fileName: 'spill.png', contentType: 'image/png', sizeBytes: PNG.byteLength })
const file = await reporter.get(res.body.attachment.url)
expect(file.status).toBe(200)
expect(file.headers.get('content-type')).toBe('image/png')
expect(file.headers.get('content-security-policy')).toContain('sandbox')
expect(new Uint8Array(file.body)).toEqual(PNG)
const detail = await reporter.get(`/api/incidents/${number}`)
expect(detail.body.incident.attachments).toHaveLength(1)
})
it('identifies files by content, not by name or claimed type', async () => {
const html = new TextEncoder().encode('<script>alert(1)</script>')
expect((await reporter.post(`/api/incidents/${number}/attachments`, form(html, 'photo.png', 'image/png'))).status).toBe(400)
})
it('rejects files over the size limit', async () => {
const big = new Uint8Array(ctx.config.maxUploadBytes + 1)
big.set(PNG)
expect((await reporter.post(`/api/incidents/${number}/attachments`, form(big, 'big.png'))).status).toBe(413)
})
it('hides files from people who cannot see the incident', async () => {
const res = await reporter.post(`/api/incidents/${number}/attachments`, form(PNG, 'second.png'))
const outsider = await ctx.createUser('reporter', 'outsider@example.com')
expect((await outsider.get(res.body.attachment.url)).status).toBe(404)
expect((await outsider.delete(res.body.attachment.url)).status).toBe(404)
})
it('lets the uploader remove their file', async () => {
const res = await reporter.post(`/api/incidents/${number}/attachments`, form(PNG, 'remove-me.png'))
expect((await reporter.delete(res.body.attachment.url)).status).toBe(204)
expect((await reporter.get(res.body.attachment.url)).status).toBe(404)
})
})
describe('upload origin check', () => {
it('rejects uploads posted from another site', async () => {
const res = await ctx.app.request(`/api/incidents/${number}/attachments`, {
method: 'POST',
headers: { cookie: reporter.cookie!, origin: 'http://evil.example' },
body: form(PNG, 'x.png'),
})
expect(res.status).toBe(403)
})
})
+48
View File
@@ -0,0 +1,48 @@
import { and, eq } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
import { hasRole } from '../../shared/enums.ts'
import { requireAuth } from '../auth/middleware.ts'
import type { SessionUser } from '../auth/sessions.ts'
import type { Db } from '../db/client.ts'
import { attachments, incidents } from '../db/schema.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
import { auditIncident, visibleTo } from '../lib/incidents.ts'
const idParam = z.object({ id: z.uuid() })
async function findAttachment(db: Db, user: SessionUser, id: string) {
const [row] = await db
.select({ file: attachments })
.from(attachments)
.innerJoin(incidents, eq(incidents.id, attachments.incidentId))
.where(and(eq(attachments.id, id), visibleTo(db, user)))
if (!row) throw fail(404, 'Attachment not found')
return row.file
}
export const attachmentRoutes = new Hono<AppEnv>()
.get('/:id', requireAuth, validate('param', idParam), async (c) => {
const file = await findAttachment(c.get('db'), currentUser(c), c.req.valid('param').id)
const data = await c.get('storage').read(file.storageKey)
const disposition = file.contentType.startsWith('image/') ? 'inline' : 'attachment'
return c.body(new Uint8Array(data), 200, {
'Content-Type': file.contentType,
'Content-Length': String(data.byteLength),
'Content-Disposition': `${disposition}; filename*=UTF-8''${encodeURIComponent(file.fileName)}`,
// Uploaded content never runs scripts, even if a file slipped past type detection
'Content-Security-Policy': "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox",
'Cache-Control': 'private, max-age=3600',
})
})
.delete('/:id', requireAuth, validate('param', idParam), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const file = await findAttachment(db, me, c.req.valid('param').id)
if (file.uploadedById !== me.id && !hasRole(me.role, 'supervisor')) throw fail(403, 'Only the uploader or a supervisor can remove this file')
await db.delete(attachments).where(eq(attachments.id, file.id))
await c.get('storage').remove(file.storageKey)
await auditIncident(db, me, me.organizationId, file.incidentId, 'attachment.remove', { fileName: file.fileName })
return c.body(null, 204)
})
+55 -1
View File
@@ -1,17 +1,29 @@
import { eq } from 'drizzle-orm'
import { Hono } from 'hono'
import { changePasswordSchema, loginSchema } from '../../shared/schemas.ts'
import { changePasswordSchema, emailOnlySchema, loginSchema, resetPasswordSchema, tokenSchema } from '../../shared/schemas.ts'
import { endSession, requireAuth, startSession } from '../auth/middleware.ts'
import { hashPassword, verifyAgainstDummy, verifyPassword } from '../auth/password.ts'
import { RateLimiter } from '../auth/rate-limit.ts'
import { deleteUserSessions } from '../auth/sessions.ts'
import { consumeAuthToken, createAuthToken } from '../auth/tokens.ts'
import type { Db } from '../db/client.ts'
import { organizations, users } from '../db/schema.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
import { publicUser } from './users.ts'
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000
const RESET_TTL_MS = 60 * 60 * 1000
async function activeUserByEmail(db: Db, email: string) {
const [user] = await db.select({ id: users.id, email: users.email, active: users.active }).from(users).where(eq(users.email, email))
return user?.active ? user : null
}
export function authRoutes() {
// 10 failed-or-successful attempts per email per 15 minutes
const loginLimiter = new RateLimiter(10, 15 * 60 * 1000)
// Emailed links: 5 per email per 15 minutes
const emailLimiter = new RateLimiter(5, 15 * 60 * 1000)
return new Hono<AppEnv>()
.post('/login', validate('json', loginSchema), async (c) => {
@@ -30,6 +42,48 @@ export function authRoutes() {
return c.json({ user: result })
})
// Always 204, so the response never reveals whether an account exists.
.post('/magic-link', validate('json', emailOnlySchema), async (c) => {
const { email } = c.req.valid('json')
if (!emailLimiter.attempt(`magic:${email}`)) throw fail(429, 'Too many requests. Try again in 15 minutes.')
const db = c.get('db')
const user = await activeUserByEmail(db, email)
if (user) c.get('notifier').magicLink(user.email, await createAuthToken(db, user.id, 'magic_link', MAGIC_LINK_TTL_MS))
return c.body(null, 204)
})
// POST rather than GET so email link scanners can't use up the token.
.post('/magic-link/verify', validate('json', tokenSchema), async (c) => {
const db = c.get('db')
const userId = await consumeAuthToken(db, c.req.valid('json').token, 'magic_link')
if (!userId) throw fail(400, 'This sign-in link is invalid or has expired. Request a new one.')
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, userId))
await startSession(c, userId)
const [user] = await db.select(publicUser).from(users).where(eq(users.id, userId))
return c.json({ user })
})
.post('/password-reset', validate('json', emailOnlySchema), async (c) => {
const { email } = c.req.valid('json')
if (!emailLimiter.attempt(`reset:${email}`)) throw fail(429, 'Too many requests. Try again in 15 minutes.')
const db = c.get('db')
const user = await activeUserByEmail(db, email)
if (user) c.get('notifier').passwordReset(user.email, await createAuthToken(db, user.id, 'password_reset', RESET_TTL_MS), null)
return c.body(null, 204)
})
.post('/password-reset/confirm', validate('json', resetPasswordSchema), async (c) => {
const { token, newPassword } = c.req.valid('json')
const db = c.get('db')
const userId = await consumeAuthToken(db, token, 'password_reset')
if (!userId) throw fail(400, 'This link is invalid or has expired. Request a new one.')
await db.update(users).set({ passwordHash: await hashPassword(newPassword), lastLoginAt: new Date() }).where(eq(users.id, userId))
await deleteUserSessions(db, userId)
await startSession(c, userId)
const [user] = await db.select(publicUser).from(users).where(eq(users.id, userId))
return c.json({ user })
})
.post('/logout', async (c) => {
await endSession(c)
return c.body(null, 204)
+80
View File
@@ -0,0 +1,80 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestContext, tokenFrom } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
beforeAll(async () => {
ctx = await createTestContext()
await ctx.createUser('supervisor', 'linked@example.com')
})
afterAll(() => ctx.close())
const lastToken = async (email: string) => {
await ctx.notifier.flush()
return tokenFrom(ctx.mailer.last(email)?.text)
}
describe('magic links', () => {
it('signs in once with an emailed link', async () => {
const client = ctx.anonymous()
expect((await client.post('/api/auth/magic-link', { email: 'Linked@Example.com' })).status).toBe(204)
const token = await lastToken('linked@example.com')
expect(ctx.mailer.last('linked@example.com')?.text).toContain(`http://safety.test/auth/magic?token=${token}`)
const res = await client.post('/api/auth/magic-link/verify', { token })
expect(res.status).toBe(200)
expect(res.body.user.email).toBe('linked@example.com')
expect((await client.get('/api/auth/me')).status).toBe(200)
expect((await ctx.anonymous().post('/api/auth/magic-link/verify', { token })).status).toBe(400)
})
it('answers the same for unknown emails and sends nothing', async () => {
const before = ctx.mailer.sent.length
expect((await ctx.anonymous().post('/api/auth/magic-link', { email: 'nobody@example.com' })).status).toBe(204)
await ctx.notifier.flush()
expect(ctx.mailer.sent.length).toBe(before)
})
it("doesn't accept a magic link token as a password reset", async () => {
await ctx.anonymous().post('/api/auth/magic-link', { email: 'linked@example.com' })
const token = await lastToken('linked@example.com')
expect((await ctx.anonymous().post('/api/auth/password-reset/confirm', { token, newPassword: 'a whole new password' })).status).toBe(400)
})
})
describe('password reset', () => {
it('sets a new password and signs out other sessions', async () => {
const user = await ctx.createUser('reporter', 'forgetful@example.com')
await ctx.anonymous().post('/api/auth/password-reset', { email: 'forgetful@example.com' })
const token = await lastToken('forgetful@example.com')
const client = ctx.anonymous()
const res = await client.post('/api/auth/password-reset/confirm', { token, newPassword: 'remembered at last' })
expect(res.status).toBe(200)
expect((await client.get('/api/auth/me')).status).toBe(200)
expect((await user.get('/api/auth/me')).status).toBe(401)
expect((await ctx.anonymous().post('/api/auth/login', { email: 'forgetful@example.com', password: 'remembered at last' })).status).toBe(200)
})
})
describe('invitations', () => {
it('emails new users without a password a link to set one', async () => {
const res = await ctx.admin.post('/api/users', { name: 'New Starter', email: 'starter@example.com', role: 'reporter' })
expect(res.body).toMatchObject({ invited: true })
await ctx.notifier.flush()
const mail = ctx.mailer.last('starter@example.com')
expect(mail?.subject).toBe("You've been invited to Northstar Co. on Open SafetyLog EHS")
// Can't sign in until the password is set
expect((await ctx.anonymous().post('/api/auth/login', { email: 'starter@example.com', password: 'anything at all' })).status).toBe(401)
const client = ctx.anonymous()
expect((await client.post('/api/auth/password-reset/confirm', { token: tokenFrom(mail?.text), newPassword: 'my first password' })).status).toBe(200)
expect((await client.get('/api/auth/me')).body.user.email).toBe('starter@example.com')
})
it('lets admins set a password directly when email is not available', async () => {
const created = await ctx.admin.post('/api/users', { name: 'No Email', email: 'offline@example.com', role: 'reporter' })
await ctx.admin.patch(`/api/users/${created.body.user.id}`, { password: 'set by the admin' })
expect((await ctx.anonymous().post('/api/auth/login', { email: 'offline@example.com', password: 'set by the admin' })).status).toBe(200)
})
})
+47 -12
View File
@@ -108,27 +108,62 @@ describe('listing', () => {
})
describe('updating', () => {
it('lets supervisors update and close incidents, recording what changed', async () => {
it('lets supervisors edit details, recording what changed', async () => {
const created = await reporter.post('/api/incidents', report({ title: 'Wet floor' }))
const number = created.body.incident.number
expect((await reporter.patch(`/api/incidents/${number}`, { status: 'closed' })).status).toBe(403)
expect((await reporter.patch(`/api/incidents/${number}`, { severity: 'low' })).status).toBe(403)
const closed = await supervisor.patch(`/api/incidents/${number}`, { status: 'closed', severity: 'low' })
expect(closed.status).toBe(200)
expect(closed.body.incident.status).toBe('closed')
expect(closed.body.incident.closedAt).not.toBeNull()
const edited = await supervisor.patch(`/api/incidents/${number}`, { severity: 'low', title: 'Wet floor by entrance' })
expect(edited.status).toBe(200)
expect(edited.body.incident).toMatchObject({ severity: 'low', title: 'Wet floor by entrance' })
const [entry] = await ctx.db.select().from(auditLog).where(eq(auditLog.action, 'update'))
expect(entry.changes).toMatchObject({ status: ['reported', 'closed'], severity: ['high', 'low'] })
expect(entry.changes).toMatchObject({ severity: ['high', 'low'], title: ['Wet floor', 'Wet floor by entrance'] })
})
const reopened = await supervisor.patch(`/api/incidents/${number}`, { status: 'investigation' })
expect(reopened.body.incident.closedAt).toBeNull()
it('does not change status through edits', async () => {
const created = await reporter.post('/api/incidents', report())
const res = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { status: 'closed' })
expect(res.body.incident.status).toBe('reported')
})
it('clears the area when moving an incident to another site', async () => {
const created = await reporter.post('/api/incidents', report({ areaId: area.id }))
const south = await ctx.createSite('West Campus')
const moved = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { siteId: south.id })
expect(moved.body.incident).toMatchObject({ site: { id: south.id }, area: null })
const west = await ctx.createSite('West Campus')
const moved = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { siteId: west.id })
expect(moved.body.incident).toMatchObject({ site: { id: west.id }, area: null })
})
})
describe('notifications', () => {
it('alerts safety managers about high-severity reports, not low ones', async () => {
const manager = await ctx.createUser('safety_manager', 'alerts@example.com')
await reporter.post('/api/incidents', report({ title: 'Arm caught in conveyor', type: 'injury', severity: 'critical' }))
await reporter.post('/api/incidents', report({ title: 'Scuffed paint', severity: 'low' }))
await ctx.notifier.flush()
const mails = ctx.mailer.sent.filter((mail) => mail.to === manager.user.email)
expect(mails).toHaveLength(1)
expect(mails[0].subject).toMatch(/^\[Critical\] INC-\d{4}: Arm caught in conveyor$/)
expect(mails[0].text).toContain('http://safety.test/incidents/')
})
})
describe('export', () => {
it('exports the filtered list as CSV, neutralising spreadsheet formulas', async () => {
await reporter.post('/api/incidents', report({ title: '=HYPERLINK("http://evil")', type: 'environmental', severity: 'low' }))
const res = await supervisor.get('/api/incidents/export?type=environmental')
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('text/csv')
const csv = res.body.toString('utf8')
const lines = csv.trimEnd().split('\r\n')
expect(lines[0]).toMatch(/^\uFEFFReference,Title,Type/)
expect(lines).toHaveLength(2)
expect(lines[1]).toContain(`"'=HYPERLINK(""http://evil"")"`)
})
it('only exports what the user can see', async () => {
const other = await ctx.createUser('reporter', 'csv-reporter@example.com')
const res = await other.get('/api/incidents/export')
expect(res.body.toString('utf8').trimEnd().split('\r\n')).toHaveLength(1)
})
})
+271 -104
View File
@@ -1,72 +1,38 @@
import { and, count, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm'
import { and, asc, count, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm'
import { Hono } from 'hono'
import { bodyLimit } from 'hono/body-limit'
import { z } from 'zod'
import type { Comment, TimelineEntry } from '../../shared/api-types.ts'
import { hasRole } from '../../shared/enums.ts'
import { incidentReference } from '../../shared/format.ts'
import { createIncidentSchema, incidentListQuery, updateIncidentSchema } from '../../shared/schemas.ts'
import type { SessionUser } from '../auth/sessions.ts'
import {
commentSchema,
createActionSchema,
createIncidentSchema,
incidentListQuery,
investigationSchema,
rootCauseSchema,
transitionSchema,
updateIncidentSchema,
type IncidentListQuery,
} from '../../shared/schemas.ts'
import { transitionBlocker, transitions } from '../../shared/workflow.ts'
import { requireAuth, requireRole } from '../auth/middleware.ts'
import type { SessionUser } from '../auth/sessions.ts'
import type { Db } from '../db/client.ts'
import { areas, incidentCounters, incidents, sites, users } from '../db/schema.ts'
import { audit, diff } from '../lib/audit.ts'
import { areas, attachments, auditLog, comments, correctiveActions, incidentCounters, incidents, investigations, rootCauses, sites, users } from '../db/schema.ts'
import { diff } from '../lib/audit.ts'
import { toCsv } from '../lib/csv.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 { assertActiveUser, auditIncident, findIncident, loadDetail, selectIncidents, toAttachment, toIncident, visibleTo } from '../lib/incidents.ts'
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 columns = {
incident: incidents,
siteName: sites.name,
areaName: areas.name,
reporterName: users.name,
}
type IncidentRow = { incident: typeof incidents.$inferSelect; siteName: string; areaName: string | null; reporterName: string | null }
function toDto({ incident, siteName, areaName, reporterName }: IncidentRow) {
return {
id: incident.id,
number: incident.number,
reference: incidentReference(incident.number),
title: incident.title,
type: incident.type,
severity: incident.severity,
status: incident.status,
occurredAt: incident.occurredAt,
reportedAt: incident.createdAt,
updatedAt: incident.updatedAt,
closedAt: incident.closedAt,
site: { id: incident.siteId, name: siteName },
area: incident.areaId ? { id: incident.areaId, name: areaName } : null,
locationDetail: incident.locationDetail,
description: incident.description,
immediateAction: incident.immediateAction,
anonymous: incident.anonymous,
reporter: incident.reporterId ? { id: incident.reporterId, name: reporterName } : null,
}
}
function selectIncidents(db: Db) {
return db
.select(columns)
.from(incidents)
.innerJoin(sites, eq(sites.id, incidents.siteId))
.leftJoin(areas, eq(areas.id, incidents.areaId))
.leftJoin(users, eq(users.id, incidents.reporterId))
}
/** Reporters only see what they reported; supervisors and above see everything in the organization. */
function visibleTo(user: SessionUser): SQL | undefined {
return and(eq(incidents.organizationId, user.organizationId), hasRole(user.role, 'supervisor') ? undefined : eq(incidents.reporterId, user.id))
}
async function findIncident(db: Db, user: SessionUser, number: number) {
const [row] = await selectIncidents(db).where(and(visibleTo(user), eq(incidents.number, number)))
if (!row) throw fail(404, 'Incident not found')
return row
}
const MAX_ATTACHMENTS_PER_INCIDENT = 20
const EXPORT_LIMIT = 10_000
async function assertLocation(db: Db, organizationId: string, siteId: string, areaId: string | null | undefined) {
const [site] = await db
@@ -91,39 +57,78 @@ function parseOccurredAt(value: string): Date {
const escapeLike = (value: string) => value.replace(/[\\%_]/g, '\\$&')
function listFilter(db: Db, user: SessionUser, { q, type, severity, status, siteId }: Omit<IncidentListQuery, 'page' | 'pageSize'>) {
let search: SQL | undefined
if (q) {
const pattern = `%${escapeLike(q)}%`
const number = /^(?:inc-?)?(\d+)$/i.exec(q)?.[1]
search = or(
ilike(incidents.title, pattern),
ilike(incidents.description, pattern),
ilike(incidents.locationDetail, pattern),
number ? eq(incidents.number, Number(number)) : undefined,
)
}
return and(
visibleTo(db, user),
search,
type ? eq(incidents.type, type) : undefined,
severity ? eq(incidents.severity, severity) : undefined,
status ? eq(incidents.status, status) : undefined,
siteId ? eq(incidents.siteId, siteId) : undefined,
)
}
/** Creates the incident's investigation if it doesn't exist yet and returns its id. */
async function ensureInvestigation(db: Db, incidentId: string): Promise<string> {
await db.insert(investigations).values({ incidentId }).onConflictDoNothing({ target: investigations.incidentId })
const [row] = await db.select({ id: investigations.id }).from(investigations).where(eq(investigations.incidentId, incidentId))
return row.id
}
export const incidentRoutes = new Hono<AppEnv>()
.get('/', requireAuth, validate('query', incidentListQuery), async (c) => {
const me = currentUser(c)
const { page, pageSize, q, type, severity, status, siteId } = c.req.valid('query')
const { page, pageSize, ...filters } = c.req.valid('query')
const db = c.get('db')
let search: SQL | undefined
if (q) {
const pattern = `%${escapeLike(q)}%`
const number = /^(?:inc-?)?(\d+)$/i.exec(q)?.[1]
search = or(
ilike(incidents.title, pattern),
ilike(incidents.description, pattern),
ilike(incidents.locationDetail, pattern),
number ? eq(incidents.number, Number(number)) : undefined,
)
}
const where = and(
visibleTo(me),
search,
type ? eq(incidents.type, type) : undefined,
severity ? eq(incidents.severity, severity) : undefined,
status ? eq(incidents.status, status) : undefined,
siteId ? eq(incidents.siteId, siteId) : undefined,
)
const where = listFilter(db, me, filters)
const [{ total }] = await db.select({ total: count() }).from(incidents).where(where)
const rows = await selectIncidents(db)
.where(where)
.orderBy(desc(incidents.occurredAt), desc(incidents.number))
.limit(pageSize)
.offset((page - 1) * pageSize)
return c.json({ items: rows.map(toDto), page, pageSize, total })
return c.json({ items: rows.map(toIncident), page, pageSize, total })
})
.get('/export', requireAuth, validate('query', incidentListQuery.omit({ page: true, pageSize: true })), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const rows = await selectIncidents(db)
.where(listFilter(db, me, c.req.valid('query')))
.orderBy(desc(incidents.occurredAt), desc(incidents.number))
.limit(EXPORT_LIMIT)
const csv = toCsv(
['Reference', 'Title', 'Type', 'Severity', 'Status', 'Occurred at', 'Reported at', 'Closed at', 'Site', 'Area', 'Location detail', 'Reporter', 'Description', 'Immediate action'],
rows.map(toIncident).map((i) => [
i.reference,
i.title,
i.type,
i.severity,
i.status,
i.occurredAt,
i.reportedAt,
i.closedAt,
i.site.name,
i.area?.name,
i.locationDetail,
i.anonymous ? 'Anonymous' : i.reporter?.name,
i.description,
i.immediateAction,
]),
)
const date = new Date().toISOString().slice(0, 10)
return c.body(csv, 200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="incidents-${date}.csv"` })
})
.post('/', requireAuth, validate('json', createIncidentSchema), async (c) => {
@@ -133,7 +138,7 @@ export const incidentRoutes = new Hono<AppEnv>()
await assertLocation(db, me.organizationId, input.siteId, input.areaId)
const occurred = parseOccurredAt(occurredAt)
const number = await db.transaction(async (tx) => {
const created = await db.transaction(async (tx) => {
// Atomic increment: concurrent reports always get distinct, gap-free numbers.
const [{ lastNumber }] = await tx
.insert(incidentCounters)
@@ -143,25 +148,23 @@ export const incidentRoutes = new Hono<AppEnv>()
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 })
await audit(tx, {
organizationId: me.organizationId,
actorId: anonymous ? null : me.id,
entityType: 'incident',
entityId: incident.id,
action: 'create',
changes: { number: lastNumber, title: input.title, type: input.type, severity: input.severity },
.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 lastNumber
return incident
})
const [row] = await selectIncidents(db).where(and(eq(incidents.organizationId, me.organizationId), eq(incidents.number, number)))
return c.json({ incident: toDto(row) }, 201)
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) => {
const row = await findIncident(c.get('db'), currentUser(c), c.req.valid('param').number)
return c.json({ incident: toDto(row) })
return c.json({ incident: await loadDetail(c.get('db'), currentUser(c), c.req.valid('param').number) })
})
.patch('/:number', requireRole('supervisor'), validate('param', numberParam), validate('json', updateIncidentSchema), async (c) => {
@@ -175,17 +178,181 @@ export const incidentRoutes = new Hono<AppEnv>()
if (input.siteId && input.siteId !== incident.siteId && input.areaId === undefined) areaId = null // moving site clears the old area
if (input.siteId !== undefined || input.areaId !== undefined) await assertLocation(db, me.organizationId, siteId, areaId)
const update = {
...input,
areaId,
occurredAt: occurredAt ? parseOccurredAt(occurredAt) : undefined,
closedAt: input.status === undefined ? undefined : input.status === 'closed' ? (incident.closedAt ?? new Date()) : null,
}
const update = { ...input, areaId, occurredAt: occurredAt ? parseOccurredAt(occurredAt) : undefined }
const changes = diff(incident, update)
if (Object.keys(changes).length > 0) {
await db.update(incidents).set(update).where(eq(incidents.id, incident.id))
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'incident', entityId: incident.id, action: 'update', changes })
await auditIncident(db, me, me.organizationId, incident.id, 'update', changes)
}
const row = await findIncident(db, me, incident.number)
return c.json({ incident: toDto(row) })
return c.json({ incident: await loadDetail(db, me, incident.number) })
})
.post('/:number/transitions', requireAuth, validate('param', numberParam), validate('json', transitionSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { to, note } = c.req.valid('json')
const detail = await loadDetail(db, me, c.req.valid('param').number)
const rule = transitions.find((t) => t.from === detail.status && t.to === to)
if (!rule) throw fail(409, `An incident in "${detail.status}" can't move to "${to}"`)
if (!hasRole(me.role, rule.minRole)) throw fail(403, 'You do not have permission to do that')
const blocker = transitionBlocker(rule, {
severity: detail.severity,
rootCauseCount: detail.rootCauses.length,
actionStatuses: detail.actions.map((action) => action.status),
})
if (blocker) throw fail(409, blocker)
// Conditional update guards against two people moving the same incident at once.
const moved = await db
.update(incidents)
.set({ status: to, closedAt: to === 'closed' ? new Date() : null })
.where(and(eq(incidents.id, detail.id), eq(incidents.status, detail.status)))
.returning({ id: incidents.id })
if (moved.length === 0) throw fail(409, 'This incident was just changed by someone else. Reload and try again.')
await auditIncident(db, me, me.organizationId, detail.id, 'status', { status: [detail.status, to], ...(note ? { note } : {}) })
if (note) await db.insert(comments).values({ incidentId: detail.id, authorId: me.id, body: note })
return c.json({ incident: await loadDetail(db, me, detail.number) })
})
.get('/:number/comments', requireAuth, validate('param', numberParam), async (c) => {
const db = c.get('db')
const { incident } = await findIncident(db, currentUser(c), c.req.valid('param').number)
const rows = await db
.select({ comment: comments, authorName: users.name })
.from(comments)
.leftJoin(users, eq(users.id, comments.authorId))
.where(eq(comments.incidentId, incident.id))
.orderBy(asc(comments.createdAt))
const result: Comment[] = rows.map(({ comment, authorName }) => ({
id: comment.id,
author: comment.authorId ? { id: comment.authorId, name: authorName ?? '' } : null,
body: comment.body,
createdAt: iso(comment.createdAt),
}))
return c.json({ comments: result })
})
.post('/:number/comments', requireAuth, validate('param', numberParam), validate('json', commentSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { incident } = await findIncident(db, me, c.req.valid('param').number)
const [comment] = await db.insert(comments).values({ incidentId: incident.id, authorId: me.id, body: c.req.valid('json').body }).returning()
const result: Comment = { id: comment.id, author: { id: me.id, name: me.name }, body: comment.body, createdAt: iso(comment.createdAt) }
return c.json({ comment: result }, 201)
})
.get('/:number/timeline', requireAuth, validate('param', numberParam), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { incident } = await findIncident(db, me, c.req.valid('param').number)
const rows = await db
.select({ entry: auditLog, actorName: users.name })
.from(auditLog)
.leftJoin(users, eq(users.id, auditLog.actorId))
.where(and(eq(auditLog.organizationId, me.organizationId), eq(auditLog.entityType, 'incident'), eq(auditLog.entityId, incident.id)))
.orderBy(asc(auditLog.id))
const timeline: TimelineEntry[] = rows.map(({ entry, actorName }) => ({
id: entry.id,
at: iso(entry.createdAt),
actor: entry.actorId ? { id: entry.actorId, name: actorName ?? '' } : null,
action: entry.action,
changes: (entry.changes as Record<string, unknown> | null) ?? null,
}))
return c.json({ timeline })
})
.put('/:number/investigation', requireRole('supervisor'), validate('param', numberParam), validate('json', investigationSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { incident } = await findIncident(db, me, c.req.valid('param').number)
const { completed, ...input } = c.req.valid('json')
if (input.leadId) await assertActiveUser(db, me.organizationId, input.leadId)
const investigationId = await ensureInvestigation(db, incident.id)
const [current] = await db.select().from(investigations).where(eq(investigations.id, investigationId))
const update = { ...input, completedAt: completed === undefined ? undefined : completed ? (current.completedAt ?? new Date()) : null }
const changes = diff(current, update)
if (Object.keys(changes).length > 0) {
await db.update(investigations).set(update).where(eq(investigations.id, investigationId))
await auditIncident(db, me, me.organizationId, incident.id, 'investigation.update', changes)
}
return c.json({ incident: await loadDetail(db, me, incident.number) })
})
.post('/:number/root-causes', requireRole('supervisor'), validate('param', numberParam), validate('json', rootCauseSchema), 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 investigationId = await ensureInvestigation(db, incident.id)
await db.insert(rootCauses).values({ ...input, investigationId })
await auditIncident(db, me, me.organizationId, incident.id, 'root_cause.add', { category: input.category, description: input.description })
return c.json({ incident: await loadDetail(db, me, incident.number) }, 201)
})
.post('/:number/actions', requireRole('supervisor'), validate('param', numberParam), validate('json', createActionSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { incident } = await findIncident(db, me, c.req.valid('param').number)
if (incident.status === 'closed') throw fail(409, 'Reopen the incident before adding actions')
const input = c.req.valid('json')
if (input.assigneeId) await assertActiveUser(db, me.organizationId, input.assigneeId)
if (input.rootCauseId) {
const [cause] = await db
.select({ id: rootCauses.id })
.from(rootCauses)
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
.where(and(eq(rootCauses.id, input.rootCauseId), eq(investigations.incidentId, incident.id)))
if (!cause) throw fail(400, 'Root cause must belong to this incident')
}
const [action] = await db
.insert(correctiveActions)
.values({ ...input, incidentId: incident.id })
.returning()
await auditIncident(db, me, me.organizationId, incident.id, 'action.add', { actionId: action.id, title: action.title, assigneeId: action.assigneeId, dueDate: action.dueDate })
if (action.assigneeId && action.assigneeId !== me.id) c.get('notifier').actionAssigned(action.id, me.name)
return c.json({ incident: await loadDetail(db, me, incident.number) }, 201)
})
.post(
'/:number/attachments',
requireAuth,
validate('param', numberParam),
// Reject oversized bodies before reading them into memory
async (c, next) => bodyLimit({ maxSize: c.get('config').maxUploadBytes + 64 * 1024, onError: (c) => c.json({ error: 'File is too large' }, 413) })(c, next),
async (c) => {
const me = currentUser(c)
const db = c.get('db')
const config = c.get('config')
const { incident } = await findIncident(db, me, c.req.valid('param').number)
const body = await c.req.parseBody()
const file = body.file
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, 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
}
},
)
+47
View File
@@ -0,0 +1,47 @@
import { and, eq } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
import { updateRootCauseSchema } 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 { incidents, investigations, rootCauses } from '../db/schema.ts'
import { diff } from '../lib/audit.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
import { auditIncident, loadDetail } from '../lib/incidents.ts'
const idParam = z.object({ id: z.uuid() })
async function findRootCause(db: Db, user: SessionUser, id: string) {
const [row] = await db
.select({ cause: rootCauses, incidentId: incidents.id, number: incidents.number })
.from(rootCauses)
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
.innerJoin(incidents, eq(incidents.id, investigations.incidentId))
.where(and(eq(rootCauses.id, id), eq(incidents.organizationId, user.organizationId)))
if (!row) throw fail(404, 'Root cause not found')
return row
}
export const rootCauseRoutes = new Hono<AppEnv>()
.patch('/:id', requireRole('supervisor'), validate('param', idParam), validate('json', updateRootCauseSchema), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { cause, incidentId, number } = await findRootCause(db, me, c.req.valid('param').id)
const input = c.req.valid('json')
const changes = diff(cause, input)
if (Object.keys(changes).length > 0) {
await db.update(rootCauses).set(input).where(eq(rootCauses.id, cause.id))
await auditIncident(db, me, me.organizationId, incidentId, 'root_cause.update', changes)
}
return c.json({ incident: await loadDetail(db, me, number) })
})
.delete('/:id', requireRole('supervisor'), validate('param', idParam), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { cause, incidentId, number } = await findRootCause(db, me, c.req.valid('param').id)
await db.delete(rootCauses).where(eq(rootCauses.id, cause.id))
await auditIncident(db, me, me.organizationId, incidentId, 'root_cause.remove', { category: cause.category, description: cause.description })
return c.json({ incident: await loadDetail(db, me, number) })
})
+47
View File
@@ -0,0 +1,47 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestContext, daysFromToday, hoursAgo } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
beforeAll(async () => {
ctx = await createTestContext()
})
afterAll(() => ctx.close())
describe('stats', () => {
it('summarises the dashboard from real data', async () => {
const north = await ctx.createSite('North')
await ctx.createSite('South')
const supervisor = await ctx.createUser('supervisor')
const report = (title: string, severity = 'medium', type = 'hazard') =>
supervisor.post('/api/incidents', { title, type, severity, occurredAt: hoursAgo(3), siteId: north.id }).then((r) => r.body.incident.number as number)
const a = await report('A', 'low')
await report('B', 'high', 'injury')
await supervisor.post(`/api/incidents/${a}/transitions`, { to: 'triage' })
await supervisor.post(`/api/incidents/${a}/transitions`, { to: 'closed' })
const c = await report('C')
await supervisor.post(`/api/incidents/${c}/actions`, { title: 'Overdue', assigneeId: supervisor.user.id, dueDate: daysFromToday(-1) })
await supervisor.post(`/api/incidents/${c}/actions`, { title: 'This week', assigneeId: supervisor.user.id, dueDate: daysFromToday(3) })
await supervisor.post(`/api/incidents/${c}/root-causes`, { category: 'equipment', description: 'Worn' })
const overview = (await supervisor.get('/api/stats/overview')).body
expect(overview).toMatchObject({ openIncidents: 2, reportedLast7Days: 3, actionsDueThisWeek: 1, overdueActions: 1, actionProgress: { total: 2, completed: 0 } })
expect(overview.byStatus).toMatchObject({ reported: 2, closed: 1 })
expect(overview.averageCloseDays).toBeGreaterThanOrEqual(0)
expect(overview.weeklyTrend).toHaveLength(12)
expect(overview.weeklyTrend.at(-1).count).toBeGreaterThanOrEqual(1)
expect(overview.bySite).toEqual([
{ siteId: north.id, name: 'North', count: 3 },
{ siteId: expect.any(String), name: 'South', count: 0 },
])
const analytics = (await supervisor.get('/api/stats/analytics?days=30')).body
expect(analytics).toMatchObject({ periodDays: 30, total: 3, topRootCause: { category: 'equipment', count: 1, share: 1 } })
expect(analytics.byType).toEqual(expect.arrayContaining([{ type: 'hazard', count: 2 }, { type: 'injury', count: 1 }]))
})
it("scopes a reporter's numbers to what they can see", async () => {
const reporter = await ctx.createUser('reporter')
expect((await reporter.get('/api/stats/overview')).body.openIncidents).toBe(0)
})
})
+145
View File
@@ -0,0 +1,145 @@
import { and, asc, count, desc, eq, gte, inArray, isNotNull, isNull, lt, ne, sql } from 'drizzle-orm'
import { Hono } from 'hono'
import { z } from 'zod'
import type { AnalyticsStats, OverviewStats } from '../../shared/api-types.ts'
import { incidentStatuses, type IncidentStatus } from '../../shared/enums.ts'
import { requireAuth } from '../auth/middleware.ts'
import { correctiveActions, incidents, investigations, rootCauses, sites } from '../db/schema.ts'
import { addDays, localDate } from '../lib/dates.ts'
import { currentUser, validate, type AppEnv } from '../lib/http.ts'
import { visibleTo } from '../lib/incidents.ts'
const DAY_MS = 86_400_000
const OPEN_ACTION = ['open', 'in_progress'] as const
/** Monday 00:00 UTC of the week containing `date` (matches Postgres `date_trunc('week', …)` in UTC). */
function weekStart(date: Date): Date {
const start = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
start.setUTCDate(start.getUTCDate() - ((start.getUTCDay() + 6) % 7))
return start
}
export const statsRoutes = new Hono<AppEnv>()
.get('/overview', requireAuth, async (c) => {
const me = currentUser(c)
const db = c.get('db')
const visible = visibleTo(db, me)
const now = new Date()
const today = localDate(now)
const statusRows = await db.select({ status: incidents.status, n: count() }).from(incidents).where(visible).groupBy(incidents.status)
const byStatus = Object.fromEntries(incidentStatuses.map((status) => [status, 0])) as Record<IncidentStatus, number>
for (const row of statusRows) byStatus[row.status] = row.n
const openIncidents = Object.entries(byStatus).reduce((sum, [status, n]) => (status === 'closed' ? sum : sum + n), 0)
const [{ recent }] = await db
.select({ recent: count() })
.from(incidents)
.where(and(visible, gte(incidents.createdAt, new Date(now.getTime() - 7 * DAY_MS))))
const actionsBase = db.select({ n: count() }).from(correctiveActions).innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
const [{ n: dueThisWeek }] = await actionsBase.where(
and(visible, inArray(correctiveActions.status, OPEN_ACTION), gte(correctiveActions.dueDate, today), sql`${correctiveActions.dueDate} <= ${addDays(today, 7)}`),
)
const [{ n: overdue }] = await db
.select({ n: count() })
.from(correctiveActions)
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
.where(and(visible, inArray(correctiveActions.status, OPEN_ACTION), lt(correctiveActions.dueDate, today)))
const progressRows = await db
.select({ status: correctiveActions.status, n: count() })
.from(correctiveActions)
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
.where(and(visible, ne(incidents.status, 'closed'), ne(correctiveActions.status, 'cancelled')))
.groupBy(correctiveActions.status)
const actionProgress = {
total: progressRows.reduce((sum, row) => sum + row.n, 0),
completed: progressRows.filter((row) => row.status === 'done' || row.status === 'verified').reduce((sum, row) => sum + row.n, 0),
}
const [{ avgDays }] = await db
.select({ avgDays: sql<string | null>`avg(extract(epoch from ${incidents.closedAt} - ${incidents.createdAt})) / 86400` })
.from(incidents)
.where(and(visible, isNotNull(incidents.closedAt), gte(incidents.closedAt, new Date(now.getTime() - 90 * DAY_MS))))
const firstWeek = weekStart(new Date(now.getTime() - 11 * 7 * DAY_MS))
const weekExpr = sql<string>`to_char(date_trunc('week', ${incidents.occurredAt} at time zone 'UTC'), 'YYYY-MM-DD')`
const weekRows = await db
.select({ week: weekExpr, n: count() })
.from(incidents)
.where(and(visible, gte(incidents.occurredAt, firstWeek)))
.groupBy(weekExpr)
const weeklyTrend = Array.from({ length: 12 }, (_, i) => {
const start = new Date(firstWeek.getTime() + i * 7 * DAY_MS).toISOString().slice(0, 10)
return { weekStart: start, count: weekRows.find((row) => row.week === start)?.n ?? 0 }
})
const yearStart = new Date(now.getFullYear(), 0, 1)
const siteRows = await db
.select({ siteId: sites.id, name: sites.name, n: count(incidents.id) })
.from(sites)
.leftJoin(incidents, and(eq(incidents.siteId, sites.id), gte(incidents.occurredAt, yearStart), visible))
.where(and(eq(sites.organizationId, me.organizationId), isNull(sites.archivedAt)))
.groupBy(sites.id, sites.name)
.orderBy(desc(count(incidents.id)), asc(sites.name))
const stats: OverviewStats = {
openIncidents,
reportedLast7Days: recent,
actionsDueThisWeek: dueThisWeek,
overdueActions: overdue,
averageCloseDays: avgDays === null ? null : Math.round(Number(avgDays) * 10) / 10,
byStatus,
actionProgress,
weeklyTrend,
bySite: siteRows.map((row) => ({ siteId: row.siteId, name: row.name, count: row.n })),
}
return c.json(stats)
})
.get('/analytics', requireAuth, validate('query', z.object({ days: z.coerce.number().int().min(7).max(730).default(90) })), async (c) => {
const me = currentUser(c)
const db = c.get('db')
const { days } = c.req.valid('query')
const since = new Date(Date.now() - days * DAY_MS)
const inPeriod = and(visibleTo(db, me), gte(incidents.occurredAt, since))
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)
})
+32 -7
View File
@@ -3,10 +3,11 @@ import { Hono } from 'hono'
import { z } from 'zod'
import { createUserSchema, updateUserSchema } from '../../shared/schemas.ts'
import { requireRole } from '../auth/middleware.ts'
import { createAuthToken } from '../auth/tokens.ts'
import { hashPassword } from '../auth/password.ts'
import { deleteUserSessions } from '../auth/sessions.ts'
import { isUniqueViolation } from '../db/client.ts'
import { users } from '../db/schema.ts'
import { organizations, users } from '../db/schema.ts'
import { audit, diff } from '../lib/audit.ts'
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
@@ -22,6 +23,7 @@ export const publicUser = {
}
const idParam = z.object({ id: z.uuid() })
const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000
export const userRoutes = new Hono<AppEnv>()
.get('/', requireRole('safety_manager'), async (c) => {
@@ -30,6 +32,18 @@ export const userRoutes = new Hono<AppEnv>()
return c.json({ users: rows })
})
/** Active people who can lead investigations or be assigned actions. */
.get('/assignable', requireRole('supervisor'), async (c) => {
const me = currentUser(c)
const rows = await c
.get('db')
.select({ id: users.id, name: users.name, role: users.role })
.from(users)
.where(and(eq(users.organizationId, me.organizationId), eq(users.active, true)))
.orderBy(asc(users.name))
return c.json({ users: rows })
})
.post('/', requireRole('admin'), validate('json', createUserSchema), async (c) => {
const me = currentUser(c)
const { password, ...input } = c.req.valid('json')
@@ -37,10 +51,15 @@ export const userRoutes = new Hono<AppEnv>()
try {
const [user] = await db
.insert(users)
.values({ ...input, organizationId: me.organizationId, passwordHash: await hashPassword(password) })
.values({ ...input, organizationId: me.organizationId, passwordHash: password ? await hashPassword(password) : null })
.returning(publicUser)
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'user', entityId: user.id, action: 'create', changes: { email: user.email, role: user.role } })
return c.json({ user }, 201)
if (!password) {
const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, me.organizationId))
const token = await createAuthToken(db, user.id, 'password_reset', INVITE_TTL_MS)
c.get('notifier').passwordReset(user.email, token, { organization: organization.name, invitedBy: me.name })
}
return c.json({ user, invited: !password }, 201)
} catch (error) {
if (isUniqueViolation(error)) throw fail(409, 'A user with that email already exists')
throw error
@@ -66,14 +85,20 @@ export const userRoutes = new Hono<AppEnv>()
if (otherAdmins.length === 0) throw fail(409, 'At least one active admin is required')
}
const changes = diff(target, input)
const { password, ...fields } = input
const changes: Record<string, unknown> = diff(target, fields)
if (password) changes.password = 'reset by admin'
if (Object.keys(changes).length === 0) {
const [user] = await db.select(publicUser).from(users).where(eq(users.id, id))
return c.json({ user })
}
const [user] = await db.update(users).set(input).where(eq(users.id, id)).returning(publicUser)
// Roles are re-read on every request, so only deactivation needs to end sessions.
if (input.active === false) await deleteUserSessions(db, id)
const [user] = await db
.update(users)
.set({ ...fields, ...(password ? { passwordHash: await hashPassword(password) } : {}) })
.where(eq(users.id, id))
.returning(publicUser)
// Roles are re-read on every request, so only deactivation and password resets need to end sessions.
if (input.active === false || (password && id !== me.id)) await deleteUserSessions(db, id)
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'user', entityId: id, action: 'update', changes })
return c.json({ user })
})
+202
View File
@@ -0,0 +1,202 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestContext, daysFromToday, hoursAgo } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
let siteId: string
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
let supervisor: Awaited<ReturnType<typeof ctx.createUser>>
let manager: Awaited<ReturnType<typeof ctx.createUser>>
async function newIncident(severity = 'high') {
const res = await reporter.post('/api/incidents', { title: 'Guard missing on press', type: 'hazard', severity, occurredAt: hoursAgo(1), siteId })
return res.body.incident.number as number
}
const move = (client: typeof supervisor, number: number, to: string, note?: string) => client.post(`/api/incidents/${number}/transitions`, { to, note })
beforeAll(async () => {
ctx = await createTestContext()
siteId = (await ctx.createSite()).id
reporter = await ctx.createUser('reporter')
supervisor = await ctx.createUser('supervisor')
manager = await ctx.createUser('safety_manager')
})
afterAll(() => ctx.close())
describe('incident workflow', () => {
it('runs an incident from report to close', async () => {
const number = await newIncident()
expect((await move(reporter, number, 'triage')).status).toBe(403)
let res = await move(supervisor, number, 'triage', 'Looking into it')
expect(res.body.incident.status).toBe('triage')
expect(res.body.incident.transitions.map((t: { to: string }) => t.to)).toEqual(['investigation', 'actions', 'closed'])
// High severity can't be closed straight from triage
expect((await move(supervisor, number, 'closed')).body.error).toMatch(/must be investigated/)
await move(supervisor, number, 'investigation')
// Need a root cause before moving on
expect((await move(supervisor, number, 'actions')).status).toBe(409)
res = await supervisor.put(`/api/incidents/${number}/investigation`, { method: 'five_whys', summary: 'Guard removed for maintenance and not refitted', leadId: supervisor.user.id })
expect(res.body.incident.investigation).toMatchObject({ method: 'five_whys', lead: { id: supervisor.user.id } })
res = await supervisor.post(`/api/incidents/${number}/root-causes`, {
category: 'process',
description: 'No lockout checklist for guard removal',
whys: ['Guard was off', 'Maintenance removed it', 'No step to refit it'],
})
const rootCauseId = res.body.incident.rootCauses[0].id
await move(supervisor, number, 'actions')
// Need finished actions before verification
expect((await move(supervisor, number, 'verification')).body.error).toMatch(/at least one corrective action/)
res = await supervisor.post(`/api/incidents/${number}/actions`, {
title: 'Add guard refit step to maintenance checklist',
assigneeId: reporter.user.id,
dueDate: daysFromToday(7),
rootCauseId,
})
const action = res.body.incident.actions[0]
expect(action).toMatchObject({ status: 'open', assignee: { id: reporter.user.id }, overdue: false })
expect((await move(supervisor, number, 'verification')).body.error).toMatch(/must be done/)
// The assignee (a reporter) can see the incident and report progress, but not edit the action
expect((await reporter.get(`/api/incidents/${number}`)).status).toBe(200)
expect((await reporter.patch(`/api/actions/${action.id}`, { title: 'Changed' })).status).toBe(403)
expect((await reporter.patch(`/api/actions/${action.id}`, { status: 'verified' })).status).toBe(403)
res = await reporter.patch(`/api/actions/${action.id}`, { status: 'done' })
expect(res.body.action.completedAt).not.toBeNull()
await move(supervisor, number, 'verification')
expect((await move(supervisor, number, 'closed')).status).toBe(403)
expect((await move(manager, number, 'closed')).body.error).toMatch(/Verify every corrective action/)
expect((await supervisor.patch(`/api/actions/${action.id}`, { status: 'verified' })).status).toBe(403)
res = await manager.patch(`/api/actions/${action.id}`, { status: 'verified' })
expect(res.body.action.verifiedBy).toMatchObject({ id: manager.user.id })
res = await move(manager, number, 'closed')
expect(res.body.incident).toMatchObject({ status: 'closed' })
expect(res.body.incident.closedAt).not.toBeNull()
// The timeline tells the whole story
const timeline = (await supervisor.get(`/api/incidents/${number}/timeline`)).body.timeline
expect(timeline.map((entry: { action: string }) => entry.action)).toEqual([
'create',
'status',
'status',
'investigation.update',
'root_cause.add',
'status',
'action.add',
'action.update',
'status',
'action.update',
'status',
])
// The note on the first transition became a comment
const comments = (await supervisor.get(`/api/incidents/${number}/comments`)).body.comments
expect(comments[0]).toMatchObject({ body: 'Looking into it', author: { id: supervisor.user.id } })
// Reopening is for safety managers
expect((await move(supervisor, number, 'investigation')).status).toBe(403)
expect((await move(manager, number, 'investigation')).body.incident.closedAt).toBeNull()
})
it('lets low-severity incidents close straight from triage', async () => {
const number = await newIncident('low')
await move(supervisor, number, 'triage')
expect((await move(supervisor, number, 'closed')).body.incident.status).toBe('closed')
})
it('rejects transitions that skip steps', async () => {
const number = await newIncident()
expect((await move(manager, number, 'closed')).status).toBe(409)
})
it('edits and removes root causes', async () => {
const number = await newIncident()
let res = await supervisor.post(`/api/incidents/${number}/root-causes`, { category: 'equipment', description: 'Worn belt' })
const id = res.body.incident.rootCauses[0].id
res = await supervisor.patch(`/api/root-causes/${id}`, { whys: ['Belt slipped', 'Not inspected'] })
expect(res.body.incident.rootCauses[0].whys).toEqual(['Belt slipped', 'Not inspected'])
res = await supervisor.delete(`/api/root-causes/${id}`)
expect(res.body.incident.rootCauses).toEqual([])
expect((await reporter.delete(`/api/root-causes/${id}`)).status).toBe(403)
})
})
describe('comments', () => {
it('lets anyone who can see an incident comment on it', async () => {
const number = await newIncident()
expect((await reporter.post(`/api/incidents/${number}/comments`, { body: 'It happened again today' })).status).toBe(201)
const other = await ctx.createUser('reporter', 'outsider@example.com')
expect((await other.post(`/api/incidents/${number}/comments`, { body: 'Hi' })).status).toBe(404)
expect((await reporter.post(`/api/incidents/${number}/comments`, { body: ' ' })).status).toBe(400)
})
})
describe('corrective actions', () => {
it('lists my open and overdue actions and all actions for supervisors', async () => {
const number = await newIncident()
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Late one', assigneeId: manager.user.id, dueDate: daysFromToday(-2) })
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'On time', assigneeId: manager.user.id, dueDate: daysFromToday(10) })
const mine = (await manager.get('/api/actions')).body.actions
expect(mine.map((a: { title: string }) => a.title)).toEqual(['Late one', 'On time'])
expect(mine[0].overdue).toBe(true)
const overdue = (await manager.get('/api/actions?filter=overdue')).body.actions
expect(overdue.map((a: { title: string }) => a.title)).toEqual(['Late one'])
expect((await reporter.get('/api/actions?scope=all')).status).toBe(403)
expect((await supervisor.get('/api/actions?scope=all')).body.actions.length).toBeGreaterThanOrEqual(2)
})
it('emails the assignee', async () => {
const number = await newIncident()
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Replace mat', assigneeId: manager.user.id })
await ctx.notifier.flush()
expect(ctx.mailer.last(manager.user.email)?.subject).toBe('Action assigned: Replace mat')
})
it('sends one daily reminder per assignee for due and overdue actions', async () => {
const assignee = await ctx.createUser('reporter', 'reminders@example.com')
const number = await newIncident()
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Overdue fix', assigneeId: assignee.user.id, dueDate: daysFromToday(-1) })
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Due tomorrow', assigneeId: assignee.user.id, dueDate: daysFromToday(1) })
await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Next month', assigneeId: assignee.user.id, dueDate: daysFromToday(30) })
await ctx.notifier.sendDueReminders()
await ctx.notifier.flush()
const reminders = ctx.mailer.sent.filter((mail) => mail.to === assignee.user.email && mail.subject !== 'Action assigned: Overdue fix')
const digest = reminders.find((mail) => mail.subject === '1 overdue corrective action')
expect(digest?.text).toContain('Overdue fix')
expect(digest?.text).toContain('Due tomorrow')
expect(digest?.text).not.toContain('Next month')
const before = ctx.mailer.sent.length
await ctx.notifier.sendDueReminders()
await ctx.notifier.flush()
expect(ctx.mailer.sent.length).toBe(before)
})
it("can't be added to a closed incident", async () => {
const number = await newIncident('low')
await move(supervisor, number, 'triage')
await move(supervisor, number, 'closed')
expect((await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Too late' })).status).toBe(409)
})
})
describe('partial updates', () => {
it('leave omitted fields alone', async () => {
const number = await newIncident()
let res = await supervisor.post(`/api/incidents/${number}/root-causes`, { category: 'people', description: 'Rushed', whys: ['Late shipment'] })
const id = res.body.incident.rootCauses[0].id
res = await supervisor.patch(`/api/root-causes/${id}`, { description: 'Rushed to meet shipment' })
expect(res.body.incident.rootCauses[0]).toMatchObject({ description: 'Rushed to meet shipment', whys: ['Late shipment'] })
res = await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Preventive check', kind: 'preventive' })
const actionId = res.body.incident.actions[0].id
const updated = await supervisor.patch(`/api/actions/${actionId}`, { status: 'in_progress' })
expect(updated.body.action).toMatchObject({ kind: 'preventive', status: 'in_progress' })
})
})
+34
View File
@@ -0,0 +1,34 @@
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
const KEY_PATTERN = /^[0-9a-f-]{36}\.[a-z]{3,4}$/
/** Stores uploaded files on local disk under random names. */
export class FileStorage {
readonly dir: string
constructor(dir: string) {
this.dir = dir
}
async save(data: Uint8Array, extension: string): Promise<string> {
await mkdir(this.dir, { recursive: true })
const key = `${randomUUID()}.${extension}`
await writeFile(this.path(key), data, { flag: 'wx' })
return key
}
read(key: string): Promise<Buffer> {
return readFile(this.path(key))
}
async remove(key: string): Promise<void> {
await rm(this.path(key), { force: true })
}
private path(key: string) {
if (!KEY_PATTERN.test(key)) throw new Error(`Invalid storage key: ${key}`)
return join(this.dir, key)
}
}
+41 -5
View File
@@ -1,6 +1,12 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createApp } from '../app.ts'
import { loadConfig } from '../config.ts'
import { openDatabase } from '../db/client.ts'
import { MemoryMailer } from '../mail.ts'
import { Notifier } from '../notifications.ts'
import { FileStorage } from '../storage.ts'
type App = ReturnType<typeof createApp>
@@ -15,12 +21,16 @@ export class Client {
async request(method: string, path: string, body?: unknown) {
const headers: Record<string, string> = {}
if (body !== undefined) headers['content-type'] = 'application/json'
const isForm = body instanceof FormData
if (body !== undefined && !isForm) headers['content-type'] = 'application/json'
if (method !== 'GET') headers.origin = 'http://safety.test' // browsers send Origin on every non-GET request
if (this.cookie) headers.cookie = this.cookie
const res = await this.app.request(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })
const payload = body === undefined ? undefined : isForm ? body : JSON.stringify(body)
const res = await this.app.request(path, { method, headers, body: payload })
const setCookie = res.headers.get('set-cookie')
if (setCookie) this.cookie = /max-age=0|expires=thu, 01 jan 1970/i.test(setCookie) ? undefined : setCookie.split(';')[0]
const text = await res.text()
const text = res.headers.get('content-type')?.includes('json') || res.status >= 400 ? await res.text() : ''
if (!text && res.ok && res.status !== 204) return { status: res.status, body: Buffer.from(await res.arrayBuffer()), headers: res.headers }
// oxlint-disable-next-line typescript/no-explicit-any -- tests read arbitrary JSON shapes
const json: any = text ? JSON.parse(text) : null
return { status: res.status, body: json, headers: res.headers }
@@ -28,7 +38,9 @@ export class Client {
get = (path: string) => this.request('GET', path)
post = (path: string, body?: unknown) => this.request('POST', path, body ?? {})
put = (path: string, body: unknown) => this.request('PUT', path, body)
patch = (path: string, body: unknown) => this.request('PATCH', path, body)
delete = (path: string) => this.request('DELETE', path)
}
export const ADMIN = { organizationName: 'Northstar Co.', name: 'Avery Admin', email: 'admin@example.com', password: 'correct horse battery' }
@@ -36,7 +48,11 @@ export const ADMIN = { organizationName: 'Northstar Co.', name: 'Avery Admin', e
/** Fresh in-memory database and app, with the first-run setup already done and the admin signed in. */
export async function createTestContext() {
const database = await openDatabase('memory://')
const app = createApp({ db: database.db, config: { ...loadConfig({}), databaseUrl: 'memory://' } })
const uploadDir = await mkdtemp(join(tmpdir(), 'safetylog-test-'))
const config = { ...loadConfig({ PUBLIC_URL: 'http://safety.test' }), databaseUrl: 'memory://', uploadDir, maxUploadBytes: 1024 * 1024 }
const mailer = new MemoryMailer()
const notifier = new Notifier(database.db, mailer, config)
const app = createApp({ db: database.db, config, notifier, storage: new FileStorage(uploadDir) })
const admin = new Client(app)
const setup = await admin.post('/api/setup', ADMIN)
if (setup.status !== 201) throw new Error(`setup failed: ${JSON.stringify(setup.body)}`)
@@ -55,7 +71,27 @@ export async function createTestContext() {
return res.body.site as { id: string; name: string }
}
return { app, db: database.db, admin, anonymous: () => new Client(app), createUser, createSite, close: database.close }
async function close() {
await notifier.flush()
await database.close()
await rm(uploadDir, { recursive: true, force: true })
}
return { app, db: database.db, admin, mailer, notifier, config, anonymous: () => new Client(app), createUser, createSite, close }
}
export const hoursAgo = (hours: number) => new Date(Date.now() - hours * 3_600_000).toISOString()
/** Pulls the token out of the link in an email body. */
export function tokenFrom(text: string | undefined): string {
const match = /token=([\w-]+)/.exec(text ?? '')
if (!match) throw new Error(`No token in: ${text}`)
return match[1]
}
export const daysFromToday = (days: number) => {
const date = new Date()
date.setDate(date.getDate() + days)
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
+183
View File
@@ -0,0 +1,183 @@
// Response shapes returned by the API. Dates are ISO 8601 strings.
import type {
actionKinds,
actionStatuses,
AreaKind,
IncidentStatus,
IncidentType,
rcaMethods,
Role,
rootCauseCategories,
Severity,
} from './enums.ts'
export type ActionKind = (typeof actionKinds)[number]
export type ActionStatus = (typeof actionStatuses)[number]
export type RcaMethod = (typeof rcaMethods)[number]
export type RootCauseCategory = (typeof rootCauseCategories)[number]
export interface UserRef {
id: string
name: string
}
export interface User {
id: string
email: string
name: string
role: Role
active: boolean
lastLoginAt: string | null
createdAt: string
}
export interface Organization {
id: string
name: string
}
export interface Area {
id: string
siteId: string
parentId: string | null
name: string
kind: AreaKind
archivedAt: string | null
}
export interface Site {
id: string
name: string
address: string | null
timezone: string
archivedAt: string | null
areas: Area[]
}
export interface Incident {
id: string
number: number
reference: string
title: string
type: IncidentType
severity: Severity
status: IncidentStatus
occurredAt: string
reportedAt: string
updatedAt: string
closedAt: string | null
site: { id: string; name: string }
area: { id: string; name: string } | null
locationDetail: string | null
description: string | null
immediateAction: string | null
anonymous: boolean
reporter: UserRef | null
}
export interface Investigation {
id: string
lead: UserRef | null
method: RcaMethod
summary: string | null
startedAt: string
completedAt: string | null
}
export interface RootCause {
id: string
category: RootCauseCategory
description: string
whys: string[]
}
export interface CorrectiveAction {
id: string
incident: { number: number; reference: string; title: string }
rootCauseId: string | null
kind: ActionKind
title: string
description: string | null
assignee: UserRef | null
dueDate: string | null
status: ActionStatus
overdue: boolean
completedAt: string | null
verifiedBy: UserRef | null
verifiedAt: string | null
createdAt: string
}
export interface Attachment {
id: string
fileName: string
contentType: string
sizeBytes: number
uploadedBy: UserRef | null
createdAt: string
url: string
}
export interface TransitionOption {
to: IncidentStatus
label: string
/** Why the transition can't happen yet; absent when it is allowed. */
blockedReason?: string
}
export interface IncidentDetail extends Incident {
investigation: Investigation | null
rootCauses: RootCause[]
actions: CorrectiveAction[]
attachments: Attachment[]
transitions: TransitionOption[]
/** What the signed-in user may do on this incident. */
can: { edit: boolean; manageInvestigation: boolean; manageActions: boolean }
}
export interface Comment {
id: string
author: UserRef | null
body: string
createdAt: string
}
export interface TimelineEntry {
id: number
at: string
actor: UserRef | null
action: string
changes: Record<string, unknown> | null
}
export interface Paginated<T> {
items: T[]
page: number
pageSize: number
total: number
}
export interface OverviewStats {
openIncidents: number
reportedLast7Days: number
actionsDueThisWeek: number
overdueActions: number
/** Average days from report to close for incidents closed in the last 90 days. */
averageCloseDays: number | null
byStatus: Record<IncidentStatus, number>
actionProgress: { completed: number; total: number }
/** Incidents per week for the last 12 weeks, oldest first. */
weeklyTrend: { weekStart: string; count: number }[]
bySite: { siteId: string; name: string; count: number }[]
}
export interface AnalyticsStats {
periodDays: number
total: number
byType: { type: IncidentType; count: number }[]
bySeverity: { severity: Severity; count: number }[]
topRootCause: { category: RootCauseCategory; count: number; share: number } | null
/** Share of completed actions finished on or before their due date. */
onTimeActionRate: number | null
monthlyCompletedActions: { month: string; onTime: number; late: number }[]
}
+61 -7
View File
@@ -1,6 +1,6 @@
// Request validation schemas, shared so the web app can validate forms with the same rules as the API.
import { z } from 'zod'
import { areaKinds, incidentStatuses, incidentTypes, roles, severities } from './enums.ts'
import { actionKinds, actionStatuses, areaKinds, incidentStatuses, incidentTypes, rcaMethods, roles, rootCauseCategories, severities } from './enums.ts'
const email = z.string().trim().toLowerCase().max(254).pipe(z.email())
const password = z.string().min(10, 'Use at least 10 characters').max(256)
@@ -14,9 +14,11 @@ export const loginSchema = z.object({ email, password: z.string().min(1).max(256
export const changePasswordSchema = z.object({ currentPassword: z.string().min(1).max(256), newPassword: password })
export const createUserSchema = z.object({ name, email, role: z.enum(roles), password })
/** Without a password, the user is emailed an invitation to set one. */
export const createUserSchema = z.object({ name, email, role: z.enum(roles), password: password.optional() })
export const updateUserSchema = z.object({ name: name.optional(), role: z.enum(roles).optional(), active: z.boolean().optional() })
/** `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 createSiteSchema = z.object({ name, address: optionalText(500), timezone: z.string().trim().max(64).optional() })
@@ -39,10 +41,57 @@ export const createIncidentSchema = z.object({
anonymous: z.boolean().default(false),
})
export const updateIncidentSchema = createIncidentSchema
.omit({ anonymous: true })
.partial()
.extend({ status: z.enum(incidentStatuses).optional() })
/** Status changes go through transitions, not updates. */
export const updateIncidentSchema = createIncidentSchema.omit({ anonymous: true }).partial()
export const transitionSchema = z.object({ to: z.enum(incidentStatuses), note: optionalText(5_000) })
export const commentSchema = z.object({ body: z.string().trim().min(1).max(5_000) })
export const investigationSchema = z.object({
leadId: z.uuid().nullish(),
method: z.enum(rcaMethods).optional(),
summary: optionalText(20_000),
completed: z.boolean().optional(),
})
const rootCauseFields = {
category: z.enum(rootCauseCategories),
description: z.string().trim().min(1).max(2_000),
whys: z.array(z.string().trim().min(1).max(1_000)).max(10),
}
export const rootCauseSchema = z.object({ ...rootCauseFields, whys: rootCauseFields.whys.default([]) })
// Update schemas are written out rather than derived with .partial(): in Zod 4, .partial() keeps
// defaults, so a partial update would silently reset omitted fields.
export const updateRootCauseSchema = z.object(rootCauseFields).partial()
const isoDate = z.iso.date()
const actionFields = {
title: name,
description: optionalText(5_000),
kind: z.enum(actionKinds),
assigneeId: z.uuid().nullish(),
dueDate: isoDate.nullish(),
rootCauseId: z.uuid().nullish(),
}
export const createActionSchema = z.object({ ...actionFields, kind: actionFields.kind.default('corrective') })
export const updateActionSchema = z.object({ ...actionFields, status: z.enum(actionStatuses) }).partial()
export const actionListQuery = z.object({
scope: z.enum(['mine', 'all']).default('mine'),
filter: z.enum(['open', 'overdue', 'done', 'all']).default('open'),
})
export const emailOnlySchema = z.object({ email })
export const tokenSchema = z.object({ token: z.string().min(20).max(200) })
export const resetPasswordSchema = z.object({ token: z.string().min(20).max(200), newPassword: password })
export const incidentListQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
@@ -57,4 +106,9 @@ export const incidentListQuery = z.object({
export type SetupInput = z.infer<typeof setupSchema>
export type CreateIncidentInput = z.input<typeof createIncidentSchema>
export type UpdateIncidentInput = z.infer<typeof updateIncidentSchema>
export type CreateActionInput = z.input<typeof createActionSchema>
export type UpdateActionInput = z.input<typeof updateActionSchema>
export type RootCauseInput = z.input<typeof rootCauseSchema>
export type UpdateRootCauseInput = z.input<typeof updateRootCauseSchema>
export type InvestigationInput = z.input<typeof investigationSchema>
export type IncidentListQuery = z.infer<typeof incidentListQuery>
+55
View File
@@ -0,0 +1,55 @@
// Incident status workflow, shared so the web app can show the same options and reasons the API enforces.
import type { ActionStatus } from './api-types.ts'
import { hasRole, type IncidentStatus, type Role, type Severity } from './enums.ts'
export interface Transition {
from: IncidentStatus
to: IncidentStatus
minRole: Role
label: string
}
export const transitions: Transition[] = [
{ from: 'reported', to: 'triage', minRole: 'supervisor', label: 'Start triage' },
{ from: 'triage', to: 'investigation', minRole: 'supervisor', label: 'Start investigation' },
{ from: 'triage', to: 'actions', minRole: 'supervisor', label: 'Skip to corrective actions' },
{ from: 'triage', to: 'closed', minRole: 'supervisor', label: 'Close: no further action' },
{ from: 'investigation', to: 'actions', minRole: 'supervisor', label: 'Move to corrective actions' },
{ from: 'actions', to: 'investigation', minRole: 'supervisor', label: 'Back to investigation' },
{ from: 'actions', to: 'verification', minRole: 'supervisor', label: 'Send for verification' },
{ from: 'verification', to: 'actions', minRole: 'safety_manager', label: 'Return to actions' },
{ from: 'verification', to: 'closed', minRole: 'safety_manager', label: 'Verify and close' },
{ from: 'closed', to: 'investigation', minRole: 'safety_manager', label: 'Reopen' },
]
export interface WorkflowFacts {
severity: Severity
rootCauseCount: number
actionStatuses: ActionStatus[]
}
/** Why a transition isn't possible yet, or undefined if it is. */
export function transitionBlocker(transition: Transition, facts: WorkflowFacts): string | undefined {
const live = facts.actionStatuses.filter((status) => status !== 'cancelled')
if (transition.from === 'triage' && transition.to === 'closed' && (facts.severity === 'high' || facts.severity === 'critical')) {
return 'High and critical incidents must be investigated before closing'
}
if (transition.to === 'actions' && transition.from === 'investigation' && facts.rootCauseCount === 0) {
return 'Record at least one root cause first'
}
if (transition.to === 'verification') {
if (live.length === 0) return 'Add at least one corrective action first'
if (live.some((status) => status === 'open' || status === 'in_progress')) return 'All corrective actions must be done first'
}
if (transition.from === 'verification' && transition.to === 'closed' && live.some((status) => status !== 'verified')) {
return 'Verify every corrective action first'
}
return undefined
}
/** Transitions from `status` that `role` may perform, with blockers explained. */
export function availableTransitions(status: IncidentStatus, role: Role, facts: WorkflowFacts) {
return transitions
.filter((t) => t.from === status && hasRole(role, t.minRole))
.map((t) => ({ to: t.to, label: t.label, blockedReason: transitionBlocker(t, facts) }))
}
-51
View File
@@ -1,51 +0,0 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it } from 'vitest'
import App from './App'
function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>,
)
}
describe('App', () => {
it('renders the overview with recent incidents', () => {
renderAt('/')
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(/Jordan/)
expect(screen.getByText('Forklift reversed into pallet stack')).toBeInTheDocument()
})
it('adds a submitted report to the incident register', async () => {
const user = userEvent.setup()
renderAt('/report')
await user.type(screen.getByLabelText('Incident title'), 'Oil leak under press 4')
await user.selectOptions(screen.getByLabelText('Incident type'), 'Hazard')
await user.type(screen.getByLabelText('Specific location'), 'Press line')
await user.click(screen.getByRole('button', { name: /submit report/i }))
expect(screen.getByRole('heading', { level: 1, name: 'Incidents' })).toBeInTheDocument()
expect(screen.getByText('Oil leak under press 4')).toBeInTheDocument()
expect(screen.getByText(/INC-2409/)).toBeInTheDocument()
})
it('filters the incident register by search text', async () => {
const user = userEvent.setup()
renderAt('/incidents')
await user.type(screen.getByLabelText('Search incidents'), 'guardrail')
expect(screen.getByText('Damaged guardrail on mezzanine')).toBeInTheDocument()
expect(screen.queryByText('Forklift reversed into pallet stack')).not.toBeInTheDocument()
})
it('links to the source code from the about page', () => {
renderAt('/about')
expect(screen.getByRole('heading', { level: 1, name: 'About Open SafetyLog EHS' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /view source code/i })).toHaveAttribute('href', 'https://www.equineform.com')
})
})
+15 -13
View File
@@ -1,30 +1,32 @@
import { useState } from 'react'
import { Navigate, Route, Routes } from 'react-router-dom'
import { AppLayout } from './components/AppLayout'
import { createIncident, isOpen } from './lib/incidents'
import { currentUser, seedIncidents } from './lib/seed'
import type { NewIncident } from './lib/types'
import { About } from './pages/About'
import { Actions } from './pages/Actions'
import { Analytics } from './pages/Analytics'
import { Login, MagicLinkLanding, ResetPassword, Setup } from './pages/Auth'
import { IncidentDetail } from './pages/IncidentDetail'
import { Incidents } from './pages/Incidents'
import { Locations } from './pages/Locations'
import { Overview } from './pages/Overview'
import { ReportIncident } from './pages/ReportIncident'
import { Settings } from './pages/Settings'
export default function App() {
// In-memory store until the backend lands (Phase 1)
const [incidents, setIncidents] = useState(seedIncidents)
const addIncident = (report: NewIncident) =>
setIncidents((current) => [createIncident(report, current, currentUser.name, new Date()), ...current])
return (
<Routes>
<Route element={<AppLayout openIncidentCount={incidents.filter(isOpen).length} />}>
<Route index element={<Overview incidents={incidents} />} />
<Route path="incidents" element={<Incidents incidents={incidents} />} />
<Route path="login" element={<Login />} />
<Route path="setup" element={<Setup />} />
<Route path="auth/magic" element={<MagicLinkLanding />} />
<Route path="auth/reset" element={<ResetPassword />} />
<Route element={<AppLayout />}>
<Route index element={<Overview />} />
<Route path="incidents" element={<Incidents />} />
<Route path="incidents/:number" element={<IncidentDetail />} />
<Route path="actions" element={<Actions />} />
<Route path="locations" element={<Locations />} />
<Route path="analytics" element={<Analytics />} />
<Route path="report" element={<ReportIncident onSubmit={addIncident} />} />
<Route path="report" element={<ReportIncident />} />
<Route path="settings" element={<Settings />} />
<Route path="about" element={<About />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
+23 -5
View File
@@ -1,16 +1,34 @@
import { useState } from 'react'
import { Outlet } from 'react-router-dom'
import { Navigate, Outlet, useLocation } from 'react-router-dom'
import { useActions, useMe, useSetupStatus } from '../lib/queries'
import { Header } from './Header'
import { ErrorMessage, Loading } from './QueryState'
import { Sidebar } from './Sidebar'
export function AppLayout({ openIncidentCount }: { openIncidentCount: number }) {
/** Signed-in shell. Sends visitors to setup (first run) or sign-in. */
export function AppLayout() {
const me = useMe()
const setup = useSetupStatus()
const location = useLocation()
const [menuOpen, setMenuOpen] = useState(false)
const myOpenActions = useActions('mine', 'open', !!me.data)
if (me.isPending) return <Loading />
if (me.isError) return <ErrorMessage error={me.error} className="m-6" />
if (!me.data) {
if (setup.data?.needsSetup) return <Navigate to="/setup" replace />
if (setup.isPending) return <Loading />
return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />
}
const open = myOpenActions.data ?? []
const overdue = open.filter((action) => action.overdue).length
return (
<div className="flex min-h-screen bg-canvas">
<Sidebar open={menuOpen} onClose={() => setMenuOpen(false)} openIncidentCount={openIncidentCount} />
<Sidebar me={me.data} open={menuOpen} onClose={() => setMenuOpen(false)} badges={{ '/actions': open.length }} />
<main className="min-w-0 flex-1">
<Header onOpenMenu={() => setMenuOpen(true)} />
<Outlet />
<Header me={me.data} onOpenMenu={() => setMenuOpen(true)} overdueActions={overdue} />
<Outlet context={me.data} />
</main>
</div>
)
+24
View File
@@ -0,0 +1,24 @@
import { ShieldCheck } from 'lucide-react'
import type { ReactNode } from 'react'
import { panel } from './styles'
/** Centered card for sign-in, setup and email-link pages. */
export function AuthLayout({ title, copy, children }: { title: string; copy?: string; children: ReactNode }) {
return (
<div className="grid min-h-screen place-items-center bg-canvas px-4 py-10">
<div className="w-full max-w-[420px]">
<div className="mb-6 flex items-center justify-center gap-2.5">
<div className="grid size-[34px] place-items-center rounded-[9px] bg-coral text-white">
<ShieldCheck size={20} />
</div>
<strong className="font-display text-xl font-bold tracking-[-0.5px] text-forest-950">Open SafetyLog EHS</strong>
</div>
<div className={`${panel} p-7`}>
<h1 className="font-display text-2xl font-semibold tracking-[-0.5px] text-ink">{title}</h1>
{copy && <p className="mt-1.5 text-xs leading-[1.6] text-muted">{copy}</p>}
<div className="mt-6">{children}</div>
</div>
</div>
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
import type { ActionStatus } from '../../shared/api-types.ts'
import type { IncidentStatus, Severity } from '../../shared/enums.ts'
import { actionStatusClass, actionStatusLabel, severityDotClass, severityLabel, statusClass, statusLabel } from '../lib/labels'
const chip = 'inline-block rounded px-2 py-[5px] text-3xs whitespace-nowrap'
export const StatusBadge = ({ status }: { status: IncidentStatus }) => <span className={`${chip} ${statusClass[status]}`}>{statusLabel[status]}</span>
export const ActionStatusBadge = ({ status }: { status: ActionStatus }) => <span className={`${chip} ${actionStatusClass[status]}`}>{actionStatusLabel[status]}</span>
export function SeverityLabel({ severity }: { severity: Severity }) {
return (
<span className="inline-flex items-center gap-[5px]">
<i className={`size-1.5 rounded-full ${severityDotClass[severity]}`} />
{severityLabel[severity]}
</span>
)
}
export const OverdueBadge = () => <span className={`${chip} bg-coral-soft font-semibold text-coral-strong`}>Overdue</span>
+39 -14
View File
@@ -1,30 +1,55 @@
import { Bell, Menu, Search } from 'lucide-react'
import { useLocation } from 'react-router-dom'
import { pageTitles } from '../lib/navigation'
import { currentUser } from '../lib/seed'
import { useState, type FormEvent } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { initials } from '../lib/initials'
import { pageTitle } from '../lib/navigation'
import type { Me } from '../lib/queries'
import { iconButton } from './styles'
export function Header({ onOpenMenu }: { onOpenMenu: () => void }) {
export function Header({ me, onOpenMenu, overdueActions }: { me: Me; onOpenMenu: () => void; overdueActions: number }) {
const { pathname } = useLocation()
const navigate = useNavigate()
const [query, setQuery] = useState('')
const search = (event: FormEvent) => {
event.preventDefault()
navigate(`/incidents${query.trim() ? `?q=${encodeURIComponent(query.trim())}` : ''}`)
setQuery('')
}
return (
<header className="flex h-[62px] items-center justify-between border-b border-line bg-white/60 px-[17px] sm:h-[72px] sm:px-6 lg:px-[42px]">
<button type="button" className={`${iconButton} mr-2.5 sm:hidden`} onClick={onOpenMenu} aria-label="Open menu">
<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">
<Menu size={21} />
</button>
<div className="flex items-center gap-2.5 text-xs text-[#94a09b]">
<div className="flex min-w-0 items-center gap-2.5 text-xs text-[#94a09b]">
<span className="max-sm:hidden">Safety workspace</span>
<span className="max-sm:hidden">/</span>
<strong className="text-ink">{pageTitles[pathname] ?? 'Not found'}</strong>
<strong className="truncate text-ink">{pageTitle(pathname)}</strong>
</div>
<div className="flex items-center gap-[15px]">
<button type="button" className={iconButton} aria-label="Search">
<search className="hidden md:block">
<form onSubmit={search} className="flex items-center gap-2 rounded-[5px] border border-line bg-white px-2.5 text-[#85938e]">
<Search size={15} />
<input
className="h-[34px] w-[180px] bg-transparent text-xs text-ink outline-none"
placeholder="Search incidents"
aria-label="Search incidents"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</form>
</search>
<Link to="/incidents" className={`${iconButton} md:hidden`} aria-label="Search incidents">
<Search size={19} />
</button>
<button type="button" className={`${iconButton} relative`} aria-label="Notifications">
</Link>
<Link to="/actions?filter=overdue" className={`${iconButton} relative`} aria-label={overdueActions ? `${overdueActions} overdue actions` : 'My actions'}>
<Bell size={19} />
<i className="absolute top-1 right-[3px] size-1.5 rounded-full border border-canvas bg-coral" />
</button>
<div className="grid size-8 place-items-center rounded-full bg-[#d2e0d8] font-mono text-2xs text-teal">{currentUser.initials}</div>
{overdueActions > 0 && <i className="absolute top-1 right-[3px] size-1.5 rounded-full border border-canvas bg-coral" />}
</Link>
<Link to="/settings" className="grid size-8 place-items-center rounded-full bg-[#d2e0d8] font-mono text-2xs text-teal" aria-label="Your settings">
{initials(me.user.name)}
</Link>
</div>
</header>
)
+33 -39
View File
@@ -1,21 +1,16 @@
import { AlertTriangle, MapPin } from 'lucide-react'
import { Link, useNavigate } from 'react-router-dom'
import type { Incident } from '../../shared/api-types.ts'
import { formatDate } from '../lib/dates'
import { severityDotClass, statusClass, typeTone } from '../lib/incidents'
import { incidentTypeLabel, incidentTypeTone } from '../lib/labels'
import { toneClasses } from '../lib/tones'
import type { Incident } from '../lib/types'
import { iconButton } from './styles'
import { SeverityLabel, StatusBadge } from './Badges'
const th = 'border-b border-line px-2.5 py-3 font-mono text-3xs font-medium tracking-[0.06em] text-faint uppercase first:pl-6 last:pr-[18px]'
const td = 'border-b border-line-soft px-2.5 py-3.5 text-2xs whitespace-nowrap text-body first:pl-6 last:pr-[18px]'
const th = 'border-b border-line px-2.5 py-3 font-mono text-3xs font-medium tracking-[0.06em] text-faint uppercase first:pl-6 last:pr-6'
const td = 'border-b border-line-soft px-2.5 py-3.5 text-2xs whitespace-nowrap text-body first:pl-6 last:pr-6'
interface IncidentTableProps {
incidents: Incident[]
limit?: number
wide?: boolean
}
export function IncidentTable({ incidents, limit, wide = false }: IncidentTableProps) {
const rows = limit ? incidents.slice(0, limit) : incidents
export function IncidentTable({ incidents, wide = false }: { incidents: Incident[]; wide?: boolean }) {
const navigate = useNavigate()
return (
<div className="overflow-auto">
<table className={`w-full border-collapse text-left ${wide ? 'min-w-[900px]' : 'min-w-[720px]'}`}>
@@ -26,51 +21,50 @@ export function IncidentTable({ incidents, limit, wide = false }: IncidentTableP
<th className={th}>Severity</th>
<th className={th}>Location</th>
<th className={th}>Status</th>
<th className={th}>Date</th>
<th className={th}>
<span className="sr-only">Actions</span>
</th>
<th className={th}>Occurred</th>
</tr>
</thead>
<tbody>
{rows.map((incident) => (
<tr key={incident.id} className="[&:last-child>td]:border-b-0">
{incidents.map((incident) => (
<tr
key={incident.id}
className="cursor-pointer hover:bg-canvas [&:last-child>td]:border-b-0"
onClick={() => navigate(`/incidents/${incident.number}`)}
>
<td className={td}>
<div className="flex min-w-[250px] items-center gap-2.5">
<span className={`grid size-7 place-items-center rounded-[7px] ${toneClasses[typeTone[incident.type]]}`}>
<span className={`grid size-7 flex-none place-items-center rounded-[7px] ${toneClasses[incidentTypeTone[incident.type]]}`}>
<AlertTriangle size={14} />
</span>
<div>
<strong className="block text-xs font-semibold text-ink">{incident.title}</strong>
<div className="min-w-0">
<Link
to={`/incidents/${incident.number}`}
className="block max-w-[320px] truncate text-xs font-semibold text-ink hover:underline"
onClick={(event) => event.stopPropagation()}
>
{incident.title}
</Link>
<small className="mt-1 block text-3xs text-faint">
{incident.id} · {incident.reporter}
{incident.reference} · {incident.anonymous ? 'Anonymous' : (incident.reporter?.name ?? 'Unknown')}
</small>
</div>
</div>
</td>
<td className={td}>{incident.type}</td>
<td className={td}>{incidentTypeLabel[incident.type]}</td>
<td className={td}>
<span className="inline-flex items-center gap-[5px]">
<i className={`size-1.5 rounded-full ${severityDotClass[incident.severity]}`} />
{incident.severity}
</span>
<SeverityLabel severity={incident.severity} />
</td>
<td className={td}>
<div className="flex max-w-[120px] flex-wrap items-center gap-1">
<MapPin size={14} />
{incident.location}
<small className="mt-1 ml-[18px] block w-full text-3xs text-faint">{incident.building}</small>
<div className="flex max-w-[160px] flex-wrap items-center gap-1">
<MapPin size={14} className="flex-none" />
<span className="truncate">{incident.area?.name ?? incident.site.name}</span>
<small className="mt-1 ml-[18px] block w-full truncate text-3xs text-faint">{incident.locationDetail || incident.site.name}</small>
</div>
</td>
<td className={td}>
<span className={`rounded px-2 py-[5px] text-3xs ${statusClass(incident.status)}`}>{incident.status}</span>
</td>
<td className={`${td} text-[#87938f]`}>{formatDate(incident.date)}</td>
<td className={td}>
<button type="button" className={iconButton} aria-label={`More actions for ${incident.id}`}>
<span className="tracking-[2px] text-[#91aaa0]">•••</span>
</button>
<StatusBadge status={incident.status} />
</td>
<td className={`${td} text-[#87938f]`}>{formatDate(incident.occurredAt)}</td>
</tr>
))}
</tbody>
+2 -4
View File
@@ -4,7 +4,7 @@ import { toneClasses, type Tone } from '../lib/tones'
interface MetricCardProps {
label: string
value: string
delta: string
delta?: string
icon: LucideIcon
tone: Tone
}
@@ -17,9 +17,7 @@ export function MetricCard({ label, value, delta, icon: Icon, tone }: MetricCard
</div>
<span className="mt-2.5 mb-1.5 block text-xs text-muted sm:mt-[15px]">{label}</span>
<strong className="block font-display text-[25px] font-semibold tracking-[-1px] text-ink sm:text-[30px]">{value}</strong>
<small className="mt-[9px] block text-[9px] text-[#7d8b86] sm:text-2xs">
<b className="text-positive">↗</b> {delta}
</small>
{delta && <small className="mt-[9px] block text-[9px] text-[#7d8b86] sm:text-2xs">{delta}</small>}
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { iconButton } from './styles'
interface PaginationProps {
page: number
pageSize: number
total: number
onPage: (page: number) => void
}
export function Pagination({ page, pageSize, total, onPage }: PaginationProps) {
const pages = Math.max(1, Math.ceil(total / pageSize))
const first = total === 0 ? 0 : (page - 1) * pageSize + 1
return (
<div className="flex items-center justify-between border-t border-line px-6 py-3 text-2xs text-muted print:hidden">
<span>
{first}–{Math.min(page * pageSize, total)} of {total}
</span>
<div className="flex items-center gap-1">
<button type="button" className={`${iconButton} disabled:opacity-30`} disabled={page <= 1} onClick={() => onPage(page - 1)} aria-label="Previous page">
<ChevronLeft size={16} />
</button>
<span>
Page {page} of {pages}
</span>
<button type="button" className={`${iconButton} disabled:opacity-30`} disabled={page >= pages} onClick={() => onPage(page + 1)} aria-label="Next page">
<ChevronRight size={16} />
</button>
</div>
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { AlertTriangle, Loader2 } from 'lucide-react'
import type { ReactNode } from 'react'
import { errorMessage } from '../lib/api'
export function Loading({ label = 'Loading…' }: { label?: string }) {
return (
<div className="flex items-center justify-center gap-2 p-10 text-xs text-muted" role="status">
<Loader2 size={16} className="animate-spin" /> {label}
</div>
)
}
export function ErrorMessage({ error, className = '' }: { error: unknown; className?: string }) {
if (!error) return null
return (
<div role="alert" className={`flex items-start gap-2 rounded-[5px] border border-coral-soft bg-[#fff6f3] px-3 py-2.5 text-xs text-coral-strong ${className}`}>
<AlertTriangle size={15} className="mt-px flex-none" />
<span>{errorMessage(error)}</span>
</div>
)
}
export function Empty({ children }: { children: ReactNode }) {
return <div className="px-6 py-10 text-center text-xs text-muted">{children}</div>
}
+32 -26
View File
@@ -1,7 +1,9 @@
import { ChevronDown, FileText, Info, Plus, Settings, ShieldCheck, X } from 'lucide-react'
import { NavLink } from 'react-router-dom'
import { Info, LogOut, Plus, Settings, ShieldCheck, X } from 'lucide-react'
import { NavLink, useNavigate } from 'react-router-dom'
import { initials } from '../lib/initials'
import { roleLabel } from '../lib/labels'
import { primaryNav } from '../lib/navigation'
import { currentUser, workspace } from '../lib/seed'
import { useLogout, type Me } from '../lib/queries'
import { Eyebrow } from './Eyebrow'
import { iconButton } from './styles'
@@ -10,22 +12,25 @@ const navLink = ({ isActive }: { isActive: boolean }) =>
const navSection = 'mb-6 border-b border-forest-700 pb-[23px]'
interface SidebarProps {
me: Me
open: boolean
onClose: () => void
openIncidentCount: number
badges: Partial<Record<string, number>>
}
export function Sidebar({ open, onClose, openIncidentCount }: SidebarProps) {
export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
const logout = useLogout()
const navigate = useNavigate()
return (
<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] ${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]'}`}
>
<div className="flex items-center gap-2.5 px-3 pb-[31px] text-white">
<div className="grid size-[34px] flex-none place-items-center rounded-[9px] bg-coral text-white">
<ShieldCheck size={20} />
</div>
<div>
<strong className="block font-display text-[19px] leading-tight sm:text-[17px] lg:text-[19px] font-bold tracking-[-0.5px] whitespace-nowrap">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>
</div>
<button type="button" className={`${iconButton} absolute top-2 right-2 sm:hidden`} onClick={onClose} aria-label="Close menu">
@@ -34,22 +39,19 @@ export function Sidebar({ open, onClose, openIncidentCount }: SidebarProps) {
</div>
<div className="mb-[27px] flex items-center gap-2.5 rounded-[7px] border border-[#316158] p-[11px]">
<div className="grid size-[29px] place-items-center rounded-full bg-[#f1c865] font-mono text-2xs font-bold text-[#143d37]">{workspace.initials}</div>
<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">
<Eyebrow>Workspace</Eyebrow>
<strong className="block text-[13px] text-white">{workspace.name}</strong>
<strong className="block truncate text-[13px] text-white">{me.organization.name}</strong>
</div>
<ChevronDown size={16} className="ml-auto" />
</div>
<nav className={navSection}>
{primaryNav.map(({ label, to, icon: Icon }) => (
<NavLink key={to} to={to} end onClick={onClose} className={navLink}>
<NavLink key={to} to={to} end={to === '/'} onClick={onClose} className={navLink}>
<Icon size={18} />
<span>{label}</span>
{to === '/incidents' && openIncidentCount > 0 && (
<em className="ml-auto rounded-[10px] bg-coral px-[7px] py-0.5 font-mono text-2xs text-white not-italic">{openIncidentCount}</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>
))}
</nav>
@@ -60,10 +62,6 @@ export function Sidebar({ open, onClose, openIncidentCount }: SidebarProps) {
<Plus size={18} />
<span>Report an incident</span>
</NavLink>
<a href="#resources" className={navLink({ isActive: false })}>
<FileText size={18} />
<span>Resources</span>
</a>
</div>
<div className="mt-auto">
@@ -71,17 +69,25 @@ export function Sidebar({ open, onClose, openIncidentCount }: SidebarProps) {
<Info size={18} />
<span>About</span>
</NavLink>
<a href="#settings" className={navLink({ isActive: false })}>
<NavLink to="/settings" onClick={onClose} className={navLink}>
<Settings size={18} />
<span>Settings</span>
</a>
</NavLink>
<div className="mt-4 flex items-center gap-2.5 border-t border-forest-700 px-2 pt-5">
<div className="grid size-[31px] place-items-center rounded-full bg-[#5c8274] font-mono text-2xs text-white">{currentUser.initials}</div>
<div>
<strong className="block text-xs text-white">{currentUser.name}</strong>
<span className="mt-0.5 block text-3xs text-forest-300">{currentUser.role}</span>
<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">
<strong className="block truncate text-xs text-white">{me.user.name}</strong>
<span className="mt-0.5 block text-3xs text-forest-300">{roleLabel[me.user.role]}</span>
</div>
<span className="ml-auto tracking-[2px] text-[#91aaa0]">•••</span>
<button
type="button"
className={`${iconButton} ml-auto text-[#91aaa0] hover:text-white`}
aria-label="Sign out"
title="Sign out"
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
>
<LogOut size={17} />
</button>
</div>
</div>
</aside>
+159
View File
@@ -0,0 +1,159 @@
import { Plus } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import { useOutletContext } from 'react-router-dom'
import type { ActionKind, ActionStatus, CorrectiveAction, IncidentDetail } from '../../../shared/api-types.ts'
import { actionKinds } from '../../../shared/enums.ts'
import { formatDay, toIsoDate } from '../../lib/dates'
import { actionStatusLabel } from '../../lib/labels'
import { statusOptions } from '../../lib/permissions'
import { useAssignableUsers, useIncidentMutations, type Me } from '../../lib/queries'
import { ActionStatusBadge, OverdueBadge } from '../Badges'
import { Field } from '../FormSection'
import { ErrorMessage } from '../QueryState'
import { field, primaryButton, secondaryButton, smallButton } from '../styles'
import { Section } from './Section'
export function ActionStatusControl({ action, onChange, disabled }: { action: CorrectiveAction; onChange: (status: ActionStatus) => void; disabled?: boolean }) {
const me = useOutletContext<Me>().user
const options = statusOptions(action, me)
if (options.length === 0) return <ActionStatusBadge status={action.status} />
return (
<select
className="rounded-[5px] border border-line bg-white px-2 py-1 text-2xs text-ink outline-none print:hidden"
value={action.status}
disabled={disabled}
aria-label={`Status of ${action.title}`}
onChange={(event) => onChange(event.target.value as ActionStatus)}
>
{!options.includes(action.status) && <option value={action.status}>{actionStatusLabel[action.status]}</option>}
{options.map((status) => (
<option key={status} value={status}>
{actionStatusLabel[status]}
</option>
))}
</select>
)
}
export function ActionsSection({ incident }: { incident: IncidentDetail }) {
const { updateAction } = useIncidentMutations(incident.number)
const [adding, setAdding] = useState(false)
const canAdd = incident.can.manageActions && incident.status !== 'closed'
return (
<Section
title="Corrective actions"
action={
canAdd &&
!adding && (
<button type="button" className={smallButton} onClick={() => setAdding(true)}>
<Plus size={13} /> Add action
</button>
)
}
>
{adding && <ActionForm incident={incident} onDone={() => setAdding(false)} />}
{incident.actions.length === 0 ? (
!adding && <p className="text-xs text-faint">No corrective actions yet.</p>
) : (
<ul className="divide-y divide-line-soft">
{incident.actions.map((action) => {
const cause = incident.rootCauses.find((c) => c.id === action.rootCauseId)
return (
<li key={action.id} className="flex flex-wrap items-start gap-x-4 gap-y-2 py-3 first:pt-0 last:pb-0">
<div className="min-w-[220px] flex-1">
<p className="text-xs font-semibold text-ink">
{action.title}
{action.kind === 'preventive' && <span className="ml-2 font-mono text-3xs font-normal text-faint uppercase">Preventive</span>}
</p>
{action.description && <p className="mt-1 text-2xs leading-[1.5] whitespace-pre-wrap text-body">{action.description}</p>}
<p className="mt-1 text-3xs text-muted">
{action.assignee ? `Assigned to ${action.assignee.name}` : 'Unassigned'}
{action.dueDate && ` · Due ${formatDay(action.dueDate)}`}
{cause && ` · Addresses “${cause.description}”`}
{action.verifiedBy && ` · Verified by ${action.verifiedBy.name}`}
</p>
</div>
<div className="flex items-center gap-2">
{action.overdue && <OverdueBadge />}
<ActionStatusControl action={action} disabled={updateAction.isPending} onChange={(status) => updateAction.mutate({ id: action.id, status })} />
</div>
</li>
)
})}
</ul>
)}
<ErrorMessage error={updateAction.error} className="mt-3" />
</Section>
)
}
function ActionForm({ incident, onDone }: { incident: IncidentDetail; onDone: () => void }) {
const { addAction } = useIncidentMutations(incident.number)
const users = useAssignableUsers()
const [form, setForm] = useState({ title: '', description: '', kind: 'corrective' as ActionKind, assigneeId: '', dueDate: '', rootCauseId: '' })
const submit = (event: FormEvent) => {
event.preventDefault()
addAction.mutate(
{ ...form, assigneeId: form.assigneeId || null, dueDate: form.dueDate || null, rootCauseId: form.rootCauseId || null, description: form.description || null },
{ onSuccess: onDone },
)
}
return (
<form onSubmit={submit} className="mb-4 rounded-md border border-line bg-canvas p-4">
<Field label="Action">
<input className={field} required value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} placeholder="e.g. Add guard refit step to maintenance checklist" />
</Field>
<Field label="Details" optional>
<textarea className={`${field} resize-y`} rows={2} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
</Field>
<div className="grid sm:grid-cols-3 sm:gap-4">
<Field label="Assign to">
<select className={field} value={form.assigneeId} onChange={(event) => setForm({ ...form, assigneeId: event.target.value })}>
<option value="">Unassigned</option>
{users.data?.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</Field>
<Field label="Due date" optional>
<input className={field} type="date" min={toIsoDate(new Date())} value={form.dueDate} onChange={(event) => setForm({ ...form, dueDate: event.target.value })} />
</Field>
<Field label="Type">
<select className={field} value={form.kind} onChange={(event) => setForm({ ...form, kind: event.target.value as ActionKind })}>
{actionKinds.map((kind) => (
<option key={kind} value={kind}>
{kind === 'corrective' ? 'Corrective (fix this)' : 'Preventive (stop it elsewhere)'}
</option>
))}
</select>
</Field>
</div>
{incident.rootCauses.length > 0 && (
<Field label="Addresses root cause" optional>
<select className={field} value={form.rootCauseId} onChange={(event) => setForm({ ...form, rootCauseId: event.target.value })}>
<option value="">None</option>
{incident.rootCauses.map((cause) => (
<option key={cause.id} value={cause.id}>
{cause.description}
</option>
))}
</select>
</Field>
)}
<ErrorMessage error={addAction.error} className="mb-3" />
<div className="flex justify-end gap-2">
<button type="button" className={secondaryButton} onClick={onDone}>
Cancel
</button>
<button type="submit" className={primaryButton} disabled={addAction.isPending}>
Add action
</button>
</div>
</form>
)
}
@@ -0,0 +1,70 @@
import { useState, type FormEvent } from 'react'
import type { IncidentDetail } from '../../../shared/api-types.ts'
import { formatDateTime } from '../../lib/dates'
import { initials } from '../../lib/initials'
import { describeTimelineEntry } from '../../lib/labels'
import { useComments, useIncidentMutations, useTimeline } from '../../lib/queries'
import { ErrorMessage, Loading } from '../QueryState'
import { fieldBase, primaryButton } from '../styles'
import { Section } from './Section'
type Item = { kind: 'comment'; id: string; at: string; who: string; body: string } | { kind: 'event'; id: string; at: string; who: string; text: string }
/** Comments and the audit trail, interleaved by time. */
export function ActivitySection({ incident }: { incident: IncidentDetail }) {
const timeline = useTimeline(incident.number)
const comments = useComments(incident.number)
const { comment } = useIncidentMutations(incident.number)
const [body, setBody] = useState('')
const items: Item[] = [
...(comments.data ?? []).map((c): Item => ({ kind: 'comment', id: `c-${c.id}`, at: c.createdAt, who: c.author?.name ?? 'Former user', body: c.body })),
...(timeline.data ?? []).map(
(entry): Item => ({ kind: 'event', id: `e-${entry.id}`, at: entry.at, who: entry.actor?.name ?? (entry.action === 'create' ? 'Anonymous' : 'Someone'), text: describeTimelineEntry(entry) }),
),
].sort((a, b) => a.at.localeCompare(b.at))
const submit = (event: FormEvent) => {
event.preventDefault()
if (body.trim()) comment.mutate(body.trim(), { onSuccess: () => setBody('') })
}
return (
<Section title="Activity">
{timeline.isPending || comments.isPending ? (
<Loading />
) : (
<ol className="space-y-3">
{items.map((item) =>
item.kind === 'comment' ? (
<li key={item.id} className="flex gap-2.5">
<span className="grid size-7 flex-none place-items-center rounded-full bg-[#d2e0d8] font-mono text-3xs text-teal">{initials(item.who)}</span>
<div className="min-w-0 flex-1 rounded-md border border-line bg-field px-3 py-2">
<p className="text-3xs text-muted">
<strong className="font-semibold text-ink">{item.who}</strong> · {formatDateTime(item.at)}
</p>
<p className="mt-1 text-xs leading-[1.5] whitespace-pre-wrap text-body">{item.body}</p>
</div>
</li>
) : (
<li key={item.id} className="flex items-baseline gap-2.5 pl-2.5 text-2xs text-muted">
<i className="size-1.5 flex-none translate-y-[-1px] rounded-full bg-line" />
<span>
<strong className="font-semibold text-body">{item.who}</strong> {item.text}
<span className="text-faint"> · {formatDateTime(item.at)}</span>
</span>
</li>
),
)}
</ol>
)}
<form onSubmit={submit} className="mt-5 flex flex-col gap-2 border-t border-line pt-4 print:hidden">
<textarea className={`${fieldBase} resize-y`} rows={2} value={body} onChange={(event) => setBody(event.target.value)} placeholder="Add a comment or update" aria-label="Comment" />
<ErrorMessage error={comment.error} />
<button type="submit" className={`${primaryButton} self-end`} disabled={!body.trim() || comment.isPending}>
Comment
</button>
</form>
</Section>
)
}
@@ -0,0 +1,71 @@
import { FileText, ImagePlus, Trash2 } from 'lucide-react'
import type { ChangeEvent } from 'react'
import { useOutletContext } from 'react-router-dom'
import type { IncidentDetail } from '../../../shared/api-types.ts'
import { hasRole } from '../../../shared/enums.ts'
import { formatBytes } from '../../lib/dates'
import { useIncidentMutations, type Me } from '../../lib/queries'
import { ErrorMessage } from '../QueryState'
import { iconButton, smallButton } from '../styles'
import { Section } from './Section'
export function AttachmentsSection({ incident }: { incident: IncidentDetail }) {
const me = useOutletContext<Me>().user
const { upload, removeAttachment } = useIncidentMutations(incident.number)
const onFiles = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files ?? [])
event.target.value = ''
for (const file of files) await upload.mutateAsync(file).catch(() => undefined)
}
return (
<Section
title="Photos and files"
action={
<label className={`${smallButton} cursor-pointer ${upload.isPending ? 'opacity-50' : ''}`}>
<ImagePlus size={13} /> {upload.isPending ? 'Uploading…' : 'Add'}
<input type="file" accept="image/*,application/pdf" multiple className="sr-only" disabled={upload.isPending} onChange={onFiles} />
</label>
}
>
<ErrorMessage error={upload.error ?? removeAttachment.error} className="mb-3" />
{incident.attachments.length === 0 ? (
<p className="text-xs text-faint">No photos or files.</p>
) : (
<ul className="grid grid-cols-2 gap-3">
{incident.attachments.map((file) => {
const canRemove = file.uploadedBy?.id === me.id || hasRole(me.role, 'supervisor')
const isImage = file.contentType.startsWith('image/') && file.contentType !== 'image/heic'
return (
<li key={file.id} className="group relative overflow-hidden rounded-md border border-line bg-canvas">
<a href={file.url} target="_blank" rel="noreferrer" className="block">
{isImage ? (
<img src={file.url} alt={file.fileName} loading="lazy" className="aspect-[4/3] w-full object-cover" />
) : (
<div className="grid aspect-[4/3] place-items-center text-muted">
<FileText size={28} />
</div>
)}
<div className="truncate px-2 py-1.5 text-3xs text-body" title={file.fileName}>
{file.fileName} <span className="text-faint">· {formatBytes(file.sizeBytes)}</span>
</div>
</a>
{canRemove && (
<button
type="button"
className={`${iconButton} absolute top-1 right-1 rounded bg-white/90 opacity-0 group-hover:opacity-100 focus:opacity-100 print:hidden`}
aria-label={`Remove ${file.fileName}`}
onClick={() => confirm(`Remove ${file.fileName}?`) && removeAttachment.mutate(file.id)}
>
<Trash2 size={14} />
</button>
)}
</li>
)
})}
</ul>
)}
</Section>
)
}
+141
View File
@@ -0,0 +1,141 @@
import { Pencil } from 'lucide-react'
import { useState, type ChangeEvent, type FormEvent } from 'react'
import type { IncidentDetail } from '../../../shared/api-types.ts'
import { incidentTypes, severities, type IncidentType, type Severity } from '../../../shared/enums.ts'
import { toDateTimeLocal } from '../../lib/dates'
import { incidentTypeLabel, severityLabel } from '../../lib/labels'
import { useIncidentMutations, useSites } from '../../lib/queries'
import { Field } from '../FormSection'
import { ErrorMessage } from '../QueryState'
import { field, primaryButton, secondaryButton, smallButton } from '../styles'
import { Section } from './Section'
function Text({ label, value }: { label: string; value: string | null }) {
return (
<div className="mb-4 last:mb-0">
<h3 className="mb-1 text-2xs font-semibold text-[#586762]">{label}</h3>
<p className={`text-xs leading-[1.6] whitespace-pre-wrap ${value ? 'text-body' : 'text-faint'}`}>{value || 'Not recorded'}</p>
</div>
)
}
export function DetailsSection({ incident }: { incident: IncidentDetail }) {
const [editing, setEditing] = useState(false)
return (
<Section
title="What happened"
action={
incident.can.edit &&
!editing && (
<button type="button" className={smallButton} onClick={() => setEditing(true)}>
<Pencil size={13} /> Edit
</button>
)
}
>
{editing ? (
<EditForm incident={incident} onDone={() => setEditing(false)} />
) : (
<>
<Text label="Description" value={incident.description} />
<Text label="Immediate response" value={incident.immediateAction} />
<Text label="Exact location" value={incident.locationDetail} />
</>
)}
</Section>
)
}
function EditForm({ incident, onDone }: { incident: IncidentDetail; onDone: () => void }) {
const { update } = useIncidentMutations(incident.number)
const sites = useSites()
const [form, setForm] = useState({
title: incident.title,
type: incident.type as IncidentType,
severity: incident.severity as Severity,
occurredAt: toDateTimeLocal(new Date(incident.occurredAt)),
siteId: incident.site.id,
areaId: incident.area?.id ?? '',
locationDetail: incident.locationDetail ?? '',
description: incident.description ?? '',
immediateAction: incident.immediateAction ?? '',
})
const set = (key: keyof typeof form) => (event: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
setForm({ ...form, [key]: event.target.value, ...(key === 'siteId' ? { areaId: '' } : {}) })
const areas = sites.data?.find((site) => site.id === form.siteId)?.areas ?? []
const submit = (event: FormEvent) => {
event.preventDefault()
update.mutate({ ...form, areaId: form.areaId || null, occurredAt: new Date(form.occurredAt).toISOString() }, { onSuccess: onDone })
}
return (
<form onSubmit={submit}>
<Field label="Title">
<input className={field} required value={form.title} onChange={set('title')} />
</Field>
<div className="grid sm:grid-cols-3 sm:gap-4">
<Field label="Type">
<select className={field} value={form.type} onChange={set('type')}>
{incidentTypes.map((type) => (
<option key={type} value={type}>
{incidentTypeLabel[type]}
</option>
))}
</select>
</Field>
<Field label="Severity">
<select className={field} value={form.severity} onChange={set('severity')}>
{severities.map((severity) => (
<option key={severity} value={severity}>
{severityLabel[severity]}
</option>
))}
</select>
</Field>
<Field label="When">
<input className={field} type="datetime-local" required value={form.occurredAt} onChange={set('occurredAt')} />
</Field>
</div>
<div className="grid sm:grid-cols-2 sm:gap-4">
<Field label="Site">
<select className={field} value={form.siteId} onChange={set('siteId')}>
{sites.data?.map((site) => (
<option key={site.id} value={site.id}>
{site.name}
</option>
))}
</select>
</Field>
<Field label="Building or area" optional>
<select className={field} value={form.areaId} onChange={set('areaId')}>
<option value="">Not specified</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
</option>
))}
</select>
</Field>
</div>
<Field label="Exact location" optional>
<input className={field} value={form.locationDetail} onChange={set('locationDetail')} />
</Field>
<Field label="Description" optional>
<textarea className={`${field} resize-y`} rows={4} value={form.description} onChange={set('description')} />
</Field>
<Field label="Immediate response" optional>
<textarea className={`${field} resize-y`} rows={3} value={form.immediateAction} onChange={set('immediateAction')} />
</Field>
<ErrorMessage error={update.error} className="mb-3" />
<div className="flex justify-end gap-2">
<button type="button" className={secondaryButton} onClick={onDone}>
Cancel
</button>
<button type="submit" className={primaryButton} disabled={update.isPending}>
Save changes
</button>
</div>
</form>
)
}
@@ -0,0 +1,261 @@
import { Plus, Trash2, X } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import type { IncidentDetail, RcaMethod, RootCause, RootCauseCategory } from '../../../shared/api-types.ts'
import { rcaMethods, rootCauseCategories } from '../../../shared/enums.ts'
import { formatDate } from '../../lib/dates'
import { rcaMethodLabel, rootCauseCategoryLabel } from '../../lib/labels'
import { useAssignableUsers, useIncidentMutations } from '../../lib/queries'
import { Field } from '../FormSection'
import { ErrorMessage } from '../QueryState'
import { field, fieldBase, iconButton, primaryButton, secondaryButton, smallButton, textButton } from '../styles'
import { Section } from './Section'
export function InvestigationSection({ incident }: { incident: IncidentDetail }) {
const { investigation, rootCauses } = incident
const canManage = incident.can.manageInvestigation
const [editing, setEditing] = useState(false)
const [adding, setAdding] = useState(false)
const { removeRootCause } = useIncidentMutations(incident.number)
if (!investigation && !canManage) return null
return (
<Section
title="Investigation"
action={
canManage &&
!editing && (
<button type="button" className={smallButton} onClick={() => setEditing(true)}>
{investigation ? 'Edit' : 'Record findings'}
</button>
)
}
>
{editing ? (
<InvestigationForm incident={incident} onDone={() => setEditing(false)} />
) : investigation ? (
<dl className="mb-5 grid gap-x-6 gap-y-3 text-xs sm:grid-cols-3">
<div>
<dt className="text-2xs font-semibold text-[#586762]">Lead</dt>
<dd className="mt-0.5 text-body">{investigation.lead?.name ?? 'Unassigned'}</dd>
</div>
<div>
<dt className="text-2xs font-semibold text-[#586762]">Method</dt>
<dd className="mt-0.5 text-body">{rcaMethodLabel[investigation.method]}</dd>
</div>
<div>
<dt className="text-2xs font-semibold text-[#586762]">Status</dt>
<dd className="mt-0.5 text-body">{investigation.completedAt ? `Completed ${formatDate(investigation.completedAt)}` : `Started ${formatDate(investigation.startedAt)}`}</dd>
</div>
{investigation.summary && (
<div className="sm:col-span-3">
<dt className="text-2xs font-semibold text-[#586762]">Findings</dt>
<dd className="mt-0.5 leading-[1.6] whitespace-pre-wrap text-body">{investigation.summary}</dd>
</div>
)}
</dl>
) : (
<p className="mb-5 text-xs text-muted">No findings recorded yet.</p>
)}
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold text-ink">Root causes</h3>
{canManage && !adding && (
<button type="button" className={`${textButton} print:hidden`} onClick={() => setAdding(true)}>
<Plus size={13} /> Add root cause
</button>
)}
</div>
{adding && <RootCauseForm incident={incident} onDone={() => setAdding(false)} />}
{rootCauses.length === 0 ? (
!adding && <p className="mt-2 text-xs text-faint">None recorded yet.</p>
) : investigation?.method === 'fishbone' ? (
<Fishbone causes={rootCauses} onRemove={canManage ? (id) => removeRootCause.mutate(id) : undefined} />
) : (
<ul className="mt-3 space-y-3">
{rootCauses.map((cause) => (
<RootCauseItem key={cause.id} cause={cause} onRemove={canManage ? () => removeRootCause.mutate(cause.id) : undefined} />
))}
</ul>
)}
<ErrorMessage error={removeRootCause.error} className="mt-3" />
</Section>
)
}
function RootCauseItem({ cause, onRemove, compact = false }: { cause: RootCause; onRemove?: () => void; compact?: boolean }) {
return (
<li className="rounded-md border border-line bg-field p-3">
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
{!compact && <span className="font-mono text-3xs tracking-[0.06em] text-faint uppercase">{rootCauseCategoryLabel[cause.category]}</span>}
<p className="text-xs font-semibold text-ink">{cause.description}</p>
</div>
{onRemove && (
<button type="button" className={`${iconButton} -m-1 print:hidden`} aria-label={`Remove root cause ${cause.description}`} onClick={onRemove}>
<Trash2 size={14} />
</button>
)}
</div>
{cause.whys.length > 0 && (
<ol className="mt-2 space-y-1 border-l-2 border-coral-soft pl-3">
{cause.whys.map((why, i) => (
<li key={i} className="text-2xs text-body">
<span className="mr-1.5 font-mono text-coral">Why {i + 1}</span>
{why}
</li>
))}
</ol>
)}
</li>
)
}
/** Causes laid out by the six fishbone (6M) categories. */
function Fishbone({ causes, onRemove }: { causes: RootCause[]; onRemove?: (id: string) => void }) {
return (
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rootCauseCategories.map((category) => {
const items = causes.filter((cause) => cause.category === category)
return (
<div key={category} className={`rounded-md border p-3 ${items.length ? 'border-coral-soft bg-[#fffaf8]' : 'border-dashed border-line'}`}>
<h4 className="mb-2 font-mono text-3xs tracking-[0.06em] text-muted uppercase">{rootCauseCategoryLabel[category]}</h4>
{items.length ? (
<ul className="space-y-2">
{items.map((cause) => (
<RootCauseItem key={cause.id} cause={cause} compact onRemove={onRemove ? () => onRemove(cause.id) : undefined} />
))}
</ul>
) : (
<p className="text-3xs text-faint">Nothing identified</p>
)}
</div>
)
})}
</div>
)
}
function InvestigationForm({ incident, onDone }: { incident: IncidentDetail; onDone: () => void }) {
const { investigation } = useIncidentMutations(incident.number)
const users = useAssignableUsers()
const current = incident.investigation
const [form, setForm] = useState({
leadId: current?.lead?.id ?? '',
method: (current?.method ?? 'five_whys') as RcaMethod,
summary: current?.summary ?? '',
completed: !!current?.completedAt,
})
const submit = (event: FormEvent) => {
event.preventDefault()
investigation.mutate({ ...form, leadId: form.leadId || null }, { onSuccess: onDone })
}
return (
<form onSubmit={submit} className="mb-5">
<div className="grid sm:grid-cols-2 sm:gap-4">
<Field label="Lead investigator">
<select className={field} value={form.leadId} onChange={(event) => setForm({ ...form, leadId: event.target.value })}>
<option value="">Unassigned</option>
{users.data?.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</Field>
<Field label="Root-cause method">
<select className={field} value={form.method} onChange={(event) => setForm({ ...form, method: event.target.value as RcaMethod })}>
{rcaMethods.map((method) => (
<option key={method} value={method}>
{rcaMethodLabel[method]}
</option>
))}
</select>
</Field>
</div>
<Field label="Findings" optional>
<textarea className={`${field} resize-y`} rows={4} value={form.summary} onChange={(event) => setForm({ ...form, summary: event.target.value })} placeholder="What the investigation found" />
</Field>
<label className="mb-4 flex items-center gap-2 text-2xs text-[#586762]">
<input type="checkbox" className="accent-coral" checked={form.completed} onChange={(event) => setForm({ ...form, completed: event.target.checked })} />
Investigation complete
</label>
<ErrorMessage error={investigation.error} className="mb-3" />
<div className="flex justify-end gap-2">
<button type="button" className={secondaryButton} onClick={onDone}>
Cancel
</button>
<button type="submit" className={primaryButton} disabled={investigation.isPending}>
Save investigation
</button>
</div>
</form>
)
}
function RootCauseForm({ incident, onDone }: { incident: IncidentDetail; onDone: () => void }) {
const { addRootCause } = useIncidentMutations(incident.number)
const [category, setCategory] = useState<RootCauseCategory>('process')
const [description, setDescription] = useState('')
const [whys, setWhys] = useState<string[]>([''])
const submit = (event: FormEvent) => {
event.preventDefault()
addRootCause.mutate({ category, description, whys: whys.map((why) => why.trim()).filter(Boolean) }, { onSuccess: onDone })
}
return (
<form onSubmit={submit} className="mt-3 rounded-md border border-line bg-canvas p-4">
<div className="grid sm:grid-cols-[200px_1fr] sm:gap-4">
<Field label="Category">
<select className={field} value={category} onChange={(event) => setCategory(event.target.value as RootCauseCategory)}>
{rootCauseCategories.map((value) => (
<option key={value} value={value}>
{rootCauseCategoryLabel[value]}
</option>
))}
</select>
</Field>
<Field label="Root cause">
<input className={field} required value={description} onChange={(event) => setDescription(event.target.value)} placeholder="e.g. No step to refit guards after maintenance" />
</Field>
</div>
<div className="mb-4">
<span className="text-2xs font-semibold text-[#586762]">5 Whys</span>
<span className="ml-2 text-3xs text-faint">Ask “why?” until you reach something you can fix</span>
{whys.map((why, index) => (
<div key={index} className="mt-2 flex items-center gap-2">
<span className="w-12 flex-none font-mono text-3xs text-coral">Why {index + 1}</span>
<input
className={fieldBase}
value={why}
aria-label={`Why ${index + 1}`}
onChange={(event) => setWhys(whys.map((value, i) => (i === index ? event.target.value : value)))}
/>
{whys.length > 1 && (
<button type="button" className={iconButton} aria-label={`Remove why ${index + 1}`} onClick={() => setWhys(whys.filter((_, i) => i !== index))}>
<X size={14} />
</button>
)}
</div>
))}
{whys.length < 5 && (
<button type="button" className={`${textButton} mt-2`} onClick={() => setWhys([...whys, ''])}>
<Plus size={13} /> Ask why again
</button>
)}
</div>
<ErrorMessage error={addRootCause.error} className="mb-3" />
<div className="flex justify-end gap-2">
<button type="button" className={secondaryButton} onClick={onDone}>
Cancel
</button>
<button type="submit" className={primaryButton} disabled={addRootCause.isPending}>
Add root cause
</button>
</div>
</form>
)
}
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react'
import { panel, sectionHeading } from '../styles'
export function Section({ title, action, children, className = '' }: { title: string; action?: ReactNode; children: ReactNode; className?: string }) {
return (
<section className={`${panel} break-inside-avoid ${className}`}>
<div className="flex items-center justify-between gap-3 border-b border-line px-5 py-4 sm:px-6">
<h2 className={sectionHeading}>{title}</h2>
{action && <div className="print:hidden">{action}</div>}
</div>
<div className="px-5 py-5 sm:px-6">{children}</div>
</section>
)
}
+60
View File
@@ -0,0 +1,60 @@
import { Check } from 'lucide-react'
import { useState } from 'react'
import type { IncidentDetail } from '../../../shared/api-types.ts'
import { incidentStatuses } from '../../../shared/enums.ts'
import { statusLabel } from '../../lib/labels'
import { useIncidentMutations } from '../../lib/queries'
import { Field } from '../FormSection'
import { ErrorMessage } from '../QueryState'
import { field, primaryButton, secondaryButton } from '../styles'
import { Section } from './Section'
export function WorkflowPanel({ incident }: { incident: IncidentDetail }) {
const { transition } = useIncidentMutations(incident.number)
const [note, setNote] = useState('')
const current = incidentStatuses.indexOf(incident.status)
const forward = incident.transitions.filter((t) => incidentStatuses.indexOf(t.to) > current || incident.status === 'closed')
const back = incident.transitions.filter((t) => !forward.includes(t))
return (
<Section title="Workflow">
<ol className="mb-5 space-y-1.5">
{incidentStatuses.map((status, index) => {
const done = index < current || incident.status === 'closed'
const active = index === current
return (
<li key={status} className={`flex items-center gap-2.5 text-xs ${active ? 'font-semibold text-ink' : done ? 'text-body' : 'text-faint'}`}>
<span
className={`grid size-5 flex-none place-items-center rounded-full text-3xs ${active ? 'bg-coral text-white' : done ? 'bg-mint-soft text-mint-strong' : 'border border-line text-faint'}`}
>
{done && !active ? <Check size={12} /> : index + 1}
</span>
{statusLabel[status]}
</li>
)
})}
</ol>
{incident.transitions.length > 0 && (
<div className="space-y-2.5 border-t border-line pt-4 print:hidden">
<Field label="Note" optional>
<textarea className={`${field} resize-y`} rows={2} value={note} onChange={(event) => setNote(event.target.value)} placeholder="Added to the activity log with the change" />
</Field>
{[...forward, ...back].map((option) => (
<div key={option.to}>
<button
type="button"
className={`${forward.includes(option) ? primaryButton : secondaryButton} w-full`}
disabled={!!option.blockedReason || transition.isPending}
onClick={() => transition.mutate({ to: option.to, note: note.trim() || undefined }, { onSuccess: () => setNote('') })}
>
{option.label}
</button>
{option.blockedReason && <p className="mt-1 text-3xs text-muted">{option.blockedReason}</p>}
</div>
))}
<ErrorMessage error={transition.error} />
</div>
)}
</Section>
)
}
+14 -3
View File
@@ -2,14 +2,25 @@
export const panel = 'min-w-0 rounded-lg border border-line bg-white shadow-[0_5px_20px_#1b252305]'
const buttonBase = 'inline-flex cursor-pointer items-center justify-center gap-2 rounded-[5px] px-3.5 py-[11px] text-xs font-bold no-underline'
const buttonBase =
'inline-flex cursor-pointer items-center justify-center gap-2 rounded-[5px] px-3.5 py-[11px] text-xs font-bold no-underline disabled:cursor-not-allowed disabled:opacity-50'
export const primaryButton = `${buttonBase} bg-coral text-white shadow-[0_4px_12px_#ee725833]`
export const secondaryButton = `${buttonBase} border border-line bg-white text-ink`
export const lightButton = `${buttonBase} bg-[#edf6ef] text-teal`
export const iconButton = 'grid cursor-pointer place-items-center bg-transparent p-1.5 text-[#72817d]'
export const field =
'mt-[7px] block w-full rounded-[5px] border border-line bg-field px-3 py-[11px] text-xs text-ink outline-none focus:border-[#83b3a5] focus:shadow-[0_0_0_3px_#83b3a522]'
/** Form control without spacing, for inline use. */
export const fieldBase =
'block w-full rounded-[5px] border border-line bg-field px-3 py-[11px] text-xs text-ink outline-none focus:border-[#83b3a5] focus:shadow-[0_0_0_3px_#83b3a522]'
/** Form control placed under its label. */
export const field = `mt-[7px] ${fieldBase}`
export const filterBox = 'flex h-[38px] items-center gap-2 rounded-[5px] border border-line px-[11px] text-[#85938e]'
export const smallButton = 'inline-flex cursor-pointer items-center gap-1.5 rounded-[5px] border border-line bg-white px-2.5 py-1.5 text-2xs font-semibold text-ink hover:bg-canvas disabled:cursor-not-allowed disabled:opacity-50'
export const textButton = 'inline-flex cursor-pointer items-center gap-1 text-2xs font-semibold text-coral hover:underline disabled:opacity-50'
export const sectionHeading = 'font-display text-[17px] font-semibold text-ink'
+34
View File
@@ -0,0 +1,34 @@
export class ApiError extends Error {
readonly status: number
readonly issues: { path: string; message: string }[]
constructor(status: number, message: string, issues: { path: string; message: string }[] = []) {
super(message)
this.status = status
this.issues = issues
}
}
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
/** Calls the JSON API. Throws ApiError with the server's message on failure. */
export async function api<T>(path: string, options: { method?: Method; body?: unknown } = {}): Promise<T> {
const { method = 'GET', body } = options
const isForm = body instanceof FormData
const res = await fetch(`/api${path}`, {
method,
credentials: 'same-origin',
headers: body === undefined || isForm ? undefined : { 'content-type': 'application/json' },
body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
})
if (res.status === 204) return undefined as T
const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null
if (!res.ok) {
const issues = data?.issues ?? []
const message = issues.length ? issues.map((issue: { path: string; message: string }) => `${issue.path}: ${issue.message}`).join('; ') : (data?.error ?? `Request failed (${res.status})`)
throw new ApiError(res.status, message, issues)
}
return data as T
}
export const errorMessage = (error: unknown) => (error instanceof Error ? error.message : 'Something went wrong')
+13 -5
View File
@@ -1,13 +1,15 @@
import { describe, expect, it } from 'vitest'
import { formatDate, greeting, toIsoDate } from './dates'
import { formatBytes, formatDay, greeting, toDateTimeLocal, toIsoDate } from './dates'
describe('dates', () => {
it('formats ISO dates without time-zone drift', () => {
expect(formatDate('2024-08-28')).toBe('Aug 28, 2024')
it('formats calendar dates without time-zone drift', () => {
expect(formatDay('2024-08-28')).toBe('Aug 28, 2024')
})
it('converts a local date to an ISO calendar date', () => {
expect(toIsoDate(new Date(2024, 0, 5, 23, 59))).toBe('2024-01-05')
it('converts local dates for form inputs', () => {
const date = new Date(2024, 0, 5, 9, 7)
expect(toIsoDate(date)).toBe('2024-01-05')
expect(toDateTimeLocal(date)).toBe('2024-01-05T09:07')
})
it('greets by time of day', () => {
@@ -15,4 +17,10 @@ describe('dates', () => {
expect(greeting(new Date(2024, 0, 1, 13))).toBe('Good afternoon')
expect(greeting(new Date(2024, 0, 1, 20))).toBe('Good evening')
})
it('formats file sizes', () => {
expect(formatBytes(512)).toBe('512 B')
expect(formatBytes(2048)).toBe('2 KB')
expect(formatBytes(3.5 * 1024 * 1024)).toBe('3.5 MB')
})
})
+25 -8
View File
@@ -1,24 +1,41 @@
const displayDate = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
const dateFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
const dateTimeFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
const dayFormat = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
const longDate = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
const weekLabel = new Intl.DateTimeFormat('en-US', { month: 'short', day: '2-digit', timeZone: 'UTC' })
/** Formats an ISO calendar date (`2024-08-28`) as `Aug 28, 2024`. */
export function formatDate(isoDate: string): string {
return displayDate.format(new Date(`${isoDate}T00:00:00Z`))
}
/** `2026-10-06T14:00:00Z` → `Oct 6, 2026` in the viewer's time zone. */
export const formatDate = (iso: string) => dateFormat.format(new Date(iso))
export const formatDateTime = (iso: string) => dateTimeFormat.format(new Date(iso))
/** Calendar date without a time (`2026-10-06`) → `Oct 6, 2026`, never shifted by time zone. */
export const formatDay = (isoDate: string) => dayFormat.format(new Date(`${isoDate}T00:00:00Z`))
export const formatWeek = (isoDate: string) => weekLabel.format(new Date(`${isoDate}T00:00:00Z`))
/** Today's date in the viewer's local time zone as an ISO calendar date. */
export function toIsoDate(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
export function formatLongDate(date: Date): string {
return longDate.format(date)
/** Value for an `<input type="datetime-local">` in local time. */
export function toDateTimeLocal(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0')
return `${toIsoDate(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
export const formatLongDate = (date: Date) => longDate.format(date)
export function greeting(date: Date): string {
const hour = date.getHours()
if (hour < 12) return 'Good morning'
if (hour < 18) return 'Good afternoon'
return 'Good evening'
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
-41
View File
@@ -1,41 +0,0 @@
import { describe, expect, it } from 'vitest'
import { ALL_TYPES, createIncident, filterIncidents, nextIncidentId } from './incidents'
import { seedIncidents } from './seed'
describe('filterIncidents', () => {
it('returns everything with an empty query and no type filter', () => {
expect(filterIncidents(seedIncidents, '', ALL_TYPES)).toHaveLength(seedIncidents.length)
})
it('matches title, id and location case-insensitively', () => {
expect(filterIncidents(seedIncidents, 'FORKLIFT', ALL_TYPES).map((i) => i.id)).toEqual(['INC-2408'])
expect(filterIncidents(seedIncidents, 'inc-2405', ALL_TYPES).map((i) => i.id)).toEqual(['INC-2405'])
expect(filterIncidents(seedIncidents, 'line 3', ALL_TYPES).map((i) => i.id)).toEqual(['INC-2406'])
})
it('filters by type', () => {
expect(filterIncidents(seedIncidents, '', 'Hazard').map((i) => i.id)).toEqual(['INC-2407'])
})
})
describe('nextIncidentId', () => {
it('increments the highest existing id', () => {
expect(nextIncidentId(seedIncidents)).toBe('INC-2409')
})
it('starts at INC-0001 when there are no incidents', () => {
expect(nextIncidentId([])).toBe('INC-0001')
})
})
describe('createIncident', () => {
it('assigns id, status, date and reporter', () => {
const incident = createIncident(
{ title: 'Spill', type: 'Hazard', severity: 'Low', building: 'Building A', location: 'Dock' },
seedIncidents,
'Alex Kim',
new Date(2026, 9, 6, 9, 30),
)
expect(incident).toMatchObject({ id: 'INC-2409', status: 'New report', date: '2026-10-06', reporter: 'Alex Kim' })
})
})
-47
View File
@@ -1,47 +0,0 @@
import { toIsoDate } from './dates'
import type { Tone } from './tones'
import type { Incident, IncidentStatus, IncidentType, NewIncident, Severity } from './types'
export const ALL_TYPES = 'All incidents'
export type TypeFilter = IncidentType | typeof ALL_TYPES
export function filterIncidents(incidents: Incident[], query: string, type: TypeFilter): Incident[] {
const needle = query.trim().toLowerCase()
return incidents.filter(
(incident) =>
`${incident.title} ${incident.id} ${incident.location}`.toLowerCase().includes(needle) &&
(type === ALL_TYPES || incident.type === type),
)
}
/** Next sequential ID after the highest existing `INC-####`. Temporary until the backend assigns IDs. */
export function nextIncidentId(incidents: Incident[]): string {
const highest = incidents.reduce((max, { id }) => Math.max(max, Number(id.replace(/^INC-/, '')) || 0), 0)
return `INC-${String(highest + 1).padStart(4, '0')}`
}
export const isOpen = (incident: Incident) => incident.status !== 'Resolved'
export const typeTone: Record<IncidentType, Tone> = {
'Near miss': 'amber',
Hazard: 'sky',
Injury: 'coral',
'Property damage': 'mint',
}
export const severityDotClass: Record<Severity, string> = {
High: 'bg-coral-strong',
Medium: 'bg-[#d8a631]',
Low: 'bg-[#52a57e]',
}
export function statusClass(status: IncidentStatus): string {
if (status === 'Resolved') return 'bg-mint-soft text-[#398461]'
if (status === 'Review required') return 'bg-coral-soft text-[#c75e4b]'
return 'bg-[#fff0d2] text-[#9a7319]'
}
/** Builds a full incident record from a submitted report. */
export function createIncident(draft: NewIncident, existing: Incident[], reporter: string, now: Date): Incident {
return { ...draft, id: nextIncidentId(existing), status: 'New report', date: toIsoDate(now), reporter }
}
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { initials } from './initials'
describe('initials', () => {
it('takes the first letters of the first two words', () => {
expect(initials('Jordan Lee')).toBe('JL')
expect(initials('Northstar Co.')).toBe('NC')
expect(initials('Admin')).toBe('AD')
expect(initials(' ')).toBe('?')
})
})
+5
View File
@@ -0,0 +1,5 @@
/** "Jordan Lee" → "JL", "Northstar Co." → "NC". */
export function initials(name: string): string {
const words = name.split(/\s+/).filter((word) => /[a-z0-9]/i.test(word))
return (words.length > 1 ? words[0][0] + words[1][0] : (words[0] ?? '?').slice(0, 2)).toUpperCase()
}
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { describeTimelineEntry } from './labels'
const entry = (action: string, changes: Record<string, unknown> | null = null) => ({ id: 1, at: '', actor: null, action, changes })
describe('describeTimelineEntry', () => {
it('describes workflow events in plain language', () => {
expect(describeTimelineEntry(entry('create'))).toBe('reported the incident')
expect(describeTimelineEntry(entry('status', { status: ['triage', 'investigation'] }))).toBe('moved it from Triage to Investigation')
expect(describeTimelineEntry(entry('update', { severity: ['high', 'low'], locationDetail: [null, 'Dock'] }))).toBe('edited the severity, location')
expect(describeTimelineEntry(entry('action.update', { actionId: 'x', title: 'Fix guard', status: ['open', 'done'] }))).toBe('marked “Fix guard” done')
expect(describeTimelineEntry(entry('attachment.add', { fileName: 'spill.jpg' }))).toBe('attached spill.jpg')
})
})
+134
View File
@@ -0,0 +1,134 @@
import type { ActionStatus, RcaMethod, RootCauseCategory, TimelineEntry } from '../../shared/api-types.ts'
import type { AreaKind, IncidentStatus, IncidentType, Role, Severity } from '../../shared/enums.ts'
import type { Tone } from './tones'
export const incidentTypeLabel: Record<IncidentType, string> = {
near_miss: 'Near miss',
hazard: 'Hazard',
injury: 'Injury',
illness: 'Illness',
property_damage: 'Property damage',
environmental: 'Environmental',
}
export const incidentTypeTone: Record<IncidentType, Tone> = {
near_miss: 'amber',
hazard: 'sky',
injury: 'coral',
illness: 'coral',
property_damage: 'mint',
environmental: 'mint',
}
export const severityLabel: Record<Severity, string> = { low: 'Low', medium: 'Medium', high: 'High', critical: 'Critical' }
export const severityDotClass: Record<Severity, string> = {
low: 'bg-[#52a57e]',
medium: 'bg-[#d8a631]',
high: 'bg-coral-strong',
critical: 'bg-[#9b2c1f]',
}
export const statusLabel: Record<IncidentStatus, string> = {
reported: 'Reported',
triage: 'Triage',
investigation: 'Investigation',
actions: 'Corrective actions',
verification: 'Verification',
closed: 'Closed',
}
export const statusClass: Record<IncidentStatus, string> = {
reported: 'bg-coral-soft text-[#c75e4b]',
triage: 'bg-[#fff0d2] text-[#9a7319]',
investigation: 'bg-[#fff0d2] text-[#9a7319]',
actions: 'bg-sky-soft text-sky-strong',
verification: 'bg-sky-soft text-sky-strong',
closed: 'bg-mint-soft text-[#398461]',
}
export const roleLabel: Record<Role, string> = { reporter: 'Reporter', supervisor: 'Supervisor', safety_manager: 'Safety manager', admin: 'Admin' }
export const actionStatusLabel: Record<ActionStatus, string> = { open: 'Open', in_progress: 'In progress', done: 'Done', verified: 'Verified', cancelled: 'Cancelled' }
export const actionStatusClass: Record<ActionStatus, string> = {
open: 'bg-[#fff0d2] text-[#9a7319]',
in_progress: 'bg-sky-soft text-sky-strong',
done: 'bg-mint-soft text-[#398461]',
verified: 'bg-mint-soft text-[#2b6e4f]',
cancelled: 'bg-line-soft text-muted',
}
export const rootCauseCategoryLabel: Record<RootCauseCategory, string> = {
people: 'People',
process: 'Methods & process',
equipment: 'Machines & equipment',
materials: 'Materials',
environment: 'Environment',
management: 'Management',
}
export const rcaMethodLabel: Record<RcaMethod, string> = { five_whys: '5 Whys', fishbone: 'Fishbone (6M)', other: 'Other' }
export const areaKindLabel: Record<AreaKind, string> = { building: 'Building', area: 'Area', line: 'Line', other: 'Other' }
const fieldLabel: Record<string, string> = {
title: 'title',
type: 'type',
severity: 'severity',
occurredAt: 'time of occurrence',
siteId: 'site',
areaId: 'area',
locationDetail: 'location',
description: 'description',
immediateAction: 'immediate action',
leadId: 'lead',
method: 'method',
summary: 'summary',
completedAt: 'completion',
status: 'status',
assigneeId: 'assignee',
dueDate: 'due date',
}
const fields = (changes: Record<string, unknown> | null, skip: string[] = []) =>
Object.keys(changes ?? {})
.filter((key) => !skip.includes(key))
.map((key) => fieldLabel[key] ?? key)
.join(', ')
/** Human sentence for a timeline entry, e.g. "moved it from Triage to Investigation". */
export function describeTimelineEntry({ action, changes }: TimelineEntry): string {
const c = (changes ?? {}) as Record<string, unknown>
switch (action) {
case 'create':
return 'reported the incident'
case 'status': {
const [from, to] = c.status as [IncidentStatus, IncidentStatus]
return `moved it from ${statusLabel[from]} to ${statusLabel[to]}`
}
case 'update':
return `edited the ${fields(changes)}`
case 'investigation.update':
return c.completedAt && (c.completedAt as unknown[])[1] ? 'marked the investigation complete' : `updated the investigation ${fields(changes)}`
case 'root_cause.add':
return `added root cause “${c.description}”`
case 'root_cause.update':
return 'edited a root cause'
case 'root_cause.remove':
return `removed root cause “${c.description}”`
case 'action.add':
return `added corrective action “${c.title}”`
case 'action.update': {
const status = c.status as [ActionStatus, ActionStatus] | undefined
if (status) return `marked “${c.title}” ${actionStatusLabel[status[1]].toLowerCase()}`
return `updated the ${fields(changes, ['actionId', 'title', 'completedAt', 'verifiedAt', 'verifiedById'])} of “${c.title}”`
}
case 'attachment.add':
return `attached ${c.fileName}`
case 'attachment.remove':
return `removed ${c.fileName}`
default:
return action
}
}
+16 -8
View File
@@ -1,4 +1,4 @@
import { BarChart3, ClipboardList, LayoutDashboard, MapPin, type LucideIcon } from 'lucide-react'
import { BarChart3, ClipboardCheck, ClipboardList, LayoutDashboard, MapPin, type LucideIcon } from 'lucide-react'
export interface NavItem {
label: string
@@ -9,15 +9,23 @@ export interface NavItem {
export const primaryNav: NavItem[] = [
{ label: 'Overview', to: '/', icon: LayoutDashboard },
{ label: 'Incidents', to: '/incidents', icon: ClipboardList },
{ label: 'My actions', to: '/actions', icon: ClipboardCheck },
{ label: 'Locations', to: '/locations', icon: MapPin },
{ label: 'Analytics', to: '/analytics', icon: BarChart3 },
]
export const pageTitles: Record<string, string> = {
'/': 'Overview',
'/incidents': 'Incidents',
'/locations': 'Locations',
'/analytics': 'Analytics',
'/report': 'Report an incident',
'/about': 'About',
export function pageTitle(pathname: string): string {
const incident = /^\/incidents\/(\d+)/.exec(pathname)
if (incident) return `INC-${incident[1].padStart(4, '0')}`
const titles: Record<string, string> = {
'/': 'Overview',
'/incidents': 'Incidents',
'/actions': 'My actions',
'/locations': 'Locations',
'/analytics': 'Analytics',
'/report': 'Report an incident',
'/settings': 'Settings',
'/about': 'About',
}
return titles[pathname] ?? 'Not found'
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import type { CorrectiveAction } from '../../shared/api-types.ts'
import { statusOptions } from './permissions'
const action = (overrides: Partial<CorrectiveAction> = {}) => ({ status: 'open', assignee: { id: 'a', name: 'A' }, ...overrides }) as CorrectiveAction
describe('statusOptions', () => {
it('lets assignees report progress but not verify', () => {
expect(statusOptions(action(), { id: 'a', role: 'reporter' })).toEqual(['open', 'in_progress', 'done'])
})
it('gives other reporters nothing', () => {
expect(statusOptions(action(), { id: 'b', role: 'reporter' })).toEqual([])
})
it('reserves verification for safety managers', () => {
expect(statusOptions(action(), { id: 'b', role: 'supervisor' })).not.toContain('verified')
expect(statusOptions(action(), { id: 'b', role: 'safety_manager' })).toContain('verified')
expect(statusOptions(action({ status: 'verified' }), { id: 'b', role: 'supervisor' })).toEqual([])
})
})
+10
View File
@@ -0,0 +1,10 @@
import type { ActionStatus, CorrectiveAction, User } from '../../shared/api-types.ts'
import { actionStatuses, hasRole } from '../../shared/enums.ts'
/** Statuses `user` may set on `action`, mirroring the API's rules. Empty means read-only. */
export function statusOptions(action: CorrectiveAction, user: Pick<User, 'id' | 'role'>): ActionStatus[] {
if (hasRole(user.role, 'safety_manager')) return [...actionStatuses]
if (hasRole(user.role, 'supervisor')) return action.status === 'verified' ? [] : actionStatuses.filter((status) => status !== 'verified')
if (action.assignee?.id === user.id && action.status !== 'verified' && action.status !== 'cancelled') return ['open', 'in_progress', 'done']
return []
}
+224
View File
@@ -0,0 +1,224 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
import type {
AnalyticsStats,
Attachment,
Comment,
CorrectiveAction,
Incident,
IncidentDetail,
Organization,
OverviewStats,
Paginated,
Site,
TimelineEntry,
User,
UserRef,
} from '../../shared/api-types.ts'
import type { IncidentStatus, Role } from '../../shared/enums.ts'
import type { CreateActionInput, CreateIncidentInput, InvestigationInput, RootCauseInput, UpdateActionInput, UpdateIncidentInput, UpdateRootCauseInput } from '../../shared/schemas.ts'
import { api, ApiError } from './api'
// ---- Session ----
export interface Me {
user: User
organization: Organization
}
export function useMe() {
return useQuery({
queryKey: ['me'],
queryFn: async () => {
try {
return await api<Me>('/auth/me')
} catch (error) {
if (error instanceof ApiError && error.status === 401) return null
throw error
}
},
staleTime: 5 * 60_000,
})
}
export const useSetupStatus = () => useQuery({ queryKey: ['setup'], queryFn: () => api<{ needsSetup: boolean }>('/setup') })
/** After sign-in/out everything cached belongs to someone else. */
function resetSession(client: QueryClient) {
client.removeQueries({ predicate: (query) => query.queryKey[0] !== 'me' })
return client.invalidateQueries({ queryKey: ['me'] })
}
export function useSignIn<TInput>(path: string) {
const client = useQueryClient()
return useMutation({
mutationFn: (input: TInput) => api<{ user: User }>(path, { method: 'POST', body: input }),
onSuccess: () => resetSession(client),
})
}
export function useLogout() {
const client = useQueryClient()
return useMutation({
mutationFn: () => api('/auth/logout', { method: 'POST' }),
onSuccess: () => {
client.clear()
client.setQueryData(['me'], null)
},
})
}
export const useRequestEmail = (path: '/auth/magic-link' | '/auth/password-reset') =>
useMutation({ mutationFn: (email: string) => api(path, { method: 'POST', body: { email } }) })
export const useChangePassword = () =>
useMutation({ mutationFn: (input: { currentPassword: string; newPassword: string }) => api('/auth/password', { method: 'POST', body: input }) })
// ---- Locations ----
export const useSites = (includeArchived = false) =>
useQuery({ queryKey: ['sites', includeArchived], queryFn: () => api<{ sites: Site[] }>(`/sites${includeArchived ? '?includeArchived=true' : ''}`).then((r) => r.sites) })
export function useSiteMutations() {
const client = useQueryClient()
const done = () => client.invalidateQueries({ queryKey: ['sites'] })
return {
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 }),
createArea: useMutation({
mutationFn: ({ siteId, ...input }: { siteId: string; name: string; kind: string; parentId?: string | null }) => api(`/sites/${siteId}/areas`, { method: 'POST', body: input }),
onSuccess: done,
}),
updateArea: useMutation({ mutationFn: ({ id, ...input }: { id: string; name?: string; archived?: boolean }) => api(`/areas/${id}`, { method: 'PATCH', body: input }), onSuccess: done }),
}
}
// ---- Incidents ----
export interface IncidentFilters {
page?: number
pageSize?: number
q?: string
type?: string
severity?: string
status?: string
siteId?: string
}
export function filtersToQuery(filters: IncidentFilters): string {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(filters)) if (value !== undefined && value !== '') params.set(key, String(value))
const text = params.toString()
return text ? `?${text}` : ''
}
export const useIncidents = (filters: IncidentFilters) =>
useQuery({ queryKey: ['incidents', filters], queryFn: () => api<Paginated<Incident>>(`/incidents${filtersToQuery(filters)}`), placeholderData: keepPreviousData })
export const useIncident = (number: number) =>
useQuery({ queryKey: ['incident', number], queryFn: () => api<{ incident: IncidentDetail }>(`/incidents/${number}`).then((r) => r.incident) })
export const useTimeline = (number: number) =>
useQuery({ queryKey: ['timeline', number], queryFn: () => api<{ timeline: TimelineEntry[] }>(`/incidents/${number}/timeline`).then((r) => r.timeline) })
export const useComments = (number: number) =>
useQuery({ queryKey: ['comments', number], queryFn: () => api<{ comments: Comment[] }>(`/incidents/${number}/comments`).then((r) => r.comments) })
export function useCreateIncident() {
const client = useQueryClient()
return useMutation({
mutationFn: (input: CreateIncidentInput) => api<{ incident: Incident }>('/incidents', { method: 'POST', body: input }).then((r) => r.incident),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['incidents'] })
client.invalidateQueries({ queryKey: ['stats'] })
},
})
}
export function uploadAttachment(number: number, file: File) {
const form = new FormData()
form.append('file', file)
return api<{ attachment: Attachment }>(`/incidents/${number}/attachments`, { method: 'POST', body: form }).then((r) => r.attachment)
}
/** Mutations on one incident. Each refreshes the incident, its timeline and dependent lists. */
export function useIncidentMutations(number: number) {
const client = useQueryClient()
const refresh = (incident?: IncidentDetail) => {
if (incident) client.setQueryData(['incident', number], incident)
else client.invalidateQueries({ queryKey: ['incident', number] })
for (const key of ['timeline', 'comments']) client.invalidateQueries({ queryKey: [key, number] })
for (const key of ['incidents', 'actions', 'stats']) client.invalidateQueries({ queryKey: [key] })
}
const detail = (promise: Promise<{ incident: IncidentDetail }>) => promise.then((r) => r.incident)
const base = `/incidents/${number}`
return {
update: useMutation({ mutationFn: (input: UpdateIncidentInput) => detail(api(base, { method: 'PATCH', body: input })), onSuccess: refresh }),
transition: useMutation({
mutationFn: (input: { to: IncidentStatus; note?: string }) => detail(api(`${base}/transitions`, { method: 'POST', body: input })),
onSuccess: refresh,
}),
comment: useMutation({ mutationFn: (body: string) => api(`${base}/comments`, { method: 'POST', body: { body } }), onSuccess: () => refresh() }),
investigation: useMutation({ mutationFn: (input: InvestigationInput) => detail(api(`${base}/investigation`, { method: 'PUT', body: input })), onSuccess: refresh }),
addRootCause: useMutation({ mutationFn: (input: RootCauseInput) => detail(api(`${base}/root-causes`, { method: 'POST', body: input })), onSuccess: refresh }),
updateRootCause: useMutation({
mutationFn: ({ id, ...input }: UpdateRootCauseInput & { id: string }) => detail(api(`/root-causes/${id}`, { method: 'PATCH', body: input })),
onSuccess: refresh,
}),
removeRootCause: useMutation({ mutationFn: (id: string) => detail(api(`/root-causes/${id}`, { method: 'DELETE' })), onSuccess: refresh }),
addAction: useMutation({ mutationFn: (input: CreateActionInput) => detail(api(`${base}/actions`, { method: 'POST', body: input })), onSuccess: refresh }),
updateAction: useMutation({
mutationFn: ({ id, ...input }: UpdateActionInput & { id: string }) => api(`/actions/${id}`, { method: 'PATCH', body: input }),
onSuccess: () => refresh(),
}),
upload: useMutation({ mutationFn: (file: File) => uploadAttachment(number, file), onSuccess: () => refresh() }),
removeAttachment: useMutation({ mutationFn: (id: string) => api(`/attachments/${id}`, { method: 'DELETE' }), onSuccess: () => refresh() }),
}
}
// ---- Actions ----
export const useActions = (scope: 'mine' | 'all', filter: 'open' | 'overdue' | 'done' | 'all', enabled = true) =>
useQuery({ queryKey: ['actions', scope, filter], enabled, queryFn: () => api<{ actions: CorrectiveAction[] }>(`/actions?scope=${scope}&filter=${filter}`).then((r) => r.actions) })
export function useUpdateAction() {
const client = useQueryClient()
return useMutation({
mutationFn: ({ id, ...input }: UpdateActionInput & { id: string }) => api<{ action: CorrectiveAction }>(`/actions/${id}`, { method: 'PATCH', body: input }),
onSuccess: ({ action }) => {
for (const key of ['actions', 'stats']) client.invalidateQueries({ queryKey: [key] })
client.invalidateQueries({ queryKey: ['incident', action.incident.number] })
client.invalidateQueries({ queryKey: ['timeline', action.incident.number] })
},
})
}
// ---- Stats ----
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}`) })
// ---- Users ----
export const useUsers = (enabled = true) => useQuery({ queryKey: ['users'], queryFn: () => api<{ users: User[] }>('/users').then((r) => r.users), enabled })
export const useAssignableUsers = (enabled = true) =>
useQuery({ queryKey: ['assignable'], queryFn: () => api<{ users: (UserRef & { role: Role })[] }>('/users/assignable').then((r) => r.users), enabled, staleTime: 60_000 })
export function useUserMutations() {
const client = useQueryClient()
const done = () => {
client.invalidateQueries({ queryKey: ['users'] })
client.invalidateQueries({ queryKey: ['assignable'] })
}
return {
create: useMutation({
mutationFn: (input: { name: string; email: string; role: Role; password?: string }) => api<{ user: User; invited: boolean }>('/users', { method: 'POST', body: input }),
onSuccess: done,
}),
update: useMutation({
mutationFn: ({ id, ...input }: { id: string; name?: string; role?: Role; active?: boolean; password?: string }) => api(`/users/${id}`, { method: 'PATCH', body: input }),
onSuccess: done,
}),
}
}
-20
View File
@@ -1,20 +0,0 @@
import type { Incident, Person, Site } from './types'
// Demo data for the UI prototype. Replaced by the API in Phase 1.
export const currentUser: Person = { name: 'Jordan Lee', initials: 'JL', role: 'Safety manager' }
export const workspace = { name: 'Northstar Co.', initials: 'NC' }
export const seedIncidents: Incident[] = [
{ id: 'INC-2408', title: 'Forklift reversed into pallet stack', type: 'Near miss', severity: 'High', status: 'Investigation', building: 'Warehouse 04', location: 'Loading bay C', date: '2024-08-28', reporter: 'Jordan Lee' },
{ id: 'INC-2407', title: 'Wet floor near employee entrance', type: 'Hazard', severity: 'Medium', status: 'Action open', building: 'Building A', location: 'Ground floor lobby', date: '2024-08-27', reporter: 'Maya Chen' },
{ id: 'INC-2406', title: 'Hand laceration during material handling', type: 'Injury', severity: 'High', status: 'Review required', building: 'Fabrication 02', location: 'Line 3', date: '2024-08-26', reporter: 'Sam Rivera' },
{ id: 'INC-2405', title: 'Damaged guardrail on mezzanine', type: 'Property damage', severity: 'Low', status: 'Resolved', building: 'Warehouse 01', location: 'Mezzanine east', date: '2024-08-24', reporter: 'Priya Shah' },
]
export const sites: Site[] = [
{ name: 'North Campus', buildings: ['Building A', 'Warehouse 04', 'Lab 2'], incidentCount: 12 },
{ name: 'South Campus', buildings: ['Fabrication 02', 'Yard', 'Office block'], incidentCount: 7 },
{ name: 'West Campus', buildings: ['Warehouse 01', 'Loading dock'], incidentCount: 5 },
]
-38
View File
@@ -1,38 +0,0 @@
export const incidentTypes = ['Near miss', 'Hazard', 'Injury', 'Property damage'] as const
export type IncidentType = (typeof incidentTypes)[number]
export const severities = ['Low', 'Medium', 'High'] as const
export type Severity = (typeof severities)[number]
export type IncidentStatus = 'New report' | 'Investigation' | 'Action open' | 'Review required' | 'Resolved'
export interface Incident {
id: string
title: string
type: IncidentType
severity: Severity
status: IncidentStatus
site?: string
building: string
location: string
/** ISO calendar date, e.g. `2024-08-28` */
date: string
reporter: string
details?: string
cause?: string
response?: string
}
export type NewIncident = Omit<Incident, 'id' | 'status' | 'date' | 'reporter'>
export interface Site {
name: string
buildings: string[]
incidentCount: number
}
export interface Person {
name: string
initials: string
role: string
}
+13 -3
View File
@@ -3,15 +3,25 @@ import '@fontsource-variable/space-grotesk'
import '@fontsource/dm-mono/400.css'
import '@fontsource/dm-mono/500.css'
import './index.css'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import { ApiError } from './lib/api'
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, retry: (count, error) => count < 2 && !(error instanceof ApiError && error.status < 500) },
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
)
+94
View File
@@ -0,0 +1,94 @@
import { Link, useOutletContext, useSearchParams } from 'react-router-dom'
import { hasRole } from '../../shared/enums.ts'
import { OverdueBadge } from '../components/Badges'
import { ActionStatusControl } from '../components/incident/ActionsSection'
import { Page } from '../components/Page'
import { PageIntro } from '../components/PageIntro'
import { Empty, ErrorMessage, Loading } from '../components/QueryState'
import { panel } from '../components/styles'
import { formatDay } from '../lib/dates'
import { useActions, useUpdateAction, type Me } from '../lib/queries'
const filters = [
{ value: 'open', label: 'Open' },
{ value: 'overdue', label: 'Overdue' },
{ value: 'done', label: 'Done' },
{ value: 'all', label: 'All' },
] 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'}`
export function Actions() {
const me = useOutletContext<Me>()
const [params, setParams] = useSearchParams()
const canSeeAll = hasRole(me.user.role, 'supervisor')
const scope = canSeeAll && params.get('scope') === 'all' ? 'all' : 'mine'
const filter = filters.find((f) => f.value === params.get('filter'))?.value ?? 'open'
const actions = useActions(scope, filter)
const update = useUpdateAction()
const set = (key: string, value: string) => {
const next = new URLSearchParams(params)
next.set(key, value)
setParams(next, { replace: true })
}
return (
<Page>
<PageIntro
eyebrow="Corrective actions"
title={scope === 'mine' ? 'My actions' : 'All actions'}
copy={scope === 'mine' ? 'Corrective actions assigned to you. Mark them done when the fix is in place.' : 'Every corrective action across the organization.'}
/>
<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">
{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)}>
{f.label}
</button>
))}
</div>
{canSeeAll && (
<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')}>
Assigned to me
</button>
<button type="button" role="tab" aria-selected={scope === 'all'} className={tab(scope === 'all')} onClick={() => set('scope', 'all')}>
Everyone
</button>
</div>
)}
</div>
<section className={panel}>
<ErrorMessage error={actions.error ?? update.error} className="m-4" />
{actions.isPending ? (
<Loading />
) : !actions.data?.length ? (
<Empty>{filter === 'open' ? 'Nothing open. Nice work.' : 'No actions here.'}</Empty>
) : (
<ul className="divide-y divide-line-soft">
{actions.data.map((action) => (
<li key={action.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-5 py-4 sm:px-6">
<div className="min-w-[240px] flex-1">
<p className="text-xs font-semibold text-ink">{action.title}</p>
<p className="mt-1 text-3xs text-muted">
<Link to={`/incidents/${action.incident.number}`} className="font-mono text-coral hover:underline">
{action.incident.reference}
</Link>{' '}
{action.incident.title}
{scope === 'all' && ` · ${action.assignee?.name ?? 'Unassigned'}`}
</p>
</div>
<div className="flex items-center gap-3 text-2xs text-body">
{action.overdue && <OverdueBadge />}
<span className="w-[100px] text-right">{action.dueDate ? `Due ${formatDay(action.dueDate)}` : 'No due date'}</span>
<ActionStatusControl action={action} disabled={update.isPending} onChange={(status) => update.mutate({ id: action.id, status })} />
</div>
</li>
))}
</ul>
)}
</section>
</Page>
)
}
+96 -33
View File
@@ -1,49 +1,112 @@
import { ArrowUpRight, Flame } from 'lucide-react'
import { Flame } from 'lucide-react'
import { useState } from 'react'
import { incidentTypes } from '../../shared/enums.ts'
import { Eyebrow } from '../components/Eyebrow'
import { Page } from '../components/Page'
import { PageIntro } from '../components/PageIntro'
import { ProgressBar } from '../components/ProgressRow'
import { lightButton, panel } from '../components/styles'
import { ErrorMessage, Loading } from '../components/QueryState'
import { filterBox, panel } from '../components/styles'
import { incidentTypeLabel, rootCauseCategoryLabel } from '../lib/labels'
import { useAnalytics } from '../lib/queries'
const card = 'flex min-h-[240px] flex-col items-start p-[26px]'
const bigStat = 'mt-8 mb-[25px] font-display text-[25px] font-semibold'
const responseTrend = [38, 54, 45, 72, 86, 94]
const pct = (value: number) => `${Math.round(value * 100)}%`
// Placeholder insights until analytics are computed from real data (Phase 5)
export function Analytics() {
const [days, setDays] = useState(90)
const stats = useAnalytics(days)
const s = stats.data
const topType = s?.byType[0]
return (
<Page>
<PageIntro eyebrow="Program intelligence" title="Analytics" copy="Patterns that help your team prevent the next incident." />
<div className="grid gap-5 lg:grid-cols-[1.3fr_1fr_1fr]">
<div className={`${card} rounded-lg border border-forest-900 bg-forest-900 text-[#dfece5] shadow-[0_5px_20px_#1b252305]`}>
<Flame size={22} />
<Eyebrow className="mt-4 text-[#9dc0b3]">Top signal</Eyebrow>
<h2 className="mt-5 mb-2.5 font-display text-2xl leading-[1.15] font-semibold">Material handling is your highest-risk activity.</h2>
<p className="mb-4 text-xs leading-[1.6] text-[#a7c0b8]">42% of incidents in the last 90 days relate to movement or storage of materials.</p>
<button type="button" className={`${lightButton} mt-auto`}>
View contributing incidents <ArrowUpRight size={16} />
</button>
</div>
<div className={`${panel} ${card}`}>
<Eyebrow>Most common root cause</Eyebrow>
<strong className={bigStat}>Inadequate guarding</strong>
<div className="flex w-full justify-between text-2xs text-muted">
<span>8 incidents</span>
<span>31% of total</span>
<PageIntro
eyebrow="Program intelligence"
title="Analytics"
copy="Patterns that help your team prevent the next incident."
action={
<div className={filterBox}>
<select className="bg-transparent text-xs text-ink outline-none" value={days} onChange={(event) => setDays(Number(event.target.value))} aria-label="Period">
<option value={30}>Last 30 days</option>
<option value={90}>Last 90 days</option>
<option value={180}>Last 6 months</option>
<option value={365}>Last 12 months</option>
</select>
</div>
<ProgressBar percent={31} className="mt-2.5 w-full" />
</div>
<div className={`${panel} ${card}`}>
<Eyebrow>Response quality</Eyebrow>
<strong className={bigStat}>94%</strong>
<p className="text-xs leading-[1.6] text-muted">of corrective actions are completed on time.</p>
<div className="mt-auto flex h-[70px] w-full items-end gap-[7px] pt-4">
{responseTrend.map((height, index) => (
<i key={index} className="min-h-[15px] flex-1 rounded-t-[3px] bg-[#76a99d]" style={{ height: `${height}%` }} />
))}
}
/>
<ErrorMessage error={stats.error} className="mb-5" />
{!s ? (
<Loading />
) : (
<>
<div className="mb-5 grid gap-5 lg:grid-cols-[1.3fr_1fr_1fr]">
<div className={`${card} rounded-lg border border-forest-900 bg-forest-900 text-[#dfece5] shadow-[0_5px_20px_#1b252305]`}>
<Flame size={22} />
<Eyebrow className="mt-4 text-[#9dc0b3]">Most common</Eyebrow>
{topType ? (
<>
<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>
<p className="text-xs leading-[1.6] text-[#a7c0b8]">
{topType.count} of {s.total} reports ({pct(topType.count / s.total)}) in this period.
</p>
</>
) : (
<h2 className="mt-5 font-display text-2xl leading-[1.15] font-semibold">No reports in this period.</h2>
)}
</div>
<div className={`${panel} ${card}`}>
<Eyebrow>Most common root cause</Eyebrow>
{s.topRootCause ? (
<>
<strong className={bigStat}>{rootCauseCategoryLabel[s.topRootCause.category]}</strong>
<div className="flex w-full justify-between text-2xs text-muted">
<span>
{s.topRootCause.count} root cause{s.topRootCause.count === 1 ? '' : 's'}
</span>
<span>{pct(s.topRootCause.share)} of total</span>
</div>
<ProgressBar percent={s.topRootCause.share * 100} className="mt-2.5 w-full" />
</>
) : (
<p className="mt-8 text-xs text-muted">No root causes recorded in this period yet.</p>
)}
</div>
<div className={`${panel} ${card}`}>
<Eyebrow>Actions completed on time</Eyebrow>
<strong className={bigStat}>{s.onTimeActionRate === null ? '–' : pct(s.onTimeActionRate)}</strong>
<p className="text-xs leading-[1.6] text-muted">of corrective actions with a due date were done by then.</p>
{s.monthlyCompletedActions.length > 0 && (
<div className="mt-auto flex h-[70px] w-full items-end gap-[7px] pt-4" aria-label="On-time share by month">
{s.monthlyCompletedActions.map((m) => {
const total = m.onTime + m.late
return <i key={m.month} title={`${m.month}: ${m.onTime} on time, ${m.late} late`} className="min-h-[6px] flex-1 rounded-t-[3px] bg-[#76a99d]" style={{ height: `${(m.onTime / total) * 100}%` }} />
})}
</div>
)}
</div>
</div>
</div>
</div>
<section className={`${panel} p-[26px]`}>
<Eyebrow>Reports by type</Eyebrow>
<ul className="mt-4 space-y-3">
{incidentTypes.map((type) => {
const count = s.byType.find((t) => t.type === type)?.count ?? 0
return (
<li key={type}>
<div className="flex justify-between text-2xs text-body">
<span>{incidentTypeLabel[type]}</span>
<strong className="font-mono font-normal text-ink">{count}</strong>
</div>
<ProgressBar percent={s.total ? (count / s.total) * 100 : 0} barClassName="bg-sage" />
</li>
)
})}
</ul>
</section>
</>
)}
</Page>
)
}
+189
View File
@@ -0,0 +1,189 @@
import { CheckCircle2 } from 'lucide-react'
import { useState, type ChangeEvent, type FormEvent } from 'react'
import { Link, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { AuthLayout } from '../components/AuthLayout'
import { Field } from '../components/FormSection'
import { ErrorMessage, Loading } from '../components/QueryState'
import { field, primaryButton, textButton } from '../components/styles'
import { useMe, useRequestEmail, useSetupStatus, useSignIn } from '../lib/queries'
const fullWidth = `${primaryButton} w-full`
function Sent({ children }: { children: string }) {
return (
<div className="flex items-start gap-2 rounded-[5px] bg-mint-soft px-3 py-2.5 text-xs text-[#2b6e4f]" role="status">
<CheckCircle2 size={15} className="mt-px flex-none" />
<span>{children}</span>
</div>
)
}
export function Login() {
const location = useLocation()
const me = useMe()
const setup = useSetupStatus()
const [mode, setMode] = useState<'password' | 'link' | 'reset'>('password')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const signIn = useSignIn<{ email: string; password: string }>('/auth/login')
const magicLink = useRequestEmail('/auth/magic-link')
const reset = useRequestEmail('/auth/password-reset')
const from = (location.state as { from?: string } | null)?.from ?? '/'
if (me.data) return <Navigate to={from} replace />
if (setup.data?.needsSetup) return <Navigate to="/setup" replace />
const emailRequest = mode === 'link' ? magicLink : reset
const submit = (event: FormEvent) => {
event.preventDefault()
// Once signed in, the session refresh re-renders this page, which redirects to `from`
if (mode === 'password') signIn.mutate({ email, password })
else emailRequest.mutate(email)
}
const switchMode = (next: typeof mode) => {
setMode(next)
signIn.reset()
magicLink.reset()
reset.reset()
}
return (
<AuthLayout
title={mode === 'reset' ? 'Reset your password' : 'Sign in'}
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.'}
>
<form onSubmit={submit} className="space-y-1">
<Field label="Email">
<input className={field} type="email" autoComplete="email" required value={email} onChange={(event) => setEmail(event.target.value)} />
</Field>
{mode === 'password' && (
<Field label="Password">
<input className={field} type="password" autoComplete="current-password" required value={password} onChange={(event) => setPassword(event.target.value)} />
</Field>
)}
<ErrorMessage error={signIn.error ?? emailRequest.error} className="mb-4" />
{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>
) : (
<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'}
</button>
)}
</form>
<div className="mt-5 flex flex-wrap justify-between gap-2">
{mode !== 'password' && (
<button type="button" className={textButton} onClick={() => switchMode('password')}>
Sign in with password
</button>
)}
{mode !== 'link' && (
<button type="button" className={textButton} onClick={() => switchMode('link')}>
Email me a sign-in link instead
</button>
)}
{mode !== 'reset' && (
<button type="button" className={textButton} onClick={() => switchMode('reset')}>
Forgot password?
</button>
)}
</div>
</AuthLayout>
)
}
export function Setup() {
const navigate = useNavigate()
const setup = useSetupStatus()
const [form, setForm] = useState({ organizationName: '', name: '', email: '', password: '' })
const create = useSignIn<typeof form>('/setup')
const update = (key: keyof typeof form) => (event: ChangeEvent<HTMLInputElement>) => setForm({ ...form, [key]: event.target.value })
if (setup.isPending) return <Loading />
if (setup.data && !setup.data.needsSetup && !create.isSuccess) return <Navigate to="/login" replace />
return (
<AuthLayout title="Set up your workspace" copy="Create your organization and the first administrator account. You can add sites and invite people next.">
<form
onSubmit={(event) => {
event.preventDefault()
create.mutate(form, { onSuccess: () => navigate('/locations', { replace: true }) })
}}
>
<Field label="Organization name">
<input className={field} required value={form.organizationName} onChange={update('organizationName')} placeholder="e.g. Northstar Manufacturing" />
</Field>
<Field label="Your name">
<input className={field} required autoComplete="name" value={form.name} onChange={update('name')} />
</Field>
<Field label="Email">
<input className={field} type="email" required autoComplete="email" value={form.email} onChange={update('email')} />
</Field>
<Field label="Password">
<input className={field} type="password" required minLength={10} autoComplete="new-password" value={form.password} onChange={update('password')} />
<span className="mt-1 block font-normal text-faint">At least 10 characters.</span>
</Field>
<ErrorMessage error={create.error} className="mb-4" />
<button className={fullWidth} type="submit" disabled={create.isPending}>
Create workspace
</button>
</form>
</AuthLayout>
)
}
export function MagicLinkLanding() {
const navigate = useNavigate()
const [params] = useSearchParams()
const verify = useSignIn<{ token: string }>('/auth/magic-link/verify')
const token = params.get('token') ?? ''
// A button rather than signing in on load, so email link scanners can't use up the link.
return (
<AuthLayout title="Sign in" copy="Continue to sign in with the link from your email.">
<ErrorMessage error={verify.error} className="mb-4" />
<button type="button" className={fullWidth} disabled={!token || verify.isPending} onClick={() => verify.mutate({ token }, { onSuccess: () => navigate('/', { replace: true }) })}>
Sign in
</button>
<Link to="/login" className={`${textButton} mt-5`}>
Back to sign in
</Link>
</AuthLayout>
)
}
export function ResetPassword() {
const navigate = useNavigate()
const [params] = useSearchParams()
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const reset = useSignIn<{ token: string; newPassword: string }>('/auth/password-reset/confirm')
const mismatch = confirm.length > 0 && password !== confirm
return (
<AuthLayout title="Choose a password" copy="Set a password for your account. You’ll be signed in afterwards.">
<form
onSubmit={(event) => {
event.preventDefault()
if (!mismatch) reset.mutate({ token: params.get('token') ?? '', newPassword: password }, { onSuccess: () => navigate('/', { replace: true }) })
}}
>
<Field label="New password">
<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>
</Field>
<Field label="Confirm password">
<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>}
</Field>
<ErrorMessage error={reset.error} className="mb-4" />
<button className={fullWidth} type="submit" disabled={reset.isPending || mismatch}>
Save password and sign in
</button>
</form>
<Link to="/login" className={`${textButton} mt-5`}>
Back to sign in
</Link>
</AuthLayout>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { ArrowLeft, CheckCircle2, MapPin, Printer } from 'lucide-react'
import { Link, useLocation, useParams } from 'react-router-dom'
import { SeverityLabel, StatusBadge } from '../components/Badges'
import { ActionsSection } from '../components/incident/ActionsSection'
import { ActivitySection } from '../components/incident/ActivitySection'
import { AttachmentsSection } from '../components/incident/AttachmentsSection'
import { DetailsSection } from '../components/incident/DetailsSection'
import { InvestigationSection } from '../components/incident/InvestigationSection'
import { WorkflowPanel } from '../components/incident/WorkflowPanel'
import { Page } from '../components/Page'
import { ErrorMessage, Loading } from '../components/QueryState'
import { secondaryButton } from '../components/styles'
import { ApiError } from '../lib/api'
import { formatDateTime } from '../lib/dates'
import { incidentTypeLabel } from '../lib/labels'
import { useIncident } from '../lib/queries'
export function IncidentDetail() {
const number = Number(useParams().number)
const location = useLocation()
const query = useIncident(number)
const incident = query.data
if (query.isPending) return <Loading />
if (!incident) {
return (
<Page>
{query.error instanceof ApiError && query.error.status === 404 ? (
<p className="text-sm text-muted">
This incident doesn’t exist or you don’t have access to it.{' '}
<Link to="/incidents" className="text-coral underline">
Back to incidents
</Link>
</p>
) : (
<ErrorMessage error={query.error} />
)}
</Page>
)
}
const justReported = (location.state as { justReported?: boolean } | null)?.justReported
return (
<Page>
<Link to="/incidents" className="mb-4 inline-flex items-center gap-1 text-2xs font-semibold text-muted hover:text-ink print:hidden">
<ArrowLeft size={14} /> All incidents
</Link>
{justReported && (
<div className="mb-5 flex items-start gap-2 rounded-md bg-mint-soft px-4 py-3 text-xs text-[#2b6e4f] print:hidden" role="status">
<CheckCircle2 size={16} className="mt-px flex-none" />
<span>
<strong>Thanks, {incident.reference} has been reported.</strong> The safety team has been notified{incident.severity === 'high' || incident.severity === 'critical' ? ' straight away' : ''}.
</span>
</div>
)}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<span className="font-mono text-3xs font-medium tracking-[0.09em] text-[#8da29b] uppercase">
{incident.reference} · {incidentTypeLabel[incident.type]}
</span>
<h1 className="mt-1.5 mb-3 font-display text-[26px] leading-tight font-semibold tracking-[-0.5px] text-ink sm:text-[30px]">{incident.title}</h1>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-2xs text-body">
<StatusBadge status={incident.status} />
<SeverityLabel severity={incident.severity} />
<span className="inline-flex items-center gap-1">
<MapPin size={13} />
{[incident.site.name, incident.area?.name].filter(Boolean).join(' · ')}
</span>
<span>Occurred {formatDateTime(incident.occurredAt)}</span>
<span>
Reported by {incident.anonymous ? 'anonymous' : (incident.reporter?.name ?? 'former user')} · {formatDateTime(incident.reportedAt)}
</span>
</div>
</div>
<button type="button" className={`${secondaryButton} flex-none print:hidden`} onClick={() => window.print()}>
<Printer size={16} /> Print / PDF
</button>
</div>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px] print:block">
<div className="min-w-0 space-y-5">
<DetailsSection incident={incident} />
<InvestigationSection incident={incident} />
<ActionsSection incident={incident} />
<ActivitySection incident={incident} />
</div>
<div className="space-y-5 print:mt-5">
<div className="print:hidden">
<WorkflowPanel incident={incident} />
</div>
<AttachmentsSection incident={incident} />
</div>
</div>
</Page>
)
}
+105 -33
View File
@@ -1,24 +1,63 @@
import { FileText, Filter, Plus, Search } from 'lucide-react'
import { useState } from 'react'
import { NavLink } from 'react-router-dom'
import { FileDown, Filter, Plus, Search } from 'lucide-react'
import { useEffect, useState } from 'react'
import { NavLink, useSearchParams } from 'react-router-dom'
import { incidentStatuses, incidentTypes, severities } from '../../shared/enums.ts'
import { IncidentTable } from '../components/IncidentTable'
import { Page } from '../components/Page'
import { PageIntro } from '../components/PageIntro'
import { Pagination } from '../components/Pagination'
import { Empty, ErrorMessage, Loading } from '../components/QueryState'
import { filterBox, panel, primaryButton, secondaryButton } from '../components/styles'
import { ALL_TYPES, filterIncidents, type TypeFilter } from '../lib/incidents'
import { incidentTypes, type Incident } from '../lib/types'
import { incidentTypeLabel, severityLabel, statusLabel } from '../lib/labels'
import { filtersToQuery, useIncidents, useSites, type IncidentFilters } from '../lib/queries'
export function Incidents({ incidents }: { incidents: Incident[] }) {
const [query, setQuery] = useState('')
const [type, setType] = useState<TypeFilter>(ALL_TYPES)
const filtered = filterIncidents(incidents, query, type)
const PAGE_SIZE = 25
const select = 'bg-transparent text-xs text-ink outline-none'
export function Incidents() {
const [params, setParams] = useSearchParams()
const sites = useSites()
const filters: IncidentFilters = {
q: params.get('q') ?? undefined,
type: params.get('type') ?? undefined,
severity: params.get('severity') ?? undefined,
status: params.get('status') ?? undefined,
siteId: params.get('siteId') ?? undefined,
}
const page = Number(params.get('page') ?? 1)
const incidents = useIncidents({ ...filters, page, pageSize: PAGE_SIZE })
// Search box updates the URL after a short pause
const [query, setQuery] = useState(filters.q ?? '')
useEffect(() => setQuery(params.get('q') ?? ''), [params])
useEffect(() => {
const handle = setTimeout(() => {
if ((params.get('q') ?? '') !== query.trim()) setFilter('q', query.trim())
}, 300)
return () => clearTimeout(handle)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
function setFilter(key: string, value: string) {
const next = new URLSearchParams(params)
if (value) next.set(key, value)
else next.delete(key)
next.delete('page')
setParams(next, { replace: true })
}
const setPage = (value: number) => {
const next = new URLSearchParams(params)
next.set('page', String(value))
setParams(next)
}
return (
<Page>
<PageIntro
eyebrow="Safety register"
title="Incidents"
copy="A complete record of reported events, hazards, and learnings."
copy="Every reported event, hazard and near miss, and where each one stands."
action={
<NavLink className={primaryButton} to="/report">
<Plus size={17} /> Report incident
@@ -26,36 +65,69 @@ export function Incidents({ incidents }: { incidents: Incident[] }) {
}
/>
<section className={`${panel} overflow-hidden`}>
<div className="flex flex-wrap items-center gap-2.5 border-b border-line px-[17px] py-3.5 sm:flex-nowrap sm:px-6 sm:py-[18px]">
<div className={`${filterBox} basis-full sm:max-w-[350px] sm:flex-1 sm:basis-auto`}>
<div className="flex flex-wrap items-center gap-2.5 border-b border-line px-[17px] py-3.5 sm:px-6 sm:py-[18px]">
<div className={`${filterBox} basis-full lg:max-w-[300px] lg:flex-1 lg:basis-auto`}>
<Search size={17} />
<input
className="w-full bg-transparent text-xs text-ink outline-none"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search incidents"
aria-label="Search incidents"
/>
<input className="w-full bg-transparent text-xs text-ink outline-none" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search title, location or INC number" aria-label="Search incidents" />
</div>
<div className={`${filterBox} sm:ml-auto`}>
<div className={filterBox}>
<Filter size={16} />
<select
className="bg-transparent text-xs text-ink outline-none"
value={type}
onChange={(event) => setType(event.target.value as TypeFilter)}
aria-label="Filter by type"
>
<option>{ALL_TYPES}</option>
{incidentTypes.map((option) => (
<option key={option}>{option}</option>
<select className={select} value={filters.type ?? ''} onChange={(event) => setFilter('type', event.target.value)} aria-label="Filter by type">
<option value="">All types</option>
{incidentTypes.map((type) => (
<option key={type} value={type}>
{incidentTypeLabel[type]}
</option>
))}
</select>
</div>
<button type="button" className={secondaryButton}>
<FileText size={16} /> Export
</button>
<div className={filterBox}>
<select className={select} value={filters.severity ?? ''} onChange={(event) => setFilter('severity', event.target.value)} aria-label="Filter by severity">
<option value="">All severities</option>
{severities.map((severity) => (
<option key={severity} value={severity}>
{severityLabel[severity]}
</option>
))}
</select>
</div>
<div className={filterBox}>
<select className={select} value={filters.status ?? ''} onChange={(event) => setFilter('status', event.target.value)} aria-label="Filter by status">
<option value="">All statuses</option>
{incidentStatuses.map((status) => (
<option key={status} value={status}>
{statusLabel[status]}
</option>
))}
</select>
</div>
{(sites.data?.length ?? 0) > 1 && (
<div className={filterBox}>
<select className={select} value={filters.siteId ?? ''} onChange={(event) => setFilter('siteId', event.target.value)} aria-label="Filter by site">
<option value="">All sites</option>
{sites.data?.map((site) => (
<option key={site.id} value={site.id}>
{site.name}
</option>
))}
</select>
</div>
)}
<a className={`${secondaryButton} lg:ml-auto`} href={`/api/incidents/export${filtersToQuery(filters)}`} download>
<FileDown size={16} /> Export CSV
</a>
</div>
<IncidentTable incidents={filtered} wide />
<ErrorMessage error={incidents.error} className="m-4" />
{incidents.isPending ? (
<Loading />
) : incidents.data?.items.length ? (
<>
<IncidentTable incidents={incidents.data.items} wide />
<Pagination page={page} pageSize={PAGE_SIZE} total={incidents.data.total} onPage={setPage} />
</>
) : (
<Empty>{Object.values(filters).some(Boolean) ? 'No incidents match these filters.' : 'No incidents reported yet.'}</Empty>
)}
</section>
</Page>
)
+189 -53
View File
@@ -1,70 +1,206 @@
import { Building2, ChevronDown, MapPin, Plus } from 'lucide-react'
import { Archive, Building2, Plus } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import { Link, useOutletContext } from 'react-router-dom'
import type { Area, Site } from '../../shared/api-types.ts'
import { areaKinds, hasRole, type AreaKind } from '../../shared/enums.ts'
import { Field } from '../components/FormSection'
import { Page } from '../components/Page'
import { PageIntro } from '../components/PageIntro'
import { PanelHeading } from '../components/PanelHeading'
import { panel, primaryButton } from '../components/styles'
import { sites } from '../lib/seed'
import { Empty, ErrorMessage, Loading } from '../components/QueryState'
import { field, iconButton, panel, primaryButton, secondaryButton, smallButton, textButton } from '../components/styles'
import { areaKindLabel } from '../lib/labels'
import { toneClasses, type Tone } from '../lib/tones'
import { useOverviewStats, useSiteMutations, useSites, type Me } from '../lib/queries'
const siteTones: Tone[] = ['coral', 'amber', 'sky']
const mapPin = 'absolute size-3.5 rounded-full border-[3px] border-white bg-coral shadow-[0_2px_5px_#275c5550]'
const siteTones: Tone[] = ['coral', 'amber', 'sky', 'mint']
/** Areas as an indented tree: each area followed by its children. */
function flattenTree(areas: Area[], parentId: string | null = null, depth = 0): { area: Area; depth: number }[] {
return areas.filter((a) => a.parentId === parentId).flatMap((area) => [{ area, depth }, ...flattenTree(areas, area.id, depth + 1)])
}
export function Locations() {
const me = useOutletContext<Me>()
const canManage = hasRole(me.user.role, 'safety_manager')
const sites = useSites()
const stats = useOverviewStats()
const { createSite } = useSiteMutations()
const [adding, setAdding] = useState(false)
const [name, setName] = useState('')
const counts = new Map(stats.data?.bySite.map((s) => [s.siteId, s.count]))
const addSite = (event: FormEvent) => {
event.preventDefault()
createSite.mutate(
{ name },
{
onSuccess: () => {
setName('')
setAdding(false)
},
},
)
}
return (
<Page>
<PageIntro
eyebrow="Site map"
eyebrow="Site register"
title="Locations"
copy="Understand incident patterns across every level of your workplace."
copy="Your sites and the buildings, areas and lines inside them. Reports are tagged with these so patterns become visible."
action={
<button type="button" className={primaryButton}>
<Plus size={17} /> Add location
</button>
canManage &&
!adding && (
<button type="button" className={primaryButton} onClick={() => setAdding(true)}>
<Plus size={17} /> Add site
</button>
)
}
/>
<div className="grid gap-5 lg:grid-cols-2">
<section className={panel}>
<PanelHeading eyebrow="Sites" title="Location register" />
{sites.map((site, index) => (
<div key={site.name} className="flex items-center gap-[13px] border-t border-line px-6 py-[18px]">
<div className={`grid size-[38px] place-items-center rounded-[7px] ${toneClasses[siteTones[index % siteTones.length]]}`}>
<Building2 size={19} />
</div>
<div className="flex-1">
<strong className="block text-[13px]">{site.name}</strong>
<span className="mt-1 block text-2xs text-muted">{site.buildings.join(', ')}</span>
</div>
<b className="font-mono text-[15px] font-normal">
{site.incidentCount}
<small className="font-sans text-3xs text-muted"> incidents</small>
</b>
<ChevronDown size={16} className="text-muted" />
</div>
))}
</section>
{/* Map placeholder until a real site map is built */}
<section className={`${panel} relative min-h-[360px] overflow-hidden bg-[#e3eee7]`}>
<div
className="absolute inset-0 scale-140 rotate-12 opacity-45"
style={{
backgroundImage:
'linear-gradient(30deg, transparent 47%, #b1cfc0 48%, transparent 49%), linear-gradient(115deg, transparent 47%, #b1cfc0 48%, transparent 49%)',
backgroundSize: '80px 80px',
}}
/>
<div className="absolute bottom-[27px] left-7 rounded-md bg-white px-3.5 py-[11px] shadow-[0_5px_15px_#275c5520]">
<div className="flex items-center gap-[5px]">
<MapPin size={17} className="text-coral" />
<strong className="text-xs">North Campus</strong>
</div>
<span className="mt-1 block text-3xs text-muted">12 incidents this year</span>
{adding && (
<form onSubmit={addSite} className={`${panel} mb-5 flex flex-wrap items-end gap-3 p-5`}>
<div className="min-w-[240px] flex-1">
<Field label="Site name">
<input className={field} required value={name} onChange={(event) => setName(event.target.value)} placeholder="e.g. North Campus" />
</Field>
</div>
<i className={`${mapPin} top-[30%] left-[40%]`} />
<i className={`${mapPin} top-[48%] left-[64%]`} />
<i className={`${mapPin} top-[65%] left-[24%]`} />
</section>
</div>
<div className="mb-[17px] flex gap-2">
<button type="button" className={secondaryButton} onClick={() => setAdding(false)}>
Cancel
</button>
<button type="submit" className={primaryButton} disabled={createSite.isPending}>
Add site
</button>
</div>
<ErrorMessage error={createSite.error} className="w-full" />
</form>
)}
{sites.isPending ? (
<Loading />
) : sites.data?.length === 0 ? (
<div className={panel}>
<Empty>{canManage ? 'Add your first site to start reporting incidents.' : 'No sites yet. Ask a safety manager to add one.'}</Empty>
</div>
) : (
<div className="grid gap-5 lg:grid-cols-2">
{sites.data?.map((site, index) => (
<SiteCard key={site.id} site={site} tone={siteTones[index % siteTones.length]} count={counts.get(site.id) ?? 0} canManage={canManage} />
))}
</div>
)}
</Page>
)
}
function SiteCard({ site, tone, count, canManage }: { site: Site; tone: Tone; count: number; canManage: boolean }) {
const { createArea, updateArea, updateSite } = useSiteMutations()
const [adding, setAdding] = useState(false)
const [form, setForm] = useState({ name: '', kind: 'building' as AreaKind, parentId: '' })
const tree = flattenTree(site.areas)
const add = (event: FormEvent) => {
event.preventDefault()
createArea.mutate(
{ siteId: site.id, name: form.name, kind: form.kind, parentId: form.parentId || null },
{
onSuccess: () => {
setForm({ ...form, name: '' })
setAdding(false)
},
},
)
}
return (
<section className={panel}>
<div className="flex items-center gap-[13px] px-6 py-[18px]">
<div className={`grid size-[38px] place-items-center rounded-[7px] ${toneClasses[tone]}`}>
<Building2 size={19} />
</div>
<div className="flex-1">
<strong className="block text-sm text-ink">{site.name}</strong>
<Link to={`/incidents?siteId=${site.id}`} className="mt-1 block text-2xs text-muted hover:text-coral">
{count} incident{count === 1 ? '' : 's'} this year
</Link>
</div>
{canManage && (
<button
type="button"
className={iconButton}
aria-label={`Archive ${site.name}`}
title="Archive site"
onClick={() => confirm(`Archive ${site.name}? It will be hidden from new reports; existing incidents keep it.`) && updateSite.mutate({ id: site.id, archived: true })}
>
<Archive size={16} />
</button>
)}
</div>
<div className="border-t border-line px-6 py-4">
{tree.length === 0 ? (
<p className="text-2xs text-faint">No buildings or areas yet.</p>
) : (
<ul className="space-y-1.5">
{tree.map(({ area, depth }) => (
<li key={area.id} className="group flex items-center gap-2 text-xs text-body" style={{ paddingLeft: depth * 18 }}>
<span className="text-faint">{depth ? '└' : '•'}</span>
<span>{area.name}</span>
<span className="font-mono text-3xs text-faint uppercase">{areaKindLabel[area.kind]}</span>
{canManage && (
<button
type="button"
className={`${textButton} ml-auto text-muted opacity-0 group-hover:opacity-100 focus:opacity-100`}
onClick={() => confirm(`Archive ${area.name}?`) && updateArea.mutate({ id: area.id, archived: true })}
>
Archive
</button>
)}
</li>
))}
</ul>
)}
{canManage &&
(adding ? (
<form onSubmit={add} className="mt-4 grid gap-x-3 sm:grid-cols-3">
<Field label="Name">
<input className={field} required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="e.g. Line 3" />
</Field>
<Field label="Kind">
<select className={field} value={form.kind} onChange={(event) => setForm({ ...form, kind: event.target.value as AreaKind })}>
{areaKinds.map((kind) => (
<option key={kind} value={kind}>
{areaKindLabel[kind]}
</option>
))}
</select>
</Field>
<Field label="Inside" optional>
<select className={field} value={form.parentId} onChange={(event) => setForm({ ...form, parentId: event.target.value })}>
<option value="">{site.name}</option>
{tree.map(({ area, depth }) => (
<option key={area.id} value={area.id}>
{'  '.repeat(depth)}
{area.name}
</option>
))}
</select>
</Field>
<ErrorMessage error={createArea.error} className="mb-3 sm:col-span-3" />
<div className="flex justify-end gap-2 sm:col-span-3">
<button type="button" className={secondaryButton} onClick={() => setAdding(false)}>
Cancel
</button>
<button type="submit" className={primaryButton} disabled={createArea.isPending}>
Add
</button>
</div>
</form>
) : (
<button type="button" className={`${smallButton} mt-4`} onClick={() => setAdding(true)}>
<Plus size={13} /> Add building, area or line
</button>
))}
</div>
</section>
)
}
+114 -61
View File
@@ -1,87 +1,140 @@
import { Bell, CheckCircle2, Plus, ShieldCheck, Siren } from 'lucide-react'
import { NavLink } from 'react-router-dom'
import { AlarmClock, CalendarClock, CheckCircle2, Plus, Siren } from 'lucide-react'
import { Link, NavLink, useOutletContext } from 'react-router-dom'
import { incidentStatuses } from '../../shared/enums.ts'
import { IncidentTable } from '../components/IncidentTable'
import { MetricCard } from '../components/MetricCard'
import { Page } from '../components/Page'
import { PageIntro } from '../components/PageIntro'
import { PanelHeading } from '../components/PanelHeading'
import { ProgressRow } from '../components/ProgressRow'
import { Empty, ErrorMessage, Loading } from '../components/QueryState'
import { panel, primaryButton } from '../components/styles'
import { formatLongDate, greeting } from '../lib/dates'
import { currentUser } from '../lib/seed'
import type { Incident } from '../lib/types'
import { formatLongDate, formatWeek, greeting } from '../lib/dates'
import { statusLabel } from '../lib/labels'
import { useIncidents, useOverviewStats, type Me } from '../lib/queries'
const trendPath =
'M0,142 C30,132 42,146 69,130 S105,116 131,126 S166,110 191,120 S225,103 250,115 S285,89 314,103 S355,104 380,85 S412,100 439,72 S468,88 498,61 S530,71 555,52 S590,65 620,32'
function TrendChart({ weeks }: { weeks: { weekStart: string; count: number }[] }) {
const max = Math.max(1, ...weeks.map((week) => week.count))
const width = 620
const height = 170
const step = width / Math.max(1, weeks.length - 1)
const points = weeks.map((week, i) => [i * step, height - 10 - (week.count / max) * (height - 30)] as const)
const line = points.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ')
const labelEvery = Math.ceil(weeks.length / 5)
return (
<div className="relative mx-[25px] mt-1 mb-[30px] h-[170px]">
<div className="absolute inset-0" style={{ background: 'repeating-linear-gradient(to bottom, transparent 0, transparent 41px, #edf0ec 42px)' }} />
<svg className="absolute inset-0 size-full overflow-visible" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" role="img" aria-label="Incidents per week, last 12 weeks">
<path d={`${line} L${width},${height} L0,${height}Z`} fill="var(--color-coral)" opacity=".12" />
<path d={line} fill="none" stroke="var(--color-coral)" strokeWidth="3" vectorEffect="non-scaling-stroke" />
</svg>
<div className="absolute right-0 -bottom-6 left-0 flex justify-between font-mono text-3xs text-[#a0aba7]">
{weeks.map((week, i) => (
<span key={week.weekStart} className={i % labelEvery && i !== weeks.length - 1 ? 'invisible' : ''} title={`${week.count} incidents`}>
{formatWeek(week.weekStart)}
</span>
))}
</div>
</div>
)
}
export function Overview({ incidents }: { incidents: Incident[] }) {
export function Overview() {
const me = useOutletContext<Me>()
const stats = useOverviewStats()
const recent = useIncidents({ pageSize: 5, status: undefined })
const now = new Date()
const s = stats.data
return (
<Page>
<PageIntro
eyebrow={formatLongDate(now)}
title={`${greeting(now)}, ${currentUser.name.split(' ')[0]}`}
copy="Here’s what’s happening across your safety program."
title={`${greeting(now)}, ${me.user.name.split(' ')[0]}`}
copy={`Here’s what’s happening across ${me.organization.name}’s safety program.`}
action={
<NavLink className={primaryButton} to="/report">
<Plus size={17} /> Report incident
</NavLink>
}
/>
{/* Placeholder figures until metrics are computed from real data (Phase 2) */}
<div className="mb-5 grid grid-cols-2 gap-[9px] sm:gap-[13px] lg:grid-cols-4">
<MetricCard label="Open incidents" value="18" delta="3 from last week" icon={Siren} tone="coral" />
<MetricCard label="Due this week" value="07" delta="2 due today" icon={Bell} tone="amber" />
<MetricCard label="Avg. close time" value="4.2d" delta="0.8d faster" icon={CheckCircle2} tone="mint" />
<MetricCard label="Program health" value="92%" delta="Up 4% this month" icon={ShieldCheck} tone="sky" />
</div>
<div className="mb-5 grid gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(260px,0.8fr)]">
<section className={`${panel} overflow-hidden`}>
<PanelHeading eyebrow="Needs attention" title="Recent incidents" link={{ label: 'View all', to: '/incidents' }} />
<IncidentTable incidents={incidents} limit={5} />
</section>
<section className={`${panel} pb-[18px]`}>
<PanelHeading eyebrow="Response progress" title="Open actions" />
<div className="mx-auto mt-3 mb-5 grid size-[126px] rotate-[28deg] place-items-center rounded-full border-[10px] border-[#e8f0e9] border-t-coral border-r-coral">
<div className="-rotate-[28deg] text-center">
<strong className="block font-display text-[25px] font-semibold">68%</strong>
<span className="text-3xs text-muted">complete</span>
</div>
<ErrorMessage error={stats.error} className="mb-5" />
{!s ? (
stats.isPending && <Loading />
) : (
<>
<div className="mb-5 grid grid-cols-2 gap-[9px] sm:gap-[13px] lg:grid-cols-4">
<Link to="/incidents" className="contents">
<MetricCard label="Open incidents" value={String(s.openIncidents)} delta={`${s.reportedLast7Days} reported in the last 7 days`} icon={Siren} tone="coral" />
</Link>
<Link to="/actions?scope=all" className="contents">
<MetricCard label="Actions due this week" value={String(s.actionsDueThisWeek)} icon={CalendarClock} tone="amber" />
</Link>
<Link to="/actions?scope=all&filter=overdue" className="contents">
<MetricCard label="Overdue actions" value={String(s.overdueActions)} delta={s.overdueActions ? 'Needs follow-up' : 'All on track'} icon={AlarmClock} tone="sky" />
</Link>
<MetricCard
label="Avg. time to close"
value={s.averageCloseDays === null ? '–' : `${s.averageCloseDays}d`}
delta="Incidents closed in the last 90 days"
icon={CheckCircle2}
tone="mint"
/>
</div>
<ProgressRow label="Root cause review" value="8 / 12" percent={66} />
<ProgressRow label="Corrective actions" value="14 / 21" percent={67} />
<ProgressRow label="Team sign-offs" value="28 / 32" percent={87} />
</section>
</div>
<div className="grid gap-5 lg:grid-cols-[1.3fr_0.9fr]">
<section className={`${panel} min-h-[260px]`}>
<PanelHeading eyebrow="Signal check" title="Incident trend" />
<div className="relative mx-[25px] mt-1 mb-[22px] h-[170px]">
<div className="absolute inset-0" style={{ background: 'repeating-linear-gradient(to bottom, transparent 0, transparent 41px, #edf0ec 42px)' }} />
<svg className="absolute inset-0 size-full" viewBox="0 0 620 190" preserveAspectRatio="none" aria-label="Incident trend">
<path d={trendPath} fill="none" stroke="var(--color-coral)" strokeWidth="3" />
<path d={`${trendPath} L620,190 L0,190Z`} fill="var(--color-coral)" opacity=".12" />
</svg>
<div className="absolute right-0 -bottom-5 left-0 flex justify-between font-mono text-3xs text-[#a0aba7]">
<span>Aug 01</span>
<span>Aug 08</span>
<span>Aug 15</span>
<span>Aug 22</span>
<span>Aug 29</span>
</div>
<div className="mb-5 grid gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(260px,0.8fr)]">
<section className={`${panel} overflow-hidden`}>
<PanelHeading eyebrow="Latest" title="Recent incidents" link={{ label: 'View all', to: '/incidents' }} />
{recent.data?.items.length ? <IncidentTable incidents={recent.data.items} /> : recent.isPending ? <Loading /> : <Empty>No incidents reported yet.</Empty>}
</section>
<section className={`${panel} pb-[18px]`}>
<PanelHeading eyebrow="Workflow" title="Where incidents are" />
<div className="mx-6 mb-4 flex items-baseline justify-between rounded-md bg-canvas px-3 py-2.5 text-2xs text-muted">
<span>Corrective actions complete</span>
<strong className="font-mono text-ink">
{s.actionProgress.completed} / {s.actionProgress.total}
</strong>
</div>
{incidentStatuses.map((status) => {
const total = Object.values(s.byStatus).reduce((sum, n) => sum + n, 0)
return (
<Link key={status} to={`/incidents?status=${status}`} className="block hover:bg-canvas">
<ProgressRow label={statusLabel[status]} value={String(s.byStatus[status])} percent={total ? (s.byStatus[status] / total) * 100 : 0} />
</Link>
)
})}
</section>
</div>
</section>
<section className={panel}>
<PanelHeading eyebrow="By location" title="Where incidents happen" link={{ label: 'Explore', to: '/locations' }} />
<ProgressRow variant="location" label="North Campus" value="12" percent={82} />
<ProgressRow variant="location" label="South Campus" value="7" percent={52} />
<ProgressRow variant="location" label="West Campus" value="5" percent={38} />
<ProgressRow variant="location" label="Remote / other" value="3" percent={24} />
</section>
</div>
<div className="grid gap-5 lg:grid-cols-[1.3fr_0.9fr]">
<section className={`${panel} min-h-[260px]`}>
<PanelHeading eyebrow="Last 12 weeks" title="Incidents per week" />
<TrendChart weeks={s.weeklyTrend} />
</section>
<section className={panel}>
<PanelHeading eyebrow="This year" title="Where incidents happen" link={{ label: 'Locations', to: '/locations' }} />
{s.bySite.length === 0 ? (
<Empty>
<Link to="/locations" className="text-coral underline">
Add your first site
</Link>{' '}
to start tracking locations.
</Empty>
) : (
s.bySite.map((site) => {
const max = Math.max(1, ...s.bySite.map((x) => x.count))
return (
<Link key={site.siteId} to={`/incidents?siteId=${site.siteId}`} className="block hover:bg-canvas">
<ProgressRow variant="location" label={site.name} value={String(site.count)} percent={(site.count / max) * 100} />
</Link>
)
})
)}
<div className="h-4" />
</section>
</div>
</>
)}
</Page>
)
}
+139 -69
View File
@@ -1,127 +1,197 @@
import { ArrowUpRight } from 'lucide-react'
import { ArrowUpRight, ImagePlus, X } from 'lucide-react'
import { useState, type ChangeEvent, type FormEvent } from 'react'
import { NavLink, useNavigate } from 'react-router-dom'
import { Link, NavLink, useNavigate } from 'react-router-dom'
import { incidentTypes, severities, type IncidentType, type Severity } from '../../shared/enums.ts'
import { Field, FormSection } from '../components/FormSection'
import { Page } from '../components/Page'
import { PageIntro } from '../components/PageIntro'
import { field, panel, primaryButton, secondaryButton } from '../components/styles'
import { sites } from '../lib/seed'
import { incidentTypes, severities, type NewIncident } from '../lib/types'
import { ErrorMessage, Loading } from '../components/QueryState'
import { field, iconButton, panel, primaryButton, secondaryButton } from '../components/styles'
import { errorMessage } from '../lib/api'
import { formatBytes, toDateTimeLocal } from '../lib/dates'
import { incidentTypeLabel, severityLabel } from '../lib/labels'
import { uploadAttachment, useCreateIncident, useSites } from '../lib/queries'
const emptyReport: NewIncident = {
title: '',
type: 'Near miss',
severity: 'Medium',
site: sites[0].name,
building: sites[0].buildings[0],
location: '',
details: '',
cause: '',
response: '',
}
const MAX_PHOTOS = 10
export function ReportIncident({ onSubmit }: { onSubmit: (report: NewIncident) => void }) {
export function ReportIncident() {
const navigate = useNavigate()
const [form, setForm] = useState<NewIncident>(emptyReport)
const buildings = sites.find((site) => site.name === form.site)?.buildings ?? []
const sites = useSites()
const create = useCreateIncident()
const [form, setForm] = useState({
title: '',
type: 'near_miss' as IncidentType,
severity: 'medium' as Severity,
occurredAt: toDateTimeLocal(new Date()),
siteId: '',
areaId: '',
locationDetail: '',
description: '',
immediateAction: '',
anonymous: false,
})
const [photos, setPhotos] = useState<File[]>([])
const [uploadError, setUploadError] = useState<string>()
const update = (key: keyof NewIncident) => (event: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
setForm({ ...form, [key]: event.target.value })
const siteId = form.siteId || sites.data?.[0]?.id || ''
const areas = sites.data?.find((site) => site.id === siteId)?.areas ?? []
const update = (key: keyof typeof form) => (event: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
setForm({ ...form, [key]: event.target.value, ...(key === 'siteId' ? { areaId: '' } : {}) })
const changeSite = (event: ChangeEvent<HTMLSelectElement>) => {
const site = event.target.value
setForm({ ...form, site, building: sites.find((s) => s.name === site)?.buildings[0] ?? '' })
const addPhotos = (event: ChangeEvent<HTMLInputElement>) => {
setPhotos([...photos, ...Array.from(event.target.files ?? [])].slice(0, MAX_PHOTOS))
event.target.value = ''
}
const submit = (event: FormEvent) => {
const submit = async (event: FormEvent) => {
event.preventDefault()
if (!form.title.trim() || !form.location.trim()) return
onSubmit(form)
navigate('/incidents')
setUploadError(undefined)
const incident = await create
.mutateAsync({
...form,
siteId,
areaId: form.areaId || null,
occurredAt: new Date(form.occurredAt).toISOString(),
})
.catch(() => null)
if (!incident) return
const failed: string[] = []
for (const photo of photos) {
try {
await uploadAttachment(incident.number, photo)
} catch (error) {
failed.push(`${photo.name}: ${errorMessage(error)}`)
}
}
if (failed.length) {
setUploadError(`Report ${incident.reference} was saved, but some photos didn't upload. ${failed.join('; ')}`)
return
}
navigate(`/incidents/${incident.number}`, { state: { justReported: true } })
}
if (sites.isPending) return <Loading />
if (sites.data?.length === 0) {
return (
<Page narrow>
<PageIntro eyebrow="New report" title="Report an incident" />
<div className={`${panel} p-8 text-sm text-muted`}>
No sites have been set up yet. A safety manager needs to{' '}
<Link to="/locations" className="text-coral underline">
add a site
</Link>{' '}
before incidents can be reported.
</div>
</Page>
)
}
return (
<Page narrow>
<PageIntro eyebrow="New report" title="Report an incident" copy="Capture the facts while they are fresh. You can add investigation details later." />
<PageIntro eyebrow="New report" title="Report an incident" copy="Capture the facts while they are fresh. Investigation details can be added later." />
<form className={`${panel} overflow-hidden`} onSubmit={submit}>
<FormSection number="01" title="What happened?" copy="Start with a clear, factual description.">
<Field label="Incident title">
<input className={field} required value={form.title} onChange={update('title')} placeholder="e.g. Slip near loading dock" />
<input className={field} required maxLength={200} value={form.title} onChange={update('title')} placeholder="e.g. Slip near loading dock" />
</Field>
<div className="grid sm:grid-cols-2 sm:gap-[18px]">
<div className="grid sm:grid-cols-3 sm:gap-[18px]">
<Field label="Incident type">
<select className={field} value={form.type} onChange={update('type')}>
{incidentTypes.map((option) => (
<option key={option}>{option}</option>
{incidentTypes.map((type) => (
<option key={type} value={type}>
{incidentTypeLabel[type]}
</option>
))}
</select>
</Field>
<Field label="Severity">
<select className={field} value={form.severity} onChange={update('severity')}>
{severities.map((option) => (
<option key={option}>{option}</option>
{severities.map((severity) => (
<option key={severity} value={severity}>
{severityLabel[severity]}
</option>
))}
</select>
</Field>
<Field label="When">
<input className={field} type="datetime-local" required max={toDateTimeLocal(new Date())} value={form.occurredAt} onChange={update('occurredAt')} />
</Field>
</div>
<Field label="What happened?" optional>
<textarea
className={`${field} resize-y`}
value={form.details}
onChange={update('details')}
placeholder="Describe what you observed, without assumptions..."
rows={4}
/>
<textarea className={`${field} resize-y`} value={form.description} onChange={update('description')} placeholder="Describe what you observed, without assumptions..." rows={4} />
</Field>
</FormSection>
<FormSection number="02" title="Where did it happen?" copy="Pinpoint the location so patterns become visible.">
<div className="grid sm:grid-cols-2 sm:gap-[18px]">
<Field label="Site">
<select className={field} value={form.site} onChange={changeSite}>
{sites.map((site) => (
<option key={site.name}>{site.name}</option>
<select className={field} value={siteId} onChange={update('siteId')}>
{sites.data?.map((site) => (
<option key={site.id} value={site.id}>
{site.name}
</option>
))}
</select>
</Field>
<Field label="Building">
<select className={field} value={form.building} onChange={update('building')}>
{buildings.map((building) => (
<option key={building}>{building}</option>
<Field label="Building or area" optional>
<select className={field} value={form.areaId} onChange={update('areaId')}>
<option value="">Not specified</option>
{areas.map((area) => (
<option key={area.id} value={area.id}>
{area.name}
</option>
))}
</select>
</Field>
</div>
<Field label="Specific location">
<input className={field} required value={form.location} onChange={update('location')} placeholder="e.g. Ground floor lobby, Line 3" />
<Field label="Exact location" optional>
<input className={field} value={form.locationDetail} onChange={update('locationDetail')} placeholder="e.g. Ground floor lobby, Line 3" />
</Field>
</FormSection>
<FormSection number="03" title="Response & root cause" copy="Add what you know now. These can be refined during investigation.">
<FormSection number="03" title="Response and photos" copy="What was done straight away, and any pictures of the scene.">
<Field label="Immediate response" optional>
<textarea
className={`${field} resize-y`}
value={form.response}
onChange={update('response')}
placeholder="e.g. Area isolated, supervisor notified..."
rows={3}
/>
</Field>
<Field label="Suspected root cause" optional>
<input className={field} value={form.cause} onChange={update('cause')} placeholder="e.g. Inadequate guarding" />
<textarea className={`${field} resize-y`} value={form.immediateAction} onChange={update('immediateAction')} placeholder="e.g. Area isolated, supervisor notified..." rows={3} />
</Field>
<div className="mb-[17px]">
<span className="block text-2xs font-semibold text-[#586762]">Photos</span>
<div className="mt-[7px] flex flex-wrap gap-2">
{photos.map((photo, index) => (
<div key={`${photo.name}-${index}`} className="flex items-center gap-1.5 rounded-[5px] border border-line bg-field py-1 pr-1 pl-2.5 text-2xs">
<span className="max-w-[160px] truncate">{photo.name}</span>
<span className="text-faint">{formatBytes(photo.size)}</span>
<button type="button" className={iconButton} aria-label={`Remove ${photo.name}`} onClick={() => setPhotos(photos.filter((_, i) => i !== index))}>
<X size={13} />
</button>
</div>
))}
{photos.length < MAX_PHOTOS && (
<label className={`${secondaryButton} cursor-pointer`}>
<ImagePlus size={16} /> Add photos
<input type="file" accept="image/*,application/pdf" multiple capture="environment" className="sr-only" onChange={addPhotos} />
</label>
)}
</div>
</div>
<label className="flex items-start gap-2 text-2xs text-[#586762]">
<input type="checkbox" className="mt-0.5 accent-coral" checked={form.anonymous} onChange={(event) => setForm({ ...form, anonymous: event.target.checked })} />
<span>
<strong className="font-semibold">Report anonymously.</strong> Your name won’t be stored with this report, and you won’t be able to find it in your own list afterwards.
</span>
</label>
</FormSection>
<div className="flex flex-col items-end gap-[15px] px-5 py-[17px] text-2xs text-muted sm:flex-row sm:items-center sm:justify-between sm:px-9 sm:py-5">
<span>
<span className="mr-1.5 text-[#3d9c7e]">✓</span>Save a draft at any time.
</span>
<div className="flex w-full justify-end gap-[9px] sm:w-auto">
<div className="flex flex-col items-stretch gap-[15px] px-5 py-[17px] text-2xs text-muted sm:flex-row sm:items-center sm:justify-between sm:px-9 sm:py-5">
<div className="space-y-2">
<ErrorMessage error={create.error} />
{uploadError && <ErrorMessage error={new Error(uploadError)} />}
</div>
<div className="flex justify-end gap-[9px]">
<NavLink to="/" className={secondaryButton}>
Cancel
</NavLink>
<button className={primaryButton} type="submit">
Submit report <ArrowUpRight size={16} />
<button className={primaryButton} type="submit" disabled={create.isPending}>
{create.isPending ? 'Submitting…' : 'Submit report'} <ArrowUpRight size={16} />
</button>
</div>
</div>
+201
View File
@@ -0,0 +1,201 @@
import { UserPlus } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import { useOutletContext } from 'react-router-dom'
import type { User } from '../../shared/api-types.ts'
import { hasRole, roles, type Role } from '../../shared/enums.ts'
import { Field } from '../components/FormSection'
import { Page } from '../components/Page'
import { PageIntro } from '../components/PageIntro'
import { ErrorMessage, Loading } from '../components/QueryState'
import { field, panel, primaryButton, secondaryButton, sectionHeading, smallButton, textButton } from '../components/styles'
import { formatDate } from '../lib/dates'
import { roleLabel } from '../lib/labels'
import { useChangePassword, useUserMutations, useUsers, type Me } from '../lib/queries'
const roleHelp: Record<Role, string> = {
reporter: 'Reports incidents and works on actions assigned to them',
supervisor: 'Sees all incidents, triages, investigates and assigns actions',
safety_manager: 'Also verifies actions, closes incidents and manages locations',
admin: 'Everything, plus managing users',
}
export function Settings() {
const me = useOutletContext<Me>()
return (
<Page narrow>
<PageIntro eyebrow={me.organization.name} title="Settings" />
<div className="space-y-5">
<Profile me={me} />
{hasRole(me.user.role, 'safety_manager') && <Users me={me} />}
</div>
</Page>
)
}
function Profile({ me }: { me: Me }) {
const change = useChangePassword()
const [form, setForm] = useState({ currentPassword: '', newPassword: '' })
const submit = (event: FormEvent) => {
event.preventDefault()
change.mutate(form, { onSuccess: () => setForm({ currentPassword: '', newPassword: '' }) })
}
return (
<section className={`${panel} p-6`}>
<h2 className={sectionHeading}>Your account</h2>
<p className="mt-1 text-xs text-muted">
{me.user.name} · {me.user.email} · {roleLabel[me.user.role]}
</p>
<form onSubmit={submit} className="mt-5 grid gap-x-4 sm:grid-cols-2">
<Field label="Current password">
<input className={field} type="password" autoComplete="current-password" required value={form.currentPassword} onChange={(event) => setForm({ ...form, currentPassword: event.target.value })} />
</Field>
<Field label="New password">
<input className={field} type="password" autoComplete="new-password" required minLength={10} value={form.newPassword} onChange={(event) => setForm({ ...form, newPassword: event.target.value })} />
</Field>
<div className="flex items-center justify-end gap-3 sm:col-span-2">
{change.isSuccess && <span className="text-2xs text-mint-strong">Password changed. Other devices have been signed out.</span>}
<ErrorMessage error={change.error} />
<button type="submit" className={secondaryButton} disabled={change.isPending}>
Change password
</button>
</div>
</form>
</section>
)
}
function Users({ me }: { me: Me }) {
const isAdmin = me.user.role === 'admin'
const users = useUsers()
const { update } = useUserMutations()
const [inviting, setInviting] = useState(false)
return (
<section className={panel}>
<div className="flex items-center justify-between border-b border-line px-6 py-4">
<div>
<h2 className={sectionHeading}>People</h2>
<p className="mt-0.5 text-2xs text-muted">{isAdmin ? 'Invite people and set what they can do.' : 'Only admins can change people and roles.'}</p>
</div>
{isAdmin && !inviting && (
<button type="button" className={smallButton} onClick={() => setInviting(true)}>
<UserPlus size={13} /> Add person
</button>
)}
</div>
{inviting && <InviteForm onDone={() => setInviting(false)} />}
<ErrorMessage error={users.error ?? update.error} className="m-4" />
{users.isPending ? (
<Loading />
) : (
<ul className="divide-y divide-line-soft">
{users.data?.map((user) => (
<UserRow key={user.id} user={user} me={me} editable={isAdmin} />
))}
</ul>
)}
</section>
)
}
function UserRow({ user, me, editable }: { user: User; me: Me; editable: boolean }) {
const { update } = useUserMutations()
const isMe = user.id === me.user.id
const setPassword = () => {
const password = prompt(`New password for ${user.name} (at least 10 characters). They'll be signed out everywhere.`)
if (password) update.mutate({ id: user.id, password }, { onError: (error) => alert(error.message) })
}
return (
<li className={`flex flex-wrap items-center gap-x-4 gap-y-2 px-6 py-3.5 ${user.active ? '' : 'opacity-60'}`}>
<div className="min-w-[200px] flex-1">
<p className="text-xs font-semibold text-ink">
{user.name} {isMe && <span className="font-normal text-faint">(you)</span>}
{!user.active && <span className="ml-2 font-mono text-3xs font-normal text-faint uppercase">Deactivated</span>}
</p>
<p className="mt-0.5 text-3xs text-muted">
{user.email} · {user.lastLoginAt ? `Last signed in ${formatDate(user.lastLoginAt)}` : 'Never signed in'}
</p>
</div>
{editable ? (
<div className="flex items-center gap-3">
<select
className="rounded-[5px] border border-line bg-white px-2 py-1 text-2xs text-ink outline-none"
value={user.role}
aria-label={`Role for ${user.name}`}
disabled={update.isPending}
onChange={(event) => update.mutate({ id: user.id, role: event.target.value as Role }, { onError: (error) => alert(error.message) })}
>
{roles.map((role) => (
<option key={role} value={role}>
{roleLabel[role]}
</option>
))}
</select>
<button type="button" className={`${textButton} text-muted`} onClick={setPassword}>
Set password
</button>
{!isMe && (
<button
type="button"
className={`${textButton} ${user.active ? 'text-muted' : ''}`}
onClick={() => update.mutate({ id: user.id, active: !user.active }, { onError: (error) => alert(error.message) })}
>
{user.active ? 'Deactivate' : 'Reactivate'}
</button>
)}
</div>
) : (
<span className="text-2xs text-body">{roleLabel[user.role]}</span>
)}
</li>
)
}
function InviteForm({ onDone }: { onDone: () => void }) {
const { create } = useUserMutations()
const [form, setForm] = useState({ name: '', email: '', role: 'reporter' as Role, password: '' })
const [usePassword, setUsePassword] = useState(false)
const submit = (event: FormEvent) => {
event.preventDefault()
create.mutate({ ...form, password: usePassword ? form.password : undefined }, { onSuccess: onDone })
}
return (
<form onSubmit={submit} className="border-b border-line bg-canvas px-6 py-5">
<div className="grid gap-x-4 sm:grid-cols-2">
<Field label="Name">
<input className={field} required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} />
</Field>
<Field label="Email">
<input className={field} type="email" required value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} />
</Field>
</div>
<Field label="Role">
<select className={field} value={form.role} onChange={(event) => setForm({ ...form, role: event.target.value as Role })}>
{roles.map((role) => (
<option key={role} value={role}>
{roleLabel[role]}: {roleHelp[role]}
</option>
))}
</select>
</Field>
<label className="mb-3 flex items-center gap-2 text-2xs text-[#586762]">
<input type="checkbox" className="accent-coral" checked={usePassword} onChange={(event) => setUsePassword(event.target.checked)} />
Set a password now instead of emailing an invitation (for people without email)
</label>
{usePassword && (
<Field label="Password">
<input className={field} type="text" required minLength={10} value={form.password} onChange={(event) => setForm({ ...form, password: event.target.value })} />
</Field>
)}
<ErrorMessage error={create.error} className="mb-3" />
<div className="flex justify-end gap-2">
<button type="button" className={secondaryButton} onClick={onDone}>
Cancel
</button>
<button type="submit" className={primaryButton} disabled={create.isPending}>
{usePassword ? 'Add person' : 'Send invitation'}
</button>
</div>
</form>
)
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"target": "ES2024",
"lib": ["ES2024"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node", "vitest/globals"],
+1 -1
View File
@@ -20,7 +20,7 @@ export default defineConfig({
test: { name: 'web', environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.test.{ts,tsx}'] },
},
{
test: { name: 'server', environment: 'node', globals: true, include: ['server/**/*.test.ts', 'shared/**/*.test.ts'] },
test: { name: 'server', environment: 'node', globals: true, testTimeout: 30_000, hookTimeout: 30_000, include: ['server/**/*.test.ts', 'shared/**/*.test.ts'] },
},
],
},