Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
f524dc8fd1
commit
ca5b7ea01a
@@ -0,0 +1,260 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { CalendarIcon, X, UserPlus, Loader2 } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { toast } from "sonner";
|
||||
import { createNotifications } from "@/lib/notifications";
|
||||
|
||||
interface CaseOption {
|
||||
id: string;
|
||||
case_number: string;
|
||||
title: 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();
|
||||
}
|
||||
|
||||
export function NewTaskDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultCaseId,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
defaultCaseId?: string | null;
|
||||
onCreated?: (taskId: string) => void;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [priority, setPriority] = useState<"low" | "normal" | "high" | "urgent">("normal");
|
||||
const [dueDate, setDueDate] = useState<Date | undefined>();
|
||||
const [caseId, setCaseId] = useState<string | null>(defaultCaseId ?? null);
|
||||
const [assigneeIds, setAssigneeIds] = useState<string[]>([]);
|
||||
const [cases, setCases] = useState<CaseOption[]>([]);
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCaseId(defaultCaseId ?? null);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setPriority("normal");
|
||||
setDueDate(undefined);
|
||||
setAssigneeIds(user ? [user.id] : []);
|
||||
(async () => {
|
||||
const [c, p] = await Promise.all([
|
||||
supabase.from("cases").select("id, case_number, title").order("case_number"),
|
||||
supabase.from("profiles").select("id, full_name, email").order("full_name"),
|
||||
]);
|
||||
setCases((c.data ?? []) as CaseOption[]);
|
||||
setProfiles((p.data ?? []) as Profile[]);
|
||||
})();
|
||||
}, [open, defaultCaseId, user]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!user || !title.trim()) return;
|
||||
setSubmitting(true);
|
||||
const { data, error } = await supabase
|
||||
.from("tasks")
|
||||
.insert({
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
priority,
|
||||
due_date: dueDate ? format(dueDate, "yyyy-MM-dd") : null,
|
||||
case_id: caseId,
|
||||
created_by: user.id,
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
if (error || !data) {
|
||||
setSubmitting(false);
|
||||
toast.error(error?.message || "Failed to create task");
|
||||
return;
|
||||
}
|
||||
if (assigneeIds.length) {
|
||||
await supabase.from("task_assignees").insert(
|
||||
assigneeIds.map((uid) => ({ task_id: data.id, user_id: uid, assigned_by: user.id })),
|
||||
);
|
||||
const others = assigneeIds.filter((uid) => uid !== user.id);
|
||||
if (others.length) {
|
||||
await createNotifications(
|
||||
others.map((uid) => ({
|
||||
user_id: uid,
|
||||
kind: "task_assigned" as const,
|
||||
title: `Assigned: ${title.trim()}`,
|
||||
link: `/tasks?task=${data.id}`,
|
||||
task_id: data.id,
|
||||
case_id: caseId,
|
||||
created_by: user.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
await supabase.from("task_history").insert({
|
||||
task_id: data.id,
|
||||
actor_id: user.id,
|
||||
event: "created",
|
||||
detail: { title: title.trim() },
|
||||
});
|
||||
setSubmitting(false);
|
||||
toast.success("Task created");
|
||||
onCreated?.(data.id);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">Title</Label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">Priority</Label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as any)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="normal">Normal</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="urgent">Urgent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Due date</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start font-normal">
|
||||
<CalendarIcon className="h-3.5 w-3.5 mr-2" />
|
||||
{dueDate ? format(dueDate, "MM/dd/yyyy") : "Pick a date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Calendar mode="single" selected={dueDate} onSelect={setDueDate} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Case</Label>
|
||||
<Select value={caseId ?? "_none"} onValueChange={(v) => setCaseId(v === "_none" ? null : v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No case" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none">No case</SelectItem>
|
||||
{cases.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.case_number} — {c.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Assignees</Label>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{assigneeIds.map((uid) => {
|
||||
const p = profiles.find((x) => x.id === uid);
|
||||
if (!p) return null;
|
||||
return (
|
||||
<Badge key={uid} variant="secondary" className="gap-1.5 pr-1 py-1">
|
||||
<Avatar className="h-4 w-4">
|
||||
<AvatarFallback className="text-[8px]">{initials(p.full_name || p.email)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs">{p.full_name || p.email}</span>
|
||||
<button onClick={() => setAssigneeIds((v) => v.filter((x) => x !== uid))}>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
<Popover open={showPicker} onOpenChange={setShowPicker}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7">
|
||||
<UserPlus className="h-3.5 w-3.5 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="p-1 w-64">
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{profiles
|
||||
.filter((p) => !assigneeIds.includes(p.id))
|
||||
.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
setAssigneeIds((v) => [...v, p.id]);
|
||||
setShowPicker(false);
|
||||
}}
|
||||
className="w-full text-left px-2 py-1.5 rounded hover:bg-accent text-sm"
|
||||
>
|
||||
{p.full_name || p.email}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={!title.trim() || submitting}>
|
||||
{submitting && <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Plus, Trash2, X, CalendarIcon, UserPlus } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import type { Database } from "@/integrations/supabase/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createNotifications } from "@/lib/notifications";
|
||||
|
||||
type Priority = Database["public"]["Enums"]["task_priority"];
|
||||
|
||||
interface TaskRow {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: "incomplete" | "complete";
|
||||
priority: Priority;
|
||||
due_date: string | null;
|
||||
case_id: string | null;
|
||||
parent_task_id: string | null;
|
||||
created_by: string | null;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface CommentRow {
|
||||
id: string;
|
||||
task_id: string;
|
||||
author_id: string;
|
||||
body: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface HistoryRow {
|
||||
id: string;
|
||||
task_id: string;
|
||||
actor_id: string | null;
|
||||
event: string;
|
||||
detail: any;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name.split(" ").map((p) => p[0]).filter(Boolean).slice(0, 2).join("").toUpperCase();
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS: Record<Priority, string> = {
|
||||
low: "bg-muted text-muted-foreground",
|
||||
normal: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-200",
|
||||
high: "bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-200",
|
||||
urgent: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-200",
|
||||
};
|
||||
|
||||
export function TaskDetailPanel({
|
||||
taskId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onChanged,
|
||||
}: {
|
||||
taskId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const { user } = useAuth();
|
||||
const [task, setTask] = useState<TaskRow | null>(null);
|
||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||
const [allProfiles, setAllProfiles] = useState<Profile[]>([]);
|
||||
const [assigneeIds, setAssigneeIds] = useState<string[]>([]);
|
||||
const [subtasks, setSubtasks] = useState<TaskRow[]>([]);
|
||||
const [comments, setComments] = useState<CommentRow[]>([]);
|
||||
const [history, setHistory] = useState<HistoryRow[]>([]);
|
||||
const [caseTitle, setCaseTitle] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newSubtask, setNewSubtask] = useState("");
|
||||
const [commentBody, setCommentBody] = useState("");
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [editDescription, setEditDescription] = useState("");
|
||||
const [showAssignPicker, setShowAssignPicker] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!taskId) return;
|
||||
setLoading(true);
|
||||
const [t, a, s, c, h, pr] = await Promise.all([
|
||||
supabase.from("tasks").select("*").eq("id", taskId).maybeSingle(),
|
||||
supabase.from("task_assignees").select("user_id").eq("task_id", taskId),
|
||||
supabase.from("tasks").select("*").eq("parent_task_id", taskId).order("sort_order"),
|
||||
supabase.from("task_comments").select("*").eq("task_id", taskId).order("created_at"),
|
||||
supabase.from("task_history").select("*").eq("task_id", taskId).order("created_at", { ascending: false }).limit(50),
|
||||
supabase.from("profiles").select("id, full_name, email").order("full_name"),
|
||||
]);
|
||||
if (t.data) {
|
||||
setTask(t.data as TaskRow);
|
||||
setEditTitle(t.data.title);
|
||||
setEditDescription(t.data.description || "");
|
||||
if ((t.data as TaskRow).case_id) {
|
||||
const { data: cd } = await supabase
|
||||
.from("cases")
|
||||
.select("title, case_number")
|
||||
.eq("id", (t.data as TaskRow).case_id!)
|
||||
.maybeSingle();
|
||||
setCaseTitle(cd ? `${cd.case_number} — ${cd.title}` : null);
|
||||
} else {
|
||||
setCaseTitle(null);
|
||||
}
|
||||
}
|
||||
setAssigneeIds((a.data ?? []).map((r) => r.user_id));
|
||||
setSubtasks((s.data ?? []) as TaskRow[]);
|
||||
setComments((c.data ?? []) as CommentRow[]);
|
||||
setHistory((h.data ?? []) as HistoryRow[]);
|
||||
setAllProfiles((pr.data ?? []) as Profile[]);
|
||||
setProfiles((pr.data ?? []) as Profile[]);
|
||||
setLoading(false);
|
||||
}, [taskId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && taskId) load();
|
||||
}, [open, taskId, load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !taskId) return;
|
||||
const ch = supabase
|
||||
.channel(`task-${taskId}`)
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "task_comments", filter: `task_id=eq.${taskId}` }, () => load())
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "task_assignees", filter: `task_id=eq.${taskId}` }, () => load())
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "tasks", filter: `id=eq.${taskId}` }, () => load())
|
||||
.subscribe();
|
||||
return () => {
|
||||
supabase.removeChannel(ch);
|
||||
};
|
||||
}, [open, taskId, load]);
|
||||
|
||||
const profilesById = Object.fromEntries(allProfiles.map((p) => [p.id, p] as const));
|
||||
|
||||
const updateTask = async (patch: Partial<TaskRow>, event?: string, detail?: any) => {
|
||||
if (!task) return;
|
||||
const { error } = await supabase.from("tasks").update(patch).eq("id", task.id);
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
if (event && user) {
|
||||
await supabase.from("task_history").insert({
|
||||
task_id: task.id,
|
||||
actor_id: user.id,
|
||||
event,
|
||||
detail: detail ?? {},
|
||||
});
|
||||
}
|
||||
onChanged?.();
|
||||
};
|
||||
|
||||
const toggleComplete = async (checked: boolean) => {
|
||||
if (!task || !user) return;
|
||||
await updateTask(
|
||||
{
|
||||
status: checked ? "complete" : "incomplete",
|
||||
completed_by: checked ? user.id : null,
|
||||
completed_at: checked ? (new Date().toISOString() as any) : null,
|
||||
},
|
||||
checked ? "completed" : "reopened",
|
||||
);
|
||||
};
|
||||
|
||||
const saveTitle = async () => {
|
||||
if (!task || editTitle === task.title) return;
|
||||
await updateTask({ title: editTitle }, "renamed", { from: task.title, to: editTitle });
|
||||
};
|
||||
|
||||
const saveDescription = async () => {
|
||||
if (!task || editDescription === (task.description || "")) return;
|
||||
await updateTask({ description: editDescription || null }, "description_updated");
|
||||
};
|
||||
|
||||
const setPriority = async (p: Priority) => {
|
||||
if (!task) return;
|
||||
await updateTask({ priority: p }, "priority_changed", { to: p });
|
||||
};
|
||||
|
||||
const setDueDate = async (d: Date | undefined) => {
|
||||
if (!task) return;
|
||||
const v = d ? format(d, "yyyy-MM-dd") : null;
|
||||
await updateTask({ due_date: v }, "due_date_changed", { to: v });
|
||||
};
|
||||
|
||||
const addAssignee = async (uid: string) => {
|
||||
if (!task || !user) return;
|
||||
if (assigneeIds.includes(uid)) return;
|
||||
const { error } = await supabase
|
||||
.from("task_assignees")
|
||||
.insert({ task_id: task.id, user_id: uid, assigned_by: user.id });
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
await supabase.from("task_history").insert({
|
||||
task_id: task.id,
|
||||
actor_id: user.id,
|
||||
event: "assignee_added",
|
||||
detail: { user_id: uid },
|
||||
});
|
||||
if (uid !== user.id) {
|
||||
await createNotifications([
|
||||
{
|
||||
user_id: uid,
|
||||
kind: "task_assigned",
|
||||
title: `Assigned: ${task.title}`,
|
||||
body: caseTitle || undefined,
|
||||
link: `/tasks?task=${task.id}`,
|
||||
task_id: task.id,
|
||||
case_id: task.case_id,
|
||||
created_by: user.id,
|
||||
},
|
||||
]);
|
||||
}
|
||||
setShowAssignPicker(false);
|
||||
};
|
||||
|
||||
const removeAssignee = async (uid: string) => {
|
||||
if (!task || !user) return;
|
||||
const { error } = await supabase
|
||||
.from("task_assignees")
|
||||
.delete()
|
||||
.eq("task_id", task.id)
|
||||
.eq("user_id", uid);
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
await supabase.from("task_history").insert({
|
||||
task_id: task.id,
|
||||
actor_id: user.id,
|
||||
event: "assignee_removed",
|
||||
detail: { user_id: uid },
|
||||
});
|
||||
};
|
||||
|
||||
const addSubtask = async () => {
|
||||
if (!task || !user || !newSubtask.trim()) return;
|
||||
const { error } = await supabase.from("tasks").insert({
|
||||
title: newSubtask.trim(),
|
||||
parent_task_id: task.id,
|
||||
case_id: task.case_id,
|
||||
created_by: user.id,
|
||||
sort_order: subtasks.length,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
await supabase.from("task_history").insert({
|
||||
task_id: task.id,
|
||||
actor_id: user.id,
|
||||
event: "subtask_added",
|
||||
detail: { title: newSubtask.trim() },
|
||||
});
|
||||
setNewSubtask("");
|
||||
load();
|
||||
};
|
||||
|
||||
const toggleSubtask = async (st: TaskRow) => {
|
||||
const newStatus = st.status === "complete" ? "incomplete" : "complete";
|
||||
await supabase
|
||||
.from("tasks")
|
||||
.update({
|
||||
status: newStatus,
|
||||
completed_by: newStatus === "complete" ? user!.id : null,
|
||||
completed_at: newStatus === "complete" ? (new Date().toISOString() as any) : null,
|
||||
})
|
||||
.eq("id", st.id);
|
||||
load();
|
||||
};
|
||||
|
||||
const removeSubtask = async (id: string) => {
|
||||
await supabase.from("tasks").delete().eq("id", id);
|
||||
load();
|
||||
};
|
||||
|
||||
const addComment = async () => {
|
||||
if (!task || !user || !commentBody.trim()) return;
|
||||
const { error } = await supabase.from("task_comments").insert({
|
||||
task_id: task.id,
|
||||
author_id: user.id,
|
||||
body: commentBody.trim(),
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
// Notify assignees + creator (excluding self)
|
||||
const recipients = new Set(assigneeIds);
|
||||
if (task.created_by) recipients.add(task.created_by);
|
||||
recipients.delete(user.id);
|
||||
if (recipients.size > 0) {
|
||||
await createNotifications(
|
||||
Array.from(recipients).map((uid) => ({
|
||||
user_id: uid,
|
||||
kind: "task_comment" as const,
|
||||
title: `New comment on: ${task.title}`,
|
||||
body: commentBody.slice(0, 120),
|
||||
link: `/tasks?task=${task.id}`,
|
||||
task_id: task.id,
|
||||
case_id: task.case_id,
|
||||
created_by: user.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
setCommentBody("");
|
||||
};
|
||||
|
||||
const deleteComment = async (id: string) => {
|
||||
await supabase.from("task_comments").delete().eq("id", id);
|
||||
};
|
||||
|
||||
if (!task && !loading) return null;
|
||||
|
||||
const completedSubtasks = subtasks.filter((s) => s.status === "complete").length;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto p-0">
|
||||
{loading || !task ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SheetHeader className="px-6 pt-6 pb-3 border-b">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={task.status === "complete"}
|
||||
onCheckedChange={(v) => toggleComplete(!!v)}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Input
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onBlur={saveTitle}
|
||||
className="text-lg font-semibold border-0 px-0 shadow-none focus-visible:ring-0 h-auto"
|
||||
/>
|
||||
{caseTitle && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Case: <span className="text-foreground">{caseTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SheetTitle className="sr-only">{task.title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="px-6 py-4 grid grid-cols-3 gap-3 border-b">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Status</div>
|
||||
<Badge variant={task.status === "complete" ? "default" : "secondary"}>
|
||||
{task.status === "complete" ? "Complete" : "Incomplete"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Due date</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-sm font-normal">
|
||||
<CalendarIcon className="h-3.5 w-3.5 mr-1.5" />
|
||||
{task.due_date ? format(new Date(task.due_date), "MM/dd/yyyy") : "Set date"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={task.due_date ? new Date(task.due_date) : undefined}
|
||||
onSelect={setDueDate}
|
||||
/>
|
||||
{task.due_date && (
|
||||
<div className="p-2 border-t">
|
||||
<Button variant="ghost" size="sm" className="w-full" onClick={() => setDueDate(undefined)}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Priority</div>
|
||||
<Select value={task.priority} onValueChange={(v) => setPriority(v as Priority)}>
|
||||
<SelectTrigger className="h-7 w-full text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="normal">Normal</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="urgent">Urgent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-b">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">Assignees</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{assigneeIds.map((uid) => {
|
||||
const p = profilesById[uid];
|
||||
if (!p) return null;
|
||||
return (
|
||||
<Badge key={uid} variant="secondary" className="gap-1.5 pr-1 py-1">
|
||||
<Avatar className="h-4 w-4">
|
||||
<AvatarFallback className="text-[8px]">{initials(p.full_name || p.email)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs">{p.full_name || p.email}</span>
|
||||
<button onClick={() => removeAssignee(uid)} className="ml-0.5 hover:text-destructive">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
<Popover open={showAssignPicker} onOpenChange={setShowAssignPicker}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7">
|
||||
<UserPlus className="h-3.5 w-3.5 mr-1" />
|
||||
Assign
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="p-1 w-64">
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{allProfiles
|
||||
.filter((p) => !assigneeIds.includes(p.id))
|
||||
.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => addAssignee(p.id)}
|
||||
className="w-full text-left px-2 py-1.5 rounded hover:bg-accent text-sm flex items-center gap-2"
|
||||
>
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="text-[10px]">{initials(p.full_name || p.email)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate">{p.full_name || p.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-b">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">Description</div>
|
||||
<Textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
onBlur={saveDescription}
|
||||
placeholder="Add a description…"
|
||||
rows={3}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-b">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
Subtasks {subtasks.length > 0 && `(${completedSubtasks}/${subtasks.length})`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{subtasks.map((st) => (
|
||||
<div key={st.id} className="flex items-center gap-2 group">
|
||||
<Checkbox
|
||||
checked={st.status === "complete"}
|
||||
onCheckedChange={() => toggleSubtask(st)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm flex-1",
|
||||
st.status === "complete" && "line-through text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{st.title}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeSubtask(st.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Input
|
||||
value={newSubtask}
|
||||
onChange={(e) => setNewSubtask(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addSubtask()}
|
||||
placeholder="Add subtask…"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button size="sm" onClick={addSubtask} disabled={!newSubtask.trim()}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="comments" className="px-6 py-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="comments">Comments ({comments.length})</TabsTrigger>
|
||||
<TabsTrigger value="history">History</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="comments" className="space-y-3 mt-3">
|
||||
{comments.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground italic">No comments yet.</div>
|
||||
)}
|
||||
{comments.map((c) => {
|
||||
const p = profilesById[c.author_id];
|
||||
const name = p?.full_name || p?.email || "Unknown";
|
||||
return (
|
||||
<div key={c.id} className="flex gap-2.5 group">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarFallback className="text-[10px]">{initials(name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-sm font-medium">{name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{new Date(c.created_at).toLocaleString()}
|
||||
</div>
|
||||
{c.author_id === user?.id && (
|
||||
<button
|
||||
onClick={() => deleteComment(c.id)}
|
||||
className="ml-auto opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-wrap mt-0.5">{c.body}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<Textarea
|
||||
value={commentBody}
|
||||
onChange={(e) => setCommentBody(e.target.value)}
|
||||
placeholder="Add a comment…"
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={addComment} disabled={!commentBody.trim()}>
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="history" className="mt-3 space-y-2">
|
||||
{history.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground italic">No activity yet.</div>
|
||||
)}
|
||||
{history.map((h) => {
|
||||
const p = h.actor_id ? profilesById[h.actor_id] : null;
|
||||
return (
|
||||
<div key={h.id} className="text-xs flex items-center gap-2 text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{p?.full_name || p?.email || "System"}</span>
|
||||
<span>{h.event.replace(/_/g, " ")}</span>
|
||||
<span className="ml-auto">{new Date(h.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user