From f591c0be18670894c0e06e5b5b97cbd9f1c0f555 Mon Sep 17 00:00:00 2001 From: weeihan Date: Thu, 23 Jul 2026 17:29:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(db):=20phase=204=20group=206=20=E2=80=94?= =?UTF-8?q?=20server=20component=20pages=20to=20Drizzle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts all 18 server component page files from Supabase client queries to Drizzle ORM using asAdmin. Adds getSession() + redirect to the three pages (hse/incidents, hse/incidents/[id], hse/dashboard) that lacked it. Maps snake_case component prop shapes explicitly where required. Co-Authored-By: Claude Sonnet 4.6 --- app/(protected)/admin/page.tsx | 69 +++++-- app/(protected)/capa-owner/page.tsx | 43 +++-- app/(protected)/hse/capa/[id]/page.tsx | 69 ++++--- app/(protected)/hse/capa/page.tsx | 46 +++-- app/(protected)/hse/dashboard/page.tsx | 137 ++++++++------ .../hse/incidents/[id]/capa/new/page.tsx | 19 +- .../hse/incidents/[id]/investigation/page.tsx | 39 ++-- app/(protected)/hse/incidents/[id]/page.tsx | 175 ++++++++++++++---- .../hse/incidents/[id]/triage/page.tsx | 26 ++- app/(protected)/hse/incidents/page.tsx | 103 ++++++++--- app/(protected)/hse/settings/page.tsx | 17 +- app/(protected)/management/page.tsx | 68 ++++--- .../reporter/incidents/[id]/page.tsx | 68 ++++--- app/(protected)/reporter/page.tsx | 78 ++++---- .../supervisor/incidents/[id]/page.tsx | 148 ++++++++++++--- app/(protected)/supervisor/incidents/page.tsx | 103 ++++++++--- app/(protected)/supervisor/page.tsx | 126 +++++++------ app/report/page.tsx | 48 +++-- 18 files changed, 932 insertions(+), 450 deletions(-) diff --git a/app/(protected)/admin/page.tsx b/app/(protected)/admin/page.tsx index 090ea7b..444aebb 100644 --- a/app/(protected)/admin/page.tsx +++ b/app/(protected)/admin/page.tsx @@ -2,8 +2,10 @@ export const dynamic = 'force-dynamic' import Link from 'next/link' import { redirect } from 'next/navigation' -import { createClient } from '@/lib/supabase/server' +import { asAdmin } from '@/lib/db/with-user' import { getSession } from '@/lib/auth/get-session' +import { users, sites, zones, trucks } from '@/lib/db/schema' +import { desc, asc } from 'drizzle-orm' import { UserManager, type AdminUser, type SiteOption } from '@/components/admin/user-manager' import { SiteZoneManager, type SiteWithZones } from '@/components/admin/site-zone-manager' import { TruckManager, type Truck } from '@/components/admin/truck-manager' @@ -13,24 +15,51 @@ export default async function AdminHome() { if (!session) redirect('/login') if (session.role !== 'admin') redirect('/login') - const supabase = await createClient() - - const [{ data: users }, { data: sites }, { data: trucks }] = 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, active, zones (id, name, qr_code_token, active)') - .order('name'), - supabase - .from('trucks') - .select('id, truck_no, carrier, active') - .order('truck_no'), + const [userRows, siteRows, zoneRows, truckRows] = await Promise.all([ + asAdmin(db => db.select({ + id: users.id, name: users.name, email: users.email, role: users.role, + department: users.department, siteId: users.siteId, active: users.active, + }).from(users).orderBy(desc(users.createdAt))), + asAdmin(db => db.select({ + id: sites.id, name: sites.name, address: sites.address, active: sites.active, + }).from(sites).orderBy(asc(sites.name))), + asAdmin(db => db.select({ + id: zones.id, siteId: zones.siteId, name: zones.name, + qrCodeToken: zones.qrCodeToken, active: zones.active, + }).from(zones)), + asAdmin(db => db.select({ + id: trucks.id, truckNo: trucks.truckNo, carrier: trucks.carrier, active: trucks.active, + }).from(trucks).orderBy(asc(trucks.truckNo))), ]) - const siteOptions: SiteOption[] = (sites ?? []).map(s => ({ id: s.id, name: s.name })) + const adminUsers: AdminUser[] = userRows.map(u => ({ + id: u.id, + name: u.name, + email: u.email, + role: u.role, + department: u.department ?? null, + site_id: u.siteId ?? null, + active: u.active, + })) + + const sitesWithZones: SiteWithZones[] = siteRows.map(s => ({ + id: s.id, + name: s.name, + address: s.address ?? null, + active: s.active, + zones: zoneRows + .filter(z => z.siteId === s.id) + .map(z => ({ id: z.id, name: z.name, qr_code_token: z.qrCodeToken, active: z.active })), + })) + + const siteOptions: SiteOption[] = siteRows.map(s => ({ id: s.id, name: s.name })) + + const truckList: Truck[] = truckRows.map(t => ({ + id: t.id, + truck_no: t.truckNo, + carrier: t.carrier ?? null, + active: t.active, + })) return (
@@ -43,9 +72,9 @@ export default async function AdminHome() { Management - - - + + +
) } diff --git a/app/(protected)/capa-owner/page.tsx b/app/(protected)/capa-owner/page.tsx index 9c8437e..aa113b9 100644 --- a/app/(protected)/capa-owner/page.tsx +++ b/app/(protected)/capa-owner/page.tsx @@ -1,9 +1,11 @@ export const dynamic = 'force-dynamic' import Link from 'next/link' -import { createClient } from '@/lib/supabase/server' +import { asAdmin } from '@/lib/db/with-user' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth/get-session' +import { capaActions, incidents } from '@/lib/db/schema' +import { eq, asc } from 'drizzle-orm' import { StatCard } from '@/components/dashboard/stat-card' import { CapaOwnerActions } from '@/components/capa/capa-owner-actions' import { CapaOwnerNotesForm } from '@/components/capa/capa-owner-notes-form' @@ -33,15 +35,23 @@ export default async function CapaOwnerPage() { if (!session) redirect('/login') if (!['capa_owner', 'admin', 'hse', 'supervisor'].includes(session.role)) redirect('/') - const supabase = await createClient() + const rows = await asAdmin(db => + db.select({ + id: capaActions.id, + description: capaActions.description, + dueDate: capaActions.dueDate, + priority: capaActions.priority, + status: capaActions.status, + incidentId: capaActions.incidentId, + ownerNotes: capaActions.ownerNotes, + incidentRefNo: incidents.referenceNo, + }) + .from(capaActions) + .leftJoin(incidents, eq(capaActions.incidentId, incidents.id)) + .where(eq(capaActions.ownerUserId, session.sub)) + .orderBy(asc(capaActions.dueDate)) + ) - const { data: capas } = await supabase - .from('capa_actions') - .select('id, description, due_date, priority, status, incident_id, owner_notes, incidents (reference_no)') - .eq('owner_user_id', session.sub) - .order('due_date', { ascending: true, nullsFirst: false }) - - const rows = capas ?? [] const openCount = rows.filter(c => ['open', 'in_progress', 'reopened'].includes(c.status)).length const overdueCount = rows.filter(c => c.status === 'overdue').length const doneCount = rows.filter(c => ['verified', 'closed'].includes(c.status)).length @@ -72,16 +82,15 @@ export default async function CapaOwnerPage() { ) : (
{activeRows.map(capa => { - const incRef = (capa.incidents as unknown as { reference_no: string | null } | null)?.reference_no const isOverdue = capa.status === 'overdue' return (

{capa.description}

- {incRef && ( - - {incRef} + {capa.incidentRefNo && ( + + {capa.incidentRefNo} )}
@@ -90,18 +99,18 @@ export default async function CapaOwnerPage() { {STATUS_LABELS[capa.status] ?? capa.status}

- {capa.due_date ? `Due ${new Date(capa.due_date as string).toLocaleDateString('en-MY')}` : 'No due date'} + {capa.dueDate ? `Due ${new Date(capa.dueDate).toLocaleDateString('en-MY')}` : 'No due date'}

{capa.priority && ( -

- {(capa.priority as string).toUpperCase()} priority +

+ {capa.priority.toUpperCase()} priority

)}
{capa.status !== 'pending_verification' && ( - + )}
) diff --git a/app/(protected)/hse/capa/[id]/page.tsx b/app/(protected)/hse/capa/[id]/page.tsx index 6735424..3c50fea 100644 --- a/app/(protected)/hse/capa/[id]/page.tsx +++ b/app/(protected)/hse/capa/[id]/page.tsx @@ -2,8 +2,11 @@ export const dynamic = 'force-dynamic' import { notFound, redirect } from 'next/navigation' import Link from 'next/link' -import { createClient } from '@/lib/supabase/server' +import { asAdmin } from '@/lib/db/with-user' import { getSession } from '@/lib/auth/get-session' +import { capaActions, incidents, users } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' +import { aliasedTable } from 'drizzle-orm' import { VerifyForm } from '@/components/capa/verify-form' import { CloseCapaButton } from '@/components/capa/close-capa-button' @@ -22,24 +25,34 @@ export default async function CapaDetailPage({ params }: Props) { const session = await getSession() if (!session) redirect(`/login?redirect=/hse/capa/${id}`) - const supabase = await createClient() + const ownerAlias = aliasedTable(users, 'owner') - const { data: capa } = await supabase - .from('capa_actions') - .select(` - id, incident_id, root_cause_ref, description, department, - due_date, priority, status, completed_at, verified_by, verified_at, created_at, - owner_notes, - incidents (reference_no, incident_type), - owner:users!owner_user_id (name, email) - `) - .eq('id', id) - .single() + const [capa] = await asAdmin(db => + db.select({ + id: capaActions.id, + incidentId: capaActions.incidentId, + rootCauseRef: capaActions.rootCauseRef, + description: capaActions.description, + department: capaActions.department, + dueDate: capaActions.dueDate, + priority: capaActions.priority, + status: capaActions.status, + completedAt: capaActions.completedAt, + ownerNotes: capaActions.ownerNotes, + incidentRefNo: incidents.referenceNo, + ownerName: ownerAlias.name, + }) + .from(capaActions) + .leftJoin(incidents, eq(capaActions.incidentId, incidents.id)) + .leftJoin(ownerAlias, eq(capaActions.ownerUserId, ownerAlias.id)) + .where(eq(capaActions.id, id)) + .limit(1) + ) if (!capa) notFound() const isHse = session && ['hse', 'admin'].includes(session.role) - const status = (capa as { status: string }).status + const status = capa.status return (
@@ -51,52 +64,50 @@ export default async function CapaDetailPage({ params }: Props) {

- Incident: {(capa.incidents as unknown as { reference_no: string | null } | null)?.reference_no ?? '—'} + Incident: {capa.incidentRefNo ?? '—'}

-

{(capa as { description: string }).description}

+

{capa.description}

- - {(capa as { priority: string }).priority} + + {capa.priority}

Owner

-

{(capa.owner as unknown as { name: string } | null)?.name ?? '—'}

+

{capa.ownerName ?? '—'}

Department

-

{(capa as { department: string }).department}

+

{capa.department}

Due Date

-

{(capa as { due_date: string }).due_date}

+

{capa.dueDate}

Status

{status.replace(/_/g, ' ')}

- {(capa as { owner_notes: string | null }).owner_notes && ( + {capa.ownerNotes && (

Owner Notes

-

- {(capa as { owner_notes: string }).owner_notes} -

+

{capa.ownerNotes}

)}
- {(capa as { root_cause_ref: string | null }).root_cause_ref && ( + {capa.rootCauseRef && (

Root Cause Reference

-

{(capa as { root_cause_ref: string }).root_cause_ref}

+

{capa.rootCauseRef}

)} - {(capa as { completed_at: string | null }).completed_at && ( + {capa.completedAt && (

- Completed: {new Date((capa as { completed_at: string }).completed_at).toLocaleString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })} + Completed: {new Date(capa.completedAt).toLocaleString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })}

)} diff --git a/app/(protected)/hse/capa/page.tsx b/app/(protected)/hse/capa/page.tsx index 74e354c..6a89495 100644 --- a/app/(protected)/hse/capa/page.tsx +++ b/app/(protected)/hse/capa/page.tsx @@ -2,24 +2,48 @@ export const dynamic = 'force-dynamic' import { redirect } from 'next/navigation' import Link from 'next/link' -import { createClient } from '@/lib/supabase/server' +import { asAdmin } from '@/lib/db/with-user' import { getSession } from '@/lib/auth/get-session' +import { capaActions, incidents, users } from '@/lib/db/schema' +import { eq, asc } from 'drizzle-orm' +import { aliasedTable } from 'drizzle-orm' import { CapaBoard } from '@/components/capa/capa-board' export default async function CapaListPage() { const session = await getSession() if (!session) redirect('/login?redirect=/hse/capa') - const supabase = await createClient() + const ownerAlias = aliasedTable(users, 'owner') - const { data: capas } = await supabase - .from('capa_actions') - .select(` - id, incident_id, description, department, due_date, priority, status, - incidents (reference_no, incident_type), - owner:users!owner_user_id (name, email) - `) - .order('due_date', { ascending: true }) + const rows = await asAdmin(db => + db.select({ + id: capaActions.id, + incidentId: capaActions.incidentId, + description: capaActions.description, + department: capaActions.department, + dueDate: capaActions.dueDate, + priority: capaActions.priority, + status: capaActions.status, + incidentRefNo: incidents.referenceNo, + ownerName: ownerAlias.name, + }) + .from(capaActions) + .leftJoin(incidents, eq(capaActions.incidentId, incidents.id)) + .leftJoin(ownerAlias, eq(capaActions.ownerUserId, ownerAlias.id)) + .orderBy(asc(capaActions.dueDate)) + ) + + const capas = rows.map(r => ({ + id: r.id, + incident_id: r.incidentId, + description: r.description, + department: r.department, + due_date: r.dueDate, + priority: r.priority, + status: r.status, + incidents: { reference_no: r.incidentRefNo ?? null }, + owner: r.ownerName ? { name: r.ownerName } : null, + })) return (
@@ -29,7 +53,7 @@ export default async function CapaListPage() { ← Incidents - [0]['capas']} /> + [0]['capas']} />
) } diff --git a/app/(protected)/hse/dashboard/page.tsx b/app/(protected)/hse/dashboard/page.tsx index 878e1c8..0e4d232 100644 --- a/app/(protected)/hse/dashboard/page.tsx +++ b/app/(protected)/hse/dashboard/page.tsx @@ -1,7 +1,11 @@ export const dynamic = 'force-dynamic' import Link from 'next/link' -import { createClient } from '@/lib/supabase/server' +import { asAdmin } from '@/lib/db/with-user' +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth/get-session' +import { incidents, sites, zones, capaActions, doshReports, investigations } from '@/lib/db/schema' +import { eq, gte, isNotNull } from 'drizzle-orm' import { StatCard } from '@/components/dashboard/stat-card' import { bucketIncidentsByMonth, topRootCauses } from '@/lib/dashboard/trends' import { RiskFlagsPanel } from '@/components/dashboard/risk-flags-panel' @@ -22,7 +26,9 @@ export default async function HseDashboardPage({ }: { searchParams: Promise<{ tab?: string }> }) { - const supabase = await createClient() + const session = await getSession() + if (!session) redirect('/login') + const params = await searchParams const tab = params.tab ?? 'overview' @@ -34,68 +40,89 @@ export default async function HseDashboardPage({ const twelveMonthsAgo = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 11, 1)) const [ - { data: incidents }, - { data: recentIncidents }, - { data: zoneIncidents }, - { data: completedCapas }, - { data: doshPendingRows }, - { data: yearIncidents }, - { data: investigations }, + allIncidents, + recentIncidents, + zoneIncidents, + completedCapas, + doshPendingRows, + yearIncidents, + investigationRows, ] = await Promise.all([ - supabase.from('incidents').select('id, status, incident_type, sites (name)'), - supabase - .from('incidents') - .select('incident_type') - .gte('reported_at', thirtyDaysAgo.toISOString()), - supabase - .from('incidents') - .select('zones (name)') - .gte('reported_at', ninetyDaysAgo.toISOString()) - .not('zone_id', 'is', null), - supabase - .from('capa_actions') - .select('due_date, completed_at, verified_at') - .not('completed_at', 'is', null), - supabase - .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), + asAdmin(db => + db.select({ + id: incidents.id, + status: incidents.status, + incidentType: incidents.incidentType, + siteName: sites.name, + }) + .from(incidents) + .leftJoin(sites, eq(incidents.siteId, sites.id)) + ), + asAdmin(db => + db.select({ incidentType: incidents.incidentType }) + .from(incidents) + .where(gte(incidents.reportedAt, thirtyDaysAgo)) + ), + asAdmin(db => + db.select({ zoneName: zones.name }) + .from(incidents) + .leftJoin(zones, eq(incidents.zoneId, zones.id)) + .where(gte(incidents.reportedAt, ninetyDaysAgo)) + ).then(rows => rows.filter(r => r.zoneName !== null)), + asAdmin(db => + db.select({ + dueDate: capaActions.dueDate, + completedAt: capaActions.completedAt, + verifiedAt: capaActions.verifiedAt, + }) + .from(capaActions) + .where(isNotNull(capaActions.completedAt)) + ), + asAdmin(db => + db.select({ id: doshReports.id }) + .from(doshReports) + .where(eq(doshReports.status, 'pending')) + ), + asAdmin(db => + db.select({ + reportedAt: incidents.reportedAt, + incidentType: incidents.incidentType, + }) + .from(incidents) + .where(gte(incidents.reportedAt, twelveMonthsAgo)) + ), + asAdmin(db => + db.select({ rootCauseSummary: investigations.rootCauseSummary }) + .from(investigations) + .where(isNotNull(investigations.rootCauseSummary)) + ), ]) - const rows = incidents ?? [] + const rows = allIncidents const total = rows.length const closed = rows.filter(r => r.status === 'closed').length const open = total - closed const by_type: Record = {} for (const r of rows) { - by_type[r.incident_type] = (by_type[r.incident_type] ?? 0) + 1 + by_type[r.incidentType] = (by_type[r.incidentType] ?? 0) + 1 } const siteMap: Record = {} for (const r of rows) { - const name = (r.sites as unknown as { name: string } | null)?.name ?? 'Unknown' + const name = r.siteName ?? 'Unknown' siteMap[name] = (siteMap[name] ?? 0) + 1 } const by_site = Object.entries(siteMap) .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count) - 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 + const leadingCount = recentIncidents.filter(r => ['hazard', 'near_miss'].includes(r.incidentType)).length + const laggingCount = recentIncidents.filter(r => r.incidentType === 'injury').length const zoneMap: Record = {} - for (const r of zoneIncidents ?? []) { - const name = (r.zones as unknown as { name: string } | null)?.name ?? 'Unknown' + for (const r of zoneIncidents) { + const name = r.zoneName ?? 'Unknown' zoneMap[name] = (zoneMap[name] ?? 0) + 1 } const by_zone = Object.entries(zoneMap) @@ -104,13 +131,13 @@ export default async function HseDashboardPage({ .slice(0, 10) const zoneMax = by_zone[0]?.count ?? 1 - const capas = completedCapas ?? [] + const capas = completedCapas const onTime = capas.filter(c => { - const due = new Date(c.due_date) - const done = c.verified_at - ? new Date(c.verified_at as string) - : c.completed_at - ? new Date(c.completed_at as string) + const due = new Date(c.dueDate) + const done = c.verifiedAt + ? new Date(c.verifiedAt) + : c.completedAt + ? new Date(c.completedAt) : null return done !== null && done <= due }) @@ -118,11 +145,17 @@ export default async function HseDashboardPage({ ? Math.round((onTime.length / capas.length) * 100) : null - const doshPendingCount = doshPendingRows?.length ?? 0 + const doshPendingCount = doshPendingRows.length - const monthly = bucketIncidentsByMonth(yearIncidents ?? [], 12, now) + const monthly = bucketIncidentsByMonth( + yearIncidents.map(r => ({ reported_at: r.reportedAt.toISOString(), incident_type: r.incidentType })), + 12, + now + ) const monthlyMax = Math.max(1, ...monthly.map(m => m.total)) - const rootCauses = topRootCauses(investigations ?? []) + const rootCauses = topRootCauses( + investigationRows.map(r => ({ root_cause_summary: r.rootCauseSummary })) + ) return (
diff --git a/app/(protected)/hse/incidents/[id]/capa/new/page.tsx b/app/(protected)/hse/incidents/[id]/capa/new/page.tsx index 50cd79b..e3c8779 100644 --- a/app/(protected)/hse/incidents/[id]/capa/new/page.tsx +++ b/app/(protected)/hse/incidents/[id]/capa/new/page.tsx @@ -1,7 +1,9 @@ import { notFound, redirect } from 'next/navigation' import Link from 'next/link' -import { createClient } from '@/lib/supabase/server' +import { asAdmin } from '@/lib/db/with-user' import { getSession } from '@/lib/auth/get-session' +import { incidents } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' import { CapaForm } from '@/components/capa/capa-form' interface Props { @@ -14,10 +16,17 @@ export default async function NewCapaPage({ params }: Props) { if (!session) redirect(`/login?redirect=/hse/incidents/${id}/capa/new`) if (!['hse', 'admin'].includes(session.role)) redirect('/hse/incidents') - const supabase = await createClient() + const [incident] = await asAdmin(db => + db.select({ + id: incidents.id, + referenceNo: incidents.referenceNo, + status: incidents.status, + }) + .from(incidents) + .where(eq(incidents.id, id)) + .limit(1) + ) - const { data: incident } = await supabase - .from('incidents').select('id, reference_no, status').eq('id', id).single() if (!incident) notFound() return ( @@ -27,7 +36,7 @@ export default async function NewCapaPage({ params }: Props) {

Add CAPA Action

- {(incident as { reference_no: string | null }).reference_no ?? id} + {incident.referenceNo ?? id}

diff --git a/app/(protected)/hse/incidents/[id]/investigation/page.tsx b/app/(protected)/hse/incidents/[id]/investigation/page.tsx index edb72c4..802b4d1 100644 --- a/app/(protected)/hse/incidents/[id]/investigation/page.tsx +++ b/app/(protected)/hse/incidents/[id]/investigation/page.tsx @@ -2,8 +2,10 @@ export const dynamic = 'force-dynamic' import { notFound, redirect } from 'next/navigation' import Link from 'next/link' -import { createClient } from '@/lib/supabase/server' +import { asAdmin } from '@/lib/db/with-user' import { getSession } from '@/lib/auth/get-session' +import { incidents, investigations } from '@/lib/db/schema' +import { eq, desc } from 'drizzle-orm' import { InvestigationForm } from '@/components/incidents/investigation-form' interface Props { @@ -16,25 +18,28 @@ export default async function InvestigationPage({ params }: Props) { if (!session) redirect(`/login?redirect=/hse/incidents/${id}/investigation`) if (!['hse', 'admin'].includes(session.role)) redirect('/hse/incidents') - const supabase = await createClient() - - const { data: incident } = await supabase - .from('incidents') - .select('id, reference_no, status') - .eq('id', id) - .single() + const [incident] = await asAdmin(db => + db.select({ + id: incidents.id, + referenceNo: incidents.referenceNo, + status: incidents.status, + }) + .from(incidents) + .where(eq(incidents.id, id)) + .limit(1) + ) if (!incident) notFound() - if (!['triaged', 'investigating'].includes((incident as { status: string }).status)) + if (!['triaged', 'investigating'].includes(incident.status)) redirect(`/hse/incidents/${id}`) - const { data: existing } = await supabase - .from('investigations') - .select('id') - .eq('incident_id', id) - .order('created_at', { ascending: false }) + const [existing] = await asAdmin(db => + db.select({ id: investigations.id }) + .from(investigations) + .where(eq(investigations.incidentId, id)) + .orderBy(desc(investigations.createdAt)) .limit(1) - .maybeSingle() + ) return (
@@ -42,10 +47,10 @@ export default async function InvestigationPage({ params }: Props) { ← Back to incident

Investigation Workspace

-

{(incident as { reference_no: string | null }).reference_no ?? id}

+

{incident.referenceNo ?? id}

) diff --git a/app/(protected)/hse/incidents/[id]/page.tsx b/app/(protected)/hse/incidents/[id]/page.tsx index b69001b..a12502f 100644 --- a/app/(protected)/hse/incidents/[id]/page.tsx +++ b/app/(protected)/hse/incidents/[id]/page.tsx @@ -1,7 +1,11 @@ export const dynamic = 'force-dynamic' -import { notFound } from 'next/navigation' -import { createClient } from '@/lib/supabase/server' +import { notFound, redirect } from 'next/navigation' +import { asAdmin } from '@/lib/db/with-user' +import { getSession } from '@/lib/auth/get-session' +import { incidents, sites, zones, trucks, users, evidenceFiles, investigations } from '@/lib/db/schema' +import { eq, and } from 'drizzle-orm' +import { aliasedTable } from 'drizzle-orm' import { IncidentDetail, type Incident } from '@/components/incidents/incident-detail' import { IncidentHeader } from '@/components/incidents/incident-header' import { SimilarIncidentsPanel } from '@/components/incidents/similar-incidents-panel' @@ -14,52 +18,145 @@ interface Props { export default async function HseIncidentDetailPage({ params }: Props) { const { id } = await params - const supabase = await createClient() + const session = await getSession() + if (!session) redirect('/login') - const { data: incident, error } = await supabase - .from('incidents') - .select(` - id, reference_no, incident_type, description, severity, status, - 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), - zones (id, name), - trucks (id, truck_no, carrier), - reporter:users!reported_by (id, name, email), - evidence_files (id, stage, file_url, file_type, uploaded_at) - `) - .eq('id', id) - .eq('evidence_files.deleted', false) - .single() + const reporterAlias = aliasedTable(users, 'reporter') + const investigatorAlias = aliasedTable(users, 'investigator') - if (error || !incident) notFound() + const [incident] = await asAdmin(db => + db.select({ + id: incidents.id, + referenceNo: incidents.referenceNo, + incidentType: incidents.incidentType, + description: incidents.description, + severity: incidents.severity, + status: incidents.status, + injuryInvolved: incidents.injuryInvolved, + assetInvolved: incidents.assetInvolved, + medicalStatus: incidents.medicalStatus, + lostDays: incidents.lostDays, + typeDetails: incidents.typeDetails, + isFatality: incidents.isFatality, + isSeriousBodilyInjury: incidents.isSeriousBodilyInjury, + isDangerousOccurrence: incidents.isDangerousOccurrence, + isOccupationalDisease: incidents.isOccupationalDisease, + reportedAt: incidents.reportedAt, + closedAt: incidents.closedAt, + siteId: sites.id, + siteName: sites.name, + zoneId: zones.id, + zoneName: zones.name, + truckId: trucks.id, + truckNo: trucks.truckNo, + truckCarrier: trucks.carrier, + reporterId: reporterAlias.id, + reporterName: reporterAlias.name, + reporterEmail: reporterAlias.email, + }) + .from(incidents) + .leftJoin(sites, eq(incidents.siteId, sites.id)) + .leftJoin(zones, eq(incidents.zoneId, zones.id)) + .leftJoin(trucks, eq(incidents.truckId, trucks.id)) + .leftJoin(reporterAlias, eq(incidents.reportedBy, reporterAlias.id)) + .where(eq(incidents.id, id)) + .limit(1) + ) - const status = (incident as { status: string }).status + if (!incident) notFound() - const { data: investigation } = await supabase - .from('investigations') - .select(` - id, method, five_why_steps, fishbone_categories, - findings_text, root_cause_summary, alcohol_test_result, urine_test_result, - witness_statement_refs, completed_at, - investigator:users!investigator_id (name, email) - `) - .eq('incident_id', id) - .maybeSingle() + const [evidenceRows, invRows] = await Promise.all([ + asAdmin(db => + db.select({ + id: evidenceFiles.id, + stage: evidenceFiles.stage, + fileUrl: evidenceFiles.fileUrl, + fileType: evidenceFiles.fileType, + uploadedAt: evidenceFiles.uploadedAt, + }) + .from(evidenceFiles) + .where(and(eq(evidenceFiles.incidentId, id), eq(evidenceFiles.deleted, false))) + ), + asAdmin(db => + db.select({ + id: investigations.id, + method: investigations.method, + fiveWhySteps: investigations.fiveWhySteps, + fishboneCategories: investigations.fishboneCategories, + findingsText: investigations.findingsText, + rootCauseSummary: investigations.rootCauseSummary, + alcoholTestResult: investigations.alcoholTestResult, + urineTestResult: investigations.urineTestResult, + witnessStatementRefs: investigations.witnessStatementRefs, + completedAt: investigations.completedAt, + investigatorName: investigatorAlias.name, + investigatorEmail: investigatorAlias.email, + }) + .from(investigations) + .leftJoin(investigatorAlias, eq(investigations.investigatorId, investigatorAlias.id)) + .where(eq(investigations.incidentId, id)) + .limit(1) + ), + ]) + + const status = incident.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 + incident.isFatality || + incident.isSeriousBodilyInjury || + incident.isDangerousOccurrence || + (incident.lostDays ?? 0) >= 4 ) + const incidentProp: Incident = { + id: incident.id, + reference_no: incident.referenceNo, + incident_type: incident.incidentType, + description: incident.description, + status: incident.status, + severity: incident.severity, + injury_involved: incident.injuryInvolved, + asset_involved: incident.assetInvolved, + medical_status: incident.medicalStatus, + lost_days: incident.lostDays, + type_details: incident.typeDetails as Record | null, + reported_at: incident.reportedAt.toISOString(), + closed_at: incident.closedAt?.toISOString() ?? null, + sites: incident.siteId ? { id: incident.siteId, name: incident.siteName! } : null, + zones: incident.zoneId ? { id: incident.zoneId, name: incident.zoneName! } : null, + trucks: incident.truckId ? { id: incident.truckId, truck_no: incident.truckNo!, carrier: incident.truckCarrier ?? null } : null, + reporter: incident.reporterId ? { id: incident.reporterId, name: incident.reporterName!, email: incident.reporterEmail! } : null, + evidence_files: evidenceRows.map(e => ({ + id: e.id, + stage: e.stage, + file_url: e.fileUrl, + file_type: e.fileType, + uploaded_at: e.uploadedAt.toISOString(), + })), + } + + const inv = invRows[0] + const investigationProp = inv ? { + id: inv.id, + method: inv.method, + five_why_steps: inv.fiveWhySteps as { why: string; answer: string }[] | null, + fishbone_categories: inv.fishboneCategories as Record | null, + findings_text: inv.findingsText, + root_cause_summary: inv.rootCauseSummary, + alcohol_test_result: inv.alcoholTestResult, + urine_test_result: inv.urineTestResult, + witness_statement_refs: inv.witnessStatementRefs ?? [], + completed_at: inv.completedAt?.toISOString() ?? null, + investigator: inv.investigatorEmail + ? { name: inv.investigatorName ?? null, email: inv.investigatorEmail } + : null, + } : null + return ( <>
- + - {investigation && ( - + {investigationProp && ( + )} {needsJkkp && ( diff --git a/app/(protected)/hse/incidents/[id]/triage/page.tsx b/app/(protected)/hse/incidents/[id]/triage/page.tsx index 656309a..5d2d3b6 100644 --- a/app/(protected)/hse/incidents/[id]/triage/page.tsx +++ b/app/(protected)/hse/incidents/[id]/triage/page.tsx @@ -2,8 +2,10 @@ export const dynamic = 'force-dynamic' import { notFound, redirect } from 'next/navigation' import Link from 'next/link' -import { createClient } from '@/lib/supabase/server' +import { asAdmin } from '@/lib/db/with-user' import { getSession } from '@/lib/auth/get-session' +import { incidents } from '@/lib/db/schema' +import { eq } from 'drizzle-orm' import { TriageForm } from '@/components/incidents/triage-form' interface Props { @@ -16,13 +18,17 @@ export default async function TriagePage({ params }: Props) { if (!session) redirect(`/login?redirect=/hse/incidents/${id}/triage`) if (!['hse', 'admin'].includes(session.role)) redirect('/hse/incidents') - const supabase = await createClient() - - const { data: incident } = await supabase - .from('incidents') - .select('id, reference_no, incident_type, status, severity') - .eq('id', id) - .single() + const [incident] = await asAdmin(db => + db.select({ + id: incidents.id, + referenceNo: incidents.referenceNo, + status: incidents.status, + severity: incidents.severity, + }) + .from(incidents) + .where(eq(incidents.id, id)) + .limit(1) + ) if (!incident) notFound() if (incident.status !== 'reported') redirect(`/hse/incidents/${id}`) @@ -33,10 +39,10 @@ export default async function TriagePage({ params }: Props) { ← Back to incident

Triage Incident

-

{incident.reference_no ?? id}

+

{incident.referenceNo ?? id}

) diff --git a/app/(protected)/hse/incidents/page.tsx b/app/(protected)/hse/incidents/page.tsx index ac5a545..5072912 100644 --- a/app/(protected)/hse/incidents/page.tsx +++ b/app/(protected)/hse/incidents/page.tsx @@ -1,6 +1,11 @@ export const dynamic = 'force-dynamic' -import { createClient } from '@/lib/supabase/server' +import { redirect } from 'next/navigation' +import { asAdmin } from '@/lib/db/with-user' +import { getSession } from '@/lib/auth/get-session' +import { incidents, sites, zones, trucks, users } from '@/lib/db/schema' +import { eq, and, or, ilike, desc, asc, sql } from 'drizzle-orm' +import { aliasedTable } from 'drizzle-orm' import { IncidentList, type Incident } from '@/components/incidents/incident-list' import { Pagination } from '@/components/incidents/pagination' import { IncidentFilters, type SiteOption, type TruckOption } from '@/components/incidents/incident-filters' @@ -12,48 +17,90 @@ export default async function HseInboxPage({ }: { searchParams: Promise<{ page?: string; q?: string; status?: string; type?: string; site_id?: string; truck_id?: string }> }) { - const supabase = await createClient() + const session = await getSession() + if (!session) redirect('/login') + const params = await searchParams const page = Math.max(1, Number.parseInt(params.page ?? '1', 10) || 1) const from = (page - 1) * PAGE_SIZE - let query = supabase - .from('incidents') - .select(` - id, reference_no, incident_type, status, severity, reported_at, - sites (name), - zones (name), - trucks (truck_no), - reporter:users!reported_by (name) - `, { count: 'exact' }) - .order('reported_at', { ascending: false }) + const reporterAlias = aliasedTable(users, 'reporter') - 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) - if (params.truck_id) query = query.eq('truck_id', params.truck_id) + const conditions = [] + if (params.q) conditions.push(or(ilike(incidents.referenceNo, `%${params.q}%`), ilike(incidents.description, `%${params.q}%`))) + if (params.status) conditions.push(eq(incidents.status, params.status as never)) + if (params.type) conditions.push(eq(incidents.incidentType, params.type as never)) + if (params.site_id) conditions.push(eq(incidents.siteId, params.site_id)) + if (params.truck_id) conditions.push(eq(incidents.truckId, params.truck_id)) + const where = conditions.length ? and(...conditions) : undefined - const [{ data: incidents, count }, { data: sites }, { data: trucks }] = await Promise.all([ - query.range(from, from + PAGE_SIZE - 1), - supabase.from('sites').select('id, name').order('name'), - supabase.from('trucks').select('id, truck_no').eq('active', true).order('truck_no'), + const [[countRow], incidentRows, siteRows, truckRows] = await Promise.all([ + asAdmin(db => + db.select({ count: sql`count(*)` }) + .from(incidents) + .where(where) + ), + asAdmin(db => + db.select({ + id: incidents.id, + referenceNo: incidents.referenceNo, + incidentType: incidents.incidentType, + status: incidents.status, + severity: incidents.severity, + reportedAt: incidents.reportedAt, + siteName: sites.name, + zoneName: zones.name, + reporterName: reporterAlias.name, + truckNo: trucks.truckNo, + }) + .from(incidents) + .leftJoin(sites, eq(incidents.siteId, sites.id)) + .leftJoin(zones, eq(incidents.zoneId, zones.id)) + .leftJoin(reporterAlias, eq(incidents.reportedBy, reporterAlias.id)) + .leftJoin(trucks, eq(incidents.truckId, trucks.id)) + .where(where) + .orderBy(desc(incidents.reportedAt)) + .limit(PAGE_SIZE) + .offset(from) + ), + asAdmin(db => + db.select({ id: sites.id, name: sites.name }).from(sites).orderBy(asc(sites.name)) + ), + asAdmin(db => + db.select({ id: trucks.id, truckNo: trucks.truckNo }) + .from(trucks) + .where(eq(trucks.active, true)) + .orderBy(asc(trucks.truckNo)) + ), ]) - const siteOptions: SiteOption[] = (sites ?? []).map(s => ({ id: s.id, name: s.name })) - const truckOptions: TruckOption[] = (trucks ?? []).map(tk => ({ id: tk.id, truck_no: tk.truck_no })) + const total = Number(countRow?.count ?? 0) + + const incidentList: Incident[] = incidentRows.map(r => ({ + id: r.id, + reference_no: r.referenceNo, + incident_type: r.incidentType, + status: r.status, + severity: r.severity, + reported_at: r.reportedAt.toISOString(), + sites: r.siteName ? { name: r.siteName } : null, + zones: r.zoneName ? { name: r.zoneName } : null, + trucks: r.truckNo ? { truck_no: r.truckNo } : null, + reporter: r.reporterName ? { name: r.reporterName } : null, + })) + + const siteOptions: SiteOption[] = siteRows.map(s => ({ id: s.id, name: s.name })) + const truckOptions: TruckOption[] = truckRows.map(t => ({ id: t.id, truck_no: t.truckNo })) return (

Incident Inbox

- {count ?? incidents?.length ?? 0} incidents + {total} incidents
- - + +
) } diff --git a/app/(protected)/hse/settings/page.tsx b/app/(protected)/hse/settings/page.tsx index aaec9c8..d1198fd 100644 --- a/app/(protected)/hse/settings/page.tsx +++ b/app/(protected)/hse/settings/page.tsx @@ -1,8 +1,9 @@ export const dynamic = 'force-dynamic' import { redirect } from 'next/navigation' -import { createClient } from '@/lib/supabase/server' +import { asAdmin } from '@/lib/db/with-user' import { getSession } from '@/lib/auth/get-session' +import { appSettings } from '@/lib/db/schema' import { ApiKeyForm } from '@/components/settings/api-key-form' export default async function SettingsPage() { @@ -10,14 +11,16 @@ export default async function SettingsPage() { if (!session) redirect('/login') if (session.role !== 'admin') redirect('/hse/dashboard') - const supabase = await createClient() - - const { data: settings } = await supabase - .from('app_settings') - .select('key, value, updated_at') + const settingRows = await asAdmin(db => + db.select({ + key: appSettings.key, + value: appSettings.value, + updatedAt: appSettings.updatedAt, + }).from(appSettings) + ) const settingsMap = Object.fromEntries( - (settings ?? []).map(s => [s.key, { set: Boolean(s.value), updated_at: s.updated_at }]) + settingRows.map(s => [s.key, { set: Boolean(s.value), updated_at: s.updatedAt?.toISOString() ?? null }]) ) return ( diff --git a/app/(protected)/management/page.tsx b/app/(protected)/management/page.tsx index cb018c2..2ca7303 100644 --- a/app/(protected)/management/page.tsx +++ b/app/(protected)/management/page.tsx @@ -1,8 +1,10 @@ export const dynamic = 'force-dynamic' -import { createClient } from '@/lib/supabase/server' +import { asAdmin } from '@/lib/db/with-user' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth/get-session' +import { incidents, sites, capaActions } from '@/lib/db/schema' +import { eq, gte, lt, and } from 'drizzle-orm' import { StatCard } from '@/components/dashboard/stat-card' import { RiskFlagsPanel } from '@/components/dashboard/risk-flags-panel' @@ -11,34 +13,53 @@ export default async function ManagementPage() { if (!session) redirect('/login') if (!['management', 'admin'].includes(session.role)) redirect('/') - const supabase = await createClient() - const now = new Date() const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString() const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString() const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString() - const [ - { data: allIncidents }, - { data: thisMonthIncidents }, - { data: lastMonthIncidents }, - { data: recentIncidents }, - { data: overdueCapas }, - ] = await Promise.all([ - supabase.from('incidents').select('id, severity, status, incident_type, medical_status, sites (name)'), - supabase.from('incidents').select('id').gte('reported_at', thisMonthStart), - supabase.from('incidents').select('id').gte('reported_at', lastMonthStart).lt('reported_at', thisMonthStart), - supabase.from('incidents').select('incident_type').gte('reported_at', thirtyDaysAgo), - supabase.from('capa_actions').select('id').eq('status', 'overdue'), + const [allIncidents, thisMonthIncidents, lastMonthIncidents, recentIncidents, overdueCapas] = await Promise.all([ + asAdmin(db => + db.select({ + id: incidents.id, + severity: incidents.severity, + status: incidents.status, + incidentType: incidents.incidentType, + medicalStatus: incidents.medicalStatus, + siteName: sites.name, + }) + .from(incidents) + .leftJoin(sites, eq(incidents.siteId, sites.id)) + ), + asAdmin(db => + db.select({ id: incidents.id }) + .from(incidents) + .where(gte(incidents.reportedAt, new Date(thisMonthStart))) + ), + asAdmin(db => + db.select({ id: incidents.id }) + .from(incidents) + .where(and(gte(incidents.reportedAt, new Date(lastMonthStart)), lt(incidents.reportedAt, new Date(thisMonthStart)))) + ), + asAdmin(db => + db.select({ incidentType: incidents.incidentType }) + .from(incidents) + .where(gte(incidents.reportedAt, new Date(thirtyDaysAgo))) + ), + asAdmin(db => + db.select({ id: capaActions.id }) + .from(capaActions) + .where(eq(capaActions.status, 'overdue')) + ), ]) - const rows = allIncidents ?? [] - const totalThisMonth = thisMonthIncidents?.length ?? 0 - const totalLastMonth = lastMonthIncidents?.length ?? 0 + const rows = allIncidents + const totalThisMonth = thisMonthIncidents.length + const totalLastMonth = lastMonthIncidents.length const monthDelta = totalThisMonth - totalLastMonth - const ltiCount = rows.filter(r => r.medical_status === 'lti').length - const overdueCount = overdueCapas?.length ?? 0 + const ltiCount = rows.filter(r => r.medicalStatus === 'lti').length + const overdueCount = overdueCapas.length // Severity distribution const severityDist: Record = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 } @@ -50,14 +71,13 @@ export default async function ManagementPage() { const severityMax = Math.max(...Object.values(severityDist), 1) // Leading vs 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 + const leadingCount = recentIncidents.filter(r => ['hazard', 'near_miss'].includes(r.incidentType)).length + const laggingCount = recentIncidents.filter(r => r.incidentType === 'injury').length // Site comparison const siteMap: Record = {} for (const r of rows) { - const name = (r.sites as unknown as { name: string } | null)?.name ?? 'Unknown' + const name = r.siteName ?? 'Unknown' siteMap[name] = (siteMap[name] ?? 0) + 1 } const by_site = Object.entries(siteMap).map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count) diff --git a/app/(protected)/reporter/incidents/[id]/page.tsx b/app/(protected)/reporter/incidents/[id]/page.tsx index 4320560..69c1c0f 100644 --- a/app/(protected)/reporter/incidents/[id]/page.tsx +++ b/app/(protected)/reporter/incidents/[id]/page.tsx @@ -1,9 +1,11 @@ export const dynamic = 'force-dynamic' import Link from 'next/link' -import { createClient } from '@/lib/supabase/server' +import { asAdmin } from '@/lib/db/with-user' import { redirect, notFound } from 'next/navigation' import { getSession } from '@/lib/auth/get-session' +import { incidents, sites, zones, capaActions } from '@/lib/db/schema' +import { eq, and } from 'drizzle-orm' const STATUS_LABELS: Record = { reported: 'Reported', triaged: 'Triaged', investigating: 'Investigating', @@ -24,25 +26,41 @@ export default async function ReporterIncidentDetail({ const session = await getSession() if (!session) redirect('/login') - const supabase = await createClient() - - const { data: inc } = await supabase - .from('incidents') - .select(` - id, reference_no, incident_type, status, severity, reported_at, closed_at, - description, injury_involved, medical_status, lost_days, - sites (name), zones (name), - capa_actions (id, description, status, due_date) - `) - .eq('id', id) - .eq('reported_by', session.sub) - .single() + const [inc] = await asAdmin(db => + db.select({ + id: incidents.id, + referenceNo: incidents.referenceNo, + incidentType: incidents.incidentType, + status: incidents.status, + severity: incidents.severity, + reportedAt: incidents.reportedAt, + closedAt: incidents.closedAt, + description: incidents.description, + injuryInvolved: incidents.injuryInvolved, + medicalStatus: incidents.medicalStatus, + lostDays: incidents.lostDays, + siteName: sites.name, + zoneName: zones.name, + }) + .from(incidents) + .leftJoin(sites, eq(incidents.siteId, sites.id)) + .leftJoin(zones, eq(incidents.zoneId, zones.id)) + .where(and(eq(incidents.id, id), eq(incidents.reportedBy, session.sub))) + .limit(1) + ) if (!inc) notFound() - const siteName = (inc.sites as unknown as { name: string } | null)?.name - const zoneName = (inc.zones as unknown as { name: string } | null)?.name - const capas = (inc.capa_actions as unknown as Array<{ id: string; description: string; status: string; due_date: string | null }>) ?? [] + const capas = await asAdmin(db => + db.select({ + id: capaActions.id, + description: capaActions.description, + status: capaActions.status, + dueDate: capaActions.dueDate, + }) + .from(capaActions) + .where(eq(capaActions.incidentId, id)) + ) return (
@@ -53,8 +71,8 @@ export default async function ReporterIncidentDetail({
-

{inc.reference_no ?? inc.id.slice(0, 8)}

-

{inc.incident_type?.replace(/_/g, ' ')}

+

{inc.referenceNo ?? inc.id.slice(0, 8)}

+

{inc.incidentType?.replace(/_/g, ' ')}

{STATUS_LABELS[inc.status] ?? inc.status} @@ -62,19 +80,19 @@ export default async function ReporterIncidentDetail({
- {siteName &&

Site

{siteName}

} - {zoneName &&

Zone

{zoneName}

} + {inc.siteName &&

Site

{inc.siteName}

} + {inc.zoneName &&

Zone

{inc.zoneName}

} {inc.severity &&

Severity

{inc.severity} / 5

} - {inc.reported_at && ( + {inc.reportedAt && (

Reported

-

{new Date(inc.reported_at as string).toLocaleDateString('en-MY')}

+

{new Date(inc.reportedAt).toLocaleDateString('en-MY')}

)} - {inc.closed_at && ( + {inc.closedAt && (

Closed

-

{new Date(inc.closed_at as string).toLocaleDateString('en-MY')}

+

{new Date(inc.closedAt).toLocaleDateString('en-MY')}

)}
diff --git a/app/(protected)/reporter/page.tsx b/app/(protected)/reporter/page.tsx index 586c53c..c786818 100644 --- a/app/(protected)/reporter/page.tsx +++ b/app/(protected)/reporter/page.tsx @@ -1,9 +1,11 @@ export const dynamic = 'force-dynamic' import Link from 'next/link' -import { createClient } from '@/lib/supabase/server' +import { asAdmin } from '@/lib/db/with-user' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth/get-session' +import { incidents, sites } from '@/lib/db/schema' +import { eq, desc } from 'drizzle-orm' const STATUS_LABELS: Record = { reported: 'Reported', @@ -32,15 +34,22 @@ export default async function ReporterPage() { const session = await getSession() if (!session) redirect('/login') - const supabase = await createClient() + const rows = await asAdmin(db => + db.select({ + id: incidents.id, + referenceNo: incidents.referenceNo, + incidentType: incidents.incidentType, + status: incidents.status, + reportedAt: incidents.reportedAt, + severity: incidents.severity, + siteName: sites.name, + }) + .from(incidents) + .leftJoin(sites, eq(incidents.siteId, sites.id)) + .where(eq(incidents.reportedBy, session.sub)) + .orderBy(desc(incidents.reportedAt)) + ) - const { data: incidents } = await supabase - .from('incidents') - .select('id, reference_no, incident_type, status, reported_at, severity, sites (name)') - .eq('reported_by', session.sub) - .order('reported_at', { ascending: false }) - - const rows = incidents ?? [] const openCount = rows.filter(r => r.status !== 'closed').length const closedCount = rows.filter(r => r.status === 'closed').length @@ -81,35 +90,32 @@ export default async function ReporterPage() {
) : (
- {rows.map(inc => { - const siteName = (inc.sites as unknown as { name: string } | null)?.name - return ( - -
-
-

- {inc.reference_no ?? inc.id.slice(0, 8)} + {rows.map(inc => ( + +

+
+

+ {inc.referenceNo ?? inc.id.slice(0, 8)} +

+

+ {TYPE_LABELS[inc.incidentType] ?? inc.incidentType} + {inc.siteName ? ` · ${inc.siteName}` : ''} + {inc.severity ? ` · Severity ${inc.severity}` : ''} +

+ {inc.reportedAt && ( +

+ {new Date(inc.reportedAt).toLocaleDateString('en-MY', { + day: 'numeric', month: 'short', year: 'numeric', + })}

-

- {TYPE_LABELS[inc.incident_type] ?? inc.incident_type} - {siteName ? ` · ${siteName}` : ''} - {inc.severity ? ` · Severity ${inc.severity}` : ''} -

- {inc.reported_at && ( -

- {new Date(inc.reported_at as string).toLocaleDateString('en-MY', { - day: 'numeric', month: 'short', year: 'numeric', - })} -

- )} -
- - {STATUS_LABELS[inc.status] ?? inc.status} - + )}
- - ) - })} + + {STATUS_LABELS[inc.status] ?? inc.status} + +
+ + ))}
)}
diff --git a/app/(protected)/supervisor/incidents/[id]/page.tsx b/app/(protected)/supervisor/incidents/[id]/page.tsx index ccab99f..cc44806 100644 --- a/app/(protected)/supervisor/incidents/[id]/page.tsx +++ b/app/(protected)/supervisor/incidents/[id]/page.tsx @@ -2,7 +2,10 @@ export const dynamic = 'force-dynamic' import { notFound } from 'next/navigation' import Link from 'next/link' -import { createClient } from '@/lib/supabase/server' +import { asAdmin } from '@/lib/db/with-user' +import { incidents, sites, zones, trucks, users, evidenceFiles, investigations } from '@/lib/db/schema' +import { eq, and } from 'drizzle-orm' +import { aliasedTable } from 'drizzle-orm' import { IncidentDetail, type Incident } from '@/components/incidents/incident-detail' import { InvestigationPanel } from '@/components/incidents/investigation-panel' @@ -12,44 +15,129 @@ interface Props { export default async function SupervisorIncidentDetailPage({ params }: Props) { const { id } = await params - const supabase = await createClient() - const { data: incident, error } = await supabase - .from('incidents') - .select(` - id, reference_no, incident_type, description, severity, status, - injury_involved, asset_involved, medical_status, lost_days, - reported_at, closed_at, - sites (id, name), - zones (id, name), - reporter:users!reported_by (id, name, email), - evidence_files (id, stage, file_url, file_type, uploaded_at) - `) - .eq('id', id) - .eq('evidence_files.deleted', false) - .single() + const reporterAlias = aliasedTable(users, 'reporter') + const investigatorAlias = aliasedTable(users, 'investigator') - if (error || !incident) notFound() + const [incident] = await asAdmin(db => + db.select({ + id: incidents.id, + referenceNo: incidents.referenceNo, + incidentType: incidents.incidentType, + description: incidents.description, + severity: incidents.severity, + status: incidents.status, + injuryInvolved: incidents.injuryInvolved, + assetInvolved: incidents.assetInvolved, + medicalStatus: incidents.medicalStatus, + lostDays: incidents.lostDays, + reportedAt: incidents.reportedAt, + closedAt: incidents.closedAt, + siteId: sites.id, + siteName: sites.name, + zoneId: zones.id, + zoneName: zones.name, + truckId: trucks.id, + truckNo: trucks.truckNo, + truckCarrier: trucks.carrier, + reporterId: reporterAlias.id, + reporterName: reporterAlias.name, + reporterEmail: reporterAlias.email, + }) + .from(incidents) + .leftJoin(sites, eq(incidents.siteId, sites.id)) + .leftJoin(zones, eq(incidents.zoneId, zones.id)) + .leftJoin(trucks, eq(incidents.truckId, trucks.id)) + .leftJoin(reporterAlias, eq(incidents.reportedBy, reporterAlias.id)) + .where(eq(incidents.id, id)) + .limit(1) + ) - const { data: investigation } = await supabase - .from('investigations') - .select(` - id, method, five_why_steps, fishbone_categories, - findings_text, root_cause_summary, alcohol_test_result, urine_test_result, - witness_statement_refs, completed_at, - investigator:users!investigator_id (name, email) - `) - .eq('incident_id', id) - .maybeSingle() + if (!incident) notFound() + + const evidenceRows = await asAdmin(db => + db.select({ + id: evidenceFiles.id, + stage: evidenceFiles.stage, + fileUrl: evidenceFiles.fileUrl, + fileType: evidenceFiles.fileType, + uploadedAt: evidenceFiles.uploadedAt, + }) + .from(evidenceFiles) + .where(and(eq(evidenceFiles.incidentId, id), eq(evidenceFiles.deleted, false))) + ) + + const [inv] = await asAdmin(db => + db.select({ + id: investigations.id, + method: investigations.method, + fiveWhySteps: investigations.fiveWhySteps, + fishboneCategories: investigations.fishboneCategories, + findingsText: investigations.findingsText, + rootCauseSummary: investigations.rootCauseSummary, + alcoholTestResult: investigations.alcoholTestResult, + urineTestResult: investigations.urineTestResult, + witnessStatementRefs: investigations.witnessStatementRefs, + completedAt: investigations.completedAt, + investigatorName: investigatorAlias.name, + investigatorEmail: investigatorAlias.email, + }) + .from(investigations) + .leftJoin(investigatorAlias, eq(investigations.investigatorId, investigatorAlias.id)) + .where(eq(investigations.incidentId, id)) + .limit(1) + ) + + const incidentProp: Incident = { + id: incident.id, + reference_no: incident.referenceNo, + incident_type: incident.incidentType, + description: incident.description, + status: incident.status, + severity: incident.severity, + injury_involved: incident.injuryInvolved, + asset_involved: incident.assetInvolved, + medical_status: incident.medicalStatus, + lost_days: incident.lostDays, + reported_at: incident.reportedAt.toISOString(), + closed_at: incident.closedAt?.toISOString() ?? null, + sites: incident.siteId ? { id: incident.siteId, name: incident.siteName! } : null, + zones: incident.zoneId ? { id: incident.zoneId, name: incident.zoneName! } : null, + trucks: incident.truckId ? { id: incident.truckId, truck_no: incident.truckNo!, carrier: incident.truckCarrier ?? null } : null, + reporter: incident.reporterId ? { id: incident.reporterId, name: incident.reporterName!, email: incident.reporterEmail! } : null, + evidence_files: evidenceRows.map(e => ({ + id: e.id, + stage: e.stage, + file_url: e.fileUrl, + file_type: e.fileType, + uploaded_at: e.uploadedAt.toISOString(), + })), + } + + const investigationProp = inv ? { + id: inv.id, + method: inv.method, + five_why_steps: inv.fiveWhySteps as { why: string; answer: string }[] | null, + fishbone_categories: inv.fishboneCategories as Record | null, + findings_text: inv.findingsText, + root_cause_summary: inv.rootCauseSummary, + alcohol_test_result: inv.alcoholTestResult, + urine_test_result: inv.urineTestResult, + witness_statement_refs: inv.witnessStatementRefs ?? [], + completed_at: inv.completedAt?.toISOString() ?? null, + investigator: inv.investigatorEmail + ? { name: inv.investigatorName ?? null, email: inv.investigatorEmail } + : null, + } : null return (
← Back to inbox - - {investigation && ( - + + {investigationProp && ( + )}
) diff --git a/app/(protected)/supervisor/incidents/page.tsx b/app/(protected)/supervisor/incidents/page.tsx index 2bcdb81..37a161f 100644 --- a/app/(protected)/supervisor/incidents/page.tsx +++ b/app/(protected)/supervisor/incidents/page.tsx @@ -1,6 +1,11 @@ export const dynamic = 'force-dynamic' -import { createClient } from '@/lib/supabase/server' +import { redirect } from 'next/navigation' +import { asAdmin } from '@/lib/db/with-user' +import { getSession } from '@/lib/auth/get-session' +import { incidents, sites, zones, trucks, users } from '@/lib/db/schema' +import { eq, and, or, ilike, desc, asc, sql } from 'drizzle-orm' +import { aliasedTable } from 'drizzle-orm' import { IncidentList, type Incident } from '@/components/incidents/incident-list' import { Pagination } from '@/components/incidents/pagination' import { IncidentFilters, type SiteOption, type TruckOption } from '@/components/incidents/incident-filters' @@ -12,48 +17,90 @@ export default async function SupervisorInboxPage({ }: { searchParams: Promise<{ page?: string; q?: string; status?: string; type?: string; site_id?: string; truck_id?: string }> }) { - const supabase = await createClient() + const session = await getSession() + if (!session) redirect('/login') + const params = await searchParams const page = Math.max(1, Number.parseInt(params.page ?? '1', 10) || 1) const from = (page - 1) * PAGE_SIZE - let query = supabase - .from('incidents') - .select(` - id, reference_no, incident_type, status, severity, reported_at, - sites (name), - zones (name), - trucks (truck_no), - reporter:users!reported_by (name) - `, { count: 'exact' }) - .order('reported_at', { ascending: false }) + const reporterAlias = aliasedTable(users, 'reporter') - 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) - if (params.truck_id) query = query.eq('truck_id', params.truck_id) + const conditions = [] + if (params.q) conditions.push(or(ilike(incidents.referenceNo, `%${params.q}%`), ilike(incidents.description, `%${params.q}%`))) + if (params.status) conditions.push(eq(incidents.status, params.status as never)) + if (params.type) conditions.push(eq(incidents.incidentType, params.type as never)) + if (params.site_id) conditions.push(eq(incidents.siteId, params.site_id)) + if (params.truck_id) conditions.push(eq(incidents.truckId, params.truck_id)) + const where = conditions.length ? and(...conditions) : undefined - const [{ data: incidents, count }, { data: sites }, { data: trucks }] = await Promise.all([ - query.range(from, from + PAGE_SIZE - 1), - supabase.from('sites').select('id, name').order('name'), - supabase.from('trucks').select('id, truck_no').eq('active', true).order('truck_no'), + const [[countRow], incidentRows, siteRows, truckRows] = await Promise.all([ + asAdmin(db => + db.select({ count: sql`count(*)` }) + .from(incidents) + .where(where) + ), + asAdmin(db => + db.select({ + id: incidents.id, + referenceNo: incidents.referenceNo, + incidentType: incidents.incidentType, + status: incidents.status, + severity: incidents.severity, + reportedAt: incidents.reportedAt, + siteName: sites.name, + zoneName: zones.name, + reporterName: reporterAlias.name, + truckNo: trucks.truckNo, + }) + .from(incidents) + .leftJoin(sites, eq(incidents.siteId, sites.id)) + .leftJoin(zones, eq(incidents.zoneId, zones.id)) + .leftJoin(reporterAlias, eq(incidents.reportedBy, reporterAlias.id)) + .leftJoin(trucks, eq(incidents.truckId, trucks.id)) + .where(where) + .orderBy(desc(incidents.reportedAt)) + .limit(PAGE_SIZE) + .offset(from) + ), + asAdmin(db => + db.select({ id: sites.id, name: sites.name }).from(sites).orderBy(asc(sites.name)) + ), + asAdmin(db => + db.select({ id: trucks.id, truckNo: trucks.truckNo }) + .from(trucks) + .where(eq(trucks.active, true)) + .orderBy(asc(trucks.truckNo)) + ), ]) - const siteOptions: SiteOption[] = (sites ?? []).map(s => ({ id: s.id, name: s.name })) - const truckOptions: TruckOption[] = (trucks ?? []).map(tk => ({ id: tk.id, truck_no: tk.truck_no })) + const total = Number(countRow?.count ?? 0) + + const incidentList: Incident[] = incidentRows.map(r => ({ + id: r.id, + reference_no: r.referenceNo, + incident_type: r.incidentType, + status: r.status, + severity: r.severity, + reported_at: r.reportedAt.toISOString(), + sites: r.siteName ? { name: r.siteName } : null, + zones: r.zoneName ? { name: r.zoneName } : null, + trucks: r.truckNo ? { truck_no: r.truckNo } : null, + reporter: r.reporterName ? { name: r.reporterName } : null, + })) + + const siteOptions: SiteOption[] = siteRows.map(s => ({ id: s.id, name: s.name })) + const truckOptions: TruckOption[] = truckRows.map(t => ({ id: t.id, truck_no: t.truckNo })) return (

Incident Inbox

- {count ?? incidents?.length ?? 0} incidents + {total} incidents
- - + +
) } diff --git a/app/(protected)/supervisor/page.tsx b/app/(protected)/supervisor/page.tsx index dee0b30..afaf1ee 100644 --- a/app/(protected)/supervisor/page.tsx +++ b/app/(protected)/supervisor/page.tsx @@ -1,9 +1,12 @@ export const dynamic = 'force-dynamic' import Link from 'next/link' -import { createClient } from '@/lib/supabase/server' +import { asAdmin } from '@/lib/db/with-user' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth/get-session' +import { incidents, sites, capaActions, users } from '@/lib/db/schema' +import { eq, not, and, inArray, sql } from 'drizzle-orm' +import { aliasedTable } from 'drizzle-orm' import { StatCard } from '@/components/dashboard/stat-card' const STATUS_LABELS: Record = { @@ -29,8 +32,6 @@ export default async function SupervisorPage() { if (!session) redirect('/login') if (!['supervisor', 'admin'].includes(session.role)) redirect('/') - const supabase = await createClient() - if (!session.siteId) { return (
@@ -40,56 +41,69 @@ export default async function SupervisorPage() { ) } - const { data: siteRow } = await supabase.from('sites').select('name').eq('id', session.siteId).single() - const siteName = (siteRow as unknown as { name: string } | null)?.name ?? 'Your Site' + const [siteRow] = await asAdmin(db => + db.select({ name: sites.name }).from(sites).where(eq(sites.id, session.siteId!)).limit(1) + ) + const siteName = siteRow?.name ?? 'Your Site' - // Resolve incident IDs once to avoid duplicate queries inside Promise.all - const { data: siteIncidents } = await supabase - .from('incidents') - .select('id') - .eq('site_id', session.siteId) - const incidentIds = siteIncidents?.map(r => r.id) ?? [] + // Get all incident IDs for this site + const siteIncidents = await asAdmin(db => + db.select({ id: incidents.id }).from(incidents).where(eq(incidents.siteId, session.siteId!)) + ) + const incidentIds = siteIncidents.map(r => r.id) - const [ - { data: openIncidents }, - { count: closedCount }, - { count: openCount }, - { data: overdueCapas }, - { data: allCapas }, - ] = await Promise.all([ - supabase - .from('incidents') - .select('id, reference_no, incident_type, status, reported_at') - .eq('site_id', session.siteId) - .neq('status', 'closed') - .order('reported_at', { ascending: false }) - .limit(10), - supabase - .from('incidents') - .select('*', { count: 'exact', head: true }) - .eq('site_id', session.siteId) - .eq('status', 'closed'), - supabase - .from('incidents') - .select('*', { count: 'exact', head: true }) - .eq('site_id', session.siteId) - .neq('status', 'closed'), - supabase - .from('capa_actions') - .select('id, description, due_date, users (name)') - .eq('status', 'overdue') - .in('incident_id', incidentIds), - supabase - .from('capa_actions') - .select('id, status') - .in('incident_id', incidentIds), + const ownerAlias = aliasedTable(users, 'owner') + + const [openIncidents, closedCountRow, openCountRow, overdueCapas, allCapas] = await Promise.all([ + asAdmin(db => + db.select({ + id: incidents.id, + referenceNo: incidents.referenceNo, + incidentType: incidents.incidentType, + status: incidents.status, + reportedAt: incidents.reportedAt, + }) + .from(incidents) + .where(and(eq(incidents.siteId, session.siteId!), not(eq(incidents.status, 'closed')))) + .orderBy(sql`${incidents.reportedAt} desc`) + .limit(10) + ), + asAdmin(db => + db.select({ count: sql`count(*)` }) + .from(incidents) + .where(and(eq(incidents.siteId, session.siteId!), eq(incidents.status, 'closed'))) + ), + asAdmin(db => + db.select({ count: sql`count(*)` }) + .from(incidents) + .where(and(eq(incidents.siteId, session.siteId!), not(eq(incidents.status, 'closed')))) + ), + incidentIds.length > 0 + ? asAdmin(db => + db.select({ + id: capaActions.id, + description: capaActions.description, + dueDate: capaActions.dueDate, + ownerName: ownerAlias.name, + }) + .from(capaActions) + .leftJoin(ownerAlias, eq(capaActions.ownerUserId, ownerAlias.id)) + .where(and(eq(capaActions.status, 'overdue'), inArray(capaActions.incidentId, incidentIds))) + ) + : Promise.resolve([]), + incidentIds.length > 0 + ? asAdmin(db => + db.select({ id: capaActions.id, status: capaActions.status }) + .from(capaActions) + .where(inArray(capaActions.incidentId, incidentIds)) + ) + : Promise.resolve([]), ]) - const resolvedOpenCount = openCount ?? 0 - const resolvedClosedCount = closedCount ?? 0 - const overdueCount = overdueCapas?.length ?? 0 - - const capaRows = allCapas ?? [] + const resolvedOpenCount = Number(openCountRow[0]?.count ?? 0) + const resolvedClosedCount = Number(closedCountRow[0]?.count ?? 0) + const overdueCount = overdueCapas.length + const capaRows = allCapas const capaOpenCount = capaRows.filter(c => ['open', 'in_progress'].includes(c.status)).length const capaVerifiedCount = capaRows.filter(c => c.status === 'verified').length @@ -121,19 +135,19 @@ export default async function SupervisorPage() { {/* Open incidents */}

Open Incidents

- {(openIncidents ?? []).length === 0 ? ( + {openIncidents.length === 0 ? (

No open incidents.

) : (
- {(openIncidents ?? []).map(inc => ( + {openIncidents.map(inc => (
-

{inc.reference_no ?? inc.id.slice(0, 8)}

-

{TYPE_LABELS[inc.incident_type] ?? inc.incident_type}

+

{inc.referenceNo ?? inc.id.slice(0, 8)}

+

{TYPE_LABELS[inc.incidentType] ?? inc.incidentType}

{STATUS_LABELS[inc.status] ?? inc.status} @@ -149,16 +163,16 @@ export default async function SupervisorPage() {

Overdue CAPAs

- {(overdueCapas ?? []).map(capa => ( + {overdueCapas.map(capa => (

{capa.description}

- Owner: {(capa.users as unknown as { name: string } | null)?.name ?? 'Unassigned'} + Owner: {capa.ownerName ?? 'Unassigned'}

- Due {capa.due_date ? new Date(capa.due_date as string).toLocaleDateString('en-MY') : '—'} + Due {capa.dueDate ? new Date(capa.dueDate).toLocaleDateString('en-MY') : '—'}
))} diff --git a/app/report/page.tsx b/app/report/page.tsx index 52e914c..f2ce9a5 100644 --- a/app/report/page.tsx +++ b/app/report/page.tsx @@ -1,8 +1,10 @@ export const dynamic = 'force-dynamic' import { redirect } from 'next/navigation' -import { createClient } from '@/lib/supabase/server' +import { asAdmin } from '@/lib/db/with-user' import { getSession } from '@/lib/auth/get-session' +import { zones, sites, trucks } from '@/lib/db/schema' +import { eq, asc } from 'drizzle-orm' import { ReportForm } from '@/components/incidents/report-form' import { LanguageSwitcher } from '@/components/language-switcher' import { OfflineSync } from '@/components/incidents/offline-sync' @@ -16,28 +18,42 @@ export default async function ReportPage({ searchParams }: Props) { const session = await getSession() if (!session) redirect(`/login?redirect=/report${zone ? `?zone=${zone}` : ''}`) - const supabase = await createClient() - let zoneName: string | null = null let siteName: string | null = null if (zone) { - const { data: zd } = await supabase - .from('zones') - .select('id, name, site_id, sites (name)') - .eq('qr_code_token', zone) - .single() + const [zd] = await asAdmin(db => + db.select({ + name: zones.name, + siteName: sites.name, + }) + .from(zones) + .leftJoin(sites, eq(zones.siteId, sites.id)) + .where(eq(zones.qrCodeToken, zone)) + .limit(1) + ) if (zd) { - zoneName = (zd as { name: string }).name ?? null - siteName = (zd.sites as unknown as { name: string } | null)?.name ?? null + zoneName = zd.name + siteName = zd.siteName ?? null } } - const { data: trucks } = await supabase - .from('trucks') - .select('id, truck_no, carrier') - .eq('active', true) - .order('truck_no') + const truckRows = await asAdmin(db => + db.select({ + id: trucks.id, + truckNo: trucks.truckNo, + carrier: trucks.carrier, + }) + .from(trucks) + .where(eq(trucks.active, true)) + .orderBy(asc(trucks.truckNo)) + ) + + const truckList = truckRows.map(t => ({ + id: t.id, + truck_no: t.truckNo, + carrier: t.carrier ?? null, + })) return (
@@ -58,7 +74,7 @@ export default async function ReportPage({ searchParams }: Props) { zoneToken={zone ?? null} zoneName={zoneName} siteName={siteName} - trucks={(trucks ?? []) as Array<{ id: string; truck_no: string; carrier: string | null }>} + trucks={truckList} initialTruckId={truck_id ?? null} />