Mega push vol 4
This commit is contained in:
@@ -1,10 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { Modal, Button, Input, Textarea } from "$lib/components/ui";
|
||||
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;
|
||||
@@ -33,6 +46,12 @@
|
||||
};
|
||||
}
|
||||
|
||||
interface OrgTag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
card: KanbanCard | null;
|
||||
isOpen: boolean;
|
||||
@@ -42,6 +61,7 @@
|
||||
mode?: "edit" | "create";
|
||||
columnId?: string;
|
||||
userId?: string;
|
||||
orgId?: string;
|
||||
onCreate?: (card: KanbanCard) => void;
|
||||
members?: Member[];
|
||||
}
|
||||
@@ -55,6 +75,7 @@
|
||||
mode = "edit",
|
||||
columnId,
|
||||
userId,
|
||||
orgId,
|
||||
onCreate,
|
||||
members = [],
|
||||
}: Props = $props();
|
||||
@@ -74,20 +95,35 @@
|
||||
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);
|
||||
|
||||
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 as any).assignee_id ?? null;
|
||||
dueDate = (card as any).due_date
|
||||
? new Date((card as any).due_date)
|
||||
.toISOString()
|
||||
.split("T")[0]
|
||||
assigneeId = card.assignee_id ?? null;
|
||||
dueDate = card.due_date
|
||||
? new Date(card.due_date).toISOString().split("T")[0]
|
||||
: "";
|
||||
priority = (card as any).priority ?? "medium";
|
||||
priority = card.priority ?? "medium";
|
||||
loadChecklist();
|
||||
loadComments();
|
||||
loadTags();
|
||||
} else if (mode === "create") {
|
||||
title = "";
|
||||
description = "";
|
||||
@@ -96,12 +132,14 @@
|
||||
priority = "medium";
|
||||
checklist = [];
|
||||
comments = [];
|
||||
cardTagIds = new Set();
|
||||
loadOrgTags();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function loadChecklist() {
|
||||
if (!card) return;
|
||||
if (!card || !isMounted) return;
|
||||
isLoading = true;
|
||||
|
||||
const { data } = await supabase
|
||||
@@ -110,12 +148,13 @@
|
||||
.eq("card_id", card.id)
|
||||
.order("position");
|
||||
|
||||
if (!isMounted) return;
|
||||
checklist = (data ?? []) as ChecklistItem[];
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function loadComments() {
|
||||
if (!card) return;
|
||||
if (!card || !isMounted) return;
|
||||
|
||||
const { data } = await supabase
|
||||
.from("kanban_comments")
|
||||
@@ -132,10 +171,75 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!isMounted) return;
|
||||
if (mode === "create") {
|
||||
await handleCreate();
|
||||
return;
|
||||
@@ -178,7 +282,7 @@
|
||||
.eq("id", columnId)
|
||||
.single();
|
||||
|
||||
const position = (column as any)?.cards?.[0]?.count ?? 0;
|
||||
const position = (column as any)?.cards?.[0]?.count ?? 0; // join aggregation not typed
|
||||
|
||||
const { data: newCard, error } = await supabase
|
||||
.from("kanban_cards")
|
||||
@@ -186,6 +290,9 @@
|
||||
column_id: columnId,
|
||||
title,
|
||||
description: description || null,
|
||||
priority: priority || null,
|
||||
due_date: dueDate || null,
|
||||
assignee_id: assigneeId || null,
|
||||
position,
|
||||
created_by: userId,
|
||||
})
|
||||
@@ -320,133 +427,97 @@
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<!-- Assignee, Due Date, Priority Row -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<!-- Assignee -->
|
||||
<div class="relative">
|
||||
<label class="block text-sm font-medium text-light mb-1"
|
||||
>Assignee</label
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-left text-sm flex items-center gap-2 hover:border-light/40 transition-colors"
|
||||
onclick={() =>
|
||||
(showAssigneePicker = !showAssigneePicker)}
|
||||
>
|
||||
{#if assigneeId && getAssignee(assigneeId)}
|
||||
{@const assignee = getAssignee(assigneeId)}
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-xs text-primary"
|
||||
>
|
||||
{(assignee?.profiles.full_name ||
|
||||
assignee?.profiles.email ||
|
||||
"?")[0].toUpperCase()}
|
||||
</div>
|
||||
<span class="text-light truncate"
|
||||
>{assignee?.profiles.full_name ||
|
||||
assignee?.profiles.email}</span
|
||||
>
|
||||
{:else}
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-light/10 flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 text-light/40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
|
||||
/>
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-light/40">Unassigned</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if showAssigneePicker}
|
||||
<div
|
||||
class="absolute top-full left-0 right-0 mt-1 bg-dark border border-light/20 rounded-lg shadow-lg z-10 max-h-48 overflow-y-auto"
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<span
|
||||
class="px-3 font-bold font-body text-body text-white mb-2 block"
|
||||
>Tags</span
|
||||
>
|
||||
<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 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
|
||||
class="w-full px-3 py-2 text-left text-sm text-light/60 hover:bg-light/5 flex items-center gap-2"
|
||||
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={() => {
|
||||
assigneeId = null;
|
||||
showAssigneePicker = false;
|
||||
showTagInput = false;
|
||||
newTagName = "";
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-light/10"
|
||||
></div>
|
||||
Unassigned
|
||||
Cancel
|
||||
</button>
|
||||
{#each members as member}
|
||||
<button
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-light/5 flex items-center gap-2 {assigneeId ===
|
||||
member.user_id
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-light'}"
|
||||
onclick={() => {
|
||||
assigneeId = member.user_id;
|
||||
showAssigneePicker = false;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-xs"
|
||||
>
|
||||
{(member.profiles.full_name ||
|
||||
member.profiles.email ||
|
||||
"?")[0].toUpperCase()}
|
||||
</div>
|
||||
{member.profiles.full_name ||
|
||||
member.profiles.email}
|
||||
</button>
|
||||
{/each}
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Due Date -->
|
||||
<div>
|
||||
<label
|
||||
for="due-date"
|
||||
class="block text-sm font-medium text-light mb-1"
|
||||
>Due Date</label
|
||||
>
|
||||
<input
|
||||
id="due-date"
|
||||
type="date"
|
||||
bind:value={dueDate}
|
||||
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<!-- Assignee, Due Date, Priority Row -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<AssigneePicker
|
||||
label="Assignee"
|
||||
value={assigneeId}
|
||||
{members}
|
||||
onchange={(id) => (assigneeId = id)}
|
||||
/>
|
||||
|
||||
<!-- Priority -->
|
||||
<div>
|
||||
<label
|
||||
for="priority"
|
||||
class="block text-sm font-medium text-light mb-1"
|
||||
>Priority</label
|
||||
>
|
||||
<select
|
||||
id="priority"
|
||||
bind:value={priority}
|
||||
class="w-full px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
<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">
|
||||
<label class="text-sm font-medium text-light"
|
||||
>Checklist</label
|
||||
<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"
|
||||
@@ -499,36 +570,26 @@
|
||||
{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"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
<Icon name="close" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary"
|
||||
<div class="flex gap-2 items-end">
|
||||
<Input
|
||||
placeholder="Add an item..."
|
||||
bind:value={newItemTitle}
|
||||
onkeydown={(e) =>
|
||||
e.key === "Enter" && handleAddItem()}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
size="md"
|
||||
onclick={handleAddItem}
|
||||
disabled={!newItemTitle.trim()}
|
||||
>
|
||||
@@ -541,8 +602,9 @@
|
||||
<!-- Comments Section -->
|
||||
{#if mode === "edit"}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-light mb-3"
|
||||
>Comments</label
|
||||
<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}
|
||||
@@ -550,8 +612,8 @@
|
||||
<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 as any)?.full_name ||
|
||||
(comment.profiles as any)?.email ||
|
||||
{(comment.profiles?.full_name ||
|
||||
comment.profiles?.email ||
|
||||
"?")[0].toUpperCase()}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -559,10 +621,8 @@
|
||||
<span
|
||||
class="text-sm font-medium text-light"
|
||||
>
|
||||
{(comment.profiles as any)
|
||||
?.full_name ||
|
||||
(comment.profiles as any)
|
||||
?.email ||
|
||||
{comment.profiles?.full_name ||
|
||||
comment.profiles?.email ||
|
||||
"Unknown"}
|
||||
</span>
|
||||
<span class="text-xs text-light/40"
|
||||
@@ -583,17 +643,15 @@
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary"
|
||||
<div class="flex gap-2 items-end">
|
||||
<Input
|
||||
placeholder="Add a comment..."
|
||||
bind:value={newComment}
|
||||
onkeydown={(e) =>
|
||||
e.key === "Enter" && handleAddComment()}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
size="md"
|
||||
onclick={handleAddComment}
|
||||
disabled={!newComment.trim()}
|
||||
>
|
||||
@@ -614,7 +672,7 @@
|
||||
<div></div>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button variant="ghost" onclick={onClose}>Cancel</Button>
|
||||
<Button variant="tertiary" onclick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
onclick={handleSave}
|
||||
loading={isSaving}
|
||||
|
||||
Reference in New Issue
Block a user