From dfca87262942d0cb9d7c3a66608e91e955490665 Mon Sep 17 00:00:00 2001 From: weeihan Date: Sat, 11 Jul 2026 18:44:52 +0800 Subject: [PATCH] feat: CSV export endpoint for HSE and management dashboards --- app/(protected)/hse/dashboard/page.tsx | 14 ++++- app/(protected)/management/page.tsx | 10 ++- app/api/dashboard/export/route.ts | 84 ++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 app/api/dashboard/export/route.ts diff --git a/app/(protected)/hse/dashboard/page.tsx b/app/(protected)/hse/dashboard/page.tsx index 60145c1..b91396e 100644 --- a/app/(protected)/hse/dashboard/page.tsx +++ b/app/(protected)/hse/dashboard/page.tsx @@ -109,9 +109,17 @@ export default async function HseDashboardPage() {

Dashboard

- - View all incidents → - +
+ + Export CSV + + + View all incidents → + +
{/* Summary stats */} diff --git a/app/(protected)/management/page.tsx b/app/(protected)/management/page.tsx index b927405..4c8d7a7 100644 --- a/app/(protected)/management/page.tsx +++ b/app/(protected)/management/page.tsx @@ -68,7 +68,15 @@ export default async function ManagementPage() { return (
-

Management Dashboard

+
+

Management Dashboard

+ + Export CSV + +
{/* Summary stats */}
diff --git a/app/api/dashboard/export/route.ts b/app/api/dashboard/export/route.ts new file mode 100644 index 0000000..b007602 --- /dev/null +++ b/app/api/dashboard/export/route.ts @@ -0,0 +1,84 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +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 +} + +function rowsToCsv(headers: string[], rows: string[][]): string { + const lines = [headers.map(escapeCsv).join(',')] + for (const row of rows) lines.push(row.map(escapeCsv).join(',')) + return lines.join('\r\n') +} + +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) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const role = request.nextUrl.searchParams.get('role') ?? 'hse' + + const allowedRoles: Record = { + hse: ['hse', 'admin'], + management: ['management', 'admin'], + } + + if (!allowedRoles[role] || !allowedRoles[role].includes(profile.role)) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const { data: incidents } = await supabase + .from('incidents') + .select(` + reference_no, incident_type, status, severity, reported_at, closed_at, + injury_involved, medical_status, lost_days, + sites (name), zones (name) + `) + .order('reported_at', { ascending: false }) + + const rows = incidents ?? [] + + const headers = [ + 'Reference', 'Type', 'Status', 'Severity', 'Site', 'Zone', + 'Reported At', 'Closed At', 'Injury Involved', 'Medical Status', 'Lost Days', + ] + + const csvRows = rows.map(inc => { + const siteName = (inc.sites as unknown as { name: string } | null)?.name ?? '' + const zoneName = (inc.zones as unknown as { name: string } | null)?.name ?? '' + return [ + inc.reference_no ?? '', + inc.incident_type, + inc.status, + String(inc.severity ?? ''), + siteName, + zoneName, + inc.reported_at ? new Date(inc.reported_at as string).toISOString().split('T')[0] : '', + inc.closed_at ? new Date(inc.closed_at as string).toISOString().split('T')[0] : '', + inc.injury_involved ? 'Yes' : 'No', + inc.medical_status ?? '', + String(inc.lost_days ?? ''), + ] + }) + + const csv = rowsToCsv(headers, csvRows) + const filename = `incidents-${role}-${new Date().toISOString().split('T')[0]}.csv` + + return new NextResponse(csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }) +}