A ward bed grid tinting each room by state (available, occupied, cleaning, reserved) with a live occupancy percentage bar and a counted legend.
npx shadcn@latest add @paragon/bed-availability-grid"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>
);
}