feat: Phase 5 & 6 — usability, compliance hardening, analytics

Phase 5 (usability + compliance):
- In-app notification bell/badge: migration 016 adds read state + per-user
  RLS + create_in_app_notification SECURITY DEFINER RPC; /api/notifications;
  wired into incident creation, CAPA assign/verify, escalation cron
- Incident closure: new POST /api/incidents/[id]/close (requires verification
  status + all CAPAs verified); migration 017 locks closed incidents at DB
  level (update/delete triggers) with append-only incident_addenda + UI panel
- Server-side pagination on HSE/supervisor incident inboxes (.range, 25/page)
- Investigation form: alcohol/urine test result + witness statement refs
  (existing schema columns, now editable)
- Type-specific intake fields: migration 018 adds incidents.type_details
  JSONB; whitelist validation; environmental/asset/security/fire field
  groups in report form; EN/MS/ZH labels; offline queue support
- JKKP 8 annual register CSV export (/api/reports/jkkp8) + dashboard button
  + January statutory deadline banner
- Admin page: user invite (service-role client), role/site/active management,
  site + zone CRUD with QR report links — replaces Phase 0 stub
- Evidence gallery thumbnails via Supabase render transform with fallback

Phase 6 (analytics):
- 12-month stacked trend chart (leading/lagging/other) + top root causes
  (lib/dashboard/trends.ts pure helpers)
- AI rising-risk zones: /api/dashboard/ai/risk-flags aggregates 90-day
  zone stats, claude-opus-4-8 forced tool_use, panel on HSE + management
  dashboards, suggestion audit-logged

Also fixes 9 pre-existing missing /ims basePath prefixes in client fetches
and download links.

132 tests passing, tsc clean, next build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
This commit is contained in:
2026-07-12 10:25:08 +08:00
co-authored by Claude Fable 5
parent 98c38c3716
commit 576557181a
51 changed files with 2394 additions and 38 deletions
+26
View File
@@ -86,3 +86,29 @@ Base commit: 55b6dac
- [x] Task 2: WhatsApp integration — incident alert + CAPA escalation (commit a47f3aa + edb0bde, review clean — MINOR: overdue_3d WhatsApp path untested; serial Meta calls in IIFE; mock call-index fragility in capa test; duplicate vi.clearAllMocks)
- [x] Task 3: CAPA effectiveness recheck (commit b9def9c, review clean — MINOR: getNextRecheckDate uses local setDate not UTC → fix setDate→setUTCDate before prod; CRON_SECRET undefined demotes to "Bearer undefined"; DB update after send has no error check; nextDate??null no-op)
- [x] Task 4: i18n infrastructure + EN/MS/ZH translations (commit 87264a5, review clean — MINOR: report page h1 still hardcoded English (plan gap); offline db stub throws at runtime (temporary, Task 5 replaces); language-switcher cookie value not URL-decoded; SW path /ims hardcoded in layout)
- [x] Task 5: PWA offline capture (commits c048878..448ea48, review clean after fix — MINOR: SW clients.claim() outside waitUntil; fixed basePath in fetch URL; fixed syncNow stable callback with useRef)
---
# Phase 5 & 6 SDD Progress Ledger
Plan: docs/superpowers/plans/2026-07-11-phase-5-6-usability-compliance-analytics.md
Started: 2026-07-11 · Completed: 2026-07-12
Branch: phase-5-6
## Tasks
- [x] Housekeeping: removed stray "whatsapp 2" duplicates (identical to canonical)
- [x] In-app notification bell + badge (migration 016, SECURITY DEFINER RPC, /api/notifications, bell in protected layout, wired into incident/CAPA/escalation flows)
- [x] Incident closure lock + addenda (migration 017: incident_addenda + DB triggers; POST /close endpoint added — did not exist; addenda UI)
- [x] Incident list pagination (server-side .range(), shared Pagination component, HSE + supervisor inboxes)
- [x] Witness statement + alcohol test UI (investigation form + route, existing schema columns)
- [x] Type-specific intake forms (migration 018: type_details JSONB; validateTypeDetails whitelist; env/asset/security/fire field groups; EN/MS/ZH; offline queue support)
- [x] JKKP 8 annual register export (lib/reports/jkkp8.ts + /api/reports/jkkp8 CSV; dashboard button + January deadline banner)
- [x] Admin user + site/zone management (invite via service-role client, role/site/active management, site+zone CRUD with QR link)
- [x] Evidence thumbnails (Supabase render transform + onError fallback, lazy loading)
- [x] Phase 6: 12-month trend chart + top root causes (lib/dashboard/trends.ts pure helpers + tests)
- [x] Phase 6: AI rising-risk zones (/api/dashboard/ai/risk-flags, claude-opus-4-8 forced tool_use, panel on HSE + management dashboards)
- [x] Drive-by: fixed 9 pre-existing missing /ims basePath prefixes
Verification: 112 tests passing (23 files), tsc clean, next build clean.
+33 -5
View File
@@ -1,9 +1,37 @@
// app/(protected)/admin/page.tsx
export default function AdminHome() {
export const dynamic = 'force-dynamic'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { UserManager, type AdminUser, type SiteOption } from '@/components/admin/user-manager'
import { SiteZoneManager, type SiteWithZones } from '@/components/admin/site-zone-manager'
export default async function AdminHome() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || profile.role !== 'admin') redirect('/login')
const [{ data: users }, { data: sites }] = await Promise.all([
supabase
.from('users')
.select('id, name, email, role, department, site_id, active')
.order('created_at', { ascending: false }),
supabase
.from('sites')
.select('id, name, address, zones (id, name, qr_code_token)')
.order('name'),
])
const siteOptions: SiteOption[] = (sites ?? []).map(s => ({ id: s.id, name: s.name }))
return (
<main className="p-8 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
<p className="mt-2 text-gray-500">Phase 0: User management and site config coming here.</p>
<main className="max-w-4xl mx-auto px-4 py-6 space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Admin</h1>
<UserManager users={(users ?? []) as AdminUser[]} sites={siteOptions} />
<SiteZoneManager sites={(sites ?? []) as unknown as SiteWithZones[]} />
</main>
)
}
+86 -1
View File
@@ -3,6 +3,8 @@ export const dynamic = 'force-dynamic'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { StatCard } from '@/components/dashboard/stat-card'
import { bucketIncidentsByMonth, topRootCauses } from '@/lib/dashboard/trends'
import { RiskFlagsPanel } from '@/components/dashboard/risk-flags-panel'
const TYPE_LABELS: Record<string, string> = {
injury: 'Injury',
@@ -22,6 +24,7 @@ export default async function HseDashboardPage() {
thirtyDaysAgo.setDate(now.getDate() - 30)
const ninetyDaysAgo = new Date(now)
ninetyDaysAgo.setDate(now.getDate() - 90)
const twelveMonthsAgo = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 11, 1))
const [
{ data: incidents },
@@ -29,6 +32,8 @@ export default async function HseDashboardPage() {
{ data: zoneIncidents },
{ data: completedCapas },
{ data: doshPendingRows },
{ data: yearIncidents },
{ data: investigations },
] = await Promise.all([
supabase.from('incidents').select('id, status, incident_type, sites (name)'),
supabase
@@ -48,6 +53,14 @@ export default async function HseDashboardPage() {
.from('dosh_reports')
.select('id')
.eq('status', 'pending'),
supabase
.from('incidents')
.select('reported_at, incident_type')
.gte('reported_at', twelveMonthsAgo.toISOString()),
supabase
.from('investigations')
.select('root_cause_summary')
.not('root_cause_summary', 'is', null),
])
// --- Existing metrics ---
@@ -105,23 +118,47 @@ export default async function HseDashboardPage() {
// --- DOSH pending filings ---
const doshPendingCount = doshPendingRows?.length ?? 0
// --- 12-month trend + top root causes ---
const monthly = bucketIncidentsByMonth(yearIncidents ?? [], 12, now)
const monthlyMax = Math.max(1, ...monthly.map(m => m.total))
const rootCauses = topRootCauses(investigations ?? [])
return (
<main className="max-w-4xl mx-auto px-4 py-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<div className="flex gap-3">
<a
href="/api/dashboard/export?role=hse"
href="/ims/api/dashboard/export?role=hse"
className="text-sm text-gray-500 hover:text-gray-700 border border-gray-200 rounded-lg px-3 py-1.5"
>
Export CSV
</a>
<a
href={`/ims/api/reports/jkkp8?year=${now.getFullYear()}`}
className="text-sm text-gray-500 hover:text-gray-700 border border-gray-200 rounded-lg px-3 py-1.5"
>
JKKP 8 Register
</a>
<Link href="/hse/incidents" className="text-sm text-blue-600 hover:underline">
View all incidents
</Link>
</div>
</div>
{/* JKKP 8 statutory deadline reminder — register due to DOSH before 31 January */}
{now.getMonth() === 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 mb-6">
<p className="text-sm text-amber-800">
<strong>JKKP 8 annual register due:</strong> the {now.getFullYear() - 1} register must be
submitted to DOSH before 31 January {now.getFullYear()}.{' '}
<a href={`/ims/api/reports/jkkp8?year=${now.getFullYear() - 1}`} className="underline font-medium">
Download {now.getFullYear() - 1} register
</a>
</p>
</div>
)}
{/* Summary stats */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
<StatCard label="Total Incidents" value={total} />
@@ -161,6 +198,54 @@ export default async function HseDashboardPage() {
</div>
</div>
<RiskFlagsPanel />
{/* 12-month incident trend */}
<div className="bg-white rounded-xl shadow-sm p-5 mb-4">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-1">
Incident Trend Last 12 Months
</h2>
<p className="text-xs text-gray-400 mb-4">
Blue: leading (hazard + near miss) · Red: lagging (injury) · Grey: other
</p>
<div className="flex items-end gap-1 h-32">
{monthly.map(m => {
const other = m.total - m.leading - m.lagging
return (
<div key={m.month} className="flex-1 flex flex-col items-center gap-1">
<div className="w-full flex flex-col-reverse" style={{ height: '100px' }}>
<div className="w-full bg-blue-400" style={{ height: `${(m.leading / monthlyMax) * 100}px` }} />
<div className="w-full bg-red-400" style={{ height: `${(m.lagging / monthlyMax) * 100}px` }} />
<div className="w-full bg-gray-300" style={{ height: `${(Math.max(0, other) / monthlyMax) * 100}px` }} />
</div>
<span className="text-[10px] text-gray-400">{m.label}</span>
<span className="text-[10px] font-semibold text-gray-600">{m.total || ''}</span>
</div>
)
})}
</div>
</div>
{/* Top root causes */}
{rootCauses.length > 0 && (
<div className="bg-white rounded-xl shadow-sm p-5 mb-4">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">
Top Root Causes
</h2>
<ol className="space-y-2">
{rootCauses.map((rc, i) => (
<li key={rc.cause} className="flex items-start justify-between gap-3">
<span className="text-sm text-gray-700">
<span className="text-gray-400 mr-2">{i + 1}.</span>
{rc.cause}
</span>
<span className="text-sm font-semibold text-gray-900 shrink-0">{rc.count}×</span>
</li>
))}
</ol>
</div>
)}
{/* Zone heatmap — last 90 days */}
{by_zone.length > 0 && (
<div className="bg-white rounded-xl shadow-sm p-5 mb-4">
+5 -3
View File
@@ -5,6 +5,7 @@ import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { IncidentDetail, type Incident } from '@/components/incidents/incident-detail'
import { SimilarIncidentsPanel } from '@/components/incidents/similar-incidents-panel'
import { ClosurePanel } from '@/components/incidents/closure-panel'
interface Props {
params: Promise<{ id: string }>
@@ -18,7 +19,7 @@ export default async function HseIncidentDetailPage({ params }: Props) {
.from('incidents')
.select(`
id, reference_no, incident_type, description, severity, status,
injury_involved, asset_involved, medical_status, lost_days,
injury_involved, asset_involved, medical_status, lost_days, type_details,
is_fatality, is_serious_bodily_injury, is_dangerous_occurrence, is_occupational_disease,
reported_at, closed_at,
sites (id, name),
@@ -80,14 +81,14 @@ export default async function HseIncidentDetailPage({ params }: Props) {
) && (
<div className="mt-4 flex gap-3">
<a
href={`/api/incidents/${id}/jkkp-pdf?form=jkkp6`}
href={`/ims/api/incidents/${id}/jkkp-pdf?form=jkkp6`}
target="_blank"
className="inline-block bg-gray-800 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-900"
>
Download JKKP 6 (PDF)
</a>
<a
href={`/api/incidents/${id}/jkkp-pdf?form=jkkp7`}
href={`/ims/api/incidents/${id}/jkkp-pdf?form=jkkp7`}
target="_blank"
className="inline-block bg-gray-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-700"
>
@@ -95,6 +96,7 @@ export default async function HseIncidentDetailPage({ params }: Props) {
</a>
</div>
)}
<ClosurePanel incidentId={id} status={status} canClose canAddAddenda />
<SimilarIncidentsPanel incidentId={id} />
</main>
)
+16 -5
View File
@@ -2,28 +2,39 @@ export const dynamic = 'force-dynamic'
import { createClient } from '@/lib/supabase/server'
import { IncidentList, type Incident } from '@/components/incidents/incident-list'
import { Pagination } from '@/components/incidents/pagination'
export default async function HseInboxPage() {
const PAGE_SIZE = 25
export default async function HseInboxPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>
}) {
const supabase = await createClient()
const params = await searchParams
const page = Math.max(1, Number.parseInt(params.page ?? '1', 10) || 1)
const from = (page - 1) * PAGE_SIZE
const { data: incidents } = await supabase
const { data: incidents, count } = await supabase
.from('incidents')
.select(`
id, reference_no, incident_type, status, severity, reported_at,
sites (name),
zones (name),
reporter:users!reported_by (name)
`)
`, { count: 'exact' })
.order('reported_at', { ascending: false })
.limit(100)
.range(from, from + PAGE_SIZE - 1)
return (
<main className="max-w-4xl mx-auto px-4 py-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Incident Inbox</h1>
<span className="text-sm text-gray-500">{incidents?.length ?? 0} incidents</span>
<span className="text-sm text-gray-500">{count ?? incidents?.length ?? 0} incidents</span>
</div>
<IncidentList incidents={(incidents ?? []) as unknown as Incident[]} basePath="/hse" />
<Pagination page={page} pageSize={PAGE_SIZE} total={count ?? 0} href="/hse/incidents" />
</main>
)
}
+7 -1
View File
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { NotificationBell } from '@/components/notifications/bell'
export default async function ProtectedLayout({
children,
@@ -16,5 +17,10 @@ export default async function ProtectedLayout({
if (!user) redirect('/login')
return <>{children}</>
return (
<>
<NotificationBell />
{children}
</>
)
}
+4 -1
View File
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { StatCard } from '@/components/dashboard/stat-card'
import { RiskFlagsPanel } from '@/components/dashboard/risk-flags-panel'
export default async function ManagementPage() {
const supabase = await createClient()
@@ -71,13 +72,15 @@ export default async function ManagementPage() {
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Management Dashboard</h1>
<a
href="/api/dashboard/export?role=management"
href="/ims/api/dashboard/export?role=management"
className="text-sm text-gray-500 hover:text-gray-700 border border-gray-200 rounded-lg px-3 py-1.5"
>
Export CSV
</a>
</div>
<RiskFlagsPanel />
{/* Summary stats */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
<StatCard
+16 -5
View File
@@ -2,28 +2,39 @@ export const dynamic = 'force-dynamic'
import { createClient } from '@/lib/supabase/server'
import { IncidentList, type Incident } from '@/components/incidents/incident-list'
import { Pagination } from '@/components/incidents/pagination'
export default async function SupervisorInboxPage() {
const PAGE_SIZE = 25
export default async function SupervisorInboxPage({
searchParams,
}: {
searchParams: Promise<{ page?: string }>
}) {
const supabase = await createClient()
const params = await searchParams
const page = Math.max(1, Number.parseInt(params.page ?? '1', 10) || 1)
const from = (page - 1) * PAGE_SIZE
const { data: incidents } = await supabase
const { data: incidents, count } = await supabase
.from('incidents')
.select(`
id, reference_no, incident_type, status, severity, reported_at,
sites (name),
zones (name),
reporter:users!reported_by (name)
`)
`, { count: 'exact' })
.order('reported_at', { ascending: false })
.limit(100)
.range(from, from + PAGE_SIZE - 1)
return (
<main className="max-w-4xl mx-auto px-4 py-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Incident Inbox</h1>
<span className="text-sm text-gray-500">{incidents?.length ?? 0} incidents</span>
<span className="text-sm text-gray-500">{count ?? incidents?.length ?? 0} incidents</span>
</div>
<IncidentList incidents={(incidents ?? []) as unknown as Incident[]} basePath="/supervisor" />
<Pagination page={page} pageSize={PAGE_SIZE} total={count ?? 0} href="/supervisor/incidents" />
</main>
)
}
+57
View File
@@ -0,0 +1,57 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
async function requireAdmin() {
const supabase = await createClient()
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) return { supabase, user: null }
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || profile.role !== 'admin') return { supabase, user: null }
return { supabase, user }
}
export async function POST(request: NextRequest) {
const { supabase, user } = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body: { kind?: 'site' | 'zone'; name?: string; address?: string; site_id?: string } =
await request.json().catch(() => ({}))
const name = (body.name ?? '').trim()
if (!name) return NextResponse.json({ error: 'name required' }, { status: 422 })
if (body.kind === 'zone') {
if (!body.site_id) return NextResponse.json({ error: 'site_id required for zone' }, { status: 422 })
const { data: zone, error } = await supabase
.from('zones')
.insert({ site_id: body.site_id, name })
.select('id, qr_code_token')
.single()
if (error || !zone) return NextResponse.json({ error: 'Insert failed' }, { status: 500 })
await supabase.rpc('write_audit_log', {
p_table_name: 'zones',
p_record_id: zone.id,
p_action: 'INSERT',
p_new_value: { name, site_id: body.site_id },
})
return NextResponse.json({ id: zone.id, qr_code_token: zone.qr_code_token }, { status: 201 })
}
const { data: site, error } = await supabase
.from('sites')
.insert({ name, address: body.address ?? null })
.select('id')
.single()
if (error || !site) return NextResponse.json({ error: 'Insert failed' }, { status: 500 })
await supabase.rpc('write_audit_log', {
p_table_name: 'sites',
p_record_id: site.id,
p_action: 'INSERT',
p_new_value: { name, address: body.address ?? null },
})
return NextResponse.json({ id: site.id }, { status: 201 })
}
+124
View File
@@ -0,0 +1,124 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import { isValidRole } from '@/lib/auth/roles'
async function requireAdmin() {
const supabase = await createClient()
const { data: { user }, error } = await supabase.auth.getUser()
if (error || !user) return { supabase, user: null }
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || profile.role !== 'admin') return { supabase, user: null }
return { supabase, user }
}
export async function GET() {
const { supabase, user } = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { data, error } = await supabase
.from('users')
.select('id, name, email, phone, role, department, site_id, active, created_at')
.order('created_at', { ascending: false })
if (error) return NextResponse.json({ error: 'Fetch failed' }, { status: 500 })
return NextResponse.json(data ?? [])
}
export async function POST(request: NextRequest) {
const { supabase, user } = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body: { email?: string; name?: string; role?: string; site_id?: string } =
await request.json().catch(() => ({}))
const email = (body.email ?? '').trim().toLowerCase()
if (!email || !email.includes('@'))
return NextResponse.json({ error: 'Valid email required' }, { status: 422 })
if (body.role && !isValidRole(body.role))
return NextResponse.json({ error: 'Invalid role' }, { status: 422 })
let admin
try {
admin = createAdminClient()
} catch {
return NextResponse.json(
{ error: 'User invites unavailable: SUPABASE_SERVICE_ROLE_KEY not configured' },
{ status: 503 },
)
}
const { data: invited, error: inviteError } = await admin.auth.admin.inviteUserByEmail(email, {
data: { full_name: body.name ?? '' },
})
if (inviteError || !invited?.user)
return NextResponse.json({ error: inviteError?.message ?? 'Invite failed' }, { status: 500 })
// handle_new_auth_user trigger creates the profile row; set role/site on top of it
const { error: profileError } = await admin
.from('users')
.update({
name: body.name ?? '',
role: body.role ?? 'reporter',
site_id: body.site_id ?? null,
})
.eq('id', invited.user.id)
if (profileError)
return NextResponse.json({ error: 'Invite sent but profile update failed' }, { status: 500 })
await supabase.rpc('write_audit_log', {
p_table_name: 'users',
p_record_id: invited.user.id,
p_action: 'invited',
p_new_value: { email, role: body.role ?? 'reporter', site_id: body.site_id ?? null },
})
return NextResponse.json({ id: invited.user.id }, { status: 201 })
}
export async function PATCH(request: NextRequest) {
const { supabase, user } = await requireAdmin()
if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body: {
id?: string
role?: string
site_id?: string | null
active?: boolean
department?: string | null
} = await request.json().catch(() => ({}))
if (!body.id) return NextResponse.json({ error: 'id required' }, { status: 422 })
if (body.role !== undefined && !isValidRole(body.role))
return NextResponse.json({ error: 'Invalid role' }, { status: 422 })
if (body.id === user.id && body.active === false)
return NextResponse.json({ error: 'Cannot deactivate your own account' }, { status: 422 })
if (body.id === user.id && body.role !== undefined && body.role !== 'admin')
return NextResponse.json({ error: 'Cannot demote your own account' }, { status: 422 })
const update: Record<string, unknown> = {}
if (body.role !== undefined) update.role = body.role
if (body.site_id !== undefined) update.site_id = body.site_id
if (body.active !== undefined) update.active = body.active
if (body.department !== undefined) update.department = body.department
if (Object.keys(update).length === 0)
return NextResponse.json({ error: 'Nothing to update' }, { status: 422 })
const { data: before } = await supabase
.from('users').select('role, site_id, active, department').eq('id', body.id).single()
const { error } = await supabase.from('users').update(update).eq('id', body.id)
if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 })
await supabase.rpc('write_audit_log', {
p_table_name: 'users',
p_record_id: body.id,
p_action: 'admin_update',
p_old_value: before ?? null,
p_new_value: update,
})
return NextResponse.json({ ok: true })
}
+14 -1
View File
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createInAppNotifications } from '@/lib/notifications/in-app'
export async function POST(
request: NextRequest,
@@ -19,7 +20,7 @@ export async function POST(
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { data: capa } = await supabase
.from('capa_actions').select('status, incident_id').eq('id', id).single()
.from('capa_actions').select('status, incident_id, owner_user_id').eq('id', id).single()
if (!capa) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (capa.status !== 'pending_verification')
return NextResponse.json({ error: 'CAPA must be pending_verification' }, { status: 409 })
@@ -56,6 +57,18 @@ export async function POST(
p_new_value: { ...update, reopen_reason: body.reopen_reason ?? null },
})
if (capa.owner_user_id) {
await createInAppNotifications(supabase, [{
userId: capa.owner_user_id,
title: body.verdict === 'verified'
? 'Your CAPA action was verified'
: `Your CAPA action was reopened${body.reopen_reason ? `: ${body.reopen_reason}` : ''}`,
link: `/hse/capa/${id}`,
incidentId: capa.incident_id,
capaId: id,
}])
}
// Check if all CAPAs for this incident are verified — if so, transition incident to verification
const { data: openCapas } = await supabase
.from('capa_actions')
+9
View File
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createInAppNotifications } from '@/lib/notifications/in-app'
export async function GET() {
const supabase = await createClient()
@@ -70,5 +71,13 @@ export async function POST(request: NextRequest) {
p_new_value: { incident_id, description, owner_user_id, department, due_date },
})
await createInAppNotifications(supabase, [{
userId: owner_user_id,
title: `CAPA assigned to you, due ${due_date}`,
link: `/hse/capa/${capa.id}`,
incidentId: incident_id,
capaId: capa.id,
}])
return NextResponse.json({ id: capa.id }, { status: 201 })
}
+155
View File
@@ -0,0 +1,155 @@
export const dynamic = 'force-dynamic'
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createAnthropicClient } from '@/lib/claude/client'
import { getApiKey } from '@/lib/settings'
type ZoneAggregate = {
zone: string
site: string
total: number
near_miss: number
hazard: number
injury: number
avg_severity: number | null
first_half: number
second_half: number
}
export async function POST() {
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin', 'management'].includes(profile.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const now = new Date()
const ninetyDaysAgo = new Date(now)
ninetyDaysAgo.setDate(now.getDate() - 90)
const midpoint = new Date(now)
midpoint.setDate(now.getDate() - 45)
const { data: incidents } = await supabase
.from('incidents')
.select('incident_type, severity, reported_at, zones (name), sites (name)')
.gte('reported_at', ninetyDaysAgo.toISOString())
const rows = incidents ?? []
if (rows.length === 0)
return NextResponse.json({ flags: [], summary: 'No incidents in the last 90 days.' })
const zoneMap = new Map<string, ZoneAggregate & { severitySum: number; severityCount: number }>()
for (const r of rows) {
const zone = (r.zones as unknown as { name: string } | null)?.name ?? 'Unknown zone'
const site = (r.sites as unknown as { name: string } | null)?.name ?? 'Unknown site'
const key = `${site}|${zone}`
let agg = zoneMap.get(key)
if (!agg) {
agg = {
zone, site, total: 0, near_miss: 0, hazard: 0, injury: 0,
avg_severity: null, first_half: 0, second_half: 0, severitySum: 0, severityCount: 0,
}
zoneMap.set(key, agg)
}
agg.total++
if (r.incident_type === 'near_miss') agg.near_miss++
if (r.incident_type === 'hazard') agg.hazard++
if (r.incident_type === 'injury') agg.injury++
if (typeof r.severity === 'number') {
agg.severitySum += r.severity
agg.severityCount++
}
if (new Date(r.reported_at as string) < midpoint) agg.first_half++
else agg.second_half++
}
const aggregates: ZoneAggregate[] = [...zoneMap.values()].map(a => ({
zone: a.zone,
site: a.site,
total: a.total,
near_miss: a.near_miss,
hazard: a.hazard,
injury: a.injury,
avg_severity: a.severityCount > 0 ? Math.round((a.severitySum / a.severityCount) * 10) / 10 : null,
first_half: a.first_half,
second_half: a.second_half,
}))
const anthropicKey = await getApiKey(supabase, 'ANTHROPIC_API_KEY')
const anthropic = createAnthropicClient(anthropicKey)
let message: Awaited<ReturnType<typeof anthropic.messages.create>>
try {
message = await anthropic.messages.create({
model: 'claude-opus-4-8',
thinking: { type: 'adaptive' },
max_tokens: 2048,
tools: [{
name: 'flag_rising_risk',
description: 'Flag warehouse zones showing rising safety risk from 90-day incident aggregates',
input_schema: {
type: 'object' as const,
properties: {
flags: {
type: 'array',
items: {
type: 'object',
properties: {
zone: { type: 'string' },
site: { type: 'string' },
risk_level: { type: 'string', enum: ['low', 'medium', 'high'] },
rationale: { type: 'string', description: 'One or two sentences citing the numbers' },
recommended_action: { type: 'string', description: 'One concrete preventive action' },
},
required: ['zone', 'site', 'risk_level', 'rationale', 'recommended_action'],
},
},
summary: { type: 'string', description: 'Two-sentence overall risk picture' },
},
required: ['flags', 'summary'],
},
}],
tool_choice: { type: 'tool', name: 'flag_rising_risk' },
messages: [{
role: 'user',
content: `You are an HSE risk analyst for a Malaysian 3PL warehouse operator. Below are 90-day incident aggregates per zone. "first_half" is incidents in days 90-46, "second_half" is days 45-0 — a rising second_half means worsening trend. Near-miss and hazard reports are leading indicators; injuries are lagging.
Flag zones with rising or elevated risk (at most 5 flags; do not flag healthy zones). Base every rationale strictly on the numbers given.
${JSON.stringify(aggregates, null, 2)}`,
}],
})
} catch {
return NextResponse.json({ error: 'AI service unavailable' }, { status: 503 })
}
const toolBlock = message.content.find(b => b.type === 'tool_use')
if (!toolBlock || toolBlock.type !== 'tool_use')
return NextResponse.json({ error: 'AI suggestion failed' }, { status: 500 })
const input = toolBlock.input as { flags?: unknown; summary?: unknown }
if (!Array.isArray(input.flags) || typeof input.summary !== 'string')
return NextResponse.json({ error: 'AI returned unexpected structure' }, { status: 500 })
const flags = input.flags.filter(
(f: unknown): f is { zone: string; site: string; risk_level: string; rationale: string; recommended_action: string } =>
typeof f === 'object' && f !== null &&
typeof (f as Record<string, unknown>).zone === 'string' &&
typeof (f as Record<string, unknown>).site === 'string' &&
['low', 'medium', 'high'].includes((f as Record<string, unknown>).risk_level as string) &&
typeof (f as Record<string, unknown>).rationale === 'string' &&
typeof (f as Record<string, unknown>).recommended_action === 'string',
)
await supabase.rpc('write_audit_log', {
p_table_name: 'incidents',
p_record_id: user.id,
p_action: 'ai_risk_flags',
p_new_value: { flags, summary: input.summary, model: 'claude-opus-4-8' } as never,
})
return NextResponse.json({ flags, summary: input.summary })
}
+61
View File
@@ -0,0 +1,61 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data, error } = await supabase
.from('incident_addenda')
.select('id, body, created_at, author:users!author (name)')
.eq('incident_id', id)
.order('created_at', { ascending: true })
if (error) return NextResponse.json({ error: 'Fetch failed' }, { status: 500 })
return NextResponse.json(data ?? [])
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin', 'supervisor'].includes(profile.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const body: { body?: string } = await request.json().catch(() => ({}))
const text = (body.body ?? '').trim()
if (!text) return NextResponse.json({ error: 'body required' }, { status: 422 })
const { data: addendum, error } = await supabase
.from('incident_addenda')
.insert({ incident_id: id, author: user.id, body: text })
.select('id')
.single()
if (error || !addendum) return NextResponse.json({ error: 'Insert failed' }, { status: 500 })
await supabase.rpc('write_audit_log', {
p_table_name: 'incident_addenda',
p_record_id: addendum.id,
p_action: 'INSERT',
p_new_value: { incident_id: id, author: user.id },
})
return NextResponse.json({ id: addendum.id }, { status: 201 })
}
+65
View File
@@ -0,0 +1,65 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createInAppNotifications } from '@/lib/notifications/in-app'
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const { data: incident } = await supabase
.from('incidents')
.select('status, reference_no, reported_by')
.eq('id', id)
.single()
if (!incident) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (incident.status === 'closed')
return NextResponse.json({ error: 'Already closed' }, { status: 409 })
if (incident.status !== 'verification')
return NextResponse.json({ error: 'Incident must be in verification status' }, { status: 409 })
const { data: openCapas } = await supabase
.from('capa_actions')
.select('id')
.eq('incident_id', id)
.not('status', 'in', '(verified,closed)')
if (openCapas && openCapas.length > 0)
return NextResponse.json({ error: 'All CAPA actions must be verified before closure' }, { status: 409 })
const closedAt = new Date().toISOString()
const { error } = await supabase
.from('incidents')
.update({ status: 'closed', closed_at: closedAt })
.eq('id', id)
if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 })
await supabase.rpc('write_audit_log', {
p_table_name: 'incidents',
p_record_id: id,
p_action: 'closed',
p_new_value: { status: 'closed', closed_at: closedAt, closed_by: user.id },
})
if (incident.reported_by) {
await createInAppNotifications(supabase, [{
userId: incident.reported_by,
title: `Your incident report ${incident.reference_no ?? ''} has been closed`,
link: '/reporter',
incidentId: id,
}])
}
return NextResponse.json({ ok: true, closed_at: closedAt })
}
@@ -37,6 +37,8 @@ export async function POST(
root_cause_summary: body.root_cause_summary ?? null,
five_why_steps: method === 'five_why' ? (body.five_why_steps ?? []) : null,
fishbone_categories: method === 'fishbone' ? (body.fishbone_categories ?? []) : null,
alcohol_test_result: body.alcohol_test_result ?? null,
witness_statement_refs: Array.isArray(body.witness_statement_refs) ? body.witness_statement_refs : [],
})
.select('id')
.single()
@@ -83,6 +85,8 @@ export async function PATCH(
root_cause_summary: fields.root_cause_summary ?? null,
five_why_steps: fields.five_why_steps ?? null,
fishbone_categories: fields.fishbone_categories ?? null,
alcohol_test_result: fields.alcohol_test_result ?? null,
witness_statement_refs: Array.isArray(fields.witness_statement_refs) ? fields.witness_statement_refs : [],
}
if (complete) updateData.completed_at = new Date().toISOString()
+30 -2
View File
@@ -1,9 +1,10 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { validateIncidentInput, type IncidentType, type MedicalStatus } from '@/lib/incidents/validate'
import { validateIncidentInput, validateTypeDetails, type IncidentType, type MedicalStatus } from '@/lib/incidents/validate'
import { uploadEvidenceFile, type EvidenceStage } from '@/lib/supabase/storage'
import { sendNewIncidentEmail } from '@/lib/notifications/email'
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
import { createInAppNotifications } from '@/lib/notifications/in-app'
import { getApiKey } from '@/lib/settings'
export const dynamic = 'force-dynamic'
@@ -42,6 +43,21 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Validation failed', details: validation.errors }, { status: 422 })
}
let rawDetails: Record<string, unknown> | undefined
if (typeof body.type_details === 'string' && body.type_details) {
try {
rawDetails = JSON.parse(body.type_details)
} catch {
return NextResponse.json({ error: 'Validation failed', details: ['type_details must be valid JSON'] }, { status: 422 })
}
} else if (body.type_details && typeof body.type_details === 'object') {
rawDetails = body.type_details as Record<string, unknown>
}
const detailsCheck = validateTypeDetails(input.incident_type, rawDetails)
if (!detailsCheck.ok) {
return NextResponse.json({ error: 'Validation failed', details: detailsCheck.errors }, { status: 422 })
}
const { data: zone, error: zoneError } = await supabase
.from('zones')
.select('id, site_id')
@@ -63,6 +79,7 @@ export async function POST(request: Request) {
injury_involved: input.injury_involved,
asset_involved: input.asset_involved,
medical_status: input.injury_involved ? (input.medical_status ?? 'none') : null,
type_details: detailsCheck.sanitized,
})
.select('id, reference_no')
.single()
@@ -121,9 +138,20 @@ export async function POST(request: Request) {
const siteName = (siteData as { name: string } | null)?.name ?? 'Unknown'
const { data: recipients } = await supabase
.from('users')
.select('phone')
.select('id, phone')
.in('role', ['supervisor', 'hse'])
.eq('site_id', zone.site_id)
await createInAppNotifications(
supabase,
(recipients ?? []).map((r: { id: string }) => ({
userId: r.id,
title: `New ${input.incident_type.replace(/_/g, ' ')} incident ${incident.reference_no ?? ''} at ${siteName}`,
link: `/hse/incidents/${incident.id}`,
incidentId: incident.id,
})),
)
for (const r of recipients ?? []) {
const phone = (r as { phone: string | null }).phone ?? ''
if (!phone) continue
+53
View File
@@ -0,0 +1,53 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
export async function GET() {
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: notifications, error } = await supabase
.from('notifications_log')
.select('id, title, link, incident_id, capa_id, sent_at, read_at')
.eq('channel', 'in_app')
.eq('recipient_user_id', user.id)
.order('sent_at', { ascending: false })
.limit(20)
if (error) return NextResponse.json({ error: 'Fetch failed' }, { status: 500 })
const { count } = await supabase
.from('notifications_log')
.select('id', { count: 'exact', head: true })
.eq('channel', 'in_app')
.eq('recipient_user_id', user.id)
.is('read_at', null)
return NextResponse.json({ notifications: notifications ?? [], unread: count ?? 0 })
}
export async function POST(request: NextRequest) {
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body: { ids?: string[]; all?: boolean } = await request.json().catch(() => ({}))
let query = supabase
.from('notifications_log')
.update({ read_at: new Date().toISOString() })
.eq('recipient_user_id', user.id)
.is('read_at', null)
if (!body.all) {
if (!Array.isArray(body.ids) || body.ids.length === 0)
return NextResponse.json({ error: 'ids required unless all:true' }, { status: 422 })
query = query.in('id', body.ids)
}
const { error } = await query
if (error) return NextResponse.json({ error: 'Update failed' }, { status: 500 })
return NextResponse.json({ ok: true })
}
+54
View File
@@ -0,0 +1,54 @@
export const dynamic = 'force-dynamic'
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { buildJkkp8Rows, jkkp8Csv, type Jkkp8Incident } from '@/lib/reports/jkkp8'
export async function GET(request: NextRequest) {
const supabase = await createClient()
const { data: { user }, error: authError } = await supabase.auth.getUser()
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data: profile } = await supabase
.from('users').select('role').eq('id', user.id).single()
if (!profile || !['hse', 'admin'].includes(profile.role))
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
const yearParam = request.nextUrl.searchParams.get('year')
const year = Number.parseInt(yearParam ?? '', 10) || new Date().getFullYear()
const { data: incidents, error } = await supabase
.from('incidents')
.select(`
reference_no, incident_type, description, reported_at,
medical_status, lost_days,
is_fatality, is_serious_bodily_injury, is_dangerous_occurrence, is_occupational_disease,
sites (name),
zones (name),
reporter:users!reported_by (name),
dosh_reports (form_type, status, submitted_at)
`)
.gte('reported_at', `${year}-01-01T00:00:00Z`)
.lt('reported_at', `${year + 1}-01-01T00:00:00Z`)
.order('reported_at', { ascending: true })
if (error) return NextResponse.json({ error: 'Fetch failed' }, { status: 500 })
const rows = buildJkkp8Rows((incidents ?? []) as unknown as Jkkp8Incident[])
const csv = jkkp8Csv(rows)
await supabase.rpc('write_audit_log', {
p_table_name: 'incidents',
p_record_id: user.id,
p_action: 'jkkp8_register_export',
p_new_value: { year, row_count: rows.length },
})
return new NextResponse(csv, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="jkkp8-register-${year}.csv"`,
},
})
}
+118
View File
@@ -0,0 +1,118 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export type SiteWithZones = {
id: string
name: string
address: string | null
zones: Array<{ id: string; name: string; qr_code_token: string }>
}
interface Props {
sites: SiteWithZones[]
}
export function SiteZoneManager({ sites }: Props) {
const router = useRouter()
const [siteName, setSiteName] = useState('')
const [zoneName, setZoneName] = useState('')
const [zoneSiteId, setZoneSiteId] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const post = async (payload: Record<string, unknown>) => {
setBusy(true)
setError(null)
const res = await fetch('/ims/api/admin/sites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
setBusy(false)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setError(data.error ?? 'Save failed')
return false
}
router.refresh()
return true
}
return (
<div className="bg-white rounded-xl shadow-sm p-5">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Sites & Zones</h2>
<div className="flex flex-wrap gap-2 mb-3">
<input
type="text" placeholder="New site name"
value={siteName}
onChange={e => setSiteName(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-48"
/>
<button
disabled={busy || !siteName.trim()}
onClick={async () => { if (await post({ kind: 'site', name: siteName })) setSiteName('') }}
className="bg-gray-800 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-900 disabled:opacity-50"
>
Add Site
</button>
</div>
<div className="flex flex-wrap gap-2 mb-5">
<select
value={zoneSiteId}
onChange={e => setZoneSiteId(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
>
<option value="">Select site</option>
{sites.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<input
type="text" placeholder="New zone name"
value={zoneName}
onChange={e => setZoneName(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-48"
/>
<button
disabled={busy || !zoneName.trim() || !zoneSiteId}
onClick={async () => { if (await post({ kind: 'zone', name: zoneName, site_id: zoneSiteId })) setZoneName('') }}
className="bg-gray-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-700 disabled:opacity-50"
>
Add Zone
</button>
</div>
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
<div className="space-y-4">
{sites.map(site => (
<div key={site.id} className="border border-gray-100 rounded-lg p-3">
<p className="text-sm font-semibold text-gray-900">{site.name}</p>
{site.address && <p className="text-xs text-gray-400">{site.address}</p>}
{site.zones.length > 0 ? (
<ul className="mt-2 space-y-1">
{site.zones.map(z => (
<li key={z.id} className="flex items-center justify-between text-sm text-gray-600">
<span>{z.name}</span>
<a
href={`/ims/report?zone=${z.qr_code_token}`}
target="_blank"
className="text-xs text-blue-600 hover:underline"
>
Report link / QR target
</a>
</li>
))}
</ul>
) : (
<p className="text-xs text-gray-400 mt-1">No zones</p>
)}
</div>
))}
{sites.length === 0 && <p className="text-sm text-gray-400">No sites yet</p>}
</div>
</div>
)
}
+164
View File
@@ -0,0 +1,164 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { ALL_ROLES, type UserRole } from '@/lib/auth/roles'
export type AdminUser = {
id: string
name: string
email: string
role: string
department: string | null
site_id: string | null
active: boolean
}
export type SiteOption = { id: string; name: string }
interface Props {
users: AdminUser[]
sites: SiteOption[]
}
export function UserManager({ users, sites }: Props) {
const router = useRouter()
const [busyId, setBusyId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [invite, setInvite] = useState({ email: '', name: '', role: 'reporter' as UserRole, site_id: '' })
const [inviting, setInviting] = useState(false)
const patchUser = async (id: string, update: Record<string, unknown>) => {
setBusyId(id)
setError(null)
const res = await fetch('/ims/api/admin/users', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...update }),
})
setBusyId(null)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setError(data.error ?? 'Update failed')
return
}
router.refresh()
}
const sendInvite = async (e: React.FormEvent) => {
e.preventDefault()
setInviting(true)
setError(null)
const res = await fetch('/ims/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...invite, site_id: invite.site_id || undefined }),
})
setInviting(false)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setError(data.error ?? 'Invite failed')
return
}
setInvite({ email: '', name: '', role: 'reporter', site_id: '' })
router.refresh()
}
return (
<div className="bg-white rounded-xl shadow-sm p-5">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">Users</h2>
<form onSubmit={sendInvite} className="flex flex-wrap gap-2 mb-5 items-end">
<input
type="email" required placeholder="email@company.com"
value={invite.email}
onChange={e => setInvite(v => ({ ...v, email: e.target.value }))}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-52"
/>
<input
type="text" placeholder="Full name"
value={invite.name}
onChange={e => setInvite(v => ({ ...v, name: e.target.value }))}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-40"
/>
<select
value={invite.role}
onChange={e => setInvite(v => ({ ...v, role: e.target.value as UserRole }))}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
>
{ALL_ROLES.map(r => <option key={r} value={r}>{r.replace(/_/g, ' ')}</option>)}
</select>
<select
value={invite.site_id}
onChange={e => setInvite(v => ({ ...v, site_id: e.target.value }))}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
>
<option value="">No site</option>
{sites.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<button
type="submit" disabled={inviting}
className="bg-blue-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-blue-700 disabled:opacity-50"
>
{inviting ? 'Inviting…' : 'Invite User'}
</button>
</form>
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-500 uppercase tracking-wide border-b border-gray-100">
<th className="py-2 pr-3">Name</th>
<th className="py-2 pr-3">Email</th>
<th className="py-2 pr-3">Role</th>
<th className="py-2 pr-3">Site</th>
<th className="py-2">Status</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} className={`border-b border-gray-50 ${u.active ? '' : 'opacity-50'}`}>
<td className="py-2 pr-3 text-gray-900">{u.name || '—'}</td>
<td className="py-2 pr-3 text-gray-600">{u.email}</td>
<td className="py-2 pr-3">
<select
value={u.role}
disabled={busyId === u.id}
onChange={e => patchUser(u.id, { role: e.target.value })}
className="border border-gray-200 rounded px-2 py-1 text-xs"
>
{ALL_ROLES.map(r => <option key={r} value={r}>{r.replace(/_/g, ' ')}</option>)}
</select>
</td>
<td className="py-2 pr-3">
<select
value={u.site_id ?? ''}
disabled={busyId === u.id}
onChange={e => patchUser(u.id, { site_id: e.target.value || null })}
className="border border-gray-200 rounded px-2 py-1 text-xs"
>
<option value="">No site</option>
{sites.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</td>
<td className="py-2">
<button
disabled={busyId === u.id}
onClick={() => patchUser(u.id, { active: !u.active })}
className={`text-xs px-2 py-1 rounded-full font-medium ${
u.active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
}`}
>
{u.active ? 'Active' : 'Deactivated'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+1 -1
View File
@@ -23,7 +23,7 @@ export function VerifyForm({ capaId }: Props) {
}
setSaving(true)
setError(null)
const res = await fetch(`/api/capa/${capaId}/verify`, {
const res = await fetch(`/ims/api/capa/${capaId}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verdict, reopen_reason: reopenReason || null }),
+88
View File
@@ -0,0 +1,88 @@
'use client'
import { useState } from 'react'
type RiskFlag = {
zone: string
site: string
risk_level: 'low' | 'medium' | 'high'
rationale: string
recommended_action: string
}
const LEVEL_COLORS: Record<RiskFlag['risk_level'], string> = {
low: 'bg-yellow-50 text-yellow-700 border-yellow-200',
medium: 'bg-orange-50 text-orange-700 border-orange-200',
high: 'bg-red-50 text-red-700 border-red-200',
}
export function RiskFlagsPanel() {
const [flags, setFlags] = useState<RiskFlag[] | null>(null)
const [summary, setSummary] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const analyze = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/ims/api/dashboard/ai/risk-flags', { method: 'POST' })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setError(data.error ?? 'Analysis failed')
return
}
const data: { flags: RiskFlag[]; summary: string } = await res.json()
setFlags(data.flags)
setSummary(data.summary)
} catch {
setError('Analysis failed')
} finally {
setLoading(false)
}
}
return (
<div className="bg-white rounded-xl shadow-sm p-5 mb-4">
<div className="flex items-center justify-between mb-1">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Rising-Risk Zones (AI)
</h2>
<button
onClick={analyze}
disabled={loading}
className="bg-purple-50 text-purple-700 border border-purple-300 rounded-lg px-3 py-1.5 text-xs font-semibold disabled:opacity-50 hover:bg-purple-100"
>
{loading ? 'Analyzing…' : flags ? 'Re-analyze' : 'Analyze 90-Day Risk'}
</button>
</div>
<p className="text-xs text-gray-400 mb-3">
AI suggestion from 90-day incident aggregates review before acting.
</p>
{error && <p className="text-sm text-red-600">{error}</p>}
{flags && flags.length === 0 && !error && (
<p className="text-sm text-gray-500">No zones flagged no rising-risk pattern detected.</p>
)}
{flags && flags.length > 0 && (
<>
<p className="text-sm text-gray-700 mb-3">{summary}</p>
<div className="space-y-2">
{flags.map(f => (
<div key={`${f.site}-${f.zone}`} className={`border rounded-lg p-3 ${LEVEL_COLORS[f.risk_level]}`}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-semibold">{f.zone} · {f.site}</span>
<span className="text-xs font-bold uppercase">{f.risk_level}</span>
</div>
<p className="text-sm">{f.rationale}</p>
<p className="text-xs mt-1"><strong>Recommended:</strong> {f.recommended_action}</p>
</div>
))}
</div>
</>
)}
</div>
)
}
+132
View File
@@ -0,0 +1,132 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
type Addendum = {
id: string
body: string
created_at: string
author: { name: string } | null
}
interface Props {
incidentId: string
status: string
canClose: boolean
canAddAddenda: boolean
}
export function ClosurePanel({ incidentId, status, canClose, canAddAddenda }: Props) {
const router = useRouter()
const [addenda, setAddenda] = useState<Addendum[]>([])
const [draft, setDraft] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const isClosed = status === 'closed'
useEffect(() => {
if (!isClosed) return
fetch(`/ims/api/incidents/${incidentId}/addenda`)
.then(r => (r.ok ? r.json() : Promise.reject()))
.then((data: Addendum[]) => setAddenda(Array.isArray(data) ? data : []))
.catch(() => {})
}, [incidentId, isClosed])
const closeIncident = async () => {
if (!confirm('Close this incident? The record will be locked — only addenda can be added afterwards.')) return
setBusy(true)
setError(null)
const res = await fetch(`/ims/api/incidents/${incidentId}/close`, { method: 'POST' })
setBusy(false)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setError(data.error ?? 'Failed to close incident')
return
}
router.refresh()
}
const addAddendum = async () => {
const text = draft.trim()
if (!text) return
setBusy(true)
setError(null)
const res = await fetch(`/ims/api/incidents/${incidentId}/addenda`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: text }),
})
setBusy(false)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setError(data.error ?? 'Failed to add addendum')
return
}
setDraft('')
const list = await fetch(`/ims/api/incidents/${incidentId}/addenda`).then(r => r.json()).catch(() => [])
setAddenda(Array.isArray(list) ? list : [])
}
if (!isClosed && !canClose) return null
if (!isClosed) {
return (
<div className="mt-6">
{status === 'verification' ? (
<button
onClick={closeIncident}
disabled={busy}
className="bg-green-700 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-green-800 disabled:opacity-50"
>
{busy ? 'Closing…' : 'Close Incident'}
</button>
) : null}
{error && <p className="text-sm text-red-600 mt-2">{error}</p>}
</div>
)
}
return (
<div className="mt-6 bg-white rounded-xl shadow-sm p-5">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-1">Addenda</h2>
<p className="text-xs text-gray-400 mb-3">
This incident is closed and locked. New information is recorded as addenda.
</p>
{addenda.length === 0 ? (
<p className="text-sm text-gray-400">No addenda.</p>
) : (
<ul className="space-y-3">
{addenda.map(a => (
<li key={a.id} className="border border-gray-100 rounded-lg p-3">
<p className="text-sm text-gray-800 whitespace-pre-wrap">{a.body}</p>
<p className="text-xs text-gray-400 mt-1">
{a.author?.name ?? 'Unknown'} · {new Date(a.created_at).toLocaleString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })}
</p>
</li>
))}
</ul>
)}
{canAddAddenda && (
<div className="mt-4">
<textarea
value={draft}
onChange={e => setDraft(e.target.value)}
rows={3}
placeholder="Add an addendum…"
className="w-full border border-gray-200 rounded-lg p-2 text-sm"
/>
<button
onClick={addAddendum}
disabled={busy || !draft.trim()}
className="mt-2 bg-gray-800 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-900 disabled:opacity-50"
>
{busy ? 'Saving…' : 'Add Addendum'}
</button>
{error && <p className="text-sm text-red-600 mt-2">{error}</p>}
</div>
)}
</div>
)
}
+17 -1
View File
@@ -18,6 +18,13 @@ interface Props {
function isImage(type: string) { return type.startsWith('image/') }
function isVideo(type: string) { return type.startsWith('video/') }
// Supabase Storage image transform endpoint. Falls back to the original object
// URL via onError if the project plan has no image transformation.
function thumbnailUrl(url: string, width = 320): string {
if (!url.includes('/storage/v1/object/public/')) return url
return `${url.replace('/storage/v1/object/public/', '/storage/v1/render/image/public/')}?width=${width}&quality=60`
}
export function EvidenceGallery({ files, stage }: Props) {
const filtered = stage ? files.filter(f => f.stage === stage) : files
if (filtered.length === 0) return <p className="text-sm text-gray-400">No files for this stage</p>
@@ -33,7 +40,16 @@ export function EvidenceGallery({ files, stage }: Props) {
className="block rounded-lg overflow-hidden bg-gray-100 aspect-square hover:opacity-90 transition-opacity"
>
{isImage(file.file_type) ? (
<img src={file.file_url} alt="Evidence" className="w-full h-full object-cover" />
<img
src={thumbnailUrl(file.file_url)}
alt="Evidence"
loading="lazy"
className="w-full h-full object-cover"
onError={e => {
const img = e.currentTarget
if (img.src !== file.file_url) img.src = file.file_url
}}
/>
) : isVideo(file.file_type) ? (
<div className="w-full h-full flex items-center justify-center text-3xl">🎥</div>
) : (
+9
View File
@@ -39,6 +39,7 @@ export type Incident = {
lost_days: number | null
reported_at: string
closed_at: string | null
type_details?: Record<string, string | boolean> | null
sites: { id: string; name: string } | null
zones: { id: string; name: string } | null
reporter: { id: string; name: string; email: string } | null
@@ -94,6 +95,14 @@ export function IncidentDetail({ incident }: Props) {
{incident.injury_involved && incident.lost_days != null && (
<Field label="Lost days" value={`${incident.lost_days} day(s)`} />
)}
{incident.type_details &&
Object.entries(incident.type_details).map(([key, value]) => (
<Field
key={key}
label={key.replace(/_/g, ' ')}
value={typeof value === 'boolean' ? (value ? 'Yes' : 'No') : value}
/>
))}
</dl>
</div>
+46 -3
View File
@@ -37,6 +37,8 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props
)
const [findingsText, setFindingsText] = useState('')
const [rootCause, setRootCause] = useState('')
const [alcoholTest, setAlcoholTest] = useState('')
const [witnessRefs, setWitnessRefs] = useState<string[]>([''])
const [complete, setComplete] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -61,7 +63,7 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props
async function getAiDraft() {
setAiDraftLoading(true)
try {
const res = await fetch(`/api/incidents/${incidentId}/ai/rca-draft`, { method: 'POST' })
const res = await fetch(`/ims/api/incidents/${incidentId}/ai/rca-draft`, { method: 'POST' })
if (!res.ok) return
const draft = await res.json() as {
five_why_steps: Array<{ why: string; answer: string }>
@@ -92,6 +94,8 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props
method,
findings_text: findingsText || null,
root_cause_summary: rootCause || null,
alcohol_test_result: alcoholTest || null,
witness_statement_refs: witnessRefs.map(w => w.trim()).filter(Boolean),
five_why_steps: method === 'five_why' ? fiveWhy.filter(s => s.answer) : null,
fishbone_categories: method === 'fishbone'
? fishbone.map(c => ({ ...c, causes: c.causes.filter(Boolean) })).filter(c => c.causes.length > 0)
@@ -100,13 +104,13 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props
let res: Response
if (existingInvestigationId) {
res = await fetch(`/api/incidents/${incidentId}/investigation`, {
res = await fetch(`/ims/api/incidents/${incidentId}/investigation`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, investigation_id: existingInvestigationId, complete }),
})
} else {
res = await fetch(`/api/incidents/${incidentId}/investigation`, {
res = await fetch(`/ims/api/incidents/${incidentId}/investigation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -207,6 +211,45 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Alcohol / Urine Test Result</label>
<select
value={alcoholTest}
onChange={e => setAlcoholTest(e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="">Not applicable / not conducted</option>
<option value="negative">Negative</option>
<option value="positive">Positive</option>
<option value="refused">Refused</option>
<option value="pending">Result pending</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Witness Statements</label>
<p className="text-xs text-gray-400 mb-2">
Reference each statement (witness name, document ref). Upload scans as investigation-stage evidence.
</p>
{witnessRefs.map((ref, i) => (
<input
key={i}
type="text"
value={ref}
onChange={e => setWitnessRefs(witnessRefs.map((w, j) => (j === i ? e.target.value : w)))}
placeholder="e.g. Ali bin Ahmad — statement dated 12/07/2026"
className="w-full rounded border border-gray-300 px-2 py-1.5 text-sm mb-1.5"
/>
))}
<button
type="button"
onClick={() => setWitnessRefs([...witnessRefs, ''])}
className="text-xs text-blue-600 hover:underline"
>
+ Add witness statement
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Key Findings</label>
<textarea
+1
View File
@@ -27,6 +27,7 @@ export function OfflineSync() {
fd.append('injury_involved', String(report.injury_involved))
fd.append('asset_involved', String(report.asset_involved))
if (report.medical_status) fd.append('medical_status', report.medical_status)
if (report.type_details) fd.append('type_details', JSON.stringify(report.type_details))
try {
const res = await fetch('/ims/api/incidents', { method: 'POST', body: fd })
+32
View File
@@ -0,0 +1,32 @@
import Link from 'next/link'
interface Props {
page: number
pageSize: number
total: number
href: string
}
export function Pagination({ page, pageSize, total, href }: Props) {
const totalPages = Math.max(1, Math.ceil(total / pageSize))
if (totalPages <= 1) return null
const link = (p: number, label: string, disabled: boolean) =>
disabled ? (
<span className="px-3 py-1.5 text-sm text-gray-300">{label}</span>
) : (
<Link href={`${href}?page=${p}`} className="px-3 py-1.5 text-sm text-blue-600 hover:underline">
{label}
</Link>
)
return (
<nav className="flex items-center justify-between mt-4" aria-label="Pagination">
{link(page - 1, '← Previous', page <= 1)}
<span className="text-sm text-gray-500">
Page {page} of {totalPages}
</span>
{link(page + 1, 'Next →', page >= totalPages)}
</nav>
)
}
+64 -1
View File
@@ -17,6 +17,7 @@ export function ReportForm({ zoneToken }: Props) {
const t = useTranslations('ReportForm')
const itLabels = useTranslations('IncidentType')
const msLabels = useTranslations('MedicalStatus')
const tdLabels = useTranslations('TypeDetails')
const INCIDENT_TYPE_OPTIONS: [IncidentType, string][] = [
['injury', itLabels.injury],
@@ -55,6 +56,35 @@ export function ReportForm({ zoneToken }: Props) {
medical_status: '' as MedicalStatus | '',
asset_involved: false,
})
const [typeDetails, setTypeDetails] = useState<Record<string, string | boolean>>({})
const setDetail = (key: string, value: string | boolean) =>
setTypeDetails(d => ({ ...d, [key]: value }))
const detailText = (key: string, label: string, placeholder = '') => (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
<input
type="text"
value={(typeDetails[key] as string) ?? ''}
onChange={e => setDetail(key, e.target.value)}
placeholder={placeholder}
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
)
const detailCheckbox = (key: string, label: string) => (
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
className="w-4 h-4 text-blue-600"
checked={Boolean(typeDetails[key])}
onChange={e => setDetail(key, e.target.checked)}
/>
<span className="text-sm font-medium text-gray-700">{label}</span>
</label>
)
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
@@ -85,6 +115,7 @@ export function ReportForm({ zoneToken }: Props) {
injury_involved: form.injury_involved,
asset_involved: form.asset_involved,
medical_status: form.medical_status || undefined,
type_details: Object.keys(typeDetails).length > 0 ? typeDetails : undefined,
created_at: new Date().toISOString(),
})
setSavedOffline(true)
@@ -134,6 +165,9 @@ export function ReportForm({ zoneToken }: Props) {
if (form.injury_involved && form.medical_status) {
fd.append('medical_status', form.medical_status)
}
if (Object.keys(typeDetails).length > 0) {
fd.append('type_details', JSON.stringify(typeDetails))
}
files.forEach(f => fd.append('files', f))
const res = await fetch('/ims/api/incidents', { method: 'POST', body: fd })
@@ -182,7 +216,10 @@ export function ReportForm({ zoneToken }: Props) {
required
className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={form.incident_type}
onChange={e => setForm(f => ({ ...f, incident_type: e.target.value as IncidentType }))}
onChange={e => {
setForm(f => ({ ...f, incident_type: e.target.value as IncidentType }))
setTypeDetails({})
}}
>
<option value="">{t.incidentTypePlaceholder}</option>
{INCIDENT_TYPE_OPTIONS.map(([v, l]) => (
@@ -191,6 +228,32 @@ export function ReportForm({ zoneToken }: Props) {
</select>
</div>
{form.incident_type === 'environmental' && (
<div className="space-y-3 border border-gray-200 rounded-lg p-3">
{detailText('substance', tdLabels.substance, tdLabels.substancePlaceholder)}
{detailText('estimated_volume', tdLabels.estimatedVolume, tdLabels.estimatedVolumePlaceholder)}
{detailCheckbox('containment_deployed', tdLabels.containmentDeployed)}
</div>
)}
{form.incident_type === 'asset_damage' && (
<div className="space-y-3 border border-gray-200 rounded-lg p-3">
{detailText('equipment_id', tdLabels.equipmentId, tdLabels.equipmentIdPlaceholder)}
{detailCheckbox('loto_applied', tdLabels.lotoApplied)}
</div>
)}
{form.incident_type === 'security' && (
<div className="space-y-3 border border-gray-200 rounded-lg p-3">
{detailText('persons_involved', tdLabels.personsInvolved, tdLabels.personsInvolvedPlaceholder)}
{detailCheckbox('police_reported', tdLabels.policeReported)}
</div>
)}
{form.incident_type === 'fire' && (
<div className="space-y-3 border border-gray-200 rounded-lg p-3">
{detailCheckbox('alarm_raised', tdLabels.alarmRaised)}
{detailCheckbox('fire_brigade_called', tdLabels.fireBrigadeCalled)}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t.descriptionLabel} <span className="text-red-500">*</span>
@@ -31,7 +31,7 @@ export function SimilarIncidentsPanel({ incidentId }: Props) {
const [error, setError] = useState(false)
useEffect(() => {
fetch(`/api/incidents/${incidentId}/similar`)
fetch(`/ims/api/incidents/${incidentId}/similar`)
.then(r => {
if (!r.ok) throw new Error('failed')
return r.json()
+2 -2
View File
@@ -42,7 +42,7 @@ export function TriageForm({ incidentId, currentSeverity }: Props) {
setAiLoading(true)
setAiRationale(null)
try {
const res = await fetch(`/api/incidents/${incidentId}/ai/triage-suggest`, { method: 'POST' })
const res = await fetch(`/ims/api/incidents/${incidentId}/ai/triage-suggest`, { method: 'POST' })
if (!res.ok) return
const data = await res.json() as {
severity: number
@@ -69,7 +69,7 @@ export function TriageForm({ incidentId, currentSeverity }: Props) {
e.preventDefault()
setSaving(true)
setError(null)
const res = await fetch(`/api/incidents/${incidentId}/triage`, {
const res = await fetch(`/ims/api/incidents/${incidentId}/triage`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
+118
View File
@@ -0,0 +1,118 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
type Notification = {
id: string
title: string | null
link: string | null
sent_at: string
read_at: string | null
}
const POLL_MS = 60_000
export function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([])
const [unread, setUnread] = useState(0)
const [open, setOpen] = useState(false)
const panelRef = useRef<HTMLDivElement>(null)
const load = useCallback(() => {
fetch('/ims/api/notifications')
.then(r => (r.ok ? r.json() : Promise.reject()))
.then((data: { notifications: Notification[]; unread: number }) => {
setNotifications(data.notifications)
setUnread(data.unread)
})
.catch(() => {})
}, [])
useEffect(() => {
load()
const id = setInterval(load, POLL_MS)
return () => clearInterval(id)
}, [load])
useEffect(() => {
if (!open) return
const onClick = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onClick)
return () => document.removeEventListener('mousedown', onClick)
}, [open])
const markAllRead = () => {
fetch('/ims/api/notifications', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ all: true }),
})
.then(() => {
setUnread(0)
setNotifications(prev => prev.map(n => ({ ...n, read_at: n.read_at ?? new Date().toISOString() })))
})
.catch(() => {})
}
return (
<div ref={panelRef} className="fixed top-3 right-3 z-50">
<button
onClick={() => setOpen(o => !o)}
aria-label={`Notifications${unread > 0 ? ` (${unread} unread)` : ''}`}
className="relative bg-white rounded-full shadow-sm border border-gray-200 w-10 h-10 flex items-center justify-center hover:shadow-md transition-shadow"
>
<span className="text-lg" aria-hidden>🔔</span>
{unread > 0 && (
<span className="absolute -top-1 -right-1 bg-red-600 text-white text-[10px] font-bold rounded-full min-w-[18px] h-[18px] px-1 flex items-center justify-center">
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
{open && (
<div className="absolute right-0 mt-2 w-80 max-h-96 overflow-y-auto bg-white rounded-xl shadow-lg border border-gray-200">
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-100">
<span className="text-sm font-semibold text-gray-700">Notifications</span>
{unread > 0 && (
<button onClick={markAllRead} className="text-xs text-blue-600 hover:underline">
Mark all read
</button>
)}
</div>
{notifications.length === 0 ? (
<p className="px-4 py-6 text-sm text-gray-400 text-center">No notifications</p>
) : (
<ul>
{notifications.map(n => {
const inner = (
<>
<p className={`text-sm ${n.read_at ? 'text-gray-500' : 'text-gray-900 font-medium'}`}>
{n.title ?? 'Notification'}
</p>
<p className="text-xs text-gray-400 mt-0.5">
{new Date(n.sent_at).toLocaleString()}
</p>
</>
)
return (
<li key={n.id} className="border-b border-gray-50 last:border-0">
{n.link ? (
<Link href={n.link} className="block px-4 py-3 hover:bg-gray-50" onClick={() => setOpen(false)}>
{inner}
</Link>
) : (
<div className="px-4 py-3">{inner}</div>
)}
</li>
)
})}
</ul>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,107 @@
# Phase 5 & 6 — Usability, Compliance Hardening, Analytics
## Context
Phases 04 complete. Gap audit against PRD (`docs/01_PRD_HSE_Incident_Management_System.md`) found 10 specced features missing or partial. User approved all four gap groups: core usability, compliance hardening, evidence UX, and analytics. Split into **Phase 5** (usability + compliance + evidence) and **Phase 6** (analytics/AI), matching the existing phase-plan convention in `docs/superpowers/plans/`.
Verified current state (Explore audit, 2026-07-11):
- `notifications_log` table exists (`supabase/migrations/20260709000006_notifications_audit.sql`) — no UI
- `app/(protected)/admin/page.tsx` is a stub
- `app/api/incidents/[id]/route.ts` has no PATCH; no closure lock
- `app/api/incidents/[id]/jkkp-pdf/route.ts` supports jkkp6/jkkp7 only; `lib/incidents/dosh.ts:45` already computes `requires_jkkp8`
- `components/incidents/report-form.tsx` branches only on `injury_involved`
- Investigation schema has `alcohol_test_result`, `witness_statement_refs` (`migrations/...004:27-28`) — no UI
- `app/(protected)/hse/incidents/page.tsx:18` hard `.limit(100)`, no pagination
- `lib/supabase/storage.ts` stores SHA-256 `file_hash`; no thumbnails
- Dashboard has type/site breakdown + zone heatmap; no time-series, no root-cause trend
## Conventions to follow (established, do not deviate)
- Every DB mutation writes `audit_log`; RLS at DB level for all new tables/columns
- AI routes: `claude-opus-4-8`, `thinking: {type:"adaptive"}`, forced `tool_choice`, validate tool output, 503 on model failure; `getApiKey(supabase, key)` (`lib/settings.ts`) before client construction (`lib/claude/client.ts`)
- Notifications: reuse `lib/notifications/email.ts`, `lib/notifications/whatsapp.ts`
- i18n: add strings to `lib/i18n/locales.ts` (EN/MS/ZH)
- Client fetches prefixed `/ims` basePath
- TDD per repo practice; tests in `tests/` mirroring lib paths (61 passing currently)
---
## Phase 5 — Usability & Compliance Completion
### Task 1: In-app notification bell + badge
- Migration: extend `notifications_log` if needed (add `read_at TIMESTAMPTZ`, index on recipient+read)
- `app/api/notifications/route.ts` — GET unread list (RLS: own rows), POST mark-read
- `components/notifications/bell.tsx` — badge count, dropdown list; mount in `app/(protected)/layout.tsx`
- Write in-app rows at the same points email/WhatsApp fire (new incident, CAPA assign/escalate, verification)
### Task 2: Admin user + site/zone management
- Replace stub `app/(protected)/admin/page.tsx`
- User list: invite (Supabase admin API server-side), role assign, deactivate — admin-only RLS + server-side role check via `lib/auth/roles.ts`
- Site/zone CRUD against `sites`/`zones` tables (migration 001)
- All mutations audit-logged
### Task 3: Incident closure lock + addenda
- Migration: `incident_addenda` table (incident_id, author, body, created_at) with RLS
- Add PATCH guard in `app/api/incidents/[id]/route.ts` and triage/investigation/capa routes: reject mutation when `closed_at IS NOT NULL` (DB trigger preferred — belt and braces)
- Addenda UI on `components/incidents/incident-detail.tsx` for closed incidents
### Task 4: JKKP 8 annual register export
- `lib/pdf/jkkp8.ts` (or Excel via existing export route pattern `app/api/dashboard/export/route.ts`) — annual register of all reportable incidents for a chosen year
- `app/api/reports/jkkp8/route.ts` — HSE/admin only; reuse `lib/incidents/dosh.ts` flags
- Download button on HSE dashboard; reminder banner in January while register unsubmitted
### Task 5: Type-specific intake forms
- Extend `components/incidents/report-form.tsx`: conditional field groups per type — environmental (spill volume/substance/containment), MHE/asset (equipment id, LOTO applied), security, fire; near-miss stays minimal by design
- Store in existing JSONB detail column if present, else migration adds one
- Validate in `lib/incidents/validate.ts`; translate new labels
### Task 6: Witness statement + alcohol test UI
- Add fields to `components/incidents/investigation-form.tsx` wired to existing `alcohol_test_result`, `witness_statement_refs` columns
- Witness statement file uploads reuse `components/incidents/file-upload.tsx` with stage tag
### Task 7: Incident list pagination
- Server-side range pagination in `app/(protected)/hse/incidents/page.tsx` + `components/incidents/incident-list.tsx` (searchParams page/pageSize, `.range()`, count)
- Same for supervisor incident list
### Task 8: Evidence thumbnails
- Supabase Storage image transform (`getPublicUrl` with `transform: {width}`) for image types in `components/incidents/evidence-gallery.tsx`; icon fallback for video/pdf/doc
- No new infra if Supabase transform available on plan; else client-side `next/image` sizing of signed URL
### Task 9: Housekeeping
- Delete stray duplicates `lib/notifications/whatsapp 2.ts`, `tests/lib/notifications/whatsapp.test 2.ts` (verify identical/stale vs canonical first)
---
## Phase 6 — Analytics & Predictive Safety
### Task 1: Dashboard time-series
- Extend `app/api/dashboard/stats/route.ts`: monthly incident counts by type (12 mo), leading vs lagging trend, CAPA on-time trend
- Chart components on `app/(protected)/hse/dashboard/page.tsx` (lightweight — no heavy chart lib unless one already present)
### Task 2: Top root cause trended
- Aggregate `root_cause_category` (phase-2 RCA data) by month; top-5 table + trend on dashboard
### Task 3: AI rising-risk heatmap
- `app/api/dashboard/ai/risk-flags/route.ts` — server-side Claude call per AI-route conventions; input: 90d near-miss + incident + zone aggregates; output: flagged zones/shifts with rationale (forced tool_use schema)
- Panel on management + HSE dashboards, clearly labelled as AI suggestion; log suggestion to `audit_log` per convention
---
## Verification
- `npm test` — all existing 61 tests plus new unit tests per task (escalation-style pattern in `tests/lib/`)
- Manual: run app, walk lifecycle — report (each type) → triage → investigate (witness/alcohol) → CAPA → verify → close → confirm lock + addenda; bell badge increments; admin creates user + zone; JKKP 8 downloads; pagination past 100 rows (seed if needed)
- `graphify update .` after code changes
- Update `docs/superpowers/plans/` with phase-5/6 plan docs and memory `phases.md` on completion
---
## Completion Record (2026-07-12)
All Phase 5 and Phase 6 tasks implemented on branch `phase-5-6`. 112 tests passing (23 files), `tsc --noEmit` clean, `next build` clean.
Notable deviations/additions vs plan:
- Incident **close endpoint did not exist at all** — added `POST /api/incidents/[id]/close` (verification status + all CAPAs verified required) alongside the lock.
- In-app notification inserts go through `create_in_app_notification` SECURITY DEFINER RPC (reporters are not covered by the elevated INSERT policy).
- Fixed 9 pre-existing missing `/ims` basePath prefixes across fetch calls and download links (similar-incidents-panel, investigation-form ×3, triage-form ×2, verify-form, JKKP PDF links ×2, dashboard/management export links).
- New migrations: 20260712000016_in_app_notifications, 20260712000017_closure_lock_addenda, 20260712000018_type_details.
+70
View File
@@ -0,0 +1,70 @@
export interface MonthlyBucket {
month: string // YYYY-MM
label: string // e.g. "Jul"
total: number
leading: number // hazard + near_miss
lagging: number // injury
}
const LEADING_TYPES = ['hazard', 'near_miss']
export function bucketIncidentsByMonth(
incidents: Array<{ reported_at: string; incident_type: string }>,
months = 12,
now = new Date(),
): MonthlyBucket[] {
const buckets: MonthlyBucket[] = []
const index = new Map<string, MonthlyBucket>()
for (let i = months - 1; i >= 0; i--) {
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1))
const month = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`
const bucket: MonthlyBucket = {
month,
label: d.toLocaleString('en', { month: 'short', timeZone: 'UTC' }),
total: 0,
leading: 0,
lagging: 0,
}
buckets.push(bucket)
index.set(month, bucket)
}
for (const inc of incidents) {
const month = inc.reported_at.slice(0, 7)
const bucket = index.get(month)
if (!bucket) continue
bucket.total++
if (LEADING_TYPES.includes(inc.incident_type)) bucket.leading++
if (inc.incident_type === 'injury') bucket.lagging++
}
return buckets
}
export interface RootCauseCount {
cause: string
count: number
}
// Root causes are free text (investigations.root_cause_summary); group on a
// normalized form so trivially different phrasings still collapse together.
export function topRootCauses(
investigations: Array<{ root_cause_summary: string | null }>,
top = 5,
): RootCauseCount[] {
const counts = new Map<string, { cause: string; count: number }>()
for (const inv of investigations) {
const raw = (inv.root_cause_summary ?? '').trim()
if (!raw) continue
const key = raw.toLowerCase().replace(/\s+/g, ' ').replace(/[.。]$/, '')
const entry = counts.get(key)
if (entry) entry.count++
else counts.set(key, { cause: raw, count: 1 })
}
return [...counts.values()]
.sort((a, b) => b.count - a.count)
.slice(0, top)
}
+55
View File
@@ -11,6 +11,61 @@ export interface IncidentInput {
injury_involved: boolean
medical_status?: MedicalStatus
asset_involved: boolean
type_details?: Record<string, unknown>
}
// Whitelisted type-specific intake fields per incident type (PRD §3).
// 's' = free text, 'b' = boolean. Near miss / hazard / injury stay minimal by design.
export const TYPE_DETAIL_FIELDS: Partial<Record<IncidentType, Record<string, 's' | 'b'>>> = {
environmental: { substance: 's', estimated_volume: 's', containment_deployed: 'b' },
asset_damage: { equipment_id: 's', loto_applied: 'b' },
security: { persons_involved: 's', police_reported: 'b' },
fire: { alarm_raised: 'b', fire_brigade_called: 'b' },
}
export function validateTypeDetails(
incidentType: IncidentType,
details: Record<string, unknown> | undefined,
): { ok: boolean; errors: string[]; sanitized: Record<string, unknown> | null } {
if (details == null || Object.keys(details).length === 0) {
return { ok: true, errors: [], sanitized: null }
}
const allowed = TYPE_DETAIL_FIELDS[incidentType]
if (!allowed) {
return { ok: false, errors: [`type_details not allowed for incident_type ${incidentType}`], sanitized: null }
}
const errors: string[] = []
const sanitized: Record<string, unknown> = {}
for (const [key, value] of Object.entries(details)) {
const kind = allowed[key]
if (!kind) {
errors.push(`unknown type_details field: ${key}`)
continue
}
if (kind === 's') {
if (typeof value !== 'string') {
errors.push(`${key} must be a string`)
continue
}
const trimmed = value.trim()
if (trimmed) sanitized[key] = trimmed.slice(0, 500)
} else {
if (typeof value !== 'boolean') {
errors.push(`${key} must be a boolean`)
continue
}
sanitized[key] = value
}
}
return {
ok: errors.length === 0,
errors,
sanitized: Object.keys(sanitized).length > 0 ? sanitized : null,
}
}
export function validateIncidentInput(input: IncidentInput): { ok: boolean; errors: string[] } {
+13 -1
View File
@@ -1,6 +1,7 @@
import { Resend } from 'resend'
import type { SupabaseClient } from '@supabase/supabase-js'
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
import { createInAppNotifications } from '@/lib/notifications/in-app'
import { getApiKey } from '@/lib/settings'
export type EscalationThreshold = 'warning_3d' | 'due_today' | 'overdue_3d' | 'overdue_7d'
@@ -32,7 +33,7 @@ export async function escalateOverdueCapa(
const { data: capas } = await supabase
.from('capa_actions')
.select(`
id, description, due_date, incident_id,
id, description, due_date, incident_id, owner_user_id,
incidents (reference_no, site_id),
owner:users!owner_user_id (email, name, phone)
`)
@@ -121,6 +122,17 @@ export async function escalateOverdueCapa(
status: threshold,
})
const ownerUserId = (capa as { owner_user_id: string | null }).owner_user_id
if (ownerUserId) {
await createInAppNotifications(supabase, [{
userId: ownerUserId,
title: `${THRESHOLD_SUBJECT[threshold].replace('[IMS] ', '')}${incidentRef}`,
link: `/hse/capa/${capa.id}`,
incidentId: capa.incident_id as string,
capaId: capa.id as string,
}])
}
notified++
}
+42
View File
@@ -0,0 +1,42 @@
import type { SupabaseClient } from '@supabase/supabase-js'
export interface InAppNotification {
userId: string
title: string
link?: string
incidentId?: string
capaId?: string
}
// Inserts go through the create_in_app_notification SECURITY DEFINER RPC:
// notifications_log INSERT is RLS-restricted to elevated roles, but reporters
// must still be able to trigger alerts to supervisors/HSE.
export async function createInAppNotifications(
supabase: SupabaseClient,
notifications: InAppNotification[],
): Promise<{ created: number }> {
const seen = new Set<string>()
let created = 0
for (const n of notifications) {
if (!n.userId || !n.title) continue
const key = `${n.userId}|${n.title}|${n.incidentId ?? ''}|${n.capaId ?? ''}`
if (seen.has(key)) continue
seen.add(key)
const { error } = await supabase.rpc('create_in_app_notification', {
p_recipient: n.userId,
p_title: n.title,
p_link: n.link ?? null,
p_incident_id: n.incidentId ?? null,
p_capa_id: n.capaId ?? null,
})
if (error) {
console.error('in-app notification error:', error)
continue
}
created++
}
return { created }
}
+1
View File
@@ -8,6 +8,7 @@ export interface PendingReport {
injury_involved: boolean
asset_involved: boolean
medical_status?: string
type_details?: Record<string, string | boolean>
created_at: string
}
+96
View File
@@ -0,0 +1,96 @@
import { computeDoshObligation } from '@/lib/incidents/dosh'
export interface Jkkp8Incident {
reference_no: string | null
incident_type: string
description: string
reported_at: string
medical_status: string | null
lost_days: number | null
is_fatality: boolean
is_serious_bodily_injury: boolean
is_dangerous_occurrence: boolean
is_occupational_disease: boolean
sites: { name: string } | null
zones: { name: string } | null
reporter: { name: string } | null
dosh_reports: Array<{ form_type: string; status: string; submitted_at: string | null }>
}
export interface Jkkp8Row {
reference: string
date: string
site: string
zone: string
incident_type: string
reported_by: string
description: string
medical_status: string
lost_days: string
obligation: string
filing_status: string
}
// JKKP 8 annual register: every incident with any NADOPOD obligation for the
// year (PRD §9 — "Any of the above → also logged in the JKKP 8 annual register").
export function buildJkkp8Rows(incidents: Jkkp8Incident[]): Jkkp8Row[] {
const rows: Jkkp8Row[] = []
for (const inc of incidents) {
const obligation = computeDoshObligation(inc)
const reportable =
obligation.requires_jkkp6 || obligation.requires_jkkp7 || obligation.requires_jkkp8
if (!reportable) continue
const filings = inc.dosh_reports ?? []
const filingStatus =
filings.length === 0
? 'pending'
: filings
.map(f => `${f.form_type.toUpperCase()}: ${f.status}${f.submitted_at ? ` (${f.submitted_at.split('T')[0]})` : ''}`)
.join('; ')
rows.push({
reference: inc.reference_no ?? '',
date: inc.reported_at.split('T')[0],
site: inc.sites?.name ?? '',
zone: inc.zones?.name ?? '',
incident_type: inc.incident_type,
reported_by: inc.reporter?.name ?? '',
description: inc.description,
medical_status: inc.medical_status ?? '',
lost_days: String(inc.lost_days ?? ''),
obligation: obligation.reasons.join('; '),
filing_status: filingStatus,
})
}
return rows
}
export const JKKP8_HEADERS = [
'Reference', 'Date', 'Site', 'Zone', 'Incident Type', 'Reported By',
'Description', 'Medical Status', 'Lost Days', 'NADOPOD Obligation', 'DOSH Filing Status',
]
export function escapeCsv(value: string | number | null | undefined): string {
if (value === null || value === undefined) return ''
const str = String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
export function jkkp8Csv(rows: Jkkp8Row[]): string {
const lines = [JKKP8_HEADERS.map(escapeCsv).join(',')]
for (const r of rows) {
lines.push(
[
r.reference, r.date, r.site, r.zone, r.incident_type, r.reported_by,
r.description, r.medical_status, r.lost_days, r.obligation, r.filing_status,
].map(escapeCsv).join(','),
)
}
return lines.join('\r\n')
}
+14
View File
@@ -0,0 +1,14 @@
import { createClient as createSupabaseClient, type SupabaseClient } from '@supabase/supabase-js'
// Service-role client — bypasses RLS. Server-side only, and only for operations
// the anon client cannot perform (auth admin user invites). Never import in client code.
export function createAdminClient(): SupabaseClient {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!url || !serviceRoleKey) {
throw new Error('SUPABASE_SERVICE_ROLE_KEY not configured')
}
return createSupabaseClient(url, serviceRoleKey, {
auth: { autoRefreshToken: false, persistSession: false },
})
}
+16 -1
View File
@@ -32,5 +32,20 @@
"first_aid": "First aid only",
"medical_treatment": "Medical treatment (non-LTI)",
"lti": "Lost Time Injury (LTI)"
},
"TypeDetails": {
"substance": "Substance / material spilled",
"substancePlaceholder": "e.g. diesel, hydraulic oil",
"estimatedVolume": "Estimated volume",
"estimatedVolumePlaceholder": "e.g. 20 litres",
"containmentDeployed": "Spill kit / containment deployed",
"equipmentId": "Equipment ID",
"equipmentIdPlaceholder": "e.g. FLT-03, Dock 5 leveller",
"lotoApplied": "Emergency shutdown / LOTO applied",
"personsInvolved": "Person(s) involved",
"personsInvolvedPlaceholder": "Names or description",
"policeReported": "Reported to police",
"alarmRaised": "Fire alarm raised",
"fireBrigadeCalled": "Fire brigade (BOMBA) called"
}
}
}
+16 -1
View File
@@ -32,5 +32,20 @@
"first_aid": "Pertolongan cemas sahaja",
"medical_treatment": "Rawatan perubatan (bukan LTI)",
"lti": "Kecederaan Masa Hilang (LTI)"
},
"TypeDetails": {
"substance": "Bahan yang tertumpah",
"substancePlaceholder": "cth. diesel, minyak hidraulik",
"estimatedVolume": "Anggaran isipadu",
"estimatedVolumePlaceholder": "cth. 20 liter",
"containmentDeployed": "Kit tumpahan / pembendungan digunakan",
"equipmentId": "ID peralatan",
"equipmentIdPlaceholder": "cth. FLT-03, leveller Dok 5",
"lotoApplied": "Penutupan kecemasan / LOTO dilaksanakan",
"personsInvolved": "Individu terlibat",
"personsInvolvedPlaceholder": "Nama atau keterangan",
"policeReported": "Dilaporkan kepada polis",
"alarmRaised": "Penggera kebakaran dibunyikan",
"fireBrigadeCalled": "Bomba dipanggil"
}
}
}
+16 -1
View File
@@ -32,5 +32,20 @@
"first_aid": "仅急救",
"medical_treatment": "医疗治疗(非 LTI",
"lti": "工伤失时(LTI"
},
"TypeDetails": {
"substance": "泄漏物质/材料",
"substancePlaceholder": "例如:柴油、液压油",
"estimatedVolume": "估计数量",
"estimatedVolumePlaceholder": "例如:20升",
"containmentDeployed": "已使用泄漏应急包/围堵措施",
"equipmentId": "设备编号",
"equipmentIdPlaceholder": "例如:FLT-03、5号月台调节板",
"lotoApplied": "已执行紧急停机/上锁挂牌 (LOTO)",
"personsInvolved": "涉及人员",
"personsInvolvedPlaceholder": "姓名或描述",
"policeReported": "已报警",
"alarmRaised": "已拉响火警警报",
"fireBrigadeCalled": "已呼叫消防局 (BOMBA)"
}
}
}
@@ -0,0 +1,50 @@
-- supabase/migrations/20260712000016_in_app_notifications.sql
-- Phase 5: in-app notification bell — read state, content columns, per-user RLS,
-- and a SECURITY DEFINER writer so non-elevated reporters can trigger alerts.
ALTER TABLE notifications_log
ADD COLUMN recipient_user_id UUID REFERENCES users(id),
ADD COLUMN read_at TIMESTAMPTZ,
ADD COLUMN title TEXT,
ADD COLUMN link TEXT;
CREATE INDEX notifications_in_app_unread_idx
ON notifications_log (recipient_user_id, sent_at DESC)
WHERE channel = 'in_app' AND read_at IS NULL;
-- Users see their own in-app notifications (elevated read policy already exists)
CREATE POLICY "notifications_read_own" ON notifications_log
FOR SELECT
USING (recipient_user_id = auth.uid());
-- Users may only mark their own notifications read
CREATE POLICY "notifications_update_own" ON notifications_log
FOR UPDATE
USING (recipient_user_id = auth.uid())
WITH CHECK (recipient_user_id = auth.uid());
-- Writer RPC: INSERT policy on notifications_log is elevated-roles-only, but a
-- reporter submitting an incident must notify supervisors/HSE. Mirrors write_audit_log.
CREATE OR REPLACE FUNCTION public.create_in_app_notification(
p_recipient UUID,
p_title TEXT,
p_link TEXT DEFAULT NULL,
p_incident_id UUID DEFAULT NULL,
p_capa_id UUID DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF auth.uid() IS NULL THEN
RAISE EXCEPTION 'authentication required';
END IF;
INSERT INTO public.notifications_log
(channel, recipient, recipient_user_id, title, link, incident_id, capa_id)
VALUES
('in_app', p_recipient::text, p_recipient, p_title, p_link, p_incident_id, p_capa_id);
END;
$$;
@@ -0,0 +1,57 @@
-- supabase/migrations/20260712000017_closure_lock_addenda.sql
-- Phase 5: closed incidents become immutable (addenda-only), per PRD §4 Phase 4.
CREATE TABLE incident_addenda (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
incident_id UUID NOT NULL REFERENCES incidents(id),
author UUID NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX incident_addenda_incident_idx ON incident_addenda(incident_id);
ALTER TABLE incident_addenda ENABLE ROW LEVEL SECURITY;
CREATE POLICY "addenda_read" ON incident_addenda
FOR SELECT
USING (
auth_user_role() IN ('hse', 'admin', 'supervisor', 'management')
OR EXISTS (
SELECT 1 FROM incidents i
WHERE i.id = incident_id AND i.reported_by = auth.uid()
)
);
CREATE POLICY "addenda_insert" ON incident_addenda
FOR INSERT
WITH CHECK (
author = auth.uid()
AND auth_user_role() IN ('hse', 'admin', 'supervisor')
);
-- No UPDATE/DELETE policies: addenda are append-only.
-- DB-level lock: once closed, the incident row cannot change or be deleted.
CREATE OR REPLACE FUNCTION public.prevent_closed_incident_change()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RAISE EXCEPTION 'incident % is closed and locked — add an addendum instead', OLD.id;
END;
$$;
CREATE TRIGGER incidents_closed_lock_update
BEFORE UPDATE ON incidents
FOR EACH ROW
WHEN (OLD.status = 'closed')
EXECUTE FUNCTION prevent_closed_incident_change();
CREATE TRIGGER incidents_closed_lock_delete
BEFORE DELETE ON incidents
FOR EACH ROW
WHEN (OLD.status = 'closed')
EXECUTE FUNCTION prevent_closed_incident_change();
@@ -0,0 +1,5 @@
-- supabase/migrations/20260712000018_type_details.sql
-- Phase 5: type-specific intake fields (PRD §3 — each incident type has its own
-- lightweight form). Stored as validated JSONB; keys whitelisted in app code.
ALTER TABLE incidents ADD COLUMN type_details JSONB;
+56
View File
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest'
import { bucketIncidentsByMonth, topRootCauses } from '@/lib/dashboard/trends'
describe('bucketIncidentsByMonth', () => {
const now = new Date('2026-07-15T12:00:00Z')
it('returns one bucket per month, oldest first', () => {
const buckets = bucketIncidentsByMonth([], 3, now)
expect(buckets.map(b => b.month)).toEqual(['2026-05', '2026-06', '2026-07'])
})
it('counts leading and lagging types into the right month', () => {
const buckets = bucketIncidentsByMonth(
[
{ reported_at: '2026-07-01T08:00:00Z', incident_type: 'near_miss' },
{ reported_at: '2026-07-02T08:00:00Z', incident_type: 'hazard' },
{ reported_at: '2026-07-03T08:00:00Z', incident_type: 'injury' },
{ reported_at: '2026-06-03T08:00:00Z', incident_type: 'fire' },
],
3,
now,
)
const july = buckets.find(b => b.month === '2026-07')!
expect(july).toMatchObject({ total: 3, leading: 2, lagging: 1 })
expect(buckets.find(b => b.month === '2026-06')).toMatchObject({ total: 1, leading: 0, lagging: 0 })
})
it('ignores incidents outside the window', () => {
const buckets = bucketIncidentsByMonth(
[{ reported_at: '2025-01-01T08:00:00Z', incident_type: 'injury' }],
3,
now,
)
expect(buckets.every(b => b.total === 0)).toBe(true)
})
})
describe('topRootCauses', () => {
it('groups normalized duplicates and sorts by count', () => {
const result = topRootCauses([
{ root_cause_summary: 'Inadequate forklift training.' },
{ root_cause_summary: 'inadequate forklift training' },
{ root_cause_summary: 'Blocked walkway' },
{ root_cause_summary: null },
{ root_cause_summary: '' },
])
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({ count: 2 })
expect(result[0].cause.toLowerCase()).toContain('forklift')
})
it('limits to top N', () => {
const invs = ['a', 'b', 'c', 'd', 'e', 'f'].map(c => ({ root_cause_summary: c }))
expect(topRootCauses(invs, 5)).toHaveLength(5)
})
})
+40 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { validateIncidentInput, type IncidentInput } from '@/lib/incidents/validate'
import { validateIncidentInput, validateTypeDetails, type IncidentInput } from '@/lib/incidents/validate'
const valid: IncidentInput = {
zone_token: 'scw1-dock-a-qr-2026',
@@ -43,3 +43,42 @@ describe('validateIncidentInput', () => {
expect(result.errors).toContain('incident_type is invalid')
})
})
describe('validateTypeDetails', () => {
it('accepts valid environmental fields and trims strings', () => {
const r = validateTypeDetails('environmental', {
substance: ' diesel ',
containment_deployed: true,
})
expect(r.ok).toBe(true)
expect(r.sanitized).toEqual({ substance: 'diesel', containment_deployed: true })
})
it('returns null sanitized when details empty or undefined', () => {
expect(validateTypeDetails('fire', undefined).sanitized).toBeNull()
expect(validateTypeDetails('fire', {}).sanitized).toBeNull()
})
it('rejects unknown fields', () => {
const r = validateTypeDetails('asset_damage', { equipment_id: 'FLT-3', bogus: 'x' })
expect(r.ok).toBe(false)
expect(r.errors[0]).toContain('bogus')
})
it('rejects wrong value types', () => {
const r = validateTypeDetails('security', { police_reported: 'yes' })
expect(r.ok).toBe(false)
expect(r.errors[0]).toContain('boolean')
})
it('rejects details for types without extra fields', () => {
const r = validateTypeDetails('near_miss', { substance: 'oil' })
expect(r.ok).toBe(false)
})
it('drops empty strings from sanitized output', () => {
const r = validateTypeDetails('asset_damage', { equipment_id: ' ', loto_applied: false })
expect(r.ok).toBe(true)
expect(r.sanitized).toEqual({ loto_applied: false })
})
})
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createInAppNotifications } from '@/lib/notifications/in-app'
import type { SupabaseClient } from '@supabase/supabase-js'
function makeSupabaseMock(rpcResult: { error: unknown } = { error: null }) {
return {
rpc: vi.fn().mockResolvedValue(rpcResult),
} as unknown as SupabaseClient
}
describe('createInAppNotifications', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('calls create_in_app_notification RPC once per recipient', async () => {
const supabase = makeSupabaseMock()
const { created } = await createInAppNotifications(supabase, [
{ userId: 'u1', title: 'New incident', link: '/hse/incidents/i1', incidentId: 'i1' },
{ userId: 'u2', title: 'New incident', link: '/hse/incidents/i1', incidentId: 'i1' },
])
expect(created).toBe(2)
expect(supabase.rpc).toHaveBeenCalledTimes(2)
expect(supabase.rpc).toHaveBeenCalledWith('create_in_app_notification', {
p_recipient: 'u1',
p_title: 'New incident',
p_link: '/hse/incidents/i1',
p_incident_id: 'i1',
p_capa_id: null,
})
})
it('skips entries missing userId or title', async () => {
const supabase = makeSupabaseMock()
const { created } = await createInAppNotifications(supabase, [
{ userId: '', title: 'x' },
{ userId: 'u1', title: '' },
])
expect(created).toBe(0)
expect(supabase.rpc).not.toHaveBeenCalled()
})
it('counts only successful inserts when RPC errors', async () => {
const supabase = makeSupabaseMock({ error: { message: 'boom' } })
const { created } = await createInAppNotifications(supabase, [
{ userId: 'u1', title: 'x' },
])
expect(created).toBe(0)
})
it('deduplicates recipients for the same notification', async () => {
const supabase = makeSupabaseMock()
const { created } = await createInAppNotifications(supabase, [
{ userId: 'u1', title: 'same', incidentId: 'i1' },
{ userId: 'u1', title: 'same', incidentId: 'i1' },
])
expect(created).toBe(1)
expect(supabase.rpc).toHaveBeenCalledTimes(1)
})
})
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest'
import { buildJkkp8Rows, jkkp8Csv, type Jkkp8Incident } from '@/lib/reports/jkkp8'
function makeIncident(overrides: Partial<Jkkp8Incident> = {}): Jkkp8Incident {
return {
reference_no: 'SETIA-202607-0001',
incident_type: 'injury',
description: 'Fell from ladder',
reported_at: '2026-07-01T08:00:00Z',
medical_status: 'lti',
lost_days: 5,
is_fatality: false,
is_serious_bodily_injury: false,
is_dangerous_occurrence: false,
is_occupational_disease: false,
sites: { name: 'Setia Alam' },
zones: { name: 'Dock 3' },
reporter: { name: 'Ali' },
dosh_reports: [],
...overrides,
}
}
describe('buildJkkp8Rows', () => {
it('includes incidents with lost_days >= 4', () => {
const rows = buildJkkp8Rows([makeIncident()])
expect(rows).toHaveLength(1)
expect(rows[0].obligation).toContain('Lost-time injury')
expect(rows[0].filing_status).toBe('pending')
})
it('excludes non-reportable incidents', () => {
const rows = buildJkkp8Rows([
makeIncident({ lost_days: 1, medical_status: 'first_aid' }),
])
expect(rows).toHaveLength(0)
})
it('includes occupational disease (JKKP 7/8 path)', () => {
const rows = buildJkkp8Rows([
makeIncident({ lost_days: 0, is_occupational_disease: true }),
])
expect(rows).toHaveLength(1)
expect(rows[0].obligation).toContain('Occupational disease')
})
it('summarises dosh filing status when reports exist', () => {
const rows = buildJkkp8Rows([
makeIncident({
dosh_reports: [
{ form_type: 'jkkp6', status: 'submitted', submitted_at: '2026-07-03T10:00:00Z' },
],
}),
])
expect(rows[0].filing_status).toBe('JKKP6: submitted (2026-07-03)')
})
})
describe('jkkp8Csv', () => {
it('escapes commas and quotes in descriptions', () => {
const rows = buildJkkp8Rows([
makeIncident({ description: 'Slip, near "dock" area' }),
])
const csv = jkkp8Csv(rows)
expect(csv).toContain('"Slip, near ""dock"" area"')
expect(csv.split('\r\n')).toHaveLength(2)
})
})