From f90bf4ed034b4aca2c48a650ac8aa6b63e381693 Mon Sep 17 00:00:00 2001 From: weeihan Date: Mon, 13 Jul 2026 10:56:07 +0800 Subject: [PATCH] feat: transport incidents with truck number support - DB: trucks table + incidents.truck_id FK + transport enum value - Validation: transport type requires truck_id - Create API: validates truck exists/active, persists truck_id - Report form: truck dropdown shown when type=transport (required) - Admin: TruckManager CRUD + /api/admin/trucks route - Detail: trucks join surfaced in incident-detail + detail page query - Inbox (HSE + supervisor): truck filter, transport in TYPE_OPTIONS, fixed stale enum values (dropped dangerous_occurrence/mhe_asset/occupational_disease) - List: transport label + truck number badge in rows - i18n: transport + truckLabel/truckPlaceholder in en/ms/zh Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ --- app/(protected)/admin/page.tsx | 8 +- app/(protected)/hse/incidents/[id]/page.tsx | 1 + app/(protected)/hse/incidents/page.tsx | 12 +- app/(protected)/supervisor/incidents/page.tsx | 12 +- app/api/admin/trucks/route.ts | 34 ++++++ app/api/incidents/route.ts | 10 ++ app/report/page.tsx | 13 ++- components/admin/truck-manager.tsx | 109 ++++++++++++++++++ components/incidents/incident-detail.tsx | 8 ++ components/incidents/incident-filters.tsx | 26 +++-- components/incidents/incident-list.tsx | 5 + components/incidents/report-form.tsx | 32 ++++- lib/offline/db.ts | 1 + messages/en.json | 7 +- messages/ms.json | 7 +- messages/zh.json | 7 +- 16 files changed, 266 insertions(+), 26 deletions(-) create mode 100644 app/api/admin/trucks/route.ts create mode 100644 components/admin/truck-manager.tsx diff --git a/app/(protected)/admin/page.tsx b/app/(protected)/admin/page.tsx index 7932182..1c3c7ef 100644 --- a/app/(protected)/admin/page.tsx +++ b/app/(protected)/admin/page.tsx @@ -5,6 +5,7 @@ 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' +import { TruckManager, type Truck } from '@/components/admin/truck-manager' export default async function AdminHome() { const supabase = await createClient() @@ -15,7 +16,7 @@ export default async function AdminHome() { .from('users').select('role').eq('id', user.id).single() if (!profile || profile.role !== 'admin') redirect('/login') - const [{ data: users }, { data: sites }] = await Promise.all([ + const [{ data: users }, { data: sites }, { data: trucks }] = await Promise.all([ supabase .from('users') .select('id, name, email, role, department, site_id, active') @@ -24,6 +25,10 @@ export default async function AdminHome() { .from('sites') .select('id, name, address, zones (id, name, qr_code_token)') .order('name'), + supabase + .from('trucks') + .select('id, truck_no, carrier, active') + .order('truck_no'), ]) const siteOptions: SiteOption[] = (sites ?? []).map(s => ({ id: s.id, name: s.name })) @@ -41,6 +46,7 @@ export default async function AdminHome() { + ) } diff --git a/app/(protected)/hse/incidents/[id]/page.tsx b/app/(protected)/hse/incidents/[id]/page.tsx index 7edff7b..98a3940 100644 --- a/app/(protected)/hse/incidents/[id]/page.tsx +++ b/app/(protected)/hse/incidents/[id]/page.tsx @@ -25,6 +25,7 @@ export default async function HseIncidentDetailPage({ params }: Props) { 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) `) diff --git a/app/(protected)/hse/incidents/page.tsx b/app/(protected)/hse/incidents/page.tsx index b7089b9..ac5a545 100644 --- a/app/(protected)/hse/incidents/page.tsx +++ b/app/(protected)/hse/incidents/page.tsx @@ -3,14 +3,14 @@ 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' +import { IncidentFilters, type SiteOption, type TruckOption } from '@/components/incidents/incident-filters' const PAGE_SIZE = 25 export default async function HseInboxPage({ searchParams, }: { - searchParams: Promise<{ page?: string; q?: string; status?: string; type?: string; site_id?: string }> + searchParams: Promise<{ page?: string; q?: string; status?: string; type?: string; site_id?: string; truck_id?: string }> }) { const supabase = await createClient() const params = await searchParams @@ -23,6 +23,7 @@ export default async function HseInboxPage({ 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 }) @@ -33,13 +34,16 @@ export default async function HseInboxPage({ 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 [{ data: incidents, count }, { data: sites }] = await Promise.all([ + 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 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 })) return (
@@ -47,7 +51,7 @@ export default async function HseInboxPage({

Incident Inbox

{count ?? incidents?.length ?? 0} incidents - +
diff --git a/app/(protected)/supervisor/incidents/page.tsx b/app/(protected)/supervisor/incidents/page.tsx index 1889066..2bcdb81 100644 --- a/app/(protected)/supervisor/incidents/page.tsx +++ b/app/(protected)/supervisor/incidents/page.tsx @@ -3,14 +3,14 @@ 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' +import { IncidentFilters, type SiteOption, type TruckOption } from '@/components/incidents/incident-filters' const PAGE_SIZE = 25 export default async function SupervisorInboxPage({ searchParams, }: { - searchParams: Promise<{ page?: string; q?: string; status?: string; type?: string; site_id?: string }> + searchParams: Promise<{ page?: string; q?: string; status?: string; type?: string; site_id?: string; truck_id?: string }> }) { const supabase = await createClient() const params = await searchParams @@ -23,6 +23,7 @@ export default async function SupervisorInboxPage({ 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 }) @@ -33,13 +34,16 @@ export default async function SupervisorInboxPage({ 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 [{ data: incidents, count }, { data: sites }] = await Promise.all([ + 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 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 })) return (
@@ -47,7 +51,7 @@ export default async function SupervisorInboxPage({

Incident Inbox

{count ?? incidents?.length ?? 0} incidents - +
diff --git a/app/api/admin/trucks/route.ts b/app/api/admin/trucks/route.ts new file mode 100644 index 0000000..d954369 --- /dev/null +++ b/app/api/admin/trucks/route.ts @@ -0,0 +1,34 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { requireAdmin } from '@/lib/auth/require-admin' + +export async function POST(request: NextRequest) { + const { supabase, user } = await requireAdmin() + if (!user) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + + const body: { truck_no?: string; carrier?: string } = await request.json().catch(() => ({})) + const truck_no = (body.truck_no ?? '').trim() + if (!truck_no) return NextResponse.json({ error: 'truck_no required' }, { status: 422 }) + + const { data: truck, error } = await supabase + .from('trucks') + .insert({ truck_no, carrier: (body.carrier ?? '').trim() || null }) + .select('id') + .single() + + if (error || !truck) + return NextResponse.json( + { error: error?.code === '23505' ? 'Truck number already exists' : 'Insert failed' }, + { status: 400 }, + ) + + await supabase.rpc('write_audit_log', { + p_table_name: 'trucks', + p_record_id: truck.id, + p_action: 'INSERT', + p_new_value: { truck_no, carrier: (body.carrier ?? '').trim() || null }, + }) + + return NextResponse.json({ id: truck.id }, { status: 201 }) +} diff --git a/app/api/incidents/route.ts b/app/api/incidents/route.ts index 61dc9a6..ee2c1d4 100644 --- a/app/api/incidents/route.ts +++ b/app/api/incidents/route.ts @@ -47,6 +47,7 @@ export async function POST(request: Request) { injury_involved: body.injury_involved === 'true' || body.injury_involved === true, asset_involved: body.asset_involved === 'true' || body.asset_involved === true, medical_status: body.medical_status as MedicalStatus | undefined || undefined, + truck_id: (body.truck_id as string) || null, } const validation = validateIncidentInput(input) @@ -54,6 +55,14 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Validation failed', details: validation.errors }, { status: 422 }) } + let truckId: string | null = null + if (input.incident_type === 'transport') { + const { data: truck } = await supabase + .from('trucks').select('id').eq('id', input.truck_id).eq('active', true).single() + if (!truck) return NextResponse.json({ error: 'Validation failed', details: ['truck not found or inactive'] }, { status: 422 }) + truckId = truck.id + } + let rawDetails: Record | undefined if (typeof body.type_details === 'string' && body.type_details) { try { @@ -91,6 +100,7 @@ export async function POST(request: Request) { asset_involved: input.asset_involved, medical_status: input.injury_involved ? (input.medical_status ?? 'none') : null, type_details: detailsCheck.sanitized, + truck_id: truckId, }) .select('id, reference_no') .single() diff --git a/app/report/page.tsx b/app/report/page.tsx index fc3160f..580481a 100644 --- a/app/report/page.tsx +++ b/app/report/page.tsx @@ -32,6 +32,12 @@ export default async function ReportPage({ searchParams }: Props) { } } + const { data: trucks } = await supabase + .from('trucks') + .select('id, truck_no, carrier') + .eq('active', true) + .order('truck_no') + return (
@@ -47,7 +53,12 @@ export default async function ReportPage({ searchParams }: Props) {

No zone detected — zone will not be recorded

)}
- + } + />
) diff --git a/components/admin/truck-manager.tsx b/components/admin/truck-manager.tsx new file mode 100644 index 0000000..eb4f68f --- /dev/null +++ b/components/admin/truck-manager.tsx @@ -0,0 +1,109 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' + +export type Truck = { + id: string + truck_no: string + carrier: string | null + active: boolean +} + +interface Props { + trucks: Truck[] +} + +export function TruckManager({ trucks }: Props) { + const router = useRouter() + const [truckNo, setTruckNo] = useState('') + const [carrier, setCarrier] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + async function handleAdd() { + const no = truckNo.trim() + if (!no) return + setSubmitting(true) + setError(null) + try { + const res = await fetch('/ims/api/admin/trucks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ truck_no: no, carrier: carrier.trim() || null }), + }) + const data = await res.json() + if (!res.ok) { + setError(data.error ?? 'Failed to add truck') + return + } + setTruckNo('') + setCarrier('') + router.refresh() + } catch { + setError('Network error') + } finally { + setSubmitting(false) + } + } + + return ( +
+
+

Trucks

+
+ +
+
+ setTruckNo(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAdd()} + /> + setCarrier(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAdd()} + /> + +
+ + {error && ( +

{error}

+ )} + + {trucks.length === 0 ? ( +

No trucks yet.

+ ) : ( +
    + {trucks.map(tk => ( +
  • + {tk.truck_no} + {tk.carrier && ( + {tk.carrier} + )} + {!tk.active && ( + + Inactive + + )} +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/components/incidents/incident-detail.tsx b/components/incidents/incident-detail.tsx index 91a3d71..059c3d6 100644 --- a/components/incidents/incident-detail.tsx +++ b/components/incidents/incident-detail.tsx @@ -8,6 +8,7 @@ const TYPE_LABELS: Record = { environmental: 'Environmental', security: 'Security', fire: 'Fire / Emergency', + transport: 'Transport / Vehicle', } const STATUS_COLORS: Record = { @@ -42,6 +43,7 @@ export type Incident = { type_details?: Record | null sites: { id: string; name: string } | null zones: { id: string; name: string } | null + trucks: { id: string; truck_no: string; carrier: string | null } | null reporter: { id: string; name: string; email: string } | null evidence_files: Array<{ id: string @@ -86,6 +88,12 @@ export function IncidentDetail({ incident }: Props) {
+ {incident.trucks && ( + + )} diff --git a/components/incidents/incident-filters.tsx b/components/incidents/incident-filters.tsx index 06658f1..b8f1fb0 100644 --- a/components/incidents/incident-filters.tsx +++ b/components/incidents/incident-filters.tsx @@ -17,21 +17,23 @@ const TYPE_OPTIONS = [ { value: '', label: 'All Types' }, { value: 'injury', label: 'Injury' }, { value: 'near_miss', label: 'Near Miss' }, - { value: 'dangerous_occurrence', label: 'Dangerous Occurrence' }, - { value: 'occupational_disease', label: 'Occupational Disease' }, + { value: 'hazard', label: 'Hazard' }, + { value: 'asset_damage', label: 'Asset Damage' }, { value: 'environmental', label: 'Environmental' }, - { value: 'mhe_asset', label: 'MHE / Asset' }, { value: 'security', label: 'Security' }, { value: 'fire', label: 'Fire' }, + { value: 'transport', label: 'Transport' }, ] export interface SiteOption { id: string; name: string } +export interface TruckOption { id: string; truck_no: string } interface FiltersProps { sites: SiteOption[] + trucks: TruckOption[] } -function FiltersInner({ sites }: FiltersProps) { +function FiltersInner({ sites, trucks }: FiltersProps) { const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() @@ -58,7 +60,7 @@ function FiltersInner({ sites }: FiltersProps) { }, [updateParam]) const hasFilters = searchParams.get('q') || searchParams.get('status') || - searchParams.get('type') || searchParams.get('site_id') + searchParams.get('type') || searchParams.get('site_id') || searchParams.get('truck_id') return (
@@ -102,6 +104,16 @@ function FiltersInner({ sites }: FiltersProps) { {sites.map(s => )} )} + {trucks.length > 0 && ( + + )} {hasFilters && (
diff --git a/components/incidents/report-form.tsx b/components/incidents/report-form.tsx index f114e1a..1e481d7 100644 --- a/components/incidents/report-form.tsx +++ b/components/incidents/report-form.tsx @@ -10,9 +10,10 @@ interface Props { zoneToken: string | null zoneName: string | null siteName: string | null + trucks: Array<{ id: string; truck_no: string; carrier: string | null }> } -export function ReportForm({ zoneToken }: Props) { +export function ReportForm({ zoneToken, trucks }: Props) { const router = useRouter() const t = useTranslations('ReportForm') const itLabels = useTranslations('IncidentType') @@ -27,6 +28,7 @@ export function ReportForm({ zoneToken }: Props) { ['environmental', itLabels.environmental], ['security', itLabels.security], ['fire', itLabels.fire], + ['transport', itLabels.transport], ] const MEDICAL_STATUS_OPTIONS: [MedicalStatus, string][] = [ @@ -55,6 +57,7 @@ export function ReportForm({ zoneToken }: Props) { injury_involved: false, medical_status: '' as MedicalStatus | '', asset_involved: false, + truck_id: '', }) const [typeDetails, setTypeDetails] = useState>({}) @@ -116,6 +119,7 @@ export function ReportForm({ zoneToken }: Props) { asset_involved: form.asset_involved, medical_status: form.medical_status || undefined, type_details: Object.keys(typeDetails).length > 0 ? typeDetails : undefined, + truck_id: form.truck_id || undefined, created_at: new Date().toISOString(), }) setSavedOffline(true) @@ -168,6 +172,7 @@ export function ReportForm({ zoneToken }: Props) { if (Object.keys(typeDetails).length > 0) { fd.append('type_details', JSON.stringify(typeDetails)) } + if (form.truck_id) fd.append('truck_id', form.truck_id) files.forEach(f => fd.append('files', f)) const res = await fetch('/ims/api/incidents', { method: 'POST', body: fd }) @@ -217,7 +222,7 @@ export function ReportForm({ zoneToken }: Props) { className="w-full border border-gray-300 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" value={form.incident_type} onChange={e => { - setForm(f => ({ ...f, incident_type: e.target.value as IncidentType })) + setForm(f => ({ ...f, incident_type: e.target.value as IncidentType, truck_id: '' })) setTypeDetails({}) }} > @@ -254,6 +259,27 @@ export function ReportForm({ zoneToken }: Props) {
)} + {form.incident_type === 'transport' && ( +
+ + +
+ )} +