
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
Last-mile route dispatching, delivery driver stop sequencing, and vehicle payload load balancing: live dispatch overview KPIs, zone filtering, AI route auto-sequencing, and driver route cards with vehicle payload load balancing, live progress tracking, interactive stops timeline, driver communications, and telemetry actions.
Also available for React ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/dispatch-assignment-board.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/dispatch-assignment-board.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/dispatch-assignment-board.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/dispatch-assignment-board.jsonnpx shadcn-vue@latest add @uipkge/dispatch-assignment-boardInstalls to:app/components/blocks/| Name | Type / Values | Default | Required |
|---|---|---|---|
initialZone | string | 'Metro Los Angeles - North Zone' | optional |
initialFilter | 'all''in_transit''high_load''near_complete' | 'all' | optional |
class | HTMLAttributes['class'] | — | optional |
Type aliases exported from this item's source. Use these to shape the data you pass in.
RouteStopinterface RouteStop {
id: string
sequence: number
locationName: string
address: string
status: StopStatus
deliveredTime?: string
estimatedTime?: string
packagesCount: number
packageType: string
notes?: string
recipient?: string
}DriverRouteinterface DriverRoute {
id: string
routeCode: string
routeName: string
zone: string
driver: {
name: string
avatar?: string
initials: string
phone: string
status: 'on_route' | 'break' | 'loading'
radioChannel: string
}
vehicle: {
name: string
type: string
plateNumber: string
powerLevel: string
powerType: 'electric' | 'diesel'
}
completedStops: number
totalStops: number
currentPayloadLbs: number
maxPayloadLbs: number
shiftWindow: string
estimatedCompletion: string
telemetry: {
speed: string
heading: string
currentLocation: string
lastPing: string
signal: string
}
stops: RouteStop[]
}ActiveModalStateinterface ActiveModalState {
type: 'gps' | 'call' | 'reassign'
route: DriverRoute
targetRouteId?: string
}<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed, ref } from 'vue'
import {
ArrowRightLeft,
BatteryCharging,
Check,
Clock,
Download,
Fuel,
Gauge,
MapPin,
Navigation,
PackageCheck,
Phone,
Search,
CheckCircle2,
ShieldCheck,
Truck,
X,
} from 'lucide-vue-next'
import DispatchActionModal from './DispatchActionModal.vue'
import { DRIVER_ROUTES_DATA } from './dispatch-data'
import type { ActiveModalState, DriverRoute, RouteStop, StopStatus } from './dispatch-assignment-board-types'
export type { ActiveModalState, DriverRoute, RouteStop, StopStatus } from './dispatch-assignment-board-types'
import { cn } from '@/lib/utils'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
const props = withDefaults(
defineProps<{
initialZone?: string
initialFilter?: 'all' | 'in_transit' | 'high_load' | 'near_complete'
class?: HTMLAttributes['class']
}>(),
{
initialZone: 'Metro Los Angeles - North Zone',
initialFilter: 'all',
},
)
const selectedZone = ref(props.initialZone)
const filterType = ref<'all' | 'in_transit' | 'high_load' | 'near_complete'>(props.initialFilter)
const searchQuery = ref('')
const isOptimizing = ref(false)
const notificationBanner = ref<{ title: string; message: string; type: 'success' | 'info' } | null>(null)
const activeModal = ref<{
type: 'gps' | 'call' | 'reassign'
route: DriverRoute
targetRouteId?: string
} | null>(null)
const filteredRoutes = computed(() => {
return DRIVER_ROUTES_DATA.filter((route) => {
// Zone filter
if (selectedZone.value !== 'all' && route.zone !== selectedZone.value) {
return false
}
// Category / Quick filter
if (filterType.value === 'in_transit' && route.driver.status !== 'on_route') {
return false
}
if (filterType.value === 'high_load' && route.currentPayloadLbs / route.maxPayloadLbs < 0.8) {
return false
}
if (filterType.value === 'near_complete' && route.completedStops / route.totalStops < 0.75) {
return false
}
// Search query
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
const matchRoute = route.routeCode.toLowerCase().includes(q) || route.routeName.toLowerCase().includes(q)
const matchDriver = route.driver.name.toLowerCase().includes(q)
const matchVehicle =
route.vehicle.name.toLowerCase().includes(q) || route.vehicle.plateNumber.toLowerCase().includes(q)
const matchStop = route.stops.some(
(s) => s.locationName.toLowerCase().includes(q) || s.address.toLowerCase().includes(q),
)
if (!matchRoute && !matchDriver && !matchVehicle && !matchStop) {
return false
}
}
return true
})
})
function handleAutoOptimize() {
isOptimizing.value = true
notificationBanner.value = null
setTimeout(() => {
isOptimizing.value = false
notificationBanner.value = {
title: 'AI Route Optimization Complete',
message: 'Re-sequenced 4 active routes · Saved 18.4 road miles · Estimated 22 min overall transit reduction.',
type: 'success',
}
}, 750)
}
function handleExportManifests() {
notificationBanner.value = {
title: 'Dispatch Manifests Exported',
message: 'Generated PDF loading manifests and CSV stop sequences for 4 routes (180 total stops).',
type: 'info',
}
}
function openModal(type: 'gps' | 'call' | 'reassign', route: DriverRoute) {
activeModal.value = {
type,
route,
targetRouteId: DRIVER_ROUTES_DATA.find((r) => r.id !== route.id)?.id,
}
}
function closeModal() {
activeModal.value = null
}
function resetFilters() {
selectedZone.value = 'all'
searchQuery.value = ''
filterType.value = 'all'
}
function handleConfirmReassign() {
if (activeModal.value) {
const route = activeModal.value.route
notificationBanner.value = {
title: 'Stops Successfully Reassigned',
message: `Pending stop from ${route.routeCode} transferred. Driver GPS routes and manifest synced.`,
type: 'success',
}
closeModal()
}
}
</script>
<template>
<div :class="cn('text-foreground w-full space-y-6', props.class)" data-slot="dispatch-assignment-board">
<!-- Header Section -->
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-1">
<div class="flex flex-wrap items-center gap-2">
<div
class="bg-success/10 text-success flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold"
>
<span class="relative flex h-2 w-2">
<span class="bg-success absolute inline-flex h-full w-full rounded-full opacity-75" />
<span class="bg-success relative inline-flex h-2 w-2 rounded-full" />
</span>
<span>Live Dispatch Operations</span>
</div>
<div class="text-muted-foreground flex items-center gap-1 text-xs">
<Clock class="h-3.5 w-3.5" />
<span class="font-mono tabular-nums">Today · Aug 21, 2026</span>
</div>
</div>
<h1 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl">
Delivery Dispatch & Route Optimization
</h1>
<p class="text-muted-foreground text-xs sm:text-sm">
Last-mile route sequencing, real-time stop execution, and vehicle payload load balancing.
</p>
</div>
<!-- Header Controls -->
<div class="flex flex-wrap items-center gap-2.5">
<div class="w-full sm:w-64">
<Select v-model="selectedZone">
<SelectTrigger class="h-9 w-full text-xs font-medium" aria-label="Select Dispatch Zone">
<SelectValue placeholder="Select Zone" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel class="text-xs">Dispatch Zones</SelectLabel>
<SelectItem value="all" class="text-xs">All Dispatch Zones</SelectItem>
<SelectItem value="Metro Los Angeles - North Zone" class="text-xs">
Metro Los Angeles - North Zone
</SelectItem>
<SelectItem value="Metro Los Angeles - South Zone" class="text-xs">
Metro Los Angeles - South Zone
</SelectItem>
<SelectItem value="San Fernando Valley Hub" class="text-xs"> San Fernando Valley Hub </SelectItem>
<SelectItem value="Orange County Central" class="text-xs"> Orange County Central </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<Button
aria-label="Download attachment"
variant="outline"
size="sm"
class="h-9 gap-1.5 text-xs font-medium"
@click="handleExportManifests"
>
<Download class="h-3.5 w-3.5" />
<span>Export Manifests</span>
</Button>
<Button
size="sm"
class="h-9 gap-1.5 text-xs font-medium shadow-xs"
:disabled="isOptimizing"
@click="handleAutoOptimize"
>
<Navigation :class="cn('h-3.5 w-3.5', isOptimizing && 'text-primary-foreground animate-spin')" />
<span>{{ isOptimizing ? 'Optimizing Routes...' : 'Auto-Optimize Routes' }}</span>
</Button>
</div>
</div>
<!-- Notification Banner -->
<div
v-if="notificationBanner"
:class="
cn(
'animate-in fade-in-50 flex items-start justify-between rounded-lg border p-3.5 text-xs transition-colors duration-200',
notificationBanner.type === 'success'
? 'border-success/30 bg-success/10 text-success'
: 'border-primary/30 bg-primary/10 text-foreground',
)
"
>
<div class="flex items-start gap-2.5">
<CheckCircle2 v-if="notificationBanner.type === 'success'" class="text-success mt-0.5 h-4 w-4 shrink-0" />
<PackageCheck v-else class="text-primary mt-0.5 h-4 w-4 shrink-0" />
<div>
<span class="font-semibold">{{ notificationBanner.title }}:</span>
<span class="text-muted-foreground dark:text-foreground/80 ml-1">{{ notificationBanner.message }}</span>
</div>
</div>
<button
aria-label="Dismiss notification"
type="button"
class="text-muted-foreground hover:text-foreground transition-colors"
@click="notificationBanner = null"
>
<X class="h-4 w-4" />
</button>
</div>
<!-- Dispatch Overview KPI Cards (4 Cards) -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<!-- KPI 1: Active Routes -->
<Card class="border-border shadow-xs">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-muted-foreground text-xs font-medium">Total Routes Active</CardTitle>
<div class="bg-primary/10 text-primary flex h-8 w-8 items-center justify-center rounded-md">
<Truck class="h-4 w-4" />
</div>
</CardHeader>
<CardContent class="space-y-1.5">
<div class="flex items-baseline gap-2">
<span class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">8</span>
<span class="text-muted-foreground text-xs font-medium">routes dispatched</span>
</div>
<div class="text-muted-foreground flex items-center gap-2 text-xs">
<Badge
variant="outline"
class="border-success/20 bg-success/10 text-success h-5 px-1.5 text-xs font-medium"
>
6 In Transit
</Badge>
<span>2 loading at hub</span>
</div>
</CardContent>
</Card>
<!-- KPI 2: Stops Remaining & Progress -->
<Card class="border-border shadow-xs">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-muted-foreground text-xs font-medium">Total Stops Remaining</CardTitle>
<div class="bg-info/10 text-info flex h-8 w-8 items-center justify-center rounded-md">
<PackageCheck class="h-4 w-4" />
</div>
</CardHeader>
<CardContent class="space-y-2">
<div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
<div class="flex items-baseline gap-1">
<span class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">142</span>
<span class="text-muted-foreground font-mono text-xs tabular-nums">/ 180 stops</span>
</div>
<span class="text-info text-info font-mono text-xs font-semibold tabular-nums">78.8%</span>
</div>
<Progress :model-value="78.8" class="h-1.5" />
<p class="text-muted-foreground text-xs">
<span class="text-foreground font-mono font-semibold tabular-nums">38</span> stops pending completion
</p>
</CardContent>
</Card>
<!-- KPI 3: On-Time Rate -->
<Card class="border-border shadow-xs">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-muted-foreground text-xs font-medium">On-Time Delivery Rate</CardTitle>
<div class="bg-success/10 text-success flex h-8 w-8 items-center justify-center rounded-md">
<ShieldCheck class="h-4 w-4" />
</div>
</CardHeader>
<CardContent class="space-y-1.5">
<div class="flex items-baseline gap-2">
<span class="text-success text-success font-mono text-2xl font-bold tracking-tight tabular-nums"
>97.4%</span
>
<Badge
variant="outline"
class="border-success/20 bg-success/10 text-success h-5 px-1.5 text-xs font-medium"
>
+1.8% vs SLA
</Badge>
</div>
<p class="text-muted-foreground text-xs">
Target: <span class="text-foreground font-mono font-medium tabular-nums">≥95.0%</span> · 0 SLA breaches
</p>
</CardContent>
</Card>
<!-- KPI 4: Cargo Weight Dispatched -->
<Card class="border-border shadow-xs">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-muted-foreground text-xs font-medium">Total Cargo Weight Dispatched</CardTitle>
<div class="bg-warning/10 text-warning flex h-8 w-8 items-center justify-center rounded-md">
<Gauge class="h-4 w-4" />
</div>
</CardHeader>
<CardContent class="space-y-2">
<div class="flex flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
<div class="flex items-baseline gap-1">
<span class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">14,280</span>
<span class="text-muted-foreground text-xs">lbs</span>
</div>
<Badge
variant="outline"
class="border-warning/20 bg-warning/10 text-warning h-5 px-1.5 text-xs font-medium"
>
82% capacity
</Badge>
</div>
<div class="bg-muted relative h-1.5 w-full overflow-hidden rounded-full">
<div class="bg-warning h-full rounded-full transition-[width] duration-500" style="width: 82%" />
</div>
<p class="text-muted-foreground text-xs">
Payload limit: <span class="text-foreground font-mono font-medium tabular-nums">17,400 lbs</span> · Well
balanced
</p>
</CardContent>
</Card>
</div>
<!-- Filter & Search Controls Bar -->
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-1.5">
<Button
size="sm"
:variant="filterType === 'all' ? 'default' : 'outline'"
class="h-8 text-xs font-medium"
@click="filterType = 'all'"
>
All Routes ({{ DRIVER_ROUTES_DATA.length }})
</Button>
<Button
size="sm"
:variant="filterType === 'in_transit' ? 'default' : 'outline'"
class="h-8 text-xs font-medium"
@click="filterType = 'in_transit'"
>
In Transit (4)
</Button>
<Button
size="sm"
:variant="filterType === 'high_load' ? 'default' : 'outline'"
class="h-8 text-xs font-medium"
@click="filterType = 'high_load'"
>
High Load (>80%)
</Button>
<Button
size="sm"
:variant="filterType === 'near_complete' ? 'default' : 'outline'"
class="h-8 text-xs font-medium"
@click="filterType = 'near_complete'"
>
Near Completion (>70%)
</Button>
</div>
<div class="relative w-full sm:w-72">
<Search class="text-muted-foreground absolute top-2.5 left-2.5 h-3.5 w-3.5" />
<input
v-model="searchQuery"
type="text"
placeholder="Filter driver, route, or stop..."
class="border-border bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring h-8 w-full rounded-md border pr-3 pl-8 text-xs focus-visible:ring-2 focus-visible:outline-hidden"
/>
<button
aria-label="Clear search"
v-if="searchQuery"
type="button"
class="text-muted-foreground hover:text-foreground absolute top-2 right-2"
@click="searchQuery = ''"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
</div>
<!-- Empty State when filtered out -->
<div
v-if="filteredRoutes.length === 0"
class="border-border flex flex-col items-center justify-center rounded-xl border border-dashed py-12 text-center"
>
<Truck class="text-muted-foreground/50 mb-3 h-10 w-10" />
<h3 class="text-foreground text-sm font-semibold">No matching dispatch routes found</h3>
<p class="text-muted-foreground mt-1 max-w-sm text-xs">
Try resetting your zone selector or search query to see active drivers and vehicle assignments.
</p>
<Button variant="outline" size="sm" class="mt-4 h-8 text-xs" @click="resetFilters"> Reset Filters </Button>
</div>
<!-- Driver Routes Grid / Multi-Card Board (4 Driver Route Cards) -->
<div v-else class="grid grid-cols-1 gap-6 xl:grid-cols-2">
<Card
v-for="route in filteredRoutes"
:key="route.id"
class="border-border flex flex-col shadow-xs transition-[color,background-color,border-color,box-shadow] hover:shadow-sm"
>
<!-- Card Header: Route ID, Status, Driver and Vehicle snapshot -->
<CardHeader class="space-y-3 pb-3">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="space-y-0.5">
<div class="flex items-center gap-2">
<span class="text-primary font-mono text-xs font-semibold">{{ route.routeCode }}</span>
<CardTitle class="text-foreground text-sm font-bold sm:text-base">
{{ route.routeName }}
</CardTitle>
</div>
<p class="text-muted-foreground text-xs">{{ route.zone }}</p>
</div>
<div class="flex items-center gap-1.5">
<Badge
variant="outline"
class="border-success/20 bg-success/10 text-success h-5 px-2 text-xs font-medium"
>
In Transit · On Schedule
</Badge>
</div>
</div>
<Separator />
<!-- Driver Profile & Vehicle Metadata -->
<div class="bg-muted/40 flex flex-col gap-3 rounded-lg p-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-3">
<Avatar class="border-border h-9 w-9 border">
<AvatarFallback class="bg-primary/10 text-primary text-xs font-bold">
{{ route.driver.initials }}
</AvatarFallback>
</Avatar>
<div class="space-y-0.5">
<div class="flex items-center gap-1.5">
<span class="text-foreground text-xs font-semibold">{{ route.driver.name }}</span>
<span class="bg-success inline-flex h-1.5 w-1.5 rounded-full" title="Active" />
</div>
<div class="text-muted-foreground flex items-center gap-2 text-xs">
<span class="font-mono tabular-nums">{{ route.driver.phone }}</span>
<span>·</span>
<span>{{ route.driver.radioChannel }}</span>
</div>
</div>
</div>
<div
class="border-border flex items-center justify-between gap-x-2 border-t pt-2 sm:border-t-0 sm:pt-0 sm:text-right"
>
<div class="space-y-0.5">
<div class="flex items-center gap-1.5 sm:justify-end">
<Truck class="text-muted-foreground h-3.5 w-3.5" />
<span class="text-foreground text-xs font-medium">{{ route.vehicle.name }}</span>
</div>
<div class="text-muted-foreground flex items-center gap-1.5 text-xs sm:justify-end">
<span class="font-mono text-xs">{{ route.vehicle.plateNumber }}</span>
<span>·</span>
<span class="flex items-center gap-1 font-mono text-xs tabular-nums">
<BatteryCharging v-if="route.vehicle.powerType === 'electric'" class="text-success h-3 w-3" />
<Fuel v-else class="text-info h-3 w-3" />
{{ route.vehicle.powerLevel }}
</span>
</div>
</div>
</div>
</div>
</CardHeader>
<CardContent class="flex-1 space-y-4">
<!-- Route Progress & Payload Load Balance Overview -->
<div class="border-border bg-card grid grid-cols-1 gap-3 rounded-lg border p-3 sm:grid-cols-2">
<!-- Stops Progress -->
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-x-2 text-xs">
<span class="text-muted-foreground">Stops Completion</span>
<span class="text-foreground font-mono font-semibold tabular-nums">
{{ route.completedStops }} / {{ route.totalStops }}
<span class="text-muted-foreground font-normal"
>({{ Math.round((route.completedStops / route.totalStops) * 100) }}%)</span
>
</span>
</div>
<Progress :model-value="(route.completedStops / route.totalStops) * 100" class="h-1.5" />
<div
class="text-muted-foreground flex items-center justify-between gap-x-2 font-mono text-xs tabular-nums"
>
<span>Shift: {{ route.shiftWindow }}</span>
<span>ETA: {{ route.estimatedCompletion }}</span>
</div>
</div>
<!-- Payload Weight Utilization -->
<div class="space-y-1.5">
<div class="flex items-center justify-between gap-x-2 text-xs">
<span class="text-muted-foreground">Payload Load Balance</span>
<span class="text-foreground font-mono font-semibold tabular-nums">
{{ route.currentPayloadLbs.toLocaleString() }} / {{ route.maxPayloadLbs.toLocaleString() }} lbs
</span>
</div>
<div class="bg-muted relative h-1.5 w-full overflow-hidden rounded-full">
<div
class="h-full rounded-full transition-[width,background-color] duration-500"
:class="route.currentPayloadLbs / route.maxPayloadLbs > 0.85 ? 'bg-warning' : 'bg-primary'"
:style="`width: ${(route.currentPayloadLbs / route.maxPayloadLbs) * 100}%`"
/>
</div>
<div
class="text-muted-foreground flex items-center justify-between gap-x-2 font-mono text-xs tabular-nums"
>
<span>{{ Math.round((route.currentPayloadLbs / route.maxPayloadLbs) * 100) }}% capacity</span>
<span
:class="
route.currentPayloadLbs / route.maxPayloadLbs > 0.85
? 'text-warning font-medium'
: 'text-muted-foreground'
"
>
{{ route.maxPayloadLbs - route.currentPayloadLbs }} lbs available
</span>
</div>
</div>
</div>
<!-- Stops Timeline Section -->
<div class="space-y-2">
<div class="flex items-center justify-between gap-x-2">
<h4 class="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
Stop Sequence & Delivery Execution
</h4>
<span class="text-muted-foreground font-mono text-xs tabular-nums">
{{ route.stops.filter((s) => s.status === 'delivered').length }} done ·
{{ route.stops.filter((s) => s.status === 'in_progress').length }} active ·
{{ route.stops.filter((s) => s.status === 'pending').length }} pending
</span>
</div>
<!-- Timeline List -->
<div
class="before:bg-border relative space-y-3 pl-2 before:absolute before:top-3 before:bottom-3 before:left-5 before:w-px"
>
<div
v-for="stop in route.stops"
:key="stop.id"
class="hover:border-border hover:bg-muted/30 relative flex items-start gap-3 rounded-lg border border-transparent p-2 transition-colors"
:class="stop.status === 'in_progress' && 'border-primary/30 bg-primary/5 dark:bg-primary/10'"
>
<!-- Status Icon Node -->
<div class="relative z-10 flex shrink-0 items-center justify-center">
<!-- Delivered Check Icon -->
<div
v-if="stop.status === 'delivered'"
class="ring-background bg-success/15 text-success text-success flex h-6 w-6 items-center justify-center rounded-full ring-2"
>
<Check class="h-3.5 w-3.5 stroke-3" />
</div>
<!-- In Progress Pulsing Map Pin -->
<div
v-else-if="stop.status === 'in_progress'"
class="bg-primary text-primary-foreground ring-primary/20 relative flex h-6 w-6 items-center justify-center rounded-full shadow-xs ring-4"
>
<MapPin class="h-3.5 w-3.5 animate-bounce" />
</div>
<!-- Pending Clock / Circle Node -->
<div
v-else
class="border-border bg-muted text-muted-foreground ring-background flex h-6 w-6 items-center justify-center rounded-full border ring-2"
>
<Clock class="h-3 w-3" />
</div>
</div>
<!-- Stop Details -->
<div class="min-w-0 flex-1 space-y-0.5">
<div class="flex flex-wrap items-center justify-between gap-1">
<div class="flex items-center gap-1.5 truncate">
<span class="text-muted-foreground font-mono text-xs font-semibold">#{{ stop.sequence }}</span>
<span class="text-foreground truncate text-xs font-semibold">{{ stop.locationName }}</span>
</div>
<!-- Stop Status Timestamp / ETA -->
<div class="flex items-center gap-1">
<Badge
v-if="stop.status === 'delivered'"
variant="outline"
class="border-success/20 bg-success/10 text-success text-success h-4.5 px-1.5 font-mono text-xs font-medium tabular-nums"
>
Delivered {{ stop.deliveredTime }}
</Badge>
<Badge
v-else-if="stop.status === 'in_progress'"
class="bg-primary text-primary-foreground h-4.5 px-1.5 font-mono text-xs font-medium tabular-nums"
>
In Progress · ETA {{ stop.estimatedTime }}
</Badge>
<Badge
v-else
variant="secondary"
class="text-muted-foreground h-4.5 px-1.5 font-mono text-xs font-medium tabular-nums"
>
Pending
</Badge>
</div>
</div>
<p class="text-muted-foreground truncate text-xs">{{ stop.address }}</p>
<div class="text-muted-foreground flex flex-wrap items-center gap-2 pt-0.5 text-xs">
<span class="text-foreground font-mono font-medium tabular-nums">
{{ stop.packagesCount }} {{ stop.packageType }}
</span>
<span v-if="stop.recipient" class="text-muted-foreground">· {{ stop.recipient }}</span>
<span v-if="stop.notes" class="text-warning font-medium">· {{ stop.notes }}</span>
</div>
</div>
</div>
</div>
</div>
</CardContent>
<!-- Card Footer: Quick Action Buttons -->
<CardFooter class="border-border border-t pt-3">
<div class="flex w-full flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
class="h-8 gap-1.5 text-xs font-medium"
@click="openModal('reassign', route)"
>
<ArrowRightLeft class="h-3.5 w-3.5" />
<span>Reassign Stops</span>
</Button>
<Button
variant="outline"
size="sm"
class="h-8 gap-1.5 text-xs font-medium"
@click="openModal('call', route)"
>
<Phone class="h-3.5 w-3.5" />
<span>Call Driver</span>
</Button>
</div>
<Button
variant="default"
size="sm"
class="h-8 gap-1.5 text-xs font-medium shadow-xs"
@click="openModal('gps', route)"
>
<Navigation class="h-3.5 w-3.5" />
<span>View Live GPS</span>
</Button>
</div>
</CardFooter>
</Card>
</div>
<!-- Action Modals (GPS telemetry, direct call, reassign stops) -->
<DispatchActionModal
v-model="activeModal"
:all-routes="DRIVER_ROUTES_DATA"
@confirm-reassign="handleConfirmReassign"
@open-modal="openModal"
/>
</div>
</template>
<script setup lang="ts">
import { ArrowRightLeft, Check, MapPin, Navigation, Phone, PhoneCall, X } from 'lucide-vue-next'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import type { ActiveModalState, DriverRoute } from './dispatch-assignment-board-types'
const props = defineProps<{
modelValue: ActiveModalState | null
allRoutes: DriverRoute[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: ActiveModalState | null): void
(e: 'confirmReassign'): void
(e: 'openModal', type: 'gps' | 'call' | 'reassign', route: DriverRoute): void
}>()
function closeModal() {
emit('update:modelValue', null)
}
function handleConfirmReassign() {
emit('confirmReassign')
}
function setTargetRoute(targetId: string) {
if (props.modelValue) {
emit('update:modelValue', {
...props.modelValue,
targetRouteId: targetId,
})
}
}
</script>
<template>
<div
v-if="modelValue"
class="animate-in fade-in fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-xs duration-150"
@click.self="closeModal"
>
<!-- GPS Telemetry Modal -->
<div
v-if="modelValue.type === 'gps'"
class="border-border bg-card w-full max-w-lg space-y-4 rounded-xl border p-6 shadow-xl"
>
<div class="flex items-center justify-between gap-x-2">
<div class="flex items-center gap-2">
<div class="bg-primary/10 text-primary flex h-8 w-8 items-center justify-center rounded-md">
<Navigation class="h-4 w-4" />
</div>
<div>
<h3 class="text-foreground text-sm font-bold">Live Telemetry & GPS Tracking</h3>
<p class="text-muted-foreground text-xs">
{{ modelValue.route.routeCode }} · {{ modelValue.route.driver.name }}
</p>
</div>
</div>
<button
aria-label="Close modal"
type="button"
class="text-muted-foreground hover:text-foreground rounded-md p-1"
@click="closeModal"
>
<X class="h-4 w-4" />
</button>
</div>
<Separator />
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="border-border bg-muted/30 rounded-lg border p-2.5 text-center">
<span class="text-muted-foreground text-xs">Current Speed</span>
<p class="text-foreground font-mono text-sm font-bold">{{ modelValue.route.telemetry.speed }}</p>
</div>
<div class="border-border bg-muted/30 rounded-lg border p-2.5 text-center">
<span class="text-muted-foreground text-xs">Heading</span>
<p class="text-foreground font-mono text-sm font-bold">{{ modelValue.route.telemetry.heading }}</p>
</div>
<div class="border-border bg-muted/30 rounded-lg border p-2.5 text-center">
<span class="text-muted-foreground text-xs">Power / Fuel</span>
<p class="text-success font-mono text-sm font-bold">
{{ modelValue.route.vehicle.powerLevel }}
</p>
</div>
<div class="border-border bg-muted/30 rounded-lg border p-2.5 text-center">
<span class="text-muted-foreground text-xs">GPS Ping</span>
<p class="text-foreground font-mono text-sm font-bold">{{ modelValue.route.telemetry.lastPing }}</p>
</div>
</div>
<div class="border-border bg-muted/20 space-y-1.5 rounded-lg border p-3">
<div class="flex items-center justify-between gap-x-2 text-xs">
<span class="text-muted-foreground">Current Coordinates & Street</span>
<span class="text-success font-mono font-medium">Signal: {{ modelValue.route.telemetry.signal }}</span>
</div>
<div class="flex items-start gap-2">
<MapPin class="text-primary mt-0.5 h-4 w-4 shrink-0" />
<p class="text-foreground text-xs font-semibold">{{ modelValue.route.telemetry.currentLocation }}</p>
</div>
</div>
<!-- Next Stop Snapshot -->
<div class="border-primary/20 bg-primary/5 space-y-1 rounded-lg border p-3">
<span class="text-primary text-xs font-semibold tracking-wide uppercase">Next Stop In Sequence</span>
<p class="text-foreground text-xs font-bold">
{{ modelValue.route.stops.find((s) => s.status === 'in_progress')?.locationName || 'Hub Return' }}
</p>
<p class="text-muted-foreground text-xs">
{{ modelValue.route.stops.find((s) => s.status === 'in_progress')?.address }}
</p>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button aria-label="Close modal" variant="outline" size="sm" class="h-8 text-xs" @click="closeModal"
>Close</Button
>
<Button size="sm" class="h-8 text-xs font-medium" @click="$emit('openModal', 'call', modelValue.route)">
<Phone class="mr-1.5 h-3.5 w-3.5" /> Call Driver
</Button>
</div>
</div>
<!-- Call Driver Modal -->
<div
v-else-if="modelValue.type === 'call'"
class="border-border bg-card w-full max-w-md space-y-4 rounded-xl border p-6 shadow-xl"
>
<div class="flex items-center justify-between gap-x-2">
<div class="flex items-center gap-2">
<div class="bg-success/10 text-success flex h-8 w-8 items-center justify-center rounded-md">
<PhoneCall class="h-4 w-4" />
</div>
<div>
<h3 class="text-foreground text-sm font-bold">Dispatch Driver Communications</h3>
<p class="text-muted-foreground text-xs">{{ modelValue.route.routeCode }} Direct Link</p>
</div>
</div>
<button
aria-label="Close modal"
type="button"
class="text-muted-foreground hover:text-foreground rounded-md p-1"
@click="closeModal"
>
<X class="h-4 w-4" />
</button>
</div>
<Separator />
<div class="border-border bg-muted/40 flex items-center gap-3 rounded-lg border p-3">
<Avatar class="border-border h-11 w-11 border">
<AvatarFallback class="bg-primary/10 text-primary text-sm font-bold">
{{ modelValue.route.driver.initials }}
</AvatarFallback>
</Avatar>
<div class="space-y-0.5">
<h4 class="text-foreground text-sm font-semibold">{{ modelValue.route.driver.name }}</h4>
<p class="text-muted-foreground font-mono text-xs tabular-nums">{{ modelValue.route.driver.phone }}</p>
<p class="text-muted-foreground text-xs">{{ modelValue.route.driver.radioChannel }} · Hands-Free Active</p>
</div>
</div>
<div class="space-y-2 text-xs">
<span class="text-muted-foreground font-medium">Quick Broadcast Dispatch Presets:</span>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
class="border-border bg-background hover:border-primary/40 hover:bg-accent rounded-md border p-2 text-left transition-colors"
@click="handleConfirmReassign"
>
<span class="text-foreground block font-semibold">Traffic Alert</span>
<span class="text-muted-foreground text-xs">Reroute around I-10 slowdown</span>
</button>
<button
type="button"
class="border-border bg-background hover:border-primary/40 hover:bg-accent rounded-md border p-2 text-left transition-colors"
@click="handleConfirmReassign"
>
<span class="text-foreground block font-semibold">Priority Stop</span>
<span class="text-muted-foreground text-xs">Bump hospital drop to next</span>
</button>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button aria-label="Close modal" variant="outline" size="sm" class="h-8 text-xs" @click="closeModal"
>Cancel</Button
>
<Button
size="sm"
class="bg-success hover:bg-success/90 h-8 text-xs font-medium text-white"
@click="handleConfirmReassign"
>
<Phone class="mr-1.5 h-3.5 w-3.5" /> Start Direct Call
</Button>
</div>
</div>
<!-- Reassign Stops Modal -->
<div
v-else-if="modelValue.type === 'reassign'"
class="border-border bg-card w-full max-w-lg space-y-4 rounded-xl border p-6 shadow-xl"
>
<div class="flex items-center justify-between gap-x-2">
<div class="flex items-center gap-2">
<div class="bg-warning/10 text-warning flex h-8 w-8 items-center justify-center rounded-md">
<ArrowRightLeft class="h-4 w-4" />
</div>
<div>
<h3 class="text-foreground text-sm font-bold">Reassign Pending Stops</h3>
<p class="text-muted-foreground text-xs">
Transfer workload from {{ modelValue.route.routeCode }} ({{ modelValue.route.driver.name }})
</p>
</div>
</div>
<button
aria-label="Close modal"
type="button"
class="text-muted-foreground hover:text-foreground rounded-md p-1"
@click="closeModal"
>
<X class="h-4 w-4" />
</button>
</div>
<Separator />
<div class="space-y-3 text-xs">
<div>
<label class="text-foreground mb-1 block font-semibold">Pending Stop to Transfer:</label>
<div class="border-border bg-muted/40 rounded-lg border p-3">
<div class="flex items-center justify-between gap-x-2">
<span class="text-foreground font-semibold">
{{
modelValue.route.stops.find((s) => s.status === 'pending')?.locationName ||
'Stop #4 - Delivery Destination'
}}
</span>
<Badge variant="outline" class="text-xs">
{{ modelValue.route.stops.find((s) => s.status === 'pending')?.packagesCount || 2 }} packages
</Badge>
</div>
<p class="text-muted-foreground mt-0.5 text-xs">
{{ modelValue.route.stops.find((s) => s.status === 'pending')?.address }}
</p>
</div>
</div>
<div>
<label class="text-foreground mb-1 block font-semibold">Target Available Route / Driver:</label>
<div class="space-y-2">
<div
v-for="target in allRoutes.filter((r) => r.id !== modelValue?.route.id)"
:key="target.id"
class="flex cursor-pointer items-center justify-between rounded-lg border p-2.5 transition-colors"
:class="
modelValue.targetRouteId === target.id
? 'border-primary bg-primary/5'
: 'border-border hover:bg-muted/30'
"
@click="setTargetRoute(target.id)"
>
<div class="space-y-0.5">
<div class="flex items-center gap-1.5">
<span class="text-foreground font-semibold">{{ target.routeCode }} · {{ target.routeName }}</span>
<span class="text-muted-foreground text-xs">({{ target.driver.name }})</span>
</div>
<p class="text-muted-foreground text-xs">
Current Load: {{ target.currentPayloadLbs }} lbs ({{
Math.round((target.currentPayloadLbs / target.maxPayloadLbs) * 100)
}}%) · {{ target.totalStops - target.completedStops }} stops left
</p>
</div>
<div
class="border-border flex h-5 w-5 items-center justify-center rounded-full border"
:class="modelValue.targetRouteId === target.id && 'border-primary bg-primary text-primary-foreground'"
>
<Check v-if="modelValue.targetRouteId === target.id" class="h-3 w-3 stroke-3" />
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button aria-label="Close modal" variant="outline" size="sm" class="h-8 text-xs" @click="closeModal"
>Cancel</Button
>
<Button size="sm" class="h-8 text-xs font-medium" @click="handleConfirmReassign">
<Check class="mr-1.5 h-3.5 w-3.5" /> Confirm Stop Reassignment
</Button>
</div>
</div>
</div>
</template>
export type StopStatus = 'delivered' | 'in_progress' | 'pending'
export interface RouteStop {
id: string
sequence: number
locationName: string
address: string
status: StopStatus
deliveredTime?: string
estimatedTime?: string
packagesCount: number
packageType: string
notes?: string
recipient?: string
}
export interface DriverRoute {
id: string
routeCode: string
routeName: string
zone: string
driver: {
name: string
avatar?: string
initials: string
phone: string
status: 'on_route' | 'break' | 'loading'
radioChannel: string
}
vehicle: {
name: string
type: string
plateNumber: string
powerLevel: string
powerType: 'electric' | 'diesel'
}
completedStops: number
totalStops: number
currentPayloadLbs: number
maxPayloadLbs: number
shiftWindow: string
estimatedCompletion: string
telemetry: {
speed: string
heading: string
currentLocation: string
lastPing: string
signal: string
}
stops: RouteStop[]
}
export interface ActiveModalState {
type: 'gps' | 'call' | 'reassign'
route: DriverRoute
targetRouteId?: string
}
import type { DriverRoute } from './dispatch-assignment-board-types'
export const DRIVER_ROUTES_DATA: DriverRoute[] = [
{
id: 'route-01',
routeCode: 'Route #01',
routeName: 'Downtown Metro',
zone: 'Metro Los Angeles - North Zone',
driver: {
name: 'Elena Rostova',
initials: 'ER',
phone: '+1 (213) 555-0182',
status: 'on_route',
radioChannel: 'Ch 4 · North Dispatch',
},
vehicle: {
name: 'Ford Transit #204',
type: 'EV Cargo Van',
plateNumber: 'CA-8KDX92',
powerLevel: '74% Battery',
powerType: 'electric',
},
completedStops: 18,
totalStops: 24,
currentPayloadLbs: 1850,
maxPayloadLbs: 2200,
shiftWindow: '07:30 AM – 16:30 PM',
estimatedCompletion: '15:45 PM',
telemetry: {
speed: '32 mph',
heading: 'NW 315°',
currentLocation: 'Grand Ave & 5th St, Los Angeles, CA 90071',
lastPing: '12s ago',
signal: '5G Active',
},
stops: [
{
id: 'r1-s1',
sequence: 1,
locationName: 'Acme Industrial Hub',
address: '1240 S Santa Fe Ave',
status: 'delivered',
deliveredTime: '09:15 AM',
packagesCount: 12,
packageType: 'Cartons',
recipient: 'Signed by J. Miller',
},
{
id: 'r1-s2',
sequence: 2,
locationName: 'Apex Tech Campus',
address: '800 Wilshire Blvd, Ste 400',
status: 'delivered',
deliveredTime: '10:45 AM',
packagesCount: 8,
packageType: 'Parcels',
recipient: 'Dock 4B Security',
},
{
id: 'r1-s3',
sequence: 3,
locationName: 'Metro Distribution Center',
address: '1950 E 7th St',
status: 'in_progress',
estimatedTime: '14:15 PM',
packagesCount: 15,
packageType: 'Heavy Boxes',
notes: 'Priority Rush · Forklift offload requested',
},
{
id: 'r1-s4',
sequence: 4,
locationName: 'Pasadena Hospital Supply',
address: '650 S Raymond Ave',
status: 'pending',
packagesCount: 2,
packageType: 'Medical Coolers',
notes: 'Cold-Chain · Temperature Log Required',
},
],
},
{
id: 'route-02',
routeCode: 'Route #02',
routeName: 'Westside & Santa Monica',
zone: 'Metro Los Angeles - North Zone',
driver: {
name: 'Marcus Vance',
initials: 'MV',
phone: '+1 (310) 555-0194',
status: 'on_route',
radioChannel: 'Ch 2 · West Dispatch',
},
vehicle: {
name: 'Mercedes Sprinter #118',
type: 'High-Roof Van',
plateNumber: 'CA-9TMR44',
powerLevel: '68% Fuel',
powerType: 'diesel',
},
completedStops: 22,
totalStops: 26,
currentPayloadLbs: 2450,
maxPayloadLbs: 3000,
shiftWindow: '08:00 AM – 17:00 PM',
estimatedCompletion: '16:15 PM',
telemetry: {
speed: '28 mph',
heading: 'W 270°',
currentLocation: 'Ocean Ave & Colorado Ave, Santa Monica, CA',
lastPing: '8s ago',
signal: '5G Active',
},
stops: [
{
id: 'r2-s1',
sequence: 1,
locationName: 'Pacific Design Center',
address: '8687 Melrose Ave',
status: 'delivered',
deliveredTime: '09:40 AM',
packagesCount: 6,
packageType: 'Crates',
recipient: 'Direct Receipt · Bay 2',
},
{
id: 'r2-s2',
sequence: 2,
locationName: 'Oceanview Medical Plaza',
address: '1301 20th St, Floor 3',
status: 'delivered',
deliveredTime: '11:20 AM',
packagesCount: 14,
packageType: 'Packages',
recipient: 'Receptionist Amy K.',
},
{
id: 'r2-s3',
sequence: 3,
locationName: 'Santa Monica Pier Logistics',
address: '200 Santa Monica Pier',
status: 'in_progress',
estimatedTime: '14:40 PM',
packagesCount: 4,
packageType: 'Cartons',
notes: 'Dock Bay 2 · Access Gate Code #4491',
},
{
id: 'r2-s4',
sequence: 4,
locationName: 'Silicon Beach Incubator',
address: '12020 Ocean Park Blvd',
status: 'pending',
packagesCount: 5,
packageType: 'Parcels',
notes: 'Signature Required · 5th Floor Suite 500',
},
],
},
{
id: 'route-03',
routeCode: 'Route #03',
routeName: 'Pasadena & Glendale',
zone: 'San Fernando Valley Hub',
driver: {
name: 'Sofia Chen',
initials: 'SC',
phone: '+1 (626) 555-0147',
status: 'on_route',
radioChannel: 'Ch 5 · Valley Dispatch',
},
vehicle: {
name: 'Ram ProMaster #309',
type: 'Electric Courier',
plateNumber: 'CA-7KPL55',
powerLevel: '82% Battery',
powerType: 'electric',
},
completedStops: 14,
totalStops: 20,
currentPayloadLbs: 1620,
maxPayloadLbs: 2100,
shiftWindow: '08:15 AM – 16:45 PM',
estimatedCompletion: '16:30 PM',
telemetry: {
speed: '38 mph',
heading: 'E 090°',
currentLocation: 'Colorado Blvd & Lake Ave, Pasadena, CA',
lastPing: '18s ago',
signal: '5G Active',
},
stops: [
{
id: 'r3-s1',
sequence: 1,
locationName: 'Glendale Aerospace Labs',
address: '4300 San Fernando Rd',
status: 'delivered',
deliveredTime: '10:05 AM',
packagesCount: 3,
packageType: 'Heavy Units',
recipient: 'Forklift Bay Delivery',
},
{
id: 'r3-s2',
sequence: 2,
locationName: 'Foothill Commercial Park',
address: '2210 Foothill Blvd',
status: 'delivered',
deliveredTime: '11:50 AM',
packagesCount: 9,
packageType: 'Boxes',
recipient: 'Suite 200 Mailroom',
},
{
id: 'r3-s3',
sequence: 3,
locationName: 'Huntington Research Institute',
address: '1151 Oxford Rd',
status: 'in_progress',
estimatedTime: '14:25 PM',
packagesCount: 6,
packageType: 'Lab Samples',
notes: 'Fragile · Bio-Sample Insulated Crate',
},
{
id: 'r3-s4',
sequence: 4,
locationName: 'Old Town Commerce Plaza',
address: '300 E Colorado Blvd',
status: 'pending',
packagesCount: 4,
packageType: 'Packages',
notes: 'Front Desk Drop · Notify Concierge',
},
],
},
{
id: 'route-04',
routeCode: 'Route #04',
routeName: 'South Bay Logistics',
zone: 'Metro Los Angeles - South Zone',
driver: {
name: 'Tariq Mansour',
initials: 'TM',
phone: '+1 (424) 555-0133',
status: 'on_route',
radioChannel: 'Ch 3 · South Dispatch',
},
vehicle: {
name: 'Freightliner Custom #412',
type: 'Box Truck',
plateNumber: 'CA-5WXZ91',
powerLevel: '88% Fuel',
powerType: 'diesel',
},
completedStops: 19,
totalStops: 22,
currentPayloadLbs: 3100,
maxPayloadLbs: 3800,
shiftWindow: '07:00 AM – 16:00 PM',
estimatedCompletion: '15:20 PM',
telemetry: {
speed: '45 mph',
heading: 'S 180°',
currentLocation: 'I-405 Southbound at Carson St, CA',
lastPing: '5s ago',
signal: '5G Active',
},
stops: [
{
id: 'r4-s1',
sequence: 1,
locationName: 'Port of LA Cargo Terminal',
address: '425 S Palos Verdes St',
status: 'delivered',
deliveredTime: '08:30 AM',
packagesCount: 18,
packageType: 'Pallets',
recipient: 'Customs Manifest #882',
},
{
id: 'r4-s2',
sequence: 2,
locationName: 'Torrance Industrial Park',
address: '190th St & Western Ave',
status: 'delivered',
deliveredTime: '10:15 AM',
packagesCount: 11,
packageType: 'Crates',
recipient: 'Security Gate 5',
},
{
id: 'r4-s3',
sequence: 3,
locationName: 'Long Beach Tech Hub',
address: '100 W Ocean Blvd',
status: 'in_progress',
estimatedTime: '14:50 PM',
packagesCount: 8,
packageType: 'Hardware Cases',
notes: 'Server Equipment · Handle with Care',
},
{
id: 'r4-s4',
sequence: 4,
locationName: 'Manhattan Beach Retail Depot',
address: '3200 Sepulveda Blvd',
status: 'pending',
packagesCount: 6,
packageType: 'Store Parcels',
notes: 'Store Receiving · Rear Loading Dock',
},
],
},
]
Raw manifest:https://uipkge.dev/r/vue/dispatch-assignment-board.json