Skip to content

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 useActionState from '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)