mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
feat(tasks): kanban board with To Do / In Progress / Done columns
- add a `status` field to tasks (backend schema/validation/service/types + frontend types), kept in sync with the legacy `done` flag - migration 0017 adds the column and backfills done tasks to "done" - rewrite the tasks page as a three-column board: per-column add buttons, draggable cards, and a status mover in the detail sheet Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "tasks" ADD COLUMN "status" text DEFAULT 'todo' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
UPDATE "tasks" SET "status" = 'done' WHERE "done" = true;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
||||
"when": 1781464772532,
|
||||
"tag": "0016_past_maestro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1781537419038,
|
||||
"tag": "0017_wealthy_northstar",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import type { TaskPriority } from "../../types/task.js";
|
||||
import type { TaskPriority, TaskStatus } from "../../types/task.js";
|
||||
import { organization, user } from "./auth.js";
|
||||
|
||||
// One row per care-team to-do, scoped to a clinic (organization). Shared across
|
||||
@@ -27,6 +27,9 @@ export const tasks = pgTable(
|
||||
assigneeRole: text("assignee_role"),
|
||||
due: text("due").notNull().default("No due date"),
|
||||
priority: text("priority").$type<TaskPriority>().notNull(),
|
||||
// Board column: todo | in_progress | done. Kept in sync with `done`
|
||||
// (done === status === "done") so the legacy toggle still works.
|
||||
status: text("status").$type<TaskStatus>().notNull().default("todo"),
|
||||
patient: text("patient"),
|
||||
notes: text("notes"),
|
||||
done: boolean("done").notNull().default(false),
|
||||
|
||||
@@ -17,6 +17,7 @@ export const taskInputSchema = z.object({
|
||||
assigneeRole: z.enum(TASK_DEPARTMENTS).nullish(),
|
||||
due: z.string().trim().max(120).default("No due date"),
|
||||
priority: z.enum(["high", "medium", "low"]).default("medium"),
|
||||
status: z.enum(["todo", "in_progress", "done"]).default("todo"),
|
||||
patient: z.string().trim().max(200).nullish(),
|
||||
notes: z.string().max(5000).nullish(),
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ function toTask(row: TaskRow): Task {
|
||||
assigneeRole: row.assigneeRole,
|
||||
due: row.due,
|
||||
priority: row.priority,
|
||||
status: row.status,
|
||||
patient: row.patient,
|
||||
notes: row.notes,
|
||||
done: row.done,
|
||||
@@ -74,6 +75,9 @@ export async function createTask(
|
||||
assigneeRole: input.assigneeRole ?? null,
|
||||
due: input.due,
|
||||
priority: input.priority,
|
||||
status: input.status,
|
||||
// Keep the legacy `done` flag in lock-step with the board column.
|
||||
done: input.status === "done",
|
||||
patient: input.patient ?? null,
|
||||
notes: input.notes ?? null,
|
||||
createdBy: creator.id,
|
||||
@@ -100,7 +104,15 @@ export async function updateTask(
|
||||
if (patch.priority !== undefined) set.priority = patch.priority;
|
||||
if (patch.patient !== undefined) set.patient = patch.patient ?? null;
|
||||
if (patch.notes !== undefined) set.notes = patch.notes ?? null;
|
||||
if (patch.done !== undefined) set.done = patch.done;
|
||||
// Keep `status` and the legacy `done` flag in sync: a status patch wins and
|
||||
// sets done; a bare done toggle maps to done/todo.
|
||||
if (patch.status !== undefined) {
|
||||
set.status = patch.status;
|
||||
set.done = patch.status === "done";
|
||||
} else if (patch.done !== undefined) {
|
||||
set.done = patch.done;
|
||||
set.status = patch.done ? "done" : "todo";
|
||||
}
|
||||
|
||||
if (Object.keys(set).length === 0) {
|
||||
const [row] = await db
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// `lib/tasks.ts` Task type. Scoped to the active clinic (a shared care-team
|
||||
// to-do board). `patient` is an optional free-text reference for context.
|
||||
export type TaskPriority = "high" | "medium" | "low";
|
||||
// Board column the task sits in. `done` mirrors `status === "done"`.
|
||||
export type TaskStatus = "todo" | "in_progress" | "done";
|
||||
|
||||
export type Task = {
|
||||
id: string;
|
||||
@@ -11,6 +13,7 @@ export type Task = {
|
||||
assigneeRole: string | null;
|
||||
due: string;
|
||||
priority: TaskPriority;
|
||||
status: TaskStatus;
|
||||
patient: string | null;
|
||||
notes: string | null;
|
||||
done: boolean;
|
||||
|
||||
@@ -12,9 +12,12 @@ import {
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ROLE_LABELS } from "@/lib/access";
|
||||
import type { TaskStatus } from "@/lib/tasks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Priority, Task } from "@/components/tasks/tasks-view";
|
||||
|
||||
const STATUSES: TaskStatus[] = ["todo", "in_progress", "done"];
|
||||
|
||||
const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline"> =
|
||||
{
|
||||
high: "destructive",
|
||||
@@ -33,12 +36,12 @@ export function TaskDetailSheet({
|
||||
task,
|
||||
open,
|
||||
onOpenChange,
|
||||
onToggle,
|
||||
onMove,
|
||||
}: {
|
||||
task: Task | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onToggle: (id: string) => void;
|
||||
onMove: (id: string, status: TaskStatus) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -67,9 +70,7 @@ export function TaskDetailSheet({
|
||||
{t("tasks.detail.status")}
|
||||
</dt>
|
||||
<dd className="text-foreground">
|
||||
{task.done
|
||||
? t("tasks.detail.completed")
|
||||
: t("tasks.detail.open")}
|
||||
{t(`tasks.status.${task.status}`)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("tasks.detail.assignedTo")}
|
||||
@@ -110,16 +111,23 @@ export function TaskDetailSheet({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => onToggle(task.id)}
|
||||
type="button"
|
||||
variant={task.done ? "outline" : "default"}
|
||||
>
|
||||
{task.done
|
||||
? t("tasks.detail.reopen")
|
||||
: t("tasks.detail.complete")}
|
||||
</Button>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("tasks.detail.moveTo")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STATUSES.map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
onClick={() => onMove(task.id, s)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={task.status === s ? "default" : "outline"}
|
||||
>
|
||||
{t(`tasks.status.${s}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { CalendarClock, Plus } from "lucide-react";
|
||||
import {
|
||||
type DragEvent,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
type Priority,
|
||||
type Task,
|
||||
type TaskInput,
|
||||
type TaskStatus,
|
||||
createTask,
|
||||
listTasks,
|
||||
updateTask,
|
||||
@@ -47,7 +49,8 @@ function deptLabel(role: string): string {
|
||||
|
||||
export type { Priority, Task } from "@/lib/tasks";
|
||||
|
||||
type Filter = "all" | "open" | "done";
|
||||
// The board columns, left → right.
|
||||
const COLUMNS: TaskStatus[] = ["todo", "in_progress", "done"];
|
||||
|
||||
const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline"> =
|
||||
{
|
||||
@@ -56,32 +59,12 @@ const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline">
|
||||
low: "outline",
|
||||
};
|
||||
|
||||
function CheckButton({
|
||||
done,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
done: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
aria-pressed={done}
|
||||
className={cn(
|
||||
"flex size-5 shrink-0 items-center justify-center rounded-md border transition-colors",
|
||||
done
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input hover:border-ring",
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{done && <Check className="size-3.5" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
// A subtle accent dot per column so the three are quick to tell apart.
|
||||
const columnDot: Record<TaskStatus, string> = {
|
||||
todo: "bg-muted-foreground",
|
||||
in_progress: "bg-primary",
|
||||
done: "bg-success",
|
||||
};
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
@@ -99,10 +82,12 @@ function AddTaskDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAdd,
|
||||
initialStatus,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdd: (task: TaskInput) => void;
|
||||
initialStatus: TaskStatus;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [title, setTitle] = useState("");
|
||||
@@ -111,6 +96,12 @@ function AddTaskDialog({
|
||||
const [department, setDepartment] = useState<string>("reception");
|
||||
const [due, setDue] = useState("");
|
||||
const [priority, setPriority] = useState<Priority>("medium");
|
||||
const [status, setStatus] = useState<TaskStatus>(initialStatus);
|
||||
|
||||
// Re-seed the column when the dialog is opened from a specific column.
|
||||
useEffect(() => {
|
||||
if (open) setStatus(initialStatus);
|
||||
}, [open, initialStatus]);
|
||||
|
||||
const reset = () => {
|
||||
setTitle("");
|
||||
@@ -119,6 +110,7 @@ function AddTaskDialog({
|
||||
setDepartment("reception");
|
||||
setDue("");
|
||||
setPriority("medium");
|
||||
setStatus(initialStatus);
|
||||
};
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
@@ -137,6 +129,7 @@ function AddTaskDialog({
|
||||
assigneeRole: assigneeMode === "self" ? null : department,
|
||||
due: due.trim() || "No due date",
|
||||
priority,
|
||||
status,
|
||||
});
|
||||
notify.success(t("tasks.toast.addedTitle"), title.trim());
|
||||
reset();
|
||||
@@ -208,7 +201,7 @@ function AddTaskDialog({
|
||||
</TabsPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label={t("tasks.dialog.due")}>
|
||||
<Input
|
||||
onChange={(e) => setDue(e.target.value)}
|
||||
@@ -227,6 +220,19 @@ function AddTaskDialog({
|
||||
<option value="low">{t("tasks.priority.low")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("tasks.dialog.statusLabel")}>
|
||||
<select
|
||||
className={controlClass}
|
||||
onChange={(e) => setStatus(e.target.value as TaskStatus)}
|
||||
value={status}
|
||||
>
|
||||
{COLUMNS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`tasks.status.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
|
||||
@@ -242,13 +248,68 @@ function AddTaskDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// One draggable task card on the board.
|
||||
function TaskCard({
|
||||
task,
|
||||
onOpen,
|
||||
onDragStart,
|
||||
}: {
|
||||
task: Task;
|
||||
onOpen: () => void;
|
||||
onDragStart: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="group flex cursor-pointer flex-col gap-2 rounded-2xl border bg-card p-3 transition-colors hover:border-ring"
|
||||
draggable
|
||||
onClick={onOpen}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
onDragStart();
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
task.done
|
||||
? "text-muted-foreground line-through"
|
||||
: "font-medium text-foreground",
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{task.assigneeRole
|
||||
? t("tasks.list.forDept", { dept: deptLabel(task.assigneeRole) })
|
||||
: t("tasks.list.personal")}
|
||||
{task.createdByName
|
||||
? ` · ${t("tasks.list.byCreator", { name: task.createdByName })}`
|
||||
: ""}
|
||||
</span>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center gap-1 text-muted-foreground text-xs">
|
||||
<CalendarClock className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{task.due}</span>
|
||||
</span>
|
||||
<Badge className="shrink-0" variant={priorityVariant[task.priority]}>
|
||||
{t(`tasks.priority.${task.priority}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TasksView() {
|
||||
const { t } = useTranslation();
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addStatus, setAddStatus] = useState<TaskStatus>("todo");
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
const [dragOver, setDragOver] = useState<TaskStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -266,26 +327,29 @@ export function TasksView() {
|
||||
|
||||
const selected = tasks.find((task) => task.id === selectedId) ?? null;
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (filter === "open") return tasks.filter((task) => !task.done);
|
||||
if (filter === "done") return tasks.filter((task) => task.done);
|
||||
return tasks;
|
||||
}, [tasks, filter]);
|
||||
const byStatus = useMemo(() => {
|
||||
const groups: Record<TaskStatus, Task[]> = {
|
||||
todo: [],
|
||||
in_progress: [],
|
||||
done: [],
|
||||
};
|
||||
for (const task of tasks) groups[task.status]?.push(task);
|
||||
return groups;
|
||||
}, [tasks]);
|
||||
|
||||
// Optimistically flip done, then persist; roll back on failure.
|
||||
const toggle = async (id: string) => {
|
||||
// Optimistically move a task to a new column, then persist; roll back on fail.
|
||||
const moveTask = async (id: string, status: TaskStatus) => {
|
||||
const current = tasks.find((task) => task.id === id);
|
||||
if (!current) return;
|
||||
const next = !current.done;
|
||||
if (!current || current.status === status) return;
|
||||
setTasks((prev) =>
|
||||
prev.map((row) => (row.id === id ? { ...row, done: next } : row)),
|
||||
prev.map((row) =>
|
||||
row.id === id ? { ...row, status, done: status === "done" } : row,
|
||||
),
|
||||
);
|
||||
try {
|
||||
await updateTask(id, { done: next });
|
||||
await updateTask(id, { status });
|
||||
} catch {
|
||||
setTasks((prev) =>
|
||||
prev.map((row) => (row.id === id ? { ...row, done: current.done } : row)),
|
||||
);
|
||||
setTasks((prev) => prev.map((row) => (row.id === id ? current : row)));
|
||||
notify.error(
|
||||
t("tasks.toast.updateFailedTitle"),
|
||||
t("tasks.toast.updateFailedBody"),
|
||||
@@ -310,97 +374,109 @@ export function TasksView() {
|
||||
}
|
||||
};
|
||||
|
||||
const openAdd = (status: TaskStatus) => {
|
||||
setAddStatus(status);
|
||||
setAddOpen(true);
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent, status: TaskStatus) => {
|
||||
event.preventDefault();
|
||||
setDragOver(null);
|
||||
const id = dragId ?? event.dataTransfer.getData("text/plain");
|
||||
setDragId(null);
|
||||
if (id) void moveTask(id, status);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-10">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-2xl tracking-tight">
|
||||
{t("tasks.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("tasks.subtitle")}</p>
|
||||
</div>
|
||||
<Button
|
||||
className="rounded-3xl"
|
||||
onClick={() => setAddOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t("tasks.new")}
|
||||
</Button>
|
||||
<div className="flex h-full w-full flex-col gap-6 px-6 py-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="font-semibold text-2xl tracking-tight">
|
||||
{t("tasks.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("tasks.subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center gap-1 rounded-2xl border bg-card/30 p-1 sm:w-fit">
|
||||
{(["all", "open", "done"] as Filter[]).map((f) => (
|
||||
<Button
|
||||
className="flex-1 sm:flex-none"
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={filter === f ? "secondary" : "ghost"}
|
||||
>
|
||||
{t(`tasks.filters.${f}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
|
||||
{visible.length === 0 ? (
|
||||
<p className="px-4 py-10 text-center text-muted-foreground text-sm">
|
||||
{t("tasks.empty")}
|
||||
</p>
|
||||
) : (
|
||||
visible.map((task) => (
|
||||
<div className="flex items-center gap-3 px-4 py-3" key={task.id}>
|
||||
<CheckButton
|
||||
done={task.done}
|
||||
label={
|
||||
task.done ? t("tasks.markNotDone") : t("tasks.markDone")
|
||||
}
|
||||
onClick={() => toggle(task.id)}
|
||||
/>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 flex-col text-left"
|
||||
onClick={() => openTask(task.id)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
task.done
|
||||
? "text-muted-foreground line-through"
|
||||
: "font-medium text-foreground",
|
||||
)}
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{COLUMNS.map((status) => {
|
||||
const column = byStatus[status];
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col gap-3 rounded-2xl border bg-card/30 p-3 transition-colors",
|
||||
dragOver === status && "border-ring bg-accent/40",
|
||||
)}
|
||||
key={status}
|
||||
onDragLeave={() => setDragOver((s) => (s === status ? null : s))}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(status);
|
||||
}}
|
||||
onDrop={(e) => handleDrop(e, status)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn("size-2 rounded-full", columnDot[status])}
|
||||
/>
|
||||
<span className="font-medium text-sm">
|
||||
{t(`tasks.status.${status}`)}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{column.length}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={t("tasks.board.addTask")}
|
||||
onClick={() => openAdd(status)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{task.assigneeRole
|
||||
? t("tasks.list.forDept", {
|
||||
dept: deptLabel(task.assigneeRole),
|
||||
})
|
||||
: t("tasks.list.personal")}
|
||||
{task.createdByName
|
||||
? ` · ${t("tasks.list.byCreator", { name: task.createdByName })}`
|
||||
: ""}
|
||||
</span>
|
||||
</button>
|
||||
<Badge
|
||||
className="shrink-0"
|
||||
variant={priorityVariant[task.priority]}
|
||||
>
|
||||
{t(`tasks.priority.${task.priority}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||
{column.length === 0 ? (
|
||||
<p className="px-1 py-6 text-center text-muted-foreground text-xs">
|
||||
{t("tasks.board.emptyColumn")}
|
||||
</p>
|
||||
) : (
|
||||
column.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
onDragStart={() => setDragId(task.id)}
|
||||
onOpen={() => openTask(task.id)}
|
||||
task={task}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<Button
|
||||
className="justify-start text-muted-foreground"
|
||||
onClick={() => openAdd(status)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t("tasks.board.addTask")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<AddTaskDialog onAdd={addTask} onOpenChange={setAddOpen} open={addOpen} />
|
||||
<AddTaskDialog
|
||||
initialStatus={addStatus}
|
||||
onAdd={addTask}
|
||||
onOpenChange={setAddOpen}
|
||||
open={addOpen}
|
||||
/>
|
||||
|
||||
<TaskDetailSheet
|
||||
onMove={moveTask}
|
||||
onOpenChange={setSheetOpen}
|
||||
onToggle={toggle}
|
||||
open={sheetOpen}
|
||||
task={selected}
|
||||
/>
|
||||
|
||||
@@ -542,6 +542,16 @@
|
||||
"empty": "No tasks here.",
|
||||
"markDone": "Mark as done",
|
||||
"markNotDone": "Mark as not done",
|
||||
"status": {
|
||||
"todo": "To Do",
|
||||
"in_progress": "In Progress",
|
||||
"done": "Done"
|
||||
},
|
||||
"board": {
|
||||
"addTask": "Add task",
|
||||
"moveTo": "Move to",
|
||||
"emptyColumn": "No tasks."
|
||||
},
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"open": "Open",
|
||||
@@ -566,6 +576,7 @@
|
||||
"due": "Due",
|
||||
"duePlaceholder": "e.g. Today",
|
||||
"priorityLabel": "Priority",
|
||||
"statusLabel": "Status",
|
||||
"cancel": "Cancel",
|
||||
"add": "Add task"
|
||||
},
|
||||
@@ -588,7 +599,8 @@
|
||||
"patient": "Patient",
|
||||
"details": "Details",
|
||||
"reopen": "Reopen task",
|
||||
"complete": "Mark complete"
|
||||
"complete": "Mark complete",
|
||||
"moveTo": "Move to"
|
||||
},
|
||||
"toast": {
|
||||
"needSubjectTitle": "Add a subject",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { apiFetch } from "@/lib/api-client";
|
||||
// A care-team task. Mirrors the backend `src/types/task.ts`. Scoped to the active
|
||||
// clinic (shared across the care team).
|
||||
export type Priority = "high" | "medium" | "low";
|
||||
// Board column the task sits in. `done` mirrors `status === "done"`.
|
||||
export type TaskStatus = "todo" | "in_progress" | "done";
|
||||
|
||||
export type Task = {
|
||||
id: string;
|
||||
@@ -12,6 +14,7 @@ export type Task = {
|
||||
assigneeRole: string | null;
|
||||
due: string;
|
||||
priority: Priority;
|
||||
status: TaskStatus;
|
||||
patient: string | null;
|
||||
notes: string | null;
|
||||
done: boolean;
|
||||
@@ -28,6 +31,7 @@ export type TaskInput = {
|
||||
assigneeRole?: string | null;
|
||||
due?: string;
|
||||
priority?: Priority;
|
||||
status?: TaskStatus;
|
||||
patient?: string | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user