Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
385 lines
15 KiB
TypeScript
385 lines
15 KiB
TypeScript
import { createFileRoute, useSearch } from "@tanstack/react-router";
|
|
import { useEffect, useMemo, useState, useCallback } from "react";
|
|
import { supabase } from "@/integrations/supabase/client";
|
|
import { useAuth } from "@/lib/auth";
|
|
import { AppShell, PageContainer, PageHeader } from "@/components/app-shell";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Plus, Search, Calendar as CalIcon, AlertCircle } from "lucide-react";
|
|
import { format, isBefore, startOfDay, isSameDay, addDays, differenceInCalendarDays } from "date-fns";
|
|
import { TaskDetailPanel } from "@/components/tasks/task-detail-panel";
|
|
import { NewTaskDialog } from "@/components/tasks/new-task-dialog";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export const Route = createFileRoute("/tasks/")({
|
|
component: TasksPage,
|
|
validateSearch: (search: Record<string, unknown>) => ({
|
|
task: typeof search.task === "string" ? search.task : undefined,
|
|
}),
|
|
});
|
|
|
|
interface TaskRow {
|
|
id: string;
|
|
title: string;
|
|
status: "incomplete" | "complete";
|
|
priority: "low" | "normal" | "high" | "urgent";
|
|
due_date: string | null;
|
|
case_id: string | null;
|
|
parent_task_id: string | null;
|
|
created_by: string | null;
|
|
}
|
|
interface AssigneeRow {
|
|
task_id: string;
|
|
user_id: string;
|
|
}
|
|
interface Profile {
|
|
id: string;
|
|
full_name: string;
|
|
email: string;
|
|
}
|
|
interface CaseRow {
|
|
id: string;
|
|
case_number: string;
|
|
title: string;
|
|
}
|
|
|
|
function initials(name: string) {
|
|
return name.split(" ").map((p) => p[0]).filter(Boolean).slice(0, 2).join("").toUpperCase();
|
|
}
|
|
|
|
const PRIORITY_DOT: Record<string, string> = {
|
|
urgent: "bg-red-500",
|
|
high: "bg-orange-500",
|
|
normal: "bg-blue-500",
|
|
low: "bg-muted-foreground/40",
|
|
};
|
|
|
|
function TasksPage() {
|
|
const { user } = useAuth();
|
|
const search = useSearch({ from: "/tasks/" });
|
|
const [tasks, setTasks] = useState<TaskRow[]>([]);
|
|
const [assignees, setAssignees] = useState<AssigneeRow[]>([]);
|
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
|
const [cases, setCases] = useState<CaseRow[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const [filterAssignee, setFilterAssignee] = useState<string>("me");
|
|
const [filterStatus, setFilterStatus] = useState<"incomplete" | "complete" | "all">("incomplete");
|
|
const [showOnlyMine, setShowOnlyMine] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
|
|
const [newOpen, setNewOpen] = useState(false);
|
|
const [openTaskId, setOpenTaskId] = useState<string | null>(search.task ?? null);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
const [t, a, p, c] = await Promise.all([
|
|
supabase
|
|
.from("tasks")
|
|
.select("id, title, status, priority, due_date, case_id, parent_task_id, created_by")
|
|
.is("parent_task_id", null)
|
|
.order("due_date", { ascending: true, nullsFirst: false }),
|
|
supabase.from("task_assignees").select("task_id, user_id"),
|
|
supabase.from("profiles").select("id, full_name, email"),
|
|
supabase.from("cases").select("id, case_number, title").is("archived_at", null),
|
|
]);
|
|
setTasks((t.data ?? []) as TaskRow[]);
|
|
setAssignees((a.data ?? []) as AssigneeRow[]);
|
|
setProfiles((p.data ?? []) as Profile[]);
|
|
setCases((c.data ?? []) as CaseRow[]);
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
const ch = supabase
|
|
.channel(`tasks-list-${Math.random().toString(36).slice(2)}`)
|
|
.on("postgres_changes", { event: "*", schema: "public", table: "tasks" }, () => load())
|
|
.on("postgres_changes", { event: "*", schema: "public", table: "task_assignees" }, () => load())
|
|
.subscribe();
|
|
return () => {
|
|
supabase.removeChannel(ch);
|
|
};
|
|
}, [load]);
|
|
|
|
useEffect(() => {
|
|
if (search.task) setOpenTaskId(search.task);
|
|
}, [search.task]);
|
|
|
|
const profilesById = useMemo(() => Object.fromEntries(profiles.map((p) => [p.id, p])), [profiles]);
|
|
const casesById = useMemo(() => Object.fromEntries(cases.map((c) => [c.id, c])), [cases]);
|
|
const assigneesByTask = useMemo(() => {
|
|
const map: Record<string, string[]> = {};
|
|
for (const a of assignees) {
|
|
(map[a.task_id] = map[a.task_id] || []).push(a.user_id);
|
|
}
|
|
return map;
|
|
}, [assignees]);
|
|
|
|
const filtered = useMemo(() => {
|
|
return tasks.filter((t) => {
|
|
if (filterStatus !== "all" && t.status !== filterStatus) return false;
|
|
if (filterAssignee === "me" && user) {
|
|
if (!(assigneesByTask[t.id] || []).includes(user.id)) return false;
|
|
} else if (filterAssignee !== "me" && filterAssignee !== "all") {
|
|
if (!(assigneesByTask[t.id] || []).includes(filterAssignee)) return false;
|
|
}
|
|
if (showOnlyMine && user && t.created_by !== user.id) return false;
|
|
if (query && !t.title.toLowerCase().includes(query.toLowerCase())) return false;
|
|
return true;
|
|
});
|
|
}, [tasks, filterStatus, filterAssignee, showOnlyMine, query, user, assigneesByTask]);
|
|
|
|
const grouped = useMemo(() => {
|
|
const today = startOfDay(new Date());
|
|
const tomorrow = addDays(today, 1);
|
|
const groups: Record<string, TaskRow[]> = {
|
|
Overdue: [],
|
|
Today: [],
|
|
Tomorrow: [],
|
|
"This week": [],
|
|
Later: [],
|
|
"No due date": [],
|
|
Completed: [],
|
|
};
|
|
for (const t of filtered) {
|
|
if (t.status === "complete") {
|
|
groups.Completed.push(t);
|
|
continue;
|
|
}
|
|
if (!t.due_date) {
|
|
groups["No due date"].push(t);
|
|
continue;
|
|
}
|
|
const d = startOfDay(new Date(t.due_date));
|
|
if (isBefore(d, today)) groups.Overdue.push(t);
|
|
else if (isSameDay(d, today)) groups.Today.push(t);
|
|
else if (isSameDay(d, tomorrow)) groups.Tomorrow.push(t);
|
|
else if (isBefore(d, addDays(today, 7))) groups["This week"].push(t);
|
|
else groups.Later.push(t);
|
|
}
|
|
return groups;
|
|
}, [filtered]);
|
|
|
|
const toggleComplete = async (t: TaskRow, checked: boolean) => {
|
|
if (!user) return;
|
|
await supabase
|
|
.from("tasks")
|
|
.update({
|
|
status: checked ? "complete" : "incomplete",
|
|
completed_by: checked ? user.id : null,
|
|
completed_at: checked ? (new Date().toISOString() as any) : null,
|
|
})
|
|
.eq("id", t.id);
|
|
await supabase.from("task_history").insert({
|
|
task_id: t.id,
|
|
actor_id: user.id,
|
|
event: checked ? "completed" : "reopened",
|
|
detail: {},
|
|
});
|
|
};
|
|
|
|
const overdueCount = grouped.Overdue.length;
|
|
|
|
return (
|
|
<AppShell>
|
|
<PageContainer>
|
|
<PageHeader
|
|
title="Tasks"
|
|
description="Workflow tasks, due dates, assignments, and comments."
|
|
actions={
|
|
<Button onClick={() => setNewOpen(true)}>
|
|
<Plus className="h-4 w-4 mr-1.5" /> New task
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<div className="flex flex-wrap items-end gap-3 mb-4">
|
|
<div className="flex-1 min-w-[200px]">
|
|
<Label className="text-xs">Search</Label>
|
|
<div className="relative">
|
|
<Search className="h-3.5 w-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Filter tasks…"
|
|
className="pl-8"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Assigned To</Label>
|
|
<Select value={filterAssignee} onValueChange={setFilterAssignee}>
|
|
<SelectTrigger className="w-[180px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="me">Me</SelectItem>
|
|
<SelectItem value="all">Anyone</SelectItem>
|
|
{profiles.map((p) => (
|
|
<SelectItem key={p.id} value={p.id}>{p.full_name || p.email}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">Status</Label>
|
|
<Select value={filterStatus} onValueChange={(v) => setFilterStatus(v as any)}>
|
|
<SelectTrigger className="w-[160px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="incomplete">Incomplete</SelectItem>
|
|
<SelectItem value="complete">Complete</SelectItem>
|
|
<SelectItem value="all">All</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center gap-2 pb-1.5">
|
|
<Switch id="mine" checked={showOnlyMine} onCheckedChange={setShowOnlyMine} />
|
|
<Label htmlFor="mine" className="text-sm">Only created by me</Label>
|
|
</div>
|
|
</div>
|
|
|
|
{overdueCount > 0 && (
|
|
<div className="mb-4 flex items-center gap-2 text-sm text-destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
{overdueCount} overdue task{overdueCount === 1 ? "" : "s"}
|
|
</div>
|
|
)}
|
|
|
|
<div className="rounded-md border bg-card">
|
|
{loading ? (
|
|
<div className="p-6 text-sm text-muted-foreground">Loading…</div>
|
|
) : (
|
|
Object.entries(grouped).map(([group, items]) =>
|
|
items.length === 0 ? null : (
|
|
<div key={group}>
|
|
<div
|
|
className={cn(
|
|
"px-4 py-2 text-xs font-semibold uppercase tracking-wider",
|
|
group === "Overdue"
|
|
? "bg-destructive/10 text-destructive"
|
|
: "bg-muted/40 text-muted-foreground",
|
|
)}
|
|
>
|
|
{group} <span className="text-muted-foreground ml-1">{items.length}</span>
|
|
</div>
|
|
{items.map((t) => {
|
|
const ids = assigneesByTask[t.id] || [];
|
|
const c = t.case_id ? casesById[t.case_id] : null;
|
|
const assigneeNames = ids
|
|
.map((uid) => profilesById[uid])
|
|
.filter(Boolean)
|
|
.map((p) => p.full_name || p.email);
|
|
let countdown: { label: string; tone: string } | null = null;
|
|
if (t.due_date && t.status !== "complete") {
|
|
const days = differenceInCalendarDays(startOfDay(new Date(t.due_date)), startOfDay(new Date()));
|
|
if (days < 0)
|
|
countdown = {
|
|
label: `${Math.abs(days)}d overdue`,
|
|
tone: "text-destructive",
|
|
};
|
|
else if (days === 0) countdown = { label: "Due today", tone: "text-orange-600" };
|
|
else if (days === 1) countdown = { label: "Due tomorrow", tone: "text-orange-600" };
|
|
else countdown = { label: `in ${days}d`, tone: "text-muted-foreground" };
|
|
}
|
|
return (
|
|
<div
|
|
key={t.id}
|
|
className="flex items-center gap-3 px-4 py-2.5 border-t hover:bg-accent/40 cursor-pointer"
|
|
onClick={() => setOpenTaskId(t.id)}
|
|
>
|
|
<Checkbox
|
|
checked={t.status === "complete"}
|
|
onCheckedChange={(v) => toggleComplete(t, !!v)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
<div className={cn("h-1.5 w-1.5 rounded-full", PRIORITY_DOT[t.priority])} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className={cn("text-sm", t.status === "complete" && "line-through text-muted-foreground")}>
|
|
{t.title}
|
|
</div>
|
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground">
|
|
{c && (
|
|
<span className="truncate">
|
|
{c.case_number} — {c.title}
|
|
</span>
|
|
)}
|
|
{assigneeNames.length > 0 && (
|
|
<span className="truncate">
|
|
Assigned: {assigneeNames.slice(0, 2).join(", ")}
|
|
{assigneeNames.length > 2 ? ` +${assigneeNames.length - 2}` : ""}
|
|
</span>
|
|
)}
|
|
{!assigneeNames.length && <span className="italic">Unassigned</span>}
|
|
</div>
|
|
</div>
|
|
{t.due_date && (
|
|
<div className="text-xs text-right shrink-0">
|
|
<div className="text-muted-foreground flex items-center gap-1 justify-end">
|
|
<CalIcon className="h-3 w-3" />
|
|
{format(new Date(t.due_date), "MMM d, yyyy")}
|
|
</div>
|
|
{countdown && (
|
|
<div className={cn("text-[11px] font-medium", countdown.tone)}>
|
|
{countdown.label}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex -space-x-1.5">
|
|
{ids.slice(0, 3).map((uid) => {
|
|
const p = profilesById[uid];
|
|
return (
|
|
<Avatar key={uid} className="h-6 w-6 border-2 border-background">
|
|
<AvatarFallback className="text-[9px]">
|
|
{p ? initials(p.full_name || p.email) : "?"}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
);
|
|
})}
|
|
{ids.length > 3 && (
|
|
<div className="h-6 w-6 rounded-full bg-muted border-2 border-background text-[9px] flex items-center justify-center">
|
|
+{ids.length - 3}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
),
|
|
)
|
|
)}
|
|
{!loading && filtered.length === 0 && (
|
|
<div className="p-12 text-center text-sm text-muted-foreground">
|
|
No tasks match these filters.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PageContainer>
|
|
|
|
<NewTaskDialog open={newOpen} onOpenChange={setNewOpen} onCreated={(id) => setOpenTaskId(id)} />
|
|
<TaskDetailPanel
|
|
taskId={openTaskId}
|
|
open={!!openTaskId}
|
|
onOpenChange={(v) => !v && setOpenTaskId(null)}
|
|
onChanged={load}
|
|
/>
|
|
</AppShell>
|
|
);
|
|
}
|