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.
npx shadcn@latest add @paragon/care-plan-board"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>
);
}