Healthcare

Bed Availability Grid

A ward bed grid tinting each room by state (available, occupied, cleaning, reserved) with a live occupancy percentage bar and a counted legend.

Install

npx shadcn@latest add @paragon/bed-availability-grid

bed-availability-grid.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export type BedState = "available" | "occupied" | "cleaning" | "reserved";

export interface Bed {
  id: string;
  state: BedState;
}

export interface BedAvailabilityGridProps extends React.ComponentProps<"div"> {
  ward?: string;
  beds?: Bed[];
}

const STATE_META: Record<
  BedState,
  { label: string; swatch: string; text: string }
> = {
  available: { label: "Available", swatch: "bg-success/15 text-success", text: "text-success" },
  occupied: { label: "Occupied", swatch: "bg-primary/12 text-foreground", text: "text-foreground" },
  cleaning: { label: "Cleaning", swatch: "bg-warning/15 text-warning", text: "text-warning" },
  reserved: { label: "Reserved", swatch: "bg-secondary text-muted-foreground", text: "text-muted-foreground" },
};

const ORDER: BedState[] = ["available", "occupied", "cleaning", "reserved"];

function makeDefault(): Bed[] {
  // Deterministic layout — no Math.random at module or render time.
  const pattern: BedState[] = [
    "occupied", "occupied", "available", "cleaning",
    "occupied", "reserved", "occupied", "available",
    "cleaning", "occupied", "occupied", "available",
    "occupied", "available", "reserved", "occupied",
    "occupied", "cleaning", "occupied", "occupied",
  ];
  return pattern.map((state, i) => ({
    id: `${Math.floor(i / 4) + 3}${String.fromCharCode(65 + (i % 4))}`,
    state,
  }));
}

/**
 * A ward bed grid. Each cell is a bed labeled by room, tinted by state
 * (available / occupied / cleaning / reserved). A header summarizes live
 * occupancy as a percentage over a scaleX fill bar; the legend counts each
 * state in tabular figures. Static composition — an operational board.
 */
export function BedAvailabilityGrid({
  ward = "4 West — Medical/Surgical",
  beds = makeDefault(),
  className,
  ...props
}: BedAvailabilityGridProps) {
  const counts = React.useMemo(() => {
    const c: Record<BedState, number> = {
      available: 0,
      occupied: 0,
      cleaning: 0,
      reserved: 0,
    };
    for (const b of beds) c[b.state]++;
    return c;
  }, [beds]);

  const total = beds.length || 1;
  const occupancy = Math.round((counts.occupied / total) * 100);

  return (
    <div
      data-slot="bed-availability-grid"
      className={cn(
        "w-full max-w-md rounded-xl bg-card p-4 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div className="flex items-baseline justify-between gap-3">
        <div>
          <h3 className="text-sm font-medium">{ward}</h3>
          <p className="text-xs text-muted-foreground tabular-nums">
            {counts.occupied} of {beds.length} beds occupied
          </p>
        </div>
        <div className="text-right">
          <p className="text-xl font-semibold tabular-nums">{occupancy}%</p>
          <p className="text-[11px] text-muted-foreground">occupancy</p>
        </div>
      </div>

      <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-secondary">
        <div
          className="h-full origin-left rounded-full bg-primary"
          style={{ transform: `scaleX(${occupancy / 100})` }}
        />
      </div>

      <div className="mt-4 grid grid-cols-4 gap-2" role="list" aria-label="Beds">
        {beds.map((b) => {
          const meta = STATE_META[b.state];
          return (
            <div
              key={b.id}
              role="listitem"
              aria-label={`Bed ${b.id}, ${meta.label}`}
              className={cn(
                "flex aspect-square flex-col items-center justify-center rounded-lg text-center",
                meta.swatch,
              )}
            >
              <span className="text-sm font-semibold tabular-nums">{b.id}</span>
              <span className="mt-0.5 text-[9px] font-medium tracking-wide uppercase opacity-80">
                {meta.label.slice(0, 4)}
              </span>
            </div>
          );
        })}
      </div>

      <div className="mt-4 flex flex-wrap gap-x-4 gap-y-1.5">
        {ORDER.map((s) => (
          <span
            key={s}
            className="flex items-center gap-1.5 text-[11px] text-muted-foreground"
          >
            <span
              aria-hidden
              className={cn("size-2.5 rounded", STATE_META[s].swatch)}
            />
            {STATE_META[s].label}
            <span className="font-medium text-foreground tabular-nums">
              {counts[s]}
            </span>
          </span>
        ))}
      </div>
    </div>
  );
}