feat: reporter clickable rows, CAPA owner action buttons, triage segmented severity
This commit is contained in:
@@ -4,6 +4,7 @@ import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { StatCard } from '@/components/dashboard/stat-card'
|
||||
import { CapaOwnerActions } from '@/components/capa/capa-owner-actions'
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
open: 'Open',
|
||||
@@ -78,21 +79,24 @@ export default async function CapaOwnerPage() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-800">{capa.description}</p>
|
||||
{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 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'}`}>
|
||||
{STATUS_LABELS[capa.status] ?? capa.status}
|
||||
</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'}
|
||||
</p>
|
||||
{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
|
||||
</p>
|
||||
)}
|
||||
<CapaOwnerActions capaId={capa.id} currentStatus={capa.status} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export default async function ReporterPage() {
|
||||
{rows.map(inc => {
|
||||
const siteName = (inc.sites as unknown as { name: string } | null)?.name
|
||||
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-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
@@ -107,7 +107,7 @@ export default async function ReporterPage() {
|
||||
{STATUS_LABELS[inc.status] ?? inc.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -94,16 +94,23 @@ export function TriageForm({ incidentId, currentSeverity }: Props) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Severity — {SEVERITY_LABELS[severity]}
|
||||
</label>
|
||||
<input
|
||||
type="range" min={1} max={5} value={severity}
|
||||
onChange={e => setSeverity(Number(e.target.value))}
|
||||
className="w-full accent-blue-600"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>1 Minor</span><span>3 Moderate</span><span>5 Critical</span>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Severity</label>
|
||||
<div className="flex rounded-lg border border-gray-300 overflow-hidden divide-x divide-gray-300">
|
||||
{([1, 2, 3, 4, 5] as const).map(v => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => setSeverity(v)}
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors ${
|
||||
severity === v
|
||||
? '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>
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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 (1–5); 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 (1–5) 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.
Reference in New Issue
Block a user