feat: reporter clickable rows, CAPA owner action buttons, triage segmented severity

This commit is contained in:
2026-07-12 16:42:49 +08:00
parent 180b0dc0a3
commit f6064b9580
11 changed files with 694 additions and 16 deletions
+8 -4
View File
@@ -4,6 +4,7 @@ import Link from 'next/link'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { StatCard } from '@/components/dashboard/stat-card' import { StatCard } from '@/components/dashboard/stat-card'
import { CapaOwnerActions } from '@/components/capa/capa-owner-actions'
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
open: 'Open', open: 'Open',
@@ -78,21 +79,24 @@ export default async function CapaOwnerPage() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm text-gray-800">{capa.description}</p> <p className="text-sm text-gray-800">{capa.description}</p>
{incRef && ( {incRef && (
<p className="text-xs text-gray-500 mt-0.5">{incRef}</p> <Link href={`/hse/incidents/${capa.incident_id}`} className="text-xs text-blue-600 hover:underline mt-0.5 inline-block">
{incRef}
</Link>
)} )}
</div> </div>
<div className="shrink-0 text-right"> <div className="shrink-0 text-right space-y-1">
<span className={`text-xs rounded-full px-2 py-0.5 ${STATUS_COLORS[capa.status] ?? 'bg-gray-100 text-gray-600'}`}> <span className={`text-xs rounded-full px-2 py-0.5 ${STATUS_COLORS[capa.status] ?? 'bg-gray-100 text-gray-600'}`}>
{STATUS_LABELS[capa.status] ?? capa.status} {STATUS_LABELS[capa.status] ?? capa.status}
</span> </span>
<p className={`text-xs mt-1 ${isOverdue ? 'text-red-600 font-semibold' : 'text-gray-400'}`}> <p className={`text-xs ${isOverdue ? 'text-red-600 font-semibold' : 'text-gray-400'}`}>
{capa.due_date ? `Due ${new Date(capa.due_date as string).toLocaleDateString('en-MY')}` : 'No due date'} {capa.due_date ? `Due ${new Date(capa.due_date as string).toLocaleDateString('en-MY')}` : 'No due date'}
</p> </p>
{capa.priority && ( {capa.priority && (
<p className={`text-xs mt-0.5 ${PRIORITY_COLORS[capa.priority as string] ?? ''}`}> <p className={`text-xs ${PRIORITY_COLORS[capa.priority as string] ?? ''}`}>
{(capa.priority as string).toUpperCase()} priority {(capa.priority as string).toUpperCase()} priority
</p> </p>
)} )}
<CapaOwnerActions capaId={capa.id} currentStatus={capa.status} />
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,107 @@
export const dynamic = 'force-dynamic'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { redirect, notFound } from 'next/navigation'
const STATUS_LABELS: Record<string, string> = {
reported: 'Reported', triaged: 'Triaged', investigating: 'Investigating',
capa_pending: 'CAPA Pending', verification: 'Verification', closed: 'Closed',
}
const STATUS_COLORS: Record<string, string> = {
reported: 'bg-gray-100 text-gray-600', triaged: 'bg-yellow-100 text-yellow-800',
investigating: 'bg-blue-100 text-blue-700', capa_pending: 'bg-orange-100 text-orange-700',
verification: 'bg-purple-100 text-purple-700', closed: 'bg-green-100 text-green-700',
}
export default async function ReporterIncidentDetail({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: inc } = await supabase
.from('incidents')
.select(`
id, reference_no, incident_type, status, severity, reported_at, closed_at,
description, injury_involved, medical_status, lost_days,
sites (name), zones (name),
capa_actions (id, description, status, due_date)
`)
.eq('id', id)
.eq('reported_by', user.id)
.single()
if (!inc) notFound()
const siteName = (inc.sites as unknown as { name: string } | null)?.name
const zoneName = (inc.zones as unknown as { name: string } | null)?.name
const capas = (inc.capa_actions as unknown as Array<{ id: string; description: string; status: string; due_date: string | null }>) ?? []
return (
<main className="max-w-2xl mx-auto px-4 py-6 space-y-4">
<div className="flex items-center gap-2 mb-2">
<Link href="/reporter" className="text-sm text-blue-600 hover:underline"> My Reports</Link>
</div>
<div className="bg-white rounded-xl shadow-sm p-5">
<div className="flex items-start justify-between gap-3 mb-4">
<div>
<h1 className="text-lg font-bold text-gray-900">{inc.reference_no ?? inc.id.slice(0, 8)}</h1>
<p className="text-sm text-gray-500 capitalize">{inc.incident_type?.replace(/_/g, ' ')}</p>
</div>
<span className={`text-xs rounded-full px-2 py-1 shrink-0 ${STATUS_COLORS[inc.status] ?? 'bg-gray-100 text-gray-600'}`}>
{STATUS_LABELS[inc.status] ?? inc.status}
</span>
</div>
<div className="grid grid-cols-2 gap-3 text-sm mb-4">
{siteName && <div><p className="text-xs text-gray-500">Site</p><p className="font-medium">{siteName}</p></div>}
{zoneName && <div><p className="text-xs text-gray-500">Zone</p><p className="font-medium">{zoneName}</p></div>}
{inc.severity && <div><p className="text-xs text-gray-500">Severity</p><p className="font-medium">{inc.severity} / 5</p></div>}
{inc.reported_at && (
<div>
<p className="text-xs text-gray-500">Reported</p>
<p className="font-medium">{new Date(inc.reported_at as string).toLocaleDateString('en-MY')}</p>
</div>
)}
{inc.closed_at && (
<div>
<p className="text-xs text-gray-500">Closed</p>
<p className="font-medium">{new Date(inc.closed_at as string).toLocaleDateString('en-MY')}</p>
</div>
)}
</div>
{inc.description && (
<div className="border-t border-gray-100 pt-4">
<p className="text-xs text-gray-500 mb-1">Description</p>
<p className="text-sm text-gray-800 whitespace-pre-line">{inc.description}</p>
</div>
)}
</div>
{capas.length > 0 && (
<div className="bg-white rounded-xl shadow-sm p-5">
<h2 className="text-sm font-semibold text-gray-900 mb-3">Corrective Actions ({capas.length})</h2>
<div className="space-y-2">
{capas.map(c => (
<div key={c.id} className="flex items-start justify-between gap-3 text-sm">
<p className="text-gray-700 flex-1">{c.description}</p>
<span className={`text-xs rounded-full px-2 py-0.5 shrink-0 ${
c.status === 'verified' ? 'bg-green-100 text-green-700' :
c.status === 'pending_verification' ? 'bg-purple-100 text-purple-700' :
'bg-gray-100 text-gray-600'
}`}>{c.status.replace(/_/g, ' ')}</span>
</div>
))}
</div>
</div>
)}
</main>
)
}
+2 -2
View File
@@ -84,7 +84,7 @@ export default async function ReporterPage() {
{rows.map(inc => { {rows.map(inc => {
const siteName = (inc.sites as unknown as { name: string } | null)?.name const siteName = (inc.sites as unknown as { name: string } | null)?.name
return ( return (
<div key={inc.id} className="p-4"> <Link key={inc.id} href={`/reporter/incidents/${inc.id}`} className="block p-4 cursor-pointer hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900"> <p className="text-sm font-semibold text-gray-900">
@@ -107,7 +107,7 @@ export default async function ReporterPage() {
{STATUS_LABELS[inc.status] ?? inc.status} {STATUS_LABELS[inc.status] ?? inc.status}
</span> </span>
</div> </div>
</div> </Link>
) )
})} })}
</div> </div>
+54
View File
@@ -0,0 +1,54 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
interface Props {
capaId: string
currentStatus: string
}
export function CapaOwnerActions({ capaId, currentStatus }: Props) {
const router = useRouter()
const [loading, setLoading] = useState(false)
async function updateStatus(status: string) {
setLoading(true)
try {
await fetch(`/ims/api/capa/${capaId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
})
router.refresh()
} finally {
setLoading(false)
}
}
if (currentStatus === 'open' || currentStatus === 'reopened') {
return (
<button
onClick={() => updateStatus('in_progress')}
disabled={loading}
className="text-sm px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
>
{loading ? '…' : 'Mark In Progress'}
</button>
)
}
if (currentStatus === 'in_progress') {
return (
<button
onClick={() => updateStatus('pending_verification')}
disabled={loading}
className="text-sm px-3 py-1.5 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '…' : 'Submit for Verification'}
</button>
)
}
return null
}
+42
View File
@@ -0,0 +1,42 @@
'use client'
import { useRouter, usePathname } from 'next/navigation'
import { Suspense } from 'react'
const TABS = [
{ key: 'overview', label: 'Overview' },
{ key: 'trends', label: 'Trends' },
{ key: 'zones', label: 'Zones & Types' },
{ key: 'ai', label: 'AI Insights' },
]
function TabsInner({ activeTab }: { activeTab: string }) {
const router = useRouter()
const pathname = usePathname()
return (
<div className="flex border-b border-gray-200 mb-6 overflow-x-auto -mx-4 px-4">
{TABS.map(tab => (
<button
key={tab.key}
onClick={() => router.replace(`${pathname}?tab=${tab.key}`)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
activeTab === tab.key
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.label}
</button>
))}
</div>
)
}
export function DashboardTabs({ activeTab }: { activeTab: string }) {
return (
<Suspense fallback={<div className="h-11 mb-6 border-b border-gray-200" />}>
<TabsInner activeTab={activeTab} />
</Suspense>
)
}
+126
View File
@@ -0,0 +1,126 @@
'use client'
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
import { useCallback, useState, useTransition, Suspense } from 'react'
const STATUS_OPTIONS = [
{ value: '', label: 'All Statuses' },
{ value: 'reported', label: 'Reported' },
{ value: 'triaged', label: 'Triaged' },
{ value: 'investigating', label: 'Investigating' },
{ value: 'capa_pending', label: 'CAPA Pending' },
{ value: 'verification', label: 'Pending Verification' },
{ value: 'closed', label: 'Closed' },
]
const TYPE_OPTIONS = [
{ value: '', label: 'All Types' },
{ value: 'injury', label: 'Injury' },
{ value: 'near_miss', label: 'Near Miss' },
{ value: 'dangerous_occurrence', label: 'Dangerous Occurrence' },
{ value: 'occupational_disease', label: 'Occupational Disease' },
{ value: 'environmental', label: 'Environmental' },
{ value: 'mhe_asset', label: 'MHE / Asset' },
{ value: 'security', label: 'Security' },
{ value: 'fire', label: 'Fire' },
]
export interface SiteOption { id: string; name: string }
interface FiltersProps {
sites: SiteOption[]
}
function FiltersInner({ sites }: FiltersProps) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const [, startTransition] = useTransition()
const [search, setSearch] = useState(searchParams.get('q') ?? '')
const updateParam = useCallback((key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString())
if (value) {
params.set(key, value)
} else {
params.delete(key)
}
params.delete('page')
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`)
})
}, [router, pathname, searchParams])
const handleSearch = useCallback((value: string) => {
setSearch(value)
const timer = setTimeout(() => updateParam('q', value), 300)
return () => clearTimeout(timer)
}, [updateParam])
const hasFilters = searchParams.get('q') || searchParams.get('status') ||
searchParams.get('type') || searchParams.get('site_id')
return (
<div className="flex flex-wrap gap-2 mb-4">
<div className="relative flex-1 min-w-48">
<svg
className="absolute left-2.5 top-2 h-4 w-4 text-gray-400 pointer-events-none"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="search"
placeholder="Search reference or description..."
value={search}
onChange={e => handleSearch(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<select
value={searchParams.get('status') ?? ''}
onChange={e => updateParam('status', e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
>
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<select
value={searchParams.get('type') ?? ''}
onChange={e => updateParam('type', e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
>
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
{sites.length > 0 && (
<select
value={searchParams.get('site_id') ?? ''}
onChange={e => updateParam('site_id', e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
>
<option value="">All Sites</option>
{sites.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
)}
{hasFilters && (
<button
onClick={() => {
setSearch('')
router.replace(pathname)
}}
className="text-sm text-gray-500 hover:text-gray-700 px-2 py-1.5 underline"
>
Clear filters
</button>
)}
</div>
)
}
export function IncidentFilters({ sites }: FiltersProps) {
return (
<Suspense fallback={<div className="h-10 mb-4" />}>
<FiltersInner sites={sites} />
</Suspense>
)
}
+17 -10
View File
@@ -94,16 +94,23 @@ export function TriageForm({ incidentId, currentSeverity }: Props) {
return ( return (
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-2">Severity</label>
Severity {SEVERITY_LABELS[severity]} <div className="flex rounded-lg border border-gray-300 overflow-hidden divide-x divide-gray-300">
</label> {([1, 2, 3, 4, 5] as const).map(v => (
<input <button
type="range" min={1} max={5} value={severity} key={v}
onChange={e => setSeverity(Number(e.target.value))} type="button"
className="w-full accent-blue-600" onClick={() => setSeverity(v)}
/> className={`flex-1 py-2 text-xs font-medium transition-colors ${
<div className="flex justify-between text-xs text-gray-400 mt-1"> severity === v
<span>1 Minor</span><span>3 Moderate</span><span>5 Critical</span> ? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<span className="block font-bold">{v}</span>
<span className="block">{SEVERITY_LABELS[v].split(' / ')[0]}</span>
</button>
))}
</div> </div>
</div> </div>
Binary file not shown.
+338
View File
@@ -0,0 +1,338 @@
# Business Requirement Document
## Centralized AI-Powered HSE Incident Management & Reporting System
**Client:** Setia Corporation — 3PL Warehouse Operations
**Prepared by:** Wee Ihan Yap
**Version:** 1.0
**Date:** 12 July 2026
**Status:** Final
---
## 1. Executive Summary
Setia Corporation currently manages Health, Safety and Environment (HSE) incident reporting through a combination of WhatsApp messages, Excel spreadsheets, and paper-based forms. This process creates significant gaps: delayed notifications, incomplete evidence, inconsistent classification, and manual workload for statutory DOSH reporting.
This document defines the business requirements for a centralized, AI-powered HSE Incident Management System (IMS) that replaces these manual processes. The system captures every incident digitally, runs a structured workflow from report through closure, tracks corrective actions, and generates statutory JKKP reports automatically — eliminating the administrative burden on HSE staff and ensuring zero missed regulatory deadlines.
---
## 2. Business Objectives
1. Replace WhatsApp/Excel/paper incident reporting with a single digital system capturing all incident types.
2. Enforce a structured, auditable workflow: report → triage → investigation → CAPA → verification → closure.
3. Track all Corrective and Preventive Actions (CAPAs) to completion with automated escalation for overdue items.
4. Provide management with a live dashboard replacing monthly manual report compilation.
5. Ensure full compliance with Malaysian DOSH reporting obligations (NADOPOD 2004) without manual form-filling.
6. Use AI (Claude API) to reduce HSE admin burden: drafting summaries, suggesting root causes, flagging incomplete reports, and detecting risk patterns.
---
## 3. Stakeholders
| Role | Representative(s) | Responsibility |
|---|---|---|
| Sponsor / Business Owner | Ms. Agnes, Mr. Terence | Approve requirements, budget, go-live sign-off |
| HSE Lead | Mr. Yap | Primary system user; investigation, CAPA, DOSH filing |
| Operations | Mr. Jensen | Supervisor workflow, CAPA ownership |
| Floor Staff / Reporters | All employees, guards | Incident submission via QR scan |
| System Developer | Wee Ihan Yap | Design, build, deployment |
---
## 4. User Roles & Access
| Role | Who | Access Level | Primary Responsibility |
|---|---|---|---|
| Reporter | Any employee, supervisor, witness, security guard | Submit reports via QR/app; view own reports only | Report incidents and hazards immediately |
| Operation Supervisor | Warehouse/shift supervisor | Full access to incidents in their site/zone | Secure area, first response, initial classification |
| HSE Officer | Safety Assistant / HSE team | Full access across all sites | Investigation, RCA, CAPA assignment, verification, closure, DOSH filing |
| CAPA Owner | Department head (Ops, Maintenance, HR, etc.) | CAPA items assigned to their department only | Complete corrective actions, upload proof |
| Management | Ms. Agnes, Mr. Terence, Mr. Yap, Mr. Jensen | Read-only dashboard across all sites | Review trends, approve budgets, audit readiness |
| System Admin | IT / appointed super-user | Full configuration access | User management, site/zone setup, form configuration |
Every action must be attributable to a logged-in user — no anonymous edits.
---
## 5. Scope
### 5.1 In Scope
- Digital incident capture for all seven incident types (see §6)
- Full incident lifecycle: report → triage → investigation → CAPA → verification → closure
- Evidence management: photos, videos, documents at every lifecycle stage
- CAPA board with auto-escalation and effectiveness re-check
- Email and WhatsApp Business notifications
- In-app notification bell and badge
- DOSH compliance: automated JKKP 6, JKKP 7, JKKP 8 PDF and CSV generation
- AI-assisted features: quality check, severity suggestion, similar incident retrieval, RCA/CAPA drafting, risk heatmap
- Multi-site, multi-zone QR-based reporting
- Multi-language UI (English, Bahasa Malaysia, Mandarin)
- Mobile-first, offline capture with auto-sync on reconnect
- Dashboard: leading/lagging indicators, site/zone heatmap, CAPA on-time rate, trend charts
- Admin panel: user management, site/zone CRUD, role assignment
- Full audit trail (every action logged with user + timestamp)
- 5-year data retention per DOSH requirements
### 5.2 Out of Scope
- WMS (Warehouse Management System) integration (optional future phase)
- Tamil language support (deferred unless workforce requires it)
- Third-party EHS platform integration
- External audit portal access
---
## 6. Incident Types
The system must support all seven incident types, each with a type-specific intake form:
1. **Injury / Medical Treatment Case** — LTI and non-LTI
2. **Near Miss** — fast, low-friction form (near-miss volume is the primary leading safety indicator)
3. **Unsafe Condition / Hazard Observation** — proactive, not tied to an event
4. **Property / Asset / MHE Damage** — forklift, racking, dock equipment
5. **Environmental Incident** — spill, leak, chemical release, waste
6. **Security Incident** — theft, unauthorized access
7. **Fire / Emergency Incident**
---
## 7. Incident Reference Format
Every incident auto-generates a unique reference number:
```
SITE-YYYYMM-####
```
Example: `KL01-202607-0042`
---
## 8. Functional Requirements
### 8.1 Incident Reporting
- Reporter scans a site/zone-specific QR code or opens the app
- System pre-fills site and zone from QR token (no manual entry)
- Reporter selects incident type and completes type-specific intake form
- Minimum one photo required for injury reports; video and documents optional
- System auto-generates incident reference number and timestamps report
- Automatic notification fires immediately to the relevant Supervisor and HSE Officer
- Offline capture supported: form data queued in browser (IndexedDB) and synced on reconnect
### 8.2 Triage & Initial Response
- Supervisor or HSE Officer confirms or reclassifies incident type
- Assigns severity level (15); AI may suggest a level but a human always confirms
- Workflow branches by type:
- Injury: medical/first-aid path, LTI/non-LTI classification, lost-day tracking
- Asset/MHE: emergency shutdown/LOTO, operator-error check, HR/discipline path if applicable
- Environmental: containment steps, spill-kit deployment, environmental authority check
- Near miss / hazard: skip to root-cause and CAPA directly
- System automatically evaluates incident data against NADOPOD 2004 rules and presents the applicable obligation (immediate DOSH notification / JKKP 6 / JKKP 7 / JKKP 8) as a checklist for the HSE Officer
### 8.3 Investigation & CAPA
- HSE enters witness statements, alcohol/urine test result (if applicable), evidence
- Structured root-cause analysis via selectable template: 5-Why or Fishbone (not free text only)
- Every CAPA item must record: description, responsible department/owner, due date, priority, and root-cause linkage
- Auto-escalation ladder:
- 3 days before due date: reminder to owner
- On due date: notify owner
- 3 days overdue: notify owner's manager
- 7 days overdue: notify HSE Officer, flag red on dashboard
### 8.4 Verification & Closure
- CAPA owner uploads completion evidence (photo/document proof) before marking CAPA as done
- HSE verifies effectiveness; if not effective, CAPA **reopens** (not closed with open gap)
- Once all CAPAs verified, HSE closes incident
- On closure: record locks against further edits; only addenda can be appended
- Closed incident automatically enters JKKP 8 annual register
### 8.5 Evidence Management
Evidence must be attachable at every lifecycle stage, not only at initial report:
| Stage | Expected Evidence |
|---|---|
| Report | Scene photo/video, hazard photo |
| Response | LOTO tag photo, first-aid record, medical referral letter |
| Investigation | Witness statement scans, CCTV export, equipment inspection report, alcohol/urine test result |
| CAPA | Before/after photos, purchase receipts, training attendance sheets, updated SOP |
| Verification | Final confirmation photo/video that corrective action is in place and effective |
Requirements:
- Accepted formats: JPG, PNG, HEIC, MP4, MOV, PDF, DOCX, XLSX
- Max file size: configurable (recommended 200 MB for video; compress on upload)
- Every file records: uploader, timestamp, incident ID, stage, immutable SHA-256 file hash
- Files retained minimum 5 years; never auto-deleted
- Thumbnail/preview generation so HSE can review without downloading
### 8.6 Notifications
| Channel | Use |
|---|---|
| Email | Formal records: investigation assignment, CAPA assignment, closure notifications |
| WhatsApp Business API | Time-critical alerts: new serious incident, CAPA overdue escalation |
| In-app notification bell | All events for all users; unread badge count; dropdown list |
### 8.7 Dashboard & Analytics
- Total incidents, near misses, severity rate, open vs. closed counts
- **Leading vs. lagging indicator split** (near miss/hazard = leading; injury/LTI = lagging)
- Site/zone/shift heatmap — critical for multi-warehouse operations
- CAPA on-time completion rate (%)
- Top incident category and top root cause, trended over 12 months
- DOSH-reportable incident count and filing status (filed / pending / overdue)
- AI rising-risk zone flags: zones with statistically increasing incident frequency
- Export to PDF/Excel for board reporting
### 8.8 AI-Assisted Capabilities
All AI outputs are suggestions that a human reviews and approves — never auto-submitted to DOSH and never auto-closed without human sign-off.
| Capability | Description | Business Value |
|---|---|---|
| Report quality check | Flags incomplete reports before submission (missing photo on injury report, vague description) | Fixes delayed/incomplete information problem |
| Severity/category suggestion | Suggests severity level (15) and incident category from free-text description | Speeds triage, reduces classification inconsistency |
| Similar incident retrieval | Surfaces top-5 past incidents with similar description/location/equipment via vector similarity | Reveals recurring hazards; supports trend detection |
| RCA/CAPA drafting assistant | Suggests likely root causes and draft corrective actions from investigation notes | Cuts write-up time, improves CAPA consistency |
| JKKP form auto-fill | Generates JKKP 6/7 PDF drafts and JKKP 8 annual register from stored data | Removes single biggest admin burden |
| Risk heatmap / prediction | Combines near-miss, incident, and hazard data by site/zone/shift to flag rising-risk areas | Predictive safety capability tuned to Setia's own warehouses |
All AI suggestions logged to audit trail: what was suggested and what the human ultimately chose.
### 8.9 DOSH Compliance (NADOPOD 2004)
The system encodes Malaysia-specific statutory reporting rules and automatically determines the applicable obligation:
| Situation | Obligation |
|---|---|
| Fatality or serious bodily injury (NADOPOD First Schedule: fracture, amputation, loss of sight) | Notify nearest DOSH office immediately; submit JKKP 6 within 7 days |
| Dangerous occurrence (Second Schedule: boiler explosion, structural collapse) regardless of injury | Notify DOSH immediately; submit JKKP 6 within 7 days |
| Other injury causing incapacity for more than 4 consecutive days | Submit JKKP 6 within 7 days |
| Occupational poisoning or disease (Third Schedule) | Submit JKKP 7 within 7 days |
| Any of the above | Also logged in JKKP 8 annual register; retained on-site 5 years; submitted to DOSH before 31 January each year |
### 8.10 Admin Management
- User management: invite by email, assign role and site, activate/deactivate
- Site CRUD: name, address, region, active flag
- Zone CRUD: name per site, QR code generation and download per zone
- All admin actions logged to audit trail
---
## 9. Non-Functional Requirements
| Category | Requirement |
|---|---|
| Mobile-first | Must work on low-end Android phones common on warehouse floors |
| Offline | Incident form submittable offline; data queues locally (IndexedDB) and auto-syncs on reconnect |
| Multi-language | English, Bahasa Malaysia, Mandarin (all three available at all times via language switcher) |
| Multi-site | Site and zone are first-class fields on every record from day one |
| Access control | Role-based access enforced at the database level (Supabase RLS), not only in UI |
| Audit trail | Every create/edit/status-change/file-upload logged with user and timestamp; immutable |
| Data retention | Minimum 5 years per DOSH JKKP 8 requirement; evidence files never hard-deleted |
| Performance | Incident list uses server-side pagination; no unbounded queries |
| Security | API keys server-side only; never exposed to client; RLS on all tables |
---
## 10. System Architecture Summary
**Stack:**
| Layer | Technology |
|---|---|
| Frontend + API routes | Next.js 15 (App Router) |
| Database + Auth + Storage | Supabase (Postgres + RLS + pgvector) |
| Hosting | Vercel (frontend) + Supabase cloud |
| AI | Claude API (Anthropic) — server-side only |
| Email | Resend |
| WhatsApp | Meta WhatsApp Business Cloud API |
| PDF generation | pdf-lib (JKKP 6/7 form fill) |
| QR codes | qrcode npm package |
**Deployment:** Vercel (frontend) + Supabase cloud. Custom domain `hse.setiacorp.com` once MVP validated. All secrets stored as environment variables — never committed to code.
---
## 11. Key Database Entities
| Entity | Purpose |
|---|---|
| sites | Warehouse locations |
| zones | Named areas within a site, each with a unique QR token |
| users | All system users with role, department, site assignment |
| incidents | Core record: type, site, zone, severity, status, lifecycle timestamps |
| evidence_files | Files attached at each lifecycle stage with immutable hash |
| investigations | RCA method, findings, root cause summary, test results |
| capa_actions | CAPA items with owner, due date, priority, status, effectiveness recheck date |
| dosh_reports | JKKP 6/7/8 records with filing status and generated PDF link |
| notifications_log | All notifications sent across all channels |
| audit_log | Immutable record of every system action |
---
## 12. Development Phases
| Phase | Scope | Status |
|---|---|---|
| 0 | Foundation: scaffold, auth, DB migrations, QR codes | Complete |
| 1 | Core reporting: incident form, inbox, evidence upload, email notification | Complete |
| 2 | Investigation + CAPA: triage, 5-Why/fishbone, CAPA board, verification, JKKP 6/7 PDF | Complete |
| 3 | AI features + dashboards: Claude integration, pgvector similar incidents, role dashboards, CSV export | Complete |
| 4 | Scale & polish: WhatsApp notifications, i18n EN/MS/ZH, PWA offline capture, CAPA effectiveness recheck | Complete |
| 5 | Usability & compliance: notification bell, closure lock, pagination, type-specific intake, witness/alcohol UI, JKKP 8, admin management, evidence thumbnails | Complete |
| 6 | Analytics & predictive: 12-month trend chart, top root causes trended, AI rising-risk zone flags | Complete |
---
## 13. Success Metrics
| Metric | Target |
|---|---|
| Digital incident capture rate | 100% within 30 days of launch (zero WhatsApp-only reports) |
| CAPA on-time closure rate | Above 85% within 3 months |
| Incident-to-HSE notification time | Under 2 minutes (vs. current WhatsApp-dependent manual forwarding) |
| Monthly HSE report preparation time | Reduced from days to minutes (auto-generated from dashboard) |
| Missed DOSH statutory reporting deadlines | Zero |
---
## 14. Assumptions & Constraints
- All warehouse sites have internet access sufficient for mobile web browsing; offline mode covers low-signal periods only.
- Users are expected to have a smartphone (Android minimum); desktop access available for HSE Officers and management.
- Supabase free/starter tier sufficient for initial rollout; video-heavy storage may require upgrade to Supabase Pro or Cloudflare R2 migration at scale.
- `SUPABASE_SERVICE_ROLE_KEY` must be set in production environment for admin user-invite functionality.
- WhatsApp Business API requires approved Meta Business account and message templates before production use.
- VPS cron jobs must be registered for CAPA escalation and effectiveness recheck automation post-deployment.
- Evidence files must never be deleted; storage cost is a known ongoing operational expense.
---
## 15. Glossary
| Term | Definition |
|---|---|
| CAPA | Corrective and Preventive Action |
| DOSH | Department of Occupational Safety and Health (Malaysia) |
| HSE | Health, Safety and Environment |
| JKKP 6 | Malaysia statutory form — notification of accident/dangerous occurrence |
| JKKP 7 | Malaysia statutory form — notification of occupational poisoning/disease |
| JKKP 8 | Malaysia statutory form — annual register of accidents/occupational diseases |
| LTI | Lost Time Injury (injury resulting in at least one day away from work) |
| LOTO | Lockout/Tagout (energy isolation safety procedure) |
| MHE | Material Handling Equipment (forklifts, pallet jacks, etc.) |
| NADOPOD 2004 | Notification of Accident, Dangerous Occurrence, Occupational Poisoning and Occupational Disease Regulations 2004 |
| pgvector | Postgres extension for vector similarity search (used for similar incident retrieval) |
| QR | Quick Response code (used for site/zone identification on incident report form) |
| RCA | Root Cause Analysis |
| RLS | Row-Level Security (Supabase/Postgres feature enforcing data access at DB level) |
| 3PL | Third-Party Logistics |
Binary file not shown.
Binary file not shown.