feat: implement permissions and actions management
- 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.
This commit is contained in:
1 parent
708da6b9f7
commit
3f6dff5f24
94 files changed
+7162
-592
No files matched your search
@@ -10,6 +10,18 @@
|
|||||||
# The URL people open in their browser. Use https:// in production so session cookies are marked Secure.
|
# The URL people open in their browser. Use https:// in production so session cookies are marked Secure.
|
||||||
# PUBLIC_URL=http://localhost:3000
|
# 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.
|
# Docker Compose only: password for the bundled PostgreSQL container.
|
||||||
# Use letters and numbers only (it is embedded in a connection URL).
|
# Use letters and numbers only (it is embedded in a connection URL).
|
||||||
# POSTGRES_PASSWORD=change-me
|
# POSTGRES_PASSWORD=change-me
|
||||||
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"react/rules-of-hooks": "error",
|
"react/rules-of-hooks": "error",
|
||||||
"react/only-export-components": ["warn", { "allowConstantExport": true }],
|
"react/only-export-components": ["warn", { "allowConstantExport": true }],
|
||||||
"jsx-a11y/control-has-associated-label": "off"
|
"jsx-a11y/control-has-associated-label": "off",
|
||||||
|
"jsx-a11y/prefer-tag-over-role": "off"
|
||||||
},
|
},
|
||||||
"ignorePatterns": ["dist", "playwright-report", "test-results"]
|
"ignorePatterns": ["dist", "playwright-report", "test-results"]
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,8 @@ ENV NODE_ENV=production \
|
|||||||
PORT=3000 \
|
PORT=3000 \
|
||||||
DATABASE_URL=pglite:///app/data/pglite
|
DATABASE_URL=pglite:///app/data/pglite
|
||||||
WORKDIR /app
|
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 ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev && npm cache clean --force
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
COPY server ./server
|
COPY server ./server
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Free, open-source accident and safety tracking for manufacturing: incident and near-miss reporting, investigations, corrective actions and (planned) OSHA record keeping.
|
Free, open-source accident and safety tracking for manufacturing: incident and near-miss reporting, investigations, corrective actions and (planned) OSHA record keeping.
|
||||||
|
|
||||||
> **Status: early development.** 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
|
## 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
|
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).
|
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
|
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>`.
|
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>`.
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,15 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://safetylog:${POSTGRES_PASSWORD}@db:5432/safetylog
|
DATABASE_URL: postgres://safetylog:${POSTGRES_PASSWORD}@db:5432/safetylog
|
||||||
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000}
|
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:
|
ports:
|
||||||
- "${PORT:-3000}:3000"
|
- "${PORT:-3000}:3000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-data:
|
db-data:
|
||||||
|
app-data:
|
||||||
+70
-16
@@ -1,11 +1,13 @@
|
|||||||
# API reference
|
# 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.
|
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
|
## Setup and authentication
|
||||||
|
|
||||||
| Method | Path | Who | Notes |
|
| 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 |
|
| 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/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/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 |
|
| POST | `/api/auth/logout` | anyone | Ends the session. 204 |
|
||||||
| GET | `/api/auth/me` | signed in | `{ user, organization }` |
|
| GET | `/api/auth/me` | signed in | `{ user, organization }` |
|
||||||
| POST | `/api/auth/password` | signed in | `{ currentPassword, newPassword }`. Signs out all other sessions. 204 |
|
| 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
|
## Users
|
||||||
|
|
||||||
| Method | Path | Who | Notes |
|
| Method | Path | Who | Notes |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| GET | `/api/users` | safety_manager | All users in the organization |
|
| GET | `/api/users` | safety_manager | All users in the organization |
|
||||||
| POST | `/api/users` | admin | `{ name, email, role, password }`. 409 if the email exists |
|
| GET | `/api/users/assignable` | supervisor | Active users `{ id, name, role }`, for assigning leads and actions |
|
||||||
| PATCH | `/api/users/:id` | admin | `{ name?, role?, active? }`. Deactivating signs the user out. The last active admin can't be demoted or deactivated |
|
| 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
|
## 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 |
|
| 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? }` |
|
| POST | `/api/sites` | safety_manager | `{ name, address?, timezone? }` |
|
||||||
| PATCH | `/api/sites/:id` | safety_manager | `{ name?, address?, timezone?, archived? }` |
|
| 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? }` |
|
| PATCH | `/api/areas/:id` | safety_manager | `{ name?, kind?, archived? }` |
|
||||||
|
|
||||||
## Incidents
|
## 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 |
|
| Method | Path | Who | Notes |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| GET | `/api/incidents` | signed in | Paginated `{ items, page, pageSize, total }`, newest first. Reporters see only their own reports |
|
| GET | `/api/incidents` | signed in | `{ items, page, pageSize, total }`, newest first |
|
||||||
| POST | `/api/incidents` | signed in | Create a report (below). 201 |
|
| GET | `/api/incidents/export` | signed in | CSV of the same filters (no paging, up to 10,000 rows) |
|
||||||
| GET | `/api/incidents/:number` | signed in | 404 if it doesn't exist or isn't visible to you |
|
| POST | `/api/incidents` | signed in | Create a report (below). High/critical reports email safety managers and admins |
|
||||||
| 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/: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:**
|
**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`
|
- `type`: `near_miss`, `hazard`, `injury`, `illness`, `property_damage`, `environmental`
|
||||||
- `severity`: `low`, `medium`, `high`, `critical`
|
- `severity`: `low`, `medium`, `high`, `critical`
|
||||||
- `status`: `reported`, `triage`, `investigation`, `actions`, `verification`, `closed`
|
- `occurredAt` needs a time-zone offset and can't be in the future. Anonymous reports don't record the reporter at all.
|
||||||
- `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.
|
|
||||||
|
|
||||||
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
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| CI | Gitea Actions (`.gitea/workflows`): lint, typecheck, unit tests, build, e2e | Decided |
|
||||||
| Product name | Open SafetyLog EHS (replaced placeholder "IA Tracker" and the prototype's "Safesite" branding) | Decided |
|
| Product name | Open SafetyLog EHS (replaced placeholder "IA Tracker" and the prototype's "Safesite" branding) | Decided |
|
||||||
|
|
||||||
@@ -46,15 +48,16 @@ This is a living document. Update it as decisions are made or scope changes.
|
|||||||
|
|
||||||
## Phase 2: Core workflow (MVP)
|
## 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
|
- [x] Web app connected to the API: sign-in, first-run setup, every page on live data (demo data removed from the frontend)
|
||||||
- Photo upload (attachments)
|
- [x] Photo and PDF upload, checked by file content (not name), size-limited, served with a sandboxing security policy
|
||||||
- Magic-link sign-in and password reset by email
|
- [x] Magic-link sign-in, password reset by email, email invitations for new users (admins can also set passwords when there's no email)
|
||||||
- Incident detail page: timeline, investigation, root-cause tools (5 Whys, fishbone), corrective actions (CAPA)
|
- [x] Incident detail page: details editing, activity log (comments + audit trail), investigation with 5 Whys and fishbone (6M) views, corrective actions (CAPA)
|
||||||
- Status workflow: Reported → Triage → Investigation → Corrective actions → Verification → Closed, with role-based transitions
|
- [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
|
||||||
- "My actions" list and overdue tracking
|
- [x] "My actions" and "All actions" lists with overdue tracking; assignees can update progress on their own actions
|
||||||
- Email notifications: assignment, due dates, high-severity alerts
|
- [x] Email notifications: high/critical alerts to safety managers, action assignment, daily due/overdue digest
|
||||||
- CSV and PDF export
|
- [x] CSV export of the filtered register (protected against spreadsheet formula injection); print-friendly incident page for PDF via the browser
|
||||||
- Dashboard metrics computed from real data
|
- [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
|
## 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.
|
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
|
|
||||||
@@ -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.
|
- [ ] **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.
|
- [ ] **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
|
## Done
|
||||||
|
|
||||||
- [x] Docker verified 2026-10-06 (Docker 29.8, Compose v5.5): image builds (~315 MB), `docker compose up` with PostgreSQL 18 passes setup → sign-in → report smoke test; data and sessions survive `down`/`up`; clean shutdown; healthcheck healthy; runs as non-root; standalone PGlite container works; `Secure` cookie with an `https://` PUBLIC_URL; web app loads with no errors.
|
- [x] Docker verified 2026-10-06 (Docker 29.8, Compose v5.5): image builds (~315 MB), `docker compose up` with PostgreSQL 18 passes setup → sign-in → report smoke test; data and sessions survive `down`/`up`; clean shutdown; healthcheck healthy; runs as non-root; standalone PGlite container works; `Secure` cookie with an `https://` PUBLIC_URL; web app loads with no errors.
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
})
|
||||||
@@ -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'),
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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([])
|
|
||||||
})
|
|
||||||
Generated
+50
@@ -14,6 +14,7 @@
|
|||||||
"@hono/zod-validator": "^0.9.1",
|
"@hono/zod-validator": "^0.9.1",
|
||||||
"drizzle-orm": "^0.45.3",
|
"drizzle-orm": "^0.45.3",
|
||||||
"hono": "^4.13.13",
|
"hono": "^4.13.13",
|
||||||
|
"nodemailer": "^10.0.15",
|
||||||
"pg": "^8.23.1",
|
"pg": "^8.23.1",
|
||||||
"zod": "^4.6.5"
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
@@ -23,10 +24,12 @@
|
|||||||
"@fontsource/dm-mono": "^5.3.0",
|
"@fontsource/dm-mono": "^5.3.0",
|
||||||
"@playwright/test": "^1.63.0",
|
"@playwright/test": "^1.63.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@tanstack/react-query": "^5.104.1",
|
||||||
"@testing-library/jest-dom": "^7.0.1",
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
"@testing-library/react": "^16.3.3",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@testing-library/user-event": "^14.6.7",
|
"@testing-library/user-event": "^14.6.7",
|
||||||
"@types/node": "^26.6.4",
|
"@types/node": "^26.6.4",
|
||||||
|
"@types/nodemailer": "^8.0.2",
|
||||||
"@types/pg": "^8.23.1",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
@@ -2499,6 +2502,34 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
"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": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.2",
|
"version": "10.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
|
||||||
@@ -2641,6 +2672,16 @@
|
|||||||
"undici-types": "~8.9.0"
|
"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": {
|
"node_modules/@types/pg": {
|
||||||
"version": "8.23.1",
|
"version": "8.23.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz",
|
"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": "^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": {
|
"node_modules/obug": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"@hono/zod-validator": "^0.9.1",
|
"@hono/zod-validator": "^0.9.1",
|
||||||
"drizzle-orm": "^0.45.3",
|
"drizzle-orm": "^0.45.3",
|
||||||
"hono": "^4.13.13",
|
"hono": "^4.13.13",
|
||||||
|
"nodemailer": "^10.0.15",
|
||||||
"pg": "^8.23.1",
|
"pg": "^8.23.1",
|
||||||
"zod": "^4.6.5"
|
"zod": "^4.6.5"
|
||||||
},
|
},
|
||||||
@@ -39,10 +40,12 @@
|
|||||||
"@fontsource/dm-mono": "^5.3.0",
|
"@fontsource/dm-mono": "^5.3.0",
|
||||||
"@playwright/test": "^1.63.0",
|
"@playwright/test": "^1.63.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@tanstack/react-query": "^5.104.1",
|
||||||
"@testing-library/jest-dom": "^7.0.1",
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
"@testing-library/react": "^16.3.3",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@testing-library/user-event": "^14.6.7",
|
"@testing-library/user-event": "^14.6.7",
|
||||||
"@types/node": "^26.6.4",
|
"@types/node": "^26.6.4",
|
||||||
|
"@types/nodemailer": "^8.0.2",
|
||||||
"@types/pg": "^8.23.1",
|
"@types/pg": "^8.23.1",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test'
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
const port = 4173
|
const port = 4173
|
||||||
|
export const baseURL = `http://localhost:${port}`
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
|
globalSetup: './e2e/global-setup.ts',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
|
||||||
use: {
|
use: {
|
||||||
baseURL: `http://localhost:${port}`,
|
baseURL,
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
|
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
|
||||||
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
|
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
|
||||||
],
|
],
|
||||||
|
// The real server with a throwaway in-memory database, serving the production build
|
||||||
webServer: {
|
webServer: {
|
||||||
command: `npm run build && npm run preview -- --port ${port} --strictPort`,
|
command: 'npm run build && node server/index.ts',
|
||||||
port,
|
port,
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
|
env: { DATABASE_URL: 'memory://', PORT: String(port), PUBLIC_URL: baseURL, UPLOAD_DIR: 'test-results/uploads', STATIC_DIR: 'dist' },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
+25
-2
@@ -6,19 +6,38 @@ import { loadSession } from './auth/middleware.ts'
|
|||||||
import type { Config } from './config.ts'
|
import type { Config } from './config.ts'
|
||||||
import type { Db } from './db/client.ts'
|
import type { Db } from './db/client.ts'
|
||||||
import type { AppEnv } from './lib/http.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 { authRoutes } from './routes/auth.ts'
|
||||||
import { incidentRoutes } from './routes/incidents.ts'
|
import { incidentRoutes } from './routes/incidents.ts'
|
||||||
|
import { rootCauseRoutes } from './routes/root-causes.ts'
|
||||||
import { setupRoutes } from './routes/setup.ts'
|
import { setupRoutes } from './routes/setup.ts'
|
||||||
import { areaRoutes, siteRoutes } from './routes/sites.ts'
|
import { areaRoutes, siteRoutes } from './routes/sites.ts'
|
||||||
|
import { statsRoutes } from './routes/stats.ts'
|
||||||
import { userRoutes } from './routes/users.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>()
|
const app = new Hono<AppEnv>()
|
||||||
|
|
||||||
app.use('*', secureHeaders())
|
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('db', db)
|
||||||
c.set('config', config)
|
c.set('config', config)
|
||||||
|
c.set('notifier', notifier)
|
||||||
|
c.set('storage', storage)
|
||||||
c.header('Cache-Control', 'no-store')
|
c.header('Cache-Control', 'no-store')
|
||||||
await next()
|
await next()
|
||||||
})
|
})
|
||||||
@@ -31,6 +50,10 @@ export function createApp({ db, config }: { db: Db; config: Config }) {
|
|||||||
app.route('/api/sites', siteRoutes)
|
app.route('/api/sites', siteRoutes)
|
||||||
app.route('/api/areas', areaRoutes)
|
app.route('/api/areas', areaRoutes)
|
||||||
app.route('/api/incidents', incidentRoutes)
|
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.all('/api/*', (c) => c.json({ error: 'Not found' }, 404))
|
||||||
|
|
||||||
app.onError((error, c) => {
|
app.onError((error, c) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ export interface Config {
|
|||||||
production: boolean
|
production: boolean
|
||||||
/** Built web app to serve, if present. */
|
/** Built web app to serve, if present. */
|
||||||
staticDir: string
|
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 {
|
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://'),
|
secureCookies: publicUrl.startsWith('https://'),
|
||||||
production: env.NODE_ENV === 'production',
|
production: env.NODE_ENV === 'production',
|
||||||
staticDir: env.STATIC_DIR ?? 'dist',
|
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
@@ -8,6 +8,13 @@
|
|||||||
"when": 1791306770507,
|
"when": 1791306770507,
|
||||||
"tag": "0000_initial",
|
"tag": "0000_initial",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791309275942,
|
||||||
|
"tag": "0001_workflow_and_email",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ export const rcaMethodEnum = pgEnum('rca_method', vocab.rcaMethods)
|
|||||||
export const rootCauseCategoryEnum = pgEnum('root_cause_category', vocab.rootCauseCategories)
|
export const rootCauseCategoryEnum = pgEnum('root_cause_category', vocab.rootCauseCategories)
|
||||||
export const actionKindEnum = pgEnum('action_kind', vocab.actionKinds)
|
export const actionKindEnum = pgEnum('action_kind', vocab.actionKinds)
|
||||||
export const actionStatusEnum = pgEnum('action_status', vocab.actionStatuses)
|
export const actionStatusEnum = pgEnum('action_status', vocab.actionStatuses)
|
||||||
|
export const authTokenPurposeEnum = pgEnum('auth_token_purpose', ['magic_link', 'password_reset'])
|
||||||
|
|
||||||
const id = () => uuid().primaryKey().defaultRandom()
|
const id = () => uuid().primaryKey().defaultRandom()
|
||||||
const timestamptz = () => timestamp({ withTimezone: true })
|
const timestamptz = () => timestamp({ withTimezone: true })
|
||||||
@@ -62,6 +63,23 @@ export const sessions = pgTable(
|
|||||||
(t) => [index('sessions_user_idx').on(t.userId)],
|
(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(
|
export const sites = pgTable(
|
||||||
'sites',
|
'sites',
|
||||||
{
|
{
|
||||||
@@ -230,6 +248,8 @@ export const correctiveActions = pgTable(
|
|||||||
completedAt: timestamptz(),
|
completedAt: timestamptz(),
|
||||||
verifiedById: uuid().references(() => users.id, { onDelete: 'set null' }),
|
verifiedById: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||||
verifiedAt: timestamptz(),
|
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(),
|
createdAt: createdAt(),
|
||||||
updatedAt: updatedAt(),
|
updatedAt: updatedAt(),
|
||||||
},
|
},
|
||||||
|
|||||||
+17
-2
@@ -2,7 +2,7 @@
|
|||||||
import { hashPassword } from '../auth/password.ts'
|
import { hashPassword } from '../auth/password.ts'
|
||||||
import { loadConfig } from '../config.ts'
|
import { loadConfig } from '../config.ts'
|
||||||
import { openDatabase } from './client.ts'
|
import { openDatabase } from './client.ts'
|
||||||
import { 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'
|
const DEMO_PASSWORD = 'safetylog-demo'
|
||||||
|
|
||||||
@@ -49,7 +49,9 @@ 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: '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 },
|
{ 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
|
] as const
|
||||||
await tx.insert(incidents).values(
|
const created = await tx
|
||||||
|
.insert(incidents)
|
||||||
|
.values(
|
||||||
demo.map((item, index) => ({
|
demo.map((item, index) => ({
|
||||||
organizationId: org.id,
|
organizationId: org.id,
|
||||||
number: index + 1,
|
number: index + 1,
|
||||||
@@ -58,12 +60,25 @@ try {
|
|||||||
severity: item.severity,
|
severity: item.severity,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
occurredAt: daysAgo(item.days),
|
occurredAt: daysAgo(item.days),
|
||||||
|
createdAt: new Date(daysAgo(item.days).getTime() + 2 * 3_600_000),
|
||||||
...areaIds[item.building],
|
...areaIds[item.building],
|
||||||
locationDetail: item.locationDetail,
|
locationDetail: item.locationDetail,
|
||||||
reporterId: person(item.reporter),
|
reporterId: person(item.reporter),
|
||||||
closedAt: item.status === 'closed' ? daysAgo(item.days - 3) : null,
|
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 })
|
await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber: demo.length })
|
||||||
})
|
})
|
||||||
console.log(`Seeded demo data. Sign in as admin@example.com, manager@example.com, supervisor@example.com or reporter@example.com with password "${DEMO_PASSWORD}".`)
|
console.log(`Seeded demo data. Sign in as admin@example.com, manager@example.com, supervisor@example.com or reporter@example.com with password "${DEMO_PASSWORD}".`)
|
||||||
|
|||||||
+12
-1
@@ -5,10 +5,19 @@ import { join } from 'node:path'
|
|||||||
import { createApp } from './app.ts'
|
import { createApp } from './app.ts'
|
||||||
import { loadConfig } from './config.ts'
|
import { loadConfig } from './config.ts'
|
||||||
import { openDatabase } from './db/client.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 config = loadConfig()
|
||||||
const database = await openDatabase(config.databaseUrl)
|
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.
|
// In production the server also serves the built web app.
|
||||||
if (existsSync(join(config.staticDir, 'index.html'))) {
|
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) {
|
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||||
process.on(signal, () => {
|
process.on(signal, () => {
|
||||||
|
clearInterval(reminderTimer)
|
||||||
server.close(async () => {
|
server.close(async () => {
|
||||||
|
await notifier.flush()
|
||||||
await database.close()
|
await database.close()
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
@@ -5,12 +5,16 @@ import type { ZodType } from 'zod'
|
|||||||
import type { SessionUser } from '../auth/sessions.ts'
|
import type { SessionUser } from '../auth/sessions.ts'
|
||||||
import type { Config } from '../config.ts'
|
import type { Config } from '../config.ts'
|
||||||
import type { Db } from '../db/client.ts'
|
import type { Db } from '../db/client.ts'
|
||||||
|
import type { Notifier } from '../notifications.ts'
|
||||||
|
import type { FileStorage } from '../storage.ts'
|
||||||
|
|
||||||
export interface AppEnv {
|
export interface AppEnv {
|
||||||
Variables: {
|
Variables: {
|
||||||
db: Db
|
db: Db
|
||||||
config: Config
|
config: Config
|
||||||
user: SessionUser | null
|
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`. */
|
/** The signed-in user; use after `requireAuth`. */
|
||||||
export function currentUser(c: Context<AppEnv>): SessionUser {
|
export function currentUser(c: Context<AppEnv>): SessionUser {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) })
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
@@ -1,17 +1,29 @@
|
|||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { Hono } from 'hono'
|
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 { endSession, requireAuth, startSession } from '../auth/middleware.ts'
|
||||||
import { hashPassword, verifyAgainstDummy, verifyPassword } from '../auth/password.ts'
|
import { hashPassword, verifyAgainstDummy, verifyPassword } from '../auth/password.ts'
|
||||||
import { RateLimiter } from '../auth/rate-limit.ts'
|
import { RateLimiter } from '../auth/rate-limit.ts'
|
||||||
import { deleteUserSessions } from '../auth/sessions.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 { organizations, users } from '../db/schema.ts'
|
||||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||||
import { publicUser } from './users.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() {
|
export function authRoutes() {
|
||||||
// 10 failed-or-successful attempts per email per 15 minutes
|
// 10 failed-or-successful attempts per email per 15 minutes
|
||||||
const loginLimiter = new RateLimiter(10, 15 * 60 * 1000)
|
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>()
|
return new Hono<AppEnv>()
|
||||||
.post('/login', validate('json', loginSchema), async (c) => {
|
.post('/login', validate('json', loginSchema), async (c) => {
|
||||||
@@ -30,6 +42,48 @@ export function authRoutes() {
|
|||||||
return c.json({ user: result })
|
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) => {
|
.post('/logout', async (c) => {
|
||||||
await endSession(c)
|
await endSession(c)
|
||||||
return c.body(null, 204)
|
return c.body(null, 204)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -108,27 +108,62 @@ describe('listing', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('updating', () => {
|
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 created = await reporter.post('/api/incidents', report({ title: 'Wet floor' }))
|
||||||
const number = created.body.incident.number
|
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' })
|
const edited = await supervisor.patch(`/api/incidents/${number}`, { severity: 'low', title: 'Wet floor by entrance' })
|
||||||
expect(closed.status).toBe(200)
|
expect(edited.status).toBe(200)
|
||||||
expect(closed.body.incident.status).toBe('closed')
|
expect(edited.body.incident).toMatchObject({ severity: 'low', title: 'Wet floor by entrance' })
|
||||||
expect(closed.body.incident.closedAt).not.toBeNull()
|
|
||||||
|
|
||||||
const [entry] = await ctx.db.select().from(auditLog).where(eq(auditLog.action, 'update'))
|
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' })
|
it('does not change status through edits', async () => {
|
||||||
expect(reopened.body.incident.closedAt).toBeNull()
|
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 () => {
|
it('clears the area when moving an incident to another site', async () => {
|
||||||
const created = await reporter.post('/api/incidents', report({ areaId: area.id }))
|
const created = await reporter.post('/api/incidents', report({ areaId: area.id }))
|
||||||
const south = await ctx.createSite('West Campus')
|
const west = await ctx.createSite('West Campus')
|
||||||
const moved = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { siteId: south.id })
|
const moved = await supervisor.patch(`/api/incidents/${created.body.incident.number}`, { siteId: west.id })
|
||||||
expect(moved.body.incident).toMatchObject({ site: { id: south.id }, area: null })
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
+257
-90
@@ -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 { Hono } from 'hono'
|
||||||
|
import { bodyLimit } from 'hono/body-limit'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import type { Comment, TimelineEntry } from '../../shared/api-types.ts'
|
||||||
import { hasRole } from '../../shared/enums.ts'
|
import { hasRole } from '../../shared/enums.ts'
|
||||||
import { incidentReference } from '../../shared/format.ts'
|
import {
|
||||||
import { createIncidentSchema, incidentListQuery, updateIncidentSchema } from '../../shared/schemas.ts'
|
commentSchema,
|
||||||
import type { SessionUser } from '../auth/sessions.ts'
|
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 { requireAuth, requireRole } from '../auth/middleware.ts'
|
||||||
|
import type { SessionUser } from '../auth/sessions.ts'
|
||||||
import type { Db } from '../db/client.ts'
|
import type { Db } from '../db/client.ts'
|
||||||
import { areas, incidentCounters, incidents, sites, users } from '../db/schema.ts'
|
import { areas, attachments, auditLog, comments, correctiveActions, incidentCounters, incidents, investigations, rootCauses, sites, users } from '../db/schema.ts'
|
||||||
import { audit, diff } from '../lib/audit.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 { 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() })
|
const numberParam = z.object({ number: z.coerce.number().int().positive() })
|
||||||
|
|
||||||
/** Reports may be dated slightly ahead to absorb clock differences between devices. */
|
/** Reports may be dated slightly ahead to absorb clock differences between devices. */
|
||||||
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000
|
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000
|
||||||
|
const MAX_ATTACHMENTS_PER_INCIDENT = 20
|
||||||
const columns = {
|
const EXPORT_LIMIT = 10_000
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertLocation(db: Db, organizationId: string, siteId: string, areaId: string | null | undefined) {
|
async function assertLocation(db: Db, organizationId: string, siteId: string, areaId: string | null | undefined) {
|
||||||
const [site] = await db
|
const [site] = await db
|
||||||
@@ -91,12 +57,7 @@ function parseOccurredAt(value: string): Date {
|
|||||||
|
|
||||||
const escapeLike = (value: string) => value.replace(/[\\%_]/g, '\\$&')
|
const escapeLike = (value: string) => value.replace(/[\\%_]/g, '\\$&')
|
||||||
|
|
||||||
export const incidentRoutes = new Hono<AppEnv>()
|
function listFilter(db: Db, user: SessionUser, { q, type, severity, status, siteId }: Omit<IncidentListQuery, 'page' | 'pageSize'>) {
|
||||||
.get('/', requireAuth, validate('query', incidentListQuery), async (c) => {
|
|
||||||
const me = currentUser(c)
|
|
||||||
const { page, pageSize, q, type, severity, status, siteId } = c.req.valid('query')
|
|
||||||
const db = c.get('db')
|
|
||||||
|
|
||||||
let search: SQL | undefined
|
let search: SQL | undefined
|
||||||
if (q) {
|
if (q) {
|
||||||
const pattern = `%${escapeLike(q)}%`
|
const pattern = `%${escapeLike(q)}%`
|
||||||
@@ -108,22 +69,66 @@ export const incidentRoutes = new Hono<AppEnv>()
|
|||||||
number ? eq(incidents.number, Number(number)) : undefined,
|
number ? eq(incidents.number, Number(number)) : undefined,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const where = and(
|
return and(
|
||||||
visibleTo(me),
|
visibleTo(db, user),
|
||||||
search,
|
search,
|
||||||
type ? eq(incidents.type, type) : undefined,
|
type ? eq(incidents.type, type) : undefined,
|
||||||
severity ? eq(incidents.severity, severity) : undefined,
|
severity ? eq(incidents.severity, severity) : undefined,
|
||||||
status ? eq(incidents.status, status) : undefined,
|
status ? eq(incidents.status, status) : undefined,
|
||||||
siteId ? eq(incidents.siteId, siteId) : 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, ...filters } = c.req.valid('query')
|
||||||
|
const db = c.get('db')
|
||||||
|
const where = listFilter(db, me, filters)
|
||||||
const [{ total }] = await db.select({ total: count() }).from(incidents).where(where)
|
const [{ total }] = await db.select({ total: count() }).from(incidents).where(where)
|
||||||
const rows = await selectIncidents(db)
|
const rows = await selectIncidents(db)
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(desc(incidents.occurredAt), desc(incidents.number))
|
.orderBy(desc(incidents.occurredAt), desc(incidents.number))
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.offset((page - 1) * 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) => {
|
.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)
|
await assertLocation(db, me.organizationId, input.siteId, input.areaId)
|
||||||
const occurred = parseOccurredAt(occurredAt)
|
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.
|
// Atomic increment: concurrent reports always get distinct, gap-free numbers.
|
||||||
const [{ lastNumber }] = await tx
|
const [{ lastNumber }] = await tx
|
||||||
.insert(incidentCounters)
|
.insert(incidentCounters)
|
||||||
@@ -143,25 +148,23 @@ export const incidentRoutes = new Hono<AppEnv>()
|
|||||||
const [incident] = await tx
|
const [incident] = await tx
|
||||||
.insert(incidents)
|
.insert(incidents)
|
||||||
.values({ ...input, occurredAt: occurred, organizationId: me.organizationId, number: lastNumber, anonymous, reporterId: anonymous ? null : me.id })
|
.values({ ...input, occurredAt: occurred, organizationId: me.organizationId, number: lastNumber, anonymous, reporterId: anonymous ? null : me.id })
|
||||||
.returning({ id: incidents.id })
|
.returning({ id: incidents.id, number: incidents.number })
|
||||||
await audit(tx, {
|
await auditIncident(tx, anonymous ? null : me, me.organizationId, incident.id, 'create', {
|
||||||
organizationId: me.organizationId,
|
number: lastNumber,
|
||||||
actorId: anonymous ? null : me.id,
|
title: input.title,
|
||||||
entityType: 'incident',
|
type: input.type,
|
||||||
entityId: incident.id,
|
severity: input.severity,
|
||||||
action: 'create',
|
|
||||||
changes: { 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)))
|
c.get('notifier').incidentReported(created.id)
|
||||||
return c.json({ incident: toDto(row) }, 201)
|
const [row] = await selectIncidents(db).where(eq(incidents.id, created.id))
|
||||||
|
return c.json({ incident: toIncident(row) }, 201)
|
||||||
})
|
})
|
||||||
|
|
||||||
.get('/:number', requireAuth, validate('param', numberParam), async (c) => {
|
.get('/:number', requireAuth, validate('param', numberParam), async (c) => {
|
||||||
const row = await findIncident(c.get('db'), currentUser(c), c.req.valid('param').number)
|
return c.json({ incident: await loadDetail(c.get('db'), currentUser(c), c.req.valid('param').number) })
|
||||||
return c.json({ incident: toDto(row) })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
.patch('/:number', requireRole('supervisor'), validate('param', numberParam), validate('json', updateIncidentSchema), async (c) => {
|
.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 && 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)
|
if (input.siteId !== undefined || input.areaId !== undefined) await assertLocation(db, me.organizationId, siteId, areaId)
|
||||||
|
|
||||||
const update = {
|
const update = { ...input, areaId, occurredAt: occurredAt ? parseOccurredAt(occurredAt) : undefined }
|
||||||
...input,
|
|
||||||
areaId,
|
|
||||||
occurredAt: occurredAt ? parseOccurredAt(occurredAt) : undefined,
|
|
||||||
closedAt: input.status === undefined ? undefined : input.status === 'closed' ? (incident.closedAt ?? new Date()) : null,
|
|
||||||
}
|
|
||||||
const changes = diff(incident, update)
|
const changes = diff(incident, update)
|
||||||
if (Object.keys(changes).length > 0) {
|
if (Object.keys(changes).length > 0) {
|
||||||
await db.update(incidents).set(update).where(eq(incidents.id, incident.id))
|
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: await loadDetail(db, me, incident.number) })
|
||||||
return c.json({ incident: toDto(row) })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -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) })
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
@@ -3,10 +3,11 @@ import { Hono } from 'hono'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { createUserSchema, updateUserSchema } from '../../shared/schemas.ts'
|
import { createUserSchema, updateUserSchema } from '../../shared/schemas.ts'
|
||||||
import { requireRole } from '../auth/middleware.ts'
|
import { requireRole } from '../auth/middleware.ts'
|
||||||
|
import { createAuthToken } from '../auth/tokens.ts'
|
||||||
import { hashPassword } from '../auth/password.ts'
|
import { hashPassword } from '../auth/password.ts'
|
||||||
import { deleteUserSessions } from '../auth/sessions.ts'
|
import { deleteUserSessions } from '../auth/sessions.ts'
|
||||||
import { isUniqueViolation } from '../db/client.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 { audit, diff } from '../lib/audit.ts'
|
||||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.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 idParam = z.object({ id: z.uuid() })
|
||||||
|
const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
export const userRoutes = new Hono<AppEnv>()
|
export const userRoutes = new Hono<AppEnv>()
|
||||||
.get('/', requireRole('safety_manager'), async (c) => {
|
.get('/', requireRole('safety_manager'), async (c) => {
|
||||||
@@ -30,6 +32,18 @@ export const userRoutes = new Hono<AppEnv>()
|
|||||||
return c.json({ users: rows })
|
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) => {
|
.post('/', requireRole('admin'), validate('json', createUserSchema), async (c) => {
|
||||||
const me = currentUser(c)
|
const me = currentUser(c)
|
||||||
const { password, ...input } = c.req.valid('json')
|
const { password, ...input } = c.req.valid('json')
|
||||||
@@ -37,10 +51,15 @@ export const userRoutes = new Hono<AppEnv>()
|
|||||||
try {
|
try {
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.insert(users)
|
.insert(users)
|
||||||
.values({ ...input, organizationId: me.organizationId, passwordHash: await hashPassword(password) })
|
.values({ ...input, organizationId: me.organizationId, passwordHash: password ? await hashPassword(password) : null })
|
||||||
.returning(publicUser)
|
.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 } })
|
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) {
|
} catch (error) {
|
||||||
if (isUniqueViolation(error)) throw fail(409, 'A user with that email already exists')
|
if (isUniqueViolation(error)) throw fail(409, 'A user with that email already exists')
|
||||||
throw error
|
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')
|
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) {
|
if (Object.keys(changes).length === 0) {
|
||||||
const [user] = await db.select(publicUser).from(users).where(eq(users.id, id))
|
const [user] = await db.select(publicUser).from(users).where(eq(users.id, id))
|
||||||
return c.json({ user })
|
return c.json({ user })
|
||||||
}
|
}
|
||||||
const [user] = await db.update(users).set(input).where(eq(users.id, id)).returning(publicUser)
|
const [user] = await db
|
||||||
// Roles are re-read on every request, so only deactivation needs to end sessions.
|
.update(users)
|
||||||
if (input.active === false) await deleteUserSessions(db, id)
|
.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 })
|
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'user', entityId: id, action: 'update', changes })
|
||||||
return c.json({ user })
|
return c.json({ user })
|
||||||
})
|
})
|
||||||
@@ -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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
@@ -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 { createApp } from '../app.ts'
|
||||||
import { loadConfig } from '../config.ts'
|
import { loadConfig } from '../config.ts'
|
||||||
import { openDatabase } from '../db/client.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>
|
type App = ReturnType<typeof createApp>
|
||||||
|
|
||||||
@@ -15,12 +21,16 @@ export class Client {
|
|||||||
|
|
||||||
async request(method: string, path: string, body?: unknown) {
|
async request(method: string, path: string, body?: unknown) {
|
||||||
const headers: Record<string, string> = {}
|
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
|
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')
|
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]
|
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
|
// oxlint-disable-next-line typescript/no-explicit-any -- tests read arbitrary JSON shapes
|
||||||
const json: any = text ? JSON.parse(text) : null
|
const json: any = text ? JSON.parse(text) : null
|
||||||
return { status: res.status, body: json, headers: res.headers }
|
return { status: res.status, body: json, headers: res.headers }
|
||||||
@@ -28,7 +38,9 @@ export class Client {
|
|||||||
|
|
||||||
get = (path: string) => this.request('GET', path)
|
get = (path: string) => this.request('GET', path)
|
||||||
post = (path: string, body?: unknown) => this.request('POST', path, body ?? {})
|
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)
|
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' }
|
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. */
|
/** Fresh in-memory database and app, with the first-run setup already done and the admin signed in. */
|
||||||
export async function createTestContext() {
|
export async function createTestContext() {
|
||||||
const database = await openDatabase('memory://')
|
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 admin = new Client(app)
|
||||||
const setup = await admin.post('/api/setup', ADMIN)
|
const setup = await admin.post('/api/setup', ADMIN)
|
||||||
if (setup.status !== 201) throw new Error(`setup failed: ${JSON.stringify(setup.body)}`)
|
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 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()
|
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())}`
|
||||||
|
}
|
||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
// Request validation schemas, shared so the web app can validate forms with the same rules as the API.
|
// Request validation schemas, shared so the web app can validate forms with the same rules as the API.
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { 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 email = z.string().trim().toLowerCase().max(254).pipe(z.email())
|
||||||
const password = z.string().min(10, 'Use at least 10 characters').max(256)
|
const password = z.string().min(10, 'Use at least 10 characters').max(256)
|
||||||
@@ -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 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() })
|
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),
|
anonymous: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const updateIncidentSchema = createIncidentSchema
|
/** Status changes go through transitions, not updates. */
|
||||||
.omit({ anonymous: true })
|
export const updateIncidentSchema = createIncidentSchema.omit({ anonymous: true }).partial()
|
||||||
.partial()
|
|
||||||
.extend({ status: z.enum(incidentStatuses).optional() })
|
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({
|
export const incidentListQuery = z.object({
|
||||||
page: z.coerce.number().int().min(1).default(1),
|
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 SetupInput = z.infer<typeof setupSchema>
|
||||||
export type CreateIncidentInput = z.input<typeof createIncidentSchema>
|
export type CreateIncidentInput = z.input<typeof createIncidentSchema>
|
||||||
export type UpdateIncidentInput = z.infer<typeof updateIncidentSchema>
|
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>
|
export type IncidentListQuery = z.infer<typeof incidentListQuery>
|
||||||
@@ -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) }))
|
||||||
|
}
|
||||||
@@ -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
@@ -1,30 +1,32 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { AppLayout } from './components/AppLayout'
|
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 { About } from './pages/About'
|
||||||
|
import { Actions } from './pages/Actions'
|
||||||
import { Analytics } from './pages/Analytics'
|
import { Analytics } from './pages/Analytics'
|
||||||
|
import { Login, MagicLinkLanding, ResetPassword, Setup } from './pages/Auth'
|
||||||
|
import { IncidentDetail } from './pages/IncidentDetail'
|
||||||
import { Incidents } from './pages/Incidents'
|
import { Incidents } from './pages/Incidents'
|
||||||
import { Locations } from './pages/Locations'
|
import { Locations } from './pages/Locations'
|
||||||
import { Overview } from './pages/Overview'
|
import { Overview } from './pages/Overview'
|
||||||
import { ReportIncident } from './pages/ReportIncident'
|
import { ReportIncident } from './pages/ReportIncident'
|
||||||
|
import { Settings } from './pages/Settings'
|
||||||
|
|
||||||
export default function App() {
|
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 (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppLayout openIncidentCount={incidents.filter(isOpen).length} />}>
|
<Route path="login" element={<Login />} />
|
||||||
<Route index element={<Overview incidents={incidents} />} />
|
<Route path="setup" element={<Setup />} />
|
||||||
<Route path="incidents" element={<Incidents incidents={incidents} />} />
|
<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="locations" element={<Locations />} />
|
||||||
<Route path="analytics" element={<Analytics />} />
|
<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="about" element={<About />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
import { useState } from 'react'
|
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 { Header } from './Header'
|
||||||
|
import { ErrorMessage, Loading } from './QueryState'
|
||||||
import { Sidebar } from './Sidebar'
|
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 [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 (
|
return (
|
||||||
<div className="flex min-h-screen bg-canvas">
|
<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">
|
<main className="min-w-0 flex-1">
|
||||||
<Header onOpenMenu={() => setMenuOpen(true)} />
|
<Header me={me.data} onOpenMenu={() => setMenuOpen(true)} overdueActions={overdue} />
|
||||||
<Outlet />
|
<Outlet context={me.data} />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -1,30 +1,55 @@
|
|||||||
import { Bell, Menu, Search } from 'lucide-react'
|
import { Bell, Menu, Search } from 'lucide-react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useState, type FormEvent } from 'react'
|
||||||
import { pageTitles } from '../lib/navigation'
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { currentUser } from '../lib/seed'
|
import { initials } from '../lib/initials'
|
||||||
|
import { pageTitle } from '../lib/navigation'
|
||||||
|
import type { Me } from '../lib/queries'
|
||||||
import { iconButton } from './styles'
|
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 { 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 (
|
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]">
|
<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} mr-2.5 sm:hidden`} onClick={onOpenMenu} aria-label="Open menu">
|
<button type="button" className={`${iconButton} sm:hidden`} onClick={onOpenMenu} aria-label="Open menu">
|
||||||
<Menu size={21} />
|
<Menu size={21} />
|
||||||
</button>
|
</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">Safety workspace</span>
|
||||||
<span className="max-sm:hidden">/</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>
|
||||||
<div className="flex items-center gap-[15px]">
|
<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} />
|
<Search size={19} />
|
||||||
</button>
|
</Link>
|
||||||
<button type="button" className={`${iconButton} relative`} aria-label="Notifications">
|
<Link to="/actions?filter=overdue" className={`${iconButton} relative`} aria-label={overdueActions ? `${overdueActions} overdue actions` : 'My actions'}>
|
||||||
<Bell size={19} />
|
<Bell size={19} />
|
||||||
<i className="absolute top-1 right-[3px] size-1.5 rounded-full border border-canvas bg-coral" />
|
{overdueActions > 0 && <i className="absolute top-1 right-[3px] size-1.5 rounded-full border border-canvas bg-coral" />}
|
||||||
</button>
|
</Link>
|
||||||
<div className="grid size-8 place-items-center rounded-full bg-[#d2e0d8] font-mono text-2xs text-teal">{currentUser.initials}</div>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
import { AlertTriangle, MapPin } from 'lucide-react'
|
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 { formatDate } from '../lib/dates'
|
||||||
import { severityDotClass, statusClass, typeTone } from '../lib/incidents'
|
import { incidentTypeLabel, incidentTypeTone } from '../lib/labels'
|
||||||
import { toneClasses } from '../lib/tones'
|
import { toneClasses } from '../lib/tones'
|
||||||
import type { Incident } from '../lib/types'
|
import { SeverityLabel, StatusBadge } from './Badges'
|
||||||
import { iconButton } from './styles'
|
|
||||||
|
|
||||||
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 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-[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-6'
|
||||||
|
|
||||||
interface IncidentTableProps {
|
export function IncidentTable({ incidents, wide = false }: { incidents: Incident[]; wide?: boolean }) {
|
||||||
incidents: Incident[]
|
const navigate = useNavigate()
|
||||||
limit?: number
|
|
||||||
wide?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IncidentTable({ incidents, limit, wide = false }: IncidentTableProps) {
|
|
||||||
const rows = limit ? incidents.slice(0, limit) : incidents
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<table className={`w-full border-collapse text-left ${wide ? 'min-w-[900px]' : 'min-w-[720px]'}`}>
|
<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}>Severity</th>
|
||||||
<th className={th}>Location</th>
|
<th className={th}>Location</th>
|
||||||
<th className={th}>Status</th>
|
<th className={th}>Status</th>
|
||||||
<th className={th}>Date</th>
|
<th className={th}>Occurred</th>
|
||||||
<th className={th}>
|
|
||||||
<span className="sr-only">Actions</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((incident) => (
|
{incidents.map((incident) => (
|
||||||
<tr key={incident.id} className="[&:last-child>td]:border-b-0">
|
<tr
|
||||||
|
key={incident.id}
|
||||||
|
className="cursor-pointer hover:bg-canvas [&:last-child>td]:border-b-0"
|
||||||
|
onClick={() => navigate(`/incidents/${incident.number}`)}
|
||||||
|
>
|
||||||
<td className={td}>
|
<td className={td}>
|
||||||
<div className="flex min-w-[250px] items-center gap-2.5">
|
<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} />
|
<AlertTriangle size={14} />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<strong className="block text-xs font-semibold text-ink">{incident.title}</strong>
|
<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">
|
<small className="mt-1 block text-3xs text-faint">
|
||||||
{incident.id} · {incident.reporter}
|
{incident.reference} · {incident.anonymous ? 'Anonymous' : (incident.reporter?.name ?? 'Unknown')}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={td}>{incident.type}</td>
|
<td className={td}>{incidentTypeLabel[incident.type]}</td>
|
||||||
<td className={td}>
|
<td className={td}>
|
||||||
<span className="inline-flex items-center gap-[5px]">
|
<SeverityLabel severity={incident.severity} />
|
||||||
<i className={`size-1.5 rounded-full ${severityDotClass[incident.severity]}`} />
|
|
||||||
{incident.severity}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className={td}>
|
<td className={td}>
|
||||||
<div className="flex max-w-[120px] flex-wrap items-center gap-1">
|
<div className="flex max-w-[160px] flex-wrap items-center gap-1">
|
||||||
<MapPin size={14} />
|
<MapPin size={14} className="flex-none" />
|
||||||
{incident.location}
|
<span className="truncate">{incident.area?.name ?? incident.site.name}</span>
|
||||||
<small className="mt-1 ml-[18px] block w-full text-3xs text-faint">{incident.building}</small>
|
<small className="mt-1 ml-[18px] block w-full truncate text-3xs text-faint">{incident.locationDetail || incident.site.name}</small>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={td}>
|
<td className={td}>
|
||||||
<span className={`rounded px-2 py-[5px] text-3xs ${statusClass(incident.status)}`}>{incident.status}</span>
|
<StatusBadge status={incident.status} />
|
||||||
</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>
|
|
||||||
</td>
|
</td>
|
||||||
|
<td className={`${td} text-[#87938f]`}>{formatDate(incident.occurredAt)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { toneClasses, type Tone } from '../lib/tones'
|
|||||||
interface MetricCardProps {
|
interface MetricCardProps {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
delta: string
|
delta?: string
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
tone: Tone
|
tone: Tone
|
||||||
}
|
}
|
||||||
@@ -17,9 +17,7 @@ export function MetricCard({ label, value, delta, icon: Icon, tone }: MetricCard
|
|||||||
</div>
|
</div>
|
||||||
<span className="mt-2.5 mb-1.5 block text-xs text-muted sm:mt-[15px]">{label}</span>
|
<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>
|
<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">
|
{delta && <small className="mt-[9px] block text-[9px] text-[#7d8b86] sm:text-2xs">{delta}</small>}
|
||||||
<b className="text-positive">↗</b> {delta}
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -1,7 +1,9 @@
|
|||||||
import { ChevronDown, FileText, Info, Plus, Settings, ShieldCheck, X } from 'lucide-react'
|
import { Info, LogOut, Plus, Settings, ShieldCheck, X } from 'lucide-react'
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink, useNavigate } from 'react-router-dom'
|
||||||
|
import { initials } from '../lib/initials'
|
||||||
|
import { roleLabel } from '../lib/labels'
|
||||||
import { primaryNav } from '../lib/navigation'
|
import { primaryNav } from '../lib/navigation'
|
||||||
import { currentUser, workspace } from '../lib/seed'
|
import { useLogout, type Me } from '../lib/queries'
|
||||||
import { Eyebrow } from './Eyebrow'
|
import { Eyebrow } from './Eyebrow'
|
||||||
import { iconButton } from './styles'
|
import { iconButton } from './styles'
|
||||||
|
|
||||||
@@ -10,22 +12,25 @@ const navLink = ({ isActive }: { isActive: boolean }) =>
|
|||||||
const navSection = 'mb-6 border-b border-forest-700 pb-[23px]'
|
const navSection = 'mb-6 border-b border-forest-700 pb-[23px]'
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
|
me: Me
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
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 (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={`fixed inset-y-0 z-10 flex w-[260px] flex-none flex-col overflow-y-auto bg-forest-950 px-[15px] pt-7 pb-[18px] text-forest-100 shadow-[8px_0_30px_#163c3740] transition-[left] duration-200 sm:static sm:min-h-screen sm:w-[230px] sm:shadow-none lg:w-[246px] ${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="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">
|
<div className="grid size-[34px] flex-none place-items-center rounded-[9px] bg-coral text-white">
|
||||||
<ShieldCheck size={20} />
|
<ShieldCheck size={20} />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<span className="mt-0.5 block text-3xs text-forest-300">EHS · Incident intelligence</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className={`${iconButton} absolute top-2 right-2 sm:hidden`} onClick={onClose} aria-label="Close menu">
|
<button type="button" className={`${iconButton} absolute top-2 right-2 sm:hidden`} onClick={onClose} aria-label="Close menu">
|
||||||
@@ -34,22 +39,19 @@ export function Sidebar({ open, onClose, openIncidentCount }: SidebarProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-[27px] flex items-center gap-2.5 rounded-[7px] border border-[#316158] p-[11px]">
|
<div className="mb-[27px] flex items-center gap-2.5 rounded-[7px] border border-[#316158] p-[11px]">
|
||||||
<div className="grid size-[29px] place-items-center rounded-full bg-[#f1c865] font-mono text-2xs font-bold text-[#143d37]">{workspace.initials}</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>
|
<div className="min-w-0">
|
||||||
<Eyebrow>Workspace</Eyebrow>
|
<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>
|
</div>
|
||||||
<ChevronDown size={16} className="ml-auto" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className={navSection}>
|
<nav className={navSection}>
|
||||||
{primaryNav.map(({ label, to, icon: Icon }) => (
|
{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} />
|
<Icon size={18} />
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
{to === '/incidents' && openIncidentCount > 0 && (
|
{!!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>}
|
||||||
<em className="ml-auto rounded-[10px] bg-coral px-[7px] py-0.5 font-mono text-2xs text-white not-italic">{openIncidentCount}</em>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -60,10 +62,6 @@ export function Sidebar({ open, onClose, openIncidentCount }: SidebarProps) {
|
|||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
<span>Report an incident</span>
|
<span>Report an incident</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<a href="#resources" className={navLink({ isActive: false })}>
|
|
||||||
<FileText size={18} />
|
|
||||||
<span>Resources</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto">
|
<div className="mt-auto">
|
||||||
@@ -71,17 +69,25 @@ export function Sidebar({ open, onClose, openIncidentCount }: SidebarProps) {
|
|||||||
<Info size={18} />
|
<Info size={18} />
|
||||||
<span>About</span>
|
<span>About</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<a href="#settings" className={navLink({ isActive: false })}>
|
<NavLink to="/settings" onClick={onClose} className={navLink}>
|
||||||
<Settings size={18} />
|
<Settings size={18} />
|
||||||
<span>Settings</span>
|
<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="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 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>
|
<div className="min-w-0">
|
||||||
<strong className="block text-xs text-white">{currentUser.name}</strong>
|
<strong className="block truncate text-xs text-white">{me.user.name}</strong>
|
||||||
<span className="mt-0.5 block text-3xs text-forest-300">{currentUser.role}</span>
|
<span className="mt-0.5 block text-3xs text-forest-300">{roleLabel[me.user.role]}</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,14 +2,25 @@
|
|||||||
|
|
||||||
export const panel = 'min-w-0 rounded-lg border border-line bg-white shadow-[0_5px_20px_#1b252305]'
|
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 primaryButton = `${buttonBase} bg-coral text-white shadow-[0_4px_12px_#ee725833]`
|
||||||
export const secondaryButton = `${buttonBase} border border-line bg-white text-ink`
|
export const secondaryButton = `${buttonBase} border border-line bg-white text-ink`
|
||||||
export const lightButton = `${buttonBase} bg-[#edf6ef] text-teal`
|
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 iconButton = 'grid cursor-pointer place-items-center bg-transparent p-1.5 text-[#72817d]'
|
||||||
|
|
||||||
export const field =
|
/** Form control without spacing, for inline use. */
|
||||||
'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]'
|
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 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'
|
||||||
@@ -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
@@ -1,13 +1,15 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { formatDate, greeting, toIsoDate } from './dates'
|
import { formatBytes, formatDay, greeting, toDateTimeLocal, toIsoDate } from './dates'
|
||||||
|
|
||||||
describe('dates', () => {
|
describe('dates', () => {
|
||||||
it('formats ISO dates without time-zone drift', () => {
|
it('formats calendar dates without time-zone drift', () => {
|
||||||
expect(formatDate('2024-08-28')).toBe('Aug 28, 2024')
|
expect(formatDay('2024-08-28')).toBe('Aug 28, 2024')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('converts a local date to an ISO calendar date', () => {
|
it('converts local dates for form inputs', () => {
|
||||||
expect(toIsoDate(new Date(2024, 0, 5, 23, 59))).toBe('2024-01-05')
|
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', () => {
|
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, 13))).toBe('Good afternoon')
|
||||||
expect(greeting(new Date(2024, 0, 1, 20))).toBe('Good evening')
|
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
@@ -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 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`. */
|
/** `2026-10-06T14:00:00Z` → `Oct 6, 2026` in the viewer's time zone. */
|
||||||
export function formatDate(isoDate: string): string {
|
export const formatDate = (iso: string) => dateFormat.format(new Date(iso))
|
||||||
return displayDate.format(new Date(`${isoDate}T00:00:00Z`))
|
|
||||||
}
|
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 {
|
export function toIsoDate(date: Date): string {
|
||||||
const pad = (value: number) => String(value).padStart(2, '0')
|
const pad = (value: number) => String(value).padStart(2, '0')
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatLongDate(date: Date): string {
|
/** Value for an `<input type="datetime-local">` in local time. */
|
||||||
return longDate.format(date)
|
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 {
|
export function greeting(date: Date): string {
|
||||||
const hour = date.getHours()
|
const hour = date.getHours()
|
||||||
if (hour < 12) return 'Good morning'
|
if (hour < 12) return 'Good morning'
|
||||||
if (hour < 18) return 'Good afternoon'
|
if (hour < 18) return 'Good afternoon'
|
||||||
return 'Good evening'
|
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`
|
||||||
|
}
|
||||||
@@ -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('?')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-2
@@ -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 {
|
export interface NavItem {
|
||||||
label: string
|
label: string
|
||||||
@@ -9,15 +9,23 @@ export interface NavItem {
|
|||||||
export const primaryNav: NavItem[] = [
|
export const primaryNav: NavItem[] = [
|
||||||
{ label: 'Overview', to: '/', icon: LayoutDashboard },
|
{ label: 'Overview', to: '/', icon: LayoutDashboard },
|
||||||
{ label: 'Incidents', to: '/incidents', icon: ClipboardList },
|
{ label: 'Incidents', to: '/incidents', icon: ClipboardList },
|
||||||
|
{ label: 'My actions', to: '/actions', icon: ClipboardCheck },
|
||||||
{ label: 'Locations', to: '/locations', icon: MapPin },
|
{ label: 'Locations', to: '/locations', icon: MapPin },
|
||||||
{ label: 'Analytics', to: '/analytics', icon: BarChart3 },
|
{ label: 'Analytics', to: '/analytics', icon: BarChart3 },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const pageTitles: Record<string, string> = {
|
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',
|
'/': 'Overview',
|
||||||
'/incidents': 'Incidents',
|
'/incidents': 'Incidents',
|
||||||
|
'/actions': 'My actions',
|
||||||
'/locations': 'Locations',
|
'/locations': 'Locations',
|
||||||
'/analytics': 'Analytics',
|
'/analytics': 'Analytics',
|
||||||
'/report': 'Report an incident',
|
'/report': 'Report an incident',
|
||||||
|
'/settings': 'Settings',
|
||||||
'/about': 'About',
|
'/about': 'About',
|
||||||
|
}
|
||||||
|
return titles[pathname] ?? 'Not found'
|
||||||
}
|
}
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 []
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,15 +3,25 @@ import '@fontsource-variable/space-grotesk'
|
|||||||
import '@fontsource/dm-mono/400.css'
|
import '@fontsource/dm-mono/400.css'
|
||||||
import '@fontsource/dm-mono/500.css'
|
import '@fontsource/dm-mono/500.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import { 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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+86
-23
@@ -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 { Eyebrow } from '../components/Eyebrow'
|
||||||
import { Page } from '../components/Page'
|
import { Page } from '../components/Page'
|
||||||
import { PageIntro } from '../components/PageIntro'
|
import { PageIntro } from '../components/PageIntro'
|
||||||
import { ProgressBar } from '../components/ProgressRow'
|
import { 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 card = 'flex min-h-[240px] flex-col items-start p-[26px]'
|
||||||
const bigStat = 'mt-8 mb-[25px] font-display text-[25px] font-semibold'
|
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() {
|
export function Analytics() {
|
||||||
|
const [days, setDays] = useState(90)
|
||||||
|
const stats = useAnalytics(days)
|
||||||
|
const s = stats.data
|
||||||
|
|
||||||
|
const topType = s?.byType[0]
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageIntro eyebrow="Program intelligence" title="Analytics" copy="Patterns that help your team prevent the next incident." />
|
<PageIntro
|
||||||
<div className="grid gap-5 lg:grid-cols-[1.3fr_1fr_1fr]">
|
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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]`}>
|
<div className={`${card} rounded-lg border border-forest-900 bg-forest-900 text-[#dfece5] shadow-[0_5px_20px_#1b252305]`}>
|
||||||
<Flame size={22} />
|
<Flame size={22} />
|
||||||
<Eyebrow className="mt-4 text-[#9dc0b3]">Top signal</Eyebrow>
|
<Eyebrow className="mt-4 text-[#9dc0b3]">Most common</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>
|
{topType ? (
|
||||||
<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`}>
|
<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>
|
||||||
View contributing incidents <ArrowUpRight size={16} />
|
<p className="text-xs leading-[1.6] text-[#a7c0b8]">
|
||||||
</button>
|
{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>
|
||||||
<div className={`${panel} ${card}`}>
|
<div className={`${panel} ${card}`}>
|
||||||
<Eyebrow>Most common root cause</Eyebrow>
|
<Eyebrow>Most common root cause</Eyebrow>
|
||||||
<strong className={bigStat}>Inadequate guarding</strong>
|
{s.topRootCause ? (
|
||||||
|
<>
|
||||||
|
<strong className={bigStat}>{rootCauseCategoryLabel[s.topRootCause.category]}</strong>
|
||||||
<div className="flex w-full justify-between text-2xs text-muted">
|
<div className="flex w-full justify-between text-2xs text-muted">
|
||||||
<span>8 incidents</span>
|
<span>
|
||||||
<span>31% of total</span>
|
{s.topRootCause.count} root cause{s.topRootCause.count === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
<span>{pct(s.topRootCause.share)} of total</span>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar percent={31} className="mt-2.5 w-full" />
|
<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>
|
||||||
<div className={`${panel} ${card}`}>
|
<div className={`${panel} ${card}`}>
|
||||||
<Eyebrow>Response quality</Eyebrow>
|
<Eyebrow>Actions completed on time</Eyebrow>
|
||||||
<strong className={bigStat}>94%</strong>
|
<strong className={bigStat}>{s.onTimeActionRate === null ? '–' : pct(s.onTimeActionRate)}</strong>
|
||||||
<p className="text-xs leading-[1.6] text-muted">of corrective actions are completed on time.</p>
|
<p className="text-xs leading-[1.6] text-muted">of corrective actions with a due date were done by then.</p>
|
||||||
<div className="mt-auto flex h-[70px] w-full items-end gap-[7px] pt-4">
|
{s.monthlyCompletedActions.length > 0 && (
|
||||||
{responseTrend.map((height, index) => (
|
<div className="mt-auto flex h-[70px] w-full items-end gap-[7px] pt-4" aria-label="On-time share by month">
|
||||||
<i key={index} className="min-h-[15px] flex-1 rounded-t-[3px] bg-[#76a99d]" style={{ height: `${height}%` }} />
|
{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>
|
</div>
|
||||||
|
<ProgressBar percent={s.total ? (count / s.total) * 100 : 0} barClassName="bg-sage" />
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -1,24 +1,63 @@
|
|||||||
import { FileText, Filter, Plus, Search } from 'lucide-react'
|
import { FileDown, Filter, Plus, Search } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink, useSearchParams } from 'react-router-dom'
|
||||||
|
import { incidentStatuses, incidentTypes, severities } from '../../shared/enums.ts'
|
||||||
import { IncidentTable } from '../components/IncidentTable'
|
import { IncidentTable } from '../components/IncidentTable'
|
||||||
import { Page } from '../components/Page'
|
import { Page } from '../components/Page'
|
||||||
import { PageIntro } from '../components/PageIntro'
|
import { PageIntro } from '../components/PageIntro'
|
||||||
|
import { Pagination } from '../components/Pagination'
|
||||||
|
import { Empty, ErrorMessage, Loading } from '../components/QueryState'
|
||||||
import { filterBox, panel, primaryButton, secondaryButton } from '../components/styles'
|
import { filterBox, panel, primaryButton, secondaryButton } from '../components/styles'
|
||||||
import { ALL_TYPES, filterIncidents, type TypeFilter } from '../lib/incidents'
|
import { incidentTypeLabel, severityLabel, statusLabel } from '../lib/labels'
|
||||||
import { incidentTypes, type Incident } from '../lib/types'
|
import { filtersToQuery, useIncidents, useSites, type IncidentFilters } from '../lib/queries'
|
||||||
|
|
||||||
export function Incidents({ incidents }: { incidents: Incident[] }) {
|
const PAGE_SIZE = 25
|
||||||
const [query, setQuery] = useState('')
|
const select = 'bg-transparent text-xs text-ink outline-none'
|
||||||
const [type, setType] = useState<TypeFilter>(ALL_TYPES)
|
|
||||||
const filtered = filterIncidents(incidents, query, type)
|
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 (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageIntro
|
<PageIntro
|
||||||
eyebrow="Safety register"
|
eyebrow="Safety register"
|
||||||
title="Incidents"
|
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={
|
action={
|
||||||
<NavLink className={primaryButton} to="/report">
|
<NavLink className={primaryButton} to="/report">
|
||||||
<Plus size={17} /> Report incident
|
<Plus size={17} /> Report incident
|
||||||
@@ -26,36 +65,69 @@ export function Incidents({ incidents }: { incidents: Incident[] }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<section className={`${panel} overflow-hidden`}>
|
<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="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 sm:max-w-[350px] sm:flex-1 sm:basis-auto`}>
|
<div className={`${filterBox} basis-full lg:max-w-[300px] lg:flex-1 lg:basis-auto`}>
|
||||||
<Search size={17} />
|
<Search size={17} />
|
||||||
<input
|
<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" />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={`${filterBox} sm:ml-auto`}>
|
<div className={filterBox}>
|
||||||
<Filter size={16} />
|
<Filter size={16} />
|
||||||
<select
|
<select className={select} value={filters.type ?? ''} onChange={(event) => setFilter('type', event.target.value)} aria-label="Filter by type">
|
||||||
className="bg-transparent text-xs text-ink outline-none"
|
<option value="">All types</option>
|
||||||
value={type}
|
{incidentTypes.map((type) => (
|
||||||
onChange={(event) => setType(event.target.value as TypeFilter)}
|
<option key={type} value={type}>
|
||||||
aria-label="Filter by type"
|
{incidentTypeLabel[type]}
|
||||||
>
|
</option>
|
||||||
<option>{ALL_TYPES}</option>
|
|
||||||
{incidentTypes.map((option) => (
|
|
||||||
<option key={option}>{option}</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className={secondaryButton}>
|
<div className={filterBox}>
|
||||||
<FileText size={16} /> Export
|
<select className={select} value={filters.severity ?? ''} onChange={(event) => setFilter('severity', event.target.value)} aria-label="Filter by severity">
|
||||||
</button>
|
<option value="">All severities</option>
|
||||||
|
{severities.map((severity) => (
|
||||||
|
<option key={severity} value={severity}>
|
||||||
|
{severityLabel[severity]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<IncidentTable incidents={filtered} wide />
|
<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>
|
||||||
|
<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>
|
</section>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
|
|||||||
+186
-50
@@ -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 { Page } from '../components/Page'
|
||||||
import { PageIntro } from '../components/PageIntro'
|
import { PageIntro } from '../components/PageIntro'
|
||||||
import { PanelHeading } from '../components/PanelHeading'
|
import { Empty, ErrorMessage, Loading } from '../components/QueryState'
|
||||||
import { panel, primaryButton } from '../components/styles'
|
import { field, iconButton, panel, primaryButton, secondaryButton, smallButton, textButton } from '../components/styles'
|
||||||
import { sites } from '../lib/seed'
|
import { areaKindLabel } from '../lib/labels'
|
||||||
import { toneClasses, type Tone } from '../lib/tones'
|
import { toneClasses, type Tone } from '../lib/tones'
|
||||||
|
import { useOverviewStats, useSiteMutations, useSites, type Me } from '../lib/queries'
|
||||||
|
|
||||||
const siteTones: Tone[] = ['coral', 'amber', 'sky']
|
const siteTones: Tone[] = ['coral', 'amber', 'sky', 'mint']
|
||||||
const mapPin = 'absolute size-3.5 rounded-full border-[3px] border-white bg-coral shadow-[0_2px_5px_#275c5550]'
|
|
||||||
|
/** 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() {
|
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 (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageIntro
|
<PageIntro
|
||||||
eyebrow="Site map"
|
eyebrow="Site register"
|
||||||
title="Locations"
|
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={
|
action={
|
||||||
<button type="button" className={primaryButton}>
|
canManage &&
|
||||||
<Plus size={17} /> Add location
|
!adding && (
|
||||||
|
<button type="button" className={primaryButton} onClick={() => setAdding(true)}>
|
||||||
|
<Plus size={17} /> Add site
|
||||||
</button>
|
</button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{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>
|
||||||
|
<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">
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
<section className={panel}>
|
{sites.data?.map((site, index) => (
|
||||||
<PanelHeading eyebrow="Sites" title="Location register" />
|
<SiteCard key={site.id} site={site} tone={siteTones[index % siteTones.length]} count={counts.get(site.id) ?? 0} canManage={canManage} />
|
||||||
{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>
|
|
||||||
</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>
|
||||||
|
)}
|
||||||
</Page>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+100
-47
@@ -1,87 +1,140 @@
|
|||||||
import { Bell, CheckCircle2, Plus, ShieldCheck, Siren } from 'lucide-react'
|
import { AlarmClock, CalendarClock, CheckCircle2, Plus, Siren } from 'lucide-react'
|
||||||
import { NavLink } from 'react-router-dom'
|
import { Link, NavLink, useOutletContext } from 'react-router-dom'
|
||||||
|
import { incidentStatuses } from '../../shared/enums.ts'
|
||||||
import { IncidentTable } from '../components/IncidentTable'
|
import { IncidentTable } from '../components/IncidentTable'
|
||||||
import { MetricCard } from '../components/MetricCard'
|
import { MetricCard } from '../components/MetricCard'
|
||||||
import { Page } from '../components/Page'
|
import { Page } from '../components/Page'
|
||||||
import { PageIntro } from '../components/PageIntro'
|
import { PageIntro } from '../components/PageIntro'
|
||||||
import { PanelHeading } from '../components/PanelHeading'
|
import { PanelHeading } from '../components/PanelHeading'
|
||||||
import { ProgressRow } from '../components/ProgressRow'
|
import { ProgressRow } from '../components/ProgressRow'
|
||||||
|
import { Empty, ErrorMessage, Loading } from '../components/QueryState'
|
||||||
import { panel, primaryButton } from '../components/styles'
|
import { panel, primaryButton } from '../components/styles'
|
||||||
import { formatLongDate, greeting } from '../lib/dates'
|
import { formatLongDate, formatWeek, greeting } from '../lib/dates'
|
||||||
import { currentUser } from '../lib/seed'
|
import { statusLabel } from '../lib/labels'
|
||||||
import type { Incident } from '../lib/types'
|
import { useIncidents, useOverviewStats, type Me } from '../lib/queries'
|
||||||
|
|
||||||
const trendPath =
|
function TrendChart({ weeks }: { weeks: { weekStart: string; count: number }[] }) {
|
||||||
'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'
|
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 now = new Date()
|
||||||
|
const s = stats.data
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageIntro
|
<PageIntro
|
||||||
eyebrow={formatLongDate(now)}
|
eyebrow={formatLongDate(now)}
|
||||||
title={`${greeting(now)}, ${currentUser.name.split(' ')[0]}`}
|
title={`${greeting(now)}, ${me.user.name.split(' ')[0]}`}
|
||||||
copy="Here’s what’s happening across your safety program."
|
copy={`Here’s what’s happening across ${me.organization.name}’s safety program.`}
|
||||||
action={
|
action={
|
||||||
<NavLink className={primaryButton} to="/report">
|
<NavLink className={primaryButton} to="/report">
|
||||||
<Plus size={17} /> Report incident
|
<Plus size={17} /> Report incident
|
||||||
</NavLink>
|
</NavLink>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<ErrorMessage error={stats.error} className="mb-5" />
|
||||||
{/* Placeholder figures until metrics are computed from real data (Phase 2) */}
|
{!s ? (
|
||||||
|
stats.isPending && <Loading />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="mb-5 grid grid-cols-2 gap-[9px] sm:gap-[13px] lg:grid-cols-4">
|
<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" />
|
<Link to="/incidents" className="contents">
|
||||||
<MetricCard label="Due this week" value="07" delta="2 due today" icon={Bell} tone="amber" />
|
<MetricCard label="Open incidents" value={String(s.openIncidents)} delta={`${s.reportedLast7Days} reported in the last 7 days`} icon={Siren} tone="coral" />
|
||||||
<MetricCard label="Avg. close time" value="4.2d" delta="0.8d faster" icon={CheckCircle2} tone="mint" />
|
</Link>
|
||||||
<MetricCard label="Program health" value="92%" delta="Up 4% this month" icon={ShieldCheck} tone="sky" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="mb-5 grid gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(260px,0.8fr)]">
|
<div className="mb-5 grid gap-5 lg:grid-cols-[minmax(0,1.7fr)_minmax(260px,0.8fr)]">
|
||||||
<section className={`${panel} overflow-hidden`}>
|
<section className={`${panel} overflow-hidden`}>
|
||||||
<PanelHeading eyebrow="Needs attention" title="Recent incidents" link={{ label: 'View all', to: '/incidents' }} />
|
<PanelHeading eyebrow="Latest" title="Recent incidents" link={{ label: 'View all', to: '/incidents' }} />
|
||||||
<IncidentTable incidents={incidents} limit={5} />
|
{recent.data?.items.length ? <IncidentTable incidents={recent.data.items} /> : recent.isPending ? <Loading /> : <Empty>No incidents reported yet.</Empty>}
|
||||||
</section>
|
</section>
|
||||||
<section className={`${panel} pb-[18px]`}>
|
<section className={`${panel} pb-[18px]`}>
|
||||||
<PanelHeading eyebrow="Response progress" title="Open actions" />
|
<PanelHeading eyebrow="Workflow" title="Where incidents are" />
|
||||||
<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="mx-6 mb-4 flex items-baseline justify-between rounded-md bg-canvas px-3 py-2.5 text-2xs text-muted">
|
||||||
<div className="-rotate-[28deg] text-center">
|
<span>Corrective actions complete</span>
|
||||||
<strong className="block font-display text-[25px] font-semibold">68%</strong>
|
<strong className="font-mono text-ink">
|
||||||
<span className="text-3xs text-muted">complete</span>
|
{s.actionProgress.completed} / {s.actionProgress.total}
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{incidentStatuses.map((status) => {
|
||||||
<ProgressRow label="Root cause review" value="8 / 12" percent={66} />
|
const total = Object.values(s.byStatus).reduce((sum, n) => sum + n, 0)
|
||||||
<ProgressRow label="Corrective actions" value="14 / 21" percent={67} />
|
return (
|
||||||
<ProgressRow label="Team sign-offs" value="28 / 32" percent={87} />
|
<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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-5 lg:grid-cols-[1.3fr_0.9fr]">
|
<div className="grid gap-5 lg:grid-cols-[1.3fr_0.9fr]">
|
||||||
<section className={`${panel} min-h-[260px]`}>
|
<section className={`${panel} min-h-[260px]`}>
|
||||||
<PanelHeading eyebrow="Signal check" title="Incident trend" />
|
<PanelHeading eyebrow="Last 12 weeks" title="Incidents per week" />
|
||||||
<div className="relative mx-[25px] mt-1 mb-[22px] h-[170px]">
|
<TrendChart weeks={s.weeklyTrend} />
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
<section className={panel}>
|
<section className={panel}>
|
||||||
<PanelHeading eyebrow="By location" title="Where incidents happen" link={{ label: 'Explore', to: '/locations' }} />
|
<PanelHeading eyebrow="This year" title="Where incidents happen" link={{ label: 'Locations', to: '/locations' }} />
|
||||||
<ProgressRow variant="location" label="North Campus" value="12" percent={82} />
|
{s.bySite.length === 0 ? (
|
||||||
<ProgressRow variant="location" label="South Campus" value="7" percent={52} />
|
<Empty>
|
||||||
<ProgressRow variant="location" label="West Campus" value="5" percent={38} />
|
<Link to="/locations" className="text-coral underline">
|
||||||
<ProgressRow variant="location" label="Remote / other" value="3" percent={24} />
|
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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+139
-69
@@ -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 { 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 { Field, FormSection } from '../components/FormSection'
|
||||||
import { Page } from '../components/Page'
|
import { Page } from '../components/Page'
|
||||||
import { PageIntro } from '../components/PageIntro'
|
import { PageIntro } from '../components/PageIntro'
|
||||||
import { field, panel, primaryButton, secondaryButton } from '../components/styles'
|
import { ErrorMessage, Loading } from '../components/QueryState'
|
||||||
import { sites } from '../lib/seed'
|
import { field, iconButton, panel, primaryButton, secondaryButton } from '../components/styles'
|
||||||
import { incidentTypes, severities, type NewIncident } from '../lib/types'
|
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 = {
|
const MAX_PHOTOS = 10
|
||||||
title: '',
|
|
||||||
type: 'Near miss',
|
|
||||||
severity: 'Medium',
|
|
||||||
site: sites[0].name,
|
|
||||||
building: sites[0].buildings[0],
|
|
||||||
location: '',
|
|
||||||
details: '',
|
|
||||||
cause: '',
|
|
||||||
response: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReportIncident({ onSubmit }: { onSubmit: (report: NewIncident) => void }) {
|
export function ReportIncident() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [form, setForm] = useState<NewIncident>(emptyReport)
|
const sites = useSites()
|
||||||
const buildings = sites.find((site) => site.name === form.site)?.buildings ?? []
|
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>) =>
|
const siteId = form.siteId || sites.data?.[0]?.id || ''
|
||||||
setForm({ ...form, [key]: event.target.value })
|
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 addPhotos = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const site = event.target.value
|
setPhotos([...photos, ...Array.from(event.target.files ?? [])].slice(0, MAX_PHOTOS))
|
||||||
setForm({ ...form, site, building: sites.find((s) => s.name === site)?.buildings[0] ?? '' })
|
event.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = (event: FormEvent) => {
|
const submit = async (event: FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!form.title.trim() || !form.location.trim()) return
|
setUploadError(undefined)
|
||||||
onSubmit(form)
|
const incident = await create
|
||||||
navigate('/incidents')
|
.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 (
|
return (
|
||||||
<Page narrow>
|
<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}>
|
<form className={`${panel} overflow-hidden`} onSubmit={submit}>
|
||||||
<FormSection number="01" title="What happened?" copy="Start with a clear, factual description.">
|
<FormSection number="01" title="What happened?" copy="Start with a clear, factual description.">
|
||||||
<Field label="Incident title">
|
<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>
|
</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">
|
<Field label="Incident type">
|
||||||
<select className={field} value={form.type} onChange={update('type')}>
|
<select className={field} value={form.type} onChange={update('type')}>
|
||||||
{incidentTypes.map((option) => (
|
{incidentTypes.map((type) => (
|
||||||
<option key={option}>{option}</option>
|
<option key={type} value={type}>
|
||||||
|
{incidentTypeLabel[type]}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Severity">
|
<Field label="Severity">
|
||||||
<select className={field} value={form.severity} onChange={update('severity')}>
|
<select className={field} value={form.severity} onChange={update('severity')}>
|
||||||
{severities.map((option) => (
|
{severities.map((severity) => (
|
||||||
<option key={option}>{option}</option>
|
<option key={severity} value={severity}>
|
||||||
|
{severityLabel[severity]}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="When">
|
||||||
|
<input className={field} type="datetime-local" required max={toDateTimeLocal(new Date())} value={form.occurredAt} onChange={update('occurredAt')} />
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Field label="What happened?" optional>
|
<Field label="What happened?" optional>
|
||||||
<textarea
|
<textarea className={`${field} resize-y`} value={form.description} onChange={update('description')} placeholder="Describe what you observed, without assumptions..." rows={4} />
|
||||||
className={`${field} resize-y`}
|
|
||||||
value={form.details}
|
|
||||||
onChange={update('details')}
|
|
||||||
placeholder="Describe what you observed, without assumptions..."
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
<FormSection number="02" title="Where did it happen?" copy="Pinpoint the location so patterns become visible.">
|
<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]">
|
<div className="grid sm:grid-cols-2 sm:gap-[18px]">
|
||||||
<Field label="Site">
|
<Field label="Site">
|
||||||
<select className={field} value={form.site} onChange={changeSite}>
|
<select className={field} value={siteId} onChange={update('siteId')}>
|
||||||
{sites.map((site) => (
|
{sites.data?.map((site) => (
|
||||||
<option key={site.name}>{site.name}</option>
|
<option key={site.id} value={site.id}>
|
||||||
|
{site.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Building">
|
<Field label="Building or area" optional>
|
||||||
<select className={field} value={form.building} onChange={update('building')}>
|
<select className={field} value={form.areaId} onChange={update('areaId')}>
|
||||||
{buildings.map((building) => (
|
<option value="">Not specified</option>
|
||||||
<option key={building}>{building}</option>
|
{areas.map((area) => (
|
||||||
|
<option key={area.id} value={area.id}>
|
||||||
|
{area.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Field label="Specific location">
|
<Field label="Exact location" optional>
|
||||||
<input className={field} required value={form.location} onChange={update('location')} placeholder="e.g. Ground floor lobby, Line 3" />
|
<input className={field} value={form.locationDetail} onChange={update('locationDetail')} placeholder="e.g. Ground floor lobby, Line 3" />
|
||||||
</Field>
|
</Field>
|
||||||
</FormSection>
|
</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>
|
<Field label="Immediate response" optional>
|
||||||
<textarea
|
<textarea className={`${field} resize-y`} value={form.immediateAction} onChange={update('immediateAction')} placeholder="e.g. Area isolated, supervisor notified..." rows={3} />
|
||||||
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" />
|
|
||||||
</Field>
|
</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>
|
</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">
|
<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">
|
||||||
<span>
|
<div className="space-y-2">
|
||||||
<span className="mr-1.5 text-[#3d9c7e]">✓</span>Save a draft at any time.
|
<ErrorMessage error={create.error} />
|
||||||
</span>
|
{uploadError && <ErrorMessage error={new Error(uploadError)} />}
|
||||||
<div className="flex w-full justify-end gap-[9px] sm:w-auto">
|
</div>
|
||||||
|
<div className="flex justify-end gap-[9px]">
|
||||||
<NavLink to="/" className={secondaryButton}>
|
<NavLink to="/" className={secondaryButton}>
|
||||||
Cancel
|
Cancel
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<button className={primaryButton} type="submit">
|
<button className={primaryButton} type="submit" disabled={create.isPending}>
|
||||||
Submit report <ArrowUpRight size={16} />
|
{create.isPending ? 'Submitting…' : 'Submit report'} <ArrowUpRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.server.tsbuildinfo",
|
||||||
"target": "ES2023",
|
"target": "ES2024",
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2024"],
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"types": ["node", "vitest/globals"],
|
"types": ["node", "vitest/globals"],
|
||||||
|
|||||||
+1
-1
@@ -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: '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'] },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user