Menu

Change language

GitHub repo

Last Mile Courier Dispatch

blockdashboard

Last-mile courier dispatch matrix with real-time route progress, ETA telemetry, and Proof of Delivery (POD) logging.

Installation

$npx uipkge-ng@latest add last-mile-courier-dispatch
Manifest:https://uipkge.dev/r/angular/last-mile-courier-dispatch.jsonInstalls to:src/app/components/blocks/last-mile-courier-dispatch/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

DeliveryStop
interface DeliveryStop {
  id: string
  stopNumber: number
  trackingCode: string
  recipient: string
  address: string
  parcels: number
  timeWindow: string
  eta: string
  status: 'delivered' | 'en_route' | 'scheduled' | 'failed'
  podType?: 'signature' | 'photo_scanned' | 'front_door'
  podTimestamp?: string
  coords?: [number, number]
}

Files installed (2)

  • src/app/components/blocks/last-mile-courier-dispatch/last-mile-courier-dispatch.component.ts18.8 kB
    // Last-mile courier dispatch. Route telemetry KPIs, a live GPS map with sequenced drop pins
    // and the courier van, and a delivery manifest whose "Confirm POD" logs proof of delivery
    // and moves the next scheduled stop en route.
    import {
      type AfterViewInit,
      Component,
      type ElementRef,
      Input,
      type OnDestroy,
      type QueryList,
      ViewChild,
      ViewChildren,
      computed,
      signal,
      ChangeDetectionStrategy,
    } from '@angular/core'
    import type * as mapboxgl from 'mapbox-gl'
    import { BatteryCharging, LucideAngularModule, Navigation, Phone, Radio, Truck } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent, type BadgeVariant } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardFooterComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiMapComponent, type MapLayerInput, type MapSourceInput } from '@/ui/map/map.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    
    export interface DeliveryStop {
      id: string
      stopNumber: number
      trackingCode: string
      recipient: string
      address: string
      parcels: number
      timeWindow: string
      eta: string
      status: 'delivered' | 'en_route' | 'scheduled' | 'failed'
      podType?: 'signature' | 'photo_scanned' | 'front_door'
      podTimestamp?: string
      coords?: [number, number]
    }
    
    export interface LastMileDispatchProps {
      routeId?: string
      driverName?: string
      vehicleCode?: string
      className?: string
      accessToken?: string
    }
    
    export const DEFAULT_DELIVERY_STOPS: DeliveryStop[] = [
      {
        id: 'stp-1',
        stopNumber: 1,
        trackingCode: 'TRK-9941-8812',
        recipient: 'Apex Logistics HQ',
        address: '742 Evergreen Terrace, Sector 4',
        parcels: 3,
        timeWindow: '08:30 - 09:30',
        eta: '08:52 AM',
        status: 'delivered',
        podType: 'signature',
        podTimestamp: '08:54 AM (Signed by J. Doe)',
        coords: [-73.991, 40.743],
      },
      {
        id: 'stp-2',
        stopNumber: 2,
        trackingCode: 'TRK-9941-8813',
        recipient: 'David K. Miller',
        address: '1204 Pinehurst Ave, Apt 3B',
        parcels: 1,
        timeWindow: '09:30 - 10:30',
        eta: '09:45 AM',
        status: 'delivered',
        podType: 'photo_scanned',
        podTimestamp: '09:48 AM (Doorstep Verified)',
        coords: [-73.987, 40.747],
      },
      {
        id: 'stp-3',
        stopNumber: 3,
        trackingCode: 'TRK-9941-8814',
        recipient: 'Global Tech Labs',
        address: '450 Innovation Parkway, Suite 100',
        parcels: 4,
        timeWindow: '10:00 - 11:30',
        eta: '10:25 AM',
        status: 'en_route',
        coords: [-73.982, 40.751],
      },
      {
        id: 'stp-4',
        stopNumber: 4,
        trackingCode: 'TRK-9941-8815',
        recipient: 'Sarah Jenkins',
        address: '88 Riverfront Blvd',
        parcels: 2,
        timeWindow: '11:30 - 12:30',
        eta: '11:45 AM',
        status: 'scheduled',
        coords: [-73.978, 40.755],
      },
      {
        id: 'stp-5',
        stopNumber: 5,
        trackingCode: 'TRK-9941-8816',
        recipient: 'Metro Health Clinic',
        address: '310 Health Sciences Way',
        parcels: 1,
        timeWindow: '12:30 - 14:00',
        eta: '01:10 PM',
        status: 'scheduled',
        coords: [-73.971, 40.758],
      },
    ]
    
    /** The live courier van position (the React block's static van MapMarker). */
    export const COURIER_VAN_LNG_LAT: [number, number] = [-73.984, 40.749]
    
    const courierRoutePaint = {
      'line-color': '#0284c7',
      'line-width': 3,
      'line-dasharray': [2, 2],
    }
    
    /**
     * Angular port of the React LastMileCourierDispatch block: route KPIs, a live GPS corridor
     * map and a delivery manifest with Proof of Delivery logging.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-last-mile-courier-dispatch, [ui-last-mile-courier-dispatch]',
      standalone: true,
      host: {
        '[attr.data-slot]': '"last-mile-courier-dispatch"',
        '[class]': 'hostClass',
      },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardFooterComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiMapComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      template: `
        <!-- Header / Dispatch Telemetry Overview -->
        <div ui-card>
          <div ui-card-header class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <div class="space-y-1">
              <div class="flex items-center gap-2">
                <span class="text-foreground font-mono text-sm font-semibold">{{ routeId }}</span>
                <span ui-badge variant="outline" class="font-mono text-xs">
                  {{ vehicleCode }}
                </span>
                <span ui-badge variant="secondary" class="gap-1 text-xs">
                  <lucide-icon [img]="Radio" class="text-foreground size-3" />
                  Live Telemetry Active
                </span>
              </div>
              <h3 ui-card-title class="text-xl">Last-Mile Courier Dispatch &amp; POD Telemetry</h3>
              <p ui-card-description>
                Courier: <span class="text-foreground font-medium">{{ driverName }}</span> · Urban Route Dispatch Matrix
              </p>
            </div>
    
            <div class="flex items-center gap-2">
              <button ui-button variant="outline" size="sm" class="gap-1.5">
                <lucide-icon [img]="Phone" class="size-3.5" />
                Contact Driver
              </button>
              <button ui-button variant="default" size="sm" class="gap-1.5">
                <lucide-icon [img]="Navigation" class="size-3.5" />
                Live GPS Map
              </button>
            </div>
          </div>
    
          <div ui-card-content class="border-border grid grid-cols-1 gap-4 border-t pt-4 sm:grid-cols-4">
            <!-- Progress Metric -->
            <div class="border-border space-y-1 rounded-lg border p-3">
              <div class="text-muted-foreground text-xs font-medium">ROUTE COMPLETION</div>
              <div class="flex items-baseline justify-between pt-1">
                <div class="font-mono text-xl font-semibold">{{ completedCount() }} / {{ totalStops() }}</div>
                <span class="text-muted-foreground font-mono text-xs">{{ completionPercent() }}%</span>
              </div>
            </div>
    
            <!-- On-time SLA -->
            <div class="border-border space-y-1 rounded-lg border p-3">
              <div class="text-muted-foreground text-xs font-medium">ON-TIME WINDOW SLA</div>
              <div class="pt-1 font-mono text-xl font-semibold">{{ onTimeRate }}</div>
            </div>
    
            <!-- Vehicle Telemetry -->
            <div class="border-border space-y-1 rounded-lg border p-3">
              <div class="text-muted-foreground flex items-center gap-1 text-xs font-medium">
                <lucide-icon [img]="BatteryCharging" class="size-3.5" />
                <span>EV BATTERY / RANGE</span>
              </div>
              <div class="pt-1 font-mono text-xl font-semibold">74% · 142 km</div>
            </div>
    
            <!-- Next Stop ETA -->
            <div class="border-border space-y-1 rounded-lg border p-3">
              <div class="text-muted-foreground text-xs font-medium">CURRENT TARGET ETA</div>
              <div class="pt-1 font-mono text-xl font-semibold">10:25 AM (Stop #3)</div>
            </div>
          </div>
        </div>
    
        <!-- Live GPS Route Map -->
        <div ui-card class="overflow-hidden">
          <div ui-card-header class="border-border/70 border-b pb-3">
            <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
              <div>
                <h3 ui-card-title class="flex items-center gap-2 text-base font-semibold">
                  <lucide-icon [img]="Navigation" class="text-info size-4" />
                  <span>Live GPS Telemetry &amp; Corridor Map</span>
                </h3>
                <p ui-card-description>
                  Real-time courier positioning with sequenced drop waypoints and completion status.
                </p>
              </div>
              <div class="flex items-center gap-2">
                <span class="bg-success inline-flex size-2 animate-ping rounded-full"></span>
                <span class="text-muted-foreground font-mono text-xs">GPS Synced · 3s ago</span>
              </div>
            </div>
          </div>
          <div class="relative h-72 w-full sm:h-80">
            <div
              ui-map
              variant="dark"
              [accessToken]="accessToken ?? ''"
              [center]="[-73.982, 40.75]"
              [zoom]="13.8"
              class="size-full"
              [sources]="routeSources()"
              [layers]="routeLayers"
              (created)="onMapCreated($event)"
            >
              <!-- Marker elements: handed to mapbox-gl Markers once the map exists, which moves each
                   inner #stopMarker / #vanMarker node onto the map; the outer node stays for @for. -->
              <div class="hidden">
                <!-- Delivery Stop Markers -->
                @for (stop of stops(); track stop.id) {
                  <div>
                    <div #stopMarker>
                      <div class="flex flex-col items-center">
                        <div [class]="stopMarkerClass(stop)">
                          {{ stop.stopNumber }}
                        </div>
                        <span
                          class="border-border/80 bg-background/90 py-0.2 mt-0.5 rounded border px-1 font-mono text-xs font-medium shadow-xs backdrop-blur-xs"
                        >
                          {{ firstWord(stop.recipient) }}
                        </span>
                      </div>
                    </div>
                  </div>
                }
    
                <!-- Live Courier Van Marker -->
                <div>
                  <div #vanMarker class="z-30">
                    <div class="relative flex size-9 items-center justify-center">
                      <span class="bg-info absolute inline-flex size-full animate-ping rounded-full opacity-60"></span>
                      <div
                        class="bg-info relative flex size-8 items-center justify-center rounded-full border-2 border-white text-white shadow-lg"
                      >
                        <lucide-icon [img]="Truck" class="size-4" />
                      </div>
                    </div>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
    
        <!-- Dispatch Sequence Table -->
        <div ui-card>
          <div ui-card-header class="pb-3">
            <h3 ui-card-title class="text-base font-semibold">Delivery Manifest &amp; Proof of Delivery (POD)</h3>
            <p ui-card-description>
              Ordered drop sequence with recipient signature capture and delivery window timestamps.
            </p>
          </div>
    
          <div ui-card-content class="p-0">
            <div ui-table>
              <thead ui-table-header>
                <tr ui-table-row>
                  <th ui-table-head class="w-[60px]">Stop</th>
                  <th ui-table-head class="w-[180px]">Tracking ID</th>
                  <th ui-table-head class="w-[260px]">Recipient &amp; Address</th>
                  <th ui-table-head>Window / ETA</th>
                  <th ui-table-head class="text-right">Parcels</th>
                  <th ui-table-head>Status &amp; Proof of Delivery</th>
                  <th ui-table-head class="w-[100px] text-right">Action</th>
                </tr>
              </thead>
              <tbody ui-table-body>
                @for (stop of stops(); track stop.id) {
                  <tr ui-table-row [class]="stop.status === 'en_route' ? 'bg-muted/40 font-medium' : ''">
                    <!-- Stop Number -->
                    <td ui-table-cell class="text-muted-foreground font-mono text-xs">#0{{ stop.stopNumber }}</td>
    
                    <!-- Tracking Code -->
                    <td ui-table-cell>
                      <div class="text-foreground font-mono text-xs font-semibold">{{ stop.trackingCode }}</div>
                    </td>
    
                    <!-- Recipient & Address -->
                    <td ui-table-cell>
                      <div class="text-foreground font-medium">{{ stop.recipient }}</div>
                      <div class="text-muted-foreground truncate text-xs">{{ stop.address }}</div>
                    </td>
    
                    <!-- Time Window & ETA -->
                    <td ui-table-cell>
                      <div class="text-foreground text-xs">{{ stop.timeWindow }}</div>
                      <div class="text-muted-foreground font-mono text-xs">ETA: {{ stop.eta }}</div>
                    </td>
    
                    <!-- Parcels Count -->
                    <td ui-table-cell class="text-right font-mono text-sm">{{ stop.parcels }} pkg</td>
    
                    <!-- Status & Proof of Delivery -->
                    <td ui-table-cell>
                      <div class="flex flex-col gap-1">
                        <div class="flex items-center gap-2">
                          <span ui-badge [variant]="statusVariant(stop.status)" class="capitalize">
                            {{ statusLabel(stop.status) }}
                          </span>
                        </div>
                        @if (stop.podTimestamp) {
                          <span class="text-muted-foreground font-mono text-xs">{{ stop.podTimestamp }}</span>
                        }
                      </div>
                    </td>
    
                    <!-- Action -->
                    <td ui-table-cell class="text-right">
                      @if (stop.status === 'en_route') {
                        <button ui-button size="sm" variant="default" class="h-7 text-xs" (click)="markDelivered(stop.id)">
                          Confirm POD
                        </button>
                      }
                      @if (stop.status === 'delivered') {
                        <span class="text-muted-foreground text-xs">Logged</span>
                      }
                      @if (stop.status === 'scheduled') {
                        <span class="text-muted-foreground text-xs">Queued</span>
                      }
                    </td>
                  </tr>
                }
              </tbody>
            </div>
          </div>
    
          <div
            ui-card-footer
            class="border-border text-muted-foreground flex items-center justify-between border-t py-3 text-xs"
          >
            <div>Geotagged proof of delivery records are synced automatically to customer tracking portals.</div>
            <div class="text-foreground font-medium">
              Route summary: <span class="font-mono">{{ totalStops() }}</span> total consignments
            </div>
          </div>
        </div>
      `,
    })
    export class UiLastMileCourierDispatchComponent implements AfterViewInit, OnDestroy {
      @Input() routeId = 'RTE-METRO-409'
      @Input() driverName = 'Elena Rostova'
      @Input() vehicleCode = 'VAN-EV-09 (Ford E-Transit)'
      @Input() accessToken?: string
      @Input('class') className?: string
    
      @ViewChildren('stopMarker') private stopMarkerEls?: QueryList<ElementRef<HTMLDivElement>>
      @ViewChild('vanMarker') private vanMarkerEl?: ElementRef<HTMLDivElement>
    
      protected readonly BatteryCharging = BatteryCharging
      protected readonly Navigation = Navigation
      protected readonly Phone = Phone
      protected readonly Radio = Radio
      protected readonly Truck = Truck
    
      readonly stops = signal<DeliveryStop[]>(DEFAULT_DELIVERY_STOPS.map((s) => ({ ...s })))
    
      readonly completedCount = computed(() => this.stops().filter((s) => s.status === 'delivered').length)
      readonly totalStops = computed(() => this.stops().length)
      readonly completionPercent = computed(() => Math.round((this.completedCount() / this.totalStops()) * 100))
      readonly onTimeRate = '98.4%'
    
      /** The route polyline (the React block's MapSource) through every stop, in drop order. */
      readonly routeSources = computed<MapSourceInput[]>(() => [
        {
          id: 'courier-route-source',
          type: 'geojson',
          data: {
            type: 'Feature',
            properties: {},
            geometry: {
              type: 'LineString',
              coordinates: this.stops()
                .map((s) => s.coords)
                .filter((c): c is [number, number] => Boolean(c)),
            },
          },
        },
      ])
    
      /** The dashed route line (the React block's MapLayer inside the MapSource). */
      readonly routeLayers: MapLayerInput[] = [
        { id: 'courier-route-line', type: 'line', source: 'courier-route-source', paint: courierRoutePaint },
      ]
    
      private map: mapboxgl.Map | null = null
      private markers: mapboxgl.Marker[] = []
      private destroyed = false
    
      get hostClass(): string {
        return cn('block w-full space-y-6', this.className)
      }
    
      ngAfterViewInit(): void {
        // A new stops list re-renders the marker elements; put the new set on the map.
        this.stopMarkerEls?.changes.subscribe(() => void this.syncMarkers())
      }
    
      ngOnDestroy(): void {
        this.destroyed = true
        this.clearMarkers()
        this.map = null
      }
    
      stopMarkerClass(stop: DeliveryStop): string {
        return cn(
          'border-background flex size-6 items-center justify-center rounded-full border-2 font-mono text-xs font-bold shadow-md',
          stop.status === 'delivered'
            ? 'bg-success text-white'
            : stop.status === 'en_route'
              ? 'bg-warning text-white ring-2 ring-amber-400/50'
              : 'bg-muted text-muted-foreground',
        )
      }
    
      statusVariant(status: DeliveryStop['status']): BadgeVariant {
        return status === 'delivered' ? 'default' : status === 'en_route' ? 'secondary' : 'outline'
      }
    
      statusLabel(status: DeliveryStop['status']): string {
        return status.replace('_', ' ')
      }
    
      firstWord(text: string): string {
        return text.split(' ')[0] ?? ''
      }
    
      /** Log POD for the stop and move the next scheduled stop after it en route. */
      markDelivered(id: string): void {
        this.stops.update((prev) => {
          let foundTarget = false
          return prev.map((stop) => {
            if (stop.id === id) {
              foundTarget = true
              return {
                ...stop,
                status: 'delivered' as const,
                podType: 'signature' as const,
                podTimestamp: 'Just now (Signed)',
              }
            }
            if (foundTarget && stop.status === 'scheduled') {
              foundTarget = false
              return { ...stop, status: 'en_route' as const }
            }
            return stop
          })
        })
      }
    
      onMapCreated(map: mapboxgl.Map): void {
        this.map = map
        void this.syncMarkers()
      }
    
      /** One mapbox-gl Marker per stop plus the van, anchored at their centre (React MapMarker anchor="center"). */
      private async syncMarkers(): Promise<void> {
        const map = this.map
        if (!map) return
        const stopEls = this.stopMarkerEls?.toArray() ?? []
        const vanEl = this.vanMarkerEl?.nativeElement
        const mod = await import('mapbox-gl')
        const mapbox = mod.default ?? mod
        if (this.destroyed || map !== this.map) return
        this.clearMarkers()
        const stops = this.stops()
        this.markers = stopEls.flatMap((el, i) => {
          const coords = stops[i]?.coords
          if (!coords) return []
          return [new mapbox.Marker({ element: el.nativeElement, anchor: 'center' }).setLngLat(coords).addTo(map)]
        })
        if (vanEl) {
          this.markers.push(
            new mapbox.Marker({ element: vanEl, anchor: 'center' }).setLngLat(COURIER_VAN_LNG_LAT).addTo(map),
          )
        }
      }
    
      private clearMarkers(): void {
        for (const m of this.markers) m.remove()
        this.markers = []
      }
    }
    
  • src/app/components/blocks/last-mile-courier-dispatch/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/last-mile-courier-dispatch.json