From c0ec6660efd3aeb53dc93b547f49f2243ddb426b Mon Sep 17 00:00:00 2001 From: weeihan Date: Sun, 12 Jul 2026 16:44:42 +0800 Subject: [PATCH] feat: sticky incident header with top-positioned action buttons (fix #5) --- app/(protected)/hse/dashboard/page.tsx | 331 +++++++++--------- app/(protected)/hse/incidents/[id]/page.tsx | 105 +++--- app/(protected)/hse/incidents/page.tsx | 23 +- app/(protected)/supervisor/incidents/page.tsx | 23 +- components/incidents/incident-detail.tsx | 6 +- components/incidents/incident-header.tsx | 81 +++++ 6 files changed, 334 insertions(+), 235 deletions(-) create mode 100644 components/incidents/incident-header.tsx diff --git a/app/(protected)/hse/dashboard/page.tsx b/app/(protected)/hse/dashboard/page.tsx index c52acc3..878e1c8 100644 --- a/app/(protected)/hse/dashboard/page.tsx +++ b/app/(protected)/hse/dashboard/page.tsx @@ -5,6 +5,7 @@ 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' +import { DashboardTabs } from '@/components/dashboard/dashboard-tabs' const TYPE_LABELS: Record = { injury: 'Injury', @@ -16,8 +17,14 @@ const TYPE_LABELS: Record = { fire: 'Fire', } -export default async function HseDashboardPage() { +export default async function HseDashboardPage({ + searchParams, +}: { + searchParams: Promise<{ tab?: string }> +}) { const supabase = await createClient() + const params = await searchParams + const tab = params.tab ?? 'overview' const now = new Date() const thirtyDaysAgo = new Date(now) @@ -63,7 +70,6 @@ export default async function HseDashboardPage() { .not('root_cause_summary', 'is', null), ]) - // --- Existing metrics --- const rows = incidents ?? [] const total = rows.length const closed = rows.filter(r => r.status === 'closed').length @@ -83,12 +89,10 @@ export default async function HseDashboardPage() { .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count) - // --- Leading / lagging (last 30 days) --- const recent = recentIncidents ?? [] const leadingCount = recent.filter(r => ['hazard', 'near_miss'].includes(r.incident_type)).length const laggingCount = recent.filter(r => r.incident_type === 'injury').length - // --- Zone heatmap (last 90 days) --- const zoneMap: Record = {} for (const r of zoneIncidents ?? []) { const name = (r.zones as unknown as { name: string } | null)?.name ?? 'Unknown' @@ -100,7 +104,6 @@ export default async function HseDashboardPage() { .slice(0, 10) const zoneMax = by_zone[0]?.count ?? 1 - // --- CAPA on-time rate --- const capas = completedCapas ?? [] const onTime = capas.filter(c => { const due = new Date(c.due_date) @@ -115,17 +118,15 @@ export default async function HseDashboardPage() { ? Math.round((onTime.length / capas.length) * 100) : null - // --- 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

- JKKP 8 Register + JKKP 8 - View all incidents → + 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 */} -
- - - - 0 ? 'yellow' : 'green'} - sub="filings outstanding" - /> -
- - {/* Leading vs lagging — last 30 days */} -
-

- Leading vs Lagging — Last 30 Days -

-

- Leading: hazard + near-miss reports (predict risk) · Lagging: injuries (past harm) -

-
-
-

{leadingCount}

-

Leading (Hazards + Near Misses)

-
-
-

{laggingCount}

-

Lagging (Injuries)

-
- {capaOnTimeRate !== null && ( -
-

{capaOnTimeRate}%

-

CAPA On-Time Rate

+ {/* Overview tab */} + {(tab === 'overview') && ( + <> + {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 + +

)} -
-
- - - {/* 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 && ( -
-

- Zone Incident Heatmap — Last 90 Days -

-
- {by_zone.map(({ name, count }) => ( -
- {name} -
-
-
- {count} -
- ))} +
+ + + + 0 ? 'yellow' : 'green'} + sub="filings outstanding" + />
-
+ +
+

+ Leading vs Lagging — Last 30 Days +

+

+ Leading: hazard + near-miss reports (predict risk) · Lagging: injuries (past harm) +

+
+
+

{leadingCount}

+

Leading (Hazards + Near Misses)

+
+
+

{laggingCount}

+

Lagging (Injuries)

+
+ {capaOnTimeRate !== null && ( +
+

{capaOnTimeRate}%

+

CAPA On-Time Rate

+
+ )} +
+
+ )} - {/* Incident type breakdown */} -
-

By Incident Type

-
- {Object.entries(by_type).sort((a, b) => b[1] - a[1]).map(([type, count]) => ( -
- {TYPE_LABELS[type] ?? type} -
-
0 ? `${(count / total) * 100}%` : '0%' }} - /> -
- {count} + {/* Trends tab */} + {tab === 'trends' && ( + <> +
+

+ 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 || ''} +
+ ) + })}
- ))} - {Object.keys(by_type).length === 0 && ( -

No incidents yet

- )} -
-
+
- {/* By site */} -
-

By Site

-
- {by_site.map(({ name, count }) => ( -
- {name} - {count} + {rootCauses.length > 0 && ( +
+

Top Root Causes

+
    + {rootCauses.map((rc, i) => ( +
  1. + + {i + 1}. + {rc.cause} + + {rc.count}× +
  2. + ))} +
- ))} - {by_site.length === 0 &&

No data

} -
-
+ )} + + )} + + {/* Zones & Types tab */} + {tab === 'zones' && ( + <> + {by_zone.length > 0 && ( +
+

+ Zone Incident Heatmap — Last 90 Days +

+
+ {by_zone.map(({ name, count }) => ( +
+ {name} +
+
+
+ {count} +
+ ))} +
+
+ )} + +
+

By Incident Type

+
+ {Object.entries(by_type).sort((a, b) => b[1] - a[1]).map(([type, count]) => ( +
+ {TYPE_LABELS[type] ?? type} +
+
0 ? `${(count / total) * 100}%` : '0%' }} + /> +
+ {count} +
+ ))} + {Object.keys(by_type).length === 0 && ( +

No incidents yet

+ )} +
+
+ +
+

By Site

+
+ {by_site.map(({ name, count }) => ( +
+ {name} + {count} +
+ ))} + {by_site.length === 0 &&

No data

} +
+
+ + )} + + {/* AI Insights tab */} + {tab === 'ai' && }
) } diff --git a/app/(protected)/hse/incidents/[id]/page.tsx b/app/(protected)/hse/incidents/[id]/page.tsx index f0901da..d5331b3 100644 --- a/app/(protected)/hse/incidents/[id]/page.tsx +++ b/app/(protected)/hse/incidents/[id]/page.tsx @@ -1,9 +1,9 @@ export const dynamic = 'force-dynamic' import { notFound } from 'next/navigation' -import Link from 'next/link' import { createClient } from '@/lib/supabase/server' import { IncidentDetail, type Incident } from '@/components/incidents/incident-detail' +import { IncidentHeader } from '@/components/incidents/incident-header' import { SimilarIncidentsPanel } from '@/components/incidents/similar-incidents-panel' import { ClosurePanel } from '@/components/incidents/closure-panel' @@ -35,69 +35,48 @@ export default async function HseIncidentDetailPage({ params }: Props) { const status = (incident as { status: string }).status + const needsJkkp = Boolean( + (incident as { is_fatality?: boolean }).is_fatality || + (incident as { is_serious_bodily_injury?: boolean }).is_serious_bodily_injury || + (incident as { is_dangerous_occurrence?: boolean }).is_dangerous_occurrence || + ((incident as { lost_days?: number | null }).lost_days ?? 0) >= 4 + ) + return ( -
- - ← Back to inbox - - + <> + +
+ - {status === 'reported' && ( -
- - Triage Incident - -
- )} - {status === 'triaged' && ( -
- - Start Investigation - -
- )} - {(['investigating', 'capa_pending'] as string[]).includes(status) && ( -
- - Add CAPA Action - -
- )} - - {Boolean( - (incident as { is_fatality?: boolean }).is_fatality || - (incident as { is_serious_bodily_injury?: boolean }).is_serious_bodily_injury || - (incident as { is_dangerous_occurrence?: boolean }).is_dangerous_occurrence || - ((incident as { lost_days?: number | null }).lost_days ?? 0) >= 4 - ) && ( - - )} - - -
+ {needsJkkp && ( + + )} + + +
+ ) } diff --git a/app/(protected)/hse/incidents/page.tsx b/app/(protected)/hse/incidents/page.tsx index da04284..b7089b9 100644 --- a/app/(protected)/hse/incidents/page.tsx +++ b/app/(protected)/hse/incidents/page.tsx @@ -3,20 +3,21 @@ 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' +import { IncidentFilters, type SiteOption } from '@/components/incidents/incident-filters' const PAGE_SIZE = 25 export default async function HseInboxPage({ searchParams, }: { - searchParams: Promise<{ page?: string }> + searchParams: Promise<{ page?: string; q?: string; status?: string; type?: string; site_id?: 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, count } = await supabase + let query = supabase .from('incidents') .select(` id, reference_no, incident_type, status, severity, reported_at, @@ -25,14 +26,28 @@ export default async function HseInboxPage({ reporter:users!reported_by (name) `, { count: 'exact' }) .order('reported_at', { ascending: false }) - .range(from, from + PAGE_SIZE - 1) + + if (params.q) { + query = query.or(`reference_no.ilike.%${params.q}%,description.ilike.%${params.q}%`) + } + if (params.status) query = query.eq('status', params.status) + if (params.type) query = query.eq('incident_type', params.type) + if (params.site_id) query = query.eq('site_id', params.site_id) + + const [{ data: incidents, count }, { data: sites }] = await Promise.all([ + query.range(from, from + PAGE_SIZE - 1), + supabase.from('sites').select('id, name').order('name'), + ]) + + const siteOptions: SiteOption[] = (sites ?? []).map(s => ({ id: s.id, name: s.name })) return (
-
+

Incident Inbox

{count ?? incidents?.length ?? 0} incidents
+
diff --git a/app/(protected)/supervisor/incidents/page.tsx b/app/(protected)/supervisor/incidents/page.tsx index 5374b3d..1889066 100644 --- a/app/(protected)/supervisor/incidents/page.tsx +++ b/app/(protected)/supervisor/incidents/page.tsx @@ -3,20 +3,21 @@ 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' +import { IncidentFilters, type SiteOption } from '@/components/incidents/incident-filters' const PAGE_SIZE = 25 export default async function SupervisorInboxPage({ searchParams, }: { - searchParams: Promise<{ page?: string }> + searchParams: Promise<{ page?: string; q?: string; status?: string; type?: string; site_id?: 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, count } = await supabase + let query = supabase .from('incidents') .select(` id, reference_no, incident_type, status, severity, reported_at, @@ -25,14 +26,28 @@ export default async function SupervisorInboxPage({ reporter:users!reported_by (name) `, { count: 'exact' }) .order('reported_at', { ascending: false }) - .range(from, from + PAGE_SIZE - 1) + + if (params.q) { + query = query.or(`reference_no.ilike.%${params.q}%,description.ilike.%${params.q}%`) + } + if (params.status) query = query.eq('status', params.status) + if (params.type) query = query.eq('incident_type', params.type) + if (params.site_id) query = query.eq('site_id', params.site_id) + + const [{ data: incidents, count }, { data: sites }] = await Promise.all([ + query.range(from, from + PAGE_SIZE - 1), + supabase.from('sites').select('id, name').order('name'), + ]) + + const siteOptions: SiteOption[] = (sites ?? []).map(s => ({ id: s.id, name: s.name })) return (
-
+

Incident Inbox

{count ?? incidents?.length ?? 0} incidents
+
diff --git a/components/incidents/incident-detail.tsx b/components/incidents/incident-detail.tsx index 659b0fe..91a3d71 100644 --- a/components/incidents/incident-detail.tsx +++ b/components/incidents/incident-detail.tsx @@ -59,7 +59,7 @@ interface Props { function Field({ label, value }: { label: string; value: React.ReactNode }) { return (
-
{label}
+
{label}
{value ?? '—'}
) @@ -107,12 +107,12 @@ export function IncidentDetail({ incident }: Props) {
-

Description

+

Description

{incident.description}

-

+

Evidence — Report Stage {reportStageFiles.length} file{reportStageFiles.length !== 1 ? 's' : ''} diff --git a/components/incidents/incident-header.tsx b/components/incidents/incident-header.tsx new file mode 100644 index 0000000..901a2a0 --- /dev/null +++ b/components/incidents/incident-header.tsx @@ -0,0 +1,81 @@ +'use client' + +import Link from 'next/link' + +interface Props { + incidentId: string + referenceNo: string | null + status: string + backHref: string + canTriage?: boolean + canInvestigate?: boolean + canAddCapa?: boolean +} + +const STATUS_COLORS: Record = { + reported: 'bg-yellow-100 text-yellow-800', + triaged: 'bg-blue-100 text-blue-800', + investigating: 'bg-purple-100 text-purple-800', + capa_pending: 'bg-orange-100 text-orange-800', + verification: 'bg-indigo-100 text-indigo-800', + closed: 'bg-green-100 text-green-800', +} + +const STATUS_LABELS: Record = { + reported: 'Reported', + triaged: 'Triaged', + investigating: 'Investigating', + capa_pending: 'CAPA Pending', + verification: 'Verification', + closed: 'Closed', +} + +export function IncidentHeader({ + incidentId, referenceNo, status, backHref, + canTriage, canInvestigate, canAddCapa, +}: Props) { + return ( +
+
+
+ + ← Incidents + + | + + {referenceNo ?? 'Pending'} + + + {STATUS_LABELS[status] ?? status.replace(/_/g, ' ')} + +
+
+ {canTriage && ( + + Triage + + )} + {canInvestigate && ( + + Investigate + + )} + {canAddCapa && ( + + Add CAPA + + )} +
+
+
+ ) +}