tasks: assign a task to a specific person

Backend: add tasks.assignee_user_id (nullable, fk user) + migration; persist
it through create/update and surface tasks to the assigned person in listTasks.

Frontend: New Task dialog gains a three-way Assignee control
(Myself / Department / Person) with a provider picker from /api/staff/providers;
task card + detail show the assignee's name when set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-20 18:40:56 +03:00
parent 1d9b1b1d22
commit bbc6869745
11 changed files with 4131 additions and 20 deletions
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "tasks" ADD COLUMN "assignee_user_id" text;--> statement-breakpoint
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_assignee_user_id_user_id_fk" FOREIGN KEY ("assignee_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -183,6 +183,13 @@
"when": 1781910033543,
"tag": "0025_nice_paladin",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1781969782874,
"tag": "0026_many_wolfsbane",
"breakpoints": true
}
]
}
+5
View File
@@ -25,6 +25,11 @@ export const tasks = pgTable(
// The department (member role) a task is assigned to, e.g. "reception".
// Null means a personal task belonging to its creator. Drives who sees it.
assigneeRole: text("assignee_role"),
// A specific person the task is assigned to. Null when assigned to a
// department or kept personal. The assignee display name lives in `assignee`.
assigneeUserId: text("assignee_user_id").references(() => user.id, {
onDelete: "set null",
}),
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`
+1
View File
@@ -15,6 +15,7 @@ export const taskInputSchema = z.object({
title: z.string().trim().min(1, "A task subject is required.").max(200),
assignee: z.string().trim().max(200).default("Unassigned"),
assigneeRole: z.enum(TASK_DEPARTMENTS).nullish(),
assigneeUserId: z.string().trim().max(120).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"),
+9 -1
View File
@@ -24,6 +24,7 @@ function toTask(row: TaskRow): Task {
title: row.title,
assignee: row.assignee,
assigneeRole: row.assigneeRole,
assigneeUserId: row.assigneeUserId,
due: row.due,
priority: row.priority,
status: row.status,
@@ -48,7 +49,11 @@ export async function listTasks(
let where = eq(tasks.organizationId, orgId);
if (!isAdmin) {
const visible = [eq(tasks.createdBy, viewer.userId)];
const visible = [
eq(tasks.createdBy, viewer.userId),
// Tasks assigned to this person specifically.
eq(tasks.assigneeUserId, viewer.userId),
];
if (roles.length) visible.push(inArray(tasks.assigneeRole, roles));
where = and(where, or(...visible))!;
}
@@ -73,6 +78,7 @@ export async function createTask(
title: input.title,
assignee: input.assignee,
assigneeRole: input.assigneeRole ?? null,
assigneeUserId: input.assigneeUserId ?? null,
due: input.due,
priority: input.priority,
status: input.status,
@@ -100,6 +106,8 @@ export async function updateTask(
if (patch.assignee !== undefined) set.assignee = patch.assignee;
if (patch.assigneeRole !== undefined)
set.assigneeRole = patch.assigneeRole ?? null;
if (patch.assigneeUserId !== undefined)
set.assigneeUserId = patch.assigneeUserId ?? null;
if (patch.due !== undefined) set.due = patch.due;
if (patch.priority !== undefined) set.priority = patch.priority;
if (patch.patient !== undefined) set.patient = patch.patient ?? null;
+4
View File
@@ -11,6 +11,10 @@ export type Task = {
assignee: string;
// Department (member role) the task is assigned to; null = personal task.
assigneeRole: string | null;
// A specific person the task is assigned to (user id); null when assigned to
// a department or kept personal. Takes precedence over assigneeRole for who
// sees the task.
assigneeUserId: string | null;
due: string;
priority: TaskPriority;
status: TaskStatus;
@@ -80,11 +80,13 @@ export function TaskDetailSheet({
{t("tasks.detail.assignedTo")}
</dt>
<dd className="text-foreground">
{task.assigneeRole
? t("tasks.detail.deptTeam", {
dept: deptLabel(task.assigneeRole),
})
: t("tasks.detail.personal")}
{task.assigneeUserId
? task.assignee
: task.assigneeRole
? t("tasks.detail.deptTeam", {
dept: deptLabel(task.assigneeRole),
})
: t("tasks.detail.personal")}
</dd>
<dt className="text-muted-foreground">
{t("tasks.detail.createdBy")}
+66 -11
View File
@@ -30,6 +30,7 @@ import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { ROLE_LABELS } from "@/lib/access";
import { DEPARTMENTS } from "@/lib/roles";
import { listProviders, type Provider, specialtyLabel } from "@/lib/staff";
import {
type Priority,
type Task,
@@ -43,7 +44,7 @@ import {
import { notify } from "@/lib/toast";
import { cn } from "@/lib/utils";
type AssigneeMode = "self" | "other";
type AssigneeMode = "self" | "department" | "person";
function deptLabel(role: string): string {
return (ROLE_LABELS as Record<string, string>)[role] ?? role;
@@ -96,13 +97,23 @@ function AddTaskDialog({
const [notes, setNotes] = useState("");
const [assigneeMode, setAssigneeMode] = useState<AssigneeMode>("self");
const [department, setDepartment] = useState<string>("reception");
const [providers, setProviders] = useState<Provider[]>([]);
const [providerId, setProviderId] = useState<string>("");
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.
// Re-seed the column when the dialog is opened from a specific column, and
// load the clinic's providers so a task can be assigned to a specific person.
useEffect(() => {
if (open) setStatus(initialStatus);
if (!open) return;
setStatus(initialStatus);
listProviders()
.then((list) => {
setProviders(list);
setProviderId((id) => id || (list[0]?.userId ?? ""));
})
.catch(() => setProviders([]));
}, [open, initialStatus]);
const reset = () => {
@@ -110,6 +121,7 @@ function AddTaskDialog({
setNotes("");
setAssigneeMode("self");
setDepartment("reception");
setProviderId("");
setDue("");
setPriority("medium");
setStatus(initialStatus);
@@ -124,11 +136,25 @@ function AddTaskDialog({
);
return;
}
if (assigneeMode === "person" && !providerId) {
notify.error(
t("tasks.toast.needPersonTitle"),
t("tasks.toast.needPersonBody"),
);
return;
}
const person =
assigneeMode === "person"
? providers.find((p) => p.userId === providerId)
: undefined;
onAdd({
title: title.trim(),
notes: notes.trim() || undefined,
// "Myself" → personal task (no department); "Other" → a department.
assigneeRole: assigneeMode === "self" ? null : department,
// Myself → personal task; Department → a member role; Person → a specific
// clinician (their name shows as the assignee, the task surfaces for them).
assigneeRole: assigneeMode === "department" ? department : null,
assigneeUserId: assigneeMode === "person" ? providerId || null : null,
assignee: person?.name,
due: due.trim() || "No due date",
priority,
status,
@@ -182,11 +208,14 @@ function AddTaskDialog({
>
<TabsList className="w-full">
<TabsTab value="self">{t("tasks.dialog.assigneeSelf")}</TabsTab>
<TabsTab value="other">
{t("tasks.dialog.assigneeOther")}
<TabsTab value="department">
{t("tasks.dialog.assigneeDepartment")}
</TabsTab>
<TabsTab value="person">
{t("tasks.dialog.assigneePerson")}
</TabsTab>
</TabsList>
<TabsPanel value="other">
<TabsPanel className="pt-2" value="department">
<Field label={t("tasks.dialog.department")}>
<select
className={controlClass}
@@ -201,6 +230,30 @@ function AddTaskDialog({
</select>
</Field>
</TabsPanel>
<TabsPanel className="pt-2" value="person">
<Field label={t("tasks.dialog.person")}>
{providers.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("tasks.dialog.personEmpty")}
</p>
) : (
<select
className={controlClass}
onChange={(e) => setProviderId(e.target.value)}
value={providerId}
>
{providers.map((p) => {
const spec = specialtyLabel(t, p.specialty);
return (
<option key={p.userId} value={p.userId}>
{spec ? `${p.name} · ${spec}` : p.name}
</option>
);
})}
</select>
)}
</Field>
</TabsPanel>
</Tabs>
</div>
<div className="grid grid-cols-3 gap-3">
@@ -283,9 +336,11 @@ function TaskCard({
{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.assigneeUserId
? t("tasks.list.forPerson", { name: task.assignee })
: task.assigneeRole
? t("tasks.list.forDept", { dept: deptLabel(task.assigneeRole) })
: t("tasks.list.personal")}
{task.createdByName
? ` · ${t("tasks.list.byCreator", { name: task.createdByName })}`
: ""}
+10 -3
View File
@@ -669,12 +669,17 @@
"priorityLabel": "Priority",
"statusLabel": "Status",
"cancel": "Cancel",
"add": "Add task"
"add": "Add task",
"assigneeDepartment": "Department",
"assigneePerson": "Person",
"person": "Team member",
"personEmpty": "No clinicians available to assign."
},
"list": {
"forDept": "For {{dept}}",
"personal": "Personal",
"byCreator": "by {{name}}"
"byCreator": "by {{name}}",
"forPerson": "For {{name}}"
},
"detail": {
"fallbackTitle": "Task",
@@ -710,7 +715,9 @@
"addFailedTitle": "Couldn't add task",
"addFailedBody": "Please try again.",
"updateFailedTitle": "Couldn't update task",
"updateFailedBody": "Please try again."
"updateFailedBody": "Please try again.",
"needPersonTitle": "Pick a team member",
"needPersonBody": "Choose who this task is for."
}
},
"meetings": {
+3
View File
@@ -12,6 +12,8 @@ export type Task = {
assignee: string;
// Department (member role) the task is assigned to; null = personal task.
assigneeRole: string | null;
// A specific person the task is assigned to (user id); null otherwise.
assigneeUserId: string | null;
due: string;
priority: Priority;
status: TaskStatus;
@@ -29,6 +31,7 @@ export type TaskInput = {
title: string;
assignee?: string;
assigneeRole?: string | null;
assigneeUserId?: string | null;
due?: string;
priority?: Priority;
status?: TaskStatus;