{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fleet-1",
  "title": "Fleet 1: vehicle fleet",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A fleet overview: each vehicle with where it is, status in words, fuel or charge, distance to the next service, and faults, filter by status, message the driver, book a service, all undone with the reason if it fails.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/fleet-1/fleet-1.tsx",
      "content": "// Ballmac UI: Fleet 1. https://ui.ballmac.com/blocks/fleet-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { CircleAlert, Fuel, Loader2, MapPin, MessageSquare, Wrench } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Fleet1Status = \"moving\" | \"idle\" | \"parked\" | \"service\"\n\ntype Fleet1Vehicle = {\n  id: string\n  name: string\n  plate: string\n  driver?: string\n  status: Fleet1Status\n  /** Where it is, in words: \"A4 near Reading, heading east\". */\n  where: string\n  /** Current speed, for a moving vehicle. */\n  speedKmh?: number\n  /** Fuel or charge, 0 to 100. */\n  level: number\n  /** Distance until the next service. */\n  serviceInKm: number\n  /** Faults or warnings, in plain words. */\n  alerts?: string[]\n}\n\ntype Fleet1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    vehicles?: Fleet1Vehicle[]\n    title?: string\n    /** Below this percentage the level is called low. */\n    lowLevel?: number\n    /** Service due within this many kilometres is called out. */\n    serviceSoonKm?: number\n    /** Called to send the driver a message. Reject and the message says it was not sent. */\n    onMessage?: (vehicle: Fleet1Vehicle, text: string) => void | Promise<void>\n    /** Called to book a service. */\n    onBookService?: (vehicle: Fleet1Vehicle) => void | Promise<void>\n  }\n\nconst sample: Fleet1Vehicle[] = [\n  { id: \"v1\", name: \"Van 12\", plate: \"KX71 OPD\", driver: \"Marcus Bell\", status: \"moving\", where: \"A4 near Reading, heading east\", speedKmh: 86, level: 62, serviceInKm: 4100 },\n  { id: \"v2\", name: \"Van 07\", plate: \"KX70 FRM\", driver: \"Aisha Rahman\", status: \"moving\", where: \"Dock Road, Bristol\", speedKmh: 24, level: 11, serviceInKm: 380, alerts: [\"Tyre pressure low on the rear left\"] },\n  { id: \"v3\", name: \"Truck 03\", plate: \"LM21 TTB\", driver: \"Piotr Nowak\", status: \"idle\", where: \"Kiln Street depot, engine running\", level: 48, serviceInKm: 9200 },\n  { id: \"v4\", name: \"Van 02\", plate: \"KX69 AAC\", status: \"parked\", where: \"Yard, bay 4\", level: 90, serviceInKm: 1200 },\n  { id: \"v5\", name: \"Van 09\", plate: \"KX71 HJL\", status: \"service\", where: \"Ridge Garage, booked until Friday\", level: 35, serviceInKm: 0, alerts: [\"Brake pads being replaced\"] },\n  { id: \"v6\", name: \"Truck 01\", plate: \"LM19 CCD\", driver: \"Mei Tanaka\", status: \"moving\", where: \"M5 junction 15, heading north\", speedKmh: 92, level: 27, serviceInKm: 640 },\n]\n\nconst STATUS: Record<Fleet1Status, string> = { moving: \"Moving\", idle: \"Idle\", parked: \"Parked\", service: \"In service\" }\nconst num = (n: number) => n.toLocaleString(\"en-US\")\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-3 sm:grid-cols-2\">\n      {[0, 1, 2, 3].map((i) => (\n        <Skeleton key={i} className=\"h-40 rounded-xl\" />\n      ))}\n    </div>\n  )\n}\n\n/**\n * A fleet at a glance: each vehicle with its state in words (moving, idle, parked, in service), where it is, who is driving, its fuel or charge and the distance\n * to its next service. Anything that needs attention (a fault, a low level, a service due soon) is written out and sorted first. Choose a vehicle to message\n * the driver or book a service.\n */\nfunction Fleet1({ vehicles = sample, title = \"Fleet\", lowLevel = 15, serviceSoonKm = 1000, onMessage, onBookService, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Fleet1Props) {\n  const id = React.useId()\n  const [filter, setFilter] = React.useState<Fleet1Status | \"all\">(\"all\")\n  const [sel, setSel] = React.useState<string | null>(null)\n  const [text, setText] = React.useState(\"\")\n  const [busy, setBusy] = React.useState(false)\n  const [note, setNote] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const view = state === \"ready\" && vehicles.length === 0 ? \"empty\" : state\n  const issues = (v: Fleet1Vehicle) => [...(v.alerts ?? []), ...(v.level < lowLevel ? [`Low ${\"level\"}: ${v.level}%`] : []), ...(v.status !== \"service\" && v.serviceInKm <= serviceSoonKm ? [`Service due in ${num(v.serviceInKm)} km`] : [])]\n  const shown = vehicles.filter((v) => filter === \"all\" || v.status === filter).sort((a, b) => issues(b).length - issues(a).length)\n  const count = (s: Fleet1Status | \"all\") => (s === \"all\" ? vehicles.length : vehicles.filter((v) => v.status === s).length)\n  const chosen = vehicles.find((v) => v.id === sel)\n  const attention = vehicles.filter((v) => issues(v).length > 0).length\n\n  async function run(work: () => void | Promise<void>, ok: string, after?: () => void) {\n    setBusy(true)\n    setNote(null)\n    try {\n      await work()\n      after?.()\n      setNote({ ok: true, text: ok })\n    } catch (e) {\n      setNote({ ok: false, text: e instanceof Error ? e.message : \"That did not go through.\" })\n    } finally {\n      setBusy(false)\n    }\n  }\n\n  return (\n    <section data-slot=\"fleet-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-5xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p aria-live=\"polite\" className=\"text-muted-foreground mt-2 max-w-xl text-pretty\">{attention ? <><strong className=\"text-foreground\">{attention} {attention === 1 ? \"vehicle needs\" : \"vehicles need\"} attention.</strong> They are listed first.</> : \"Nothing needs attention.\"}</p>\n      </header>\n      <StateView state={view} onRetry={onRetry} label=\"Loading the fleet\" loading={<Skel />} empty={{ title: \"No vehicles yet\", description: \"Add a vehicle and it will appear here with where it is and how it is doing.\", ...emptyState }} error={{ title: \"The fleet could not be loaded\", description: \"Nothing was sent or changed. Check your connection and try again.\", ...errorState }}>\n        <div role=\"group\" aria-label=\"Filter by status\" className=\"mb-4 flex flex-wrap gap-2\">\n          {([\"all\", \"moving\", \"idle\", \"parked\", \"service\"] as const).map((k) => (\n            <button key={k} type=\"button\" aria-pressed={filter === k} onClick={() => setFilter(k)} className={cn(\"focus-visible:ring-ring/50 rounded-full border px-3.5 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\", filter === k ? \"bg-foreground text-background border-foreground\" : \"hover:bg-accent\")}>\n              {k === \"all\" ? \"All\" : STATUS[k]} <span className={cn(\"tabular-nums\", filter === k ? \"opacity-70\" : \"text-muted-foreground\")}>{count(k)}</span>\n            </button>\n          ))}\n        </div>\n        <ul className=\"grid gap-3 sm:grid-cols-2\">\n          {shown.map((v) => {\n            const is = issues(v)\n            const low = v.level < lowLevel\n            const on = sel === v.id\n            return (\n              <li key={v.id}>\n                <button type=\"button\" aria-pressed={on} onClick={() => { setSel(on ? null : v.id); setNote(null); setText(\"\") }} className={cn(\"bg-card focus-visible:ring-ring/50 flex h-full w-full flex-col gap-3 rounded-xl border p-4 text-start outline-none transition-colors hover:bg-accent/30 focus-visible:ring-[3px] motion-reduce:transition-none\", on && \"border-foreground\", is.length > 0 && !on && \"border-chart-3/70\")}>\n                  <span className=\"flex items-start justify-between gap-3\">\n                    <span className=\"min-w-0\">\n                      <span className=\"block font-medium\">{v.name} <span className=\"text-muted-foreground font-mono text-xs font-normal\" dir=\"ltr\">{v.plate}</span></span>\n                      <span className=\"text-muted-foreground block text-xs\">{v.driver ?? \"No driver\"}</span>\n                    </span>\n                    <span className=\"shrink-0 rounded-full border px-2.5 py-0.5 text-xs font-medium\">{STATUS[v.status]}{v.status === \"moving\" && v.speedKmh !== undefined ? ` · ${v.speedKmh} km/h` : \"\"}</span>\n                  </span>\n                  <span className=\"text-muted-foreground flex items-start gap-1.5 text-xs\"><MapPin aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0\" />{v.where}</span>\n                  <span className=\"grid gap-1\">\n                    <span className=\"flex items-center justify-between text-xs\"><span className=\"flex items-center gap-1.5\"><Fuel aria-hidden=\"true\" className=\"size-3.5\" /> Level</span><span className={cn(\"tabular-nums\", low && \"text-destructive font-semibold\")}>{v.level}%{low ? \" · low\" : \"\"}</span></span>\n                    <span role=\"meter\" aria-label={`${v.name} level`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={v.level} aria-valuetext={`${v.level} percent${low ? \", low\" : \"\"}`} className=\"bg-muted block h-1.5 overflow-hidden rounded-full\"><span className={cn(\"block h-full rounded-full\", low ? \"bg-destructive\" : \"bg-foreground\")} style={{ width: `${v.level}%` }} /></span>\n                  </span>\n                  {is.length > 0 && (\n                    <span className=\"grid gap-1 text-xs font-medium\">\n                      {is.map((i) => (\n                        <span key={i} className=\"flex items-center gap-1.5\"><CircleAlert aria-hidden=\"true\" className=\"text-chart-3 size-3.5 shrink-0\" />{i}</span>\n                      ))}\n                    </span>\n                  )}\n                </button>\n              </li>\n            )\n          })}\n        </ul>\n\n        <div aria-live=\"polite\" className=\"bg-card mt-4 min-h-[5.5rem] rounded-xl border p-4\">\n          {chosen ? (\n            <div className=\"grid gap-3\">\n              <p className=\"font-medium\">{chosen.name} <span className=\"text-muted-foreground font-normal\">· next service in {num(chosen.serviceInKm)} km</span></p>\n              {note && <p role={note.ok ? \"status\" : \"alert\"} className={cn(\"text-sm font-medium\", !note.ok && \"text-destructive\")}>{note.text}</p>}\n              <div className=\"flex flex-wrap items-center gap-2\">\n                {chosen.driver && (\n                  <>\n                    <label className=\"sr-only\" htmlFor={`${id}-msg`}>Message for {chosen.driver}</label>\n                    <input id={`${id}-msg`} value={text} onChange={(e) => setText(e.target.value)} placeholder={`Message ${chosen.driver}`} className=\"bg-background focus-visible:ring-ring/50 h-9 min-w-0 flex-1 rounded-md border px-3 text-sm outline-none focus-visible:ring-[3px]\" />\n                    <Button size=\"sm\" disabled={!text.trim() || busy} onClick={() => void run(() => onMessage?.(chosen, text.trim()), `Sent to ${chosen.driver}.`, () => setText(\"\"))}>{busy ? <Loader2 aria-hidden=\"true\" className=\"animate-spin\" /> : <MessageSquare aria-hidden=\"true\" />} Send</Button>\n                  </>\n                )}\n                {chosen.status !== \"service\" && (\n                  <Button size=\"sm\" variant=\"outline\" disabled={busy} onClick={() => void run(() => onBookService?.(chosen), `A service is requested for ${chosen.name}.`)}><Wrench aria-hidden=\"true\" /> Book a service</Button>\n                )}\n              </div>\n            </div>\n          ) : (\n            <p className=\"text-muted-foreground text-sm\">Select a vehicle to message the driver or book a service.</p>\n          )}\n        </div>\n      </StateView>\n    </section>\n  )\n}\n\nexport { Fleet1, type Fleet1Props, type Fleet1Status, type Fleet1Vehicle }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/fleet-1/fleet-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "fleet",
      "vehicles",
      "logistics",
      "transport",
      "telematics",
      "drivers",
      "maintenance",
      "operations"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "Fleet overview. <Fleet1 vehicles={[{ id, name, plate, driver?, status: 'moving'|'idle'|'parked'|'service', where, speedKmh?, level, serviceInKm, alerts? }]} lowLevel={20} serviceSoonKm={500} onMessage={(vehicle, text) => Promise} onBookService={(vehicle) => Promise} />. Low level and service soon are called out in words.",
      "whenToUse": [
        "A dispatcher or fleet manager overview",
        "Any list of assets with location, level and upkeep"
      ],
      "whenNotToUse": [
        "A live map; place this beside one",
        "Driver payroll or trips history"
      ],
      "composesWith": [
        "inspection-1",
        "capacity-1",
        "alerts-1"
      ],
      "a11y": [
        {
          "keys": "Status",
          "action": "Moving, Idle, Parked and In service are words with icons"
        },
        {
          "keys": "Level",
          "action": "A meter with a text value, and Low is written"
        },
        {
          "keys": "Message",
          "action": "A labelled field and a button named for the driver; the result is announced"
        },
        {
          "keys": "Filter",
          "action": "A group of toggle buttons with pressed state"
        }
      ],
      "customization": [
        "vehicles, lowLevel, serviceSoonKm",
        "onMessage and onBookService"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "fleet-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton cards under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No vehicles yet",
        "example": "fleet-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says no vehicles yet; emptyState sets the copy. A filter with no match says so."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "fleet-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was sent or booked, with Try again (onRetry). An action that fails says why."
      }
    ],
    "examples": [
      "fleet-1-demo",
      "fleet-1-service",
      "fleet-1-loading",
      "fleet-1-empty",
      "fleet-1-error"
    ],
    "url": "https://ui.ballmac.com/components/fleet-1"
  },
  "categories": [
    "blocks",
    "fleet"
  ],
  "type": "registry:block"
}