Files
root-org/src/lib/components/kanban/CardDetailModal.svelte
2026-02-07 01:31:55 +02:00

875 lines
21 KiB
Svelte

<script lang="ts">
import { getContext, onDestroy } from "svelte";
import {
Modal,
Button,
Input,
Textarea,
Select,
AssigneePicker,
Icon,
} from "$lib/components/ui";
import type { KanbanCard } from "$lib/supabase/types";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
let isMounted = $state(true);
onDestroy(() => {
isMounted = false;
});
interface ChecklistItem {
id: string;
card_id: string;
title: string;
completed: boolean;
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 OrgTag {
id: string;
name: string;
color: string | null;
}
interface Props {
card: KanbanCard | null;
isOpen: boolean;
onClose: () => void;
onUpdate: (card: KanbanCard) => void;
onDelete: (cardId: string) => void;
mode?: "edit" | "create";
columnId?: string;
userId?: string;
orgId?: string;
onCreate?: (card: KanbanCard) => void;
members?: Member[];
}
let {
card,
isOpen,
onClose,
onUpdate,
onDelete,
mode = "edit",
columnId,
userId,
orgId,
onCreate,
members = [],
}: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
let title = $state("");
let description = $state("");
let checklist = $state<ChecklistItem[]>([]);
let comments = $state<Comment[]>([]);
let newItemTitle = $state("");
let newComment = $state("");
let assigneeId = $state<string | null>(null);
let dueDate = $state<string>("");
let priority = $state<string>("medium");
let isLoading = $state(false);
let isSaving = $state(false);
let showAssigneePicker = $state(false);
// Tags state
let orgTags = $state<OrgTag[]>([]);
let cardTagIds = $state<Set<string>>(new Set());
let newTagName = $state("");
let showTagInput = $state(false);
let editingTagId = $state<string | null>(null);
let editTagName = $state("");
let editTagColor = $state("");
let showTagManager = $state(false);
const TAG_COLORS = [
"#00A3E0",
"#33E000",
"#E03D00",
"#FFAB00",
"#A855F7",
"#EC4899",
"#6366F1",
];
$effect(() => {
if (isOpen) {
if (mode === "edit" && card) {
title = card.title;
description = card.description ?? "";
assigneeId = card.assignee_id ?? null;
dueDate = card.due_date
? new Date(card.due_date).toISOString().split("T")[0]
: "";
priority = card.priority ?? "medium";
loadChecklist();
loadComments();
loadTags();
} else if (mode === "create") {
title = "";
description = "";
assigneeId = null;
dueDate = "";
priority = "medium";
checklist = [];
comments = [];
cardTagIds = new Set();
loadOrgTags();
}
}
});
async function loadChecklist() {
if (!card || !isMounted) return;
isLoading = true;
const { data } = await supabase
.from("kanban_checklist_items")
.select("*")
.eq("card_id", card.id)
.order("position");
if (!isMounted) return;
checklist = (data ?? []) as ChecklistItem[];
isLoading = false;
}
async function loadComments() {
if (!card || !isMounted) 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 });
if (!isMounted) return;
comments = (data ?? []) as Comment[];
}
async function loadOrgTags() {
if (!orgId) return;
const { data } = await supabase
.from("tags")
.select("id, name, color")
.eq("org_id", orgId)
.order("name");
if (!isMounted) return;
orgTags = (data ?? []) as OrgTag[];
}
async function loadTags() {
await loadOrgTags();
if (!card) return;
const { data } = await supabase
.from("card_tags")
.select("tag_id")
.eq("card_id", card.id);
if (!isMounted) return;
cardTagIds = new Set((data ?? []).map((t) => t.tag_id));
}
async function toggleTag(tagId: string) {
if (!card) return;
if (cardTagIds.has(tagId)) {
await supabase
.from("card_tags")
.delete()
.eq("card_id", card.id)
.eq("tag_id", tagId);
cardTagIds.delete(tagId);
cardTagIds = new Set(cardTagIds);
} else {
await supabase
.from("card_tags")
.insert({ card_id: card.id, tag_id: tagId });
cardTagIds.add(tagId);
cardTagIds = new Set(cardTagIds);
}
}
async function createTag() {
if (!newTagName.trim() || !orgId) return;
const color = TAG_COLORS[orgTags.length % TAG_COLORS.length];
const { data: newTag, error } = await supabase
.from("tags")
.insert({ name: newTagName.trim(), org_id: orgId, color })
.select()
.single();
if (!error && newTag) {
orgTags = [...orgTags, newTag as OrgTag];
if (card) {
await supabase
.from("card_tags")
.insert({ card_id: card.id, tag_id: newTag.id });
cardTagIds.add(newTag.id);
cardTagIds = new Set(cardTagIds);
}
}
newTagName = "";
showTagInput = false;
}
function startEditTag(tag: OrgTag) {
editingTagId = tag.id;
editTagName = tag.name;
editTagColor = tag.color || TAG_COLORS[0];
}
async function saveEditTag() {
if (!editingTagId || !editTagName.trim()) return;
const { error } = await supabase
.from("tags")
.update({ name: editTagName.trim(), color: editTagColor })
.eq("id", editingTagId);
if (!error) {
orgTags = orgTags.map((t) =>
t.id === editingTagId
? { ...t, name: editTagName.trim(), color: editTagColor }
: t,
);
}
editingTagId = null;
}
async function deleteTag(tagId: string) {
if (!confirm("Delete this tag from the organization?")) return;
const { error } = await supabase.from("tags").delete().eq("id", tagId);
if (!error) {
orgTags = orgTags.filter((t) => t.id !== tagId);
cardTagIds.delete(tagId);
cardTagIds = new Set(cardTagIds);
}
}
async function handleSave() {
if (!isMounted) return;
if (mode === "create") {
await handleCreate();
return;
}
if (!card) return;
isSaving = true;
const { error } = await supabase
.from("kanban_cards")
.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,
assignee_id: assigneeId,
due_date: dueDate || null,
priority,
} as KanbanCard);
}
isSaving = false;
}
async function handleCreate() {
if (!title.trim() || !columnId || !userId) return;
isSaving = true;
const { data: column } = await supabase
.from("kanban_columns")
.select("cards:kanban_cards(count)")
.eq("id", columnId)
.single();
const cards = (column as Record<string, unknown> | null)?.cards as
| { count: number }[]
| undefined;
const position = cards?.[0]?.count ?? 0;
const { data: newCard, error } = await supabase
.from("kanban_cards")
.insert({
column_id: columnId,
title,
description: description || null,
priority: priority || null,
due_date: dueDate || null,
assignee_id: assigneeId || null,
position,
created_by: userId,
})
.select()
.single();
if (!error && newCard) {
// Persist checklist items added during creation
if (checklist.length > 0) {
await supabase.from("kanban_checklist_items").insert(
checklist.map((item, i) => ({
card_id: newCard.id,
title: item.title,
position: i,
completed: false,
})),
);
}
// Persist tags assigned during creation
if (cardTagIds.size > 0) {
await supabase.from("card_tags").insert(
[...cardTagIds].map((tagId) => ({
card_id: newCard.id,
tag_id: tagId,
})),
);
}
onCreate?.(newCard as KanbanCard);
onClose();
}
isSaving = false;
}
async function handleAddItem() {
if (!newItemTitle.trim()) return;
if (mode === "create") {
// In create mode, add items locally (no card ID yet)
checklist = [
...checklist,
{
id: `temp-${Date.now()}`,
card_id: "",
title: newItemTitle.trim(),
completed: false,
position: checklist.length,
},
];
newItemTitle = "";
return;
}
if (!card) return;
const position = checklist.length;
const { data, error } = await supabase
.from("kanban_checklist_items")
.insert({
card_id: card.id,
title: newItemTitle,
position,
completed: false,
})
.select()
.single();
if (!error && data) {
checklist = [...checklist, data as ChecklistItem];
newItemTitle = "";
}
}
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("kanban_checklist_items")
.update({ completed: !item.completed })
.eq("id", item.id);
if (!error) {
checklist = checklist.map((i) =>
i.id === item.id ? { ...i, completed: !i.completed } : i,
);
}
}
async function deleteItem(itemId: string) {
const { error } = await supabase
.from("kanban_checklist_items")
.delete()
.eq("id", itemId);
if (!error) {
checklist = checklist.filter((i) => i.id !== itemId);
}
}
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;
await supabase.from("kanban_cards").delete().eq("id", card.id);
onDelete(card.id);
onClose();
}
const completedCount = $derived(
checklist.filter((i) => i.completed).length,
);
const progress = $derived(
checklist.length > 0 ? (completedCount / checklist.length) * 100 : 0,
);
</script>
<Modal
{isOpen}
{onClose}
title={mode === "create" ? "Add Card" : "Card Details"}
size="lg"
>
{#if mode === "create" || card}
<div class="space-y-5">
<Input label="Title" bind:value={title} placeholder="Card title" />
<Textarea
label="Description"
bind:value={description}
placeholder="Add a more detailed description..."
rows={3}
/>
<!-- Tags -->
<div>
<div class="flex items-center justify-between mb-2">
<span class="px-3 font-bold font-body text-body text-white"
>Tags</span
>
<button
type="button"
class="text-xs text-light/40 hover:text-light transition-colors"
onclick={() => (showTagManager = !showTagManager)}
>
{showTagManager ? "Done" : "Manage"}
</button>
</div>
{#if showTagManager}
<!-- Tag Manager: edit/delete/create tags -->
<div class="space-y-2 mb-3 p-3 bg-background rounded-2xl">
{#each orgTags as tag}
<div class="flex items-center gap-2 group">
{#if editingTagId === tag.id}
<div class="flex items-center gap-2 flex-1">
<input
type="color"
class="w-6 h-6 rounded cursor-pointer border-0 bg-transparent"
bind:value={editTagColor}
/>
<input
type="text"
class="bg-dark border border-primary rounded-lg px-2 py-1 text-sm text-white flex-1 focus:outline-none"
bind:value={editTagName}
onkeydown={(e) => {
if (e.key === "Enter")
saveEditTag();
if (e.key === "Escape") {
editingTagId = null;
}
}}
/>
<button
type="button"
class="text-primary text-xs font-bold"
onclick={saveEditTag}>Save</button
>
<button
type="button"
class="text-light/40 text-xs"
onclick={() =>
(editingTagId = null)}
>Cancel</button
>
</div>
{:else}
<span
class="w-3 h-3 rounded-sm shrink-0"
style="background-color: {tag.color ||
'#00A3E0'}"
></span>
<span
class="text-sm text-light flex-1 truncate"
>{tag.name}</span
>
<button
type="button"
class="opacity-0 group-hover:opacity-100 p-0.5 text-light/40 hover:text-light transition-all"
onclick={() => startEditTag(tag)}
aria-label="Edit tag"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>edit</span
>
</button>
<button
type="button"
class="opacity-0 group-hover:opacity-100 p-0.5 text-light/40 hover:text-error transition-all"
onclick={() => deleteTag(tag.id)}
aria-label="Delete tag"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>delete</span
>
</button>
{/if}
</div>
{/each}
<!-- Inline create new tag -->
<div
class="flex items-center gap-2 pt-1 border-t border-light/10"
>
<input
type="text"
class="bg-dark border border-light/20 rounded-lg px-2 py-1 text-sm text-white flex-1 focus:outline-none focus:border-primary"
placeholder="New tag name..."
bind:value={newTagName}
onkeydown={(e) =>
e.key === "Enter" && createTag()}
/>
<button
type="button"
class="text-primary text-xs font-bold hover:text-primary/80 whitespace-nowrap"
onclick={createTag}
disabled={!newTagName.trim()}
>
+ Add
</button>
</div>
</div>
{/if}
<!-- Tag toggle chips -->
<div class="flex flex-wrap gap-2 items-center">
{#each orgTags as tag}
<button
type="button"
class="rounded-[4px] px-2 py-1 font-body font-bold text-[13px] leading-none transition-all border-2"
style="background-color: {cardTagIds.has(tag.id)
? tag.color || '#00A3E0'
: 'transparent'}; color: {cardTagIds.has(tag.id)
? '#0A121F'
: tag.color ||
'#00A3E0'}; border-color: {tag.color ||
'#00A3E0'};"
onclick={() => toggleTag(tag.id)}
>
{tag.name}
</button>
{/each}
{#if !showTagManager}
{#if showTagInput}
<div class="flex gap-1 items-center">
<input
type="text"
class="bg-dark border border-light/20 rounded-lg px-2 py-1 text-sm text-white w-24 focus:outline-none focus:border-primary"
placeholder="Tag name"
bind:value={newTagName}
onkeydown={(e) =>
e.key === "Enter" && createTag()}
/>
<button
type="button"
class="text-primary text-sm font-bold hover:text-primary/80"
onclick={createTag}
>
Add
</button>
<button
type="button"
class="text-light/40 text-sm hover:text-light"
onclick={() => {
showTagInput = false;
newTagName = "";
}}
>
Cancel
</button>
</div>
{:else}
<button
type="button"
class="rounded-lg px-2 py-1 text-sm text-light/50 hover:text-light border border-dashed border-light/20 hover:border-light/40 transition-colors"
onclick={() => (showTagInput = true)}
>
+ New tag
</button>
{/if}
{/if}
</div>
</div>
<!-- Assignee, Due Date, Priority Row -->
<div class="grid grid-cols-3 gap-4">
<AssigneePicker
label="Assignee"
value={assigneeId}
{members}
onchange={(id) => (assigneeId = id)}
/>
<Input type="date" label="Due Date" bind:value={dueDate} />
<Select
label="Priority"
bind:value={priority}
placeholder=""
options={[
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "urgent", label: "Urgent" },
]}
/>
</div>
<div>
<div class="flex items-center justify-between mb-3">
<span class="px-3 font-bold font-body text-body text-white"
>Checklist</span
>
{#if checklist.length > 0}
<span class="text-xs text-light/50"
>{completedCount}/{checklist.length}</span
>
{/if}
</div>
{#if checklist.length > 0}
<div
class="mb-3 h-1.5 bg-light/10 rounded-full overflow-hidden"
>
<div
class="h-full bg-success transition-all duration-300"
style="width: {progress}%"
></div>
</div>
{/if}
{#if isLoading}
<div class="text-light/50 text-sm py-2">Loading...</div>
{:else}
<div class="space-y-2 mb-3">
{#each checklist as item}
<div class="flex items-center gap-3 group">
<button
class="w-5 h-5 rounded border flex items-center justify-center transition-colors
{item.completed
? 'bg-success border-success'
: 'border-light/30 hover:border-light/50'}"
onclick={() => toggleItem(item)}
>
{#if item.completed}
<svg
class="w-3 h-3 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<polyline points="20,6 9,17 4,12" />
</svg>
{/if}
</button>
<span
class="flex-1 text-sm {item.completed
? 'line-through text-light/40'
: 'text-light'}"
>
{item.title}
</span>
<button
type="button"
class="opacity-0 group-hover:opacity-100 p-1 text-light/40 hover:text-error transition-all"
onclick={() => deleteItem(item.id)}
aria-label="Delete item"
>
<Icon name="close" size={16} />
</button>
</div>
{/each}
</div>
<div class="flex gap-2 items-end">
<Input
placeholder="Add an item..."
bind:value={newItemTitle}
onkeydown={(e) =>
e.key === "Enter" && handleAddItem()}
/>
<Button
size="md"
onclick={handleAddItem}
disabled={!newItemTitle.trim()}
>
Add
</Button>
</div>
{/if}
</div>
<!-- Comments Section -->
{#if mode === "edit"}
<div>
<span
class="px-3 font-bold font-body text-body text-white mb-3 block"
>Comments</span
>
<div class="space-y-3 mb-3 max-h-48 overflow-y-auto">
{#each comments as comment}
<div class="flex gap-3">
<div
class="w-8 h-8 rounded-full bg-primary/20 flex-shrink-0 flex items-center justify-center text-xs text-primary"
>
{(comment.profiles?.full_name ||
comment.profiles?.email ||
"?")[0].toUpperCase()}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span
class="text-sm font-medium text-light"
>
{comment.profiles?.full_name ||
comment.profiles?.email ||
"Unknown"}
</span>
<span class="text-xs text-light/40"
>{formatDate(
comment.created_at,
)}</span
>
</div>
<p class="text-sm text-light/70 mt-0.5">
{comment.content}
</p>
</div>
</div>
{/each}
{#if comments.length === 0}
<p class="text-sm text-light/40 text-center py-2">
No comments yet
</p>
{/if}
</div>
<div class="flex gap-2 items-end">
<Input
placeholder="Add a comment..."
bind:value={newComment}
onkeydown={(e) =>
e.key === "Enter" && handleAddComment()}
/>
<Button
size="md"
onclick={handleAddComment}
disabled={!newComment.trim()}
>
Send
</Button>
</div>
</div>
{/if}
<div
class="flex items-center justify-between pt-3 border-t border-light/10"
>
{#if mode === "edit"}
<Button variant="danger" onclick={handleDelete}>
Delete Card
</Button>
{:else}
<div></div>
{/if}
<div class="flex gap-2">
<Button variant="tertiary" onclick={onClose}>Cancel</Button>
<Button
onclick={handleSave}
loading={isSaving}
disabled={!title.trim()}
>
{mode === "create" ? "Add Card" : "Save Changes"}
</Button>
</div>
</div>
</div>
{/if}
</Modal>