Dispatch Assignment Board
blocklogisticsLast-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.
Installation
$pnpm dlx uipkge-ng@latest add dispatch-assignment-board$npx uipkge-ng@latest add dispatch-assignment-board$yarn dlx uipkge-ng@latest add dispatch-assignment-board$bunx uipkge-ng@latest add dispatch-assignment-boardManifest:
https://uipkge.dev/r/angular/dispatch-assignment-board.jsonInstalls to:src/app/components/blocks/dispatch-assignment-board/Variants
Loading interactive previews…
Loading interactive previews…
Schema
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
}Files installed (2)
src/app/components/blocks/dispatch-assignment-board/dispatch-assignment-board.component.ts57.6 kB
// 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. Port of the React block 1:1. import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core' import { ArrowRightLeft, BatteryCharging, Check, CheckCircle2, Clock, Download, Fuel, Gauge, LucideAngularModule, MapPin, Navigation, PackageCheck, Phone, PhoneCall, Search, ShieldCheck, Truck, X, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent, UiCardFooterComponent, UiCardHeaderComponent, UiCardTitleComponent, } from '@/ui/card/card.component' import { UiProgressComponent } from '@/ui/progress/progress.component' import { UiSelectComponent, UiSelectContentComponent, UiSelectGroupComponent, UiSelectItemComponent, UiSelectLabelComponent, UiSelectTriggerComponent, UiSelectValueComponent, } from '@/ui/select/select.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' 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 } 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', }, ], }, ] const ZONE_OPTIONS = [ 'Metro Los Angeles - North Zone', 'Metro Los Angeles - South Zone', 'San Fernando Valley Hub', 'Orange County Central', ] export type DispatchFilter = 'all' | 'in_transit' | 'high_load' | 'near_complete' @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-dispatch-assignment-board, [ui-dispatch-assignment-board]', standalone: true, // React renders the root <div> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiAvatarComponent, UiAvatarFallbackComponent, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiCardFooterComponent, UiCardHeaderComponent, UiCardTitleComponent, UiProgressComponent, UiSelectComponent, UiSelectContentComponent, UiSelectGroupComponent, UiSelectItemComponent, UiSelectLabelComponent, UiSelectTriggerComponent, UiSelectValueComponent, UiSeparatorComponent, ], template: ` <div data-slot="dispatch-assignment-board" [class]="rootClass"> <!-- 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> <span class="bg-success relative inline-flex h-2 w-2 rounded-full"></span> </span> <span>Live Dispatch Operations</span> </div> <div class="text-muted-foreground flex items-center gap-1 text-xs"> <lucide-icon [img]="Clock" class="size-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> <div class="flex flex-wrap items-center gap-2.5"> <div class="w-full sm:w-64"> <ui-select [value]="selectedZone()" (valueChange)="selectedZone.set($event)"> <button ui-select-trigger class="h-9 w-full text-xs font-medium" aria-label="Select Dispatch Zone"> <ui-select-value placeholder="Select Zone" /> </button> <ui-select-content> <ui-select-group> <ui-select-label class="text-xs">Dispatch Zones</ui-select-label> <ui-select-item value="all" class="text-xs">All Dispatch Zones</ui-select-item> @for (zone of zoneOptions; track zone) { <ui-select-item [value]="zone" class="text-xs">{{ zone }}</ui-select-item> } </ui-select-group> </ui-select-content> </ui-select> </div> <button ui-button aria-label="Download attachment" variant="outline" size="sm" class="h-9 gap-1.5 text-xs font-medium" (click)="handleExportManifests()" > <lucide-icon [img]="Download" class="size-3.5" /> <span>Export Manifests</span> </button> <button ui-button size="sm" class="h-9 gap-1.5 text-xs font-medium shadow-xs" [disabled]="isOptimizing()" (click)="handleAutoOptimize()" > <lucide-icon [img]="Navigation" [class]="optimizeIconClass" /> <span>{{ isOptimizing() ? 'Optimizing Routes...' : 'Auto-Optimize Routes' }}</span> </button> </div> </div> <!-- Notification Banner --> @if (notificationBanner(); as banner) { <div [class]="bannerClass(banner.type)"> <div class="flex items-start gap-2.5"> @if (banner.type === 'success') { <lucide-icon [img]="CheckCircle2" class="text-success mt-0.5 size-4 shrink-0" /> } @else { <lucide-icon [img]="PackageCheck" class="text-primary mt-0.5 size-4 shrink-0" /> } <div> <span class="font-semibold">{{ banner.title }}:</span> <span class="text-muted-foreground dark:text-foreground/80 ml-1">{{ banner.message }}</span> </div> </div> <button aria-label="Dismiss notification" type="button" class="text-muted-foreground hover:text-foreground transition-colors" (click)="notificationBanner.set(null)" > <lucide-icon [img]="X" class="size-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"> <div ui-card class="border-border shadow-xs"> <div ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2"> <h3 ui-card-title class="text-muted-foreground text-xs font-medium">Total Routes Active</h3> <div class="bg-primary/10 text-primary flex h-8 w-8 items-center justify-center rounded-md"> <lucide-icon [img]="Truck" class="size-4" /> </div> </div> <div ui-card-content 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"> <span ui-badge variant="outline" class="border-success/20 bg-success/10 text-success h-5 px-1.5 text-xs font-medium" > 6 In Transit </span> <span>2 loading at hub</span> </div> </div> </div> <div ui-card class="border-border shadow-xs"> <div ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2"> <h3 ui-card-title class="text-muted-foreground text-xs font-medium">Total Stops Remaining</h3> <div class="bg-info/10 text-info flex h-8 w-8 items-center justify-center rounded-md"> <lucide-icon [img]="PackageCheck" class="size-4" /> </div> </div> <div ui-card-content 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 font-mono text-xs font-semibold tabular-nums">78.8%</span> </div> <div ui-progress [value]="78.8" class="h-1.5"></div> <p class="text-muted-foreground text-xs"> <span class="text-foreground font-mono font-semibold tabular-nums">38</span> stops pending completion </p> </div> </div> <div ui-card class="border-border shadow-xs"> <div ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2"> <h3 ui-card-title class="text-muted-foreground text-xs font-medium">On-Time Delivery Rate</h3> <div class="bg-success/10 text-success flex h-8 w-8 items-center justify-center rounded-md"> <lucide-icon [img]="ShieldCheck" class="size-4" /> </div> </div> <div ui-card-content class="space-y-1.5"> <div class="flex items-baseline gap-2"> <span class="text-success font-mono text-2xl font-bold tracking-tight tabular-nums">97.4%</span> <span ui-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 </span> </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> </div> </div> <div ui-card class="border-border shadow-xs"> <div ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2"> <h3 ui-card-title class="text-muted-foreground text-xs font-medium">Total Cargo Weight Dispatched</h3> <div class="bg-warning/10 text-warning flex h-8 w-8 items-center justify-center rounded-md"> <lucide-icon [img]="Gauge" class="size-4" /> </div> </div> <div ui-card-content 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> <span ui-badge variant="outline" class="border-warning/20 bg-warning/10 text-warning h-5 px-1.5 text-xs font-medium" > 82% capacity </span> </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> </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> </div> </div> </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 ui-button size="sm" [variant]="filterType() === 'all' ? 'default' : 'outline'" class="h-8 text-xs font-medium" (click)="filterType.set('all')" > All Routes ({{ routes().length }}) </button> <button ui-button size="sm" [variant]="filterType() === 'in_transit' ? 'default' : 'outline'" class="h-8 text-xs font-medium" (click)="filterType.set('in_transit')" > In Transit (4) </button> <button ui-button size="sm" [variant]="filterType() === 'high_load' ? 'default' : 'outline'" class="h-8 text-xs font-medium" (click)="filterType.set('high_load')" > High Load (>80%) </button> <button ui-button size="sm" [variant]="filterType() === 'near_complete' ? 'default' : 'outline'" class="h-8 text-xs font-medium" (click)="filterType.set('near_complete')" > Near Completion (>70%) </button> </div> <div class="relative w-full sm:w-72"> <lucide-icon [img]="Search" class="text-muted-foreground pointer-events-none absolute top-2.5 left-2.5 size-3.5" /> <input [value]="searchQuery()" (input)="searchQuery.set($any($event.target).value)" 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" /> @if (searchQuery()) { <button type="button" class="text-muted-foreground hover:text-foreground absolute top-2 right-2" aria-label="Clear search" (click)="searchQuery.set('')" > <lucide-icon [img]="X" class="size-3.5" /> </button> } </div> </div> <!-- Empty State when filtered out --> @if (filteredRoutes().length === 0) { <div class="border-border flex flex-col items-center justify-center rounded-xl border border-dashed py-12 text-center" > <lucide-icon [img]="Truck" class="text-muted-foreground/50 mb-3 size-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 ui-button variant="outline" size="sm" class="mt-4 h-8 text-xs" (click)="resetFilters()"> Reset Filters </button> </div> } @else { <div class="grid grid-cols-1 gap-6 xl:grid-cols-2"> @for (route of filteredRoutes(); track route.id) { <div ui-card class="border-border flex flex-col shadow-xs transition-[color,background-color,border-color,box-shadow] hover:shadow-sm" > <div ui-card-header 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> <h3 ui-card-title class="text-foreground text-sm font-bold sm:text-base"> {{ route.routeName }} </h3> </div> <p class="text-muted-foreground text-xs">{{ route.zone }}</p> </div> <div class="flex items-center gap-1.5"> <span ui-badge variant="outline" class="border-success/20 bg-success/10 text-success h-5 px-2 text-xs font-medium" > In Transit · On Schedule </span> </div> </div> <div ui-separator></div> <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"> <ui-avatar class="border-border h-9 w-9 border"> <ui-avatar-fallback class="bg-primary/10 text-primary text-xs font-bold"> {{ route.driver.initials }} </ui-avatar-fallback> </ui-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"></span> </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"> <lucide-icon [img]="Truck" class="text-muted-foreground size-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"> @if (route.vehicle.powerType === 'electric') { <lucide-icon [img]="BatteryCharging" class="text-success size-3" /> } @else { <lucide-icon [img]="Fuel" class="text-info size-3" /> } {{ route.vehicle.powerLevel }} </span> </div> </div> </div> </div> </div> <div ui-card-content class="flex-1 space-y-4"> <div class="border-border bg-card grid grid-cols-1 gap-3 rounded-lg border p-3 sm:grid-cols-2"> <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"> ({{ stopPct(route) }}%) </span> </span> </div> <div ui-progress [value]="stopPct(route)" class="h-1.5"></div> <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> <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]="payloadBarClass(route)" [style.width]="payloadPct(route) + '%'"></div> </div> <div class="text-muted-foreground flex items-center justify-between gap-x-2 font-mono text-xs tabular-nums" > <span>{{ payloadPct(route) }}% capacity</span> <span [class]="payloadAvailClass(route)"> {{ route.maxPayloadLbs - route.currentPayloadLbs }} lbs available </span> </div> </div> </div> <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"> {{ countBy(route, 'delivered') }} done · {{ countBy(route, 'in_progress') }} active · {{ countBy(route, 'pending') }} pending </span> </div> <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" > @for (stop of route.stops; track stop.id) { <div [class]="stopRowClass(stop.status)"> <div class="relative z-10 flex shrink-0 items-center justify-center"> @if (stop.status === 'delivered') { <div class="ring-background bg-success/15 text-success flex h-6 w-6 items-center justify-center rounded-full ring-2" > <lucide-icon [img]="Check" class="size-3.5 stroke-[3]" /> </div> } @else if (stop.status === 'in_progress') { <div 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" > <lucide-icon [img]="MapPin" class="size-3.5 animate-bounce" /> </div> } @else { <div class="border-border bg-muted text-muted-foreground ring-background flex h-6 w-6 items-center justify-center rounded-full border ring-2" > <lucide-icon [img]="Clock" class="size-3" /> </div> } </div> <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> <div class="flex items-center gap-1"> @if (stop.status === 'delivered') { <span ui-badge variant="outline" class="border-success/20 bg-success/10 text-success h-4.5 px-1.5 font-mono text-xs font-medium tabular-nums" > Delivered {{ stop.deliveredTime }} </span> } @else if (stop.status === 'in_progress') { <span ui-badge variant="default" class="h-4.5 px-1.5 font-mono text-xs font-medium tabular-nums" > In Progress · ETA {{ stop.estimatedTime }} </span> } @else { <span ui-badge variant="secondary" class="text-muted-foreground h-4.5 px-1.5 font-mono text-xs font-medium tabular-nums" > Pending </span> } </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> @if (stop.recipient) { <span class="text-muted-foreground">· {{ stop.recipient }}</span> } @if (stop.notes) { <span class="text-warning font-medium">· {{ stop.notes }}</span> } </div> </div> </div> } </div> </div> </div> <div ui-card-footer 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 ui-button variant="outline" size="sm" class="h-8 gap-1.5 text-xs font-medium" (click)="openModal('reassign', route)" > <lucide-icon [img]="ArrowRightLeft" class="size-3.5" /> <span>Reassign Stops</span> </button> <button ui-button variant="outline" size="sm" class="h-8 gap-1.5 text-xs font-medium" (click)="openModal('call', route)" > <lucide-icon [img]="Phone" class="size-3.5" /> <span>Call Driver</span> </button> </div> <button ui-button variant="default" size="sm" class="h-8 gap-1.5 text-xs font-medium shadow-xs" (click)="openModal('gps', route)" > <lucide-icon [img]="Navigation" class="size-3.5" /> <span>View Live GPS</span> </button> </div> </div> </div> } </div> } <!-- Action Modals (GPS telemetry, direct call, reassign stops) --> @if (activeModal(); as modal) { <div 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)="onOverlayClick($event)" > @if (modal.type === 'gps') { <div 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"> <lucide-icon [img]="Navigation" class="size-4" /> </div> <div> <h3 class="text-foreground text-sm font-bold">Live Telemetry & GPS Tracking</h3> <p class="text-muted-foreground text-xs"> {{ modal.route.routeCode }} · {{ modal.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()" > <lucide-icon [img]="X" class="size-4" /> </button> </div> <div ui-separator></div> <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">{{ modal.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">{{ modal.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">{{ modal.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">{{ modal.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: {{ modal.route.telemetry.signal }}</span> </div> <div class="flex items-start gap-2"> <lucide-icon [img]="MapPin" class="text-primary mt-0.5 size-4 shrink-0" /> <p class="text-foreground text-xs font-semibold">{{ modal.route.telemetry.currentLocation }}</p> </div> </div> <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">{{ nextStopName(modal.route) }}</p> <p class="text-muted-foreground text-xs">{{ nextStopAddress(modal.route) }}</p> </div> <div class="flex justify-end gap-2 pt-2"> <button ui-button aria-label="Close modal" variant="outline" size="sm" class="h-8 text-xs" (click)="closeModal()" > Close </button> <button ui-button size="sm" class="h-8 text-xs font-medium" (click)="openModal('call', modal.route)"> <lucide-icon [img]="Phone" class="mr-1.5 size-3.5" /> Call Driver </button> </div> </div> } @if (modal.type === 'call') { <div 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"> <lucide-icon [img]="PhoneCall" class="size-4" /> </div> <div> <h3 class="text-foreground text-sm font-bold">Dispatch Driver Communications</h3> <p class="text-muted-foreground text-xs">{{ modal.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()" > <lucide-icon [img]="X" class="size-4" /> </button> </div> <div ui-separator></div> <div class="border-border bg-muted/40 flex items-center gap-3 rounded-lg border p-3"> <ui-avatar class="border-border h-11 w-11 border"> <ui-avatar-fallback class="bg-primary/10 text-primary text-sm font-bold"> {{ modal.route.driver.initials }} </ui-avatar-fallback> </ui-avatar> <div class="space-y-0.5"> <h4 class="text-foreground text-sm font-semibold">{{ modal.route.driver.name }}</h4> <p class="text-muted-foreground font-mono text-xs tabular-nums">{{ modal.route.driver.phone }}</p> <p class="text-muted-foreground text-xs">{{ modal.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 ui-button aria-label="Close modal" variant="outline" size="sm" class="h-8 text-xs" (click)="closeModal()" > Cancel </button> <button ui-button size="sm" class="bg-success hover:bg-success/90 h-8 text-xs font-medium text-white" (click)="handleConfirmReassign()" > <lucide-icon [img]="Phone" class="mr-1.5 size-3.5" /> Start Direct Call </button> </div> </div> } @if (modal.type === 'reassign') { <div 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"> <lucide-icon [img]="ArrowRightLeft" class="size-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 {{ modal.route.routeCode }} ({{ modal.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()" > <lucide-icon [img]="X" class="size-4" /> </button> </div> <div ui-separator></div> <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">{{ pendingStopName(modal.route) }}</span> <span ui-badge variant="outline" class="text-xs" >{{ pendingStopPackages(modal.route) }} packages</span > </div> <p class="text-muted-foreground mt-0.5 text-xs">{{ pendingStopAddress(modal.route) }}</p> </div> </div> <div> <label class="text-foreground mb-1 block font-semibold">Target Available Route / Driver:</label> <div class="space-y-2"> @for (target of reassignTargets(modal.route.id); track target.id) { <div [class]="targetRowClass(modal.targetRouteId === target.id)" (click)="selectTargetRoute(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 ({{ payloadPct(target) }}%) · {{ target.totalStops - target.completedStops }} stops left </p> </div> <div [class]="targetRadioClass(modal.targetRouteId === target.id)"> @if (modal.targetRouteId === target.id) { <lucide-icon [img]="Check" class="size-3 stroke-3" /> } </div> </div> } </div> </div> </div> <div class="flex justify-end gap-2 pt-2"> <button ui-button aria-label="Close modal" variant="outline" size="sm" class="h-8 text-xs" (click)="closeModal()" > Cancel </button> <button ui-button size="sm" class="h-8 text-xs font-medium" (click)="handleConfirmReassign()"> <lucide-icon [img]="Check" class="mr-1.5 size-3.5" /> Confirm Stop Reassignment </button> </div> </div> } </div> } </div> `, }) export class UiDispatchAssignmentBoardComponent { protected readonly ArrowRightLeft = ArrowRightLeft protected readonly BatteryCharging = BatteryCharging protected readonly Check = Check protected readonly CheckCircle2 = CheckCircle2 protected readonly Clock = Clock protected readonly Download = Download protected readonly Fuel = Fuel protected readonly Gauge = Gauge protected readonly MapPin = MapPin protected readonly Navigation = Navigation protected readonly PackageCheck = PackageCheck protected readonly Phone = Phone protected readonly PhoneCall = PhoneCall protected readonly Search = Search protected readonly ShieldCheck = ShieldCheck protected readonly Truck = Truck protected readonly X = X protected readonly zoneOptions = ZONE_OPTIONS @Input() set initialZone(v: string | undefined) { if (v !== undefined) this.selectedZone.set(v) } @Input() set initialFilter(v: DispatchFilter | undefined) { if (v !== undefined) this.filterType.set(v) } @Input('class') className?: string readonly routes = signal<DriverRoute[]>(DRIVER_ROUTES_DATA) readonly selectedZone = signal('Metro Los Angeles - North Zone') readonly filterType = signal<DispatchFilter>('all') readonly searchQuery = signal('') readonly isOptimizing = signal(false) readonly notificationBanner = signal<{ title: string; message: string; type: 'success' | 'info' } | null>(null) readonly activeModal = signal<ActiveModalState | null>(null) private optimizeTimer: ReturnType<typeof setTimeout> | undefined constructor() { inject(DestroyRef).onDestroy(() => clearTimeout(this.optimizeTimer)) } readonly filteredRoutes = computed(() => { const zone = this.selectedZone() const filter = this.filterType() const q = this.searchQuery().trim().toLowerCase() return this.routes().filter((route) => { if (zone !== 'all' && route.zone !== zone) return false if (filter === 'in_transit' && route.driver.status !== 'on_route') return false if (filter === 'high_load' && route.currentPayloadLbs / route.maxPayloadLbs < 0.8) return false if (filter === 'near_complete' && route.completedStops / route.totalStops < 0.75) return false if (q) { 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 }) }) get rootClass(): string { return cn('text-foreground w-full space-y-6', this.className) } get optimizeIconClass(): string { return cn('size-3.5', this.isOptimizing() && 'text-primary-foreground animate-spin') } bannerClass(type: 'success' | 'info'): string { return cn( 'animate-in fade-in-50 flex items-start justify-between rounded-lg border p-3.5 text-xs transition-colors duration-200', type === 'success' ? 'border-success/30 bg-success/10 text-success' : 'border-primary/30 bg-primary/10 text-foreground', ) } stopPct(route: DriverRoute): number { return Math.round((route.completedStops / route.totalStops) * 100) } payloadPct(route: DriverRoute): number { return Math.round((route.currentPayloadLbs / route.maxPayloadLbs) * 100) } payloadBarClass(route: DriverRoute): string { return cn( 'h-full rounded-full transition-[width,background-color] duration-500', route.currentPayloadLbs / route.maxPayloadLbs > 0.85 ? 'bg-warning' : 'bg-primary', ) } payloadAvailClass(route: DriverRoute): string { return route.currentPayloadLbs / route.maxPayloadLbs > 0.85 ? 'text-warning font-medium' : 'text-muted-foreground' } countBy(route: DriverRoute, status: StopStatus): number { return route.stops.filter((s) => s.status === status).length } stopRowClass(status: StopStatus): string { return cn( 'hover:border-border hover:bg-muted/30 relative flex items-start gap-3 rounded-lg border border-transparent p-2 transition-colors', status === 'in_progress' && 'border-primary/30 bg-primary/5 dark:bg-primary/10', ) } nextStopName(route: DriverRoute): string { return route.stops.find((s) => s.status === 'in_progress')?.locationName || 'Hub Return' } nextStopAddress(route: DriverRoute): string { return route.stops.find((s) => s.status === 'in_progress')?.address || '' } pendingStopName(route: DriverRoute): string { return route.stops.find((s) => s.status === 'pending')?.locationName || 'Stop #4 - Delivery Destination' } pendingStopPackages(route: DriverRoute): number { return route.stops.find((s) => s.status === 'pending')?.packagesCount || 2 } pendingStopAddress(route: DriverRoute): string { return route.stops.find((s) => s.status === 'pending')?.address || '' } reassignTargets(excludeId: string): DriverRoute[] { return this.routes().filter((r) => r.id !== excludeId) } targetRowClass(selected: boolean): string { return cn( 'flex cursor-pointer items-center justify-between rounded-lg border p-2.5 transition-colors', selected ? 'border-primary bg-primary/5' : 'border-border hover:bg-muted/30', ) } targetRadioClass(selected: boolean): string { return cn( 'border-border flex h-5 w-5 items-center justify-center rounded-full border', selected && 'border-primary bg-primary text-primary-foreground', ) } handleAutoOptimize(): void { this.isOptimizing.set(true) this.notificationBanner.set(null) clearTimeout(this.optimizeTimer) this.optimizeTimer = setTimeout(() => { this.isOptimizing.set(false) this.notificationBanner.set({ 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) } handleExportManifests(): void { this.notificationBanner.set({ title: 'Dispatch Manifests Exported', message: 'Generated PDF loading manifests and CSV stop sequences for 4 routes (180 total stops).', type: 'info', }) } openModal(type: 'gps' | 'call' | 'reassign', route: DriverRoute): void { this.activeModal.set({ type, route, targetRouteId: this.routes().find((r) => r.id !== route.id)?.id, }) } closeModal(): void { this.activeModal.set(null) } onOverlayClick(event: MouseEvent): void { if (event.target === event.currentTarget) this.closeModal() } selectTargetRoute(targetId: string): void { const modal = this.activeModal() if (modal) this.activeModal.set({ ...modal, targetRouteId: targetId }) } handleConfirmReassign(): void { const modal = this.activeModal() if (modal) { this.notificationBanner.set({ title: 'Stops Successfully Reassigned', message: `Pending stop from ${modal.route.routeCode} transferred. Driver GPS routes and manifest synced.`, type: 'success', }) this.closeModal() } } resetFilters(): void { this.selectedZone.set('all') this.searchQuery.set('') this.filterType.set('all') } }src/app/components/blocks/dispatch-assignment-board/index.ts0.2 kB
export { UiDispatchAssignmentBoardComponent, DRIVER_ROUTES_DATA, type ActiveModalState, type DispatchFilter, type DriverRoute, type RouteStop, type StopStatus, } from './dispatch-assignment-board.component'
Raw manifest:https://uipkge.dev/r/angular/dispatch-assignment-board.json