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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPf5Rc8QPx6V8KLEEgfKEQ
This commit is contained in:
2026-07-13 10:56:07 +08:00
co-authored by Claude Sonnet 4.6
parent 6c1ad71643
commit f90bf4ed03
16 changed files with 266 additions and 26 deletions
+8
View File
@@ -8,6 +8,7 @@ const TYPE_LABELS: Record<string, string> = {
environmental: 'Environmental',
security: 'Security',
fire: 'Fire / Emergency',
transport: 'Transport / Vehicle',
}
const STATUS_COLORS: Record<string, string> = {
@@ -42,6 +43,7 @@ export type Incident = {
type_details?: Record<string, string | boolean> | 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) {
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<Field label="Site" value={incident.sites?.name} />
<Field label="Zone" value={incident.zones?.name} />
{incident.trucks && (
<Field
label="Truck"
value={`${incident.trucks.truck_no}${incident.trucks.carrier ? `${incident.trucks.carrier}` : ''}`}
/>
)}
<Field label="Reported by" value={incident.reporter?.name} />
<Field label="Reported at" value={new Date(incident.reported_at).toLocaleString('en-MY', { timeZone: 'Asia/Kuala_Lumpur' })} />
<Field label="Severity" value={incident.severity ? `Level ${incident.severity}` : 'Not yet assigned'} />
+19 -7
View File
@@ -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 (
<div className="flex flex-wrap gap-2 mb-4">
@@ -102,6 +104,16 @@ function FiltersInner({ sites }: FiltersProps) {
{sites.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
)}
{trucks.length > 0 && (
<select
value={searchParams.get('truck_id') ?? ''}
onChange={e => updateParam('truck_id', e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
>
<option value="">All Trucks</option>
{trucks.map(tk => <option key={tk.id} value={tk.id}>{tk.truck_no}</option>)}
</select>
)}
{hasFilters && (
<button
onClick={() => {
@@ -117,10 +129,10 @@ function FiltersInner({ sites }: FiltersProps) {
)
}
export function IncidentFilters({ sites }: FiltersProps) {
export function IncidentFilters({ sites, trucks }: FiltersProps) {
return (
<Suspense fallback={<div className="h-10 mb-4" />}>
<FiltersInner sites={sites} />
<FiltersInner sites={sites} trucks={trucks} />
</Suspense>
)
}
+5
View File
@@ -11,6 +11,7 @@ const TYPE_LABELS: Record<string, string> = {
environmental: 'Environmental',
security: 'Security',
fire: 'Fire / Emergency',
transport: 'Transport / Vehicle',
}
const STATUS_COLORS: Record<string, string> = {
@@ -31,6 +32,7 @@ export type Incident = {
reported_at: string
sites: { name: string } | null
zones: { name: string } | null
trucks: { truck_no: string } | null
reporter: { name: string } | null
}
@@ -113,6 +115,9 @@ export function IncidentList({ incidents, basePath }: Props) {
{TYPE_LABELS[inc.incident_type] ?? inc.incident_type}
{inc.zones?.name && ` · ${inc.zones.name}`}
{inc.sites?.name && ` · ${inc.sites.name}`}
{inc.trucks?.truck_no && (
<span className="ml-1 text-xs text-gray-400">· {inc.trucks.truck_no}</span>
)}
</div>
</div>
<div className="ml-4 text-right">
+29 -3
View File
@@ -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<Record<string, string | boolean>>({})
@@ -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) {
</div>
)}
{form.incident_type === 'transport' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t.truckLabel} <span className="text-red-500">*</span>
</label>
<select
required
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.truck_id}
onChange={e => setForm(f => ({ ...f, truck_id: e.target.value }))}
>
<option value="">{t.truckPlaceholder}</option>
{trucks.map(tk => (
<option key={tk.id} value={tk.id}>
{tk.truck_no}{tk.carrier ? `${tk.carrier}` : ''}
</option>
))}
</select>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t.descriptionLabel} <span className="text-red-500">*</span>
@@ -344,7 +370,7 @@ export function ReportForm({ zoneToken }: Props) {
<button
type="submit"
disabled={submitting || !form.incident_type}
disabled={submitting || !form.incident_type || (form.incident_type === 'transport' && !form.truck_id)}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium text-sm
hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>