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:
@@ -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 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 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 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.
|
||||||
|
|||||||
@@ -1,9 +1,37 @@
|
|||||||
// app/(protected)/admin/page.tsx
|
export const dynamic = 'force-dynamic'
|
||||||
export default function AdminHome() {
|
|
||||||
|
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 (
|
return (
|
||||||
<main className="p-8 max-w-4xl mx-auto">
|
<main className="max-w-4xl mx-auto px-4 py-6 space-y-6">
|
||||||
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Admin</h1>
|
||||||
<p className="mt-2 text-gray-500">Phase 0: User management and site config coming here.</p>
|
<UserManager users={(users ?? []) as AdminUser[]} sites={siteOptions} />
|
||||||
|
<SiteZoneManager sites={(sites ?? []) as unknown as SiteWithZones[]} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export const dynamic = 'force-dynamic'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { StatCard } from '@/components/dashboard/stat-card'
|
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> = {
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
injury: 'Injury',
|
injury: 'Injury',
|
||||||
@@ -22,6 +24,7 @@ export default async function HseDashboardPage() {
|
|||||||
thirtyDaysAgo.setDate(now.getDate() - 30)
|
thirtyDaysAgo.setDate(now.getDate() - 30)
|
||||||
const ninetyDaysAgo = new Date(now)
|
const ninetyDaysAgo = new Date(now)
|
||||||
ninetyDaysAgo.setDate(now.getDate() - 90)
|
ninetyDaysAgo.setDate(now.getDate() - 90)
|
||||||
|
const twelveMonthsAgo = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 11, 1))
|
||||||
|
|
||||||
const [
|
const [
|
||||||
{ data: incidents },
|
{ data: incidents },
|
||||||
@@ -29,6 +32,8 @@ export default async function HseDashboardPage() {
|
|||||||
{ data: zoneIncidents },
|
{ data: zoneIncidents },
|
||||||
{ data: completedCapas },
|
{ data: completedCapas },
|
||||||
{ data: doshPendingRows },
|
{ data: doshPendingRows },
|
||||||
|
{ data: yearIncidents },
|
||||||
|
{ data: investigations },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
supabase.from('incidents').select('id, status, incident_type, sites (name)'),
|
supabase.from('incidents').select('id, status, incident_type, sites (name)'),
|
||||||
supabase
|
supabase
|
||||||
@@ -48,6 +53,14 @@ export default async function HseDashboardPage() {
|
|||||||
.from('dosh_reports')
|
.from('dosh_reports')
|
||||||
.select('id')
|
.select('id')
|
||||||
.eq('status', 'pending'),
|
.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 ---
|
// --- Existing metrics ---
|
||||||
@@ -105,23 +118,47 @@ export default async function HseDashboardPage() {
|
|||||||
// --- DOSH pending filings ---
|
// --- DOSH pending filings ---
|
||||||
const doshPendingCount = doshPendingRows?.length ?? 0
|
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 (
|
return (
|
||||||
<main className="max-w-4xl mx-auto px-4 py-6">
|
<main className="max-w-4xl mx-auto px-4 py-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<a
|
<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"
|
className="text-sm text-gray-500 hover:text-gray-700 border border-gray-200 rounded-lg px-3 py-1.5"
|
||||||
>
|
>
|
||||||
Export CSV
|
Export CSV
|
||||||
</a>
|
</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">
|
<Link href="/hse/incidents" className="text-sm text-blue-600 hover:underline">
|
||||||
View all incidents →
|
View all incidents →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Summary stats */}
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
|
||||||
<StatCard label="Total Incidents" value={total} />
|
<StatCard label="Total Incidents" value={total} />
|
||||||
@@ -161,6 +198,54 @@ export default async function HseDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Zone heatmap — last 90 days */}
|
||||||
{by_zone.length > 0 && (
|
{by_zone.length > 0 && (
|
||||||
<div className="bg-white rounded-xl shadow-sm p-5 mb-4">
|
<div className="bg-white rounded-xl shadow-sm p-5 mb-4">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from 'next/link'
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { IncidentDetail, type Incident } from '@/components/incidents/incident-detail'
|
import { IncidentDetail, type Incident } from '@/components/incidents/incident-detail'
|
||||||
import { SimilarIncidentsPanel } from '@/components/incidents/similar-incidents-panel'
|
import { SimilarIncidentsPanel } from '@/components/incidents/similar-incidents-panel'
|
||||||
|
import { ClosurePanel } from '@/components/incidents/closure-panel'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
@@ -18,7 +19,7 @@ export default async function HseIncidentDetailPage({ params }: Props) {
|
|||||||
.from('incidents')
|
.from('incidents')
|
||||||
.select(`
|
.select(`
|
||||||
id, reference_no, incident_type, description, severity, status,
|
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,
|
is_fatality, is_serious_bodily_injury, is_dangerous_occurrence, is_occupational_disease,
|
||||||
reported_at, closed_at,
|
reported_at, closed_at,
|
||||||
sites (id, name),
|
sites (id, name),
|
||||||
@@ -80,14 +81,14 @@ export default async function HseIncidentDetailPage({ params }: Props) {
|
|||||||
) && (
|
) && (
|
||||||
<div className="mt-4 flex gap-3">
|
<div className="mt-4 flex gap-3">
|
||||||
<a
|
<a
|
||||||
href={`/api/incidents/${id}/jkkp-pdf?form=jkkp6`}
|
href={`/ims/api/incidents/${id}/jkkp-pdf?form=jkkp6`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="inline-block bg-gray-800 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-900"
|
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)
|
Download JKKP 6 (PDF)
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={`/api/incidents/${id}/jkkp-pdf?form=jkkp7`}
|
href={`/ims/api/incidents/${id}/jkkp-pdf?form=jkkp7`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="inline-block bg-gray-600 text-white rounded-lg px-4 py-2 text-sm font-semibold hover:bg-gray-700"
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ClosurePanel incidentId={id} status={status} canClose canAddAddenda />
|
||||||
<SimilarIncidentsPanel incidentId={id} />
|
<SimilarIncidentsPanel incidentId={id} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,28 +2,39 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { IncidentList, type Incident } from '@/components/incidents/incident-list'
|
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 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')
|
.from('incidents')
|
||||||
.select(`
|
.select(`
|
||||||
id, reference_no, incident_type, status, severity, reported_at,
|
id, reference_no, incident_type, status, severity, reported_at,
|
||||||
sites (name),
|
sites (name),
|
||||||
zones (name),
|
zones (name),
|
||||||
reporter:users!reported_by (name)
|
reporter:users!reported_by (name)
|
||||||
`)
|
`, { count: 'exact' })
|
||||||
.order('reported_at', { ascending: false })
|
.order('reported_at', { ascending: false })
|
||||||
.limit(100)
|
.range(from, from + PAGE_SIZE - 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="max-w-4xl mx-auto px-4 py-6">
|
<main className="max-w-4xl mx-auto px-4 py-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Incident Inbox</h1>
|
<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>
|
</div>
|
||||||
<IncidentList incidents={(incidents ?? []) as unknown as Incident[]} basePath="/hse" />
|
<IncidentList incidents={(incidents ?? []) as unknown as Incident[]} basePath="/hse" />
|
||||||
|
<Pagination page={page} pageSize={PAGE_SIZE} total={count ?? 0} href="/hse/incidents" />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
|
import { NotificationBell } from '@/components/notifications/bell'
|
||||||
|
|
||||||
export default async function ProtectedLayout({
|
export default async function ProtectedLayout({
|
||||||
children,
|
children,
|
||||||
@@ -16,5 +17,10 @@ export default async function ProtectedLayout({
|
|||||||
|
|
||||||
if (!user) redirect('/login')
|
if (!user) redirect('/login')
|
||||||
|
|
||||||
return <>{children}</>
|
return (
|
||||||
|
<>
|
||||||
|
<NotificationBell />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
|
|||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { StatCard } from '@/components/dashboard/stat-card'
|
import { StatCard } from '@/components/dashboard/stat-card'
|
||||||
|
import { RiskFlagsPanel } from '@/components/dashboard/risk-flags-panel'
|
||||||
|
|
||||||
export default async function ManagementPage() {
|
export default async function ManagementPage() {
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
@@ -71,13 +72,15 @@ export default async function ManagementPage() {
|
|||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Management Dashboard</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Management Dashboard</h1>
|
||||||
<a
|
<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"
|
className="text-sm text-gray-500 hover:text-gray-700 border border-gray-200 rounded-lg px-3 py-1.5"
|
||||||
>
|
>
|
||||||
Export CSV
|
Export CSV
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RiskFlagsPanel />
|
||||||
|
|
||||||
{/* Summary stats */}
|
{/* Summary stats */}
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
|
||||||
<StatCard
|
<StatCard
|
||||||
|
|||||||
@@ -2,28 +2,39 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { IncidentList, type Incident } from '@/components/incidents/incident-list'
|
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 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')
|
.from('incidents')
|
||||||
.select(`
|
.select(`
|
||||||
id, reference_no, incident_type, status, severity, reported_at,
|
id, reference_no, incident_type, status, severity, reported_at,
|
||||||
sites (name),
|
sites (name),
|
||||||
zones (name),
|
zones (name),
|
||||||
reporter:users!reported_by (name)
|
reporter:users!reported_by (name)
|
||||||
`)
|
`, { count: 'exact' })
|
||||||
.order('reported_at', { ascending: false })
|
.order('reported_at', { ascending: false })
|
||||||
.limit(100)
|
.range(from, from + PAGE_SIZE - 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="max-w-4xl mx-auto px-4 py-6">
|
<main className="max-w-4xl mx-auto px-4 py-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Incident Inbox</h1>
|
<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>
|
</div>
|
||||||
<IncidentList incidents={(incidents ?? []) as unknown as Incident[]} basePath="/supervisor" />
|
<IncidentList incidents={(incidents ?? []) as unknown as Incident[]} basePath="/supervisor" />
|
||||||
|
<Pagination page={page} pageSize={PAGE_SIZE} total={count ?? 0} href="/supervisor/incidents" />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
@@ -19,7 +20,7 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||||
|
|
||||||
const { data: capa } = await supabase
|
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) return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||||
if (capa.status !== 'pending_verification')
|
if (capa.status !== 'pending_verification')
|
||||||
return NextResponse.json({ error: 'CAPA must be pending_verification' }, { status: 409 })
|
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 },
|
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
|
// Check if all CAPAs for this incident are verified — if so, transition incident to verification
|
||||||
const { data: openCapas } = await supabase
|
const { data: openCapas } = await supabase
|
||||||
.from('capa_actions')
|
.from('capa_actions')
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic'
|
|||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const supabase = await createClient()
|
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 },
|
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 })
|
return NextResponse.json({ id: capa.id }, { status: 201 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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,
|
root_cause_summary: body.root_cause_summary ?? null,
|
||||||
five_why_steps: method === 'five_why' ? (body.five_why_steps ?? []) : null,
|
five_why_steps: method === 'five_why' ? (body.five_why_steps ?? []) : null,
|
||||||
fishbone_categories: method === 'fishbone' ? (body.fishbone_categories ?? []) : 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')
|
.select('id')
|
||||||
.single()
|
.single()
|
||||||
@@ -83,6 +85,8 @@ export async function PATCH(
|
|||||||
root_cause_summary: fields.root_cause_summary ?? null,
|
root_cause_summary: fields.root_cause_summary ?? null,
|
||||||
five_why_steps: fields.five_why_steps ?? null,
|
five_why_steps: fields.five_why_steps ?? null,
|
||||||
fishbone_categories: fields.fishbone_categories ?? 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()
|
if (complete) updateData.completed_at = new Date().toISOString()
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/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 { uploadEvidenceFile, type EvidenceStage } from '@/lib/supabase/storage'
|
||||||
import { sendNewIncidentEmail } from '@/lib/notifications/email'
|
import { sendNewIncidentEmail } from '@/lib/notifications/email'
|
||||||
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
||||||
|
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
||||||
import { getApiKey } from '@/lib/settings'
|
import { getApiKey } from '@/lib/settings'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
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 })
|
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
|
const { data: zone, error: zoneError } = await supabase
|
||||||
.from('zones')
|
.from('zones')
|
||||||
.select('id, site_id')
|
.select('id, site_id')
|
||||||
@@ -63,6 +79,7 @@ export async function POST(request: Request) {
|
|||||||
injury_involved: input.injury_involved,
|
injury_involved: input.injury_involved,
|
||||||
asset_involved: input.asset_involved,
|
asset_involved: input.asset_involved,
|
||||||
medical_status: input.injury_involved ? (input.medical_status ?? 'none') : null,
|
medical_status: input.injury_involved ? (input.medical_status ?? 'none') : null,
|
||||||
|
type_details: detailsCheck.sanitized,
|
||||||
})
|
})
|
||||||
.select('id, reference_no')
|
.select('id, reference_no')
|
||||||
.single()
|
.single()
|
||||||
@@ -121,9 +138,20 @@ export async function POST(request: Request) {
|
|||||||
const siteName = (siteData as { name: string } | null)?.name ?? 'Unknown'
|
const siteName = (siteData as { name: string } | null)?.name ?? 'Unknown'
|
||||||
const { data: recipients } = await supabase
|
const { data: recipients } = await supabase
|
||||||
.from('users')
|
.from('users')
|
||||||
.select('phone')
|
.select('id, phone')
|
||||||
.in('role', ['supervisor', 'hse'])
|
.in('role', ['supervisor', 'hse'])
|
||||||
.eq('site_id', zone.site_id)
|
.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 ?? []) {
|
for (const r of recipients ?? []) {
|
||||||
const phone = (r as { phone: string | null }).phone ?? ''
|
const phone = (r as { phone: string | null }).phone ?? ''
|
||||||
if (!phone) continue
|
if (!phone) continue
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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"`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ export function VerifyForm({ capaId }: Props) {
|
|||||||
}
|
}
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
const res = await fetch(`/api/capa/${capaId}/verify`, {
|
const res = await fetch(`/ims/api/capa/${capaId}/verify`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ verdict, reopen_reason: reopenReason || null }),
|
body: JSON.stringify({ verdict, reopen_reason: reopenReason || null }),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,6 +18,13 @@ interface Props {
|
|||||||
function isImage(type: string) { return type.startsWith('image/') }
|
function isImage(type: string) { return type.startsWith('image/') }
|
||||||
function isVideo(type: string) { return type.startsWith('video/') }
|
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) {
|
export function EvidenceGallery({ files, stage }: Props) {
|
||||||
const filtered = stage ? files.filter(f => f.stage === stage) : files
|
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>
|
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"
|
className="block rounded-lg overflow-hidden bg-gray-100 aspect-square hover:opacity-90 transition-opacity"
|
||||||
>
|
>
|
||||||
{isImage(file.file_type) ? (
|
{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) ? (
|
) : isVideo(file.file_type) ? (
|
||||||
<div className="w-full h-full flex items-center justify-center text-3xl">🎥</div>
|
<div className="w-full h-full flex items-center justify-center text-3xl">🎥</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export type Incident = {
|
|||||||
lost_days: number | null
|
lost_days: number | null
|
||||||
reported_at: string
|
reported_at: string
|
||||||
closed_at: string | null
|
closed_at: string | null
|
||||||
|
type_details?: Record<string, string | boolean> | null
|
||||||
sites: { id: string; name: string } | null
|
sites: { id: string; name: string } | null
|
||||||
zones: { id: string; name: string } | null
|
zones: { id: string; name: string } | null
|
||||||
reporter: { id: string; name: string; email: 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 && (
|
{incident.injury_involved && incident.lost_days != null && (
|
||||||
<Field label="Lost days" value={`${incident.lost_days} day(s)`} />
|
<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>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props
|
|||||||
)
|
)
|
||||||
const [findingsText, setFindingsText] = useState('')
|
const [findingsText, setFindingsText] = useState('')
|
||||||
const [rootCause, setRootCause] = useState('')
|
const [rootCause, setRootCause] = useState('')
|
||||||
|
const [alcoholTest, setAlcoholTest] = useState('')
|
||||||
|
const [witnessRefs, setWitnessRefs] = useState<string[]>([''])
|
||||||
const [complete, setComplete] = useState(false)
|
const [complete, setComplete] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -61,7 +63,7 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props
|
|||||||
async function getAiDraft() {
|
async function getAiDraft() {
|
||||||
setAiDraftLoading(true)
|
setAiDraftLoading(true)
|
||||||
try {
|
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
|
if (!res.ok) return
|
||||||
const draft = await res.json() as {
|
const draft = await res.json() as {
|
||||||
five_why_steps: Array<{ why: string; answer: string }>
|
five_why_steps: Array<{ why: string; answer: string }>
|
||||||
@@ -92,6 +94,8 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props
|
|||||||
method,
|
method,
|
||||||
findings_text: findingsText || null,
|
findings_text: findingsText || null,
|
||||||
root_cause_summary: rootCause || 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,
|
five_why_steps: method === 'five_why' ? fiveWhy.filter(s => s.answer) : null,
|
||||||
fishbone_categories: method === 'fishbone'
|
fishbone_categories: method === 'fishbone'
|
||||||
? fishbone.map(c => ({ ...c, causes: c.causes.filter(Boolean) })).filter(c => c.causes.length > 0)
|
? 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
|
let res: Response
|
||||||
if (existingInvestigationId) {
|
if (existingInvestigationId) {
|
||||||
res = await fetch(`/api/incidents/${incidentId}/investigation`, {
|
res = await fetch(`/ims/api/incidents/${incidentId}/investigation`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ ...payload, investigation_id: existingInvestigationId, complete }),
|
body: JSON.stringify({ ...payload, investigation_id: existingInvestigationId, complete }),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
res = await fetch(`/api/incidents/${incidentId}/investigation`, {
|
res = await fetch(`/ims/api/incidents/${incidentId}/investigation`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -207,6 +211,45 @@ export function InvestigationForm({ incidentId, existingInvestigationId }: Props
|
|||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Key Findings</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Key Findings</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function OfflineSync() {
|
|||||||
fd.append('injury_involved', String(report.injury_involved))
|
fd.append('injury_involved', String(report.injury_involved))
|
||||||
fd.append('asset_involved', String(report.asset_involved))
|
fd.append('asset_involved', String(report.asset_involved))
|
||||||
if (report.medical_status) fd.append('medical_status', report.medical_status)
|
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 {
|
try {
|
||||||
const res = await fetch('/ims/api/incidents', { method: 'POST', body: fd })
|
const res = await fetch('/ims/api/incidents', { method: 'POST', body: fd })
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
const t = useTranslations('ReportForm')
|
const t = useTranslations('ReportForm')
|
||||||
const itLabels = useTranslations('IncidentType')
|
const itLabels = useTranslations('IncidentType')
|
||||||
const msLabels = useTranslations('MedicalStatus')
|
const msLabels = useTranslations('MedicalStatus')
|
||||||
|
const tdLabels = useTranslations('TypeDetails')
|
||||||
|
|
||||||
const INCIDENT_TYPE_OPTIONS: [IncidentType, string][] = [
|
const INCIDENT_TYPE_OPTIONS: [IncidentType, string][] = [
|
||||||
['injury', itLabels.injury],
|
['injury', itLabels.injury],
|
||||||
@@ -55,6 +56,35 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
medical_status: '' as MedicalStatus | '',
|
medical_status: '' as MedicalStatus | '',
|
||||||
asset_involved: false,
|
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(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
@@ -85,6 +115,7 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
injury_involved: form.injury_involved,
|
injury_involved: form.injury_involved,
|
||||||
asset_involved: form.asset_involved,
|
asset_involved: form.asset_involved,
|
||||||
medical_status: form.medical_status || undefined,
|
medical_status: form.medical_status || undefined,
|
||||||
|
type_details: Object.keys(typeDetails).length > 0 ? typeDetails : undefined,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
setSavedOffline(true)
|
setSavedOffline(true)
|
||||||
@@ -134,6 +165,9 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
if (form.injury_involved && form.medical_status) {
|
if (form.injury_involved && form.medical_status) {
|
||||||
fd.append('medical_status', 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))
|
files.forEach(f => fd.append('files', f))
|
||||||
|
|
||||||
const res = await fetch('/ims/api/incidents', { method: 'POST', body: fd })
|
const res = await fetch('/ims/api/incidents', { method: 'POST', body: fd })
|
||||||
@@ -182,7 +216,10 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
required
|
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"
|
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}
|
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>
|
<option value="">{t.incidentTypePlaceholder}</option>
|
||||||
{INCIDENT_TYPE_OPTIONS.map(([v, l]) => (
|
{INCIDENT_TYPE_OPTIONS.map(([v, l]) => (
|
||||||
@@ -191,6 +228,32 @@ export function ReportForm({ zoneToken }: Props) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
{t.descriptionLabel} <span className="text-red-500">*</span>
|
{t.descriptionLabel} <span className="text-red-500">*</span>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function SimilarIncidentsPanel({ incidentId }: Props) {
|
|||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/incidents/${incidentId}/similar`)
|
fetch(`/ims/api/incidents/${incidentId}/similar`)
|
||||||
.then(r => {
|
.then(r => {
|
||||||
if (!r.ok) throw new Error('failed')
|
if (!r.ok) throw new Error('failed')
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function TriageForm({ incidentId, currentSeverity }: Props) {
|
|||||||
setAiLoading(true)
|
setAiLoading(true)
|
||||||
setAiRationale(null)
|
setAiRationale(null)
|
||||||
try {
|
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
|
if (!res.ok) return
|
||||||
const data = await res.json() as {
|
const data = await res.json() as {
|
||||||
severity: number
|
severity: number
|
||||||
@@ -69,7 +69,7 @@ export function TriageForm({ incidentId, currentSeverity }: Props) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
const res = await fetch(`/api/incidents/${incidentId}/triage`, {
|
const res = await fetch(`/ims/api/incidents/${incidentId}/triage`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -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 0–4 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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -11,6 +11,61 @@ export interface IncidentInput {
|
|||||||
injury_involved: boolean
|
injury_involved: boolean
|
||||||
medical_status?: MedicalStatus
|
medical_status?: MedicalStatus
|
||||||
asset_involved: boolean
|
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[] } {
|
export function validateIncidentInput(input: IncidentInput): { ok: boolean; errors: string[] } {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Resend } from 'resend'
|
import { Resend } from 'resend'
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp'
|
||||||
|
import { createInAppNotifications } from '@/lib/notifications/in-app'
|
||||||
import { getApiKey } from '@/lib/settings'
|
import { getApiKey } from '@/lib/settings'
|
||||||
|
|
||||||
export type EscalationThreshold = 'warning_3d' | 'due_today' | 'overdue_3d' | 'overdue_7d'
|
export type EscalationThreshold = 'warning_3d' | 'due_today' | 'overdue_3d' | 'overdue_7d'
|
||||||
@@ -32,7 +33,7 @@ export async function escalateOverdueCapa(
|
|||||||
const { data: capas } = await supabase
|
const { data: capas } = await supabase
|
||||||
.from('capa_actions')
|
.from('capa_actions')
|
||||||
.select(`
|
.select(`
|
||||||
id, description, due_date, incident_id,
|
id, description, due_date, incident_id, owner_user_id,
|
||||||
incidents (reference_no, site_id),
|
incidents (reference_no, site_id),
|
||||||
owner:users!owner_user_id (email, name, phone)
|
owner:users!owner_user_id (email, name, phone)
|
||||||
`)
|
`)
|
||||||
@@ -121,6 +122,17 @@ export async function escalateOverdueCapa(
|
|||||||
status: threshold,
|
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++
|
notified++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export interface PendingReport {
|
|||||||
injury_involved: boolean
|
injury_involved: boolean
|
||||||
asset_involved: boolean
|
asset_involved: boolean
|
||||||
medical_status?: string
|
medical_status?: string
|
||||||
|
type_details?: Record<string, string | boolean>
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -32,5 +32,20 @@
|
|||||||
"first_aid": "First aid only",
|
"first_aid": "First aid only",
|
||||||
"medical_treatment": "Medical treatment (non-LTI)",
|
"medical_treatment": "Medical treatment (non-LTI)",
|
||||||
"lti": "Lost Time Injury (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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,5 +32,20 @@
|
|||||||
"first_aid": "Pertolongan cemas sahaja",
|
"first_aid": "Pertolongan cemas sahaja",
|
||||||
"medical_treatment": "Rawatan perubatan (bukan LTI)",
|
"medical_treatment": "Rawatan perubatan (bukan LTI)",
|
||||||
"lti": "Kecederaan Masa Hilang (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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,5 +32,20 @@
|
|||||||
"first_aid": "仅急救",
|
"first_aid": "仅急救",
|
||||||
"medical_treatment": "医疗治疗(非 LTI)",
|
"medical_treatment": "医疗治疗(非 LTI)",
|
||||||
"lti": "工伤失时(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;
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
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 = {
|
const valid: IncidentInput = {
|
||||||
zone_token: 'scw1-dock-a-qr-2026',
|
zone_token: 'scw1-dock-a-qr-2026',
|
||||||
@@ -43,3 +43,42 @@ describe('validateIncidentInput', () => {
|
|||||||
expect(result.errors).toContain('incident_type is invalid')
|
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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user