From 576557181a58e4b14951b1a4017e545be0c0af69 Mon Sep 17 00:00:00 2001 From: weeihan Date: Sun, 12 Jul 2026 10:25:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=205=20&=206=20=E2=80=94=20usabili?= =?UTF-8?q?ty,=20compliance=20hardening,=20analytics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ --- .superpowers/sdd/progress.md | 26 +++ app/(protected)/admin/page.tsx | 38 +++- app/(protected)/hse/dashboard/page.tsx | 87 +++++++++- app/(protected)/hse/incidents/[id]/page.tsx | 8 +- app/(protected)/hse/incidents/page.tsx | 21 ++- app/(protected)/layout.tsx | 8 +- app/(protected)/management/page.tsx | 5 +- app/(protected)/supervisor/incidents/page.tsx | 21 ++- app/api/admin/sites/route.ts | 57 ++++++ app/api/admin/users/route.ts | 124 +++++++++++++ app/api/capa/[id]/verify/route.ts | 15 +- app/api/capa/route.ts | 9 + app/api/dashboard/ai/risk-flags/route.ts | 155 +++++++++++++++++ app/api/incidents/[id]/addenda/route.ts | 61 +++++++ app/api/incidents/[id]/close/route.ts | 65 +++++++ app/api/incidents/[id]/investigation/route.ts | 4 + app/api/incidents/route.ts | 32 +++- app/api/notifications/route.ts | 53 ++++++ app/api/reports/jkkp8/route.ts | 54 ++++++ components/admin/site-zone-manager.tsx | 118 +++++++++++++ components/admin/user-manager.tsx | 164 ++++++++++++++++++ components/capa/verify-form.tsx | 2 +- components/dashboard/risk-flags-panel.tsx | 88 ++++++++++ components/incidents/closure-panel.tsx | 132 ++++++++++++++ components/incidents/evidence-gallery.tsx | 18 +- components/incidents/incident-detail.tsx | 9 + components/incidents/investigation-form.tsx | 49 +++++- components/incidents/offline-sync.tsx | 1 + components/incidents/pagination.tsx | 32 ++++ components/incidents/report-form.tsx | 65 ++++++- .../incidents/similar-incidents-panel.tsx | 2 +- components/incidents/triage-form.tsx | 4 +- components/notifications/bell.tsx | 118 +++++++++++++ ...hase-5-6-usability-compliance-analytics.md | 107 ++++++++++++ lib/dashboard/trends.ts | 70 ++++++++ lib/incidents/validate.ts | 55 ++++++ lib/notifications/capa-escalation.ts | 14 +- lib/notifications/in-app.ts | 42 +++++ lib/offline/db.ts | 1 + lib/reports/jkkp8.ts | 96 ++++++++++ lib/supabase/admin.ts | 14 ++ messages/en.json | 17 +- messages/ms.json | 17 +- messages/zh.json | 17 +- .../20260712000016_in_app_notifications.sql | 50 ++++++ .../20260712000017_closure_lock_addenda.sql | 57 ++++++ .../20260712000018_type_details.sql | 5 + tests/lib/dashboard/trends.test.ts | 56 ++++++ tests/lib/incidents/validate.test.ts | 41 ++++- tests/lib/notifications/in-app.test.ts | 60 +++++++ tests/lib/reports/jkkp8.test.ts | 68 ++++++++ 51 files changed, 2394 insertions(+), 38 deletions(-) create mode 100644 app/api/admin/sites/route.ts create mode 100644 app/api/admin/users/route.ts create mode 100644 app/api/dashboard/ai/risk-flags/route.ts create mode 100644 app/api/incidents/[id]/addenda/route.ts create mode 100644 app/api/incidents/[id]/close/route.ts create mode 100644 app/api/notifications/route.ts create mode 100644 app/api/reports/jkkp8/route.ts create mode 100644 components/admin/site-zone-manager.tsx create mode 100644 components/admin/user-manager.tsx create mode 100644 components/dashboard/risk-flags-panel.tsx create mode 100644 components/incidents/closure-panel.tsx create mode 100644 components/incidents/pagination.tsx create mode 100644 components/notifications/bell.tsx create mode 100644 docs/superpowers/plans/2026-07-11-phase-5-6-usability-compliance-analytics.md create mode 100644 lib/dashboard/trends.ts create mode 100644 lib/notifications/in-app.ts create mode 100644 lib/reports/jkkp8.ts create mode 100644 lib/supabase/admin.ts create mode 100644 supabase/migrations/20260712000016_in_app_notifications.sql create mode 100644 supabase/migrations/20260712000017_closure_lock_addenda.sql create mode 100644 supabase/migrations/20260712000018_type_details.sql create mode 100644 tests/lib/dashboard/trends.test.ts create mode 100644 tests/lib/notifications/in-app.test.ts create mode 100644 tests/lib/reports/jkkp8.test.ts diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index d28b383..0598b7b 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -86,3 +86,29 @@ Base commit: 55b6dac - [x] Task 2: WhatsApp integration — incident alert + CAPA escalation (commit a47f3aa + edb0bde, review clean — MINOR: overdue_3d WhatsApp path untested; serial Meta calls in IIFE; mock call-index fragility in capa test; duplicate vi.clearAllMocks) - [x] Task 3: CAPA effectiveness recheck (commit b9def9c, review clean — MINOR: getNextRecheckDate uses local setDate not UTC → fix setDate→setUTCDate before prod; CRON_SECRET undefined demotes to "Bearer undefined"; DB update after send has no error check; nextDate??null no-op) - [x] Task 4: i18n infrastructure + EN/MS/ZH translations (commit 87264a5, review clean — MINOR: report page h1 still hardcoded English (plan gap); offline db stub throws at runtime (temporary, Task 5 replaces); language-switcher cookie value not URL-decoded; SW path /ims hardcoded in layout) +- [x] Task 5: PWA offline capture (commits c048878..448ea48, review clean after fix — MINOR: SW clients.claim() outside waitUntil; fixed basePath in fetch URL; fixed syncNow stable callback with useRef) + +--- + +# Phase 5 & 6 SDD Progress Ledger + +Plan: docs/superpowers/plans/2026-07-11-phase-5-6-usability-compliance-analytics.md +Started: 2026-07-11 · Completed: 2026-07-12 +Branch: phase-5-6 + +## Tasks + +- [x] Housekeeping: removed stray "whatsapp 2" duplicates (identical to canonical) +- [x] In-app notification bell + badge (migration 016, SECURITY DEFINER RPC, /api/notifications, bell in protected layout, wired into incident/CAPA/escalation flows) +- [x] Incident closure lock + addenda (migration 017: incident_addenda + DB triggers; POST /close endpoint added — did not exist; addenda UI) +- [x] Incident list pagination (server-side .range(), shared Pagination component, HSE + supervisor inboxes) +- [x] Witness statement + alcohol test UI (investigation form + route, existing schema columns) +- [x] Type-specific intake forms (migration 018: type_details JSONB; validateTypeDetails whitelist; env/asset/security/fire field groups; EN/MS/ZH; offline queue support) +- [x] JKKP 8 annual register export (lib/reports/jkkp8.ts + /api/reports/jkkp8 CSV; dashboard button + January deadline banner) +- [x] Admin user + site/zone management (invite via service-role client, role/site/active management, site+zone CRUD with QR link) +- [x] Evidence thumbnails (Supabase render transform + onError fallback, lazy loading) +- [x] Phase 6: 12-month trend chart + top root causes (lib/dashboard/trends.ts pure helpers + tests) +- [x] Phase 6: AI rising-risk zones (/api/dashboard/ai/risk-flags, claude-opus-4-8 forced tool_use, panel on HSE + management dashboards) +- [x] Drive-by: fixed 9 pre-existing missing /ims basePath prefixes + +Verification: 112 tests passing (23 files), tsc clean, next build clean. diff --git a/app/(protected)/admin/page.tsx b/app/(protected)/admin/page.tsx index b7665a6..2441d82 100644 --- a/app/(protected)/admin/page.tsx +++ b/app/(protected)/admin/page.tsx @@ -1,9 +1,37 @@ -// app/(protected)/admin/page.tsx -export default function AdminHome() { +export const dynamic = 'force-dynamic' + +import { redirect } from 'next/navigation' +import { createClient } from '@/lib/supabase/server' +import { UserManager, type AdminUser, type SiteOption } from '@/components/admin/user-manager' +import { SiteZoneManager, type SiteWithZones } from '@/components/admin/site-zone-manager' + +export default async function AdminHome() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const { data: profile } = await supabase + .from('users').select('role').eq('id', user.id).single() + if (!profile || profile.role !== 'admin') redirect('/login') + + const [{ data: users }, { data: sites }] = await Promise.all([ + supabase + .from('users') + .select('id, name, email, role, department, site_id, active') + .order('created_at', { ascending: false }), + supabase + .from('sites') + .select('id, name, address, zones (id, name, qr_code_token)') + .order('name'), + ]) + + const siteOptions: SiteOption[] = (sites ?? []).map(s => ({ id: s.id, name: s.name })) + return ( -
-

Admin Dashboard

-

Phase 0: User management and site config coming here.

+
+

Admin

+ +
) } diff --git a/app/(protected)/hse/dashboard/page.tsx b/app/(protected)/hse/dashboard/page.tsx index b91396e..c52acc3 100644 --- a/app/(protected)/hse/dashboard/page.tsx +++ b/app/(protected)/hse/dashboard/page.tsx @@ -3,6 +3,8 @@ export const dynamic = 'force-dynamic' import Link from 'next/link' import { createClient } from '@/lib/supabase/server' import { StatCard } from '@/components/dashboard/stat-card' +import { bucketIncidentsByMonth, topRootCauses } from '@/lib/dashboard/trends' +import { RiskFlagsPanel } from '@/components/dashboard/risk-flags-panel' const TYPE_LABELS: Record = { injury: 'Injury', @@ -22,6 +24,7 @@ export default async function HseDashboardPage() { thirtyDaysAgo.setDate(now.getDate() - 30) const ninetyDaysAgo = new Date(now) ninetyDaysAgo.setDate(now.getDate() - 90) + const twelveMonthsAgo = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 11, 1)) const [ { data: incidents }, @@ -29,6 +32,8 @@ export default async function HseDashboardPage() { { data: zoneIncidents }, { data: completedCapas }, { data: doshPendingRows }, + { data: yearIncidents }, + { data: investigations }, ] = await Promise.all([ supabase.from('incidents').select('id, status, incident_type, sites (name)'), supabase @@ -48,6 +53,14 @@ export default async function HseDashboardPage() { .from('dosh_reports') .select('id') .eq('status', 'pending'), + supabase + .from('incidents') + .select('reported_at, incident_type') + .gte('reported_at', twelveMonthsAgo.toISOString()), + supabase + .from('investigations') + .select('root_cause_summary') + .not('root_cause_summary', 'is', null), ]) // --- Existing metrics --- @@ -105,23 +118,47 @@ export default async function HseDashboardPage() { // --- DOSH pending filings --- const doshPendingCount = doshPendingRows?.length ?? 0 + // --- 12-month trend + top root causes --- + const monthly = bucketIncidentsByMonth(yearIncidents ?? [], 12, now) + const monthlyMax = Math.max(1, ...monthly.map(m => m.total)) + const rootCauses = topRootCauses(investigations ?? []) + return (

Dashboard

Export CSV + + JKKP 8 Register + View all incidents →
+ {/* JKKP 8 statutory deadline reminder — register due to DOSH before 31 January */} + {now.getMonth() === 0 && ( +
+

+ JKKP 8 annual register due: the {now.getFullYear() - 1} register must be + submitted to DOSH before 31 January {now.getFullYear()}.{' '} + + Download {now.getFullYear() - 1} register + +

+
+ )} + {/* Summary stats */}
@@ -161,6 +198,54 @@ export default async function HseDashboardPage() {
+ + + {/* 12-month incident trend */} +
+

+ Incident Trend — Last 12 Months +

+

+ Blue: leading (hazard + near miss) · Red: lagging (injury) · Grey: other +

+
+ {monthly.map(m => { + const other = m.total - m.leading - m.lagging + return ( +
+
+
+
+
+
+ {m.label} + {m.total || ''} +
+ ) + })} +
+
+ + {/* Top root causes */} + {rootCauses.length > 0 && ( +
+

+ Top Root Causes +

+
    + {rootCauses.map((rc, i) => ( +
  1. + + {i + 1}. + {rc.cause} + + {rc.count}× +
  2. + ))} +
+
+ )} + {/* Zone heatmap — last 90 days */} {by_zone.length > 0 && (
diff --git a/app/(protected)/hse/incidents/[id]/page.tsx b/app/(protected)/hse/incidents/[id]/page.tsx index ea67000..f0901da 100644 --- a/app/(protected)/hse/incidents/[id]/page.tsx +++ b/app/(protected)/hse/incidents/[id]/page.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import { createClient } from '@/lib/supabase/server' import { IncidentDetail, type Incident } from '@/components/incidents/incident-detail' import { SimilarIncidentsPanel } from '@/components/incidents/similar-incidents-panel' +import { ClosurePanel } from '@/components/incidents/closure-panel' interface Props { params: Promise<{ id: string }> @@ -18,7 +19,7 @@ export default async function HseIncidentDetailPage({ params }: Props) { .from('incidents') .select(` id, reference_no, incident_type, description, severity, status, - injury_involved, asset_involved, medical_status, lost_days, + injury_involved, asset_involved, medical_status, lost_days, type_details, is_fatality, is_serious_bodily_injury, is_dangerous_occurrence, is_occupational_disease, reported_at, closed_at, sites (id, name), @@ -80,14 +81,14 @@ export default async function HseIncidentDetailPage({ params }: Props) { ) && ( )} +
) diff --git a/app/(protected)/hse/incidents/page.tsx b/app/(protected)/hse/incidents/page.tsx index 0f9a2d4..da04284 100644 --- a/app/(protected)/hse/incidents/page.tsx +++ b/app/(protected)/hse/incidents/page.tsx @@ -2,28 +2,39 @@ export const dynamic = 'force-dynamic' import { createClient } from '@/lib/supabase/server' import { IncidentList, type Incident } from '@/components/incidents/incident-list' +import { Pagination } from '@/components/incidents/pagination' -export default async function HseInboxPage() { +const PAGE_SIZE = 25 + +export default async function HseInboxPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string }> +}) { const supabase = await createClient() + const params = await searchParams + const page = Math.max(1, Number.parseInt(params.page ?? '1', 10) || 1) + const from = (page - 1) * PAGE_SIZE - const { data: incidents } = await supabase + const { data: incidents, count } = await supabase .from('incidents') .select(` id, reference_no, incident_type, status, severity, reported_at, sites (name), zones (name), reporter:users!reported_by (name) - `) + `, { count: 'exact' }) .order('reported_at', { ascending: false }) - .limit(100) + .range(from, from + PAGE_SIZE - 1) return (

Incident Inbox

- {incidents?.length ?? 0} incidents + {count ?? incidents?.length ?? 0} incidents
+
) } diff --git a/app/(protected)/layout.tsx b/app/(protected)/layout.tsx index ffc457c..e5f4289 100644 --- a/app/(protected)/layout.tsx +++ b/app/(protected)/layout.tsx @@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic' import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' +import { NotificationBell } from '@/components/notifications/bell' export default async function ProtectedLayout({ children, @@ -16,5 +17,10 @@ export default async function ProtectedLayout({ if (!user) redirect('/login') - return <>{children} + return ( + <> + + {children} + + ) } diff --git a/app/(protected)/management/page.tsx b/app/(protected)/management/page.tsx index 4c8d7a7..426a19a 100644 --- a/app/(protected)/management/page.tsx +++ b/app/(protected)/management/page.tsx @@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic' import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' import { StatCard } from '@/components/dashboard/stat-card' +import { RiskFlagsPanel } from '@/components/dashboard/risk-flags-panel' export default async function ManagementPage() { const supabase = await createClient() @@ -71,13 +72,15 @@ export default async function ManagementPage() {

Management Dashboard

Export CSV
+ + {/* Summary stats */}
+}) { const supabase = await createClient() + const params = await searchParams + const page = Math.max(1, Number.parseInt(params.page ?? '1', 10) || 1) + const from = (page - 1) * PAGE_SIZE - const { data: incidents } = await supabase + const { data: incidents, count } = await supabase .from('incidents') .select(` id, reference_no, incident_type, status, severity, reported_at, sites (name), zones (name), reporter:users!reported_by (name) - `) + `, { count: 'exact' }) .order('reported_at', { ascending: false }) - .limit(100) + .range(from, from + PAGE_SIZE - 1) return (

Incident Inbox

- {incidents?.length ?? 0} incidents + {count ?? incidents?.length ?? 0} incidents
+
) } diff --git a/app/api/admin/sites/route.ts b/app/api/admin/sites/route.ts new file mode 100644 index 0000000..1dcd3a0 --- /dev/null +++ b/app/api/admin/sites/route.ts @@ -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 }) +} diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts new file mode 100644 index 0000000..9b3b368 --- /dev/null +++ b/app/api/admin/users/route.ts @@ -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 = {} + 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 }) +} diff --git a/app/api/capa/[id]/verify/route.ts b/app/api/capa/[id]/verify/route.ts index 5f969b1..2b2b7fc 100644 --- a/app/api/capa/[id]/verify/route.ts +++ b/app/api/capa/[id]/verify/route.ts @@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' +import { createInAppNotifications } from '@/lib/notifications/in-app' export async function POST( request: NextRequest, @@ -19,7 +20,7 @@ export async function POST( return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) const { data: capa } = await supabase - .from('capa_actions').select('status, incident_id').eq('id', id).single() + .from('capa_actions').select('status, incident_id, owner_user_id').eq('id', id).single() if (!capa) return NextResponse.json({ error: 'Not found' }, { status: 404 }) if (capa.status !== 'pending_verification') return NextResponse.json({ error: 'CAPA must be pending_verification' }, { status: 409 }) @@ -56,6 +57,18 @@ export async function POST( p_new_value: { ...update, reopen_reason: body.reopen_reason ?? null }, }) + if (capa.owner_user_id) { + await createInAppNotifications(supabase, [{ + userId: capa.owner_user_id, + title: body.verdict === 'verified' + ? 'Your CAPA action was verified' + : `Your CAPA action was reopened${body.reopen_reason ? `: ${body.reopen_reason}` : ''}`, + link: `/hse/capa/${id}`, + incidentId: capa.incident_id, + capaId: id, + }]) + } + // Check if all CAPAs for this incident are verified — if so, transition incident to verification const { data: openCapas } = await supabase .from('capa_actions') diff --git a/app/api/capa/route.ts b/app/api/capa/route.ts index b7afe30..ef7d2a5 100644 --- a/app/api/capa/route.ts +++ b/app/api/capa/route.ts @@ -2,6 +2,7 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' +import { createInAppNotifications } from '@/lib/notifications/in-app' export async function GET() { const supabase = await createClient() @@ -70,5 +71,13 @@ export async function POST(request: NextRequest) { p_new_value: { incident_id, description, owner_user_id, department, due_date }, }) + await createInAppNotifications(supabase, [{ + userId: owner_user_id, + title: `CAPA assigned to you, due ${due_date}`, + link: `/hse/capa/${capa.id}`, + incidentId: incident_id, + capaId: capa.id, + }]) + return NextResponse.json({ id: capa.id }, { status: 201 }) } diff --git a/app/api/dashboard/ai/risk-flags/route.ts b/app/api/dashboard/ai/risk-flags/route.ts new file mode 100644 index 0000000..366b368 --- /dev/null +++ b/app/api/dashboard/ai/risk-flags/route.ts @@ -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() + 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> + 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).zone === 'string' && + typeof (f as Record).site === 'string' && + ['low', 'medium', 'high'].includes((f as Record).risk_level as string) && + typeof (f as Record).rationale === 'string' && + typeof (f as Record).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 }) +} diff --git a/app/api/incidents/[id]/addenda/route.ts b/app/api/incidents/[id]/addenda/route.ts new file mode 100644 index 0000000..78a8210 --- /dev/null +++ b/app/api/incidents/[id]/addenda/route.ts @@ -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 }) +} diff --git a/app/api/incidents/[id]/close/route.ts b/app/api/incidents/[id]/close/route.ts new file mode 100644 index 0000000..93afcc7 --- /dev/null +++ b/app/api/incidents/[id]/close/route.ts @@ -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 }) +} diff --git a/app/api/incidents/[id]/investigation/route.ts b/app/api/incidents/[id]/investigation/route.ts index 363ff75..2e126d9 100644 --- a/app/api/incidents/[id]/investigation/route.ts +++ b/app/api/incidents/[id]/investigation/route.ts @@ -37,6 +37,8 @@ export async function POST( root_cause_summary: body.root_cause_summary ?? null, five_why_steps: method === 'five_why' ? (body.five_why_steps ?? []) : null, fishbone_categories: method === 'fishbone' ? (body.fishbone_categories ?? []) : null, + alcohol_test_result: body.alcohol_test_result ?? null, + witness_statement_refs: Array.isArray(body.witness_statement_refs) ? body.witness_statement_refs : [], }) .select('id') .single() @@ -83,6 +85,8 @@ export async function PATCH( root_cause_summary: fields.root_cause_summary ?? null, five_why_steps: fields.five_why_steps ?? null, fishbone_categories: fields.fishbone_categories ?? null, + alcohol_test_result: fields.alcohol_test_result ?? null, + witness_statement_refs: Array.isArray(fields.witness_statement_refs) ? fields.witness_statement_refs : [], } if (complete) updateData.completed_at = new Date().toISOString() diff --git a/app/api/incidents/route.ts b/app/api/incidents/route.ts index c55dc7b..c4c92cc 100644 --- a/app/api/incidents/route.ts +++ b/app/api/incidents/route.ts @@ -1,9 +1,10 @@ import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' -import { validateIncidentInput, type IncidentType, type MedicalStatus } from '@/lib/incidents/validate' +import { validateIncidentInput, validateTypeDetails, type IncidentType, type MedicalStatus } from '@/lib/incidents/validate' import { uploadEvidenceFile, type EvidenceStage } from '@/lib/supabase/storage' import { sendNewIncidentEmail } from '@/lib/notifications/email' import { sendWhatsAppMessage } from '@/lib/notifications/whatsapp' +import { createInAppNotifications } from '@/lib/notifications/in-app' import { getApiKey } from '@/lib/settings' export const dynamic = 'force-dynamic' @@ -42,6 +43,21 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Validation failed', details: validation.errors }, { status: 422 }) } + let rawDetails: Record | 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 + } + const detailsCheck = validateTypeDetails(input.incident_type, rawDetails) + if (!detailsCheck.ok) { + return NextResponse.json({ error: 'Validation failed', details: detailsCheck.errors }, { status: 422 }) + } + const { data: zone, error: zoneError } = await supabase .from('zones') .select('id, site_id') @@ -63,6 +79,7 @@ export async function POST(request: Request) { injury_involved: input.injury_involved, asset_involved: input.asset_involved, medical_status: input.injury_involved ? (input.medical_status ?? 'none') : null, + type_details: detailsCheck.sanitized, }) .select('id, reference_no') .single() @@ -121,9 +138,20 @@ export async function POST(request: Request) { const siteName = (siteData as { name: string } | null)?.name ?? 'Unknown' const { data: recipients } = await supabase .from('users') - .select('phone') + .select('id, phone') .in('role', ['supervisor', 'hse']) .eq('site_id', zone.site_id) + + await createInAppNotifications( + supabase, + (recipients ?? []).map((r: { id: string }) => ({ + userId: r.id, + title: `New ${input.incident_type.replace(/_/g, ' ')} incident ${incident.reference_no ?? ''} at ${siteName}`, + link: `/hse/incidents/${incident.id}`, + incidentId: incident.id, + })), + ) + for (const r of recipients ?? []) { const phone = (r as { phone: string | null }).phone ?? '' if (!phone) continue diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts new file mode 100644 index 0000000..b1782e1 --- /dev/null +++ b/app/api/notifications/route.ts @@ -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 }) +} diff --git a/app/api/reports/jkkp8/route.ts b/app/api/reports/jkkp8/route.ts new file mode 100644 index 0000000..50de24f --- /dev/null +++ b/app/api/reports/jkkp8/route.ts @@ -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"`, + }, + }) +} diff --git a/components/admin/site-zone-manager.tsx b/components/admin/site-zone-manager.tsx new file mode 100644 index 0000000..01f0623 --- /dev/null +++ b/components/admin/site-zone-manager.tsx @@ -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(null) + + const post = async (payload: Record) => { + 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 ( +
+

Sites & Zones

+ +
+ setSiteName(e.target.value)} + className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-48" + /> + +
+ +
+ + setZoneName(e.target.value)} + className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-48" + /> + +
+ + {error &&

{error}

} + +
+ {sites.map(site => ( +
+

{site.name}

+ {site.address &&

{site.address}

} + {site.zones.length > 0 ? ( + + ) : ( +

No zones

+ )} +
+ ))} + {sites.length === 0 &&

No sites yet

} +
+
+ ) +} diff --git a/components/admin/user-manager.tsx b/components/admin/user-manager.tsx new file mode 100644 index 0000000..d5c478b --- /dev/null +++ b/components/admin/user-manager.tsx @@ -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(null) + const [error, setError] = useState(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) => { + 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 ( +
+

Users

+ +
+ setInvite(v => ({ ...v, email: e.target.value }))} + className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-52" + /> + setInvite(v => ({ ...v, name: e.target.value }))} + className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-40" + /> + + + +
+ + {error &&

{error}

} + +
+ + + + + + + + + + + + {users.map(u => ( + + + + + + + + ))} + +
NameEmailRoleSiteStatus
{u.name || '—'}{u.email} + + + + + +
+
+
+ ) +} diff --git a/components/capa/verify-form.tsx b/components/capa/verify-form.tsx index 5e58a2d..1f67cef 100644 --- a/components/capa/verify-form.tsx +++ b/components/capa/verify-form.tsx @@ -23,7 +23,7 @@ export function VerifyForm({ capaId }: Props) { } setSaving(true) setError(null) - const res = await fetch(`/api/capa/${capaId}/verify`, { + const res = await fetch(`/ims/api/capa/${capaId}/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ verdict, reopen_reason: reopenReason || null }), diff --git a/components/dashboard/risk-flags-panel.tsx b/components/dashboard/risk-flags-panel.tsx new file mode 100644 index 0000000..810569e --- /dev/null +++ b/components/dashboard/risk-flags-panel.tsx @@ -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 = { + 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(null) + const [summary, setSummary] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( +
+
+

+ Rising-Risk Zones (AI) +

+ +
+

+ AI suggestion from 90-day incident aggregates — review before acting. +

+ + {error &&

{error}

} + + {flags && flags.length === 0 && !error && ( +

No zones flagged — no rising-risk pattern detected.

+ )} + + {flags && flags.length > 0 && ( + <> +

{summary}

+
+ {flags.map(f => ( +
+
+ {f.zone} · {f.site} + {f.risk_level} +
+

{f.rationale}

+

Recommended: {f.recommended_action}

+
+ ))} +
+ + )} +
+ ) +} diff --git a/components/incidents/closure-panel.tsx b/components/incidents/closure-panel.tsx new file mode 100644 index 0000000..d47d7b7 --- /dev/null +++ b/components/incidents/closure-panel.tsx @@ -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([]) + const [draft, setDraft] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(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 ( +
+ {status === 'verification' ? ( + + ) : null} + {error &&

{error}

} +
+ ) + } + + return ( +
+

Addenda

+

+ This incident is closed and locked. New information is recorded as addenda. +

+ {addenda.length === 0 ? ( +

No addenda.

+ ) : ( +
    + {addenda.map(a => ( +
  • +

    {a.body}

    +

    + {a.author?.name ?? 'Unknown'} · {new Date(a.created_at).toLocaleString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })} +

    +
  • + ))} +
+ )} + {canAddAddenda && ( +
+