feat(i18n): add internationalization support with English and Spanish translations
feat(kiosk): implement kiosk report submission functionality with offline support test(outbox): add tests for outbox functionality including report submission and error handling feat(outbox): create outbox management for queued reports with IndexedDB persistence feat(uuid): add UUID generation utility for client IDs feat(qr): implement QR code generation for reporting links by site and area feat(kiosk): create Kiosk page for reporting incidents without an account
This commit is contained in:
1 parent
3f6dff5f24
commit
3e964d1176
64 files changed
+10048
-259
No files matched your search
+2
-1
@@ -5,7 +5,8 @@
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }],
|
||||
"jsx-a11y/control-has-associated-label": "off",
|
||||
"jsx-a11y/prefer-tag-over-role": "off"
|
||||
"jsx-a11y/prefer-tag-over-role": "off",
|
||||
"jsx-a11y/label-has-associated-control": ["error", { "depth": 4 }]
|
||||
},
|
||||
"ignorePatterns": ["dist", "playwright-report", "test-results"]
|
||||
}
|
||||
@@ -39,6 +39,7 @@ CI runs the same checks on every pull request.
|
||||
- **Permissions:** every API route must check the signed-in user's role and organization. Add a test showing a lower role is refused.
|
||||
- **Styling:** Tailwind CSS. Use the theme tokens in `src/index.css` (`text-ink`, `bg-coral`, `border-line`, …) rather than new hex values; add a token if a color is reused.
|
||||
- **Tests:** put domain logic in `src/lib/` with a unit test next to it (`*.test.ts`). Cover user-facing flows with a component test or a Playwright test in `e2e/`.
|
||||
- **Translations:** user-facing text on worker screens lives in `src/i18n/en.ts` with the Spanish in `src/i18n/es.ts`. TypeScript and a test fail if a Spanish entry is missing, so add both together. If you can't translate, open the pull request anyway and ask for help.
|
||||
- **Accessibility:** every form control needs a label, and every icon-only button needs an `aria-label`. Shop-floor users may be on gloves-on touchscreens, so keep touch targets generous.
|
||||
- **Safety data:** incident and injury records can contain medical and personal information. Never log them, and never add third-party scripts, fonts or analytics that would send data off the server.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Free, open-source accident and safety tracking for manufacturing: incident and near-miss reporting, investigations, corrective actions and (planned) OSHA record keeping.
|
||||
|
||||
> **Status: early development (Phases 0–2 done).** Reporting, investigation, corrective actions, email notifications, dashboards and CSV export work end to end. OSHA record keeping and offline shop-floor reporting are next. See [docs/ROADMAP.md](docs/ROADMAP.md) for the plan and [docs/TODO.md](docs/TODO.md) for open items.
|
||||
> **Status: early development (Phases 0–3 done).** Reporting (including offline, kiosk tablets, QR codes and Spanish), investigation, corrective actions, email notifications, dashboards and CSV export work end to end. OSHA record keeping is next. See [docs/ROADMAP.md](docs/ROADMAP.md) for the plan and [docs/TODO.md](docs/TODO.md) for open items.
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -33,6 +33,13 @@ This runs the app and PostgreSQL. The app applies database migrations on startup
|
||||
- Set `SMTP_URL` and `MAIL_FROM` so sign-in links, invitations and notifications are delivered.
|
||||
- Set `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).
|
||||
- Installing the app on phones and opening it offline needs HTTPS (browsers require it for service workers). Over plain HTTP everything else works, and reports made while offline are still queued once the page is open.
|
||||
|
||||
## Shop-floor reporting
|
||||
|
||||
- **Kiosk tablets and QR codes:** in **Settings → Reporting links**, create a link. Open it on a shared tablet for a kiosk, or print QR codes for it from **Locations → QR codes**. Anyone can then report without an account; the link can't read anything and can be revoked at any time.
|
||||
- **Offline:** reports made without a connection are kept on the device and sent automatically when it returns.
|
||||
- **Spanish:** use the language button (sidebar, sign-in page or kiosk header). The choice is remembered on that device.
|
||||
|
||||
For a very small single-machine install, the app image also runs on its own with embedded PostgreSQL: `docker run -p 3000:3000 -v safetylog-data:/app/data <image>`.
|
||||
|
||||
|
||||
+20
-1
@@ -68,6 +68,8 @@ Addressed by number (`/api/incidents/42` is `INC-0042`). Numbers are sequential
|
||||
|
||||
**List and export filters:** `page`, `pageSize` (1–100, default 25), `q` (title, description, location; `INC-0042` or `42` matches the number), `type`, `severity`, `status`, `siteId`.
|
||||
|
||||
Send a random `clientId` (UUID) with each report. Resubmitting the same `clientId` returns the original report (200 instead of 201) instead of creating a duplicate, so devices can safely retry after a dropped connection.
|
||||
|
||||
**Create body:**
|
||||
|
||||
```json
|
||||
@@ -81,7 +83,8 @@ Addressed by number (`/api/incidents/42` is `INC-0042`). Numbers are sequential
|
||||
"locationDetail": "Loading bay C",
|
||||
"description": "…",
|
||||
"immediateAction": "Area cordoned off",
|
||||
"anonymous": false
|
||||
"anonymous": false,
|
||||
"clientId": "6f1c…"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -119,6 +122,22 @@ Addressed by number (`/api/incidents/42` is `INC-0042`). Numbers are sequential
|
||||
|
||||
Each action has `overdue: true` when it's open or in progress and its due date has passed (server time zone, `TZ`). Assignees get one email digest per day listing their actions due tomorrow or overdue.
|
||||
|
||||
## Reporting links (kiosks and QR codes)
|
||||
|
||||
Reporting links let people file reports without an account. Each has a token that can **only** create reports.
|
||||
|
||||
| Method | Path | Who | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/api/kiosks` | safety_manager | All links, including their tokens (to reprint QR codes) |
|
||||
| POST | `/api/kiosks` | safety_manager | `{ name, siteId? }`. `siteId` restricts the link to one site |
|
||||
| PATCH | `/api/kiosks/:id` | safety_manager | `{ name?, siteId?, revoked? }` |
|
||||
| GET | `/api/kiosk/config` | link token | Organization name, link name, and the sites/areas it may report on |
|
||||
| POST | `/api/kiosk/reports` | link token | Multipart: `report` (JSON: the create body minus `anonymous`, plus optional `reporterName`) and up to 5 `files`. Returns `{ reference, failedFiles }`. 30 reports per link per 10 minutes |
|
||||
|
||||
The token goes in the `X-Kiosk-Token` header. Browsers can't send custom headers cross-site without a CORS preflight, which the API doesn't allow, so other websites can't submit through a link. Reports without `reporterName` are anonymous. Incidents filed this way have `channel: "kiosk"` and `kiosk: { id, name }`.
|
||||
|
||||
The web app opens links at `/k/<token>`, optionally with `?site=<id>&area=<id>` to pre-fill the location (as QR codes do).
|
||||
|
||||
## Stats
|
||||
|
||||
| Method | Path | Who | Notes |
|
||||
|
||||
+10
-4
@@ -17,6 +17,9 @@ This is a living document. Update it as decisions are made or scope changes.
|
||||
| Compliance scope | US OSHA first; data model kept extensible for other regions (e.g. RIDDOR) | Proposed |
|
||||
| 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 |
|
||||
| Reporting without accounts | "Reporting links": revocable, rate-limited tokens that can only file reports; used for kiosk tablets and QR codes | Decided |
|
||||
| Languages | English + Spanish with typed message files; worker-facing screens first, manager screens later (see TODO) | Decided |
|
||||
| Offline | Installable PWA; reports queued in IndexedDB and retried with a client-generated id so retries never duplicate | Decided |
|
||||
| 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 |
|
||||
|
||||
@@ -61,10 +64,13 @@ This is a living document. Update it as decisions are made or scope changes.
|
||||
|
||||
## Phase 3: Shop-floor reporting
|
||||
|
||||
- Installable PWA with offline queue (sync when connection returns)
|
||||
- Printable QR codes per area that pre-fill the location
|
||||
- Kiosk mode and anonymous reporting
|
||||
- Multi-language support, Spanish first
|
||||
- [x] Installable app (web manifest, icons, service worker caching the app shell plus who-am-I and site lists)
|
||||
- [x] Offline outbox: reports and photos saved on the device, sent automatically on reconnect; duplicate-proof retries via a client-generated id; reports only sent for the user who made them; warning on sign-out with unsent reports
|
||||
- [x] Reporting links (managed in Settings): revocable tokens, optionally limited to one site, 30 reports per 10 minutes, photos included; kiosk page for shared tablets with large touch targets and auto-reset
|
||||
- [x] Printable QR code sheets per site/area, for signed-in users or through a reporting link, with bilingual captions; scanning pre-fills the location
|
||||
- [x] Anonymous reporting from the app, the kiosk and QR codes
|
||||
- [x] English/Spanish: sign-in pages, navigation, report form, kiosk, My actions, offline messages; language toggle remembered per device; follows the browser language by default
|
||||
- [x] Tests: outbox (IndexedDB), translations complete, reporting links and duplicate-proof retries on the server, browser tests for offline sync, Spanish kiosk via QR, QR pre-fill and installability
|
||||
|
||||
## Phase 4: Compliance (US OSHA)
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@ Open items that aren't tied to a single roadmap phase. Tick them off (or delete
|
||||
- [ ] **HEIC thumbnails.** iPhone HEIC photos are stored and downloadable but browsers can't show them inline; convert to JPEG on upload.
|
||||
- [ ] **Session check console noise.** Signed-out visitors see one 401 in the browser console from the session check; harmless, but a `200 { user: null }` endpoint would be quieter.
|
||||
|
||||
## Phase 3 follow-ups
|
||||
|
||||
- [ ] **Translate manager screens.** Spanish currently covers what shop-floor workers use (sign-in, report form, kiosk, My actions, navigation). The incident page, investigation, dashboards, analytics, locations and settings are still English. Add their strings to `src/i18n/en.ts` and `es.ts`.
|
||||
- [ ] **Spanish emails.** Notification and sign-in emails are English only. Store a language preference per user and use it for emails.
|
||||
- [ ] **HTTPS for offline use.** Browsers only allow installing the app and the offline app shell over HTTPS (or localhost). Reports still queue offline over plain HTTP once the page is open, but the page itself can't load offline. Document a simple HTTPS setup (e.g. Caddy in front of the app) in the install guide.
|
||||
- [ ] **Have Spanish reviewed by a native speaker** familiar with plant safety terms (e.g. "casi accidente" for near miss, "planta" for site).
|
||||
- [ ] **Offline incident list.** Only reporting works offline; viewing incidents and actions needs a connection.
|
||||
|
||||
## 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.
|
||||
|
||||
+6
-1
@@ -12,7 +12,12 @@ export default async function globalSetup() {
|
||||
}
|
||||
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' } }))
|
||||
const { area } = await ok(await api.post(`/api/sites/${site.id}/areas`, { data: { name: 'Press Shop', kind: 'building' } }))
|
||||
const { kiosk } = await ok(await api.post('/api/kiosks', { data: { name: 'Canteen tablet' }, headers: { origin: baseURL } }))
|
||||
// Shared with the test workers
|
||||
process.env.E2E_KIOSK_TOKEN = kiosk.token
|
||||
process.env.E2E_SITE_ID = site.id
|
||||
process.env.E2E_AREA_ID = area.id
|
||||
for (const role of ['supervisor', 'reporter'] as const) {
|
||||
await ok(await api.post('/api/users', { data: { ...users[role], role, password: PASSWORD } }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { signIn, unique, users } from './fixtures'
|
||||
|
||||
const token = () => process.env.E2E_KIOSK_TOKEN!
|
||||
const site = () => process.env.E2E_SITE_ID!
|
||||
const area = () => process.env.E2E_AREA_ID!
|
||||
|
||||
test('saves a report offline and sends it when the connection returns', async ({ page, context }) => {
|
||||
const title = unique('Offline report: frayed sling')
|
||||
await signIn(page, 'reporter')
|
||||
await page.goto('/report')
|
||||
await expect(page.getByLabel('Incident title')).toBeVisible()
|
||||
|
||||
await context.setOffline(true)
|
||||
await page.getByLabel('Incident title').fill(title)
|
||||
await page.getByRole('button', { name: 'Submit report' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Saved on this device' })).toBeVisible()
|
||||
await expect(page.getByText('1 report waiting to send')).toBeVisible()
|
||||
|
||||
await context.setOffline(false)
|
||||
await expect(page.getByText('1 report waiting to send')).toBeHidden({ timeout: 15_000 })
|
||||
await page.goto('/incidents')
|
||||
await expect(page.getByRole('link', { name: title })).toBeVisible()
|
||||
})
|
||||
|
||||
test('kiosk: reports without an account, in Spanish, from a QR code', async ({ browser, page }) => {
|
||||
const title = unique('Cable suelto en el pasillo')
|
||||
const kiosk = await (await browser.newContext({ locale: 'es-MX' })).newPage()
|
||||
await kiosk.goto(`/k/${token()}?site=${site()}&area=${area()}`)
|
||||
await expect(kiosk.getByRole('heading', { name: 'Reporte un problema de seguridad' })).toBeVisible()
|
||||
await expect(kiosk.getByLabel('Edificio o área')).toHaveValue(area())
|
||||
|
||||
await kiosk.getByText('Peligro', { exact: true }).click()
|
||||
await kiosk.getByText('Alta', { exact: true }).click()
|
||||
await kiosk.getByLabel('En pocas palabras, ¿qué vio?').fill(title)
|
||||
await kiosk.getByLabel('Su nombre').fill('Luis')
|
||||
await kiosk.getByRole('button', { name: 'Enviar reporte' }).click()
|
||||
await expect(kiosk.getByRole('heading', { name: 'Gracias' })).toBeVisible()
|
||||
const reference = (await kiosk.getByText(/Su reporte INC-\d{4}/).textContent())!.match(/INC-\d{4}/)![0]
|
||||
|
||||
// Switching language works on the kiosk too
|
||||
await kiosk.getByRole('button', { name: 'Reportar otra cosa' }).click()
|
||||
await kiosk.getByRole('button', { name: 'English' }).click()
|
||||
await expect(kiosk.getByRole('heading', { name: 'Report a safety concern' })).toBeVisible()
|
||||
|
||||
// The safety team sees who reported it and through which link
|
||||
await signIn(page, 'admin')
|
||||
await page.goto(`/incidents/${Number(reference.slice(4))}`)
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText(title)
|
||||
await expect(page.getByText('Reported by Luis via Canteen tablet')).toBeVisible()
|
||||
await expect(page.getByText('Press Shop').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('kiosk: a made-up or revoked link explains itself', async ({ page }) => {
|
||||
await page.goto('/k/not-a-real-token')
|
||||
await expect(page.getByText('This reporting link isn’t working')).toBeVisible()
|
||||
})
|
||||
|
||||
test('QR codes open the report form with the location filled in', async ({ page, isMobile }) => {
|
||||
test.skip(isMobile, 'Printing QR sheets is a desktop task')
|
||||
await signIn(page, 'admin')
|
||||
await page.goto(`/locations/qr?site=${site()}`)
|
||||
await expect(page.getByRole('img', { name: /QR code for/ })).toHaveCount(2) // whole site + Press Shop
|
||||
const url = await page.locator('main').getByText(/\/report\?site=/).nth(1).textContent()
|
||||
expect(url).toContain(`area=${area()}`)
|
||||
|
||||
await page.goto(url!.trim())
|
||||
await expect(page.getByText('Location filled in from the QR code.')).toBeVisible()
|
||||
await expect(page.getByLabel('Building or area')).toHaveValue(area())
|
||||
})
|
||||
|
||||
test('the signed-in app speaks Spanish too', async ({ page, isMobile }) => {
|
||||
await signIn(page, 'reporter')
|
||||
if (isMobile) await page.getByRole('button', { name: 'Open menu' }).click()
|
||||
await page.getByRole('button', { name: 'Español' }).click()
|
||||
if (isMobile) await page.getByRole('button', { name: 'Cerrar menú' }).click()
|
||||
await page.goto('/report')
|
||||
await expect(page.getByRole('heading', { name: 'Reportar un incidente' })).toBeVisible()
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'es')
|
||||
await page.goto('/actions')
|
||||
await expect(page.getByRole('heading', { name: 'Mis acciones' })).toBeVisible()
|
||||
expect(users.reporter.email).toBeTruthy()
|
||||
})
|
||||
|
||||
test('is installable: manifest and service worker', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
const manifest = await (await page.request.get('/manifest.webmanifest')).json()
|
||||
expect(manifest).toMatchObject({ name: 'Open SafetyLog EHS', display: 'standalone' })
|
||||
expect(manifest.icons.map((icon: { sizes: string }) => icon.sizes)).toEqual(expect.arrayContaining(['192x192', '512x512']))
|
||||
const scope = await page.evaluate(async () => (await navigator.serviceWorker.ready).scope)
|
||||
expect(scope).toMatch(/\/$/)
|
||||
})
|
||||
@@ -3,6 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<meta name="theme-color" content="#163c37" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Open SafetyLog EHS</title>
|
||||
<meta name="description" content="Open-source accident and safety tracking for manufacturing" />
|
||||
|
||||
Generated
+5480
-6
File diff suppressed because it is too large.
Load diff
+6
-1
@@ -47,20 +47,25 @@
|
||||
"@types/node": "^26.6.4",
|
||||
"@types/nodemailer": "^8.0.2",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"concurrently": "^10.0.5",
|
||||
"drizzle-kit": "^0.31.11",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"jsdom": "^30.1.2",
|
||||
"lucide-react": "^1.39.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^5.0.3"
|
||||
"vite-plugin-pwa": "^2.0.0",
|
||||
"vitest": "^5.0.3",
|
||||
"workbox-window": "^7.4.1"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
@@ -0,0 +1,28 @@
|
||||
// Renders the app icon to the PNG sizes browsers need to install the app.
|
||||
// Run after changing the icon: node scripts/generate-icons.mjs
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
const shield = (stroke) => `
|
||||
<path d="M16 6.5 8.5 9.5v6c0 4.6 3.2 8.6 7.5 9.9 4.3-1.3 7.5-5.3 7.5-9.9v-6L16 6.5Z" fill="none" stroke="#fff" stroke-width="${stroke}" stroke-linejoin="round"/>
|
||||
<path d="m12.5 16 2.4 2.4 4.6-4.8" fill="none" stroke="#fff" stroke-width="${stroke}" stroke-linecap="round" stroke-linejoin="round"/>`
|
||||
|
||||
// Regular icon: rounded tile. Maskable: full-bleed square with the mark inside the safe zone.
|
||||
const regular = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#ee7258"/>${shield(2.2)}</svg>`
|
||||
const maskable = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#ee7258"/><g transform="translate(16 16) scale(0.72) translate(-16 -16)">${shield(2.4)}</g></svg>`
|
||||
|
||||
const outputs = [
|
||||
['public/icons/icon-192.png', regular, 192],
|
||||
['public/icons/icon-512.png', regular, 512],
|
||||
['public/icons/maskable-512.png', maskable, 512],
|
||||
['public/icons/apple-touch-icon.png', maskable, 180],
|
||||
]
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
for (const [path, svg, size] of outputs) {
|
||||
await page.setViewportSize({ width: size, height: size })
|
||||
await page.setContent(`<style>html,body{margin:0;background:transparent}</style><img src="data:image/svg+xml,${encodeURIComponent(svg)}" width="${size}" height="${size}">`)
|
||||
await page.locator('img').screenshot({ path, omitBackground: true })
|
||||
console.log('wrote', path)
|
||||
}
|
||||
await browser.close()
|
||||
@@ -11,6 +11,7 @@ import { actionRoutes } from './routes/actions.ts'
|
||||
import { attachmentRoutes } from './routes/attachments.ts'
|
||||
import { authRoutes } from './routes/auth.ts'
|
||||
import { incidentRoutes } from './routes/incidents.ts'
|
||||
import { kioskAdminRoutes, kioskPublicRoutes } from './routes/kiosks.ts'
|
||||
import { rootCauseRoutes } from './routes/root-causes.ts'
|
||||
import { setupRoutes } from './routes/setup.ts'
|
||||
import { areaRoutes, siteRoutes } from './routes/sites.ts'
|
||||
@@ -54,6 +55,8 @@ export function createApp({ db, config, notifier, storage }: AppDeps) {
|
||||
app.route('/api/actions', actionRoutes)
|
||||
app.route('/api/attachments', attachmentRoutes)
|
||||
app.route('/api/stats', statsRoutes)
|
||||
app.route('/api/kiosks', kioskAdminRoutes)
|
||||
app.route('/api/kiosk', kioskPublicRoutes())
|
||||
app.all('/api/*', (c) => c.json({ error: 'Not found' }, 404))
|
||||
|
||||
app.onError((error, c) => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE TYPE "public"."report_channel" AS ENUM('web', 'kiosk');--> statement-breakpoint
|
||||
CREATE TABLE "kiosks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"organization_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"site_id" uuid,
|
||||
"created_by_id" uuid,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD COLUMN "reporter_name" text;--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD COLUMN "channel" "report_channel" DEFAULT 'web' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD COLUMN "kiosk_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD COLUMN "client_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "kiosks_token_unique" ON "kiosks" USING btree ("token");--> statement-breakpoint
|
||||
CREATE INDEX "kiosks_organization_idx" ON "kiosks" USING btree ("organization_id");--> statement-breakpoint
|
||||
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_kiosk_id_kiosks_id_fk" FOREIGN KEY ("kiosk_id") REFERENCES "public"."kiosks"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "incidents_organization_client_id_unique" ON "incidents" USING btree ("organization_id","client_id");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,13 @@
|
||||
"when": 1791309275942,
|
||||
"tag": "0001_workflow_and_email",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1791310953907,
|
||||
"tag": "0002_kiosks_and_offline",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export const rootCauseCategoryEnum = pgEnum('root_cause_category', vocab.rootCau
|
||||
export const actionKindEnum = pgEnum('action_kind', vocab.actionKinds)
|
||||
export const actionStatusEnum = pgEnum('action_status', vocab.actionStatuses)
|
||||
export const authTokenPurposeEnum = pgEnum('auth_token_purpose', ['magic_link', 'password_reset'])
|
||||
export const reportChannelEnum = pgEnum('report_channel', ['web', 'kiosk'])
|
||||
|
||||
const id = () => uuid().primaryKey().defaultRandom()
|
||||
const timestamptz = () => timestamp({ withTimezone: true })
|
||||
@@ -115,6 +116,29 @@ export const areas = pgTable(
|
||||
(t) => [index('areas_site_idx').on(t.siteId), index('areas_parent_idx').on(t.parentId)],
|
||||
)
|
||||
|
||||
/**
|
||||
* Reporting links let people without accounts file reports: on a shared kiosk tablet, or by scanning
|
||||
* a QR code. The token only allows creating reports, so it is stored as-is to let admins reprint QR codes.
|
||||
*/
|
||||
export const kiosks = pgTable(
|
||||
'kiosks',
|
||||
{
|
||||
id: id(),
|
||||
organizationId: uuid()
|
||||
.notNull()
|
||||
.references(() => organizations.id),
|
||||
name: text().notNull(),
|
||||
token: text().notNull(),
|
||||
/** Limits the link to one site; null allows any. */
|
||||
siteId: uuid().references(() => sites.id, { onDelete: 'set null' }),
|
||||
createdById: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
lastUsedAt: timestamptz(),
|
||||
revokedAt: timestamptz(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [uniqueIndex('kiosks_token_unique').on(t.token), index('kiosks_organization_idx').on(t.organizationId)],
|
||||
)
|
||||
|
||||
/** Per-organization counter for gap-free, human-friendly incident numbers. */
|
||||
export const incidentCounters = pgTable('incident_counters', {
|
||||
organizationId: uuid()
|
||||
@@ -146,12 +170,19 @@ export const incidents = pgTable(
|
||||
/** Null when reported anonymously. */
|
||||
reporterId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
anonymous: boolean().notNull().default(false),
|
||||
/** Name typed by someone reporting without an account (kiosk/QR). */
|
||||
reporterName: text(),
|
||||
channel: reportChannelEnum().notNull().default('web'),
|
||||
kioskId: uuid().references(() => kiosks.id, { onDelete: 'set null' }),
|
||||
/** Generated on the reporting device so retries after a lost response don't create duplicates. */
|
||||
clientId: uuid(),
|
||||
closedAt: timestamptz(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('incidents_organization_number_unique').on(t.organizationId, t.number),
|
||||
uniqueIndex('incidents_organization_client_id_unique').on(t.organizationId, t.clientId),
|
||||
index('incidents_organization_occurred_idx').on(t.organizationId, t.occurredAt),
|
||||
index('incidents_organization_status_idx').on(t.organizationId, t.status),
|
||||
index('incidents_reporter_idx').on(t.reporterId),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import type { Config } from '../config.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { attachments } from '../db/schema.ts'
|
||||
import type { FileStorage } from '../storage.ts'
|
||||
import { detectFileType } from './file-type.ts'
|
||||
import { fail } from './http.ts'
|
||||
|
||||
export const MAX_ATTACHMENTS_PER_INCIDENT = 20
|
||||
|
||||
/** Validates and stores one uploaded file against an incident. */
|
||||
export async function saveAttachment(
|
||||
{ db, storage, config }: { db: Db; storage: FileStorage; config: Config },
|
||||
incidentId: string,
|
||||
file: unknown,
|
||||
uploadedById: string | null,
|
||||
) {
|
||||
if (!(file instanceof File)) throw fail(400, 'Attach a file in the "file" field')
|
||||
if (file.size > config.maxUploadBytes) throw fail(413, `Files can be at most ${Math.round(config.maxUploadBytes / 1024 / 1024)} MB`)
|
||||
const [{ existing }] = await db.select({ existing: count() }).from(attachments).where(eq(attachments.incidentId, incidentId))
|
||||
if (existing >= MAX_ATTACHMENTS_PER_INCIDENT) throw fail(409, `An incident can have at most ${MAX_ATTACHMENTS_PER_INCIDENT} attachments`)
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer())
|
||||
const type = detectFileType(data)
|
||||
if (!type) throw fail(400, 'Only photos (JPEG, PNG, WebP, GIF, HEIC) and PDFs can be attached')
|
||||
|
||||
const storageKey = await storage.save(data, type.extension)
|
||||
const fileName = (file.name || `attachment.${type.extension}`).replace(/[\r\n"\\/]/g, '_').slice(0, 200)
|
||||
try {
|
||||
const [row] = await db
|
||||
.insert(attachments)
|
||||
.values({ incidentId, fileName, contentType: type.contentType, sizeBytes: data.byteLength, storageKey, uploadedById })
|
||||
.returning()
|
||||
return row
|
||||
} catch (error) {
|
||||
await storage.remove(storageKey)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ 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 { areas, attachments, correctiveActions, incidents, investigations, kiosks, rootCauses, sites, users } from '../db/schema.ts'
|
||||
import { audit } from './audit.ts'
|
||||
import { iso, localDate } from './dates.ts'
|
||||
import { fail } from './http.ts'
|
||||
@@ -15,16 +15,17 @@ const reporters = alias(users, 'reporter')
|
||||
|
||||
export function selectIncidents(db: Db) {
|
||||
return db
|
||||
.select({ incident: incidents, siteName: sites.name, areaName: areas.name, reporterName: reporters.name })
|
||||
.select({ incident: incidents, siteName: sites.name, areaName: areas.name, reporterName: reporters.name, kioskName: kiosks.name })
|
||||
.from(incidents)
|
||||
.innerJoin(sites, eq(sites.id, incidents.siteId))
|
||||
.leftJoin(areas, eq(areas.id, incidents.areaId))
|
||||
.leftJoin(reporters, eq(reporters.id, incidents.reporterId))
|
||||
.leftJoin(kiosks, eq(kiosks.id, incidents.kioskId))
|
||||
}
|
||||
|
||||
type IncidentRow = Awaited<ReturnType<ReturnType<typeof selectIncidents>['execute']>>[number]
|
||||
|
||||
export function toIncident({ incident, siteName, areaName, reporterName }: IncidentRow): Incident {
|
||||
export function toIncident({ incident, siteName, areaName, reporterName, kioskName }: IncidentRow): Incident {
|
||||
return {
|
||||
id: incident.id,
|
||||
number: incident.number,
|
||||
@@ -44,6 +45,9 @@ export function toIncident({ incident, siteName, areaName, reporterName }: Incid
|
||||
immediateAction: incident.immediateAction,
|
||||
anonymous: incident.anonymous,
|
||||
reporter: incident.reporterId ? { id: incident.reporterId, name: reporterName ?? '' } : null,
|
||||
reporterName: incident.reporterName,
|
||||
channel: incident.channel,
|
||||
kiosk: incident.kioskId ? { id: incident.kioskId, name: kioskName ?? '' } : null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm'
|
||||
import type { IncidentType, Severity } from '../../shared/enums.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import { isUniqueViolation, type Db } from '../db/client.ts'
|
||||
import { areas, incidentCounters, incidents, sites } from '../db/schema.ts'
|
||||
import type { Notifier } from '../notifications.ts'
|
||||
import { fail } from './http.ts'
|
||||
import { auditIncident } from './incidents.ts'
|
||||
|
||||
/** Reports may be dated slightly ahead to absorb clock differences between devices. */
|
||||
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000
|
||||
|
||||
export function parseOccurredAt(value: string): Date {
|
||||
const date = new Date(value)
|
||||
if (date.getTime() > Date.now() + MAX_CLOCK_SKEW_MS) throw fail(400, 'Occurrence time cannot be in the future')
|
||||
return date
|
||||
}
|
||||
|
||||
export async function assertLocation(db: Db, organizationId: string, siteId: string, areaId: string | null | undefined) {
|
||||
const [site] = await db
|
||||
.select({ id: sites.id })
|
||||
.from(sites)
|
||||
.where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId), isNull(sites.archivedAt)))
|
||||
if (!site) throw fail(400, 'Unknown site')
|
||||
if (areaId) {
|
||||
const [area] = await db
|
||||
.select({ id: areas.id })
|
||||
.from(areas)
|
||||
.where(and(eq(areas.id, areaId), eq(areas.siteId, siteId)))
|
||||
if (!area) throw fail(400, 'Area must belong to the selected site')
|
||||
}
|
||||
}
|
||||
|
||||
export interface NewReport {
|
||||
organizationId: string
|
||||
title: string
|
||||
type: IncidentType
|
||||
severity: Severity
|
||||
occurredAt: string
|
||||
siteId: string
|
||||
areaId?: string | null
|
||||
locationDetail?: string | null
|
||||
description?: string | null
|
||||
immediateAction?: string | null
|
||||
clientId?: string
|
||||
/** Signed-in reporter, or null for anonymous and kiosk reports. */
|
||||
reporter: SessionUser | null
|
||||
reporterName?: string | null
|
||||
anonymous: boolean
|
||||
kioskId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Files a report with the next incident number. If `clientId` was already used in the organization
|
||||
* (a retried submission), returns the existing incident instead of creating a duplicate.
|
||||
*/
|
||||
export async function fileReport(db: Db, notifier: Notifier, report: NewReport): Promise<{ id: string; number: number; created: boolean }> {
|
||||
const { organizationId, reporter, anonymous, kioskId, occurredAt, clientId, ...fields } = report
|
||||
await assertLocation(db, organizationId, fields.siteId, fields.areaId)
|
||||
const occurred = parseOccurredAt(occurredAt)
|
||||
|
||||
const existing = async () => {
|
||||
if (!clientId) return undefined
|
||||
const [row] = await db
|
||||
.select({ id: incidents.id, number: incidents.number })
|
||||
.from(incidents)
|
||||
.where(and(eq(incidents.organizationId, organizationId), eq(incidents.clientId, clientId)))
|
||||
return row && { ...row, created: false }
|
||||
}
|
||||
const previous = await existing()
|
||||
if (previous) return previous
|
||||
|
||||
try {
|
||||
const created = await db.transaction(async (tx) => {
|
||||
// Atomic increment: concurrent reports always get distinct, gap-free numbers.
|
||||
const [{ lastNumber }] = await tx
|
||||
.insert(incidentCounters)
|
||||
.values({ organizationId, lastNumber: 1 })
|
||||
.onConflictDoUpdate({ target: incidentCounters.organizationId, set: { lastNumber: sql`${incidentCounters.lastNumber} + 1` } })
|
||||
.returning({ lastNumber: incidentCounters.lastNumber })
|
||||
const [incident] = await tx
|
||||
.insert(incidents)
|
||||
.values({
|
||||
...fields,
|
||||
occurredAt: occurred,
|
||||
organizationId,
|
||||
number: lastNumber,
|
||||
clientId,
|
||||
anonymous,
|
||||
reporterId: anonymous ? null : (reporter?.id ?? null),
|
||||
reporterName: anonymous ? null : (fields.reporterName ?? null),
|
||||
channel: kioskId ? 'kiosk' : 'web',
|
||||
kioskId,
|
||||
})
|
||||
.returning({ id: incidents.id, number: incidents.number })
|
||||
await auditIncident(tx, anonymous ? null : reporter, organizationId, incident.id, 'create', {
|
||||
number: lastNumber,
|
||||
title: fields.title,
|
||||
type: fields.type,
|
||||
severity: fields.severity,
|
||||
...(kioskId ? { kioskId } : {}),
|
||||
})
|
||||
return incident
|
||||
})
|
||||
notifier.incidentReported(created.id)
|
||||
return { ...created, created: true }
|
||||
} catch (error) {
|
||||
// Two copies of the same submission raced; the other one won.
|
||||
if (clientId && isUniqueViolation(error)) {
|
||||
const winner = await existing()
|
||||
if (winner) return winner
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
+11
-78
@@ -1,4 +1,4 @@
|
||||
import { and, asc, count, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm'
|
||||
import { and, asc, count, desc, eq, ilike, or, type SQL } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { bodyLimit } from 'hono/body-limit'
|
||||
import { z } from 'zod'
|
||||
@@ -19,42 +19,19 @@ import { transitionBlocker, transitions } from '../../shared/workflow.ts'
|
||||
import { requireAuth, requireRole } from '../auth/middleware.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { areas, attachments, auditLog, comments, correctiveActions, incidentCounters, incidents, investigations, rootCauses, sites, users } from '../db/schema.ts'
|
||||
import { auditLog, comments, correctiveActions, incidents, investigations, rootCauses, users } from '../db/schema.ts'
|
||||
import { diff } from '../lib/audit.ts'
|
||||
import { toCsv } from '../lib/csv.ts'
|
||||
import { saveAttachment } from '../lib/attachments.ts'
|
||||
import { iso } from '../lib/dates.ts'
|
||||
import { detectFileType } from '../lib/file-type.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { assertActiveUser, auditIncident, findIncident, loadDetail, selectIncidents, toAttachment, toIncident, visibleTo } from '../lib/incidents.ts'
|
||||
import { assertLocation, fileReport, parseOccurredAt } from '../lib/reporting.ts'
|
||||
|
||||
const numberParam = z.object({ number: z.coerce.number().int().positive() })
|
||||
|
||||
/** Reports may be dated slightly ahead to absorb clock differences between devices. */
|
||||
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000
|
||||
const MAX_ATTACHMENTS_PER_INCIDENT = 20
|
||||
const EXPORT_LIMIT = 10_000
|
||||
|
||||
async function assertLocation(db: Db, organizationId: string, siteId: string, areaId: string | null | undefined) {
|
||||
const [site] = await db
|
||||
.select({ id: sites.id })
|
||||
.from(sites)
|
||||
.where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId), isNull(sites.archivedAt)))
|
||||
if (!site) throw fail(400, 'Unknown site')
|
||||
if (areaId) {
|
||||
const [area] = await db
|
||||
.select({ id: areas.id })
|
||||
.from(areas)
|
||||
.where(and(eq(areas.id, areaId), eq(areas.siteId, siteId)))
|
||||
if (!area) throw fail(400, 'Area must belong to the selected site')
|
||||
}
|
||||
}
|
||||
|
||||
function parseOccurredAt(value: string): Date {
|
||||
const date = new Date(value)
|
||||
if (date.getTime() > Date.now() + MAX_CLOCK_SKEW_MS) throw fail(400, 'Occurrence time cannot be in the future')
|
||||
return date
|
||||
}
|
||||
|
||||
const escapeLike = (value: string) => value.replace(/[\\%_]/g, '\\$&')
|
||||
|
||||
function listFilter(db: Db, user: SessionUser, { q, type, severity, status, siteId }: Omit<IncidentListQuery, 'page' | 'pageSize'>) {
|
||||
@@ -122,7 +99,7 @@ export const incidentRoutes = new Hono<AppEnv>()
|
||||
i.site.name,
|
||||
i.area?.name,
|
||||
i.locationDetail,
|
||||
i.anonymous ? 'Anonymous' : i.reporter?.name,
|
||||
i.anonymous ? 'Anonymous' : (i.reporter?.name ?? i.reporterName ?? (i.kiosk ? `Reporting link: ${i.kiosk.name}` : null)),
|
||||
i.description,
|
||||
i.immediateAction,
|
||||
]),
|
||||
@@ -133,34 +110,11 @@ export const incidentRoutes = new Hono<AppEnv>()
|
||||
|
||||
.post('/', requireAuth, validate('json', createIncidentSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const { anonymous, occurredAt, ...input } = c.req.valid('json')
|
||||
const input = c.req.valid('json')
|
||||
const db = c.get('db')
|
||||
await assertLocation(db, me.organizationId, input.siteId, input.areaId)
|
||||
const occurred = parseOccurredAt(occurredAt)
|
||||
|
||||
const created = await db.transaction(async (tx) => {
|
||||
// Atomic increment: concurrent reports always get distinct, gap-free numbers.
|
||||
const [{ lastNumber }] = await tx
|
||||
.insert(incidentCounters)
|
||||
.values({ organizationId: me.organizationId, lastNumber: 1 })
|
||||
.onConflictDoUpdate({ target: incidentCounters.organizationId, set: { lastNumber: sql`${incidentCounters.lastNumber} + 1` } })
|
||||
.returning({ lastNumber: incidentCounters.lastNumber })
|
||||
const [incident] = await tx
|
||||
.insert(incidents)
|
||||
.values({ ...input, occurredAt: occurred, organizationId: me.organizationId, number: lastNumber, anonymous, reporterId: anonymous ? null : me.id })
|
||||
.returning({ id: incidents.id, number: incidents.number })
|
||||
await auditIncident(tx, anonymous ? null : me, me.organizationId, incident.id, 'create', {
|
||||
number: lastNumber,
|
||||
title: input.title,
|
||||
type: input.type,
|
||||
severity: input.severity,
|
||||
})
|
||||
return incident
|
||||
})
|
||||
|
||||
c.get('notifier').incidentReported(created.id)
|
||||
const [row] = await selectIncidents(db).where(eq(incidents.id, created.id))
|
||||
return c.json({ incident: toIncident(row) }, 201)
|
||||
const { id, created } = await fileReport(db, c.get('notifier'), { ...input, organizationId: me.organizationId, reporter: me })
|
||||
const [row] = await selectIncidents(db).where(eq(incidents.id, id))
|
||||
return c.json({ incident: toIncident(row) }, created ? 201 : 200)
|
||||
})
|
||||
|
||||
.get('/:number', requireAuth, validate('param', numberParam), async (c) => {
|
||||
@@ -330,29 +284,8 @@ export const incidentRoutes = new Hono<AppEnv>()
|
||||
const { incident } = await findIncident(db, me, c.req.valid('param').number)
|
||||
|
||||
const 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 })
|
||||
const row = await saveAttachment({ db, storage: c.get('storage'), config }, incident.id, body.file, me.id)
|
||||
await auditIncident(db, me, me.organizationId, incident.id, 'attachment.add', { attachmentId: row.id, fileName: row.fileName })
|
||||
return c.json({ attachment: toAttachment(row, me.name) }, 201)
|
||||
} catch (error) {
|
||||
await storage.remove(storageKey)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
// oxlint-disable typescript/no-explicit-any -- tests read arbitrary JSON
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createTestContext, hoursAgo } from '../test/helpers.ts'
|
||||
|
||||
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||
let north: { id: string }
|
||||
let south: { id: string }
|
||||
|
||||
const json = (res: Response): Promise<any> => res.json()
|
||||
|
||||
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52])
|
||||
|
||||
/** Submits like the kiosk page: multipart with the token header. */
|
||||
function kioskReport(token: string, report: Record<string, unknown>, files: File[] = []) {
|
||||
const form = new FormData()
|
||||
form.append('report', JSON.stringify(report))
|
||||
for (const file of files) form.append('files', file)
|
||||
return ctx.app.request('/api/kiosk/reports', { method: 'POST', headers: { 'x-kiosk-token': token, origin: 'http://safety.test' }, body: form })
|
||||
}
|
||||
const report = (overrides: Record<string, unknown> = {}) => ({ title: 'Leaking valve', type: 'hazard', severity: 'medium', occurredAt: hoursAgo(1), siteId: north.id, ...overrides })
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createTestContext()
|
||||
north = await ctx.createSite('North')
|
||||
south = await ctx.createSite('South')
|
||||
})
|
||||
afterAll(() => ctx.close())
|
||||
|
||||
describe('reporting links', () => {
|
||||
it('are managed by safety managers', async () => {
|
||||
const supervisor = await ctx.createUser('supervisor')
|
||||
expect((await supervisor.post('/api/kiosks', { name: 'Canteen tablet' })).status).toBe(403)
|
||||
const res = await ctx.admin.post('/api/kiosks', { name: 'Canteen tablet' })
|
||||
expect(res.status).toBe(201)
|
||||
expect(res.body.kiosk.token).toMatch(/^[\w-]{32}$/)
|
||||
expect((await ctx.admin.get('/api/kiosks')).body.kiosks.map((k: { name: string }) => k.name)).toContain('Canteen tablet')
|
||||
})
|
||||
|
||||
it('let anyone with the token file a report, with photos, and nothing more', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Gate 2' })).body
|
||||
|
||||
const config = await ctx.app.request('/api/kiosk/config', { headers: { 'x-kiosk-token': kiosk.token } })
|
||||
expect(await json(config)).toMatchObject({ organization: { name: 'Northstar Co.' }, kiosk: { name: 'Gate 2' } })
|
||||
|
||||
const res = await kioskReport(kiosk.token, report({ reporterName: 'Luis' }), [new File([PNG], 'valve.png', { type: 'image/png' })])
|
||||
expect(res.status).toBe(201)
|
||||
const { reference, failedFiles } = await json(res)
|
||||
expect(reference).toMatch(/^INC-\d{4}$/)
|
||||
expect(failedFiles).toEqual([])
|
||||
|
||||
const number = Number(reference.slice(4))
|
||||
const incident = (await ctx.admin.get(`/api/incidents/${number}`)).body.incident
|
||||
expect(incident).toMatchObject({ channel: 'kiosk', kiosk: { name: 'Gate 2' }, reporterName: 'Luis', reporter: null, anonymous: false })
|
||||
expect(incident.attachments).toHaveLength(1)
|
||||
|
||||
// The token is not a session
|
||||
expect((await ctx.app.request('/api/incidents', { headers: { 'x-kiosk-token': kiosk.token } })).status).toBe(401)
|
||||
})
|
||||
|
||||
it('treat reports without a name as anonymous', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Anon' })).body
|
||||
const { reference } = await json(await kioskReport(kiosk.token, report()))
|
||||
const incident = (await ctx.admin.get(`/api/incidents/${Number(reference.slice(4))}`)).body.incident
|
||||
expect(incident).toMatchObject({ anonymous: true, reporterName: null })
|
||||
})
|
||||
|
||||
it('stop working when revoked', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Old tablet' })).body
|
||||
await ctx.admin.patch(`/api/kiosks/${kiosk.id}`, { revoked: true })
|
||||
expect((await kioskReport(kiosk.token, report())).status).toBe(401)
|
||||
expect((await kioskReport('made-up-token', report())).status).toBe(401)
|
||||
})
|
||||
|
||||
it('can be limited to one site', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'North only', siteId: north.id })).body
|
||||
const config = await json(await ctx.app.request('/api/kiosk/config', { headers: { 'x-kiosk-token': kiosk.token } }))
|
||||
expect(config.sites.map((s: { id: string }) => s.id)).toEqual([north.id])
|
||||
expect((await kioskReport(kiosk.token, report({ siteId: south.id }))).status).toBe(400)
|
||||
})
|
||||
|
||||
it('validate reports', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Validation' })).body
|
||||
const res = await kioskReport(kiosk.token, report({ type: 'explosion' }))
|
||||
expect(res.status).toBe(400)
|
||||
expect((await json(res)).issues[0].path).toBe('type')
|
||||
})
|
||||
|
||||
it('are rate limited', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Busy' })).body
|
||||
for (let i = 0; i < 30; i++) expect((await kioskReport(kiosk.token, report({ severity: 'low' }))).status).toBe(201)
|
||||
expect((await kioskReport(kiosk.token, report({ severity: 'low' }))).status).toBe(429)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resubmitting the same report (offline retries)', () => {
|
||||
it('returns the original instead of creating a duplicate', async () => {
|
||||
const reporter = await ctx.createUser('reporter')
|
||||
const clientId = crypto.randomUUID()
|
||||
const first = await reporter.post('/api/incidents', { ...report(), clientId })
|
||||
const again = await reporter.post('/api/incidents', { ...report(), clientId })
|
||||
expect(first.status).toBe(201)
|
||||
expect(again.status).toBe(200)
|
||||
expect(again.body.incident.number).toBe(first.body.incident.number)
|
||||
})
|
||||
|
||||
it('holds under simultaneous retries', async () => {
|
||||
const reporter = await ctx.createUser('reporter', 'racer@example.com')
|
||||
const clientId = crypto.randomUUID()
|
||||
const results = await Promise.all([1, 2, 3].map(() => reporter.post('/api/incidents', { ...report(), clientId })))
|
||||
expect(new Set(results.map((r) => r.body.incident.number)).size).toBe(1)
|
||||
})
|
||||
|
||||
it('does not attach photos twice from a kiosk retry', async () => {
|
||||
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Retry' })).body
|
||||
const clientId = crypto.randomUUID()
|
||||
const file = () => new File([PNG], 'p.png', { type: 'image/png' })
|
||||
const a = await json(await kioskReport(kiosk.token, report({ clientId }), [file()]))
|
||||
const b = await kioskReport(kiosk.token, report({ clientId }), [file()])
|
||||
expect(b.status).toBe(200)
|
||||
expect((await json(b)).reference).toBe(a.reference)
|
||||
const incident = (await ctx.admin.get(`/api/incidents/${Number(a.reference.slice(4))}`)).body.incident
|
||||
expect(incident.attachments).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { and, asc, eq, inArray, isNull } from 'drizzle-orm'
|
||||
import { Hono, type Context } from 'hono'
|
||||
import { bodyLimit } from 'hono/body-limit'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
import type { Kiosk, KioskConfig } from '../../shared/api-types.ts'
|
||||
import { incidentReference } from '../../shared/format.ts'
|
||||
import { createKioskSchema, kioskReportSchema, updateKioskSchema } from '../../shared/schemas.ts'
|
||||
import { requireRole } from '../auth/middleware.ts'
|
||||
import { RateLimiter } from '../auth/rate-limit.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { areas, kiosks, organizations, sites } from '../db/schema.ts'
|
||||
import { saveAttachment } from '../lib/attachments.ts'
|
||||
import { audit, diff } from '../lib/audit.ts'
|
||||
import { iso } from '../lib/dates.ts'
|
||||
import { currentUser, fail, validate, type AppEnv } from '../lib/http.ts'
|
||||
import { auditIncident } from '../lib/incidents.ts'
|
||||
import { fileReport } from '../lib/reporting.ts'
|
||||
|
||||
export const KIOSK_HEADER = 'x-kiosk-token'
|
||||
const MAX_FILES_PER_REPORT = 5
|
||||
const idParam = z.object({ id: z.uuid() })
|
||||
|
||||
async function toKiosk(db: Db, row: typeof kiosks.$inferSelect): Promise<Kiosk> {
|
||||
const [site] = row.siteId ? await db.select({ id: sites.id, name: sites.name }).from(sites).where(eq(sites.id, row.siteId)) : []
|
||||
return { id: row.id, name: row.name, token: row.token, site: site ?? null, lastUsedAt: iso(row.lastUsedAt), revokedAt: iso(row.revokedAt), createdAt: iso(row.createdAt) }
|
||||
}
|
||||
|
||||
async function assertSite(db: Db, organizationId: string, siteId: string | null | undefined) {
|
||||
if (!siteId) return
|
||||
const [site] = await db.select({ id: sites.id }).from(sites).where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId)))
|
||||
if (!site) throw fail(400, 'Unknown site')
|
||||
}
|
||||
|
||||
/** Managing reporting links (signed-in safety managers). */
|
||||
export const kioskAdminRoutes = new Hono<AppEnv>()
|
||||
.get('/', requireRole('safety_manager'), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const rows = await db.select().from(kiosks).where(eq(kiosks.organizationId, me.organizationId)).orderBy(asc(kiosks.name))
|
||||
return c.json({ kiosks: await Promise.all(rows.map((row) => toKiosk(db, row))) })
|
||||
})
|
||||
|
||||
.post('/', requireRole('safety_manager'), validate('json', createKioskSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const input = c.req.valid('json')
|
||||
await assertSite(db, me.organizationId, input.siteId)
|
||||
const [row] = await db
|
||||
.insert(kiosks)
|
||||
.values({ ...input, organizationId: me.organizationId, token: randomBytes(24).toString('base64url'), createdById: me.id })
|
||||
.returning()
|
||||
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'kiosk', entityId: row.id, action: 'create', changes: { name: row.name } })
|
||||
return c.json({ kiosk: await toKiosk(db, row) }, 201)
|
||||
})
|
||||
|
||||
.patch('/:id', requireRole('safety_manager'), validate('param', idParam), validate('json', updateKioskSchema), async (c) => {
|
||||
const me = currentUser(c)
|
||||
const db = c.get('db')
|
||||
const [current] = await db
|
||||
.select()
|
||||
.from(kiosks)
|
||||
.where(and(eq(kiosks.id, c.req.valid('param').id), eq(kiosks.organizationId, me.organizationId)))
|
||||
if (!current) throw fail(404, 'Reporting link not found')
|
||||
const { revoked, ...fields } = c.req.valid('json')
|
||||
await assertSite(db, me.organizationId, fields.siteId)
|
||||
const update = { ...fields, revokedAt: revoked === undefined ? undefined : revoked ? (current.revokedAt ?? new Date()) : null }
|
||||
const changes = diff(current, update)
|
||||
if (Object.keys(changes).length === 0) return c.json({ kiosk: await toKiosk(db, current) })
|
||||
const [row] = await db.update(kiosks).set(update).where(eq(kiosks.id, current.id)).returning()
|
||||
await audit(db, { organizationId: me.organizationId, actorId: me.id, entityType: 'kiosk', entityId: row.id, action: 'update', changes })
|
||||
return c.json({ kiosk: await toKiosk(db, row) })
|
||||
})
|
||||
|
||||
/**
|
||||
* Endpoints used with a reporting link's token instead of a session. The token travels in a custom
|
||||
* header, which browsers never send cross-site without a CORS preflight, so these can't be forged
|
||||
* from another site.
|
||||
*/
|
||||
export function kioskPublicRoutes() {
|
||||
// Each link can file 30 reports per 10 minutes: plenty for a busy shop floor, a brake on abuse.
|
||||
const limiter = new RateLimiter(30, 10 * 60 * 1000)
|
||||
|
||||
async function kioskFrom(c: Context<AppEnv>) {
|
||||
const token = c.req.header(KIOSK_HEADER)
|
||||
if (!token) throw fail(401, 'Missing reporting link token')
|
||||
const [row] = await c
|
||||
.get('db')
|
||||
.select()
|
||||
.from(kiosks)
|
||||
.where(and(eq(kiosks.token, token), isNull(kiosks.revokedAt)))
|
||||
if (!row) throw fail(401, 'This reporting link is no longer active. Ask your safety team for a new one.')
|
||||
return row
|
||||
}
|
||||
|
||||
return new Hono<AppEnv>()
|
||||
.get('/config', async (c) => {
|
||||
const kiosk = await kioskFrom(c)
|
||||
const db = c.get('db')
|
||||
const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, kiosk.organizationId))
|
||||
const siteRows = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.organizationId, kiosk.organizationId), isNull(sites.archivedAt), kiosk.siteId ? eq(sites.id, kiosk.siteId) : undefined))
|
||||
.orderBy(asc(sites.name))
|
||||
const areaRows = siteRows.length
|
||||
? await db
|
||||
.select()
|
||||
.from(areas)
|
||||
.where(and(inArray(areas.siteId, siteRows.map((s) => s.id)), isNull(areas.archivedAt)))
|
||||
.orderBy(asc(areas.name))
|
||||
: []
|
||||
const config: KioskConfig = {
|
||||
organization,
|
||||
kiosk: { name: kiosk.name },
|
||||
sites: siteRows.map((site) => ({
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
address: null,
|
||||
timezone: site.timezone,
|
||||
archivedAt: null,
|
||||
areas: areaRows
|
||||
.filter((area) => area.siteId === site.id)
|
||||
.map((area) => ({ id: area.id, siteId: area.siteId, parentId: area.parentId, name: area.name, kind: area.kind, archivedAt: null })),
|
||||
})),
|
||||
}
|
||||
return c.json(config)
|
||||
})
|
||||
|
||||
// Multipart: a `report` field holding the JSON report, plus up to 5 `files`.
|
||||
.post('/reports', async (c, next) => bodyLimit({ maxSize: c.get('config').maxUploadBytes * MAX_FILES_PER_REPORT + 256 * 1024, onError: (c) => c.json({ error: 'Files are too large' }, 413) })(c, next), async (c) => {
|
||||
const kiosk = await kioskFrom(c)
|
||||
if (!limiter.attempt(kiosk.id)) throw fail(429, 'Too many reports from this link right now. Please try again in a few minutes.')
|
||||
const db = c.get('db')
|
||||
|
||||
const body = await c.req.parseBody({ all: true })
|
||||
let json: unknown
|
||||
try {
|
||||
json = JSON.parse(String(body.report ?? ''))
|
||||
} catch {
|
||||
throw fail(400, 'Missing report')
|
||||
}
|
||||
const parsed = kioskReportSchema.safeParse(json)
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: 'Invalid request', issues: parsed.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })) }, 400)
|
||||
}
|
||||
if (kiosk.siteId && parsed.data.siteId !== kiosk.siteId) throw fail(400, 'This reporting link is for a different site')
|
||||
const files = ([] as unknown[]).concat(body.files ?? []).filter((file) => file instanceof File)
|
||||
if (files.length > MAX_FILES_PER_REPORT) throw fail(400, `Attach at most ${MAX_FILES_PER_REPORT} photos`)
|
||||
|
||||
const { reporterName, ...report } = parsed.data
|
||||
const { id, number, created } = await fileReport(db, c.get('notifier'), {
|
||||
...report,
|
||||
organizationId: kiosk.organizationId,
|
||||
reporter: null,
|
||||
reporterName,
|
||||
anonymous: !reporterName,
|
||||
kioskId: kiosk.id,
|
||||
})
|
||||
await db.update(kiosks).set({ lastUsedAt: new Date() }).where(eq(kiosks.id, kiosk.id))
|
||||
|
||||
// Files are only taken with the first copy of a submission, so retries don't duplicate photos.
|
||||
const failed: string[] = []
|
||||
if (created) {
|
||||
for (const file of files) {
|
||||
try {
|
||||
const row = await saveAttachment({ db, storage: c.get('storage'), config: c.get('config') }, id, file, null)
|
||||
await auditIncident(db, null, kiosk.organizationId, id, 'attachment.add', { attachmentId: row.id, fileName: row.fileName })
|
||||
} catch (error) {
|
||||
failed.push(`${(file as File).name}: ${error instanceof Error ? error.message : 'upload failed'}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only the reference goes back: people using a link can't read reports.
|
||||
return c.json({ reference: incidentReference(number), failedFiles: failed }, created ? 201 : 200)
|
||||
})
|
||||
}
|
||||
@@ -73,6 +73,28 @@ export interface Incident {
|
||||
immediateAction: string | null
|
||||
anonymous: boolean
|
||||
reporter: UserRef | null
|
||||
/** Name given by someone reporting without an account. */
|
||||
reporterName: string | null
|
||||
/** How the report arrived; `kiosk` covers kiosk tablets and QR codes on reporting links. */
|
||||
channel: 'web' | 'kiosk'
|
||||
kiosk: { id: string; name: string } | null
|
||||
}
|
||||
|
||||
export interface Kiosk {
|
||||
id: string
|
||||
name: string
|
||||
token: string
|
||||
site: { id: string; name: string } | null
|
||||
lastUsedAt: string | null
|
||||
revokedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** What a reporting link may see: just enough to fill in the report form. */
|
||||
export interface KioskConfig {
|
||||
organization: { name: string }
|
||||
kiosk: { name: string }
|
||||
sites: Site[]
|
||||
}
|
||||
|
||||
export interface Investigation {
|
||||
|
||||
+11
-1
@@ -39,10 +39,12 @@ export const createIncidentSchema = z.object({
|
||||
description: optionalText(10_000),
|
||||
immediateAction: optionalText(10_000),
|
||||
anonymous: z.boolean().default(false),
|
||||
/** Random id from the reporting device; resubmitting the same id returns the existing report. */
|
||||
clientId: z.uuid().optional(),
|
||||
})
|
||||
|
||||
/** Status changes go through transitions, not updates. */
|
||||
export const updateIncidentSchema = createIncidentSchema.omit({ anonymous: true }).partial()
|
||||
export const updateIncidentSchema = createIncidentSchema.omit({ anonymous: true, clientId: true }).partial()
|
||||
|
||||
export const transitionSchema = z.object({ to: z.enum(incidentStatuses), note: optionalText(5_000) })
|
||||
|
||||
@@ -87,6 +89,13 @@ export const actionListQuery = z.object({
|
||||
filter: z.enum(['open', 'overdue', 'done', 'all']).default('open'),
|
||||
})
|
||||
|
||||
export const createKioskSchema = z.object({ name, siteId: z.uuid().nullish() })
|
||||
|
||||
export const updateKioskSchema = z.object({ name: name.optional(), siteId: z.uuid().nullish(), revoked: z.boolean().optional() })
|
||||
|
||||
/** Report filed through a reporting link (kiosk tablet or QR code), without an account. */
|
||||
export const kioskReportSchema = createIncidentSchema.omit({ anonymous: true }).extend({ reporterName: optionalText(200) })
|
||||
|
||||
export const emailOnlySchema = z.object({ email })
|
||||
|
||||
export const tokenSchema = z.object({ token: z.string().min(20).max(200) })
|
||||
@@ -105,6 +114,7 @@ export const incidentListQuery = z.object({
|
||||
|
||||
export type SetupInput = z.infer<typeof setupSchema>
|
||||
export type CreateIncidentInput = z.input<typeof createIncidentSchema>
|
||||
export type KioskReportInput = z.input<typeof kioskReportSchema>
|
||||
export type UpdateIncidentInput = z.infer<typeof updateIncidentSchema>
|
||||
export type CreateActionInput = z.input<typeof createActionSchema>
|
||||
export type UpdateActionInput = z.input<typeof updateActionSchema>
|
||||
|
||||
@@ -5,9 +5,11 @@ import { Actions } from './pages/Actions'
|
||||
import { Analytics } from './pages/Analytics'
|
||||
import { Login, MagicLinkLanding, ResetPassword, Setup } from './pages/Auth'
|
||||
import { IncidentDetail } from './pages/IncidentDetail'
|
||||
import { Kiosk } from './pages/Kiosk'
|
||||
import { Incidents } from './pages/Incidents'
|
||||
import { Locations } from './pages/Locations'
|
||||
import { Overview } from './pages/Overview'
|
||||
import { QrSheet } from './pages/QrSheet'
|
||||
import { ReportIncident } from './pages/ReportIncident'
|
||||
import { Settings } from './pages/Settings'
|
||||
|
||||
@@ -18,12 +20,14 @@ export default function App() {
|
||||
<Route path="setup" element={<Setup />} />
|
||||
<Route path="auth/magic" element={<MagicLinkLanding />} />
|
||||
<Route path="auth/reset" element={<ResetPassword />} />
|
||||
<Route path="k/:token" element={<Kiosk />} />
|
||||
<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/qr" element={<QrSheet />} />
|
||||
<Route path="analytics" element={<Analytics />} />
|
||||
<Route path="report" element={<ReportIncident />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useActions, useMe, useSetupStatus } from '../lib/queries'
|
||||
import type { QueuedItem } from '../lib/outbox'
|
||||
import { useOutboxSync } from '../lib/use-outbox'
|
||||
import { Header } from './Header'
|
||||
import { OutboxBanner } from './OutboxBanner'
|
||||
import { ErrorMessage, Loading } from './QueryState'
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
@@ -12,6 +15,9 @@ export function AppLayout() {
|
||||
const location = useLocation()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const myOpenActions = useActions('mine', 'open', !!me.data)
|
||||
const userId = me.data?.user.id
|
||||
const belongs = userId ? (item: QueuedItem) => item.kind === 'report' && item.userId === userId : null
|
||||
useOutboxSync(belongs)
|
||||
|
||||
if (me.isPending) return <Loading />
|
||||
if (me.isError) return <ErrorMessage error={me.error} className="m-6" />
|
||||
@@ -28,6 +34,7 @@ export function AppLayout() {
|
||||
<Sidebar me={me.data} open={menuOpen} onClose={() => setMenuOpen(false)} badges={{ '/actions': open.length }} />
|
||||
<main className="min-w-0 flex-1">
|
||||
<Header me={me.data} onOpenMenu={() => setMenuOpen(true)} overdueActions={overdue} />
|
||||
<OutboxBanner belongs={belongs!} />
|
||||
<Outlet context={me.data} />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { LanguageToggle } from './LanguageToggle'
|
||||
import { panel } from './styles'
|
||||
|
||||
/** Centered card for sign-in, setup and email-link pages. */
|
||||
@@ -18,6 +19,9 @@ export function AuthLayout({ title, copy, children }: { title: string; copy?: st
|
||||
{copy && <p className="mt-1.5 text-xs leading-[1.6] text-muted">{copy}</p>}
|
||||
<div className="mt-6">{children}</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center">
|
||||
<LanguageToggle className="text-muted hover:text-ink" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -17,4 +17,4 @@ export function SeverityLabel({ severity }: { severity: Severity }) {
|
||||
)
|
||||
}
|
||||
|
||||
export const OverdueBadge = () => <span className={`${chip} bg-coral-soft font-semibold text-coral-strong`}>Overdue</span>
|
||||
export const OverdueBadge = ({ label = 'Overdue' }: { label?: string }) => <span className={`${chip} bg-coral-soft font-semibold text-coral-strong`}>{label}</span>
|
||||
@@ -22,11 +22,19 @@ export function FormSection({ number, title, copy, children }: FormSectionProps)
|
||||
)
|
||||
}
|
||||
|
||||
export function Field({ label, optional = false, children }: { label: string; optional?: boolean; children: ReactNode }) {
|
||||
interface FieldProps {
|
||||
label: string
|
||||
optional?: boolean
|
||||
/** Translated "Optional" marker. */
|
||||
optionalLabel?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Field({ label, optional = false, optionalLabel = 'Optional', children }: FieldProps) {
|
||||
return (
|
||||
<label className="mb-[17px] block text-2xs font-semibold text-[#586762]">
|
||||
{label}
|
||||
{optional && <em className="float-right font-normal text-[#9ba7a2] not-italic">Optional</em>}
|
||||
{optional && <em className="float-right font-normal text-[#9ba7a2] not-italic">{optionalLabel}</em>}
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Bell, Menu, Search } from 'lucide-react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useI18n } from '../i18n'
|
||||
import { initials } from '../lib/initials'
|
||||
import { pageTitle } from '../lib/navigation'
|
||||
import type { Me } from '../lib/queries'
|
||||
@@ -10,6 +11,7 @@ export function Header({ me, onOpenMenu, overdueActions }: { me: Me; onOpenMenu:
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [query, setQuery] = useState('')
|
||||
const { t } = useI18n()
|
||||
|
||||
const search = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
@@ -19,7 +21,7 @@ export function Header({ me, onOpenMenu, overdueActions }: { me: Me; onOpenMenu:
|
||||
|
||||
return (
|
||||
<header className="flex h-[62px] items-center justify-between gap-3 border-b border-line bg-white/60 px-[17px] sm:h-[72px] sm:px-6 lg:px-[42px] print:hidden">
|
||||
<button type="button" className={`${iconButton} sm:hidden`} onClick={onOpenMenu} aria-label="Open menu">
|
||||
<button type="button" className={`${iconButton} sm:hidden`} onClick={onOpenMenu} aria-label={t.nav.openMenu}>
|
||||
<Menu size={21} />
|
||||
</button>
|
||||
<div className="flex min-w-0 items-center gap-2.5 text-xs text-[#94a09b]">
|
||||
@@ -33,14 +35,14 @@ export function Header({ me, onOpenMenu, overdueActions }: { me: Me; onOpenMenu:
|
||||
<Search size={15} />
|
||||
<input
|
||||
className="h-[34px] w-[180px] bg-transparent text-xs text-ink outline-none"
|
||||
placeholder="Search incidents"
|
||||
aria-label="Search incidents"
|
||||
placeholder={t.nav.searchIncidents}
|
||||
aria-label={t.nav.searchIncidents}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</search>
|
||||
<Link to="/incidents" className={`${iconButton} md:hidden`} aria-label="Search incidents">
|
||||
<Link to="/incidents" className={`${iconButton} md:hidden`} aria-label={t.nav.searchIncidents}>
|
||||
<Search size={19} />
|
||||
</Link>
|
||||
<Link to="/actions?filter=overdue" className={`${iconButton} relative`} aria-label={overdueActions ? `${overdueActions} overdue actions` : 'My actions'}>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function IncidentTable({ incidents, wide = false }: { incidents: Incident
|
||||
{incident.title}
|
||||
</Link>
|
||||
<small className="mt-1 block text-3xs text-faint">
|
||||
{incident.reference} · {incident.anonymous ? 'Anonymous' : (incident.reporter?.name ?? 'Unknown')}
|
||||
{incident.reference} · {incident.anonymous ? 'Anonymous' : (incident.reporter?.name ?? incident.reporterName ?? 'Unknown')}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Languages } from 'lucide-react'
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
/** Switches between English and Spanish; shows the language you'd switch to, in that language. */
|
||||
export function LanguageToggle({ className = '' }: { className?: string }) {
|
||||
const { lang, setLang, t } = useI18n()
|
||||
return (
|
||||
<button type="button" className={`inline-flex cursor-pointer items-center gap-1.5 text-2xs font-semibold ${className}`} onClick={() => setLang(lang === 'en' ? 'es' : 'en')} lang={lang === 'en' ? 'es' : 'en'}>
|
||||
<Languages size={15} /> {t.language.switchTo}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { CloudOff, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { useI18n } from '../i18n'
|
||||
import { outbox, type QueuedItem } from '../lib/outbox'
|
||||
import { useOnline, useOutbox } from '../lib/use-outbox'
|
||||
|
||||
/** Offline notice plus any reports still waiting on this device. */
|
||||
export function OutboxBanner({ belongs }: { belongs: (item: QueuedItem) => boolean }) {
|
||||
const { t } = useI18n()
|
||||
const online = useOnline()
|
||||
const { items, syncing } = useOutbox()
|
||||
const mine = items.filter(belongs)
|
||||
if (online && mine.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="border-b border-[#f3d9a4] bg-[#fff8e6] px-[17px] py-2.5 text-2xs text-[#7a5a12] sm:px-6 lg:px-[42px] print:hidden" role="status">
|
||||
{!online && (
|
||||
<p className="flex items-center gap-2">
|
||||
<CloudOff size={15} className="flex-none" /> {t.outbox.offline}
|
||||
</p>
|
||||
)}
|
||||
{mine.length > 0 && (
|
||||
<div className={!online ? 'mt-1.5' : ''}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<strong className="font-semibold">{t.outbox.waiting(mine.length)}</strong>
|
||||
{online && (
|
||||
<button type="button" className="inline-flex items-center gap-1 font-semibold underline disabled:opacity-50" disabled={syncing} onClick={() => void outbox.flush(belongs)}>
|
||||
<RefreshCw size={13} className={syncing ? 'animate-spin' : ''} /> {syncing ? t.outbox.sending : t.outbox.retry}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{mine
|
||||
.filter((item) => item.rejected)
|
||||
.map((item) => (
|
||||
<p key={item.clientId} className="mt-1 flex items-center gap-2">
|
||||
“{item.report.title}”: {t.outbox.failed(item.lastError ?? '')}
|
||||
<button type="button" className="inline-flex items-center gap-1 underline" onClick={() => confirm(t.outbox.discardConfirm) && void outbox.discard(item.clientId)}>
|
||||
<Trash2 size={12} /> {t.outbox.discard}
|
||||
</button>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import QRCode from 'qrcode'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
/** QR code as crisp SVG squares (prints sharply at any size). */
|
||||
export function QrCode({ value, size = 160, label }: { value: string; size?: number; label: string }) {
|
||||
const { path, count } = useMemo(() => {
|
||||
const { modules } = QRCode.create(value, { errorCorrectionLevel: 'M' })
|
||||
let d = ''
|
||||
for (let y = 0; y < modules.size; y++) for (let x = 0; x < modules.size; x++) if (modules.get(x, y)) d += `M${x} ${y}h1v1h-1z`
|
||||
return { path: d, count: modules.size }
|
||||
}, [value])
|
||||
const margin = 2
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`${-margin} ${-margin} ${count + margin * 2} ${count + margin * 2}`} role="img" aria-label={label} shapeRendering="crispEdges">
|
||||
<rect x={-margin} y={-margin} width={count + margin * 2} height={count + margin * 2} fill="#fff" />
|
||||
<path d={path} fill="#000" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
+21
-10
@@ -1,10 +1,13 @@
|
||||
import { Info, LogOut, Plus, Settings, ShieldCheck, X } from 'lucide-react'
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useI18n } from '../i18n'
|
||||
import { outbox } from '../lib/outbox'
|
||||
import { initials } from '../lib/initials'
|
||||
import { roleLabel } from '../lib/labels'
|
||||
import { primaryNav } from '../lib/navigation'
|
||||
import { useLogout, type Me } from '../lib/queries'
|
||||
import { Eyebrow } from './Eyebrow'
|
||||
import { LanguageToggle } from './LanguageToggle'
|
||||
import { iconButton } from './styles'
|
||||
|
||||
const navLink = ({ isActive }: { isActive: boolean }) =>
|
||||
@@ -21,6 +24,13 @@ interface SidebarProps {
|
||||
export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
|
||||
const logout = useLogout()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useI18n()
|
||||
|
||||
const signOut = () => {
|
||||
const unsent = outbox.getState().items.filter((item) => item.kind === 'report' && item.userId === me.user.id).length
|
||||
if (unsent && !confirm(t.outbox.signOutWarning(unsent))) return
|
||||
logout.mutate(undefined, { onSuccess: () => navigate('/login') })
|
||||
}
|
||||
return (
|
||||
<aside
|
||||
className={`fixed inset-y-0 z-10 flex w-[260px] flex-none flex-col overflow-y-auto bg-forest-950 px-[15px] pt-7 pb-[18px] text-forest-100 shadow-[8px_0_30px_#163c3740] transition-[left] duration-200 sm:static sm:min-h-screen sm:w-[230px] sm:shadow-none lg:w-[246px] print:hidden ${open ? 'left-0' : '-left-[270px]'}`}
|
||||
@@ -33,7 +43,7 @@ export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
|
||||
<strong className="block font-display text-[19px] leading-tight font-bold tracking-[-0.5px] whitespace-nowrap sm:text-[17px] lg:text-[19px]">Open SafetyLog</strong>
|
||||
<span className="mt-0.5 block text-3xs text-forest-300">EHS · Incident intelligence</span>
|
||||
</div>
|
||||
<button type="button" className={`${iconButton} absolute top-2 right-2 sm:hidden`} onClick={onClose} aria-label="Close menu">
|
||||
<button type="button" className={`${iconButton} absolute top-2 right-2 sm:hidden`} onClick={onClose} aria-label={t.nav.closeMenu}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -41,7 +51,7 @@ export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
|
||||
<div className="mb-[27px] flex items-center gap-2.5 rounded-[7px] border border-[#316158] p-[11px]">
|
||||
<div className="grid size-[29px] flex-none place-items-center rounded-full bg-[#f1c865] font-mono text-2xs font-bold text-[#143d37]">{initials(me.organization.name)}</div>
|
||||
<div className="min-w-0">
|
||||
<Eyebrow>Workspace</Eyebrow>
|
||||
<Eyebrow>{t.nav.workspace}</Eyebrow>
|
||||
<strong className="block truncate text-[13px] text-white">{me.organization.name}</strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,29 +60,30 @@ export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
|
||||
{primaryNav.map(({ label, to, icon: Icon }) => (
|
||||
<NavLink key={to} to={to} end={to === '/'} onClick={onClose} className={navLink}>
|
||||
<Icon size={18} />
|
||||
<span>{label}</span>
|
||||
<span>{t.nav[label]}</span>
|
||||
{!!badges[to] && <em className="ml-auto rounded-[10px] bg-coral px-[7px] py-0.5 font-mono text-2xs text-white not-italic">{badges[to]}</em>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={navSection}>
|
||||
<Eyebrow className="px-3 pb-2 text-[#8da29b]">Manage</Eyebrow>
|
||||
<Eyebrow className="px-3 pb-2 text-[#8da29b]">{t.nav.manage}</Eyebrow>
|
||||
<NavLink to="/report" onClick={onClose} className={navLink}>
|
||||
<Plus size={18} />
|
||||
<span>Report an incident</span>
|
||||
<span>{t.nav.reportIncident}</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto">
|
||||
<NavLink to="/about" onClick={onClose} className={navLink}>
|
||||
<Info size={18} />
|
||||
<span>About</span>
|
||||
<span>{t.nav.about}</span>
|
||||
</NavLink>
|
||||
<NavLink to="/settings" onClick={onClose} className={navLink}>
|
||||
<Settings size={18} />
|
||||
<span>Settings</span>
|
||||
<span>{t.nav.settings}</span>
|
||||
</NavLink>
|
||||
<LanguageToggle className="h-[42px] w-full px-3 text-forest-200 hover:text-white" />
|
||||
<div className="mt-4 flex items-center gap-2.5 border-t border-forest-700 px-2 pt-5">
|
||||
<div className="grid size-[31px] flex-none place-items-center rounded-full bg-[#5c8274] font-mono text-2xs text-white">{initials(me.user.name)}</div>
|
||||
<div className="min-w-0">
|
||||
@@ -82,9 +93,9 @@ export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
|
||||
<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') })}
|
||||
aria-label={t.nav.signOut}
|
||||
title={t.nav.signOut}
|
||||
onClick={signOut}
|
||||
>
|
||||
<LogOut size={17} />
|
||||
</button>
|
||||
|
||||
@@ -4,8 +4,8 @@ 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 { useI18n } from '../../i18n'
|
||||
import { useAssignableUsers, useIncidentMutations, type Me } from '../../lib/queries'
|
||||
import { ActionStatusBadge, OverdueBadge } from '../Badges'
|
||||
import { Field } from '../FormSection'
|
||||
@@ -15,6 +15,7 @@ import { Section } from './Section'
|
||||
|
||||
export function ActionStatusControl({ action, onChange, disabled }: { action: CorrectiveAction; onChange: (status: ActionStatus) => void; disabled?: boolean }) {
|
||||
const me = useOutletContext<Me>().user
|
||||
const { t } = useI18n()
|
||||
const options = statusOptions(action, me)
|
||||
if (options.length === 0) return <ActionStatusBadge status={action.status} />
|
||||
return (
|
||||
@@ -22,13 +23,13 @@ export function ActionStatusControl({ action, onChange, disabled }: { action: Co
|
||||
className="rounded-[5px] border border-line bg-white px-2 py-1 text-2xs text-ink outline-none print:hidden"
|
||||
value={action.status}
|
||||
disabled={disabled}
|
||||
aria-label={`Status of ${action.title}`}
|
||||
aria-label={t.actions.statusOf(action.title)}
|
||||
onChange={(event) => onChange(event.target.value as ActionStatus)}
|
||||
>
|
||||
{!options.includes(action.status) && <option value={action.status}>{actionStatusLabel[action.status]}</option>}
|
||||
{!options.includes(action.status) && <option value={action.status}>{t.actionStatuses[action.status]}</option>}
|
||||
{options.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{actionStatusLabel[status]}
|
||||
{t.actionStatuses[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
import type { ActionStatus } from '../../shared/api-types.ts'
|
||||
import type { IncidentType, Severity } from '../../shared/enums.ts'
|
||||
|
||||
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
|
||||
|
||||
export const en = {
|
||||
language: { name: 'English', switchTo: 'Español' },
|
||||
common: {
|
||||
cancel: 'Cancel',
|
||||
optional: 'Optional',
|
||||
loading: 'Loading…',
|
||||
back: 'Back',
|
||||
},
|
||||
nav: {
|
||||
overview: 'Overview',
|
||||
incidents: 'Incidents',
|
||||
myActions: 'My actions',
|
||||
locations: 'Locations',
|
||||
analytics: 'Analytics',
|
||||
manage: 'Manage',
|
||||
reportIncident: 'Report an incident',
|
||||
about: 'About',
|
||||
settings: 'Settings',
|
||||
signOut: 'Sign out',
|
||||
openMenu: 'Open menu',
|
||||
closeMenu: 'Close menu',
|
||||
workspace: 'Workspace',
|
||||
searchIncidents: 'Search incidents',
|
||||
},
|
||||
auth: {
|
||||
signIn: 'Sign in',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
resetTitle: 'Reset your password',
|
||||
linkCopy: 'We’ll email you a link that signs you in.',
|
||||
resetCopy: 'We’ll email you a link to choose a new password.',
|
||||
sendLink: 'Email me a sign-in link',
|
||||
sendReset: 'Email me a reset link',
|
||||
usePassword: 'Sign in with password',
|
||||
useLink: 'Email me a sign-in link instead',
|
||||
forgot: 'Forgot password?',
|
||||
sent: 'If that email belongs to an account, a link is on its way. It expires soon, so use it right away.',
|
||||
continueWithLink: 'Continue to sign in with the link from your email.',
|
||||
backToSignIn: 'Back to sign in',
|
||||
choosePassword: 'Choose a password',
|
||||
choosePasswordCopy: 'Set a password for your account. You’ll be signed in afterwards.',
|
||||
newPassword: 'New password',
|
||||
confirmPassword: 'Confirm password',
|
||||
atLeast10: 'At least 10 characters.',
|
||||
mismatch: 'Passwords don’t match.',
|
||||
savePassword: 'Save password and sign in',
|
||||
},
|
||||
report: {
|
||||
eyebrow: 'New report',
|
||||
title: 'Report an incident',
|
||||
intro: 'Capture the facts while they are fresh. Investigation details can be added later.',
|
||||
whatHappened: 'What happened?',
|
||||
whatHappenedCopy: 'Start with a clear, factual description.',
|
||||
incidentTitle: 'Incident title',
|
||||
titlePlaceholder: 'e.g. Slip near loading dock',
|
||||
type: 'Incident type',
|
||||
severity: 'Severity',
|
||||
when: 'When',
|
||||
description: 'What happened?',
|
||||
descriptionPlaceholder: 'Describe what you observed, without assumptions...',
|
||||
where: 'Where did it happen?',
|
||||
whereCopy: 'Pinpoint the location so patterns become visible.',
|
||||
site: 'Site',
|
||||
area: 'Building or area',
|
||||
notSpecified: 'Not specified',
|
||||
exactLocation: 'Exact location',
|
||||
exactLocationPlaceholder: 'e.g. Ground floor lobby, Line 3',
|
||||
fromQr: 'Location filled in from the QR code.',
|
||||
responseAndPhotos: 'Response and photos',
|
||||
responseAndPhotosCopy: 'What was done straight away, and any pictures of the scene.',
|
||||
immediateResponse: 'Immediate response',
|
||||
immediateResponsePlaceholder: 'e.g. Area isolated, supervisor notified...',
|
||||
photos: 'Photos',
|
||||
addPhotos: 'Add photos',
|
||||
removePhoto: (name: string) => `Remove ${name}`,
|
||||
anonymous: 'Report anonymously.',
|
||||
anonymousHelp: 'Your name won’t be stored with this report, and you won’t be able to find it in your own list afterwards.',
|
||||
submit: 'Submit report',
|
||||
submitting: 'Submitting…',
|
||||
noSites: 'No sites have been set up yet. A safety manager needs to add a site before incidents can be reported.',
|
||||
addSite: 'Add a site',
|
||||
queuedTitle: 'Saved on this device',
|
||||
queuedCopy: 'You’re offline, so the report hasn’t been sent yet. It will be sent automatically as soon as there’s a connection. You can close this page.',
|
||||
reportAnother: 'Report something else',
|
||||
photosFailed: (reference: string, details: string) => `Report ${reference} was saved, but some photos didn't upload. ${details}`,
|
||||
},
|
||||
kiosk: {
|
||||
heading: 'Report a safety concern',
|
||||
intro: 'Hazards, near misses, injuries: anything that could hurt someone. You don’t need an account.',
|
||||
whatKind: 'What kind of problem?',
|
||||
howSerious: 'How serious could it be?',
|
||||
summary: 'In a few words, what did you see?',
|
||||
summaryPlaceholder: 'e.g. Oil on the floor by press 4',
|
||||
details: 'Anything else we should know?',
|
||||
yourName: 'Your name',
|
||||
yourNameHelp: 'Leave blank to report anonymously.',
|
||||
send: 'Send report',
|
||||
sending: 'Sending…',
|
||||
thanksTitle: 'Thank you',
|
||||
thanks: (reference: string) => `Your report ${reference} has been sent to the safety team.`,
|
||||
thanksQueued: 'Your report is saved on this device and will be sent as soon as there’s a connection.',
|
||||
resetIn: (seconds: number) => `This screen resets in ${seconds} s.`,
|
||||
invalidLink: 'This reporting link isn’t working. Ask your safety team for a new one.',
|
||||
},
|
||||
actions: {
|
||||
eyebrow: 'Corrective actions',
|
||||
mine: 'My actions',
|
||||
all: 'All actions',
|
||||
mineCopy: 'Corrective actions assigned to you. Mark them done when the fix is in place.',
|
||||
allCopy: 'Every corrective action across the organization.',
|
||||
filterOpen: 'Open',
|
||||
filterOverdue: 'Overdue',
|
||||
filterDone: 'Done',
|
||||
filterAll: 'All',
|
||||
assignedToMe: 'Assigned to me',
|
||||
everyone: 'Everyone',
|
||||
nothingOpen: 'Nothing open. Nice work.',
|
||||
none: 'No actions here.',
|
||||
due: (date: string) => `Due ${date}`,
|
||||
noDueDate: 'No due date',
|
||||
overdue: 'Overdue',
|
||||
unassigned: 'Unassigned',
|
||||
statusOf: (title: string) => `Status of ${title}`,
|
||||
},
|
||||
outbox: {
|
||||
offline: 'You’re offline. New reports will be saved on this device and sent when you reconnect.',
|
||||
waiting: (n: number) => `${plural(n, 'report', 'reports')} waiting to send`,
|
||||
sending: 'Sending…',
|
||||
retry: 'Send now',
|
||||
otherUser: 'Saved by another user on this device',
|
||||
failed: (error: string) => `Couldn’t send: ${error}`,
|
||||
discard: 'Discard',
|
||||
discardConfirm: 'Discard this unsent report? This can’t be undone.',
|
||||
signOutWarning: (n: number) => `${plural(n, 'report hasn’t', 'reports haven’t')} been sent yet. They’ll be sent the next time you sign in on this device. Sign out anyway?`,
|
||||
},
|
||||
qr: {
|
||||
caption: 'Scan to report a hazard or near miss',
|
||||
},
|
||||
types: {
|
||||
near_miss: 'Near miss',
|
||||
hazard: 'Hazard',
|
||||
injury: 'Injury',
|
||||
illness: 'Illness',
|
||||
property_damage: 'Property damage',
|
||||
environmental: 'Environmental',
|
||||
} satisfies Record<IncidentType, string>,
|
||||
typeHints: {
|
||||
near_miss: 'Something almost went wrong',
|
||||
hazard: 'Something that could hurt someone',
|
||||
injury: 'Someone was hurt',
|
||||
illness: 'Someone became ill from work',
|
||||
property_damage: 'Equipment or building damaged',
|
||||
environmental: 'Spill, leak or emission',
|
||||
} satisfies Record<IncidentType, string>,
|
||||
severities: { low: 'Low', medium: 'Medium', high: 'High', critical: 'Critical' } satisfies Record<Severity, string>,
|
||||
severityHints: {
|
||||
low: 'Minor, no injury likely',
|
||||
medium: 'Could cause a minor injury',
|
||||
high: 'Could cause a serious injury',
|
||||
critical: 'Could be life-threatening',
|
||||
} satisfies Record<Severity, string>,
|
||||
actionStatuses: { open: 'Open', in_progress: 'In progress', done: 'Done', verified: 'Verified', cancelled: 'Cancelled' } satisfies Record<ActionStatus, string>,
|
||||
}
|
||||
|
||||
export type Messages = typeof en
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
import type { Messages } from './en'
|
||||
|
||||
const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
|
||||
|
||||
export const es: Messages = {
|
||||
language: { name: 'Español', switchTo: 'English' },
|
||||
common: {
|
||||
cancel: 'Cancelar',
|
||||
optional: 'Opcional',
|
||||
loading: 'Cargando…',
|
||||
back: 'Volver',
|
||||
},
|
||||
nav: {
|
||||
overview: 'Resumen',
|
||||
incidents: 'Incidentes',
|
||||
myActions: 'Mis acciones',
|
||||
locations: 'Ubicaciones',
|
||||
analytics: 'Análisis',
|
||||
manage: 'Gestionar',
|
||||
reportIncident: 'Reportar un incidente',
|
||||
about: 'Acerca de',
|
||||
settings: 'Configuración',
|
||||
signOut: 'Cerrar sesión',
|
||||
openMenu: 'Abrir menú',
|
||||
closeMenu: 'Cerrar menú',
|
||||
workspace: 'Espacio de trabajo',
|
||||
searchIncidents: 'Buscar incidentes',
|
||||
},
|
||||
auth: {
|
||||
signIn: 'Iniciar sesión',
|
||||
email: 'Correo electrónico',
|
||||
password: 'Contraseña',
|
||||
resetTitle: 'Restablecer la contraseña',
|
||||
linkCopy: 'Le enviaremos un enlace por correo para iniciar sesión.',
|
||||
resetCopy: 'Le enviaremos un enlace por correo para elegir una nueva contraseña.',
|
||||
sendLink: 'Enviarme un enlace de acceso',
|
||||
sendReset: 'Enviarme un enlace para restablecer',
|
||||
usePassword: 'Iniciar sesión con contraseña',
|
||||
useLink: 'Prefiero recibir un enlace por correo',
|
||||
forgot: '¿Olvidó su contraseña?',
|
||||
sent: 'Si ese correo pertenece a una cuenta, el enlace ya está en camino. Caduca pronto, así que úselo enseguida.',
|
||||
continueWithLink: 'Continúe para iniciar sesión con el enlace de su correo.',
|
||||
backToSignIn: 'Volver a iniciar sesión',
|
||||
choosePassword: 'Elija una contraseña',
|
||||
choosePasswordCopy: 'Establezca una contraseña para su cuenta. Después iniciará sesión automáticamente.',
|
||||
newPassword: 'Nueva contraseña',
|
||||
confirmPassword: 'Confirme la contraseña',
|
||||
atLeast10: 'Al menos 10 caracteres.',
|
||||
mismatch: 'Las contraseñas no coinciden.',
|
||||
savePassword: 'Guardar contraseña e iniciar sesión',
|
||||
},
|
||||
report: {
|
||||
eyebrow: 'Nuevo reporte',
|
||||
title: 'Reportar un incidente',
|
||||
intro: 'Registre los hechos mientras están frescos. Los detalles de la investigación se pueden añadir después.',
|
||||
whatHappened: '¿Qué pasó?',
|
||||
whatHappenedCopy: 'Empiece con una descripción clara y objetiva.',
|
||||
incidentTitle: 'Título del incidente',
|
||||
titlePlaceholder: 'p. ej. Resbalón cerca del muelle de carga',
|
||||
type: 'Tipo de incidente',
|
||||
severity: 'Gravedad',
|
||||
when: 'Cuándo',
|
||||
description: '¿Qué pasó?',
|
||||
descriptionPlaceholder: 'Describa lo que observó, sin suposiciones...',
|
||||
where: '¿Dónde ocurrió?',
|
||||
whereCopy: 'Indique el lugar exacto para que se puedan ver patrones.',
|
||||
site: 'Planta',
|
||||
area: 'Edificio o área',
|
||||
notSpecified: 'Sin especificar',
|
||||
exactLocation: 'Lugar exacto',
|
||||
exactLocationPlaceholder: 'p. ej. Vestíbulo planta baja, Línea 3',
|
||||
fromQr: 'Ubicación tomada del código QR.',
|
||||
responseAndPhotos: 'Respuesta y fotos',
|
||||
responseAndPhotosCopy: 'Lo que se hizo de inmediato y fotos del lugar.',
|
||||
immediateResponse: 'Respuesta inmediata',
|
||||
immediateResponsePlaceholder: 'p. ej. Área aislada, supervisor avisado...',
|
||||
photos: 'Fotos',
|
||||
addPhotos: 'Añadir fotos',
|
||||
removePhoto: (name: string) => `Quitar ${name}`,
|
||||
anonymous: 'Reportar de forma anónima.',
|
||||
anonymousHelp: 'Su nombre no se guardará con este reporte y después no podrá encontrarlo en su lista.',
|
||||
submit: 'Enviar reporte',
|
||||
submitting: 'Enviando…',
|
||||
noSites: 'Todavía no hay plantas configuradas. Un gerente de seguridad debe añadir una planta antes de poder reportar incidentes.',
|
||||
addSite: 'Añadir una planta',
|
||||
queuedTitle: 'Guardado en este dispositivo',
|
||||
queuedCopy: 'No hay conexión, así que el reporte aún no se ha enviado. Se enviará automáticamente en cuanto haya conexión. Puede cerrar esta página.',
|
||||
reportAnother: 'Reportar otra cosa',
|
||||
photosFailed: (reference: string, details: string) => `El reporte ${reference} se guardó, pero algunas fotos no se subieron. ${details}`,
|
||||
},
|
||||
kiosk: {
|
||||
heading: 'Reporte un problema de seguridad',
|
||||
intro: 'Peligros, casi accidentes, lesiones: cualquier cosa que pueda lastimar a alguien. No necesita una cuenta.',
|
||||
whatKind: '¿Qué tipo de problema?',
|
||||
howSerious: '¿Qué tan grave podría ser?',
|
||||
summary: 'En pocas palabras, ¿qué vio?',
|
||||
summaryPlaceholder: 'p. ej. Aceite en el piso junto a la prensa 4',
|
||||
details: '¿Algo más que debamos saber?',
|
||||
yourName: 'Su nombre',
|
||||
yourNameHelp: 'Déjelo en blanco para reportar de forma anónima.',
|
||||
send: 'Enviar reporte',
|
||||
sending: 'Enviando…',
|
||||
thanksTitle: 'Gracias',
|
||||
thanks: (reference: string) => `Su reporte ${reference} se envió al equipo de seguridad.`,
|
||||
thanksQueued: 'Su reporte está guardado en este dispositivo y se enviará en cuanto haya conexión.',
|
||||
resetIn: (seconds: number) => `Esta pantalla se reinicia en ${seconds} s.`,
|
||||
invalidLink: 'Este enlace de reporte no funciona. Pida uno nuevo a su equipo de seguridad.',
|
||||
},
|
||||
actions: {
|
||||
eyebrow: 'Acciones correctivas',
|
||||
mine: 'Mis acciones',
|
||||
all: 'Todas las acciones',
|
||||
mineCopy: 'Acciones correctivas asignadas a usted. Márquelas como hechas cuando la solución esté en marcha.',
|
||||
allCopy: 'Todas las acciones correctivas de la organización.',
|
||||
filterOpen: 'Abiertas',
|
||||
filterOverdue: 'Vencidas',
|
||||
filterDone: 'Hechas',
|
||||
filterAll: 'Todas',
|
||||
assignedToMe: 'Asignadas a mí',
|
||||
everyone: 'Todos',
|
||||
nothingOpen: 'Nada pendiente. ¡Buen trabajo!',
|
||||
none: 'No hay acciones aquí.',
|
||||
due: (date: string) => `Vence ${date}`,
|
||||
noDueDate: 'Sin fecha límite',
|
||||
overdue: 'Vencida',
|
||||
unassigned: 'Sin asignar',
|
||||
statusOf: (title: string) => `Estado de ${title}`,
|
||||
},
|
||||
outbox: {
|
||||
offline: 'Sin conexión. Los nuevos reportes se guardarán en este dispositivo y se enviarán al reconectarse.',
|
||||
waiting: (n: number) => `${plural(n, 'reporte pendiente', 'reportes pendientes')} de enviar`,
|
||||
sending: 'Enviando…',
|
||||
retry: 'Enviar ahora',
|
||||
otherUser: 'Guardado por otro usuario en este dispositivo',
|
||||
failed: (error: string) => `No se pudo enviar: ${error}`,
|
||||
discard: 'Descartar',
|
||||
discardConfirm: '¿Descartar este reporte sin enviar? No se puede deshacer.',
|
||||
signOutWarning: (n: number) => `${plural(n, 'reporte aún no se ha', 'reportes aún no se han')} enviado. Se enviarán la próxima vez que inicie sesión en este dispositivo. ¿Cerrar sesión de todos modos?`,
|
||||
},
|
||||
qr: {
|
||||
caption: 'Escanee para reportar un peligro o casi accidente',
|
||||
},
|
||||
types: {
|
||||
near_miss: 'Casi accidente',
|
||||
hazard: 'Peligro',
|
||||
injury: 'Lesión',
|
||||
illness: 'Enfermedad',
|
||||
property_damage: 'Daño a la propiedad',
|
||||
environmental: 'Ambiental',
|
||||
},
|
||||
typeHints: {
|
||||
near_miss: 'Algo casi salió mal',
|
||||
hazard: 'Algo que podría lastimar a alguien',
|
||||
injury: 'Alguien se lastimó',
|
||||
illness: 'Alguien se enfermó por el trabajo',
|
||||
property_damage: 'Equipo o edificio dañado',
|
||||
environmental: 'Derrame, fuga o emisión',
|
||||
},
|
||||
severities: { low: 'Baja', medium: 'Media', high: 'Alta', critical: 'Crítica' },
|
||||
severityHints: {
|
||||
low: 'Menor, sin probabilidad de lesión',
|
||||
medium: 'Podría causar una lesión menor',
|
||||
high: 'Podría causar una lesión grave',
|
||||
critical: 'Podría poner en riesgo la vida',
|
||||
},
|
||||
actionStatuses: { open: 'Abierta', in_progress: 'En curso', done: 'Hecha', verified: 'Verificada', cancelled: 'Cancelada' },
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { en } from './en'
|
||||
import { es } from './es'
|
||||
|
||||
/** Every leaf path in a messages object, e.g. "report.title". */
|
||||
function paths(value: unknown, prefix = ''): string[] {
|
||||
if (value && typeof value === 'object') return Object.entries(value).flatMap(([key, child]) => paths(child, prefix ? `${prefix}.${key}` : key))
|
||||
return [prefix]
|
||||
}
|
||||
|
||||
describe('translations', () => {
|
||||
it('Spanish covers every English message', () => {
|
||||
expect(paths(es).sort()).toEqual(paths(en).sort())
|
||||
})
|
||||
|
||||
it('has no untranslated copies of English text', () => {
|
||||
const allowed = new Set(['language.switchTo'])
|
||||
const same = paths(en).filter((path) => {
|
||||
const get = (root: unknown) => path.split('.').reduce((node: unknown, key) => (node as Record<string, unknown>)[key], root)
|
||||
const a = get(en)
|
||||
return typeof a === 'string' && a === get(es) && !allowed.has(path)
|
||||
})
|
||||
expect(same).toEqual([])
|
||||
})
|
||||
|
||||
it('interpolates', () => {
|
||||
expect(es.kiosk.thanks('INC-0042')).toBe('Su reporte INC-0042 se envió al equipo de seguridad.')
|
||||
expect(en.outbox.waiting(1)).toBe('1 report waiting to send')
|
||||
expect(es.outbox.waiting(3)).toBe('3 reportes pendientes de enviar')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import type { Messages } from './en'
|
||||
|
||||
export type Language = 'en' | 'es'
|
||||
|
||||
export interface I18n {
|
||||
lang: Language
|
||||
t: Messages
|
||||
setLang: (lang: Language) => void
|
||||
/** Locale-aware calendar date, e.g. "6 oct 2026". */
|
||||
formatDay: (isoDate: string) => string
|
||||
}
|
||||
|
||||
export const I18nContext = createContext<I18n | null>(null)
|
||||
|
||||
export function useI18n(): I18n {
|
||||
const context = useContext(I18nContext)
|
||||
if (!context) throw new Error('useI18n must be used inside I18nProvider')
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { en, type Messages } from './en'
|
||||
import { es } from './es'
|
||||
import { I18nContext, type I18n, type Language } from './index'
|
||||
|
||||
const messages: Record<Language, Messages> = { en, es }
|
||||
const STORAGE_KEY = 'safetylog.language'
|
||||
|
||||
function initialLanguage(): Language {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved === 'en' || saved === 'es') return saved
|
||||
} catch {
|
||||
// storage unavailable (private mode); fall through to the browser language
|
||||
}
|
||||
return navigator.language?.toLowerCase().startsWith('es') ? 'es' : 'en'
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [lang, setLangState] = useState<Language>(initialLanguage)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = lang
|
||||
}, [lang])
|
||||
|
||||
const setLang = useCallback((next: Language) => {
|
||||
setLangState(next)
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, next)
|
||||
} catch {
|
||||
// not persisted; still switches for this visit
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value = useMemo<I18n>(() => {
|
||||
const dayFormat = new Intl.DateTimeFormat(lang === 'es' ? 'es' : 'en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
|
||||
return { lang, t: messages[lang], setLang, formatDay: (isoDate) => dayFormat.format(new Date(`${isoDate}T00:00:00Z`)) }
|
||||
}, [lang, setLang])
|
||||
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { KioskConfig } from '../../shared/api-types.ts'
|
||||
import type { KioskReportInput } from '../../shared/schemas.ts'
|
||||
import { ApiError } from './api'
|
||||
|
||||
const HEADER = 'x-kiosk-token'
|
||||
|
||||
async function parse<T>(res: Response): Promise<T> {
|
||||
const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null
|
||||
if (!res.ok) throw new ApiError(res.status, data?.error ?? `Request failed (${res.status})`, data?.issues ?? [])
|
||||
return data as T
|
||||
}
|
||||
|
||||
export const fetchKioskConfig = (token: string) => fetch('/api/kiosk/config', { headers: { [HEADER]: token } }).then((res) => parse<KioskConfig>(res))
|
||||
|
||||
export function sendKioskReport(token: string, report: KioskReportInput, files: { name: string; type: string; blob: Blob }[]) {
|
||||
const form = new FormData()
|
||||
form.append('report', JSON.stringify(report))
|
||||
for (const file of files) form.append('files', new File([file.blob], file.name, { type: file.type }))
|
||||
return fetch('/api/kiosk/reports', { method: 'POST', headers: { [HEADER]: token }, body: form }).then((res) => parse<{ reference: string; failedFiles: string[] }>(res))
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import { BarChart3, ClipboardCheck, ClipboardList, LayoutDashboard, MapPin, type LucideIcon } from 'lucide-react'
|
||||
|
||||
import type { Messages } from '../i18n/en'
|
||||
|
||||
export interface NavItem {
|
||||
label: string
|
||||
label: keyof Messages['nav']
|
||||
to: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
export const primaryNav: NavItem[] = [
|
||||
{ label: 'Overview', to: '/', icon: LayoutDashboard },
|
||||
{ label: 'Incidents', to: '/incidents', icon: ClipboardList },
|
||||
{ label: 'My actions', to: '/actions', icon: ClipboardCheck },
|
||||
{ label: 'Locations', to: '/locations', icon: MapPin },
|
||||
{ label: 'Analytics', to: '/analytics', icon: BarChart3 },
|
||||
{ label: 'overview', to: '/', icon: LayoutDashboard },
|
||||
{ label: 'incidents', to: '/incidents', icon: ClipboardList },
|
||||
{ label: 'myActions', to: '/actions', icon: ClipboardCheck },
|
||||
{ label: 'locations', to: '/locations', icon: MapPin },
|
||||
{ label: 'analytics', to: '/analytics', icon: BarChart3 },
|
||||
]
|
||||
|
||||
export function pageTitle(pathname: string): string {
|
||||
@@ -24,6 +26,7 @@ export function pageTitle(pathname: string): string {
|
||||
'/locations': 'Locations',
|
||||
'/analytics': 'Analytics',
|
||||
'/report': 'Report an incident',
|
||||
'/locations/qr': 'QR codes',
|
||||
'/settings': 'Settings',
|
||||
'/about': 'About',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'fake-indexeddb/auto'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { outbox, type QueuedReport } from './outbox'
|
||||
|
||||
const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
|
||||
|
||||
function item(overrides: Partial<QueuedReport> = {}): QueuedReport {
|
||||
const clientId = crypto.randomUUID()
|
||||
return {
|
||||
kind: 'report',
|
||||
clientId,
|
||||
userId: 'u1',
|
||||
createdAt: new Date().toISOString(),
|
||||
attempts: 0,
|
||||
files: [],
|
||||
report: { title: 'Spill', type: 'hazard', severity: 'low', occurredAt: new Date().toISOString(), siteId: crypto.randomUUID(), clientId },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const online = (value: boolean) => vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(value)
|
||||
|
||||
beforeEach(async () => {
|
||||
online(true)
|
||||
for (const queued of outbox.getState().items) await outbox.discard(queued.clientId)
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('outbox', () => {
|
||||
it('sends straight away when online and keeps nothing', async () => {
|
||||
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json(201, { incident: { number: 7 } }))
|
||||
const result = await outbox.submit(item())
|
||||
expect(result).toEqual({ status: 'sent', reference: 'INC-0007', number: 7, failedFiles: [] })
|
||||
expect(fetch).toHaveBeenCalledOnce()
|
||||
expect(outbox.getState().items).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the report on the device while offline, then sends it on flush', async () => {
|
||||
online(false)
|
||||
const queued = item()
|
||||
expect(await outbox.submit(queued)).toEqual({ status: 'queued' })
|
||||
expect(outbox.getState().items.map((i) => i.clientId)).toEqual([queued.clientId])
|
||||
|
||||
online(true)
|
||||
const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue(json(201, { incident: { number: 8 } }))
|
||||
await outbox.flush((i) => i.kind === 'report' && i.userId === 'u1')
|
||||
expect(JSON.parse(fetch.mock.calls[0][1]!.body as string).clientId).toBe(queued.clientId)
|
||||
expect(outbox.getState().items).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps reports when the network drops mid-send', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
expect(await outbox.submit(item())).toEqual({ status: 'queued' })
|
||||
expect(outbox.getState().items[0]).toMatchObject({ attempts: 1, lastError: 'Failed to fetch' })
|
||||
})
|
||||
|
||||
it('hands validation errors back instead of retrying', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(json(400, { error: 'Unknown site' }))
|
||||
const result = await outbox.submit(item())
|
||||
expect(result.status).toBe('rejected')
|
||||
expect(outbox.getState().items).toEqual([])
|
||||
})
|
||||
|
||||
it("only sends a user's own reports", async () => {
|
||||
online(false)
|
||||
await outbox.submit(item({ userId: 'someone-else' }))
|
||||
online(true)
|
||||
const fetch = vi.spyOn(globalThis, 'fetch')
|
||||
await outbox.flush((i) => i.kind === 'report' && i.userId === 'u1')
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(outbox.getState().items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('waits for sign-in when the session has expired', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(json(401, { error: 'Sign in required' }))
|
||||
expect(await outbox.submit(item())).toEqual({ status: 'queued' })
|
||||
expect(outbox.getState().items[0].lastError).toBe('Sign in to send')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
// Reports waiting to be sent, stored in IndexedDB so they survive going offline, closing the tab and
|
||||
// restarting the device. Each has a client-generated id that the server uses to ignore duplicates,
|
||||
// so retrying is always safe.
|
||||
import type { CreateIncidentInput, KioskReportInput } from '../../shared/schemas.ts'
|
||||
import type { Incident } from '../../shared/api-types.ts'
|
||||
import { incidentReference } from '../../shared/format.ts'
|
||||
import { api, ApiError, errorMessage } from './api'
|
||||
import { sendKioskReport } from './kiosk'
|
||||
import { uploadAttachment } from './queries'
|
||||
|
||||
export interface QueuedFile {
|
||||
name: string
|
||||
type: string
|
||||
blob: Blob
|
||||
/** Set once uploaded, so a retry after a partial upload doesn't send it twice. */
|
||||
uploaded?: boolean
|
||||
}
|
||||
|
||||
interface QueuedBase {
|
||||
clientId: string
|
||||
createdAt: string
|
||||
files: QueuedFile[]
|
||||
attempts: number
|
||||
lastError?: string
|
||||
/** The server refused it for a reason retrying won't fix; waits for the user to discard it. */
|
||||
rejected?: boolean
|
||||
}
|
||||
|
||||
export interface QueuedReport extends QueuedBase {
|
||||
kind: 'report'
|
||||
/** Only sent while this user is signed in. */
|
||||
userId: string
|
||||
report: CreateIncidentInput & { clientId: string }
|
||||
/** Known once the report itself is saved, while photos may still be pending. */
|
||||
incidentNumber?: number
|
||||
}
|
||||
|
||||
export interface QueuedKioskReport extends QueuedBase {
|
||||
kind: 'kiosk'
|
||||
token: string
|
||||
report: KioskReportInput & { clientId: string }
|
||||
}
|
||||
|
||||
export type QueuedItem = QueuedReport | QueuedKioskReport
|
||||
|
||||
export type SendResult =
|
||||
| { status: 'sent'; reference: string; number?: number; failedFiles: string[] }
|
||||
| { status: 'queued' }
|
||||
| { status: 'rejected'; error: ApiError }
|
||||
|
||||
// ---- IndexedDB ----
|
||||
|
||||
const DB_NAME = 'safetylog'
|
||||
const STORE = 'outbox'
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, 1)
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(STORE, { keyPath: 'clientId' })
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function withStore<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest<T>): Promise<T> {
|
||||
const db = await openDb()
|
||||
try {
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const request = run(db.transaction(STORE, mode).objectStore(STORE))
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Sending ----
|
||||
|
||||
/** Worth retrying later: no connection, server down or overloaded, or signed out. */
|
||||
export function isTransient(error: unknown): boolean {
|
||||
if (error instanceof TypeError) return true // fetch() network failure
|
||||
return error instanceof ApiError && (error.status >= 500 || error.status === 401 || error.status === 408 || error.status === 429)
|
||||
}
|
||||
|
||||
async function sendReport(item: QueuedReport, save: (item: QueuedItem) => Promise<void>): Promise<SendResult> {
|
||||
if (item.incidentNumber === undefined) {
|
||||
const { incident } = await api<{ incident: Incident }>('/incidents', { method: 'POST', body: item.report })
|
||||
item.incidentNumber = incident.number
|
||||
await save(item)
|
||||
}
|
||||
const failedFiles: string[] = []
|
||||
for (const file of item.files.filter((f) => !f.uploaded)) {
|
||||
try {
|
||||
await uploadAttachment(item.incidentNumber, new File([file.blob], file.name, { type: file.type }))
|
||||
} catch (error) {
|
||||
if (isTransient(error)) throw error
|
||||
failedFiles.push(`${file.name}: ${errorMessage(error)}`) // e.g. unsupported type; don't retry forever
|
||||
}
|
||||
file.uploaded = true
|
||||
await save(item)
|
||||
}
|
||||
return { status: 'sent', reference: incidentReference(item.incidentNumber), number: item.incidentNumber, failedFiles }
|
||||
}
|
||||
|
||||
async function sendKiosk(item: QueuedKioskReport): Promise<SendResult> {
|
||||
const result = await sendKioskReport(item.token, item.report, item.files)
|
||||
return { status: 'sent', reference: result.reference, failedFiles: result.failedFiles }
|
||||
}
|
||||
|
||||
// ---- Store with subscribers ----
|
||||
|
||||
export interface OutboxState {
|
||||
items: QueuedItem[]
|
||||
syncing: boolean
|
||||
}
|
||||
|
||||
class Outbox {
|
||||
private state: OutboxState = { items: [], syncing: false }
|
||||
private listeners = new Set<() => void>()
|
||||
private loaded: Promise<void> | undefined
|
||||
|
||||
getState = () => this.state
|
||||
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener)
|
||||
void this.load()
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
private set(partial: Partial<OutboxState>) {
|
||||
this.state = { ...this.state, ...partial }
|
||||
for (const listener of this.listeners) listener()
|
||||
}
|
||||
|
||||
load(): Promise<void> {
|
||||
this.loaded ??= this.refresh()
|
||||
return this.loaded
|
||||
}
|
||||
|
||||
private async refresh() {
|
||||
try {
|
||||
const items = await withStore<QueuedItem[]>('readonly', (store) => store.getAll())
|
||||
this.set({ items: items.sort((a, b) => a.createdAt.localeCompare(b.createdAt)) })
|
||||
} catch (error) {
|
||||
console.warn('[outbox] storage unavailable', error)
|
||||
}
|
||||
}
|
||||
|
||||
private save = async (item: QueuedItem) => {
|
||||
await withStore('readwrite', (store) => store.put(item))
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
private async remove(clientId: string) {
|
||||
await withStore('readwrite', (store) => store.delete(clientId))
|
||||
await this.refresh()
|
||||
}
|
||||
|
||||
discard = (clientId: string) => this.remove(clientId)
|
||||
|
||||
private async attempt(item: QueuedItem): Promise<SendResult> {
|
||||
try {
|
||||
const result = item.kind === 'report' ? await sendReport(item, this.save) : await sendKiosk(item)
|
||||
await this.remove(item.clientId)
|
||||
return result
|
||||
} catch (error) {
|
||||
if (isTransient(error)) {
|
||||
item.attempts += 1
|
||||
item.lastError = error instanceof ApiError && error.status === 401 ? 'Sign in to send' : errorMessage(error)
|
||||
await this.save(item)
|
||||
return { status: 'queued' }
|
||||
}
|
||||
const apiError = error instanceof ApiError ? error : new ApiError(0, errorMessage(error))
|
||||
item.lastError = apiError.message
|
||||
item.rejected = true
|
||||
await this.save(item)
|
||||
return { status: 'rejected', error: apiError }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the report on the device first, then tries to send it straight away. Rejected reports
|
||||
* (e.g. a validation error the user can fix) are removed again so the form can show the error.
|
||||
*/
|
||||
async submit(item: QueuedItem): Promise<SendResult> {
|
||||
await this.load()
|
||||
await this.save(item)
|
||||
if (!navigator.onLine) return { status: 'queued' }
|
||||
const result = await this.attempt(item)
|
||||
if (result.status === 'rejected') await this.remove(item.clientId)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Sends every waiting item that `belongs` returns true for, oldest first, stopping when offline. */
|
||||
async flush(belongs: (item: QueuedItem) => boolean): Promise<void> {
|
||||
await this.load()
|
||||
if (this.state.syncing || !navigator.onLine) return
|
||||
const pending = this.state.items.filter((item) => belongs(item) && !item.rejected)
|
||||
if (pending.length === 0) return
|
||||
this.set({ syncing: true })
|
||||
try {
|
||||
for (const item of pending) {
|
||||
const result = await this.attempt(item)
|
||||
if (result.status === 'queued') break
|
||||
}
|
||||
} finally {
|
||||
this.set({ syncing: false })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const outbox = new Outbox()
|
||||
+26
-1
@@ -6,6 +6,7 @@ import type {
|
||||
CorrectiveAction,
|
||||
Incident,
|
||||
IncidentDetail,
|
||||
Kiosk,
|
||||
Organization,
|
||||
OverviewStats,
|
||||
Paginated,
|
||||
@@ -60,9 +61,11 @@ export function useLogout() {
|
||||
const client = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => api('/auth/logout', { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
onSuccess: async () => {
|
||||
client.clear()
|
||||
client.setQueryData(['me'], null)
|
||||
// Offline copies of "who am I" and site lists belong to the signed-out user
|
||||
if ('caches' in window) await caches.delete('api-essentials')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -222,3 +225,25 @@ export function useUserMutations() {
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Reporting links (kiosks / QR) ----
|
||||
|
||||
export const useKiosks = (enabled = true) => useQuery({ queryKey: ['kiosks'], queryFn: () => api<{ kiosks: Kiosk[] }>('/kiosks').then((r) => r.kiosks), enabled })
|
||||
|
||||
export function useKioskMutations() {
|
||||
const client = useQueryClient()
|
||||
const done = () => client.invalidateQueries({ queryKey: ['kiosks'] })
|
||||
return {
|
||||
create: useMutation({ mutationFn: (input: { name: string; siteId?: string | null }) => api<{ kiosk: Kiosk }>('/kiosks', { method: 'POST', body: input }), onSuccess: done }),
|
||||
update: useMutation({ mutationFn: ({ id, ...input }: { id: string; name?: string; revoked?: boolean }) => api(`/kiosks/${id}`, { method: 'PATCH', body: input }), onSuccess: done }),
|
||||
}
|
||||
}
|
||||
|
||||
/** Link that opens the report form, optionally through a reporting link and pre-filled with a location. */
|
||||
export function reportUrl(origin: string, { token, siteId, areaId }: { token?: string; siteId?: string; areaId?: string }) {
|
||||
const params = new URLSearchParams()
|
||||
if (siteId) params.set('site', siteId)
|
||||
if (areaId) params.set('area', areaId)
|
||||
const query = params.toString() ? `?${params}` : ''
|
||||
return `${origin}${token ? `/k/${token}` : '/report'}${query}`
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
import { outbox, type QueuedItem } from './outbox'
|
||||
|
||||
export function useOutbox() {
|
||||
return useSyncExternalStore(outbox.subscribe, outbox.getState)
|
||||
}
|
||||
|
||||
const RETRY_MS = 30_000
|
||||
|
||||
/** Sends matching waiting reports now, whenever the connection returns, and every 30 s while any wait. */
|
||||
export function useOutboxSync(belongs: ((item: QueuedItem) => boolean) | null) {
|
||||
const { items } = useOutbox()
|
||||
const waiting = belongs ? items.filter(belongs).length : 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!belongs) return
|
||||
const flush = () => void outbox.flush(belongs)
|
||||
flush()
|
||||
window.addEventListener('online', flush)
|
||||
return () => window.removeEventListener('online', flush)
|
||||
// `belongs` is recreated each render; the filter it encodes is stable per user/token
|
||||
// oxlint-disable-next-line react/exhaustive-deps
|
||||
}, [!!belongs])
|
||||
|
||||
useEffect(() => {
|
||||
if (!belongs || waiting === 0) return
|
||||
const timer = setInterval(() => void outbox.flush(belongs), RETRY_MS)
|
||||
return () => clearInterval(timer)
|
||||
// oxlint-disable-next-line react/exhaustive-deps
|
||||
}, [waiting, !!belongs])
|
||||
}
|
||||
|
||||
export function useOnline() {
|
||||
return useSyncExternalStore(
|
||||
(onChange) => {
|
||||
window.addEventListener('online', onChange)
|
||||
window.addEventListener('offline', onChange)
|
||||
return () => {
|
||||
window.removeEventListener('online', onChange)
|
||||
window.removeEventListener('offline', onChange)
|
||||
}
|
||||
},
|
||||
() => navigator.onLine,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { uuid } from './uuid'
|
||||
|
||||
describe('uuid', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('makes v4 UUIDs even where crypto.randomUUID is missing (plain-HTTP LAN installs)', () => {
|
||||
const real = globalThis.crypto
|
||||
vi.stubGlobal('crypto', { getRandomValues: real.getRandomValues.bind(real) })
|
||||
expect(globalThis.crypto.randomUUID).toBeUndefined()
|
||||
const id = uuid()
|
||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
||||
expect(uuid()).not.toBe(id)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Random UUID v4. `crypto.randomUUID` only exists on HTTPS/localhost, and plant servers are often
|
||||
* reached over plain HTTP on the LAN, so fall back to getRandomValues (available everywhere).
|
||||
*/
|
||||
export function uuid(): string {
|
||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16))
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import { I18nProvider } from './i18n/provider'
|
||||
import { ApiError } from './lib/api'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -19,9 +20,11 @@ const queryClient = new QueryClient({
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
+16
-15
@@ -6,20 +6,21 @@ 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 { useI18n } from '../i18n'
|
||||
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' },
|
||||
{ value: 'open', label: 'filterOpen' },
|
||||
{ value: 'overdue', label: 'filterOverdue' },
|
||||
{ value: 'done', label: 'filterDone' },
|
||||
{ value: 'all', label: 'filterAll' },
|
||||
] 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 { t, formatDay } = useI18n()
|
||||
const [params, setParams] = useSearchParams()
|
||||
const canSeeAll = hasRole(me.user.role, 'supervisor')
|
||||
const scope = canSeeAll && params.get('scope') === 'all' ? 'all' : 'mine'
|
||||
@@ -36,25 +37,25 @@ export function Actions() {
|
||||
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.'}
|
||||
eyebrow={t.actions.eyebrow}
|
||||
title={scope === 'mine' ? t.actions.mine : t.actions.all}
|
||||
copy={scope === 'mine' ? t.actions.mineCopy : t.actions.allCopy}
|
||||
/>
|
||||
<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}
|
||||
{t.actions[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
|
||||
{t.actions.assignedToMe}
|
||||
</button>
|
||||
<button type="button" role="tab" aria-selected={scope === 'all'} className={tab(scope === 'all')} onClick={() => set('scope', 'all')}>
|
||||
Everyone
|
||||
{t.actions.everyone}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -64,7 +65,7 @@ export function Actions() {
|
||||
{actions.isPending ? (
|
||||
<Loading />
|
||||
) : !actions.data?.length ? (
|
||||
<Empty>{filter === 'open' ? 'Nothing open. Nice work.' : 'No actions here.'}</Empty>
|
||||
<Empty>{filter === 'open' ? t.actions.nothingOpen : t.actions.none}</Empty>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-soft">
|
||||
{actions.data.map((action) => (
|
||||
@@ -76,12 +77,12 @@ export function Actions() {
|
||||
{action.incident.reference}
|
||||
</Link>{' '}
|
||||
{action.incident.title}
|
||||
{scope === 'all' && ` · ${action.assignee?.name ?? 'Unassigned'}`}
|
||||
{scope === 'all' && ` · ${action.assignee?.name ?? t.actions.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>
|
||||
{action.overdue && <OverdueBadge label={t.actions.overdue} />}
|
||||
<span className="w-[100px] text-right">{action.dueDate ? t.actions.due(formatDay(action.dueDate)) : t.actions.noDueDate}</span>
|
||||
<ActionStatusControl action={action} disabled={update.isPending} onChange={(status) => update.mutate({ id: action.id, status })} />
|
||||
</div>
|
||||
</li>
|
||||
|
||||
+23
-19
@@ -5,6 +5,7 @@ import { AuthLayout } from '../components/AuthLayout'
|
||||
import { Field } from '../components/FormSection'
|
||||
import { ErrorMessage, Loading } from '../components/QueryState'
|
||||
import { field, primaryButton, textButton } from '../components/styles'
|
||||
import { useI18n } from '../i18n'
|
||||
import { useMe, useRequestEmail, useSetupStatus, useSignIn } from '../lib/queries'
|
||||
|
||||
const fullWidth = `${primaryButton} w-full`
|
||||
@@ -19,6 +20,7 @@ function Sent({ children }: { children: string }) {
|
||||
}
|
||||
|
||||
export function Login() {
|
||||
const { t } = useI18n()
|
||||
const location = useLocation()
|
||||
const me = useMe()
|
||||
const setup = useSetupStatus()
|
||||
@@ -50,41 +52,41 @@ export function Login() {
|
||||
|
||||
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.'}
|
||||
title={mode === 'reset' ? t.auth.resetTitle : t.auth.signIn}
|
||||
copy={mode === 'password' ? undefined : mode === 'link' ? t.auth.linkCopy : t.auth.resetCopy}
|
||||
>
|
||||
<form onSubmit={submit} className="space-y-1">
|
||||
<Field label="Email">
|
||||
<Field label={t.auth.email}>
|
||||
<input className={field} type="email" autoComplete="email" required value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
</Field>
|
||||
{mode === 'password' && (
|
||||
<Field label="Password">
|
||||
<Field label={t.auth.password}>
|
||||
<input className={field} type="password" autoComplete="current-password" required value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||
</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>
|
||||
<Sent>{t.auth.sent}</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'}
|
||||
{mode === 'password' ? t.auth.signIn : mode === 'link' ? t.auth.sendLink : t.auth.sendReset}
|
||||
</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
|
||||
{t.auth.usePassword}
|
||||
</button>
|
||||
)}
|
||||
{mode !== 'link' && (
|
||||
<button type="button" className={textButton} onClick={() => switchMode('link')}>
|
||||
Email me a sign-in link instead
|
||||
{t.auth.useLink}
|
||||
</button>
|
||||
)}
|
||||
{mode !== 'reset' && (
|
||||
<button type="button" className={textButton} onClick={() => switchMode('reset')}>
|
||||
Forgot password?
|
||||
{t.auth.forgot}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -133,6 +135,7 @@ export function Setup() {
|
||||
}
|
||||
|
||||
export function MagicLinkLanding() {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const [params] = useSearchParams()
|
||||
const verify = useSignIn<{ token: string }>('/auth/magic-link/verify')
|
||||
@@ -140,19 +143,20 @@ export function MagicLinkLanding() {
|
||||
|
||||
// A button rather than signing in on load, so email link scanners can't use up the link.
|
||||
return (
|
||||
<AuthLayout title="Sign in" copy="Continue to sign in with the link from your email.">
|
||||
<AuthLayout title={t.auth.signIn} copy={t.auth.continueWithLink}>
|
||||
<ErrorMessage error={verify.error} className="mb-4" />
|
||||
<button type="button" className={fullWidth} disabled={!token || verify.isPending} onClick={() => verify.mutate({ token }, { onSuccess: () => navigate('/', { replace: true }) })}>
|
||||
Sign in
|
||||
{t.auth.signIn}
|
||||
</button>
|
||||
<Link to="/login" className={`${textButton} mt-5`}>
|
||||
Back to sign in
|
||||
{t.auth.backToSignIn}
|
||||
</Link>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResetPassword() {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const [params] = useSearchParams()
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -161,28 +165,28 @@ export function ResetPassword() {
|
||||
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.">
|
||||
<AuthLayout title={t.auth.choosePassword} copy={t.auth.choosePasswordCopy}>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!mismatch) reset.mutate({ token: params.get('token') ?? '', newPassword: password }, { onSuccess: () => navigate('/', { replace: true }) })
|
||||
}}
|
||||
>
|
||||
<Field label="New password">
|
||||
<Field label={t.auth.newPassword}>
|
||||
<input className={field} type="password" required minLength={10} autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||
<span className="mt-1 block font-normal text-faint">At least 10 characters.</span>
|
||||
<span className="mt-1 block font-normal text-faint">{t.auth.atLeast10}</span>
|
||||
</Field>
|
||||
<Field label="Confirm password">
|
||||
<Field label={t.auth.confirmPassword}>
|
||||
<input className={field} type="password" required autoComplete="new-password" value={confirm} onChange={(event) => setConfirm(event.target.value)} />
|
||||
{mismatch && <span className="mt-1 block font-normal text-coral-strong">Passwords don’t match.</span>}
|
||||
{mismatch && <span className="mt-1 block font-normal text-coral-strong">{t.auth.mismatch}</span>}
|
||||
</Field>
|
||||
<ErrorMessage error={reset.error} className="mb-4" />
|
||||
<button className={fullWidth} type="submit" disabled={reset.isPending || mismatch}>
|
||||
Save password and sign in
|
||||
{t.auth.savePassword}
|
||||
</button>
|
||||
</form>
|
||||
<Link to="/login" className={`${textButton} mt-5`}>
|
||||
Back to sign in
|
||||
{t.auth.backToSignIn}
|
||||
</Link>
|
||||
</AuthLayout>
|
||||
)
|
||||
|
||||
@@ -70,7 +70,8 @@ export function IncidentDetail() {
|
||||
</span>
|
||||
<span>Occurred {formatDateTime(incident.occurredAt)}</span>
|
||||
<span>
|
||||
Reported by {incident.anonymous ? 'anonymous' : (incident.reporter?.name ?? 'former user')} · {formatDateTime(incident.reportedAt)}
|
||||
Reported by {incident.anonymous ? 'anonymous' : (incident.reporter?.name ?? incident.reporterName ?? 'former user')}
|
||||
{incident.kiosk && ` via ${incident.kiosk.name}`} · {formatDateTime(incident.reportedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { AlertTriangle, Biohazard, CheckCircle2, CloudOff, Droplets, Eye, HardHat, Hammer, ShieldCheck, type LucideIcon } from 'lucide-react'
|
||||
import { useEffect, useState, type ChangeEvent, type FormEvent } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import { incidentTypes, severities, type IncidentType, type Severity } from '../../shared/enums.ts'
|
||||
import { Field } from '../components/FormSection'
|
||||
import { LanguageToggle } from '../components/LanguageToggle'
|
||||
import { OutboxBanner } from '../components/OutboxBanner'
|
||||
import { ErrorMessage, Loading } from '../components/QueryState'
|
||||
import { field, panel, primaryButton } from '../components/styles'
|
||||
import { useI18n } from '../i18n'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { fetchKioskConfig } from '../lib/kiosk'
|
||||
import { outbox, type QueuedItem } from '../lib/outbox'
|
||||
import { useOutboxSync } from '../lib/use-outbox'
|
||||
import { uuid } from '../lib/uuid'
|
||||
import { PhotoPicker } from './ReportIncident'
|
||||
|
||||
const typeIcons: Record<IncidentType, LucideIcon> = {
|
||||
hazard: AlertTriangle,
|
||||
near_miss: Eye,
|
||||
injury: HardHat,
|
||||
illness: Biohazard,
|
||||
property_damage: Hammer,
|
||||
environmental: Droplets,
|
||||
}
|
||||
const severityColors: Record<Severity, string> = {
|
||||
low: 'peer-checked:border-[#52a57e] peer-checked:bg-mint-soft',
|
||||
medium: 'peer-checked:border-[#d8a631] peer-checked:bg-amber-soft',
|
||||
high: 'peer-checked:border-coral peer-checked:bg-coral-soft',
|
||||
critical: 'peer-checked:border-[#9b2c1f] peer-checked:bg-[#f7d4cd]',
|
||||
}
|
||||
const RESET_SECONDS = 20
|
||||
const MAX_PHOTOS = 5
|
||||
const tile = 'flex h-full cursor-pointer flex-col gap-1 rounded-lg border-2 border-line bg-white p-3 text-left transition peer-focus-visible:ring-2 peer-focus-visible:ring-coral'
|
||||
|
||||
/**
|
||||
* Report form for people without an account, opened through a reporting link: a shared tablet on
|
||||
* the shop floor (kiosk) or a phone that scanned a QR code. Works offline.
|
||||
*/
|
||||
export function Kiosk() {
|
||||
const token = useParams().token ?? ''
|
||||
const [params] = useSearchParams()
|
||||
const { t } = useI18n()
|
||||
const config = useQuery({ queryKey: ['kiosk-config', token], queryFn: () => fetchKioskConfig(token), retry: false })
|
||||
const belongs = (item: QueuedItem) => item.kind === 'kiosk' && item.token === token
|
||||
useOutboxSync(belongs)
|
||||
|
||||
const blank = () => ({
|
||||
type: '' as IncidentType | '',
|
||||
severity: 'medium' as Severity,
|
||||
title: '',
|
||||
description: '',
|
||||
siteId: params.get('site') ?? '',
|
||||
areaId: params.get('area') ?? '',
|
||||
reporterName: '',
|
||||
})
|
||||
const [form, setForm] = useState(blank)
|
||||
const [photos, setPhotos] = useState<File[]>([])
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState<Error>()
|
||||
const [done, setDone] = useState<{ reference?: string } | null>(null)
|
||||
const [countdown, setCountdown] = useState(RESET_SECONDS)
|
||||
|
||||
const reset = () => {
|
||||
setForm(blank())
|
||||
setPhotos([])
|
||||
setDone(null)
|
||||
setError(undefined)
|
||||
}
|
||||
|
||||
// After a report, return to an empty form for the next person
|
||||
useEffect(() => {
|
||||
if (!done) return
|
||||
setCountdown(RESET_SECONDS)
|
||||
const timer = setInterval(() => setCountdown((n) => n - 1), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [done])
|
||||
useEffect(() => {
|
||||
if (done && countdown <= 0) reset()
|
||||
// oxlint-disable-next-line react/exhaustive-deps
|
||||
}, [countdown, done])
|
||||
|
||||
if (config.isPending) return <Loading label={t.common.loading} />
|
||||
if (config.error) {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-canvas p-6">
|
||||
<div className={`${panel} max-w-md p-8 text-center`}>
|
||||
<AlertTriangle size={30} className="mx-auto text-coral" />
|
||||
<p className="mt-4 text-sm text-body">{config.error instanceof ApiError && config.error.status === 401 ? t.kiosk.invalidLink : config.error.message}</p>
|
||||
<LanguageToggle className="mt-6 text-muted" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { sites, organization, kiosk } = config.data
|
||||
const siteId = form.siteId || sites[0]?.id || ''
|
||||
const areas = sites.find((site) => site.id === siteId)?.areas ?? []
|
||||
const set = (key: keyof typeof form) => (event: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
|
||||
setForm({ ...form, [key]: event.target.value, ...(key === 'siteId' ? { areaId: '' } : {}) })
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!form.type) return
|
||||
setSending(true)
|
||||
setError(undefined)
|
||||
const clientId = uuid()
|
||||
const result = await outbox.submit({
|
||||
kind: 'kiosk',
|
||||
token,
|
||||
clientId,
|
||||
createdAt: new Date().toISOString(),
|
||||
attempts: 0,
|
||||
files: photos.map((photo) => ({ name: photo.name, type: photo.type, blob: photo })),
|
||||
report: {
|
||||
title: form.title,
|
||||
type: form.type,
|
||||
severity: form.severity,
|
||||
description: form.description || null,
|
||||
occurredAt: new Date().toISOString(),
|
||||
siteId,
|
||||
areaId: form.areaId || null,
|
||||
reporterName: form.reporterName.trim() || null,
|
||||
clientId,
|
||||
},
|
||||
})
|
||||
setSending(false)
|
||||
if (result.status === 'rejected') setError(result.error)
|
||||
else setDone(result.status === 'sent' ? { reference: result.reference } : {})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<header className="flex items-center gap-3 bg-forest-950 px-5 py-4 text-white">
|
||||
<div className="grid size-9 flex-none place-items-center rounded-[9px] bg-coral">
|
||||
<ShieldCheck size={20} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<strong className="block truncate font-display text-lg leading-tight">{organization.name}</strong>
|
||||
<span className="block truncate text-2xs text-forest-300">{kiosk.name}</span>
|
||||
</div>
|
||||
<LanguageToggle className="rounded-md border border-forest-700 px-3 py-2 text-white hover:bg-forest-800" />
|
||||
</header>
|
||||
<OutboxBanner belongs={belongs} />
|
||||
|
||||
<main className="mx-auto max-w-[760px] px-4 py-6 sm:py-10">
|
||||
{done ? (
|
||||
<div className={`${panel} p-10 text-center`} role="status">
|
||||
{done.reference ? <CheckCircle2 size={48} className="mx-auto text-mint-strong" /> : <CloudOff size={48} className="mx-auto text-amber-strong" />}
|
||||
<h1 className="mt-4 font-display text-3xl font-semibold text-ink">{t.kiosk.thanksTitle}</h1>
|
||||
<p className="mx-auto mt-3 max-w-[420px] text-base leading-[1.6] text-body">{done.reference ? t.kiosk.thanks(done.reference) : t.kiosk.thanksQueued}</p>
|
||||
<button type="button" className={`${primaryButton} mt-8 px-6 py-4 text-sm`} onClick={reset}>
|
||||
{t.report.reportAnother}
|
||||
</button>
|
||||
<p className="mt-4 text-2xs text-faint">{t.kiosk.resetIn(countdown)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="space-y-6">
|
||||
<div>
|
||||
<h1 className="font-display text-[28px] leading-tight font-semibold text-ink sm:text-[34px]">{t.kiosk.heading}</h1>
|
||||
<p className="mt-2 text-sm leading-[1.6] text-muted">{t.kiosk.intro}</p>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend className="mb-3 text-sm font-semibold text-ink">{t.kiosk.whatKind}</legend>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{incidentTypes.map((type) => {
|
||||
const Icon = typeIcons[type]
|
||||
return (
|
||||
<label key={type}>
|
||||
<input type="radio" name="type" value={type} required className="peer sr-only" checked={form.type === type} onChange={() => setForm({ ...form, type })} />
|
||||
<span className={`${tile} peer-checked:border-coral peer-checked:bg-[#fff6f3]`}>
|
||||
<Icon size={22} className="text-coral" />
|
||||
<strong className="text-sm text-ink">{t.types[type]}</strong>
|
||||
<span className="text-2xs leading-[1.4] text-muted">{t.typeHints[type]}</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend className="mb-3 text-sm font-semibold text-ink">{t.kiosk.howSerious}</legend>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{severities.map((severity) => (
|
||||
<label key={severity}>
|
||||
<input type="radio" name="severity" value={severity} className="peer sr-only" checked={form.severity === severity} onChange={() => setForm({ ...form, severity })} />
|
||||
<span className={`${tile} ${severityColors[severity]}`}>
|
||||
<strong className="text-sm text-ink">{t.severities[severity]}</strong>
|
||||
<span className="text-2xs leading-[1.4] text-muted">{t.severityHints[severity]}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className={`${panel} p-5 sm:p-6`}>
|
||||
<Field label={t.kiosk.summary}>
|
||||
<input className={`${field} text-sm`} required maxLength={200} value={form.title} onChange={set('title')} placeholder={t.kiosk.summaryPlaceholder} />
|
||||
</Field>
|
||||
<Field label={t.kiosk.details} optional optionalLabel={t.common.optional}>
|
||||
<textarea className={`${field} resize-y text-sm`} rows={3} value={form.description} onChange={set('description')} />
|
||||
</Field>
|
||||
<div className="grid sm:grid-cols-2 sm:gap-4">
|
||||
{sites.length > 1 && (
|
||||
<Field label={t.report.site}>
|
||||
<select className={`${field} text-sm`} value={siteId} onChange={set('siteId')}>
|
||||
{sites.map((site) => (
|
||||
<option key={site.id} value={site.id}>
|
||||
{site.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t.report.area} optional optionalLabel={t.common.optional}>
|
||||
<select className={`${field} text-sm`} value={form.areaId} onChange={set('areaId')}>
|
||||
<option value="">{t.report.notSpecified}</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
{area.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<PhotoPicker
|
||||
photos={photos}
|
||||
max={MAX_PHOTOS}
|
||||
onAdd={(event) => {
|
||||
setPhotos([...photos, ...Array.from(event.target.files ?? [])].slice(0, MAX_PHOTOS))
|
||||
event.target.value = ''
|
||||
}}
|
||||
onRemove={(index) => setPhotos(photos.filter((_, i) => i !== index))}
|
||||
/>
|
||||
<Field label={t.kiosk.yourName} optional optionalLabel={t.common.optional}>
|
||||
<input className={`${field} text-sm`} autoComplete="off" value={form.reporterName} onChange={set('reporterName')} />
|
||||
<span className="mt-1 block font-normal text-faint">{t.kiosk.yourNameHelp}</span>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
<button type="submit" className={`${primaryButton} w-full py-4 text-base`} disabled={sending || !form.type}>
|
||||
{sending ? t.kiosk.sending : t.kiosk.send}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+10
-4
@@ -1,4 +1,4 @@
|
||||
import { Archive, Building2, Plus } from 'lucide-react'
|
||||
import { Archive, Building2, Plus, QrCode } 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'
|
||||
@@ -50,12 +50,18 @@ export function Locations() {
|
||||
title="Locations"
|
||||
copy="Your sites and the buildings, areas and lines inside them. Reports are tagged with these so patterns become visible."
|
||||
action={
|
||||
canManage &&
|
||||
!adding && (
|
||||
<div className="flex gap-2">
|
||||
{canManage && (
|
||||
<Link to="/locations/qr" className={secondaryButton}>
|
||||
<QrCode size={17} /> QR codes
|
||||
</Link>
|
||||
)}
|
||||
{canManage && !adding && (
|
||||
<button type="button" className={primaryButton} onClick={() => setAdding(true)}>
|
||||
<Plus size={17} /> Add site
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{adding && (
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Printer } from 'lucide-react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { Page } from '../components/Page'
|
||||
import { PageIntro } from '../components/PageIntro'
|
||||
import { QrCode } from '../components/QrCode'
|
||||
import { Empty, Loading } from '../components/QueryState'
|
||||
import { filterBox, panel, primaryButton } from '../components/styles'
|
||||
import { en } from '../i18n/en'
|
||||
import { es } from '../i18n/es'
|
||||
import { reportUrl, useKiosks, useSites } from '../lib/queries'
|
||||
|
||||
const select = 'bg-transparent text-xs text-ink outline-none'
|
||||
|
||||
/** Printable QR codes, one per area, that open the report form with the location filled in. */
|
||||
export function QrSheet() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const sites = useSites()
|
||||
const kiosks = useKiosks()
|
||||
const activeLinks = kiosks.data?.filter((k) => !k.revokedAt) ?? []
|
||||
const site = sites.data?.find((s) => s.id === params.get('site')) ?? sites.data?.[0]
|
||||
const linkId = params.get('via') ?? ''
|
||||
const link = activeLinks.find((k) => k.id === linkId)
|
||||
const set = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(params)
|
||||
next.set(key, value)
|
||||
setParams(next, { replace: true })
|
||||
}
|
||||
|
||||
if (sites.isPending || kiosks.isPending) return <Loading />
|
||||
if (!site) return <Empty>Add a site first.</Empty>
|
||||
|
||||
const origin = window.location.origin
|
||||
const targets = [{ id: '', name: `${site.name} (whole site)` }, ...site.areas.map((area) => ({ id: area.id, name: area.name }))]
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="print:hidden">
|
||||
<PageIntro
|
||||
eyebrow="Locations"
|
||||
title="QR codes"
|
||||
copy="Print and post these where people work. Scanning one opens the report form with the location already filled in."
|
||||
action={
|
||||
<button type="button" className={primaryButton} onClick={() => window.print()}>
|
||||
<Printer size={17} /> Print
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||
<div className={filterBox}>
|
||||
<select className={select} value={site.id} onChange={(event) => set('site', event.target.value)} aria-label="Site">
|
||||
{sites.data?.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className={filterBox}>
|
||||
<select className={select} value={link?.id ?? ''} onChange={(event) => set('via', event.target.value)} aria-label="Who will scan">
|
||||
<option value="">People with accounts (sign in first)</option>
|
||||
{activeLinks
|
||||
.filter((k) => !k.site || k.site.id === site.id)
|
||||
.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
Anyone, through reporting link “{k.name}”
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{activeLinks.length === 0 && (
|
||||
<span className="text-2xs text-muted">
|
||||
To let people report without an account,{' '}
|
||||
<Link to="/settings" className="text-coral underline">
|
||||
create a reporting link
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 print:grid-cols-3 print:gap-3">
|
||||
{targets.map((target) => {
|
||||
const url = reportUrl(origin, { token: link?.token, siteId: site.id, areaId: target.id || undefined })
|
||||
return (
|
||||
<div key={target.id || 'site'} className={`${panel} flex break-inside-avoid flex-col items-center p-5 text-center print:shadow-none`}>
|
||||
<QrCode value={url} label={`QR code for ${target.name}`} />
|
||||
<strong className="mt-3 font-display text-lg leading-tight text-ink">{target.name}</strong>
|
||||
<span className="text-2xs text-muted">{site.name}</span>
|
||||
<p className="mt-3 text-xs font-semibold text-ink">{en.qr.caption}</p>
|
||||
<p className="text-xs text-body" lang="es">
|
||||
{es.qr.caption}
|
||||
</p>
|
||||
<span className="mt-2 max-w-full truncate font-mono text-[8px] text-faint">{url}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
+123
-89
@@ -1,37 +1,44 @@
|
||||
import { ArrowUpRight, ImagePlus, X } from 'lucide-react'
|
||||
import { ArrowUpRight, CloudOff, ImagePlus, QrCode, X } from 'lucide-react'
|
||||
import { useState, type ChangeEvent, type FormEvent } from 'react'
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { Link, NavLink, useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
|
||||
import { incidentTypes, severities, type IncidentType, type Severity } from '../../shared/enums.ts'
|
||||
import { Field, FormSection } from '../components/FormSection'
|
||||
import { Page } from '../components/Page'
|
||||
import { PageIntro } from '../components/PageIntro'
|
||||
import { ErrorMessage, Loading } from '../components/QueryState'
|
||||
import { field, iconButton, panel, primaryButton, secondaryButton } from '../components/styles'
|
||||
import { errorMessage } from '../lib/api'
|
||||
import { useI18n } from '../i18n'
|
||||
import { formatBytes, toDateTimeLocal } from '../lib/dates'
|
||||
import { incidentTypeLabel, severityLabel } from '../lib/labels'
|
||||
import { uploadAttachment, useCreateIncident, useSites } from '../lib/queries'
|
||||
import { outbox } from '../lib/outbox'
|
||||
import { useSites, type Me } from '../lib/queries'
|
||||
import { uuid } from '../lib/uuid'
|
||||
|
||||
const MAX_PHOTOS = 10
|
||||
|
||||
export function ReportIncident() {
|
||||
const me = useOutletContext<Me>()
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const [params] = useSearchParams()
|
||||
const sites = useSites()
|
||||
const create = useCreateIncident()
|
||||
const [form, setForm] = useState({
|
||||
const fromQr = !!params.get('site')
|
||||
const blank = () => ({
|
||||
title: '',
|
||||
type: 'near_miss' as IncidentType,
|
||||
severity: 'medium' as Severity,
|
||||
occurredAt: toDateTimeLocal(new Date()),
|
||||
siteId: '',
|
||||
areaId: '',
|
||||
siteId: params.get('site') ?? '',
|
||||
areaId: params.get('area') ?? '',
|
||||
locationDetail: '',
|
||||
description: '',
|
||||
immediateAction: '',
|
||||
anonymous: false,
|
||||
})
|
||||
const [form, setForm] = useState(blank)
|
||||
const [photos, setPhotos] = useState<File[]>([])
|
||||
const [uploadError, setUploadError] = useState<string>()
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<Error>()
|
||||
const [queued, setQueued] = useState(false)
|
||||
|
||||
const siteId = form.siteId || sites.data?.[0]?.id || ''
|
||||
const areas = sites.data?.find((site) => site.id === siteId)?.areas ?? []
|
||||
@@ -45,42 +52,60 @@ export function ReportIncident() {
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setUploadError(undefined)
|
||||
const incident = await create
|
||||
.mutateAsync({
|
||||
...form,
|
||||
siteId,
|
||||
areaId: form.areaId || null,
|
||||
occurredAt: new Date(form.occurredAt).toISOString(),
|
||||
setSubmitting(true)
|
||||
setError(undefined)
|
||||
const clientId = uuid()
|
||||
// Saved on the device first, so nothing is lost if the connection drops mid-way
|
||||
const result = await outbox.submit({
|
||||
kind: 'report',
|
||||
clientId,
|
||||
userId: me.user.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
attempts: 0,
|
||||
files: photos.map((photo) => ({ name: photo.name, type: photo.type, blob: photo })),
|
||||
report: { ...form, siteId, areaId: form.areaId || null, occurredAt: new Date(form.occurredAt).toISOString(), clientId },
|
||||
})
|
||||
.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 } })
|
||||
setSubmitting(false)
|
||||
if (result.status === 'queued') setQueued(true)
|
||||
else if (result.status === 'rejected') setError(result.error)
|
||||
else if (result.failedFiles.length) setError(new Error(t.report.photosFailed(result.reference, result.failedFiles.join('; '))))
|
||||
else navigate(`/incidents/${result.number}`, { state: { justReported: true } })
|
||||
}
|
||||
|
||||
if (sites.isPending) return <Loading />
|
||||
if (sites.data?.length === 0) {
|
||||
if (queued) {
|
||||
return (
|
||||
<Page narrow>
|
||||
<PageIntro eyebrow="New report" title="Report an incident" />
|
||||
<div className={`${panel} p-8 text-center`}>
|
||||
<CloudOff size={32} className="mx-auto text-amber-strong" />
|
||||
<h1 className="mt-4 font-display text-2xl font-semibold text-ink">{t.report.queuedTitle}</h1>
|
||||
<p className="mx-auto mt-2 max-w-[440px] text-sm leading-[1.6] text-muted">{t.report.queuedCopy}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={`${primaryButton} mt-6`}
|
||||
onClick={() => {
|
||||
setForm(blank())
|
||||
setPhotos([])
|
||||
setQueued(false)
|
||||
}}
|
||||
>
|
||||
{t.report.reportAnother}
|
||||
</button>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
if (sites.isPending) return <Loading label={t.common.loading} />
|
||||
if (!sites.data?.length) {
|
||||
return (
|
||||
<Page narrow>
|
||||
<PageIntro eyebrow={t.report.eyebrow} title={t.report.title} />
|
||||
<ErrorMessage error={sites.error} className="mb-4" />
|
||||
<div className={`${panel} p-8 text-sm text-muted`}>
|
||||
No sites have been set up yet. A safety manager needs to{' '}
|
||||
{t.report.noSites}{' '}
|
||||
<Link to="/locations" className="text-coral underline">
|
||||
add a site
|
||||
</Link>{' '}
|
||||
before incidents can be reported.
|
||||
{t.report.addSite}
|
||||
</Link>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
@@ -88,54 +113,59 @@ export function ReportIncident() {
|
||||
|
||||
return (
|
||||
<Page narrow>
|
||||
<PageIntro eyebrow="New report" title="Report an incident" copy="Capture the facts while they are fresh. Investigation details can be added later." />
|
||||
<PageIntro eyebrow={t.report.eyebrow} title={t.report.title} copy={t.report.intro} />
|
||||
<form className={`${panel} overflow-hidden`} onSubmit={submit}>
|
||||
<FormSection number="01" title="What happened?" copy="Start with a clear, factual description.">
|
||||
<Field label="Incident title">
|
||||
<input className={field} required maxLength={200} value={form.title} onChange={update('title')} placeholder="e.g. Slip near loading dock" />
|
||||
<FormSection number="01" title={t.report.whatHappened} copy={t.report.whatHappenedCopy}>
|
||||
<Field label={t.report.incidentTitle}>
|
||||
<input className={field} required maxLength={200} value={form.title} onChange={update('title')} placeholder={t.report.titlePlaceholder} />
|
||||
</Field>
|
||||
<div className="grid sm:grid-cols-3 sm:gap-[18px]">
|
||||
<Field label="Incident type">
|
||||
<Field label={t.report.type}>
|
||||
<select className={field} value={form.type} onChange={update('type')}>
|
||||
{incidentTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{incidentTypeLabel[type]}
|
||||
{t.types[type]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Severity">
|
||||
<Field label={t.report.severity}>
|
||||
<select className={field} value={form.severity} onChange={update('severity')}>
|
||||
{severities.map((severity) => (
|
||||
<option key={severity} value={severity}>
|
||||
{severityLabel[severity]}
|
||||
{t.severities[severity]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="When">
|
||||
<Field label={t.report.when}>
|
||||
<input className={field} type="datetime-local" required max={toDateTimeLocal(new Date())} value={form.occurredAt} onChange={update('occurredAt')} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="What happened?" optional>
|
||||
<textarea className={`${field} resize-y`} value={form.description} onChange={update('description')} placeholder="Describe what you observed, without assumptions..." rows={4} />
|
||||
<Field label={t.report.description} optional optionalLabel={t.common.optional}>
|
||||
<textarea className={`${field} resize-y`} value={form.description} onChange={update('description')} placeholder={t.report.descriptionPlaceholder} rows={4} />
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection number="02" title="Where did it happen?" copy="Pinpoint the location so patterns become visible.">
|
||||
<FormSection number="02" title={t.report.where} copy={t.report.whereCopy}>
|
||||
{fromQr && (
|
||||
<p className="mb-4 flex items-center gap-2 rounded-md bg-sky-soft px-3 py-2 text-2xs text-sky-strong">
|
||||
<QrCode size={14} /> {t.report.fromQr}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid sm:grid-cols-2 sm:gap-[18px]">
|
||||
<Field label="Site">
|
||||
<Field label={t.report.site}>
|
||||
<select className={field} value={siteId} onChange={update('siteId')}>
|
||||
{sites.data?.map((site) => (
|
||||
{sites.data.map((site) => (
|
||||
<option key={site.id} value={site.id}>
|
||||
{site.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Building or area" optional>
|
||||
<Field label={t.report.area} optional optionalLabel={t.common.optional}>
|
||||
<select className={field} value={form.areaId} onChange={update('areaId')}>
|
||||
<option value="">Not specified</option>
|
||||
<option value="">{t.report.notSpecified}</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
{area.name}
|
||||
@@ -144,54 +174,32 @@ export function ReportIncident() {
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Exact location" optional>
|
||||
<input className={field} value={form.locationDetail} onChange={update('locationDetail')} placeholder="e.g. Ground floor lobby, Line 3" />
|
||||
<Field label={t.report.exactLocation} optional optionalLabel={t.common.optional}>
|
||||
<input className={field} value={form.locationDetail} onChange={update('locationDetail')} placeholder={t.report.exactLocationPlaceholder} />
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection number="03" title="Response and photos" copy="What was done straight away, and any pictures of the scene.">
|
||||
<Field label="Immediate response" optional>
|
||||
<textarea className={`${field} resize-y`} value={form.immediateAction} onChange={update('immediateAction')} placeholder="e.g. Area isolated, supervisor notified..." rows={3} />
|
||||
<FormSection number="03" title={t.report.responseAndPhotos} copy={t.report.responseAndPhotosCopy}>
|
||||
<Field label={t.report.immediateResponse} optional optionalLabel={t.common.optional}>
|
||||
<textarea className={`${field} resize-y`} value={form.immediateAction} onChange={update('immediateAction')} placeholder={t.report.immediateResponsePlaceholder} rows={3} />
|
||||
</Field>
|
||||
<div className="mb-[17px]">
|
||||
<span className="block text-2xs font-semibold text-[#586762]">Photos</span>
|
||||
<div className="mt-[7px] flex flex-wrap gap-2">
|
||||
{photos.map((photo, index) => (
|
||||
<div key={`${photo.name}-${index}`} className="flex items-center gap-1.5 rounded-[5px] border border-line bg-field py-1 pr-1 pl-2.5 text-2xs">
|
||||
<span className="max-w-[160px] truncate">{photo.name}</span>
|
||||
<span className="text-faint">{formatBytes(photo.size)}</span>
|
||||
<button type="button" className={iconButton} aria-label={`Remove ${photo.name}`} onClick={() => setPhotos(photos.filter((_, i) => i !== index))}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{photos.length < MAX_PHOTOS && (
|
||||
<label className={`${secondaryButton} cursor-pointer`}>
|
||||
<ImagePlus size={16} /> Add photos
|
||||
<input type="file" accept="image/*,application/pdf" multiple capture="environment" className="sr-only" onChange={addPhotos} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PhotoPicker photos={photos} onAdd={addPhotos} onRemove={(index) => setPhotos(photos.filter((_, i) => i !== index))} />
|
||||
<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.
|
||||
<strong className="font-semibold">{t.report.anonymous}</strong> {t.report.anonymousHelp}
|
||||
</span>
|
||||
</label>
|
||||
</FormSection>
|
||||
|
||||
<div className="flex flex-col items-stretch gap-[15px] px-5 py-[17px] text-2xs text-muted sm:flex-row sm:items-center sm:justify-between sm:px-9 sm:py-5">
|
||||
<div className="space-y-2">
|
||||
<ErrorMessage error={create.error} />
|
||||
{uploadError && <ErrorMessage error={new Error(uploadError)} />}
|
||||
</div>
|
||||
<div className="flex justify-end gap-[9px]">
|
||||
<ErrorMessage error={error} />
|
||||
<div className="ml-auto flex justify-end gap-[9px]">
|
||||
<NavLink to="/" className={secondaryButton}>
|
||||
Cancel
|
||||
{t.common.cancel}
|
||||
</NavLink>
|
||||
<button className={primaryButton} type="submit" disabled={create.isPending}>
|
||||
{create.isPending ? 'Submitting…' : 'Submit report'} <ArrowUpRight size={16} />
|
||||
<button className={primaryButton} type="submit" disabled={submitting}>
|
||||
{submitting ? t.report.submitting : t.report.submit} <ArrowUpRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,3 +207,29 @@ export function ReportIncident() {
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export function PhotoPicker({ photos, onAdd, onRemove, max = MAX_PHOTOS }: { photos: File[]; onAdd: (event: ChangeEvent<HTMLInputElement>) => void; onRemove: (index: number) => void; max?: number }) {
|
||||
const { t } = useI18n()
|
||||
return (
|
||||
<div className="mb-[17px]">
|
||||
<span className="block text-2xs font-semibold text-[#586762]">{t.report.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={t.report.removePhoto(photo.name)} onClick={() => onRemove(index)}>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{photos.length < max && (
|
||||
<label className={`${secondaryButton} cursor-pointer`}>
|
||||
<ImagePlus size={16} /> {t.report.addPhotos}
|
||||
<input type="file" accept="image/*,application/pdf" multiple capture="environment" className="sr-only" onChange={onAdd} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+130
-4
@@ -1,7 +1,7 @@
|
||||
import { UserPlus } from 'lucide-react'
|
||||
import { Copy, ExternalLink, Link2, QrCode, 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 { Link, useOutletContext } from 'react-router-dom'
|
||||
import type { Kiosk, 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'
|
||||
@@ -10,7 +10,7 @@ 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'
|
||||
import { reportUrl, useChangePassword, useKioskMutations, useKiosks, useSites, useUserMutations, useUsers, type Me } from '../lib/queries'
|
||||
|
||||
const roleHelp: Record<Role, string> = {
|
||||
reporter: 'Reports incidents and works on actions assigned to them',
|
||||
@@ -26,6 +26,7 @@ export function Settings() {
|
||||
<PageIntro eyebrow={me.organization.name} title="Settings" />
|
||||
<div className="space-y-5">
|
||||
<Profile me={me} />
|
||||
{hasRole(me.user.role, 'safety_manager') && <ReportingLinks />}
|
||||
{hasRole(me.user.role, 'safety_manager') && <Users me={me} />}
|
||||
</div>
|
||||
</Page>
|
||||
@@ -199,3 +200,128 @@ function InviteForm({ onDone }: { onDone: () => void }) {
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ReportingLinks() {
|
||||
const kiosks = useKiosks()
|
||||
const sites = useSites()
|
||||
const { create } = useKioskMutations()
|
||||
const [form, setForm] = useState({ name: '', siteId: '' })
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
create.mutate(
|
||||
{ name: form.name, siteId: form.siteId || null },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setForm({ name: '', siteId: '' })
|
||||
setAdding(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={panel}>
|
||||
<div className="flex items-center justify-between border-b border-line px-6 py-4">
|
||||
<div>
|
||||
<h2 className={sectionHeading}>Reporting links</h2>
|
||||
<p className="mt-0.5 max-w-[520px] text-2xs leading-[1.5] text-muted">
|
||||
Let people report without an account: open a link on a shared tablet (kiosk), or put it in QR codes for phones. A link can only file reports, never read them. Revoke it if it leaks.
|
||||
</p>
|
||||
</div>
|
||||
{!adding && (
|
||||
<button type="button" className={smallButton} onClick={() => setAdding(true)}>
|
||||
<Link2 size={13} /> New link
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{adding && (
|
||||
<form onSubmit={submit} className="grid gap-x-4 border-b border-line bg-canvas px-6 py-5 sm:grid-cols-2">
|
||||
<Field label="Name">
|
||||
<input className={field} required value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="e.g. Canteen tablet, Press shop QR codes" />
|
||||
</Field>
|
||||
<Field label="Site" optional>
|
||||
<select className={field} value={form.siteId} onChange={(event) => setForm({ ...form, siteId: event.target.value })}>
|
||||
<option value="">Any site</option>
|
||||
{sites.data?.map((site) => (
|
||||
<option key={site.id} value={site.id}>
|
||||
{site.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<ErrorMessage error={create.error} className="mb-3 sm:col-span-2" />
|
||||
<div className="flex justify-end gap-2 sm:col-span-2">
|
||||
<button type="button" className={secondaryButton} onClick={() => setAdding(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className={primaryButton} disabled={create.isPending}>
|
||||
Create link
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{kiosks.isPending ? (
|
||||
<Loading />
|
||||
) : kiosks.data?.length === 0 ? (
|
||||
<p className="px-6 py-5 text-xs text-faint">No reporting links yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-soft">
|
||||
{kiosks.data?.map((kiosk) => (
|
||||
<KioskRow key={kiosk.id} kiosk={kiosk} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function KioskRow({ kiosk }: { kiosk: Kiosk }) {
|
||||
const { update } = useKioskMutations()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const url = reportUrl(window.location.origin, { token: kiosk.token, siteId: kiosk.site?.id })
|
||||
const revoked = !!kiosk.revokedAt
|
||||
|
||||
const copy = async () => {
|
||||
await navigator.clipboard?.writeText(url)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={`flex flex-wrap items-center gap-x-4 gap-y-2 px-6 py-3.5 ${revoked ? 'opacity-60' : ''}`}>
|
||||
<div className="min-w-[200px] flex-1">
|
||||
<p className="text-xs font-semibold text-ink">
|
||||
{kiosk.name}
|
||||
{revoked && <span className="ml-2 font-mono text-3xs font-normal text-faint uppercase">Revoked</span>}
|
||||
</p>
|
||||
<p className="mt-0.5 text-3xs text-muted">
|
||||
{kiosk.site?.name ?? 'Any site'} · {kiosk.lastUsedAt ? `Last report ${formatDate(kiosk.lastUsedAt)}` : 'Not used yet'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{!revoked && (
|
||||
<>
|
||||
<button type="button" className={`${textButton} text-muted`} onClick={copy}>
|
||||
<Copy size={12} /> {copied ? 'Copied' : 'Copy link'}
|
||||
</button>
|
||||
<a className={`${textButton} text-muted`} href={url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink size={12} /> Open kiosk
|
||||
</a>
|
||||
<Link className={`${textButton} text-muted`} to={`/locations/qr?via=${kiosk.id}${kiosk.site ? `&site=${kiosk.site.id}` : ''}`}>
|
||||
<QrCode size={12} /> QR codes
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={textButton}
|
||||
onClick={() => (revoked || confirm(`Revoke “${kiosk.name}”? Tablets and QR codes using it will stop working.`)) && update.mutate({ id: kiosk.id, revoked: !revoked })}
|
||||
>
|
||||
{revoked ? 'Restore' : 'Revoke'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
+39
-1
@@ -3,12 +3,50 @@ import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { defineConfig } from 'vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
const { version } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { version: string }
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
// Installable app + offline shell. Service workers need HTTPS (or localhost).
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.svg', 'icons/apple-touch-icon.png'],
|
||||
manifest: {
|
||||
name: 'Open SafetyLog EHS',
|
||||
short_name: 'SafetyLog',
|
||||
description: 'Report hazards, near misses and incidents, and follow them through to closure.',
|
||||
theme_color: '#163c37',
|
||||
background_color: '#f7f8f5',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
icons: [
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
{ src: '/icons/maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
shortcuts: [{ name: 'Report an incident', url: '/report', icons: [{ src: '/icons/icon-192.png', sizes: '192x192' }] }],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
|
||||
navigateFallback: '/index.html',
|
||||
navigateFallbackDenylist: [/^\/api\//],
|
||||
runtimeCaching: [
|
||||
{
|
||||
// Just enough to open the report form offline: who you are, and the list of sites/areas.
|
||||
urlPattern: ({ url }) => ['/api/auth/me', '/api/sites', '/api/kiosk/config'].includes(url.pathname),
|
||||
handler: 'NetworkFirst',
|
||||
options: { cacheName: 'api-essentials', networkTimeoutSeconds: 5, expiration: { maxEntries: 20, maxAgeSeconds: 7 * 24 * 60 * 60 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
define: { __APP_VERSION__: JSON.stringify(version) },
|
||||
server: {
|
||||
proxy: { '/api': 'http://localhost:3000' },
|
||||
|
||||
Reference in new issue
Block a user