Logistics

Dispatch Board

A driver and vehicle assignment board with live status pills and a pulsing indicator for en-route rows.

Install

npx shadcn@latest add @paragon/dispatch-board

Also installs: number-ticker, tooltip, button

dispatch-board.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Truck, User, Package, Inbox } from "lucide-react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";

export type DispatchStatus = "idle" | "loading" | "enroute" | "delivered";

export interface DispatchRow {
  /** Stable identity for layout animation across reorders. */
  id: string;
  driver: string;
  vehicle: string;
  status: DispatchStatus;
  /** Assigned load / stop count. */
  stops: number;
  /** Optional destination or note. */
  detail?: string;
}

export interface DispatchBoardProps
  extends Omit<React.ComponentProps<"div">, "onSelect"> {
  rows: DispatchRow[];
  /** Notifies when a row is activated (click / Enter / Space). */
  onSelect?: (row: DispatchRow) => void;
  /** Suppresses enter + layout motion. */
  static?: boolean;
}

const STATUS: Record<
  DispatchStatus,
  { label: string; dot: string; text: string; help: string }
> = {
  idle: {
    label: "Idle",
    dot: "bg-muted-foreground",
    text: "text-muted-foreground",
    help: "Available — no active assignment",
  },
  loading: {
    label: "Loading",
    dot: "bg-warning",
    text: "text-warning",
    help: "At dock, loading freight",
  },
  enroute: {
    label: "En route",
    dot: "bg-primary",
    text: "text-foreground",
    help: "On the road toward the next stop",
  },
  delivered: {
    label: "Delivered",
    dot: "bg-success",
    text: "text-success",
    help: "All stops completed for this run",
  },
};

/**
 * A driver/vehicle assignment board. Rows carry a stable `id` so that when the
 * caller reorders or reassigns them, motion/react `layout` animates the rows to
 * their new positions and AnimatePresence handles add/remove with the house
 * enter/exit language. Each status pill has a pulsing dot for the active
 * "en route" state and a tooltip explaining it. Deterministic — order and
 * statuses come from props.
 */
export function DispatchBoard({
  rows,
  onSelect,
  static: isStatic = false,
  className,
  ...props
}: DispatchBoardProps) {
  const reducedMotion = useReducedMotion();
  const animate = !isStatic && !reducedMotion;
  const enroute = rows.filter((r) => r.status === "enroute").length;

  return (
    <TooltipProvider>
      <div
        data-slot="dispatch-board"
        className={cn(
          "w-full max-w-md overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
          className,
        )}
        {...props}
      >
        <style href="paragon-dispatch-board" precedence="paragon">{`
          @keyframes dispatch-pulse {
            0%, 100% { opacity: 1; }
            50% { opacity: 0.35; }
          }
          @media (prefers-reduced-motion: reduce) {
            [data-dispatch-pulse] { animation: none !important; }
          }
        `}</style>
        <div className="flex items-center justify-between border-b border-border px-4 py-3">
          <p className="text-sm font-medium">Dispatch board</p>
          <span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">
            <NumberTicker
              value={enroute}
              static={!animate}
              className="text-foreground"
            />{" "}
            en route
          </span>
        </div>
        {rows.length === 0 ? (
          <div className="flex flex-col items-center justify-center gap-2 px-4 py-12 text-center">
            <span className="flex size-9 items-center justify-center rounded-full bg-secondary text-muted-foreground">
              <Inbox aria-hidden className="size-4" />
            </span>
            <p className="text-sm font-medium">No drivers assigned</p>
            <p className="max-w-[30ch] text-xs text-pretty text-muted-foreground">
              Assign a driver to a route to see it appear on the board.
            </p>
          </div>
        ) : (
        <motion.ul layout={animate} className="divide-y divide-border">
          <AnimatePresence initial={false}>
            {rows.map((row) => {
              const s = STATUS[row.status];
              const interactive = Boolean(onSelect);
              return (
                <motion.li
                  key={row.id}
                  layout={animate ? "position" : false}
                  initial={
                    animate
                      ? { opacity: 0, y: 12, filter: "blur(4px)" }
                      : false
                  }
                  animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                  exit={
                    animate
                      ? { opacity: 0, y: -12, filter: "blur(4px)" }
                      : { opacity: 0 }
                  }
                  transition={{
                    duration: 0.25,
                    ease: [0.22, 1, 0.36, 1],
                    layout: { duration: 0.3, ease: [0.65, 0, 0.35, 1] },
                  }}
                  className={cn(
                    "flex items-center gap-3 px-4 py-3 outline-none",
                    interactive &&
                      "cursor-pointer transition-[background-color] duration-150 ease-out hover:bg-accent focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring active:not-disabled:scale-[0.99]",
                  )}
                  style={interactive ? { transformOrigin: "center" } : undefined}
                  role={interactive ? "button" : undefined}
                  tabIndex={interactive ? 0 : undefined}
                  onClick={interactive ? () => onSelect?.(row) : undefined}
                  onKeyDown={
                    interactive
                      ? (e) => {
                          if (e.key === "Enter" || e.key === " ") {
                            e.preventDefault();
                            onSelect?.(row);
                          }
                        }
                      : undefined
                  }
                >
                  <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary text-muted-foreground">
                    <User aria-hidden className="size-4" />
                  </span>
                  <div className="min-w-0 flex-1">
                    <p className="truncate text-sm font-medium">{row.driver}</p>
                    <p className="flex items-center gap-1.5 truncate text-xs text-muted-foreground">
                      <Truck aria-hidden className="size-3.5 shrink-0" />
                      <span className="tabular-nums">{row.vehicle}</span>
                      {row.detail && (
                        <>
                          <span aria-hidden>·</span>
                          <span className="truncate">{row.detail}</span>
                        </>
                      )}
                    </p>
                  </div>
                  <Tooltip>
                    <TooltipTrigger asChild>
                      <span className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-muted-foreground">
                        <Package aria-hidden className="size-3.5" />
                        {row.stops}
                      </span>
                    </TooltipTrigger>
                    <TooltipContent>
                      {row.stops} {row.stops === 1 ? "stop" : "stops"} assigned
                    </TooltipContent>
                  </Tooltip>
                  <Tooltip>
                    <TooltipTrigger asChild>
                      <span
                        className={cn(
                          "flex shrink-0 items-center gap-1.5 rounded-full bg-secondary px-2 py-1 text-[11px] font-medium",
                          s.text,
                        )}
                      >
                        <span
                          aria-hidden
                          data-dispatch-pulse={
                            row.status === "enroute" ? "" : undefined
                          }
                          className={cn("size-1.5 rounded-full", s.dot)}
                          style={
                            row.status === "enroute"
                              ? {
                                  animation:
                                    "dispatch-pulse 1.6s ease-in-out infinite",
                                }
                              : undefined
                          }
                        />
                        {s.label}
                      </span>
                    </TooltipTrigger>
                    <TooltipContent>{s.help}</TooltipContent>
                  </Tooltip>
                </motion.li>
              );
            })}
          </AnimatePresence>
        </motion.ul>
        )}
      </div>
    </TooltipProvider>
  );
}