Mega push vol 7 mvp lesgoooo
This commit is contained in:
438
src/lib/components/modules/BudgetWidget.svelte
Normal file
438
src/lib/components/modules/BudgetWidget.svelte
Normal 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}
|
||||
279
src/lib/components/modules/ChecklistWidget.svelte
Normal file
279
src/lib/components/modules/ChecklistWidget.svelte
Normal 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>
|
||||
547
src/lib/components/modules/ContactsWidget.svelte
Normal file
547
src/lib/components/modules/ContactsWidget.svelte
Normal 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>
|
||||
32
src/lib/components/modules/FilesWidget.svelte
Normal file
32
src/lib/components/modules/FilesWidget.svelte
Normal 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>
|
||||
37
src/lib/components/modules/KanbanWidget.svelte
Normal file
37
src/lib/components/modules/KanbanWidget.svelte
Normal 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>
|
||||
178
src/lib/components/modules/NotesWidget.svelte
Normal file
178
src/lib/components/modules/NotesWidget.svelte
Normal 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>
|
||||
660
src/lib/components/modules/ScheduleWidget.svelte
Normal file
660
src/lib/components/modules/ScheduleWidget.svelte
Normal 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>
|
||||
571
src/lib/components/modules/SponsorsWidget.svelte
Normal file
571
src/lib/components/modules/SponsorsWidget.svelte
Normal 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}
|
||||
Reference in New Issue
Block a user