FleetPilot — Frontend Patterns
Reference for VS Code Claude sessions working on fleetpilot-frontend. Also lives in fleetpilot-frontend/CONTEXT.md but expanded here.
Stack
- Next.js (App Router) + Tailwind CSS v4 + shadcn/ui → Vercel
- Font: Geist (Next.js default)
- React 19 — use
useActionStatefrom'react'NOT'react-dom'
App structure
app/
(auth)/
login/
signup/
verify-email/
(dashboard)/
layout.tsx ← Auth guard + DashboardShell
dashboard/ ← SAA-49
fleet/ ← SAA-16
customers/ ← SAA-17
bookings/ ← SAA-18, SAA-63
violations/ ← SAA-42
scan/ ← SAA-30
queue/ ← SAA-57
incidents/ ← SAA-41
tracking/ ← SAA-55
components/
layout/
dashboard-shell.tsx
sidebar.tsx
top-bar.tsx
ui/ ← shadcn/ui primitives
fleet/
customers/
bookings/
violations/
incidents/
tracking/
documents/
lib/
supabase/
server.ts ← server components + actions
client.ts ← client components only
api/
client.ts ← apiGet/apiPost for FastAPI calls
bookings/
derived-status.ts ← single source of truth for booking display status
mock/
fleet-positions.ts ← mock GPS data (real GPS = SAA-32)
Server components (pages)
Every page under app/(dashboard)/ is an async server component.
export default async function FleetPage({ searchParams }) {
const { search } = await searchParams // Next.js 15+ — searchParams is a Promise
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: userRow } = await supabase
.from('users')
.select('tenant_id')
.eq('id', user.id)
.single()
// ALWAYS fetch tenant_id before any data query
// ALWAYS scope data queries to tenant_id explicitly
const { data: vehicles } = await supabase
.from('vehicles')
.select('*')
.eq('tenant_id', userRow.tenant_id)
.is('archived_at', null)
.order('created_at', { ascending: false })
return <VehicleList vehicles={vehicles} />
}
Time-sensitive pages (dashboard): export const dynamic = 'force-dynamic'
Parallel fetching: Use Promise.all to avoid waterfalls.
Server actions
Location: app/(dashboard)/[section]/actions.ts — marked 'use server'
Pattern for every action:
1. createClient() → auth check → redirect if no user
2. Fetch tenant_id from users table
3. Parse + validate formData
4. Supabase query scoped to tenant_id
5. Return { error: 'Generic message' } on failure — NEVER return raw Supabase error.message
6. redirect('/section') on success
'use server'
export async function createVehicle(_prevState: ActionState, formData: FormData) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: userRow } = await supabase
.from('users').select('tenant_id').eq('id', user.id).single()
if (!userRow) return { error: 'User tenant not found' }
const { error } = await supabase.from('vehicles').insert({
tenant_id: userRow.tenant_id,
registration: formData.get('registration') as string,
// ...
})
if (error) return { error: 'Failed to create vehicle' } // NEVER return error.message
redirect('/fleet')
}
Client components (forms)
'use client'
export function VehicleForm({ action }: { action: BoundAction }) {
const [state, formAction, isPending] = useActionState(action, null)
// useActionState from 'react' NOT 'react-dom' (React 19)
return (
<form action={formAction}>
{state?.error && (
<p role="alert">{state.error}</p> // role="alert" for errors
)}
{/* ... */}
<Button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</Button>
</form>
)
}
Supabase client
// Server components and actions:
import { createClient } from '@/lib/supabase/server'
// Client components only (avoid if possible):
import { createClient } from '@/lib/supabase/client'
FastAPI client
Used only in client components for GCS operations.
import { apiPost, apiGet } from '@/lib/api/client'
// Requires Supabase session token
const { data, error } = await apiPost('/documents/upload-url', body, token)
FastAPI is called for: GCS upload URL generation, document registration, document deletion, Claude API calls, Cloud Tasks operations, DVLA/Credas API calls.
NEVER call FastAPI for standard CRUD (vehicles, customers, bookings, violations, incidents).
Date handling
All timestamps stored as UTC.
// Display dates:
new Date(date).toLocaleDateString('en-GB', { timeZone: 'UTC' })
// Day-boundary comparisons (avoid timezone edge cases):
const todayUTC = Date.UTC(year, month, date)
// Date inputs yield YYYY-MM-DD strings — convert for storage:
const isoTimestamp = `${dateString}T00:00:00Z`
Design tokens
/* Available as CSS variables (SAA-51): */
--color-brand-navy
--color-brand-surface
--color-brand-white
--color-brand-accent
--color-ok /* success green */
--color-warn /* warning amber */
--color-crit /* danger red */
--color-neutral /* gray */
// Use semantic classes:
<div className="bg-[--color-brand-navy]">
// Not:
<div className="bg-[#191E29]">
Tenant isolation
Every Supabase query in an action or page MUST include .eq('tenant_id', userRow.tenant_id).
RLS is a safety net, NOT a substitute for explicit filtering.
Mobile standard
Every component must pass before marking Done: - Tested at 390px viewport - Navigation usable one-handed - Forms: correct input types (tel, email, number), no zoom on focus - Tables: collapse to cards below 768px - Tap targets: minimum 44px
DerivedStatus pattern (bookings)
// lib/bookings/derived-status.ts
export type DerivedStatus = 'active' | 'upcoming' | 'overdue' | 'returned' | 'cancelled'
export function deriveStatus(b: { status: string; starts_at: string; ends_at: string }): DerivedStatus {
if (b.status === 'completed') return 'returned'
if (b.status === 'cancelled') return 'cancelled'
if (b.status === 'pending' && new Date(b.starts_at) < new Date()) return 'overdue'
if (b.status === 'pending') return 'upcoming'
if (b.status === 'active' && new Date(b.ends_at) < new Date()) return 'overdue'
return 'active'
}
// Badge colours:
// active → ok (green)
// upcoming → brand (blue)
// overdue → crit (red)
// returned → neutral (gray)
// cancelled → crit (red)