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
+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>
)
}