diff --git a/src/lib/components/kanban/CardDetailModal.svelte b/src/lib/components/kanban/CardDetailModal.svelte index 5087d38..54a6bbe 100644 --- a/src/lib/components/kanban/CardDetailModal.svelte +++ b/src/lib/components/kanban/CardDetailModal.svelte @@ -13,6 +13,26 @@ position: number; } + interface Comment { + id: string; + card_id: string; + user_id: string; + content: string; + created_at: string; + profiles?: { full_name: string | null; email: string }; + } + + interface Member { + id: string; + user_id: string; + profiles: { + id: string; + full_name: string | null; + email: string; + avatar_url: string | null; + }; + } + interface Props { card: KanbanCard | null; isOpen: boolean; @@ -23,6 +43,7 @@ columnId?: string; userId?: string; onCreate?: (card: KanbanCard) => void; + members?: Member[]; } let { @@ -35,6 +56,7 @@ columnId, userId, onCreate, + members = [], }: Props = $props(); const supabase = getContext>("supabase"); @@ -42,20 +64,38 @@ let title = $state(""); let description = $state(""); let checklist = $state([]); + let comments = $state([]); let newItemTitle = $state(""); + let newComment = $state(""); + let assigneeId = $state(null); + let dueDate = $state(""); + let priority = $state("medium"); let isLoading = $state(false); let isSaving = $state(false); + let showAssigneePicker = $state(false); $effect(() => { if (isOpen) { if (mode === "edit" && card) { title = card.title; description = card.description ?? ""; + assigneeId = (card as any).assignee_id ?? null; + dueDate = (card as any).due_date + ? new Date((card as any).due_date) + .toISOString() + .split("T")[0] + : ""; + priority = (card as any).priority ?? "medium"; loadChecklist(); + loadComments(); } else if (mode === "create") { title = ""; description = ""; + assigneeId = null; + dueDate = ""; + priority = "medium"; checklist = []; + comments = []; } } }); @@ -65,7 +105,7 @@ isLoading = true; const { data } = await supabase - .from("checklist_items") + .from("kanban_checklist_items") .select("*") .eq("card_id", card.id) .order("position"); @@ -74,6 +114,27 @@ isLoading = false; } + async function loadComments() { + if (!card) return; + + const { data } = await supabase + .from("kanban_comments") + .select( + ` + id, + card_id, + user_id, + content, + created_at, + profiles:user_id (full_name, email) + `, + ) + .eq("card_id", card.id) + .order("created_at", { ascending: true }); + + comments = (data ?? []) as Comment[]; + } + async function handleSave() { if (mode === "create") { await handleCreate(); @@ -88,11 +149,21 @@ .update({ title, description: description || null, + assignee_id: assigneeId, + due_date: dueDate || null, + priority, }) .eq("id", card.id); if (!error) { - onUpdate({ ...card, title, description: description || null }); + onUpdate({ + ...card, + title, + description: description || null, + assignee_id: assigneeId, + due_date: dueDate || null, + priority, + } as KanbanCard); } isSaving = false; } @@ -133,7 +204,7 @@ const position = checklist.length; const { data, error } = await supabase - .from("checklist_items") + .from("kanban_checklist_items") .insert({ card_id: card.id, title: newItemTitle, @@ -149,9 +220,37 @@ } } + async function handleAddComment() { + if (!card || !newComment.trim() || !userId) return; + + const { data, error } = await supabase + .from("kanban_comments") + .insert({ + card_id: card.id, + user_id: userId, + content: newComment, + }) + .select( + ` + id, + card_id, + user_id, + content, + created_at, + profiles:user_id (full_name, email) + `, + ) + .single(); + + if (!error && data) { + comments = [...comments, data as Comment]; + newComment = ""; + } + } + async function toggleItem(item: ChecklistItem) { const { error } = await supabase - .from("checklist_items") + .from("kanban_checklist_items") .update({ completed: !item.completed }) .eq("id", item.id); @@ -164,7 +263,7 @@ async function deleteItem(itemId: string) { const { error } = await supabase - .from("checklist_items") + .from("kanban_checklist_items") .delete() .eq("id", itemId); @@ -173,6 +272,20 @@ } } + function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } + + function getAssignee(id: string | null) { + if (!id) return null; + return members.find((m) => m.user_id === id); + } + async function handleDelete() { if (!card || !confirm("Delete this card?")) return; @@ -207,6 +320,129 @@ rows={3} /> + +
+ +
+ + + {#if showAssigneePicker} +
+ + {#each members as member} + + {/each} +
+ {/if} +
+ + +
+ + +
+ + +
+ + +
+
+
diff --git a/supabase/migrations/012_task_comments.sql b/supabase/migrations/012_task_comments.sql new file mode 100644 index 0000000..73923c5 --- /dev/null +++ b/supabase/migrations/012_task_comments.sql @@ -0,0 +1,53 @@ +-- Task comments for kanban cards + +CREATE TABLE IF NOT EXISTS kanban_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + card_id UUID NOT NULL REFERENCES kanban_cards(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Enable RLS +ALTER TABLE kanban_comments ENABLE ROW LEVEL SECURITY; + +-- Comments inherit access from the card's board -> org +CREATE POLICY "Comments inherit card access" ON kanban_comments + FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM kanban_cards c + JOIN kanban_columns col ON c.column_id = col.id + JOIN kanban_boards b ON col.board_id = b.id + JOIN org_members m ON b.org_id = m.org_id + WHERE c.id = kanban_comments.card_id + AND m.user_id = auth.uid() + ) + ); + +-- Users can insert their own comments +CREATE POLICY "Users can insert own comments" ON kanban_comments + FOR INSERT WITH CHECK ( + user_id = auth.uid() AND + EXISTS ( + SELECT 1 FROM kanban_cards c + JOIN kanban_columns col ON c.column_id = col.id + JOIN kanban_boards b ON col.board_id = b.id + JOIN org_members m ON b.org_id = m.org_id + WHERE c.id = kanban_comments.card_id + AND m.user_id = auth.uid() + ) + ); + +-- Users can update their own comments +CREATE POLICY "Users can update own comments" ON kanban_comments + FOR UPDATE USING (user_id = auth.uid()); + +-- Users can delete their own comments +CREATE POLICY "Users can delete own comments" ON kanban_comments + FOR DELETE USING (user_id = auth.uid()); + +-- Index for faster lookups +CREATE INDEX IF NOT EXISTS idx_kanban_comments_card ON kanban_comments(card_id); +CREATE INDEX IF NOT EXISTS idx_kanban_comments_user ON kanban_comments(user_id); +CREATE INDEX IF NOT EXISTS idx_kanban_comments_created ON kanban_comments(created_at DESC);