A driver and vehicle assignment board with live status pills and a pulsing indicator for en-route rows.
npx shadcn@latest add @paragon/dispatch-boardAlso installs: number-ticker, tooltip, button
"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>
);
}