mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 20:08:14 +00:00
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:
@@ -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
@@ -183,6 +183,13 @@
|
||||
"when": 1781910033543,
|
||||
"tag": "0025_nice_paladin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1781969782874,
|
||||
"tag": "0026_many_wolfsbane",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 })}`
|
||||
: ""}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user