Mega push vol 7 mvp lesgoooo

This commit is contained in:
AlacrisDevs
2026-02-07 21:47:47 +02:00
parent dcee479839
commit d22847f555
75 changed files with 7685 additions and 892 deletions

View File

@@ -0,0 +1,438 @@
<script lang="ts">
import type { BudgetCategory, BudgetItem } from '$lib/supabase/types';
interface Props {
categories: BudgetCategory[];
items: BudgetItem[];
isEditor: boolean;
fullscreen?: boolean;
onCreateCategory: (name: string, color: string) => void;
onDeleteCategory: (categoryId: string) => void;
onCreateItem: (params: {
description: string;
item_type: 'income' | 'expense';
planned_amount?: number;
actual_amount?: number;
category_id?: string | null;
notes?: string;
}) => void;
onUpdateItem: (
itemId: string,
params: Partial<Pick<BudgetItem, 'description' | 'item_type' | 'planned_amount' | 'actual_amount' | 'category_id' | 'notes'>>
) => void;
onDeleteItem: (itemId: string) => void;
}
let {
categories,
items,
isEditor,
fullscreen = false,
onCreateCategory,
onDeleteCategory,
onCreateItem,
onUpdateItem,
onDeleteItem,
}: Props = $props();
let viewMode = $state<'overview' | 'income' | 'expense'>('overview');
let showAddItemModal = $state(false);
let showAddCategoryModal = $state(false);
let editingItem = $state<BudgetItem | null>(null);
// Form state
let newCategoryName = $state('');
let newCategoryColor = $state('#6366f1');
let formDescription = $state('');
let formType = $state<'income' | 'expense'>('expense');
let formPlanned = $state('0');
let formActual = $state('0');
let formCategoryId = $state<string | null>(null);
let formNotes = $state('');
const CATEGORY_COLORS = ['#6366f1', '#10B981', '#F59E0B', '#EF4444', '#EC4899', '#8B5CF6', '#06B6D4', '#F97316'];
// Computed totals
const incomeItems = $derived(items.filter((i) => i.item_type === 'income'));
const expenseItems = $derived(items.filter((i) => i.item_type === 'expense'));
const totalPlannedIncome = $derived(incomeItems.reduce((s, i) => s + Number(i.planned_amount), 0));
const totalActualIncome = $derived(incomeItems.reduce((s, i) => s + Number(i.actual_amount), 0));
const totalPlannedExpense = $derived(expenseItems.reduce((s, i) => s + Number(i.planned_amount), 0));
const totalActualExpense = $derived(expenseItems.reduce((s, i) => s + Number(i.actual_amount), 0));
const plannedBalance = $derived(totalPlannedIncome - totalPlannedExpense);
const actualBalance = $derived(totalActualIncome - totalActualExpense);
const filteredItems = $derived(
viewMode === 'income'
? incomeItems
: viewMode === 'expense'
? expenseItems
: items
);
function getCategoryName(categoryId: string | null): string {
if (!categoryId) return 'Uncategorized';
return categories.find((c) => c.id === categoryId)?.name ?? 'Uncategorized';
}
function getCategoryColor(categoryId: string | null): string {
if (!categoryId) return '#64748b';
return categories.find((c) => c.id === categoryId)?.color ?? '#64748b';
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(amount);
}
function openAddItem(type: 'income' | 'expense' = 'expense') {
editingItem = null;
formDescription = '';
formType = type;
formPlanned = '0';
formActual = '0';
formCategoryId = null;
formNotes = '';
showAddItemModal = true;
}
function openEditItem(item: BudgetItem) {
editingItem = item;
formDescription = item.description;
formType = item.item_type;
formPlanned = String(item.planned_amount);
formActual = String(item.actual_amount);
formCategoryId = item.category_id;
formNotes = item.notes ?? '';
showAddItemModal = true;
}
function handleSubmitItem() {
if (!formDescription.trim()) return;
if (editingItem) {
onUpdateItem(editingItem.id, {
description: formDescription.trim(),
item_type: formType,
planned_amount: parseFloat(formPlanned) || 0,
actual_amount: parseFloat(formActual) || 0,
category_id: formCategoryId,
notes: formNotes.trim() || undefined,
});
} else {
onCreateItem({
description: formDescription.trim(),
item_type: formType,
planned_amount: parseFloat(formPlanned) || 0,
actual_amount: parseFloat(formActual) || 0,
category_id: formCategoryId,
notes: formNotes.trim() || undefined,
});
}
showAddItemModal = false;
}
function handleAddCategory() {
if (!newCategoryName.trim()) return;
onCreateCategory(newCategoryName.trim(), newCategoryColor);
newCategoryName = '';
newCategoryColor = '#6366f1';
showAddCategoryModal = false;
}
// Group items by category for overview
const itemsByCategory = $derived(() => {
const map = new Map<string | null, BudgetItem[]>();
for (const item of filteredItems) {
const key = item.category_id;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(item);
}
return map;
});
</script>
<div class="flex flex-col gap-3 {fullscreen ? 'h-full' : ''}" >
<!-- Summary cards -->
<div class="grid grid-cols-2 {fullscreen ? 'md:grid-cols-4' : ''} gap-2">
<div class="bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-3">
<p class="text-[11px] text-emerald-400/70 uppercase tracking-wide">Income</p>
<p class="text-body font-heading text-emerald-400">{formatCurrency(totalActualIncome)}</p>
<p class="text-[11px] text-light/30">Planned: {formatCurrency(totalPlannedIncome)}</p>
</div>
<div class="bg-red-500/10 border border-red-500/20 rounded-xl p-3">
<p class="text-[11px] text-red-400/70 uppercase tracking-wide">Expenses</p>
<p class="text-body font-heading text-red-400">{formatCurrency(totalActualExpense)}</p>
<p class="text-[11px] text-light/30">Planned: {formatCurrency(totalPlannedExpense)}</p>
</div>
{#if fullscreen}
<div class="bg-blue-500/10 border border-blue-500/20 rounded-xl p-3">
<p class="text-[11px] text-blue-400/70 uppercase tracking-wide">Planned Balance</p>
<p class="text-body font-heading {plannedBalance >= 0 ? 'text-blue-400' : 'text-red-400'}">{formatCurrency(plannedBalance)}</p>
</div>
<div class="bg-light/5 border border-light/10 rounded-xl p-3">
<p class="text-[11px] text-light/50 uppercase tracking-wide">Actual Balance</p>
<p class="text-body font-heading {actualBalance >= 0 ? 'text-emerald-400' : 'text-red-400'}">{formatCurrency(actualBalance)}</p>
</div>
{/if}
</div>
<!-- Toolbar -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1 bg-dark/50 rounded-lg p-0.5">
{#each ['overview', 'income', 'expense'] as mode}
<button
class="px-2.5 py-1 rounded text-[11px] transition-colors {viewMode === mode ? 'bg-primary text-background' : 'text-light/40 hover:text-light/70'}"
onclick={() => (viewMode = mode as 'overview' | 'income' | 'expense')}
>
{mode === 'overview' ? 'All' : mode === 'income' ? 'Income' : 'Expenses'}
</button>
{/each}
</div>
{#if isEditor}
<div class="flex items-center gap-1">
{#if fullscreen}
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg bg-light/5 hover:bg-light/10 text-light/60 text-[11px] transition-colors"
onclick={() => (showAddCategoryModal = true)}
>
<span class="material-symbols-rounded" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;">category</span>
Category
</button>
{/if}
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg bg-primary/10 hover:bg-primary/20 text-primary text-[11px] transition-colors"
onclick={() => openAddItem(viewMode === 'income' ? 'income' : 'expense')}
>
<span class="material-symbols-rounded" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;">add</span>
Add Item
</button>
</div>
{/if}
</div>
<!-- Items list -->
<div class="flex-1 overflow-auto space-y-1">
{#if filteredItems.length === 0}
<div class="flex flex-col items-center justify-center py-8 text-light/30 gap-2">
<span class="material-symbols-rounded" style="font-size: 32px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 32;">account_balance</span>
<p class="text-body-sm">No budget items yet</p>
</div>
{:else}
<!-- Table header -->
<div class="grid grid-cols-12 gap-2 px-3 py-1.5 text-[10px] uppercase tracking-wider text-light/30">
<div class="col-span-1">Type</div>
<div class="{fullscreen ? 'col-span-3' : 'col-span-4'}">Description</div>
<div class="col-span-2">Category</div>
<div class="col-span-2 text-right">Planned</div>
<div class="col-span-2 text-right">Actual</div>
{#if fullscreen}
<div class="col-span-1 text-right">Diff</div>
<div class="col-span-1"></div>
{:else}
<div class="col-span-1"></div>
{/if}
</div>
{#each filteredItems as item (item.id)}
{@const diff = Number(item.item_type === 'income' ? item.actual_amount - item.planned_amount : item.planned_amount - item.actual_amount)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="grid grid-cols-12 gap-2 px-3 py-2 rounded-lg hover:bg-light/5 transition-colors w-full text-left items-center cursor-pointer"
onclick={() => isEditor && openEditItem(item)}
onkeydown={(e) => e.key === 'Enter' && isEditor && openEditItem(item)}
role="button"
tabindex="0"
>
<div class="col-span-1">
<span
class="inline-flex items-center justify-center w-5 h-5 rounded {item.item_type === 'income' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'}"
>
<span class="material-symbols-rounded" style="font-size: 12px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 12;">
{item.item_type === 'income' ? 'arrow_downward' : 'arrow_upward'}
</span>
</span>
</div>
<div class="{fullscreen ? 'col-span-3' : 'col-span-4'} text-body-sm text-white truncate">{item.description}</div>
<div class="col-span-2">
<span
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px]"
style="background-color: {getCategoryColor(item.category_id)}20; color: {getCategoryColor(item.category_id)}"
>
{getCategoryName(item.category_id)}
</span>
</div>
<div class="col-span-2 text-right text-body-sm text-light/60">{formatCurrency(Number(item.planned_amount))}</div>
<div class="col-span-2 text-right text-body-sm text-white">{formatCurrency(Number(item.actual_amount))}</div>
{#if fullscreen}
<div class="col-span-1 text-right text-[11px] {diff >= 0 ? 'text-emerald-400' : 'text-red-400'}">
{diff >= 0 ? '+' : ''}{formatCurrency(diff)}
</div>
<div class="col-span-1 text-right">
{#if isEditor}
<button
class="p-0.5 rounded hover:bg-error/10 transition-colors"
onclick={(e) => { e.stopPropagation(); onDeleteItem(item.id); }}
>
<span class="material-symbols-rounded text-light/30 hover:text-error" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;">delete</span>
</button>
{/if}
</div>
{:else}
<div class="col-span-1 text-right">
{#if isEditor}
<button
class="p-0.5 rounded hover:bg-error/10 transition-colors"
onclick={(e) => { e.stopPropagation(); onDeleteItem(item.id); }}
>
<span class="material-symbols-rounded text-light/30 hover:text-error" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;">delete</span>
</button>
{/if}
</div>
{/if}
</div>
{/each}
<!-- Totals row -->
<div class="grid grid-cols-12 gap-2 px-3 py-2 border-t border-light/10 mt-2">
<div class="col-span-1"></div>
<div class="{fullscreen ? 'col-span-3' : 'col-span-4'} text-body-sm font-heading text-white">Total</div>
<div class="col-span-2"></div>
<div class="col-span-2 text-right text-body-sm font-heading text-light/60">
{formatCurrency(filteredItems.reduce((s, i) => s + Number(i.planned_amount), 0))}
</div>
<div class="col-span-2 text-right text-body-sm font-heading text-white">
{formatCurrency(filteredItems.reduce((s, i) => s + Number(i.actual_amount), 0))}
</div>
{#if fullscreen}
<div class="col-span-2"></div>
{:else}
<div class="col-span-1"></div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Add/Edit Item Modal -->
{#if showAddItemModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-[60] bg-black/60 flex items-center justify-center p-4" onclick={() => (showAddItemModal = false)} onkeydown={(e) => e.key === 'Escape' && (showAddItemModal = false)}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="bg-surface rounded-2xl border border-light/10 p-5 w-full max-w-md space-y-4" onclick={(e) => e.stopPropagation()}>
<h3 class="text-body font-heading text-white">{editingItem ? 'Edit' : 'Add'} Budget Item</h3>
<div class="space-y-3">
<div>
<label class="block text-[11px] text-light/50 mb-1" for="budget-desc">Description</label>
<input id="budget-desc" type="text" bind:value={formDescription} placeholder="e.g. Venue rental"
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11px] text-light/50 mb-1" for="budget-type">Type</label>
<select id="budget-type" bind:value={formType}
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary/50">
<option value="expense">Expense</option>
<option value="income">Income</option>
</select>
</div>
<div>
<label class="block text-[11px] text-light/50 mb-1" for="budget-cat">Category</label>
<select id="budget-cat" bind:value={formCategoryId}
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary/50">
<option value={null}>Uncategorized</option>
{#each categories as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11px] text-light/50 mb-1" for="budget-planned">Planned Amount</label>
<input id="budget-planned" type="number" step="0.01" bind:value={formPlanned}
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary/50" />
</div>
<div>
<label class="block text-[11px] text-light/50 mb-1" for="budget-actual">Actual Amount</label>
<input id="budget-actual" type="number" step="0.01" bind:value={formActual}
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary/50" />
</div>
</div>
<div>
<label class="block text-[11px] text-light/50 mb-1" for="budget-notes">Notes</label>
<textarea id="budget-notes" bind:value={formNotes} rows="2" placeholder="Optional notes..."
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50 resize-none"></textarea>
</div>
</div>
<div class="flex justify-end gap-2">
<button class="px-3 py-1.5 rounded-lg text-body-sm text-light/60 hover:text-white transition-colors" onclick={() => (showAddItemModal = false)}>Cancel</button>
<button class="px-3 py-1.5 rounded-lg bg-primary text-background text-body-sm font-heading hover:bg-primary/90 transition-colors" onclick={handleSubmitItem}>
{editingItem ? 'Save' : 'Add'}
</button>
</div>
</div>
</div>
{/if}
<!-- Add Category Modal -->
{#if showAddCategoryModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-[60] bg-black/60 flex items-center justify-center p-4" onclick={() => (showAddCategoryModal = false)} onkeydown={(e) => e.key === 'Escape' && (showAddCategoryModal = false)}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="bg-surface rounded-2xl border border-light/10 p-5 w-full max-w-sm space-y-4" onclick={(e) => e.stopPropagation()}>
<h3 class="text-body font-heading text-white">Add Category</h3>
<div class="space-y-3">
<div>
<label class="block text-[11px] text-light/50 mb-1" for="cat-name">Name</label>
<input id="cat-name" type="text" bind:value={newCategoryName} placeholder="e.g. Venue"
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50" />
</div>
<div>
<p class="text-[11px] text-light/50 mb-1">Color</p>
<div class="flex gap-1.5">
{#each CATEGORY_COLORS as color}
<button
class="w-6 h-6 rounded-full border-2 transition-all {newCategoryColor === color ? 'border-white scale-110' : 'border-transparent'}"
style="background-color: {color}"
onclick={() => (newCategoryColor = color)}
></button>
{/each}
</div>
</div>
<!-- Existing categories -->
{#if categories.length > 0}
<div>
<p class="text-[11px] text-light/50 mb-1">Existing Categories</p>
<div class="flex flex-wrap gap-1.5">
{#each categories as cat}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px]" style="background-color: {cat.color}20; color: {cat.color}">
{cat.name}
{#if isEditor}
<button class="hover:text-white transition-colors" onclick={() => onDeleteCategory(cat.id)}>
<span class="material-symbols-rounded" style="font-size: 12px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 12;">close</span>
</button>
{/if}
</span>
{/each}
</div>
</div>
{/if}
</div>
<div class="flex justify-end gap-2">
<button class="px-3 py-1.5 rounded-lg text-body-sm text-light/60 hover:text-white transition-colors" onclick={() => (showAddCategoryModal = false)}>Close</button>
<button class="px-3 py-1.5 rounded-lg bg-primary text-background text-body-sm font-heading hover:bg-primary/90 transition-colors" onclick={handleAddCategory}>
Add
</button>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,279 @@
<script lang="ts">
import type { ChecklistWithItems } from "$lib/api/department-dashboard";
import { Button } from "$lib/components/ui";
interface Props {
checklists: ChecklistWithItems[];
isEditor: boolean;
fullscreen?: boolean;
onAddItem: (checklistId: string, content: string) => void;
onToggleItem: (itemId: string, checked: boolean) => void;
onDeleteItem: (itemId: string) => void;
onUpdateItem: (itemId: string, content: string) => void;
onAddChecklist: (title: string) => void;
onDeleteChecklist: (checklistId: string) => void;
onRenameChecklist: (checklistId: string, title: string) => void;
}
let {
checklists,
isEditor,
fullscreen = false,
onAddItem,
onToggleItem,
onDeleteItem,
onUpdateItem,
onAddChecklist,
onDeleteChecklist,
onRenameChecklist,
}: Props = $props();
let newItemContent: Record<string, string> = $state({});
let editingItemId = $state<string | null>(null);
let editingContent = $state("");
let showNewChecklist = $state(false);
let newChecklistTitle = $state("");
let renamingId = $state<string | null>(null);
let renamingTitle = $state("");
function handleAddItem(checklistId: string) {
const content = (newItemContent[checklistId] ?? "").trim();
if (!content) return;
onAddItem(checklistId, content);
newItemContent[checklistId] = "";
}
function startEdit(itemId: string, content: string) {
editingItemId = itemId;
editingContent = content;
}
function confirmEdit() {
if (editingItemId && editingContent.trim()) {
onUpdateItem(editingItemId, editingContent.trim());
}
editingItemId = null;
editingContent = "";
}
function startRename(checklistId: string, title: string) {
renamingId = checklistId;
renamingTitle = title;
}
function confirmRename() {
if (renamingId && renamingTitle.trim()) {
onRenameChecklist(renamingId, renamingTitle.trim());
}
renamingId = null;
renamingTitle = "";
}
function handleCreateChecklist() {
if (!newChecklistTitle.trim()) return;
onAddChecklist(newChecklistTitle.trim());
newChecklistTitle = "";
showNewChecklist = false;
}
function completionPercent(cl: ChecklistWithItems): number {
if (cl.items.length === 0) return 0;
return Math.round(
(cl.items.filter((i) => i.is_completed).length / cl.items.length) *
100,
);
}
</script>
<div class="flex flex-col gap-4 {fullscreen ? 'max-w-2xl mx-auto' : ''}">
{#each checklists as cl (cl.id)}
<div class="flex flex-col gap-2">
<!-- Checklist header -->
<div class="flex items-center justify-between">
{#if renamingId === cl.id}
<input
type="text"
bind:value={renamingTitle}
onkeydown={(e) => {
if (e.key === "Enter") confirmRename();
if (e.key === "Escape") {
renamingId = null;
renamingTitle = "";
}
}}
onblur={confirmRename}
class="bg-transparent text-body-sm font-heading text-white border-b border-primary outline-none px-0 py-0.5"
/>
{:else}
<div class="flex items-center gap-2">
<h3 class="text-body-sm font-heading text-white">
{cl.title}
</h3>
<span class="text-[11px] text-light/30">
{cl.items.filter((i) => i.is_completed).length}/{cl
.items.length}
</span>
{#if cl.items.length > 0}
<div
class="w-16 h-1 rounded-full bg-light/10 overflow-hidden"
>
<div
class="h-full bg-emerald-400 rounded-full transition-all"
style="width: {completionPercent(cl)}%"
></div>
</div>
{/if}
</div>
{/if}
{#if isEditor}
<div class="flex items-center gap-1">
<button
class="p-0.5 rounded hover:bg-light/10 transition-colors"
onclick={() => startRename(cl.id, cl.title)}
title="Rename"
>
<span
class="material-symbols-rounded text-light/30"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>edit</span
>
</button>
<button
class="p-0.5 rounded hover:bg-error/10 transition-colors"
onclick={() => onDeleteChecklist(cl.id)}
title="Delete checklist"
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>delete</span
>
</button>
</div>
{/if}
</div>
<!-- Items -->
<div class="flex flex-col gap-0.5">
{#each cl.items as item (item.id)}
<div
class="group flex items-start gap-2 px-2 py-1.5 rounded-lg hover:bg-light/5 transition-colors"
>
<input
type="checkbox"
checked={item.is_completed}
onchange={() =>
onToggleItem(item.id, !item.is_completed)}
class="mt-0.5 w-4 h-4 rounded border-light/20 text-primary accent-primary cursor-pointer"
/>
{#if editingItemId === item.id}
<input
type="text"
bind:value={editingContent}
onkeydown={(e) => {
if (e.key === "Enter") confirmEdit();
if (e.key === "Escape") {
editingItemId = null;
editingContent = "";
}
}}
onblur={confirmEdit}
class="flex-1 bg-transparent text-body-sm text-light border-b border-primary outline-none"
/>
{:else}
<button
class="flex-1 text-left text-body-sm {item.is_completed
? 'text-light/30 line-through'
: 'text-light'}"
ondblclick={() =>
isEditor &&
startEdit(item.id, item.content)}
>
{item.content}
</button>
{/if}
{#if isEditor}
<button
class="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all"
onclick={() => onDeleteItem(item.id)}
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>close</span
>
</button>
{/if}
</div>
{/each}
</div>
<!-- Add item input -->
{#if isEditor}
<div class="flex items-center gap-2 px-2">
<input
type="text"
placeholder="Add item..."
bind:value={newItemContent[cl.id]}
onkeydown={(e) => {
if (e.key === "Enter") handleAddItem(cl.id);
}}
class="flex-1 bg-transparent text-body-sm text-light placeholder:text-light/20 border-b border-light/10 focus:border-primary outline-none py-1"
/>
<button
class="p-1 rounded-lg hover:bg-light/10 transition-colors"
onclick={() => handleAddItem(cl.id)}
disabled={!(newItemContent[cl.id] ?? "").trim()}
>
<span
class="material-symbols-rounded text-light/40"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>add</span
>
</button>
</div>
{/if}
</div>
{/each}
<!-- Add checklist -->
{#if isEditor}
{#if showNewChecklist}
<div class="flex items-center gap-2">
<input
type="text"
placeholder="Checklist name..."
bind:value={newChecklistTitle}
onkeydown={(e) => {
if (e.key === "Enter") handleCreateChecklist();
if (e.key === "Escape") {
showNewChecklist = false;
newChecklistTitle = "";
}
}}
class="flex-1 bg-transparent text-body-sm text-light placeholder:text-light/20 border-b border-primary outline-none py-1"
/>
<Button size="sm" onclick={handleCreateChecklist}>Create</Button
>
</div>
{:else}
<button
class="flex items-center gap-1.5 text-body-sm text-light/30 hover:text-light/60 transition-colors"
onclick={() => (showNewChecklist = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>add</span
>
Add checklist
</button>
{/if}
{/if}
{#if checklists.length === 0}
<p class="text-body-sm text-light/30 text-center py-4">
No checklists yet
</p>
{/if}
</div>

View File

@@ -0,0 +1,547 @@
<script lang="ts">
import type { DepartmentContact } from "$lib/supabase/types";
import {
CONTACT_CATEGORIES,
CATEGORY_LABELS,
CATEGORY_ICONS,
} from "$lib/api/contacts";
import { Button, Modal } from "$lib/components/ui";
interface Props {
contacts: DepartmentContact[];
isEditor: boolean;
fullscreen?: boolean;
onCreate: (params: {
name: string;
role?: string;
company?: string;
email?: string;
phone?: string;
website?: string;
notes?: string;
category?: string;
color?: string;
}) => void;
onUpdate: (
contactId: string,
params: Partial<
Pick<
DepartmentContact,
| "name"
| "role"
| "company"
| "email"
| "phone"
| "website"
| "notes"
| "category"
| "color"
>
>,
) => void;
onDelete: (contactId: string) => void;
}
let {
contacts,
isEditor,
fullscreen = false,
onCreate,
onUpdate,
onDelete,
}: Props = $props();
// Filter
let filterCategory = $state<string>("all");
let searchQuery = $state("");
// Modal state
let showContactModal = $state(false);
let editingContact = $state<DepartmentContact | null>(null);
let contactName = $state("");
let contactRole = $state("");
let contactCompany = $state("");
let contactEmail = $state("");
let contactPhone = $state("");
let contactWebsite = $state("");
let contactNotes = $state("");
let contactCategory = $state("general");
// Expanded contact detail
let expandedId = $state<string | null>(null);
const filteredContacts = $derived.by(() => {
let result = contacts;
if (filterCategory !== "all") {
result = result.filter((c) => c.category === filterCategory);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
(c.company ?? "").toLowerCase().includes(q) ||
(c.role ?? "").toLowerCase().includes(q) ||
(c.email ?? "").toLowerCase().includes(q),
);
}
return result;
});
// Categories that have contacts
const usedCategories = $derived(
[...new Set(contacts.map((c) => c.category))].sort(),
);
function openContactModal(contact?: DepartmentContact) {
if (contact) {
editingContact = contact;
contactName = contact.name;
contactRole = contact.role ?? "";
contactCompany = contact.company ?? "";
contactEmail = contact.email ?? "";
contactPhone = contact.phone ?? "";
contactWebsite = contact.website ?? "";
contactNotes = contact.notes ?? "";
contactCategory = contact.category;
} else {
editingContact = null;
contactName = "";
contactRole = "";
contactCompany = "";
contactEmail = "";
contactPhone = "";
contactWebsite = "";
contactNotes = "";
contactCategory = "general";
}
showContactModal = true;
}
function handleSaveContact() {
if (!contactName.trim()) return;
const params = {
name: contactName.trim(),
role: contactRole.trim() || undefined,
company: contactCompany.trim() || undefined,
email: contactEmail.trim() || undefined,
phone: contactPhone.trim() || undefined,
website: contactWebsite.trim() || undefined,
notes: contactNotes.trim() || undefined,
category: contactCategory,
};
if (editingContact) {
onUpdate(editingContact.id, params);
} else {
onCreate(params);
}
showContactModal = false;
}
</script>
<div
class="flex flex-col gap-3 {fullscreen
? 'max-w-3xl mx-auto'
: ''} h-full"
>
<!-- Toolbar -->
<div class="flex items-center gap-2 shrink-0">
<!-- Search -->
<div class="relative flex-1">
<span
class="material-symbols-rounded absolute left-2 top-1/2 -translate-y-1/2 text-light/30"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>search</span
>
<input
type="text"
bind:value={searchQuery}
placeholder="Search contacts..."
class="w-full pl-8 pr-3 py-1.5 bg-dark/50 border border-light/10 rounded-lg text-[12px] text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
<!-- Category filter -->
<select
bind:value={filterCategory}
class="bg-dark/50 border border-light/10 rounded-lg px-2 py-1.5 text-[12px] text-white focus:outline-none focus:border-primary"
>
<option value="all">All</option>
{#each CONTACT_CATEGORIES as cat}
<option value={cat}>{CATEGORY_LABELS[cat] ?? cat}</option>
{/each}
</select>
{#if isEditor}
<Button size="sm" onclick={() => openContactModal()}>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>add</span
>
Add
</Button>
{/if}
</div>
<!-- Contact list -->
<div class="flex-1 overflow-auto">
{#if filteredContacts.length === 0}
<div
class="flex flex-col items-center justify-center h-full gap-2 text-light/30"
>
<span
class="material-symbols-rounded"
style="font-size: 36px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 36;"
>contacts</span
>
<p class="text-body-sm">
{contacts.length === 0
? "No contacts yet"
: "No matches found"}
</p>
</div>
{:else}
<div class="flex flex-col gap-1">
{#each filteredContacts as contact (contact.id)}
<div class="rounded-xl border border-light/5 overflow-hidden">
<!-- Contact row -->
<button
class="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-light/5 transition-colors text-left group"
onclick={() =>
(expandedId =
expandedId === contact.id
? null
: contact.id)}
>
<!-- Category icon -->
<div
class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style="background-color: {contact.color ??
'#00A3E0'}20"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; color: {contact.color ??
'#00A3E0'}; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>
{CATEGORY_ICONS[contact.category] ??
"person"}
</span>
</div>
<!-- Name & company -->
<div class="flex-1 min-w-0">
<span
class="text-body-sm text-white font-medium truncate block"
>{contact.name}</span
>
{#if contact.company || contact.role}
<span
class="text-[11px] text-light/40 truncate block"
>
{[contact.role, contact.company]
.filter(Boolean)
.join(" · ")}
</span>
{/if}
</div>
<!-- Quick actions -->
{#if contact.email}
<a
href="mailto:{contact.email}"
class="p-1 rounded-lg hover:bg-light/10 transition-colors shrink-0"
onclick={(e) => e.stopPropagation()}
title={contact.email}
>
<span
class="material-symbols-rounded text-light/30 hover:text-primary"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>mail</span
>
</a>
{/if}
{#if contact.phone}
<a
href="tel:{contact.phone}"
class="p-1 rounded-lg hover:bg-light/10 transition-colors shrink-0"
onclick={(e) => e.stopPropagation()}
title={contact.phone}
>
<span
class="material-symbols-rounded text-light/30 hover:text-primary"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>phone</span
>
</a>
{/if}
<!-- Category badge -->
<span
class="text-[10px] px-1.5 py-0.5 rounded bg-light/5 text-light/30 shrink-0"
>
{CATEGORY_LABELS[contact.category] ??
contact.category}
</span>
</button>
<!-- Expanded detail -->
{#if expandedId === contact.id}
<div
class="px-3 pb-3 pt-1 border-t border-light/5 bg-dark/30"
>
<div
class="grid grid-cols-2 gap-2 text-[11px]"
>
{#if contact.email}
<div>
<span class="text-light/30"
>Email</span
>
<a
href="mailto:{contact.email}"
class="block text-primary hover:underline truncate"
>{contact.email}</a
>
</div>
{/if}
{#if contact.phone}
<div>
<span class="text-light/30"
>Phone</span
>
<a
href="tel:{contact.phone}"
class="block text-primary hover:underline"
>{contact.phone}</a
>
</div>
{/if}
{#if contact.website}
<div>
<span class="text-light/30"
>Website</span
>
<a
href={contact.website.startsWith(
"http",
)
? contact.website
: `https://${contact.website}`}
target="_blank"
rel="noopener noreferrer"
class="block text-primary hover:underline truncate"
>{contact.website}</a
>
</div>
{/if}
{#if contact.role}
<div>
<span class="text-light/30"
>Role</span
>
<span
class="block text-light/60"
>{contact.role}</span
>
</div>
{/if}
</div>
{#if contact.notes}
<div class="mt-2 text-[11px]">
<span class="text-light/30"
>Notes</span
>
<p
class="text-light/60 whitespace-pre-wrap"
>
{contact.notes}
</p>
</div>
{/if}
{#if isEditor}
<div
class="flex items-center gap-2 mt-3 pt-2 border-t border-light/5"
>
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] text-light/40 hover:text-white hover:bg-light/10 transition-colors"
onclick={() =>
openContactModal(contact)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>edit</span
>
Edit
</button>
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] text-light/40 hover:text-error hover:bg-error/10 transition-colors"
onclick={() =>
onDelete(contact.id)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>delete</span
>
Delete
</button>
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Contact Modal -->
<Modal
isOpen={showContactModal}
onClose={() => (showContactModal = false)}
title={editingContact ? "Edit Contact" : "Add Contact"}
>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label
for="contact-name"
class="text-body-sm text-light/60 font-body">Name *</label
>
<input
id="contact-name"
type="text"
bind:value={contactName}
placeholder="Full name"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
<div class="flex flex-col gap-1.5">
<label
for="contact-company"
class="text-body-sm text-light/60 font-body">Company</label
>
<input
id="contact-company"
type="text"
bind:value={contactCompany}
placeholder="Company name"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label
for="contact-role"
class="text-body-sm text-light/60 font-body">Role</label
>
<input
id="contact-role"
type="text"
bind:value={contactRole}
placeholder="e.g. Account Manager"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
<div class="flex flex-col gap-1.5">
<label
for="contact-category"
class="text-body-sm text-light/60 font-body">Category</label
>
<select
id="contact-category"
bind:value={contactCategory}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
>
{#each CONTACT_CATEGORIES as cat}
<option value={cat}
>{CATEGORY_LABELS[cat] ?? cat}</option
>
{/each}
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label
for="contact-email"
class="text-body-sm text-light/60 font-body">Email</label
>
<input
id="contact-email"
type="email"
bind:value={contactEmail}
placeholder="email@example.com"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
<div class="flex flex-col gap-1.5">
<label
for="contact-phone"
class="text-body-sm text-light/60 font-body">Phone</label
>
<input
id="contact-phone"
type="tel"
bind:value={contactPhone}
placeholder="+372 ..."
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label
for="contact-website"
class="text-body-sm text-light/60 font-body">Website</label
>
<input
id="contact-website"
type="url"
bind:value={contactWebsite}
placeholder="https://..."
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
<div class="flex flex-col gap-1.5">
<label
for="contact-notes"
class="text-body-sm text-light/60 font-body">Notes</label
>
<textarea
id="contact-notes"
bind:value={contactNotes}
placeholder="Additional notes..."
rows="2"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary resize-none"
></textarea>
</div>
<div
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
>
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showContactModal = false)}>Cancel</button
>
<button
type="button"
disabled={!contactName.trim()}
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={handleSaveContact}
>
{editingContact ? "Save" : "Create"}
</button>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { goto } from "$app/navigation";
interface Props {
departmentId: string;
orgSlug: string;
fullscreen?: boolean;
}
let { departmentId, orgSlug, fullscreen = false }: Props = $props();
// Files module links to the org documents page
// In the future, this could be scoped to a department subfolder
const filesPath = $derived(`/${orgSlug}/documents`);
</script>
<div
class="flex flex-col items-center justify-center h-full gap-3 {fullscreen ? 'py-12' : 'py-6'}"
>
<span
class="material-symbols-rounded text-light/20"
style="font-size: 36px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 36;"
>folder</span
>
<p class="text-body-sm text-light/40">Department files and documents</p>
<button
class="px-4 py-2 rounded-xl bg-primary/10 text-primary text-body-sm font-heading hover:bg-primary/20 transition-colors"
onclick={() => goto(filesPath)}
>
Open Files
</button>
</div>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/stores";
interface Props {
departmentId: string;
eventId: string;
fullscreen?: boolean;
}
let { departmentId, eventId, fullscreen = false }: Props = $props();
// Kanban is already built as a full page — link to the event tasks page
const tasksPath = $derived(() => {
const base = $page.url.pathname.split("/dept/")[0];
return `${base}/tasks`;
});
</script>
<div
class="flex flex-col items-center justify-center h-full gap-3 {fullscreen ? 'py-12' : 'py-6'}"
>
<span
class="material-symbols-rounded text-light/20"
style="font-size: 36px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 36;"
>view_kanban</span
>
<p class="text-body-sm text-light/40">
Task board for this department
</p>
<button
class="px-4 py-2 rounded-xl bg-primary/10 text-primary text-body-sm font-heading hover:bg-primary/20 transition-colors"
onclick={() => goto(tasksPath())}
>
Open Tasks Board
</button>
</div>

View File

@@ -0,0 +1,178 @@
<script lang="ts">
import type { DepartmentNote } from "$lib/supabase/types";
import { Button } from "$lib/components/ui";
interface Props {
notes: DepartmentNote[];
isEditor: boolean;
fullscreen?: boolean;
onCreate: (title: string) => void;
onUpdate: (noteId: string, params: { title?: string; content?: string }) => void;
onDelete: (noteId: string) => void;
}
let {
notes,
isEditor,
fullscreen = false,
onCreate,
onUpdate,
onDelete,
}: Props = $props();
// svelte-ignore state_referenced_locally
let selectedNoteId = $state<string | null>(notes.length > 0 ? notes[0].id : null);
let editingTitle = $state(false);
let titleInput = $state("");
let showNewNote = $state(false);
let newNoteTitle = $state("");
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
const selectedNote = $derived(notes.find((n) => n.id === selectedNoteId) ?? null);
$effect(() => {
if (notes.length > 0 && !notes.find((n) => n.id === selectedNoteId)) {
selectedNoteId = notes[0].id;
}
});
function handleContentChange(e: Event) {
const target = e.target as HTMLTextAreaElement;
if (!selectedNoteId) return;
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
onUpdate(selectedNoteId!, { content: target.value });
}, 500);
}
function startTitleEdit() {
if (!selectedNote || !isEditor) return;
editingTitle = true;
titleInput = selectedNote.title;
}
function confirmTitleEdit() {
if (selectedNoteId && titleInput.trim()) {
onUpdate(selectedNoteId, { title: titleInput.trim() });
}
editingTitle = false;
}
function handleCreateNote() {
if (!newNoteTitle.trim()) return;
onCreate(newNoteTitle.trim());
newNoteTitle = "";
showNewNote = false;
}
</script>
<div class="flex {fullscreen ? 'h-full' : 'h-full min-h-[200px]'} gap-0">
<!-- Note list sidebar -->
<div
class="w-40 shrink-0 border-r border-light/5 flex flex-col {fullscreen ? 'w-56' : ''}"
>
<div class="flex-1 overflow-auto">
{#each notes as note (note.id)}
<button
class="w-full text-left px-3 py-2 text-body-sm transition-colors truncate {selectedNoteId ===
note.id
? 'bg-primary/10 text-primary'
: 'text-light/60 hover:text-white hover:bg-light/5'}"
onclick={() => (selectedNoteId = note.id)}
>
{note.title}
</button>
{/each}
</div>
{#if isEditor}
{#if showNewNote}
<div class="p-2 border-t border-light/5">
<input
type="text"
placeholder="Note title..."
bind:value={newNoteTitle}
onkeydown={(e) => {
if (e.key === "Enter") handleCreateNote();
if (e.key === "Escape") {
showNewNote = false;
newNoteTitle = "";
}
}}
class="w-full bg-transparent text-body-sm text-light placeholder:text-light/20 border-b border-primary outline-none py-1 px-1"
/>
</div>
{:else}
<button
class="flex items-center gap-1 px-3 py-2 border-t border-light/5 text-body-sm text-light/30 hover:text-light/60 transition-colors"
onclick={() => (showNewNote = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>add</span
>
New note
</button>
{/if}
{/if}
</div>
<!-- Note content -->
<div class="flex-1 flex flex-col">
{#if selectedNote}
<!-- Title -->
<div class="px-4 py-2 border-b border-light/5 flex items-center justify-between">
{#if editingTitle}
<input
type="text"
bind:value={titleInput}
onkeydown={(e) => {
if (e.key === "Enter") confirmTitleEdit();
if (e.key === "Escape") (editingTitle = false);
}}
onblur={confirmTitleEdit}
class="bg-transparent text-body font-heading text-white border-b border-primary outline-none flex-1"
/>
{:else}
<button
class="text-body font-heading text-white text-left flex-1"
ondblclick={startTitleEdit}
>
{selectedNote.title}
</button>
{/if}
{#if isEditor}
<button
class="p-1 rounded-lg hover:bg-error/10 transition-colors"
onclick={() => {
if (selectedNoteId) onDelete(selectedNoteId);
}}
title="Delete note"
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>delete</span
>
</button>
{/if}
</div>
<!-- Content -->
<textarea
class="flex-1 w-full bg-transparent text-body-sm text-light p-4 outline-none resize-none placeholder:text-light/20 {fullscreen ? 'text-body' : ''}"
placeholder="Start writing..."
value={selectedNote.content ?? ""}
oninput={handleContentChange}
disabled={!isEditor}
></textarea>
{:else}
<div
class="flex items-center justify-center h-full text-light/30 text-body-sm"
>
{notes.length === 0 ? "No notes yet" : "Select a note"}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,660 @@
<script lang="ts">
import type { ScheduleStage, ScheduleBlock } from "$lib/supabase/types";
import { Button, Modal } from "$lib/components/ui";
interface Props {
stages: ScheduleStage[];
blocks: ScheduleBlock[];
isEditor: boolean;
fullscreen?: boolean;
onCreateStage: (name: string, color: string) => void;
onDeleteStage: (stageId: string) => void;
onCreateBlock: (params: {
title: string;
start_time: string;
end_time: string;
stage_id?: string | null;
description?: string;
color?: string;
speaker?: string;
}) => void;
onUpdateBlock: (
blockId: string,
params: Partial<
Pick<
ScheduleBlock,
| "title"
| "description"
| "start_time"
| "end_time"
| "stage_id"
| "color"
| "speaker"
>
>,
) => void;
onDeleteBlock: (blockId: string) => void;
}
let {
stages,
blocks,
isEditor,
fullscreen = false,
onCreateStage,
onDeleteStage,
onCreateBlock,
onUpdateBlock,
onDeleteBlock,
}: Props = $props();
// View mode: timeline or list
let viewMode = $state<"timeline" | "list">("timeline");
// Add block modal
let showBlockModal = $state(false);
let editingBlock = $state<ScheduleBlock | null>(null);
let blockTitle = $state("");
let blockDescription = $state("");
let blockDate = $state("");
let blockStartTime = $state("09:00");
let blockEndTime = $state("10:00");
let blockStageId = $state<string | null>(null);
let blockColor = $state("#6366f1");
let blockSpeaker = $state("");
// Add stage modal
let showStageModal = $state(false);
let stageName = $state("");
let stageColor = $state("#6366f1");
const PRESET_COLORS = [
"#6366f1",
"#EC4899",
"#10B981",
"#F59E0B",
"#00A3E0",
"#EF4444",
"#8B5CF6",
"#14B8A6",
];
// Group blocks by date
const blocksByDate = $derived.by(() => {
const groups: Record<string, ScheduleBlock[]> = {};
for (const block of blocks) {
const date = new Date(block.start_time).toLocaleDateString("en-CA");
if (!groups[date]) groups[date] = [];
groups[date].push(block);
}
// Sort dates
const sorted: [string, ScheduleBlock[]][] = Object.entries(groups).sort(
([a], [b]) => a.localeCompare(b),
);
return sorted;
});
function openBlockModal(block?: ScheduleBlock) {
if (block) {
editingBlock = block;
blockTitle = block.title;
blockDescription = block.description ?? "";
const start = new Date(block.start_time);
blockDate = start.toLocaleDateString("en-CA");
blockStartTime = start.toTimeString().slice(0, 5);
const end = new Date(block.end_time);
blockEndTime = end.toTimeString().slice(0, 5);
blockStageId = block.stage_id;
blockColor = block.color ?? "#6366f1";
blockSpeaker = block.speaker ?? "";
} else {
editingBlock = null;
blockTitle = "";
blockDescription = "";
blockDate = new Date().toLocaleDateString("en-CA");
blockStartTime = "09:00";
blockEndTime = "10:00";
blockStageId = null;
blockColor = "#6366f1";
blockSpeaker = "";
}
showBlockModal = true;
}
function handleSaveBlock() {
if (!blockTitle.trim() || !blockDate) return;
const start_time = new Date(
`${blockDate}T${blockStartTime}:00`,
).toISOString();
const end_time = new Date(
`${blockDate}T${blockEndTime}:00`,
).toISOString();
if (editingBlock) {
onUpdateBlock(editingBlock.id, {
title: blockTitle.trim(),
description: blockDescription.trim() || null,
start_time,
end_time,
stage_id: blockStageId,
color: blockColor,
speaker: blockSpeaker.trim() || null,
});
} else {
onCreateBlock({
title: blockTitle.trim(),
start_time,
end_time,
stage_id: blockStageId,
description: blockDescription.trim() || undefined,
color: blockColor,
speaker: blockSpeaker.trim() || undefined,
});
}
showBlockModal = false;
}
function handleCreateStage() {
if (!stageName.trim()) return;
onCreateStage(stageName.trim(), stageColor);
stageName = "";
stageColor = "#6366f1";
showStageModal = false;
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr + "T00:00:00");
return d.toLocaleDateString(undefined, {
weekday: "long",
month: "short",
day: "numeric",
});
}
function durationMinutes(block: ScheduleBlock): number {
return (
(new Date(block.end_time).getTime() -
new Date(block.start_time).getTime()) /
60000
);
}
function stageName_for(stageId: string | null): string {
if (!stageId) return "";
return stages.find((s) => s.id === stageId)?.name ?? "";
}
</script>
<div
class="flex flex-col gap-3 {fullscreen
? 'max-w-3xl mx-auto'
: ''} h-full"
>
<!-- Toolbar -->
<div class="flex items-center justify-between gap-2 shrink-0">
<div class="flex items-center gap-1 bg-dark/50 rounded-lg p-0.5">
<button
class="px-2 py-1 rounded text-[11px] transition-colors {viewMode ===
'timeline'
? 'bg-primary text-background'
: 'text-light/40 hover:text-light/70'}"
onclick={() => (viewMode = "timeline")}
>
Timeline
</button>
<button
class="px-2 py-1 rounded text-[11px] transition-colors {viewMode ===
'list'
? 'bg-primary text-background'
: 'text-light/40 hover:text-light/70'}"
onclick={() => (viewMode = "list")}
>
List
</button>
</div>
{#if isEditor}
<div class="flex items-center gap-1.5">
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] text-light/40 hover:text-light/70 hover:bg-light/5 transition-colors"
onclick={() => (showStageModal = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>add</span
>
Stage
</button>
<Button size="sm" onclick={() => openBlockModal()}>
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>add</span
>
Block
</Button>
</div>
{/if}
</div>
<!-- Stages bar -->
{#if stages.length > 0}
<div class="flex items-center gap-2 flex-wrap shrink-0">
{#each stages as stage (stage.id)}
<div
class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-dark/50 border border-light/5 group"
>
<div
class="w-2.5 h-2.5 rounded-full shrink-0"
style="background-color: {stage.color}"
></div>
<span class="text-[11px] text-light/60">{stage.name}</span>
{#if isEditor}
<button
class="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all"
onclick={() => onDeleteStage(stage.id)}
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 12px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 12;"
>close</span
>
</button>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Content -->
<div class="flex-1 overflow-auto">
{#if blocks.length === 0}
<div
class="flex flex-col items-center justify-center h-full gap-2 text-light/30"
>
<span
class="material-symbols-rounded"
style="font-size: 36px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 36;"
>calendar_today</span
>
<p class="text-body-sm">No schedule blocks yet</p>
</div>
{:else if viewMode === "timeline"}
<!-- Timeline view: grouped by date -->
<div class="flex flex-col gap-6">
{#each blocksByDate as [date, dayBlocks] (date)}
<div>
<h3
class="text-body-sm font-heading text-light/50 mb-3 sticky top-0 bg-surface/80 backdrop-blur-sm py-1 z-10"
>
{formatDateLabel(date)}
</h3>
<div class="flex flex-col gap-1 relative ml-3">
<!-- Timeline line -->
<div
class="absolute left-0 top-2 bottom-2 w-px bg-light/10"
></div>
{#each dayBlocks as block (block.id)}
{@const mins = durationMinutes(block)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative pl-6 py-1.5 group {isEditor
? 'cursor-pointer hover:bg-light/5 rounded-lg'
: ''}"
onclick={() =>
isEditor && openBlockModal(block)}
>
<!-- Dot on timeline -->
<div
class="absolute left-[-3px] top-3 w-[7px] h-[7px] rounded-full border-2 border-surface"
style="background-color: {block.color ??
'#6366f1'}"
></div>
<div class="flex items-start gap-3">
<div class="shrink-0 w-24">
<span
class="text-[11px] text-light/40 font-mono"
>
{formatTime(block.start_time)} {formatTime(
block.end_time,
)}
</span>
<span
class="block text-[10px] text-light/20"
>{mins}min</span
>
</div>
<div class="flex-1 min-w-0">
<div
class="flex items-center gap-2"
>
<div
class="w-1 h-4 rounded-full shrink-0"
style="background-color: {block.color ??
'#6366f1'}"
></div>
<span
class="text-body-sm text-white font-medium truncate"
>{block.title}</span
>
</div>
{#if block.speaker}
<span
class="text-[11px] text-light/40 ml-3"
>{block.speaker}</span
>
{/if}
{#if block.stage_id}
<span
class="text-[10px] text-light/30 ml-3"
>{stageName_for(
block.stage_id,
)}</span
>
{/if}
</div>
{#if isEditor}
<button
class="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all shrink-0"
onclick={(e) => {
e.stopPropagation();
onDeleteBlock(block.id);
}}
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>delete</span
>
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/each}
</div>
{:else}
<!-- List view: simple table -->
<div class="flex flex-col gap-1">
{#each blocks as block (block.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-light/5 transition-colors group {isEditor
? 'cursor-pointer'
: ''}"
onclick={() => isEditor && openBlockModal(block)}
>
<div
class="w-1.5 h-8 rounded-full shrink-0"
style="background-color: {block.color ?? '#6366f1'}"
></div>
<div class="flex-1 min-w-0">
<span
class="text-body-sm text-white font-medium truncate block"
>{block.title}</span
>
{#if block.speaker}
<span class="text-[11px] text-light/40"
>{block.speaker}</span
>
{/if}
</div>
<div class="text-right shrink-0">
<span class="text-[11px] text-light/40 font-mono">
{formatTime(block.start_time)} {formatTime(
block.end_time,
)}
</span>
<span class="block text-[10px] text-light/20">
{new Date(
block.start_time,
).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})}
</span>
</div>
{#if block.stage_id}
<span
class="text-[10px] px-1.5 py-0.5 rounded bg-light/5 text-light/30 shrink-0"
>{stageName_for(block.stage_id)}</span
>
{/if}
{#if isEditor}
<button
class="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all shrink-0"
onclick={(e) => {
e.stopPropagation();
onDeleteBlock(block.id);
}}
>
<span
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>delete</span
>
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Block Modal -->
<Modal
isOpen={showBlockModal}
onClose={() => (showBlockModal = false)}
title={editingBlock ? "Edit Block" : "Add Schedule Block"}
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label
for="block-title"
class="text-body-sm text-light/60 font-body">Title</label
>
<input
id="block-title"
type="text"
bind:value={blockTitle}
placeholder="e.g. Opening Ceremony"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<div class="flex flex-col gap-1.5">
<label
for="block-date"
class="text-body-sm text-light/60 font-body">Date</label
>
<input
id="block-date"
type="date"
bind:value={blockDate}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
/>
</div>
<div class="flex flex-col gap-1.5">
<label
for="block-start"
class="text-body-sm text-light/60 font-body">Start</label
>
<input
id="block-start"
type="time"
bind:value={blockStartTime}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
/>
</div>
<div class="flex flex-col gap-1.5">
<label
for="block-end"
class="text-body-sm text-light/60 font-body">End</label
>
<input
id="block-end"
type="time"
bind:value={blockEndTime}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
/>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label
for="block-speaker"
class="text-body-sm text-light/60 font-body"
>Speaker / Host</label
>
<input
id="block-speaker"
type="text"
bind:value={blockSpeaker}
placeholder="Optional"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
{#if stages.length > 0}
<div class="flex flex-col gap-1.5">
<label
for="block-stage"
class="text-body-sm text-light/60 font-body">Stage</label
>
<select
id="block-stage"
bind:value={blockStageId}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
>
<option value={null}>No stage</option>
{#each stages as stage (stage.id)}
<option value={stage.id}>{stage.name}</option>
{/each}
</select>
</div>
{/if}
<div class="flex flex-col gap-1.5">
<label
for="block-desc"
class="text-body-sm text-light/60 font-body"
>Description</label
>
<textarea
id="block-desc"
bind:value={blockDescription}
placeholder="Optional description..."
rows="2"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary resize-none"
></textarea>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-body-sm text-light/60 font-body">Color</span>
<div class="flex items-center gap-2">
{#each PRESET_COLORS as c}
<button
type="button"
class="w-6 h-6 rounded-full border-2 transition-all {blockColor ===
c
? 'border-white scale-110'
: 'border-transparent hover:border-light/30'}"
style="background-color: {c}"
onclick={() => (blockColor = c)}
aria-label="Color {c}"
></button>
{/each}
</div>
</div>
<div
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
>
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showBlockModal = false)}>Cancel</button
>
<button
type="button"
disabled={!blockTitle.trim() || !blockDate}
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={handleSaveBlock}
>
{editingBlock ? "Save" : "Create"}
</button>
</div>
</div>
</Modal>
<!-- Stage Modal -->
<Modal
isOpen={showStageModal}
onClose={() => (showStageModal = false)}
title="Add Stage"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label
for="stage-name"
class="text-body-sm text-light/60 font-body">Name</label
>
<input
id="stage-name"
type="text"
bind:value={stageName}
placeholder="e.g. Main Stage, Room A"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-body-sm text-light/60 font-body">Color</span>
<div class="flex items-center gap-2">
{#each PRESET_COLORS as c}
<button
type="button"
class="w-6 h-6 rounded-full border-2 transition-all {stageColor ===
c
? 'border-white scale-110'
: 'border-transparent hover:border-light/30'}"
style="background-color: {c}"
onclick={() => (stageColor = c)}
aria-label="Color {c}"
></button>
{/each}
</div>
</div>
<div
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
>
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showStageModal = false)}>Cancel</button
>
<button
type="button"
disabled={!stageName.trim()}
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={handleCreateStage}
>
Create
</button>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,571 @@
<script lang="ts">
import type { SponsorTier, Sponsor, SponsorDeliverable } from '$lib/supabase/types';
import { STATUS_LABELS, STATUS_COLORS } from '$lib/api/sponsors';
interface Props {
tiers: SponsorTier[];
sponsors: Sponsor[];
deliverables: SponsorDeliverable[];
isEditor: boolean;
fullscreen?: boolean;
onCreateTier: (name: string, amount: number, color: string) => void;
onDeleteTier: (tierId: string) => void;
onCreateSponsor: (params: {
name: string;
tier_id?: string | null;
contact_name?: string;
contact_email?: string;
contact_phone?: string;
website?: string;
status?: string;
amount?: number;
notes?: string;
}) => void;
onUpdateSponsor: (
sponsorId: string,
params: Partial<Pick<Sponsor, 'name' | 'tier_id' | 'contact_name' | 'contact_email' | 'contact_phone' | 'website' | 'status' | 'amount' | 'notes'>>
) => void;
onDeleteSponsor: (sponsorId: string) => void;
onCreateDeliverable: (sponsorId: string, description: string, dueDate?: string) => void;
onToggleDeliverable: (deliverableId: string, completed: boolean) => void;
onDeleteDeliverable: (deliverableId: string) => void;
}
let {
tiers,
sponsors,
deliverables,
isEditor,
fullscreen = false,
onCreateTier,
onDeleteTier,
onCreateSponsor,
onUpdateSponsor,
onDeleteSponsor,
onCreateDeliverable,
onToggleDeliverable,
onDeleteDeliverable,
}: Props = $props();
let filterStatus = $state<string>('all');
let filterTier = $state<string>('all');
let expandedSponsor = $state<string | null>(null);
let showAddSponsorModal = $state(false);
let showAddTierModal = $state(false);
let editingSponsor = $state<Sponsor | null>(null);
let newDeliverableText = $state('');
// Form state
let formName = $state('');
let formTierId = $state<string | null>(null);
let formContactName = $state('');
let formContactEmail = $state('');
let formContactPhone = $state('');
let formWebsite = $state('');
let formStatus = $state('prospect');
let formAmount = $state('0');
let formNotes = $state('');
// Tier form
let tierName = $state('');
let tierAmount = $state('0');
let tierColor = $state('#F59E0B');
const TIER_COLORS = ['#F59E0B', '#94a3b8', '#CD7F32', '#6366f1', '#10B981', '#EC4899', '#EF4444', '#06B6D4'];
const STATUSES = ['prospect', 'contacted', 'confirmed', 'declined', 'active'] as const;
// Computed
const totalCommitted = $derived(
sponsors.filter((s) => s.status === 'confirmed' || s.status === 'active').reduce((sum, s) => sum + Number(s.amount), 0)
);
const totalProspect = $derived(
sponsors.filter((s) => s.status === 'prospect' || s.status === 'contacted').reduce((sum, s) => sum + Number(s.amount), 0)
);
const filteredSponsors = $derived(
sponsors.filter((s) => {
if (filterStatus !== 'all' && s.status !== filterStatus) return false;
if (filterTier !== 'all' && (s.tier_id ?? 'none') !== filterTier) return false;
return true;
})
);
function getTierName(tierId: string | null): string {
if (!tierId) return 'No Tier';
return tiers.find((t) => t.id === tierId)?.name ?? 'No Tier';
}
function getTierColor(tierId: string | null): string {
if (!tierId) return '#64748b';
return tiers.find((t) => t.id === tierId)?.color ?? '#64748b';
}
function getDeliverables(sponsorId: string): SponsorDeliverable[] {
return deliverables.filter((d) => d.sponsor_id === sponsorId);
}
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(amount);
}
function openAddSponsor() {
editingSponsor = null;
formName = '';
formTierId = null;
formContactName = '';
formContactEmail = '';
formContactPhone = '';
formWebsite = '';
formStatus = 'prospect';
formAmount = '0';
formNotes = '';
showAddSponsorModal = true;
}
function openEditSponsor(sponsor: Sponsor) {
editingSponsor = sponsor;
formName = sponsor.name;
formTierId = sponsor.tier_id;
formContactName = sponsor.contact_name ?? '';
formContactEmail = sponsor.contact_email ?? '';
formContactPhone = sponsor.contact_phone ?? '';
formWebsite = sponsor.website ?? '';
formStatus = sponsor.status;
formAmount = String(sponsor.amount);
formNotes = sponsor.notes ?? '';
showAddSponsorModal = true;
}
function handleSubmitSponsor() {
if (!formName.trim()) return;
if (editingSponsor) {
onUpdateSponsor(editingSponsor.id, {
name: formName.trim(),
tier_id: formTierId,
contact_name: formContactName.trim() || undefined,
contact_email: formContactEmail.trim() || undefined,
contact_phone: formContactPhone.trim() || undefined,
website: formWebsite.trim() || undefined,
status: formStatus as Sponsor['status'],
amount: parseFloat(formAmount) || 0,
notes: formNotes.trim() || undefined,
});
} else {
onCreateSponsor({
name: formName.trim(),
tier_id: formTierId,
contact_name: formContactName.trim() || undefined,
contact_email: formContactEmail.trim() || undefined,
contact_phone: formContactPhone.trim() || undefined,
website: formWebsite.trim() || undefined,
status: formStatus,
amount: parseFloat(formAmount) || 0,
notes: formNotes.trim() || undefined,
});
}
showAddSponsorModal = false;
}
function handleAddTier() {
if (!tierName.trim()) return;
onCreateTier(tierName.trim(), parseFloat(tierAmount) || 0, tierColor);
tierName = '';
tierAmount = '0';
tierColor = '#F59E0B';
showAddTierModal = false;
}
function handleAddDeliverable(sponsorId: string) {
if (!newDeliverableText.trim()) return;
onCreateDeliverable(sponsorId, newDeliverableText.trim());
newDeliverableText = '';
}
</script>
<div class="flex flex-col gap-3 {fullscreen ? 'h-full' : ''}">
<!-- Summary -->
<div class="grid grid-cols-2 {fullscreen ? 'md:grid-cols-4' : ''} gap-2">
<div class="bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-3">
<p class="text-[11px] text-emerald-400/70 uppercase tracking-wide">Confirmed</p>
<p class="text-body font-heading text-emerald-400">{formatCurrency(totalCommitted)}</p>
<p class="text-[11px] text-light/30">{sponsors.filter((s) => s.status === 'confirmed' || s.status === 'active').length} sponsors</p>
</div>
<div class="bg-amber-500/10 border border-amber-500/20 rounded-xl p-3">
<p class="text-[11px] text-amber-400/70 uppercase tracking-wide">Pipeline</p>
<p class="text-body font-heading text-amber-400">{formatCurrency(totalProspect)}</p>
<p class="text-[11px] text-light/30">{sponsors.filter((s) => s.status === 'prospect' || s.status === 'contacted').length} prospects</p>
</div>
{#if fullscreen}
<div class="bg-light/5 border border-light/10 rounded-xl p-3">
<p class="text-[11px] text-light/50 uppercase tracking-wide">Total Sponsors</p>
<p class="text-body font-heading text-white">{sponsors.length}</p>
</div>
<div class="bg-indigo-500/10 border border-indigo-500/20 rounded-xl p-3">
<p class="text-[11px] text-indigo-400/70 uppercase tracking-wide">Tiers</p>
<p class="text-body font-heading text-indigo-400">{tiers.length}</p>
</div>
{/if}
</div>
<!-- Toolbar -->
<div class="flex items-center justify-between gap-2 flex-wrap">
<div class="flex items-center gap-2">
<select
class="bg-dark/50 border border-light/10 rounded-lg px-2 py-1 text-[11px] text-light/60 focus:outline-none focus:border-primary/50"
bind:value={filterStatus}
>
<option value="all">All Statuses</option>
{#each STATUSES as status}
<option value={status}>{STATUS_LABELS[status]}</option>
{/each}
</select>
{#if tiers.length > 0}
<select
class="bg-dark/50 border border-light/10 rounded-lg px-2 py-1 text-[11px] text-light/60 focus:outline-none focus:border-primary/50"
bind:value={filterTier}
>
<option value="all">All Tiers</option>
<option value="none">No Tier</option>
{#each tiers as tier}
<option value={tier.id}>{tier.name}</option>
{/each}
</select>
{/if}
</div>
{#if isEditor}
<div class="flex items-center gap-1">
{#if fullscreen}
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg bg-light/5 hover:bg-light/10 text-light/60 text-[11px] transition-colors"
onclick={() => (showAddTierModal = true)}
>
<span class="material-symbols-rounded" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;">workspace_premium</span>
Tiers
</button>
{/if}
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg bg-primary/10 hover:bg-primary/20 text-primary text-[11px] transition-colors"
onclick={openAddSponsor}
>
<span class="material-symbols-rounded" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;">add</span>
Add Sponsor
</button>
</div>
{/if}
</div>
<!-- Sponsors list -->
<div class="flex-1 overflow-auto space-y-1">
{#if filteredSponsors.length === 0}
<div class="flex flex-col items-center justify-center py-8 text-light/30 gap-2">
<span class="material-symbols-rounded" style="font-size: 32px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 32;">handshake</span>
<p class="text-body-sm">No sponsors yet</p>
</div>
{:else}
{#each filteredSponsors as sponsor (sponsor.id)}
{@const sponsorDeliverables = getDeliverables(sponsor.id)}
{@const completedCount = sponsorDeliverables.filter((d) => d.is_completed).length}
{@const isExpanded = expandedSponsor === sponsor.id}
<div class="rounded-xl border border-light/5 overflow-hidden transition-colors {isExpanded ? 'bg-light/5' : 'hover:bg-light/[0.03]'}">
<!-- Sponsor row -->
<button
class="w-full flex items-center gap-3 px-3 py-2.5 text-left"
onclick={() => (expandedSponsor = isExpanded ? null : sponsor.id)}
>
<!-- Status dot -->
<span class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background-color: {STATUS_COLORS[sponsor.status]}"></span>
<!-- Name + tier -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-body-sm text-white font-heading truncate">{sponsor.name}</span>
<span
class="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] uppercase tracking-wider flex-shrink-0"
style="background-color: {getTierColor(sponsor.tier_id)}20; color: {getTierColor(sponsor.tier_id)}"
>
{getTierName(sponsor.tier_id)}
</span>
</div>
{#if sponsor.contact_name}
<p class="text-[11px] text-light/40 truncate">{sponsor.contact_name}</p>
{/if}
</div>
<!-- Amount -->
<span class="text-body-sm font-heading text-white flex-shrink-0">{formatCurrency(Number(sponsor.amount))}</span>
<!-- Status badge -->
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] flex-shrink-0"
style="background-color: {STATUS_COLORS[sponsor.status]}20; color: {STATUS_COLORS[sponsor.status]}"
>
{STATUS_LABELS[sponsor.status]}
</span>
<!-- Deliverables count -->
{#if sponsorDeliverables.length > 0}
<span class="text-[10px] text-light/30 flex-shrink-0">{completedCount}/{sponsorDeliverables.length}</span>
{/if}
<!-- Expand icon -->
<span class="material-symbols-rounded text-light/30 transition-transform {isExpanded ? 'rotate-180' : ''}" style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;">expand_more</span>
</button>
<!-- Expanded details -->
{#if isExpanded}
<div class="px-3 pb-3 space-y-3 border-t border-light/5 pt-3">
<!-- Contact info -->
<div class="grid grid-cols-2 gap-2 text-[11px]">
{#if sponsor.contact_email}
<a href="mailto:{sponsor.contact_email}" class="flex items-center gap-1 text-primary hover:underline">
<span class="material-symbols-rounded" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;">mail</span>
{sponsor.contact_email}
</a>
{/if}
{#if sponsor.contact_phone}
<a href="tel:{sponsor.contact_phone}" class="flex items-center gap-1 text-primary hover:underline">
<span class="material-symbols-rounded" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;">phone</span>
{sponsor.contact_phone}
</a>
{/if}
{#if sponsor.website}
<a href={sponsor.website} target="_blank" rel="noopener" class="flex items-center gap-1 text-primary hover:underline">
<span class="material-symbols-rounded" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;">language</span>
Website
</a>
{/if}
</div>
{#if sponsor.notes}
<p class="text-[11px] text-light/40 bg-dark/30 rounded-lg px-2 py-1.5">{sponsor.notes}</p>
{/if}
<!-- Deliverables -->
<div>
<p class="text-[10px] text-light/40 uppercase tracking-wider mb-1.5">Deliverables</p>
{#if sponsorDeliverables.length > 0}
<div class="space-y-1">
{#each sponsorDeliverables as del (del.id)}
<div class="flex items-center gap-2 group">
<button
class="w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 transition-colors {del.is_completed ? 'bg-primary border-primary' : 'border-light/20 hover:border-primary/50'}"
onclick={() => onToggleDeliverable(del.id, !del.is_completed)}
disabled={!isEditor}
>
{#if del.is_completed}
<span class="material-symbols-rounded text-background" style="font-size: 12px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 12;">check</span>
{/if}
</button>
<span class="text-[11px] flex-1 {del.is_completed ? 'text-light/30 line-through' : 'text-light/70'}">{del.description}</span>
{#if del.due_date}
<span class="text-[10px] text-light/30">{new Date(del.due_date).toLocaleDateString()}</span>
{/if}
{#if isEditor}
<button
class="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-error/10 transition-all"
onclick={() => onDeleteDeliverable(del.id)}
>
<span class="material-symbols-rounded text-light/30 hover:text-error" style="font-size: 12px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 12;">close</span>
</button>
{/if}
</div>
{/each}
</div>
{/if}
{#if isEditor}
<div class="flex items-center gap-2 mt-1.5">
<input
type="text"
bind:value={newDeliverableText}
placeholder="Add deliverable..."
class="flex-1 bg-dark/30 border border-light/10 rounded px-2 py-1 text-[11px] text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50"
onkeydown={(e) => e.key === 'Enter' && handleAddDeliverable(sponsor.id)}
/>
<button
class="p-1 rounded hover:bg-primary/10 transition-colors"
onclick={() => handleAddDeliverable(sponsor.id)}
>
<span class="material-symbols-rounded text-primary" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;">add</span>
</button>
</div>
{/if}
</div>
<!-- Actions -->
{#if isEditor}
<div class="flex items-center gap-2 pt-1">
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg bg-light/5 hover:bg-light/10 text-light/60 text-[11px] transition-colors"
onclick={() => openEditSponsor(sponsor)}
>
<span class="material-symbols-rounded" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;">edit</span>
Edit
</button>
<button
class="flex items-center gap-1 px-2 py-1 rounded-lg hover:bg-error/10 text-light/40 hover:text-error text-[11px] transition-colors"
onclick={() => onDeleteSponsor(sponsor.id)}
>
<span class="material-symbols-rounded" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;">delete</span>
Delete
</button>
</div>
{/if}
</div>
{/if}
</div>
{/each}
{/if}
</div>
</div>
<!-- Add/Edit Sponsor Modal -->
{#if showAddSponsorModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-[60] bg-black/60 flex items-center justify-center p-4" onclick={() => (showAddSponsorModal = false)} onkeydown={(e) => e.key === 'Escape' && (showAddSponsorModal = false)}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="bg-surface rounded-2xl border border-light/10 p-5 w-full max-w-md space-y-4 max-h-[80vh] overflow-auto" onclick={(e) => e.stopPropagation()}>
<h3 class="text-body font-heading text-white">{editingSponsor ? 'Edit' : 'Add'} Sponsor</h3>
<div class="space-y-3">
<div>
<label class="block text-[11px] text-light/50 mb-1" for="sp-name">Sponsor Name</label>
<input id="sp-name" type="text" bind:value={formName} placeholder="e.g. Acme Corp"
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11px] text-light/50 mb-1" for="sp-tier">Tier</label>
<select id="sp-tier" bind:value={formTierId}
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary/50">
<option value={null}>No Tier</option>
{#each tiers as tier}
<option value={tier.id}>{tier.name}</option>
{/each}
</select>
</div>
<div>
<label class="block text-[11px] text-light/50 mb-1" for="sp-status">Status</label>
<select id="sp-status" bind:value={formStatus}
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary/50">
{#each STATUSES as status}
<option value={status}>{STATUS_LABELS[status]}</option>
{/each}
</select>
</div>
</div>
<div>
<label class="block text-[11px] text-light/50 mb-1" for="sp-amount">Sponsorship Amount</label>
<input id="sp-amount" type="number" step="0.01" bind:value={formAmount}
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary/50" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11px] text-light/50 mb-1" for="sp-contact">Contact Name</label>
<input id="sp-contact" type="text" bind:value={formContactName} placeholder="John Doe"
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50" />
</div>
<div>
<label class="block text-[11px] text-light/50 mb-1" for="sp-email">Contact Email</label>
<input id="sp-email" type="email" bind:value={formContactEmail} placeholder="john@acme.com"
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-[11px] text-light/50 mb-1" for="sp-phone">Contact Phone</label>
<input id="sp-phone" type="tel" bind:value={formContactPhone} placeholder="+1 555 0123"
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50" />
</div>
<div>
<label class="block text-[11px] text-light/50 mb-1" for="sp-web">Website</label>
<input id="sp-web" type="url" bind:value={formWebsite} placeholder="https://acme.com"
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50" />
</div>
</div>
<div>
<label class="block text-[11px] text-light/50 mb-1" for="sp-notes">Notes</label>
<textarea id="sp-notes" bind:value={formNotes} rows="2" placeholder="Internal notes..."
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50 resize-none"></textarea>
</div>
</div>
<div class="flex justify-end gap-2">
<button class="px-3 py-1.5 rounded-lg text-body-sm text-light/60 hover:text-white transition-colors" onclick={() => (showAddSponsorModal = false)}>Cancel</button>
<button class="px-3 py-1.5 rounded-lg bg-primary text-background text-body-sm font-heading hover:bg-primary/90 transition-colors" onclick={handleSubmitSponsor}>
{editingSponsor ? 'Save' : 'Add'}
</button>
</div>
</div>
</div>
{/if}
<!-- Add Tier Modal -->
{#if showAddTierModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-[60] bg-black/60 flex items-center justify-center p-4" onclick={() => (showAddTierModal = false)} onkeydown={(e) => e.key === 'Escape' && (showAddTierModal = false)}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="bg-surface rounded-2xl border border-light/10 p-5 w-full max-w-sm space-y-4" onclick={(e) => e.stopPropagation()}>
<h3 class="text-body font-heading text-white">Manage Tiers</h3>
<div class="space-y-3">
<div>
<label class="block text-[11px] text-light/50 mb-1" for="tier-name">Tier Name</label>
<input id="tier-name" type="text" bind:value={tierName} placeholder="e.g. Gold"
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50" />
</div>
<div>
<label class="block text-[11px] text-light/50 mb-1" for="tier-amount">Min. Amount</label>
<input id="tier-amount" type="number" step="0.01" bind:value={tierAmount}
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary/50" />
</div>
<div>
<p class="text-[11px] text-light/50 mb-1">Color</p>
<div class="flex gap-1.5">
{#each TIER_COLORS as color}
<button
class="w-6 h-6 rounded-full border-2 transition-all {tierColor === color ? 'border-white scale-110' : 'border-transparent'}"
style="background-color: {color}"
onclick={() => (tierColor = color)}
></button>
{/each}
</div>
</div>
<!-- Existing tiers -->
{#if tiers.length > 0}
<div>
<p class="text-[11px] text-light/50 mb-1">Existing Tiers</p>
<div class="space-y-1">
{#each tiers as tier}
<div class="flex items-center justify-between px-2 py-1 rounded-lg bg-dark/30">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full" style="background-color: {tier.color}"></span>
<span class="text-[11px] text-white">{tier.name}</span>
<span class="text-[10px] text-light/30">{formatCurrency(Number(tier.amount))}</span>
</div>
{#if isEditor}
<button class="p-0.5 rounded hover:bg-error/10 transition-colors" onclick={() => onDeleteTier(tier.id)}>
<span class="material-symbols-rounded text-light/30 hover:text-error" style="font-size: 12px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 12;">close</span>
</button>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
<div class="flex justify-end gap-2">
<button class="px-3 py-1.5 rounded-lg text-body-sm text-light/60 hover:text-white transition-colors" onclick={() => (showAddTierModal = false)}>Close</button>
<button class="px-3 py-1.5 rounded-lg bg-primary text-background text-body-sm font-heading hover:bg-primary/90 transition-colors" onclick={handleAddTier}>
Add Tier
</button>
</div>
</div>
</div>
{/if}