A QR-code-triggered incident reporting progressive web app for workplace and site environments. Workers scan a QR code posted at a physical location, fill in (or speak) a short description of the incident, select severity, and submit — all in under 60 seconds, with no account, no app install, and no friction.
Behind the simple reporter screen: AI analysis classifies the submission, flags urgent events for immediate admin notification, and feeds a management dashboard showing trends by location over time.
Worker or visitor on-site. Stressed, in a hurry, standing up, using a phone. Needs a 3-tap flow. No login ever.
Site safety manager. Receives urgent alerts. Manages locations, QR codes, incident status, and internal notes.
Senior leadership. Views aggregate trends — incidents per location/month, severity breakdowns. Not involved in individual cases.
The reporter is standing up, on a phone, possibly stressed or in poor light. Every design decision is filtered through this reality. Speed and clarity over completeness.
Worker scans QR code posted at the location. Mobile browser opens directly — no app install, no splash screen.
Location name shown prominently ("Level 3 — Warehouse B") so reporter confirms they're on the right screen. One screen only — notes field + voice button + three severity buttons.
Large text area + microphone button labeled "Speak instead". Voice activates browser-native speech-to-text, words appear in real time. Reporter taps "Done speaking" when finished.
Three large pill buttons: Minor / Serious / Emergency. No default — reporter must make a deliberate choice. Minimum 48px height for fat fingers.
Full-width submit button. Target: <2 seconds. Confirmation screen shows a checkmark, reference number, and severity-appropriate message. Form does not disappear on network error — content is preserved.
Push notification (mobile) or email arrives with location, severity badge, first 100 characters of notes, and a deep link to the full report.
Opens to incident detail: location, time, severity badge, full notes, AI analysis panel (collapsed, expandable), status field. Minimal scrolling — key info above the fold.
Status change (Open → In Progress → Closed) with one tap + confirm. Internal notes field — not visible to reporter. Admin is probably standing on-site on their phone.
Location list shows: name, incidents this month, last incident date, QR status. Create/edit location → "Download QR code" generates a print-ready PNG. Admin prints and laminates it.
Injury risk: Likely
Persons affected: One
Summary: Worker injured knee after slipping on unmarked wet floor.
Four tiles: total incidents this period, vs prior period (delta + colour), emergency incidents (red if >0), locations with zero incidents (green — positive signal).
Bar chart, one bar per week, stacked by severity. Date range selector: this month / last 3 months / this year / custom.
Horizontal bar chart ranked by volume. Colour indicates most serious incident at that location. Tap a row to filter the full dashboard to that location.
Donut chart of AI-assigned incident categories. Tap a segment to see count and percentage.
Three-line chart: Minor, Serious, Emergency counts over time. Answers the question: "Are we getting safer?" independently of total volume.
Individual incident notes, reporter identity (if captured), or free text. Aggregate data only. Management views are separate from admin views — different permission level.
| Layer | Technology | Rationale |
|---|---|---|
| Framework | Next.js 16 (App Router) + TypeScript | App Router enables server components for fast dashboard queries; established standard across all projects |
| Database + Auth | Supabase (Postgres + RLS + Auth) | Managed Postgres + RLS, no auth to hand-roll, Edge Functions for async AI pipeline, Realtime for push |
| Deployment | Vercel | Zero-config CI/CD, auto-preview per PR, global CDN — no servers to manage |
| AI analysis | OpenAI GPT-4o via Supabase Edge Function | Structured output mode; <$0.01/analysis; async so reporter never waits |
| Speech-to-text | Web Speech API (browser-native) | Free, real-time, no round-trip, no API key. Upgrade path to OpenAI Whisper if needed. |
| Email notifications | Resend | Clean API, Next.js-native DX, generous free tier |
| SMS notifications | Twilio (Emergency severity only) | Reliable delivery fallback when admin may not have push enabled |
| Charts | Recharts or Tremor | Lightweight, React-native charting without a heavy BI dependency |
The QR code encodes the full HTTPS URL directly. The location ID is the URL.
https://app.domain.com/report/[locationId]
- Generation: Admin dashboard generates print-ready PNG on demand using the
qrcodenpm library. Location name printed below the code. - Validation: On page load,
[locationId]is validated against Supabase via a server component. Invalid IDs render a graceful error page — not a broken form. - Vanity slugs: A short alphanumeric slug can be stored alongside the UUID as an alternate key if cleaner URLs are wanted later. Low effort, no urgency.
- Physical security note: A malicious actor could replace a printed QR with one pointing to a phishing page. Recommend laminated/tamper-proof QR sheets. Software cannot solve this.
- Cost: Free (browser handles it)
- Latency: Real-time, no upload round-trip
- Privacy: Audio processed by browser vendor, not sent to our servers
- Support: Chrome (Android), Safari 14.1+ (iOS), Edge
- Fallback: Unsupported browsers show: "Voice not available — please type your report."
Phase 2 only if needed
- Better accuracy for accented speech and noisy environments
- Requires audio capture, upload, and API key management
- Per-minute cost
- This decision does not need to be made now — the architecture supports both
pwa/ ├── app/ │ ├── report/[locationId]/ # Public reporter form — no auth │ ├── (admin)/ │ │ ├── dashboard/ # Incident list + status management │ │ ├── incidents/[id]/ # Incident detail + AI panel │ │ ├── locations/ # Location management + QR generation │ │ └── reporting/ # Management trend charts │ └── api/ │ ├── incidents/route.ts # POST — accepts submissions │ └── locations/[id]/route.ts ├── lib/ │ ├── supabase.ts # Client (anon key, RLS enforced) │ └── supabase-admin.ts # Server-only (service role) ├── components/ │ ├── reporter/ # SpeechInput, SeverityPicker, SubmitForm │ ├── dashboard/ # IncidentList, IncidentDetail, AIPanel │ └── reporting/ # Charts, KPI tiles └── supabase/ ├── functions/ │ ├── analyse-incident/ # Edge Function — AI + notification trigger └── migrations/ └── 001_initial_schema.sql
The AI analysis does not override the reporter's severity selection. It surfaces risk signals a busy admin might miss when reading quickly. It does not make safety decisions.
| Field | Type | Purpose |
|---|---|---|
| severity_assessment | low / medium / high / critical | AI's independent read of the notes. Flagged in UI if it contradicts reporter's choice — a safety net for under-reporting. |
| injury_risk | none / possible / likely / confirmed | "Likely" or "confirmed" triggers immediate admin notification regardless of reporter severity. |
| incident_category | enum | Primary category for trend reporting. Values: slip-trip-fall, equipment-failure, chemical-exposure, vehicle, violence, fire, near-miss, illness, environmental, other. |
| persons_affected | none / one / multiple / unknown | Extracted from language patterns in the notes. |
| urgency_signal | boolean | True if notes describe active danger (fire still burning, gas still leaking). Triggers notification regardless of reporter's severity selection. |
| summary_sentence | string (120 chars max) | Plain-English summary used in notification previews. Admin understands situation without opening the app. |
| confidence_score | float 0–1 | Below 0.5: admin sees caveat "Low confidence — read the full notes carefully." |
| recommended_response | string, nullable | If category implies a known protocol (chemical spill, gas leak), surface a plain-language prompt. Advisory only. |
Make any determination about organisational liability. Speculate about causes not in the notes. Recommend dismissing or downgrading an incident. Extract or store personal identifying information from notes text.
Reporter submits form
↓
POST /api/incidents
↓
INSERT to incidents table → 200 OK returned to reporter immediately
↓ (async, does not block reporter)
Supabase DB Webhook fires → Edge Function: analyse-incident
↓
Call OpenAI GPT-4o (structured output mode)
↓
UPDATE incidents SET ai_result = {...}
↓
IF escalate = true
→ Email via Resend
→ SMS via Twilio (Emergency only)
→ Realtime push → Admin dashboard toast
Incident is stored without analysis. Admin sees "Analysis pending". A retry job attempts analysis within 5 minutes. Failed jobs logged in a failed_ai_jobs table for monitoring.
GPT-4o at <$0.01 per incident. For 1,000 incidents/month = ~$10/month in AI costs. Negligible for any commercial deployment.
The AI acts as a safety net for under-reporting. Minor submissions that trigger none of the above are logged silently — they appear in the admin incident list but generate no notification.
The "call 000" line is non-optional on Emergency severity. Duty of care consideration.
-- Locations registered in the system CREATE TABLE locations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, address TEXT, slug TEXT UNIQUE, -- optional friendly short code qr_url TEXT, -- generated QR image URL created_at TIMESTAMPTZ DEFAULT now() ); -- Submitted incidents CREATE TABLE incidents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), location_id UUID REFERENCES locations(id) NOT NULL, severity TEXT CHECK (severity IN ('low','medium','high')) NOT NULL, notes TEXT NOT NULL, ai_result JSONB, -- populated async after submission submitted_at TIMESTAMPTZ DEFAULT now(), notified BOOLEAN DEFAULT false, reference TEXT UNIQUE -- 6-char alphanumeric e.g. "WH3-4F2" ); -- Admin users (Supabase Auth handles passwords + sessions) CREATE TABLE admin_profiles ( id UUID PRIMARY KEY REFERENCES auth.users(id), full_name TEXT, email TEXT, role TEXT DEFAULT 'admin', -- future: 'superadmin', 'viewer' notify_sms BOOLEAN DEFAULT false, phone TEXT, created_at TIMESTAMPTZ DEFAULT now() ); -- Notification audit trail CREATE TABLE notification_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), incident_id UUID REFERENCES incidents(id), channel TEXT, -- 'email' | 'sms' | 'push' sent_at TIMESTAMPTZ DEFAULT now(), success BOOLEAN, error_message TEXT ); -- Retry queue for failed AI analysis CREATE TABLE failed_ai_jobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), incident_id UUID REFERENCES incidents(id), attempts INT DEFAULT 0, last_attempt TIMESTAMPTZ, error TEXT );
| Table | Public (unauthenticated) | Admin (authenticated) |
|---|---|---|
| locations | SELECT (by ID only) | Full access |
| incidents | INSERT only | SELECT + UPDATE |
| admin_profiles | No access | Own row only |
| notification_log | No access | SELECT |
RLS enforced by Supabase — no custom auth middleware needed on the reporter path. Reporter submits without logging in.
These three questions have direct architectural impact. Schema design, data model, and notification logic all depend on the answers. The remaining questions are important but can be decided in parallel with early build work.
Multi-tenancy is fundamentally different at the database level. Every table needs a
tenant_id column. RLS policies change. The URL scheme changes. Admin invite flows change. This cannot be retrofitted after build begins.Option A — Fully anonymous: No name field. Reference number is the only link. Maximises reporting volume.
Option B — Optional identity: Name/phone field shown but not required. Recommended balance.
Option C — Named (required): Name field required. Adds friction — expect lower incident reporting rates.
Recommendation: Option A or B. Under-reporting of minor incidents and near-misses is the most common failure mode in safety reporting. Any friction that reduces reporting frequency costs more than the identity data is worth.
Some organisations use: Low / Medium / High — or — Near Miss / Incident / Emergency — or — Green / Amber / Red.
Do you need a 4th level (e.g. "Near Miss" as separate from "Minor Incident")?
Note: adding a fourth level is not just a label change — it changes notification thresholds, the data model, and all dashboard charts.
Current proposal: Serious + AI injury risk triggers a notification. Serious alone is logged without a push — reduces alert fatigue.
If your organisation's policy is that all serious incidents require immediate human acknowledgement, the threshold should be raised.
Confirm: What retention period applies? Does it vary by severity? Do you need a deletion workflow (relevant if personal data is captured via Q2 Option B/C)?
This must be designed into the database from the start. Retrofitting a retention policy is expensive.
— Is occasional misclassification acceptable (e.g. a vehicle incident categorised as equipment failure)?
— Are there regulatory or industry compliance constraints on using AI to analyse safety records?
— Does the AI analysis need to be auditable (could a regulator ask "how did the system classify this event")?
If classification must be auditable, a model logging layer is required from day one.
— How many locations will be registered at launch?
— How many admin users are needed?
— Is there a phased rollout (one building first, then site-wide)?
— Who is responsible for printing and posting QR codes at each location?
Phase 1 cannot be skipped or compressed — the decisions made here directly control the database schema. Starting code before Phase 1 is complete guarantees rework. Each phase ends with a gate that must be passed before the next phase begins.
| Phase | Duration | Starts when | Gate to pass |
|---|---|---|---|
| Phase 1 — Specification & Design | 2 weeks | Q1, Q2, Q5 answered by client | Client sign-off on UX + spec; Pete approves architecture |
| Phase 2 — Core Build | 6–8 weeks | Phase 1 gate passed | E2E tests green; AI validation ≥ 90%; notification chain confirmed |
| Phase 3 — Management + Launch | 3–4 weeks | Phase 2 gate passed | Smoke test passes; QR codes posted; admin users onboarded |
| Total | 11–14 weeks | From Q1/Q2/Q5 decision | — |