Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-17 02:56:44 +00:00
co-authored by renee-png
parent ca5b7ea01a
commit 9536ac8e02
6 changed files with 750 additions and 7 deletions
+182
View File
@@ -0,0 +1,182 @@
import { useEffect, useState, useCallback } from "react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Plus, Calendar as CalIcon, Workflow } from "lucide-react";
import { format, isBefore, startOfDay } from "date-fns";
import { TaskDetailPanel } from "@/components/tasks/task-detail-panel";
import { NewTaskDialog } from "@/components/tasks/new-task-dialog";
import { ApplyWorkflowDialog } from "@/components/tasks/apply-workflow-dialog";
import { cn } from "@/lib/utils";
interface TaskRow {
id: string;
title: string;
status: "incomplete" | "complete";
priority: "low" | "normal" | "high" | "urgent";
due_date: string | null;
parent_task_id: string | null;
}
interface AssigneeRow {
task_id: string;
user_id: string;
}
interface Profile {
id: string;
full_name: string;
email: 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",
};
export function CaseTasksTab({ caseId }: { caseId: string }) {
const { user } = useAuth();
const [tasks, setTasks] = useState<TaskRow[]>([]);
const [assignees, setAssignees] = useState<AssigneeRow[]>([]);
const [profiles, setProfiles] = useState<Profile[]>([]);
const [openTaskId, setOpenTaskId] = useState<string | null>(null);
const [newOpen, setNewOpen] = useState(false);
const [wfOpen, setWfOpen] = useState(false);
const load = useCallback(async () => {
const [t, a, p] = await Promise.all([
supabase
.from("tasks")
.select("id, title, status, priority, due_date, parent_task_id")
.eq("case_id", caseId)
.is("parent_task_id", null)
.order("due_date", { ascending: true, nullsFirst: false }),
supabase
.from("task_assignees")
.select("task_id, user_id, tasks!inner(case_id)")
.eq("tasks.case_id", caseId),
supabase.from("profiles").select("id, full_name, email"),
]);
setTasks((t.data ?? []) as TaskRow[]);
setAssignees(((a.data ?? []) as any[]).map((r) => ({ task_id: r.task_id, user_id: r.user_id })));
setProfiles((p.data ?? []) as Profile[]);
}, [caseId]);
useEffect(() => {
load();
const ch = supabase
.channel(`case-tasks-${caseId}`)
.on("postgres_changes", { event: "*", schema: "public", table: "tasks", filter: `case_id=eq.${caseId}` }, () => load())
.on("postgres_changes", { event: "*", schema: "public", table: "task_assignees" }, () => load())
.subscribe();
return () => {
supabase.removeChannel(ch);
};
}, [caseId, load]);
const profilesById = Object.fromEntries(profiles.map((p) => [p.id, p]));
const assigneesByTask: Record<string, string[]> = {};
for (const a of assignees) (assigneesByTask[a.task_id] = assigneesByTask[a.task_id] || []).push(a.user_id);
const toggle = 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);
};
const today = startOfDay(new Date());
const incomplete = tasks.filter((t) => t.status === "incomplete");
const complete = tasks.filter((t) => t.status === "complete");
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{incomplete.length} open · {complete.length} done
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setWfOpen(true)}>
<Workflow className="h-3.5 w-3.5 mr-1.5" /> Apply workflow
</Button>
<Button size="sm" onClick={() => setNewOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1.5" /> New task
</Button>
</div>
</div>
<div className="rounded-md border bg-card divide-y">
{tasks.length === 0 && (
<div className="p-8 text-center text-sm text-muted-foreground">
No tasks for this case yet.
</div>
)}
{tasks.map((t) => {
const ids = assigneesByTask[t.id] || [];
const overdue = t.due_date && t.status === "incomplete" && isBefore(startOfDay(new Date(t.due_date)), today);
return (
<div
key={t.id}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-accent/40 cursor-pointer"
onClick={() => setOpenTaskId(t.id)}
>
<Checkbox
checked={t.status === "complete"}
onCheckedChange={(v) => toggle(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>
{t.due_date && (
<Badge variant={overdue ? "destructive" : "outline"} className="gap-1">
<CalIcon className="h-3 w-3" />
{format(new Date(t.due_date), "MMM d")}
</Badge>
)}
<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>
);
})}
</div>
</div>
);
})}
</div>
<NewTaskDialog
open={newOpen}
onOpenChange={setNewOpen}
defaultCaseId={caseId}
onCreated={(id) => setOpenTaskId(id)}
/>
<ApplyWorkflowDialog open={wfOpen} onOpenChange={setWfOpen} caseId={caseId} onApplied={load} />
<TaskDetailPanel
taskId={openTaskId}
open={!!openTaskId}
onOpenChange={(v) => !v && setOpenTaskId(null)}
onChanged={load}
/>
</div>
);
}