Healthcare

Care Plan Board

A care-task board grouping items into to-do, in-progress, and done columns with assignees; advancing a task springs it into the next column via a shared layout id.

Install

npx shadcn@latest add @paragon/care-plan-board

care-plan-board.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type CareStatus = "todo" | "in-progress" | "done";

export interface CareTask {
  id: string;
  title: string;
  /** Care domain, e.g. "Labs", "Education". */
  category?: string;
  /** Assignee initials, e.g. "RN". */
  assignee?: string;
  due?: string;
  status: CareStatus;
}

export interface CarePlanBoardProps extends React.ComponentProps<"div"> {
  tasks?: CareTask[];
  static?: boolean;
}

const COLUMNS: { key: CareStatus; label: string; dot: string }[] = [
  { key: "todo", label: "To do", dot: "bg-muted-foreground/50" },
  { key: "in-progress", label: "In progress", dot: "bg-warning" },
  { key: "done", label: "Done", dot: "bg-success" },
];

const DEFAULT_TASKS: CareTask[] = [
  { id: "1", title: "Draw A1c panel", category: "Labs", assignee: "RN", due: "Today", status: "todo" },
  { id: "2", title: "Foot exam & sensation test", category: "Exam", assignee: "MA", due: "Today", status: "todo" },
  { id: "3", title: "Diabetes nutrition counseling", category: "Education", assignee: "RD", due: "Tue", status: "in-progress" },
  { id: "4", title: "Adjust insulin titration", category: "Meds", assignee: "MD", due: "Wed", status: "in-progress" },
  { id: "5", title: "Retinal screening referral", category: "Referral", assignee: "RN", due: "Mon", status: "done" },
  { id: "6", title: "Update care goals in chart", category: "Docs", assignee: "MD", status: "done" },
];

/**
 * A care-plan board grouping tasks into to-do / in-progress / done columns.
 * Advancing a task springs it into the next column via a shared layout id;
 * counts read in tabular figures. Reduced motion swaps columns without the
 * layout animation.
 */
export function CarePlanBoard({
  tasks: initial = DEFAULT_TASKS,
  static: isStatic = false,
  className,
  ...props
}: CarePlanBoardProps) {
  const reduced = useReducedMotion();
  const [tasks, setTasks] = React.useState(initial);
  const animate = !isStatic && !reduced;

  const advance = (id: string) =>
    setTasks((prev) =>
      prev.map((t) =>
        t.id === id
          ? {
              ...t,
              status:
                t.status === "todo"
                  ? "in-progress"
                  : t.status === "in-progress"
                    ? "done"
                    : "done",
            }
          : t,
      ),
    );

  return (
    <div
      data-slot="care-plan-board"
      className={cn(
        "grid w-full grid-cols-1 gap-3 sm:grid-cols-3",
        className,
      )}
      {...props}
    >
      {COLUMNS.map((col) => {
        const items = tasks.filter((t) => t.status === col.key);
        return (
          <section
            key={col.key}
            className="flex flex-col rounded-xl bg-secondary/40 p-2"
          >
            <header className="flex items-center gap-2 px-1.5 py-1.5">
              <span aria-hidden className={cn("size-2 rounded-full", col.dot)} />
              <h3 className="text-xs font-medium">{col.label}</h3>
              <span className="ml-auto text-xs text-muted-foreground tabular-nums">
                {items.length}
              </span>
            </header>
            <div className="flex flex-col gap-2">
              <AnimatePresence initial={false} mode="popLayout">
                {items.map((t) => (
                  <motion.article
                    key={t.id}
                    layout={animate}
                    layoutId={animate ? t.id : undefined}
                    initial={animate ? { opacity: 0, y: 8 } : false}
                    animate={{ opacity: 1, y: 0 }}
                    exit={animate ? { opacity: 0, scale: 0.96 } : undefined}
                    transition={{ type: "spring", duration: 0.35, bounce: 0 }}
                    className="rounded-lg bg-card p-2.5 text-card-foreground shadow-border"
                  >
                    <div className="flex items-start justify-between gap-2">
                      <p
                        className={cn(
                          "text-sm leading-snug",
                          t.status === "done" && "text-muted-foreground line-through",
                        )}
                      >
                        {t.title}
                      </p>
                      {t.assignee && (
                        <span
                          aria-hidden
                          className="flex size-6 shrink-0 items-center justify-center rounded-full bg-secondary text-[10px] font-semibold text-secondary-foreground"
                        >
                          {t.assignee}
                        </span>
                      )}
                    </div>
                    <div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
                      {t.category && (
                        <span className="rounded bg-secondary px-1.5 py-0.5 font-medium">
                          {t.category}
                        </span>
                      )}
                      {t.due && <span className="tabular-nums">Due {t.due}</span>}
                      {t.status !== "done" && (
                        <button
                          type="button"
                          onClick={() => advance(t.id)}
                          className="ml-auto rounded px-1.5 py-0.5 font-medium text-foreground transition-colors duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring outline-none"
                        >
                          Advance →
                        </button>
                      )}
                    </div>
                  </motion.article>
                ))}
              </AnimatePresence>
              {items.length === 0 && (
                <p className="px-1.5 py-3 text-center text-xs text-muted-foreground">
                  Nothing here
                </p>
              )}
            </div>
          </section>
        );
      })}
    </div>
  );
}