Mega push vol 7 mvp lesgoooo

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

View File

@@ -387,5 +387,28 @@
"chat_joining": "Setting up your account...", "chat_joining": "Setting up your account...",
"chat_join_success": "Chat account created! Welcome.", "chat_join_success": "Chat account created! Welcome.",
"chat_join_error": "Failed to set up chat. Please try again.", "chat_join_error": "Failed to set up chat. Please try again.",
"chat_disconnect": "Disconnect from Chat" "chat_disconnect": "Disconnect from Chat",
"dept_dashboard_no_modules": "No modules configured yet",
"dept_dashboard_add_first": "Add your first module",
"dept_dashboard_add_module": "Add Module",
"dept_dashboard_all_added": "All modules are already added",
"dept_dashboard_expand": "Expand",
"dept_dashboard_remove_module": "Remove module",
"dept_dashboard_coming_soon": "Coming soon",
"dept_dashboard_module_coming_soon": "Module coming soon",
"dept_dashboard_departments": "Departments",
"dept_checklist_no_items": "No checklists yet",
"dept_checklist_add": "Add checklist",
"dept_checklist_add_item": "Add item...",
"dept_notes_no_notes": "No notes yet",
"dept_notes_new": "New note",
"dept_notes_select": "Select a note",
"dept_notes_placeholder": "Start writing...",
"dept_notes_title_placeholder": "Note title...",
"dept_kanban_open": "Open Tasks Board",
"dept_kanban_desc": "Task board for this department",
"dept_files_open": "Open Files",
"dept_files_desc": "Department files and documents",
"dept_quick_add": "Quick add",
"dept_modules_label": "Modules"
} }

View File

@@ -387,5 +387,28 @@
"chat_joining": "Konto seadistamine...", "chat_joining": "Konto seadistamine...",
"chat_join_success": "Vestluskonto loodud! Tere tulemast.", "chat_join_success": "Vestluskonto loodud! Tere tulemast.",
"chat_join_error": "Vestluse seadistamine ebaõnnestus. Proovi uuesti.", "chat_join_error": "Vestluse seadistamine ebaõnnestus. Proovi uuesti.",
"chat_disconnect": "Katkesta vestlusühendus" "chat_disconnect": "Katkesta vestlusühendus",
"dept_dashboard_no_modules": "Mooduleid pole veel seadistatud",
"dept_dashboard_add_first": "Lisa oma esimene moodul",
"dept_dashboard_add_module": "Lisa moodul",
"dept_dashboard_all_added": "Kõik moodulid on juba lisatud",
"dept_dashboard_expand": "Laienda",
"dept_dashboard_remove_module": "Eemalda moodul",
"dept_dashboard_coming_soon": "Tulekul",
"dept_dashboard_module_coming_soon": "Moodul tulekul",
"dept_dashboard_departments": "Osakonnad",
"dept_checklist_no_items": "Kontrollnimekirju pole veel",
"dept_checklist_add": "Lisa kontrollnimekiri",
"dept_checklist_add_item": "Lisa üksus...",
"dept_notes_no_notes": "Märkmeid pole veel",
"dept_notes_new": "Uus märge",
"dept_notes_select": "Vali märge",
"dept_notes_placeholder": "Alusta kirjutamist...",
"dept_notes_title_placeholder": "Märkme pealkiri...",
"dept_kanban_open": "Ava ülesannete tahvel",
"dept_kanban_desc": "Selle osakonna ülesannete tahvel",
"dept_files_open": "Ava failid",
"dept_files_desc": "Osakonna failid ja dokumendid",
"dept_quick_add": "Kiirvalik",
"dept_modules_label": "Moodulid"
} }

View File

@@ -9,6 +9,13 @@
content="width=device-width, initial-scale=1" content="width=device-width, initial-scale=1"
/> />
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="root" />
<link rel="manifest" href="/site.webmanifest" />
%sveltekit.head% %sveltekit.head%
</head> </head>

View File

@@ -46,18 +46,14 @@ const originalHandle: Handle = async ({ event, resolve }) => {
}); });
event.locals.safeGetSession = async () => { event.locals.safeGetSession = async () => {
const { data: { session } } = await event.locals.supabase.auth.getSession();
if (!session) {
return { session: null, user: null };
}
const { data: { user }, error } = await event.locals.supabase.auth.getUser(); const { data: { user }, error } = await event.locals.supabase.auth.getUser();
if (error) { if (error || !user) {
return { session: null, user: null }; return { session: null, user: null };
} }
const { data: { session } } = await event.locals.supabase.auth.getSession();
return { session, user }; return { session, user };
}; };

176
src/lib/api/budget.ts Normal file
View File

@@ -0,0 +1,176 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { BudgetCategory, BudgetItem } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.budget');
// Helper to cast supabase for tables not yet in generated types
function db(supabase: SupabaseClient) {
return supabase as any;
}
// ============================================================
// Budget Categories
// ============================================================
export async function fetchBudgetCategories(
supabase: SupabaseClient,
departmentId: string
): Promise<BudgetCategory[]> {
const { data, error } = await db(supabase)
.from('budget_categories')
.select('*')
.eq('department_id', departmentId)
.order('sort_order');
if (error) {
log.error('fetchBudgetCategories failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as BudgetCategory[];
}
export async function createBudgetCategory(
supabase: SupabaseClient,
departmentId: string,
name: string,
color?: string
): Promise<BudgetCategory> {
const { data, error } = await db(supabase)
.from('budget_categories')
.insert({
department_id: departmentId,
name,
color: color ?? '#6366f1',
})
.select()
.single();
if (error) {
log.error('createBudgetCategory failed', { error, data: { departmentId, name } });
throw error;
}
return data as BudgetCategory;
}
export async function updateBudgetCategory(
supabase: SupabaseClient,
categoryId: string,
params: Partial<Pick<BudgetCategory, 'name' | 'color' | 'sort_order'>>
): Promise<BudgetCategory> {
const { data, error } = await db(supabase)
.from('budget_categories')
.update(params)
.eq('id', categoryId)
.select()
.single();
if (error) {
log.error('updateBudgetCategory failed', { error, data: { categoryId } });
throw error;
}
return data as BudgetCategory;
}
export async function deleteBudgetCategory(
supabase: SupabaseClient,
categoryId: string
): Promise<void> {
const { error } = await db(supabase)
.from('budget_categories')
.delete()
.eq('id', categoryId);
if (error) {
log.error('deleteBudgetCategory failed', { error, data: { categoryId } });
throw error;
}
}
// ============================================================
// Budget Items
// ============================================================
export async function fetchBudgetItems(
supabase: SupabaseClient,
departmentId: string
): Promise<BudgetItem[]> {
const { data, error } = await db(supabase)
.from('budget_items')
.select('*')
.eq('department_id', departmentId)
.order('sort_order');
if (error) {
log.error('fetchBudgetItems failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as BudgetItem[];
}
export async function createBudgetItem(
supabase: SupabaseClient,
departmentId: string,
params: {
description: string;
item_type: 'income' | 'expense';
planned_amount?: number;
actual_amount?: number;
category_id?: string | null;
notes?: string;
}
): Promise<BudgetItem> {
const { data, error } = await db(supabase)
.from('budget_items')
.insert({
department_id: departmentId,
description: params.description,
item_type: params.item_type,
planned_amount: params.planned_amount ?? 0,
actual_amount: params.actual_amount ?? 0,
category_id: params.category_id ?? null,
notes: params.notes ?? null,
})
.select()
.single();
if (error) {
log.error('createBudgetItem failed', { error, data: { departmentId, description: params.description } });
throw error;
}
return data as BudgetItem;
}
export async function updateBudgetItem(
supabase: SupabaseClient,
itemId: string,
params: Partial<Pick<BudgetItem, 'description' | 'item_type' | 'planned_amount' | 'actual_amount' | 'category_id' | 'notes' | 'sort_order'>>
): Promise<BudgetItem> {
const { data, error } = await db(supabase)
.from('budget_items')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', itemId)
.select()
.single();
if (error) {
log.error('updateBudgetItem failed', { error, data: { itemId } });
throw error;
}
return data as BudgetItem;
}
export async function deleteBudgetItem(
supabase: SupabaseClient,
itemId: string
): Promise<void> {
const { error } = await db(supabase)
.from('budget_items')
.delete()
.eq('id', itemId);
if (error) {
log.error('deleteBudgetItem failed', { error, data: { itemId } });
throw error;
}
}

147
src/lib/api/contacts.ts Normal file
View File

@@ -0,0 +1,147 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { DepartmentContact } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.contacts');
// Helper to cast supabase for tables not yet in generated types
function db(supabase: SupabaseClient) {
return supabase as any;
}
export const CONTACT_CATEGORIES = [
'general',
'vendor',
'sponsor',
'speaker',
'venue',
'catering',
'av_tech',
'transport',
'security',
'media',
] as const;
export type ContactCategory = (typeof CONTACT_CATEGORIES)[number];
export const CATEGORY_LABELS: Record<string, string> = {
general: 'General',
vendor: 'Vendor',
sponsor: 'Sponsor',
speaker: 'Speaker',
venue: 'Venue',
catering: 'Catering',
av_tech: 'AV / Tech',
transport: 'Transport',
security: 'Security',
media: 'Media',
};
export const CATEGORY_ICONS: Record<string, string> = {
general: 'person',
vendor: 'storefront',
sponsor: 'handshake',
speaker: 'mic',
venue: 'location_on',
catering: 'restaurant',
av_tech: 'settings_input_hdmi',
transport: 'local_shipping',
security: 'shield',
media: 'videocam',
};
// ============================================================
// CRUD
// ============================================================
export async function fetchContacts(
supabase: SupabaseClient,
departmentId: string
): Promise<DepartmentContact[]> {
const { data, error } = await db(supabase)
.from('department_contacts')
.select('*')
.eq('department_id', departmentId)
.order('name');
if (error) {
log.error('fetchContacts failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as DepartmentContact[];
}
export async function createContact(
supabase: SupabaseClient,
departmentId: string,
params: {
name: string;
role?: string;
company?: string;
email?: string;
phone?: string;
website?: string;
notes?: string;
category?: string;
color?: string;
},
userId?: string
): Promise<DepartmentContact> {
const { data, error } = await db(supabase)
.from('department_contacts')
.insert({
department_id: departmentId,
name: params.name,
role: params.role ?? null,
company: params.company ?? null,
email: params.email ?? null,
phone: params.phone ?? null,
website: params.website ?? null,
notes: params.notes ?? null,
category: params.category ?? 'general',
color: params.color ?? '#00A3E0',
created_by: userId ?? null,
})
.select()
.single();
if (error) {
log.error('createContact failed', { error, data: { departmentId, name: params.name } });
throw error;
}
return data as DepartmentContact;
}
export async function updateContact(
supabase: SupabaseClient,
contactId: string,
params: Partial<Pick<DepartmentContact, 'name' | 'role' | 'company' | 'email' | 'phone' | 'website' | 'notes' | 'category' | 'color'>>
): Promise<DepartmentContact> {
const { data, error } = await db(supabase)
.from('department_contacts')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', contactId)
.select()
.single();
if (error) {
log.error('updateContact failed', { error, data: { contactId } });
throw error;
}
return data as DepartmentContact;
}
export async function deleteContact(
supabase: SupabaseClient,
contactId: string
): Promise<void> {
const { error } = await db(supabase)
.from('department_contacts')
.delete()
.eq('id', contactId);
if (error) {
log.error('deleteContact failed', { error, data: { contactId } });
throw error;
}
}

View File

@@ -0,0 +1,354 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database, DepartmentDashboard, DashboardPanel, DepartmentChecklist, DepartmentChecklistItem, DepartmentNote, ModuleType, LayoutPreset } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.department-dashboard');
// ============================================================
// Dashboard
// ============================================================
export interface DashboardWithPanels extends DepartmentDashboard {
panels: DashboardPanel[];
}
export async function fetchDashboard(
supabase: SupabaseClient<Database>,
departmentId: string
): Promise<DashboardWithPanels | null> {
const { data, error } = await supabase
.from('department_dashboards')
.select('*, panels:dashboard_panels(*)')
.eq('department_id', departmentId)
.single();
if (error) {
if (error.code === 'PGRST116') return null;
log.error('fetchDashboard failed', { error, data: { departmentId } });
throw error;
}
const dashboard = data as any;
return {
...dashboard,
panels: (dashboard.panels ?? []).sort((a: DashboardPanel, b: DashboardPanel) => a.position - b.position),
};
}
export async function updateDashboardLayout(
supabase: SupabaseClient<Database>,
dashboardId: string,
layout: LayoutPreset
): Promise<DepartmentDashboard> {
const { data, error } = await supabase
.from('department_dashboards')
.update({ layout, updated_at: new Date().toISOString() })
.eq('id', dashboardId)
.select()
.single();
if (error) {
log.error('updateDashboardLayout failed', { error, data: { dashboardId, layout } });
throw error;
}
return data as unknown as DepartmentDashboard;
}
// ============================================================
// Panels
// ============================================================
export async function addPanel(
supabase: SupabaseClient<Database>,
dashboardId: string,
module: ModuleType,
position: number,
width: string = 'half'
): Promise<DashboardPanel> {
const { data, error } = await supabase
.from('dashboard_panels')
.insert({ dashboard_id: dashboardId, module, position, width })
.select()
.single();
if (error) {
log.error('addPanel failed', { error, data: { dashboardId, module } });
throw error;
}
return data as unknown as DashboardPanel;
}
export async function updatePanel(
supabase: SupabaseClient<Database>,
panelId: string,
params: Partial<Pick<DashboardPanel, 'position' | 'width' | 'config'>>
): Promise<DashboardPanel> {
const { data, error } = await supabase
.from('dashboard_panels')
.update(params)
.eq('id', panelId)
.select()
.single();
if (error) {
log.error('updatePanel failed', { error, data: { panelId } });
throw error;
}
return data as unknown as DashboardPanel;
}
export async function removePanel(
supabase: SupabaseClient<Database>,
panelId: string
): Promise<void> {
const { error } = await supabase
.from('dashboard_panels')
.delete()
.eq('id', panelId);
if (error) {
log.error('removePanel failed', { error, data: { panelId } });
throw error;
}
}
// ============================================================
// Checklists
// ============================================================
export interface ChecklistWithItems extends DepartmentChecklist {
items: DepartmentChecklistItem[];
}
export async function fetchChecklists(
supabase: SupabaseClient<Database>,
departmentId: string
): Promise<ChecklistWithItems[]> {
const { data: checklists, error } = await supabase
.from('department_checklists')
.select('*')
.eq('department_id', departmentId)
.order('sort_order');
if (error) {
log.error('fetchChecklists failed', { error, data: { departmentId } });
throw error;
}
if (!checklists || checklists.length === 0) return [];
const checklistIds = checklists.map(c => c.id);
const { data: items, error: itemsError } = await supabase
.from('department_checklist_items')
.select('*')
.in('checklist_id', checklistIds)
.order('sort_order');
if (itemsError) {
log.error('fetchChecklistItems failed', { error: itemsError });
throw itemsError;
}
const itemsByChecklist: Record<string, DepartmentChecklistItem[]> = {};
for (const item of (items ?? [])) {
if (!itemsByChecklist[item.checklist_id]) itemsByChecklist[item.checklist_id] = [];
itemsByChecklist[item.checklist_id].push(item as unknown as DepartmentChecklistItem);
}
return checklists.map(c => ({
...(c as unknown as DepartmentChecklist),
items: itemsByChecklist[c.id] ?? [],
}));
}
export async function createChecklist(
supabase: SupabaseClient<Database>,
departmentId: string,
title: string,
userId?: string
): Promise<DepartmentChecklist> {
const { data, error } = await supabase
.from('department_checklists')
.insert({ department_id: departmentId, title, created_by: userId ?? null })
.select()
.single();
if (error) {
log.error('createChecklist failed', { error, data: { departmentId, title } });
throw error;
}
return data as unknown as DepartmentChecklist;
}
export async function deleteChecklist(
supabase: SupabaseClient<Database>,
checklistId: string
): Promise<void> {
const { error } = await supabase
.from('department_checklists')
.delete()
.eq('id', checklistId);
if (error) {
log.error('deleteChecklist failed', { error, data: { checklistId } });
throw error;
}
}
export async function renameChecklist(
supabase: SupabaseClient<Database>,
checklistId: string,
title: string
): Promise<DepartmentChecklist> {
const { data, error } = await supabase
.from('department_checklists')
.update({ title })
.eq('id', checklistId)
.select()
.single();
if (error) {
log.error('renameChecklist failed', { error, data: { checklistId, title } });
throw error;
}
return data as unknown as DepartmentChecklist;
}
// ============================================================
// Checklist Items
// ============================================================
export async function addChecklistItem(
supabase: SupabaseClient<Database>,
checklistId: string,
content: string,
sortOrder: number = 0
): Promise<DepartmentChecklistItem> {
const { data, error } = await supabase
.from('department_checklist_items')
.insert({ checklist_id: checklistId, content, sort_order: sortOrder })
.select()
.single();
if (error) {
log.error('addChecklistItem failed', { error, data: { checklistId, content } });
throw error;
}
return data as unknown as DepartmentChecklistItem;
}
export async function updateChecklistItem(
supabase: SupabaseClient<Database>,
itemId: string,
params: Partial<Pick<DepartmentChecklistItem, 'content' | 'is_completed' | 'assigned_to' | 'due_date' | 'sort_order'>>
): Promise<DepartmentChecklistItem> {
const { data, error } = await supabase
.from('department_checklist_items')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', itemId)
.select()
.single();
if (error) {
log.error('updateChecklistItem failed', { error, data: { itemId } });
throw error;
}
return data as unknown as DepartmentChecklistItem;
}
export async function deleteChecklistItem(
supabase: SupabaseClient<Database>,
itemId: string
): Promise<void> {
const { error } = await supabase
.from('department_checklist_items')
.delete()
.eq('id', itemId);
if (error) {
log.error('deleteChecklistItem failed', { error, data: { itemId } });
throw error;
}
}
export async function toggleChecklistItem(
supabase: SupabaseClient<Database>,
itemId: string,
isCompleted: boolean
): Promise<DepartmentChecklistItem> {
return updateChecklistItem(supabase, itemId, { is_completed: isCompleted });
}
// ============================================================
// Notes
// ============================================================
export async function fetchNotes(
supabase: SupabaseClient<Database>,
departmentId: string
): Promise<DepartmentNote[]> {
const { data, error } = await supabase
.from('department_notes')
.select('*')
.eq('department_id', departmentId)
.order('sort_order');
if (error) {
log.error('fetchNotes failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as unknown as DepartmentNote[];
}
export async function createNote(
supabase: SupabaseClient<Database>,
departmentId: string,
title: string,
userId?: string
): Promise<DepartmentNote> {
const { data, error } = await supabase
.from('department_notes')
.insert({ department_id: departmentId, title, created_by: userId ?? null })
.select()
.single();
if (error) {
log.error('createNote failed', { error, data: { departmentId, title } });
throw error;
}
return data as unknown as DepartmentNote;
}
export async function updateNote(
supabase: SupabaseClient<Database>,
noteId: string,
params: Partial<Pick<DepartmentNote, 'title' | 'content' | 'sort_order'>>
): Promise<DepartmentNote> {
const { data, error } = await supabase
.from('department_notes')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', noteId)
.select()
.single();
if (error) {
log.error('updateNote failed', { error, data: { noteId } });
throw error;
}
return data as unknown as DepartmentNote;
}
export async function deleteNote(
supabase: SupabaseClient<Database>,
noteId: string
): Promise<void> {
const { error } = await supabase
.from('department_notes')
.delete()
.eq('id', noteId);
if (error) {
log.error('deleteNote failed', { error, data: { noteId } });
throw error;
}
}

176
src/lib/api/schedule.ts Normal file
View File

@@ -0,0 +1,176 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { ScheduleStage, ScheduleBlock } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.schedule');
// Helper to cast supabase for tables not yet in generated types
function db(supabase: SupabaseClient) {
return supabase as any;
}
// ============================================================
// Stages
// ============================================================
export async function fetchStages(
supabase: SupabaseClient,
departmentId: string
): Promise<ScheduleStage[]> {
const { data, error } = await db(supabase)
.from('schedule_stages')
.select('*')
.eq('department_id', departmentId)
.order('sort_order');
if (error) {
log.error('fetchStages failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as ScheduleStage[];
}
export async function createStage(
supabase: SupabaseClient,
departmentId: string,
name: string,
color?: string
): Promise<ScheduleStage> {
const { data, error } = await db(supabase)
.from('schedule_stages')
.insert({ department_id: departmentId, name, color: color ?? '#6366f1' })
.select()
.single();
if (error) {
log.error('createStage failed', { error, data: { departmentId, name } });
throw error;
}
return data as ScheduleStage;
}
export async function updateStage(
supabase: SupabaseClient,
stageId: string,
params: Partial<Pick<ScheduleStage, 'name' | 'color' | 'sort_order'>>
): Promise<ScheduleStage> {
const { data, error } = await db(supabase)
.from('schedule_stages')
.update(params)
.eq('id', stageId)
.select()
.single();
if (error) {
log.error('updateStage failed', { error, data: { stageId } });
throw error;
}
return data as ScheduleStage;
}
export async function deleteStage(
supabase: SupabaseClient,
stageId: string
): Promise<void> {
const { error } = await db(supabase)
.from('schedule_stages')
.delete()
.eq('id', stageId);
if (error) {
log.error('deleteStage failed', { error, data: { stageId } });
throw error;
}
}
// ============================================================
// Blocks
// ============================================================
export async function fetchBlocks(
supabase: SupabaseClient,
departmentId: string
): Promise<ScheduleBlock[]> {
const { data, error } = await db(supabase)
.from('schedule_blocks')
.select('*')
.eq('department_id', departmentId)
.order('start_time');
if (error) {
log.error('fetchBlocks failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as ScheduleBlock[];
}
export async function createBlock(
supabase: SupabaseClient,
departmentId: string,
params: {
title: string;
start_time: string;
end_time: string;
stage_id?: string | null;
description?: string;
color?: string;
speaker?: string;
},
userId?: string
): Promise<ScheduleBlock> {
const { data, error } = await db(supabase)
.from('schedule_blocks')
.insert({
department_id: departmentId,
title: params.title,
start_time: params.start_time,
end_time: params.end_time,
stage_id: params.stage_id ?? null,
description: params.description ?? null,
color: params.color ?? '#6366f1',
speaker: params.speaker ?? null,
created_by: userId ?? null,
})
.select()
.single();
if (error) {
log.error('createBlock failed', { error, data: { departmentId, title: params.title } });
throw error;
}
return data as ScheduleBlock;
}
export async function updateBlock(
supabase: SupabaseClient,
blockId: string,
params: Partial<Pick<ScheduleBlock, 'title' | 'description' | 'start_time' | 'end_time' | 'stage_id' | 'color' | 'speaker' | 'sort_order'>>
): Promise<ScheduleBlock> {
const { data, error } = await db(supabase)
.from('schedule_blocks')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', blockId)
.select()
.single();
if (error) {
log.error('updateBlock failed', { error, data: { blockId } });
throw error;
}
return data as ScheduleBlock;
}
export async function deleteBlock(
supabase: SupabaseClient,
blockId: string
): Promise<void> {
const { error } = await db(supabase)
.from('schedule_blocks')
.delete()
.eq('id', blockId);
if (error) {
log.error('deleteBlock failed', { error, data: { blockId } });
throw error;
}
}

301
src/lib/api/sponsors.ts Normal file
View File

@@ -0,0 +1,301 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { SponsorTier, Sponsor, SponsorDeliverable } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.sponsors');
// Helper to cast supabase for tables not yet in generated types
function db(supabase: SupabaseClient) {
return supabase as any;
}
export const SPONSOR_STATUSES = ['prospect', 'contacted', 'confirmed', 'declined', 'active'] as const;
export type SponsorStatus = (typeof SPONSOR_STATUSES)[number];
export const STATUS_LABELS: Record<string, string> = {
prospect: 'Prospect',
contacted: 'Contacted',
confirmed: 'Confirmed',
declined: 'Declined',
active: 'Active',
};
export const STATUS_COLORS: Record<string, string> = {
prospect: '#94a3b8',
contacted: '#F59E0B',
confirmed: '#10B981',
declined: '#EF4444',
active: '#6366f1',
};
// ============================================================
// Sponsor Tiers
// ============================================================
export async function fetchSponsorTiers(
supabase: SupabaseClient,
departmentId: string
): Promise<SponsorTier[]> {
const { data, error } = await db(supabase)
.from('sponsor_tiers')
.select('*')
.eq('department_id', departmentId)
.order('sort_order');
if (error) {
log.error('fetchSponsorTiers failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as SponsorTier[];
}
export async function createSponsorTier(
supabase: SupabaseClient,
departmentId: string,
name: string,
amount?: number,
color?: string
): Promise<SponsorTier> {
const { data, error } = await db(supabase)
.from('sponsor_tiers')
.insert({
department_id: departmentId,
name,
amount: amount ?? 0,
color: color ?? '#F59E0B',
})
.select()
.single();
if (error) {
log.error('createSponsorTier failed', { error, data: { departmentId, name } });
throw error;
}
return data as SponsorTier;
}
export async function updateSponsorTier(
supabase: SupabaseClient,
tierId: string,
params: Partial<Pick<SponsorTier, 'name' | 'amount' | 'color' | 'sort_order'>>
): Promise<SponsorTier> {
const { data, error } = await db(supabase)
.from('sponsor_tiers')
.update(params)
.eq('id', tierId)
.select()
.single();
if (error) {
log.error('updateSponsorTier failed', { error, data: { tierId } });
throw error;
}
return data as SponsorTier;
}
export async function deleteSponsorTier(
supabase: SupabaseClient,
tierId: string
): Promise<void> {
const { error } = await db(supabase)
.from('sponsor_tiers')
.delete()
.eq('id', tierId);
if (error) {
log.error('deleteSponsorTier failed', { error, data: { tierId } });
throw error;
}
}
// ============================================================
// Sponsors
// ============================================================
export async function fetchSponsors(
supabase: SupabaseClient,
departmentId: string
): Promise<Sponsor[]> {
const { data, error } = await db(supabase)
.from('sponsors')
.select('*')
.eq('department_id', departmentId)
.order('name');
if (error) {
log.error('fetchSponsors failed', { error, data: { departmentId } });
throw error;
}
return (data ?? []) as Sponsor[];
}
export async function createSponsor(
supabase: SupabaseClient,
departmentId: string,
params: {
name: string;
tier_id?: string | null;
contact_name?: string;
contact_email?: string;
contact_phone?: string;
website?: string;
logo_url?: string;
status?: SponsorStatus;
amount?: number;
notes?: string;
}
): Promise<Sponsor> {
const { data, error } = await db(supabase)
.from('sponsors')
.insert({
department_id: departmentId,
name: params.name,
tier_id: params.tier_id ?? null,
contact_name: params.contact_name ?? null,
contact_email: params.contact_email ?? null,
contact_phone: params.contact_phone ?? null,
website: params.website ?? null,
logo_url: params.logo_url ?? null,
status: params.status ?? 'prospect',
amount: params.amount ?? 0,
notes: params.notes ?? null,
})
.select()
.single();
if (error) {
log.error('createSponsor failed', { error, data: { departmentId, name: params.name } });
throw error;
}
return data as Sponsor;
}
export async function updateSponsor(
supabase: SupabaseClient,
sponsorId: string,
params: Partial<Pick<Sponsor, 'name' | 'tier_id' | 'contact_name' | 'contact_email' | 'contact_phone' | 'website' | 'logo_url' | 'status' | 'amount' | 'notes'>>
): Promise<Sponsor> {
const { data, error } = await db(supabase)
.from('sponsors')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', sponsorId)
.select()
.single();
if (error) {
log.error('updateSponsor failed', { error, data: { sponsorId } });
throw error;
}
return data as Sponsor;
}
export async function deleteSponsor(
supabase: SupabaseClient,
sponsorId: string
): Promise<void> {
const { error } = await db(supabase)
.from('sponsors')
.delete()
.eq('id', sponsorId);
if (error) {
log.error('deleteSponsor failed', { error, data: { sponsorId } });
throw error;
}
}
// ============================================================
// Sponsor Deliverables
// ============================================================
export async function fetchDeliverables(
supabase: SupabaseClient,
sponsorId: string
): Promise<SponsorDeliverable[]> {
const { data, error } = await db(supabase)
.from('sponsor_deliverables')
.select('*')
.eq('sponsor_id', sponsorId)
.order('sort_order');
if (error) {
log.error('fetchDeliverables failed', { error, data: { sponsorId } });
throw error;
}
return (data ?? []) as SponsorDeliverable[];
}
export async function fetchAllDeliverables(
supabase: SupabaseClient,
sponsorIds: string[]
): Promise<SponsorDeliverable[]> {
if (sponsorIds.length === 0) return [];
const { data, error } = await db(supabase)
.from('sponsor_deliverables')
.select('*')
.in('sponsor_id', sponsorIds)
.order('sort_order');
if (error) {
log.error('fetchAllDeliverables failed', { error, data: { sponsorIds } });
throw error;
}
return (data ?? []) as SponsorDeliverable[];
}
export async function createDeliverable(
supabase: SupabaseClient,
sponsorId: string,
description: string,
dueDate?: string
): Promise<SponsorDeliverable> {
const { data, error } = await db(supabase)
.from('sponsor_deliverables')
.insert({
sponsor_id: sponsorId,
description,
due_date: dueDate ?? null,
})
.select()
.single();
if (error) {
log.error('createDeliverable failed', { error, data: { sponsorId, description } });
throw error;
}
return data as SponsorDeliverable;
}
export async function updateDeliverable(
supabase: SupabaseClient,
deliverableId: string,
params: Partial<Pick<SponsorDeliverable, 'description' | 'is_completed' | 'due_date' | 'sort_order'>>
): Promise<SponsorDeliverable> {
const { data, error } = await db(supabase)
.from('sponsor_deliverables')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', deliverableId)
.select()
.single();
if (error) {
log.error('updateDeliverable failed', { error, data: { deliverableId } });
throw error;
}
return data as SponsorDeliverable;
}
export async function deleteDeliverable(
supabase: SupabaseClient,
deliverableId: string
): Promise<void> {
const { error } = await db(supabase)
.from('sponsor_deliverables')
.delete()
.eq('id', deliverableId);
if (error) {
log.error('deleteDeliverable failed', { error, data: { deliverableId } });
throw error;
}
}

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -19,6 +19,7 @@
}: Props = $props(); }: Props = $props();
let currentDate = $state(new Date()); let currentDate = $state(new Date());
// svelte-ignore state_referenced_locally
let currentView = $state<ViewType>(initialView); let currentView = $state<ViewType>(initialView);
const today = new Date(); const today = new Date();
@@ -218,16 +219,20 @@
{day.getDate()} {day.getDate()}
</span> </span>
{#each dayEvents.slice(0, 2) as event} {#each dayEvents.slice(0, 2) as event}
<button <!-- svelte-ignore a11y_click_events_have_key_events -->
class="w-full mt-0.5 px-1.5 py-0.5 rounded text-[11px] font-body text-night truncate text-left font-medium" <!-- svelte-ignore a11y_no_static_element_interactions -->
<span
class="w-full mt-0.5 px-1.5 py-0.5 rounded text-[11px] font-body text-night truncate text-left font-medium block cursor-pointer"
style="background-color: {event.color ?? '#00A3E0'}" style="background-color: {event.color ?? '#00A3E0'}"
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
onEventClick?.(event); onEventClick?.(event);
}} }}
role="button"
tabindex="0"
> >
{event.title} {event.title}
</button> </span>
{/each} {/each}
{#if dayEvents.length > 2} {#if dayEvents.length > 2}
<span class="text-[10px] text-light/30 mt-0.5 px-1">+{dayEvents.length - 2}</span> <span class="text-[10px] text-light/30 mt-0.5 px-1">+{dayEvents.length - 2}</span>

View File

@@ -167,6 +167,7 @@
<div class="flex items-center gap-1 p-1 rounded-[32px]"> <div class="flex items-center gap-1 p-1 rounded-[32px]">
<div class="flex items-center gap-2 flex-1 min-w-0"> <div class="flex items-center gap-2 flex-1 min-w-0">
{#if renamingColumnId === column.id} {#if renamingColumnId === column.id}
<!-- svelte-ignore a11y_autofocus -->
<input <input
type="text" type="text"
class="bg-dark border border-primary rounded-lg px-2 py-1 text-white font-heading text-h4 w-full focus:outline-none" class="bg-dark border border-primary rounded-lg px-2 py-1 text-white font-heading text-h4 w-full focus:outline-none"
@@ -252,6 +253,7 @@
<!-- Cards --> <!-- Cards -->
<div class="flex-1 overflow-y-auto flex flex-col gap-0"> <div class="flex-1 overflow-y-auto flex flex-col gap-0">
{#each column.cards as card, cardIndex} {#each column.cards as card, cardIndex}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="mb-2 relative {dropIndicatorClass(card, cardIndex, column.id, column.cards.length)}" class="mb-2 relative {dropIndicatorClass(card, cardIndex, column.id, column.cards.length)}"
ondragover={(e) => ondragover={(e) =>

View File

@@ -69,6 +69,7 @@
> >
<!-- Delete button (top-right, visible on hover) --> <!-- Delete button (top-right, visible on hover) -->
{#if ondelete} {#if ondelete}
<!-- svelte-ignore node_invalid_placement_ssr -->
<button <button
type="button" type="button"
class="absolute top-1.5 right-1.5 p-0.5 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all z-10" class="absolute top-1.5 right-1.5 p-0.5 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all z-10"

View File

@@ -59,6 +59,8 @@
aria-modal="true" aria-modal="true"
tabindex="-1" tabindex="-1"
> >
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div <div
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4" class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}

View File

@@ -12,20 +12,18 @@
let { client, children }: Props = $props(); let { client, children }: Props = $props();
// Store client reference for cleanup
let clientRef = client;
// Set the context during component initialization // Set the context during component initialization
setMatrixContext(clientRef); // svelte-ignore state_referenced_locally
setMatrixContext(client);
// Setup sync handlers when provider mounts // Setup sync handlers when provider mounts
onMount(() => { onMount(() => {
setupSyncHandlers(clientRef); setupSyncHandlers(client);
}); });
// Cleanup when provider unmounts // Cleanup when provider unmounts
onDestroy(() => { onDestroy(() => {
removeSyncHandlers(clientRef); removeSyncHandlers(client);
}); });
</script> </script>

View File

@@ -79,9 +79,10 @@
let showMentions = $state(false); let showMentions = $state(false);
let mentionQuery = $state(""); let mentionQuery = $state("");
let mentionStartIndex = $state(0); let mentionStartIndex = $state(0);
let autocompleteRef: let autocompleteRef = $state<
| { handleKeyDown: (e: KeyboardEvent) => void } | { handleKeyDown: (e: KeyboardEvent) => void }
| undefined; | undefined
>();
// Emoji picker state // Emoji picker state
let showEmojiPicker = $state(false); let showEmojiPicker = $state(false);
@@ -91,9 +92,10 @@
let showEmojiAutocomplete = $state(false); let showEmojiAutocomplete = $state(false);
let emojiQuery = $state(""); let emojiQuery = $state("");
let emojiStartIndex = $state(0); let emojiStartIndex = $state(0);
let emojiAutocompleteRef: let emojiAutocompleteRef = $state<
| { handleKeyDown: (e: KeyboardEvent) => void } | { handleKeyDown: (e: KeyboardEvent) => void }
| undefined; | undefined
>();
// Get room members for autocomplete // Get room members for autocomplete
const roomMembers = $derived(getRoomMembers(roomId)); const roomMembers = $derived(getRoomMembers(roomId));

View File

@@ -17,6 +17,7 @@
let { room, members, onClose }: Props = $props(); let { room, members, onClose }: Props = $props();
let showSettings = $state(false); let showSettings = $state(false);
// svelte-ignore state_referenced_locally
let isMuted = $state(getRoomNotificationLevel(room.roomId) === "mute"); let isMuted = $state(getRoomNotificationLevel(room.roomId) === "mute");
let isTogglingMute = $state(false); let isTogglingMute = $state(false);

View File

@@ -12,7 +12,9 @@
let { room, onClose }: Props = $props(); let { room, onClose }: Props = $props();
// svelte-ignore state_referenced_locally
let name = $state(room.name); let name = $state(room.name);
// svelte-ignore state_referenced_locally
let topic = $state(room.topic || ""); let topic = $state(room.topic || "");
let isSaving = $state(false); let isSaving = $state(false);
let avatarFile = $state<File | null>(null); let avatarFile = $state<File | null>(null);
@@ -74,6 +76,7 @@
aria-labelledby="settings-title" aria-labelledby="settings-title"
tabindex="-1" tabindex="-1"
> >
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div <div
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4" class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
role="document" role="document"

View File

@@ -60,6 +60,7 @@
<svelte:window onkeydown={handleKeyDown} /> <svelte:window onkeydown={handleKeyDown} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onclick={onClose} onclick={onClose}
@@ -67,6 +68,8 @@
aria-modal="true" aria-modal="true"
tabindex="-1" tabindex="-1"
> >
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div <div
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4" class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
@@ -80,6 +83,7 @@
<circle cx="11" cy="11" r="8" /> <circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" /> <path d="m21 21-4.35-4.35" />
</svg> </svg>
<!-- svelte-ignore a11y_autofocus -->
<input <input
type="text" type="text"
bind:value={searchQuery} bind:value={searchQuery}

View File

@@ -62,6 +62,7 @@
onclick={onClose} onclick={onClose}
onkeydown={(e) => e.key === 'Enter' && onClose()} onkeydown={(e) => e.key === 'Enter' && onClose()}
> >
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div <div
class="bg-dark rounded-2xl w-full max-w-sm mx-4 overflow-hidden" class="bg-dark rounded-2xl w-full max-w-sm mx-4 overflow-hidden"
role="document" role="document"

View File

@@ -145,6 +145,7 @@
{#if showContextMenu} {#if showContextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
class="fixed bg-dark border border-light/20 rounded-lg shadow-xl py-1 z-[100] min-w-[180px]" class="fixed bg-dark border border-light/20 rounded-lg shadow-xl py-1 z-[100] min-w-[180px]"
style="left: {menuPosition.x}px; top: {menuPosition.y}px;" style="left: {menuPosition.x}px; top: {menuPosition.y}px;"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,8 +20,11 @@
let { supabase, org, isOwner, onLeave, onDelete }: Props = $props(); let { supabase, org, isOwner, onLeave, onDelete }: Props = $props();
// svelte-ignore state_referenced_locally
let orgName = $state(org.name); let orgName = $state(org.name);
// svelte-ignore state_referenced_locally
let orgSlug = $state(org.slug); let orgSlug = $state(org.slug);
// svelte-ignore state_referenced_locally
let avatarUrl = $state(org.avatar_url ?? null); let avatarUrl = $state(org.avatar_url ?? null);
let isSaving = $state(false); let isSaving = $state(false);
let isUploading = $state(false); let isUploading = $state(false);

View File

@@ -43,6 +43,7 @@
setTimeout(() => (emailCopied = false), 2000); setTimeout(() => (emailCopied = false), 2000);
} }
// svelte-ignore state_referenced_locally
let showConnectModal = $state(initialShowConnect); let showConnectModal = $state(initialShowConnect);
let isLoading = $state(false); let isLoading = $state(false);
let calendarUrlInput = $state(""); let calendarUrlInput = $state("");

View File

@@ -184,7 +184,7 @@
<!-- Pending Invites --> <!-- Pending Invites -->
{#if invites.length > 0} {#if invites.length > 0}
<div class="bg-dark/30 border border-light/5 rounded-xl p-4"> <div class="bg-dark/30 border border-light/5 rounded-2xl p-4">
<h3 class="text-body-sm font-heading text-light/60 mb-3"> <h3 class="text-body-sm font-heading text-light/60 mb-3">
{m.settings_members_pending()} {m.settings_members_pending()}
</h3> </h3>
@@ -229,7 +229,7 @@
{/if} {/if}
<!-- Members List --> <!-- Members List -->
<div class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden"> <div class="bg-dark/30 border border-light/5 rounded-2xl overflow-hidden">
<div class="divide-y divide-light/5"> <div class="divide-y divide-light/5">
{#each members as member} {#each members as member}
{@const rawProfile = member.profiles} {@const rawProfile = member.profiles}
@@ -288,42 +288,40 @@
onClose={() => (showInviteModal = false)} onClose={() => (showInviteModal = false)}
title="Invite Member" title="Invite Member"
> >
<div class="space-y-4"> <div class="flex flex-col gap-4">
<Input <div class="flex flex-col gap-1.5">
type="email" <label for="invite-email" class="text-body-sm text-light/60 font-body">Email address</label>
label="Email address" <input
bind:value={inviteEmail} id="invite-email"
placeholder="colleague@example.com" type="email"
/> bind:value={inviteEmail}
<Select placeholder="colleague@example.com"
label="Role" 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"
bind:value={inviteRole} />
placeholder="" </div>
options={[ <div class="flex flex-col gap-1.5">
{ value: "viewer", label: "Viewer - Can view content" }, <label for="invite-role" class="text-body-sm text-light/60 font-body">Role</label>
{ <select
value: "commenter", id="invite-role"
label: "Commenter - Can view and comment", bind:value={inviteRole}
}, class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
{
value: "editor",
label: "Editor - Can create and edit content",
},
{
value: "admin",
label: "Admin - Can manage members and settings",
},
]}
/>
<div class="flex justify-end gap-2 pt-2">
<Button variant="tertiary" onclick={() => (showInviteModal = false)}
>Cancel</Button
> >
<Button <option value="viewer">Viewer - Can view content</option>
<option value="commenter">Commenter - Can view and comment</option>
<option value="editor">Editor - Can create and edit content</option>
<option value="admin">Admin - Can manage members and settings</option>
</select>
</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={() => (showInviteModal = false)}>Cancel</button>
<button
type="button"
disabled={!inviteEmail.trim() || isSendingInvite}
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={sendInvite} onclick={sendInvite}
loading={isSendingInvite}
disabled={!inviteEmail.trim()}>Send Invite</Button
> >
{isSendingInvite ? "..." : "Send Invite"}
</button>
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -337,46 +335,45 @@
{#if selectedMember} {#if selectedMember}
{@const rawP = selectedMember.profiles} {@const rawP = selectedMember.profiles}
{@const memberProfile = Array.isArray(rawP) ? rawP[0] : rawP} {@const memberProfile = Array.isArray(rawP) ? rawP[0] : rawP}
<div class="space-y-4"> <div class="flex flex-col gap-4">
<div class="flex items-center gap-3 p-3 bg-light/5 rounded-lg"> <div class="flex items-center gap-3 p-3 bg-dark/50 rounded-xl">
<div <Avatar
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium" name={memberProfile?.full_name || memberProfile?.email || "?"}
> src={memberProfile?.avatar_url}
{(memberProfile?.full_name || size="sm"
memberProfile?.email || />
"?")[0].toUpperCase()}
</div>
<div> <div>
<p class="text-light font-medium"> <p class="text-body-sm text-white">
{memberProfile?.full_name || "No name"} {memberProfile?.full_name || "No name"}
</p> </p>
<p class="text-sm text-light/50"> <p class="text-[11px] text-light/40">
{memberProfile?.email || "No email"} {memberProfile?.email || "No email"}
</p> </p>
</div> </div>
</div> </div>
<Select <div class="flex flex-col gap-1.5">
label="Role" <label for="member-role" class="text-body-sm text-light/60 font-body">Role</label>
bind:value={selectedMemberRole} <select
placeholder="" id="member-role"
options={[ bind:value={selectedMemberRole}
{ value: "viewer", label: "Viewer" }, class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
{ value: "commenter", label: "Commenter" },
{ value: "editor", label: "Editor" },
{ value: "admin", label: "Admin" },
]}
/>
<div class="flex items-center justify-between pt-2">
<Button variant="danger" onclick={removeMember}
>Remove from Org</Button
> >
<div class="flex gap-2"> <option value="viewer">Viewer</option>
<Button <option value="commenter">Commenter</option>
variant="tertiary" <option value="editor">Editor</option>
onclick={() => (showMemberModal = false)}>Cancel</Button <option value="admin">Admin</option>
> </select>
<Button onclick={updateMemberRole}>Save</Button> </div>
</div> <button type="button" class="text-[11px] text-error hover:underline self-start" onclick={removeMember}>
Remove from organization
</button>
<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={() => (showMemberModal = false)}>Cancel</button>
<button
type="button"
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors"
onclick={updateMemberRole}
>Save</button>
</div> </div>
</div> </div>
{/if} {/if}

View File

@@ -203,7 +203,7 @@
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each roles as role} {#each roles as role}
<div class="bg-dark/30 border border-light/5 rounded-xl px-4 py-3 hover:border-light/10 transition-colors"> <div class="bg-dark/30 border border-light/5 rounded-2xl px-4 py-3 hover:border-light/10 transition-colors">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
@@ -264,25 +264,27 @@
onClose={() => (showRoleModal = false)} onClose={() => (showRoleModal = false)}
title={editingRole ? "Edit Role" : "Create Role"} title={editingRole ? "Edit Role" : "Create Role"}
> >
<div class="space-y-4"> <div class="flex flex-col gap-4">
<Input <div class="flex flex-col gap-1.5">
label="Name" <label for="role-name" class="text-body-sm text-light/60 font-body">Name</label>
bind:value={newRoleName} <input
placeholder="e.g., Moderator" id="role-name"
disabled={editingRole?.is_system} type="text"
/> bind:value={newRoleName}
<div> placeholder="e.g., Moderator"
<label class="block text-sm font-medium text-light mb-2" disabled={editingRole?.is_system}
>Color</label 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 disabled:opacity-50"
> />
<div class="flex gap-2"> </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 roleColors as color} {#each roleColors as color}
<button <button
type="button" type="button"
class="w-8 h-8 rounded-full transition-transform {newRoleColor === class="w-6 h-6 rounded-full border-2 transition-all {newRoleColor === color.value
color.value ? 'border-white scale-110'
? 'ring-2 ring-white scale-110' : 'border-transparent hover:border-light/30'}"
: ''}"
style="background-color: {color.value}" style="background-color: {color.value}"
onclick={() => (newRoleColor = color.value)} onclick={() => (newRoleColor = color.value)}
title={color.label} title={color.label}
@@ -290,30 +292,26 @@
{/each} {/each}
</div> </div>
</div> </div>
<div> <div class="flex flex-col gap-1.5">
<label class="block text-sm font-medium text-light mb-2" <span class="text-body-sm text-light/60 font-body">Permissions</span>
>Permissions</label <div class="flex flex-col gap-2 max-h-64 overflow-y-auto">
>
<div class="space-y-3 max-h-64 overflow-y-auto">
{#each permissionGroups as group} {#each permissionGroups as group}
<div class="p-3 bg-light/5 rounded-lg"> <div class="p-3 bg-dark/50 rounded-xl">
<p class="text-sm font-medium text-light mb-2"> <p class="text-body-sm font-body text-white mb-2">
{group.name} {group.name}
</p> </p>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
{#each group.permissions as perm} {#each group.permissions as perm}
<label <label
class="flex items-center gap-2 text-sm text-light/70 cursor-pointer" class="flex items-center gap-2 text-[12px] text-light/50 cursor-pointer hover:text-white transition-colors"
> >
<input <input
type="checkbox" type="checkbox"
checked={newRolePermissions.includes( checked={newRolePermissions.includes(perm)}
perm,
)}
onchange={() => togglePermission(perm)} onchange={() => togglePermission(perm)}
class="rounded" class="rounded accent-primary"
/> />
{perm.split(".")[1]} <span class="capitalize">{perm.split(".")[1]}</span>
</label> </label>
{/each} {/each}
</div> </div>
@@ -321,16 +319,16 @@
{/each} {/each}
</div> </div>
</div> </div>
<div class="flex justify-end gap-2 pt-2"> <div class="flex items-center justify-end gap-3 pt-2 border-t border-light/5">
<Button variant="tertiary" onclick={() => (showRoleModal = false)} <button type="button" class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors" onclick={() => (showRoleModal = false)}>{m.btn_cancel()}</button>
>Cancel</Button <button
> type="button"
<Button disabled={!newRoleName.trim() || isSavingRole}
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={saveRole} onclick={saveRole}
loading={isSavingRole}
disabled={!newRoleName.trim()}
>{editingRole ? "Save" : "Create"}</Button
> >
{isSavingRole ? "..." : editingRole ? m.btn_save() : m.btn_create()}
</button>
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@@ -23,6 +23,7 @@
<svelte:window onkeydown={handleKeyDown} /> <svelte:window onkeydown={handleKeyDown} />
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/90 backdrop-blur-sm" class="fixed inset-0 z-[200] flex items-center justify-center bg-black/90 backdrop-blur-sm"
onclick={handleBackdropClick} onclick={handleBackdropClick}
@@ -31,6 +32,7 @@
<button <button
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full bg-light/10 hover:bg-light/20 transition-colors text-light" class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full bg-light/10 hover:bg-light/20 transition-colors text-light"
onclick={onClose} onclick={onClose}
aria-label="Close preview"
> >
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" /> <path d="M18 6L6 18M6 6l12 12" />

View File

@@ -45,6 +45,7 @@
tabindex="-1" tabindex="-1"
transition:fade={{ duration: 150 }} transition:fade={{ duration: 150 }}
> >
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
class="bg-surface rounded-2xl w-full mx-4 {sizeClasses[ class="bg-surface rounded-2xl w-full mx-4 {sizeClasses[
size size
@@ -59,25 +60,19 @@
> >
<h2 <h2
id="modal-title" id="modal-title"
class="text-lg font-semibold text-light" class="text-body font-heading text-white"
> >
{title} {title}
</h2> </h2>
<button <button
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-full transition-colors" class="w-8 h-8 flex items-center justify-center text-light/40 hover:text-white hover:bg-light/10 rounded-xl transition-colors"
onclick={onClose} onclick={onClose}
aria-label="Close" aria-label="Close"
> >
<svg <span
class="w-5 h-5" class="material-symbols-rounded"
viewBox="0 0 24 24" style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
fill="none" >close</span>
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button> </button>
</div> </div>
{/if} {/if}

View File

@@ -29,8 +29,8 @@
card: { w: '100%', h: '8rem' }, card: { w: '100%', h: '8rem' },
}; };
const finalWidth = width || defaultSizes[variant].w; const finalWidth = $derived(width || defaultSizes[variant].w);
const finalHeight = height || defaultSizes[variant].h; const finalHeight = $derived(height || defaultSizes[variant].h);
</script> </script>
{#if variant === 'text' && lines > 1} {#if variant === 'text' && lines > 1}

View File

@@ -58,6 +58,7 @@
<button <button
class="shrink-0 text-night/50 hover:text-night transition-colors" class="shrink-0 text-night/50 hover:text-night transition-colors"
onclick={onClose} onclick={onClose}
aria-label="Close"
> >
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="currentColor"> <svg class="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" /> <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />

View File

@@ -26,6 +26,7 @@
type="button" type="button"
role="switch" role="switch"
aria-checked={checked} aria-checked={checked}
aria-label="Toggle"
{disabled} {disabled}
onclick={handleClick} onclick={handleClick}
class="relative inline-flex items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-dark disabled:opacity-50 disabled:cursor-not-allowed {sizeClasses[ class="relative inline-flex items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-dark disabled:opacity-50 disabled:cursor-not-allowed {sizeClasses[

View File

@@ -245,6 +245,199 @@ export type Database = {
}, },
] ]
} }
dashboard_panels: {
Row: {
config: Json | null
created_at: string | null
dashboard_id: string
id: string
module: Database["public"]["Enums"]["module_type"]
position: number
width: string
}
Insert: {
config?: Json | null
created_at?: string | null
dashboard_id: string
id?: string
module: Database["public"]["Enums"]["module_type"]
position?: number
width?: string
}
Update: {
config?: Json | null
created_at?: string | null
dashboard_id?: string
id?: string
module?: Database["public"]["Enums"]["module_type"]
position?: number
width?: string
}
Relationships: [
{
foreignKeyName: "dashboard_panels_dashboard_id_fkey"
columns: ["dashboard_id"]
isOneToOne: false
referencedRelation: "department_dashboards"
referencedColumns: ["id"]
},
]
}
department_checklist_items: {
Row: {
assigned_to: string | null
checklist_id: string
content: string
created_at: string | null
due_date: string | null
id: string
is_completed: boolean
sort_order: number
updated_at: string | null
}
Insert: {
assigned_to?: string | null
checklist_id: string
content: string
created_at?: string | null
due_date?: string | null
id?: string
is_completed?: boolean
sort_order?: number
updated_at?: string | null
}
Update: {
assigned_to?: string | null
checklist_id?: string
content?: string
created_at?: string | null
due_date?: string | null
id?: string
is_completed?: boolean
sort_order?: number
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "department_checklist_items_checklist_id_fkey"
columns: ["checklist_id"]
isOneToOne: false
referencedRelation: "department_checklists"
referencedColumns: ["id"]
},
]
}
department_checklists: {
Row: {
created_at: string | null
created_by: string | null
department_id: string
id: string
sort_order: number
title: string
}
Insert: {
created_at?: string | null
created_by?: string | null
department_id: string
id?: string
sort_order?: number
title?: string
}
Update: {
created_at?: string | null
created_by?: string | null
department_id?: string
id?: string
sort_order?: number
title?: string
}
Relationships: [
{
foreignKeyName: "department_checklists_department_id_fkey"
columns: ["department_id"]
isOneToOne: false
referencedRelation: "event_departments"
referencedColumns: ["id"]
},
]
}
department_dashboards: {
Row: {
created_at: string | null
created_by: string | null
department_id: string
id: string
layout: Database["public"]["Enums"]["layout_preset"]
updated_at: string | null
}
Insert: {
created_at?: string | null
created_by?: string | null
department_id: string
id?: string
layout?: Database["public"]["Enums"]["layout_preset"]
updated_at?: string | null
}
Update: {
created_at?: string | null
created_by?: string | null
department_id?: string
id?: string
layout?: Database["public"]["Enums"]["layout_preset"]
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "department_dashboards_department_id_fkey"
columns: ["department_id"]
isOneToOne: true
referencedRelation: "event_departments"
referencedColumns: ["id"]
},
]
}
department_notes: {
Row: {
content: string | null
created_at: string | null
created_by: string | null
department_id: string
id: string
sort_order: number
title: string
updated_at: string | null
}
Insert: {
content?: string | null
created_at?: string | null
created_by?: string | null
department_id: string
id?: string
sort_order?: number
title?: string
updated_at?: string | null
}
Update: {
content?: string | null
created_at?: string | null
created_by?: string | null
department_id?: string
id?: string
sort_order?: number
title?: string
updated_at?: string | null
}
Relationships: [
{
foreignKeyName: "department_notes_department_id_fkey"
columns: ["department_id"]
isOneToOne: false
referencedRelation: "event_departments"
referencedColumns: ["id"]
},
]
}
document_locks: { document_locks: {
Row: { Row: {
document_id: string document_id: string
@@ -365,6 +558,7 @@ export type Database = {
color: string color: string
created_at: string | null created_at: string | null
description: string | null description: string | null
enabled_modules: Database["public"]["Enums"]["module_type"][]
event_id: string event_id: string
id: string id: string
name: string name: string
@@ -374,6 +568,7 @@ export type Database = {
color?: string color?: string
created_at?: string | null created_at?: string | null
description?: string | null description?: string | null
enabled_modules?: Database["public"]["Enums"]["module_type"][]
event_id: string event_id: string
id?: string id?: string
name: string name: string
@@ -383,6 +578,7 @@ export type Database = {
color?: string color?: string
created_at?: string | null created_at?: string | null
description?: string | null description?: string | null
enabled_modules?: Database["public"]["Enums"]["module_type"][]
event_id?: string event_id?: string
id?: string id?: string
name?: string name?: string
@@ -1400,7 +1596,14 @@ export type Database = {
is_org_member: { Args: { org_id: string }; Returns: boolean } is_org_member: { Args: { org_id: string }; Returns: boolean }
} }
Enums: { Enums: {
[_ in never]: never layout_preset: "single" | "split" | "grid" | "focus_sidebar" | "custom"
module_type:
| "kanban"
| "files"
| "checklist"
| "notes"
| "schedule"
| "contacts"
} }
CompositeTypes: { CompositeTypes: {
[_ in never]: never [_ in never]: never
@@ -1527,11 +1730,23 @@ export type CompositeTypes<
export const Constants = { export const Constants = {
public: { public: {
Enums: {}, Enums: {
layout_preset: ["single", "split", "grid", "focus_sidebar", "custom"],
module_type: [
"kanban",
"files",
"checklist",
"notes",
"schedule",
"contacts",
],
},
}, },
} as const } as const
// Convenience type aliases // ============================================================
// Convenience type aliases used throughout the codebase
// ============================================================
export type Profile = Tables<'profiles'>; export type Profile = Tables<'profiles'>;
export type Organization = Tables<'organizations'>; export type Organization = Tables<'organizations'>;
export type Document = Tables<'documents'>; export type Document = Tables<'documents'>;
@@ -1540,6 +1755,122 @@ export type KanbanColumn = Tables<'kanban_columns'>;
export type KanbanCard = Tables<'kanban_cards'>; export type KanbanCard = Tables<'kanban_cards'>;
export type CalendarEvent = Tables<'calendar_events'>; export type CalendarEvent = Tables<'calendar_events'>;
export type OrgRole = Tables<'org_roles'>; export type OrgRole = Tables<'org_roles'>;
export type MemberRole = string;
export type EventTaskColumn = Tables<'event_task_columns'>; export type EventTaskColumn = Tables<'event_task_columns'>;
export type EventTask = Tables<'event_tasks'>; export type EventTask = Tables<'event_tasks'>;
export type MemberRole = string; export type DepartmentDashboard = Tables<'department_dashboards'>;
export type DashboardPanel = Tables<'dashboard_panels'>;
export type DepartmentChecklist = Tables<'department_checklists'>;
export type DepartmentChecklistItem = Tables<'department_checklist_items'>;
export type DepartmentNote = Tables<'department_notes'>;
export type ModuleType = Database['public']['Enums']['module_type'];
export type LayoutPreset = Database['public']['Enums']['layout_preset'];
// Schedule/Timeline types (migration 028 — use db() cast until types regenerated)
export interface ScheduleStage {
id: string;
department_id: string;
name: string;
color: string | null;
sort_order: number;
created_at: string;
}
export interface ScheduleBlock {
id: string;
department_id: string;
stage_id: string | null;
title: string;
description: string | null;
start_time: string;
end_time: string;
color: string | null;
speaker: string | null;
sort_order: number;
created_by: string | null;
created_at: string;
updated_at: string;
}
// Budget/Finance types (migration 029 — use db() cast until types regenerated)
export interface BudgetCategory {
id: string;
department_id: string;
name: string;
color: string | null;
sort_order: number;
created_at: string;
}
export interface BudgetItem {
id: string;
department_id: string;
category_id: string | null;
description: string;
item_type: 'income' | 'expense';
planned_amount: number;
actual_amount: number;
notes: string | null;
sort_order: number;
created_by: string | null;
created_at: string;
updated_at: string;
}
// Sponsors & Partners types (migration 029)
export interface SponsorTier {
id: string;
department_id: string;
name: string;
amount: number;
color: string | null;
sort_order: number;
created_at: string;
}
export interface Sponsor {
id: string;
department_id: string;
tier_id: string | null;
name: string;
contact_name: string | null;
contact_email: string | null;
contact_phone: string | null;
website: string | null;
logo_url: string | null;
status: 'prospect' | 'contacted' | 'confirmed' | 'declined' | 'active';
amount: number;
notes: string | null;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface SponsorDeliverable {
id: string;
sponsor_id: string;
description: string;
is_completed: boolean;
due_date: string | null;
sort_order: number;
created_at: string;
updated_at: string;
}
// Contacts/Vendor Directory types (migration 028)
export interface DepartmentContact {
id: string;
department_id: string;
name: string;
role: string | null;
company: string | null;
email: string | null;
phone: string | null;
website: string | null;
notes: string | null;
category: string;
color: string | null;
created_by: string | null;
created_at: string;
updated_at: string;
}

View File

@@ -2,7 +2,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { locales, localizeHref } from '$lib/paraglide/runtime'; import { locales, localizeHref } from '$lib/paraglide/runtime';
import "./layout.css"; import "./layout.css";
import favicon from "$lib/assets/favicon.svg";
import { createClient } from "$lib/supabase"; import { createClient } from "$lib/supabase";
import { setContext } from "svelte"; import { setContext } from "svelte";
import { ToastContainer } from "$lib/components/ui"; import { ToastContainer } from "$lib/components/ui";
@@ -13,7 +13,6 @@
setContext("supabase", supabase); setContext("supabase", supabase);
</script> </script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
{@render children()} {@render children()}
<ToastContainer /> <ToastContainer />

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
import { Button, Modal, Input } from "$lib/components/ui"; import { Modal } from "$lib/components/ui";
import { createOrganization, generateSlug } from "$lib/api/organizations"; import { createOrganization, generateSlug } from "$lib/api/organizations";
import { toasts } from "$lib/stores/toast.svelte"; import { toasts } from "$lib/stores/toast.svelte";
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
@@ -24,6 +24,7 @@
const supabase = getContext<SupabaseClient<Database>>("supabase"); const supabase = getContext<SupabaseClient<Database>>("supabase");
// svelte-ignore state_referenced_locally
let organizations = $state(data.organizations); let organizations = $state(data.organizations);
$effect(() => { $effect(() => {
organizations = data.organizations; organizations = data.organizations;
@@ -62,14 +63,15 @@
<!-- Header --> <!-- Header -->
<header class="border-b border-light/5"> <header class="border-b border-light/5">
<div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between"> <div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3"> <div class="flex items-center gap-2.5">
<span class="material-symbols-rounded text-primary" style="font-size: 28px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 28;">hub</span> <div class="w-8 h-8 bg-primary/10 rounded-xl flex items-center justify-center">
<span class="font-heading text-h4 text-white">Root</span> <span class="material-symbols-rounded text-primary" style="font-size: 18px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 18;">hub</span>
</div>
<span class="font-heading text-body text-white">Root</span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a href="/style" class="px-3 py-1.5 text-[12px] text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors">Style Guide</a>
<form method="POST" action="/auth/logout"> <form method="POST" action="/auth/logout">
<Button variant="tertiary" size="sm" type="submit">Sign Out</Button> <button type="submit" class="px-3 py-1.5 text-body-sm text-light/40 hover:text-white hover:bg-dark/50 rounded-xl transition-colors">Sign Out</button>
</form> </form>
</div> </div>
</div> </div>
@@ -81,26 +83,37 @@
<h2 class="font-heading text-h3 text-white">Your Organizations</h2> <h2 class="font-heading text-h3 text-white">Your Organizations</h2>
<p class="text-body-sm text-light/40 mt-1">Select an organization to get started</p> <p class="text-body-sm text-light/40 mt-1">Select an organization to get started</p>
</div> </div>
<Button size="sm" icon="add" onclick={() => (showCreateModal = true)}>New Organization</Button> <button
class="flex items-center gap-1.5 px-3 py-2 bg-primary text-background rounded-xl text-body-sm font-body hover:bg-primary-hover transition-colors"
onclick={() => (showCreateModal = true)}
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">add</span>
New Organization
</button>
</div> </div>
{#if organizations.length === 0} {#if organizations.length === 0}
<div class="bg-dark/30 border border-light/5 rounded-xl p-12 text-center"> <div class="bg-dark/30 border border-light/5 rounded-2xl p-12 text-center">
<span class="material-symbols-rounded text-light/20 mb-3 block" style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;">groups</span> <div class="w-14 h-14 mx-auto mb-4 rounded-2xl bg-light/5 flex items-center justify-center">
<span class="material-symbols-rounded text-light/20" style="font-size: 28px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 28;">groups</span>
</div>
<h3 class="font-heading text-body text-white mb-1">No organizations yet</h3> <h3 class="font-heading text-body text-white mb-1">No organizations yet</h3>
<p class="text-body-sm text-light/40 mb-6">Create your first organization to start collaborating</p> <p class="text-body-sm text-light/40 mb-6">Create your first organization to start collaborating</p>
<Button size="sm" icon="add" onclick={() => (showCreateModal = true)}>Create Organization</Button> <button
class="px-4 py-2 bg-primary text-background rounded-xl text-body-sm font-body hover:bg-primary-hover transition-colors"
onclick={() => (showCreateModal = true)}
>Create Organization</button>
</div> </div>
{:else} {:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{#each organizations as org} {#each organizations as org}
<a href="/{org.slug}" class="block group"> <a href="/{org.slug}" class="block group">
<div class="bg-dark/30 border border-light/5 hover:border-light/10 rounded-xl p-5 transition-all h-full"> <div class="bg-dark/30 border border-light/5 hover:border-primary/30 rounded-2xl p-5 transition-all h-full">
<div class="flex items-start justify-between mb-3"> <div class="flex items-start justify-between mb-3">
<div class="w-10 h-10 bg-primary/10 rounded-xl flex items-center justify-center text-primary font-heading text-body"> <div class="w-10 h-10 bg-primary/10 rounded-xl flex items-center justify-center text-primary font-heading text-body">
{org.name.charAt(0).toUpperCase()} {org.name.charAt(0).toUpperCase()}
</div> </div>
<span class="text-[10px] px-2 py-0.5 bg-light/5 rounded-md text-light/40 capitalize font-body">{org.role}</span> <span class="text-[10px] px-2 py-0.5 bg-light/5 rounded-lg text-light/40 capitalize font-body">{org.role}</span>
</div> </div>
<h3 class="font-heading text-body-sm text-white group-hover:text-primary transition-colors">{org.name}</h3> <h3 class="font-heading text-body-sm text-white group-hover:text-primary transition-colors">{org.name}</h3>
<p class="text-[11px] text-light/30 mt-0.5 font-body">/{org.slug}</p> <p class="text-[11px] text-light/30 mt-0.5 font-body">/{org.slug}</p>
@@ -117,29 +130,32 @@
onClose={() => (showCreateModal = false)} onClose={() => (showCreateModal = false)}
title="Create Organization" title="Create Organization"
> >
<div class="space-y-4"> <div class="flex flex-col gap-4">
<Input <div class="flex flex-col gap-1.5">
label="Organization Name" <label for="org-name" class="text-body-sm text-light/60 font-body">Organization Name</label>
bind:value={newOrgName} <input
placeholder="e.g. Acme Inc" id="org-name"
/> type="text"
bind:value={newOrgName}
placeholder="e.g. Acme Inc"
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 newOrgName} {#if newOrgName}
<p class="text-sm text-light/50"> <p class="text-body-sm text-light/40">
URL: <span class="text-light/70" URL: <span class="text-white font-body">/{generateSlug(newOrgName)}</span>
>/{generateSlug(newOrgName)}</span
>
</p> </p>
{/if} {/if}
<div class="flex justify-end gap-2 pt-2"> <div class="flex items-center justify-end gap-3 pt-2 border-t border-light/5">
<Button variant="tertiary" onclick={() => (showCreateModal = false)} <button type="button" class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors" onclick={() => (showCreateModal = false)}>Cancel</button>
>Cancel</Button <button
> type="button"
<Button
onclick={handleCreateOrg}
disabled={!newOrgName.trim() || creating} disabled={!newOrgName.trim() || creating}
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={handleCreateOrg}
> >
{creating ? "Creating..." : "Create"} {creating ? "Creating..." : "Create"}
</Button> </button>
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@@ -36,11 +36,17 @@
const supabase = getContext<SupabaseClient<Database>>("supabase"); const supabase = getContext<SupabaseClient<Database>>("supabase");
// Profile state // Profile state
// svelte-ignore state_referenced_locally
let fullName = $state(data.profile.full_name ?? ""); let fullName = $state(data.profile.full_name ?? "");
// svelte-ignore state_referenced_locally
let avatarUrl = $state(data.profile.avatar_url ?? null); let avatarUrl = $state(data.profile.avatar_url ?? null);
// svelte-ignore state_referenced_locally
let phone = $state(data.profile.phone ?? ""); let phone = $state(data.profile.phone ?? "");
// svelte-ignore state_referenced_locally
let discordHandle = $state(data.profile.discord_handle ?? ""); let discordHandle = $state(data.profile.discord_handle ?? "");
// svelte-ignore state_referenced_locally
let shirtSize = $state(data.profile.shirt_size ?? ""); let shirtSize = $state(data.profile.shirt_size ?? "");
// svelte-ignore state_referenced_locally
let hoodieSize = $state(data.profile.hoodie_size ?? ""); let hoodieSize = $state(data.profile.hoodie_size ?? "");
let isSaving = $state(false); let isSaving = $state(false);
let isUploading = $state(false); let isUploading = $state(false);
@@ -49,8 +55,11 @@
const clothingSizes = ["XS", "S", "M", "L", "XL", "XXL", "3XL"]; const clothingSizes = ["XS", "S", "M", "L", "XL", "XXL", "3XL"];
// Preferences state // Preferences state
// svelte-ignore state_referenced_locally
let theme = $state(data.preferences?.theme ?? "dark"); let theme = $state(data.preferences?.theme ?? "dark");
// svelte-ignore state_referenced_locally
let accentColor = $state(data.preferences?.accent_color ?? "#00A3E0"); let accentColor = $state(data.preferences?.accent_color ?? "#00A3E0");
// svelte-ignore state_referenced_locally
let useOrgTheme = $state(data.preferences?.use_org_theme ?? true); let useOrgTheme = $state(data.preferences?.use_org_theme ?? true);
let currentLocale = $state<(typeof locales)[number]>(getLocale()); let currentLocale = $state<(typeof locales)[number]>(getLocale());
@@ -250,7 +259,7 @@
<div class="flex-1 p-6 overflow-auto"> <div class="flex-1 p-6 overflow-auto">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- Profile Section --> <!-- Profile Section -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5"> <div class="bg-dark/30 border border-light/5 rounded-2xl p-5 flex flex-col gap-5">
<h2 class="font-heading text-body text-white"> <h2 class="font-heading text-body text-white">
{m.account_profile()} {m.account_profile()}
</h2> </h2>
@@ -326,7 +335,7 @@
</div> </div>
<!-- Contact & Sizing Section --> <!-- Contact & Sizing Section -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5"> <div class="bg-dark/30 border border-light/5 rounded-2xl p-5 flex flex-col gap-5">
<h2 class="font-heading text-body text-white"> <h2 class="font-heading text-body text-white">
{m.account_contact_info()} {m.account_contact_info()}
</h2> </h2>
@@ -378,7 +387,7 @@
</div> </div>
<!-- Appearance Section --> <!-- Appearance Section -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5"> <div class="bg-dark/30 border border-light/5 rounded-2xl p-5 flex flex-col gap-5">
<h2 class="font-heading text-body text-white"> <h2 class="font-heading text-body text-white">
{m.account_appearance()} {m.account_appearance()}
</h2> </h2>
@@ -404,17 +413,17 @@
{#each accentColors as color} {#each accentColors as color}
<button <button
type="button" type="button"
class="w-8 h-8 rounded-full border-2 transition-all {accentColor === class="w-6 h-6 rounded-full border-2 transition-all {accentColor ===
color.value color.value
? 'border-white scale-110' ? 'border-white scale-110'
: 'border-transparent hover:scale-105'}" : 'border-transparent hover:border-light/30'}"
style="background-color: {color.value}" style="background-color: {color.value}"
title={color.label} title={color.label}
onclick={() => (accentColor = color.value)} onclick={() => (accentColor = color.value)}
></button> ></button>
{/each} {/each}
<label <label
class="w-8 h-8 rounded-full border-2 border-dashed border-light/30 hover:border-light/60 transition-all cursor-pointer flex items-center justify-center overflow-hidden" class="w-6 h-6 rounded-full border-2 border-dashed border-light/20 hover:border-light/40 transition-all cursor-pointer flex items-center justify-center overflow-hidden"
title="Custom color" title="Custom color"
> >
<input <input
@@ -423,8 +432,8 @@
bind:value={accentColor} bind:value={accentColor}
/> />
<span <span
class="material-symbols-rounded text-light/40" class="material-symbols-rounded text-light/30"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
> >
colorize colorize
</span> </span>
@@ -490,7 +499,7 @@
</div> </div>
<!-- Security & Sessions Section --> <!-- Security & Sessions Section -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5"> <div class="bg-dark/30 border border-light/5 rounded-2xl p-5 flex flex-col gap-5">
<h2 class="font-heading text-body text-white"> <h2 class="font-heading text-body text-white">
{m.account_security()} {m.account_security()}
</h2> </h2>

View File

@@ -33,6 +33,7 @@
const supabase = getContext<SupabaseClient<Database>>("supabase"); const supabase = getContext<SupabaseClient<Database>>("supabase");
const log = createLogger("page.calendar"); const log = createLogger("page.calendar");
// svelte-ignore state_referenced_locally
let events = $state(data.events); let events = $state(data.events);
$effect(() => { $effect(() => {
events = data.events; events = data.events;
@@ -735,6 +736,7 @@
: ''}" : ''}"
style="background-color: {color}" style="background-color: {color}"
onclick={() => (eventColor = color)} onclick={() => (eventColor = color)}
aria-label="Color {color}"
></button> ></button>
{/each} {/each}
</div> </div>

View File

@@ -12,6 +12,7 @@
let { data }: Props = $props(); let { data }: Props = $props();
// svelte-ignore state_referenced_locally
let documents = $state(data.documents); let documents = $state(data.documents);
$effect(() => { $effect(() => {
documents = data.documents; documents = data.documents;

View File

@@ -13,6 +13,7 @@
let { data }: Props = $props(); let { data }: Props = $props();
// svelte-ignore state_referenced_locally
let documents = $state(data.documents); let documents = $state(data.documents);
$effect(() => { $effect(() => {
documents = data.documents; documents = data.documents;

View File

@@ -30,41 +30,11 @@
icon: "dashboard", icon: "dashboard",
exact: true, exact: true,
}, },
{
href: `${basePath}/tasks`,
label: m.events_mod_tasks(),
icon: "task_alt",
},
{
href: `${basePath}/files`,
label: m.events_mod_files(),
icon: "folder",
},
{
href: `${basePath}/schedule`,
label: m.events_mod_schedule(),
icon: "calendar_today",
},
{
href: `${basePath}/budget`,
label: m.events_mod_budget(),
icon: "account_balance_wallet",
},
{
href: `${basePath}/guests`,
label: m.events_mod_guests(),
icon: "groups",
},
{ {
href: `${basePath}/team`, href: `${basePath}/team`,
label: m.events_mod_team(), label: m.events_mod_team(),
icon: "badge", icon: "badge",
}, },
{
href: `${basePath}/sponsors`,
label: m.events_mod_sponsors(),
icon: "handshake",
},
]); ]);
function isModuleActive(href: string, exact?: boolean): boolean { function isModuleActive(href: string, exact?: boolean): boolean {
@@ -162,6 +132,30 @@
{/if} {/if}
</a> </a>
{/each} {/each}
<!-- Departments -->
{#if data.eventDepartments.length > 0}
<p class="text-[10px] uppercase tracking-wider text-light/30 px-3 mt-3 mb-1">
Departments
</p>
{#each data.eventDepartments as dept}
<a
href="{basePath}/dept/{dept.id}"
class="flex items-center gap-2.5 px-3 py-2 rounded-xl text-body-sm font-body transition-colors {isModuleActive(`${basePath}/dept/${dept.id}`)
? 'bg-primary text-background'
: 'text-light/50 hover:text-white hover:bg-dark/50'}"
>
<span
class="w-2.5 h-2.5 rounded-full shrink-0"
style="background-color: {dept.color}"
></span>
<span class="flex-1 truncate">{dept.name}</span>
{#if isNavigatingToModule(`${basePath}/dept/${dept.id}`)}
<span class="block w-3.5 h-3.5 border-2 border-background/30 border-t-background rounded-full animate-spin shrink-0"></span>
{/if}
</a>
{/each}
{/if}
</nav> </nav>
<!-- Event Team Preview --> <!-- Event Team Preview -->

View File

@@ -0,0 +1,61 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { fetchDashboard, fetchChecklists, fetchNotes } from '$lib/api/department-dashboard';
import { fetchStages, fetchBlocks } from '$lib/api/schedule';
import { fetchContacts } from '$lib/api/contacts';
import { fetchBudgetCategories, fetchBudgetItems } from '$lib/api/budget';
import { fetchSponsorTiers, fetchSponsors, fetchAllDeliverables } from '$lib/api/sponsors';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.department-dashboard');
export const load: PageServerLoad = async ({ params, locals, parent }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) error(401, 'Unauthorized');
const parentData = await parent();
const event = (parentData as any).event;
const departments = (parentData as any).eventDepartments ?? [];
const department = departments.find((d: any) => d.id === params.deptId);
if (!department) error(404, 'Department not found');
try {
const [dashboard, checklists, notes, scheduleStages, scheduleBlocks, contacts, budgetCategories, budgetItems, sponsorTiers, sponsors] = await Promise.all([
fetchDashboard(locals.supabase, params.deptId),
fetchChecklists(locals.supabase, params.deptId),
fetchNotes(locals.supabase, params.deptId),
fetchStages(locals.supabase, params.deptId).catch(() => []),
fetchBlocks(locals.supabase, params.deptId).catch(() => []),
fetchContacts(locals.supabase, params.deptId).catch(() => []),
fetchBudgetCategories(locals.supabase, params.deptId).catch(() => []),
fetchBudgetItems(locals.supabase, params.deptId).catch(() => []),
fetchSponsorTiers(locals.supabase, params.deptId).catch(() => []),
fetchSponsors(locals.supabase, params.deptId).catch(() => []),
]);
// Fetch deliverables for all sponsors
const sponsorIds = (sponsors as any[]).map((s: any) => s.id);
const sponsorDeliverables = sponsorIds.length > 0
? await fetchAllDeliverables(locals.supabase, sponsorIds).catch(() => [])
: [];
return {
department,
dashboard,
checklists,
notes,
scheduleStages,
scheduleBlocks,
contacts,
budgetCategories,
budgetItems,
sponsorTiers,
sponsors,
sponsorDeliverables,
};
} catch (e: any) {
log.error('Failed to load department dashboard', { error: e, data: { deptId: params.deptId } });
error(500, 'Failed to load department dashboard');
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,7 @@
const supabase = getContext<SupabaseClient<Database>>("supabase"); const supabase = getContext<SupabaseClient<Database>>("supabase");
// svelte-ignore state_referenced_locally
let taskColumns = $state<TaskColumnWithTasks[]>(data.taskColumns); let taskColumns = $state<TaskColumnWithTasks[]>(data.taskColumns);
let realtimeChannel = $state<RealtimeChannel | null>(null); let realtimeChannel = $state<RealtimeChannel | null>(null);
let optimisticMoveIds = new Set<string>(); let optimisticMoveIds = new Set<string>();

View File

@@ -49,8 +49,11 @@
); );
// Local mutable state // Local mutable state
// svelte-ignore state_referenced_locally
let teamMembers = $state<EventMemberWithDetails[]>(data.eventMembers); let teamMembers = $state<EventMemberWithDetails[]>(data.eventMembers);
// svelte-ignore state_referenced_locally
let roles = $state<EventRole[]>(data.eventRoles); let roles = $state<EventRole[]>(data.eventRoles);
// svelte-ignore state_referenced_locally
let departments = $state<EventDepartment[]>(data.eventDepartments); let departments = $state<EventDepartment[]>(data.eventDepartments);
$effect(() => { $effect(() => {
@@ -118,8 +121,29 @@
let editingDept = $state<EventDepartment | null>(null); let editingDept = $state<EventDepartment | null>(null);
let deptName = $state(""); let deptName = $state("");
let deptColor = $state("#00A3E0"); let deptColor = $state("#00A3E0");
type ModuleType = "kanban" | "files" | "checklist" | "notes" | "schedule" | "contacts" | "budget" | "sponsors";
let deptModules = $state<ModuleType[]>(["kanban", "files", "checklist"]);
let savingDept = $state(false); let savingDept = $state(false);
const allModules = [
{ id: "kanban", label: "Kanban", icon: "view_kanban", color: "#6366f1" },
{ id: "files", label: "Files", icon: "folder", color: "#F59E0B" },
{ id: "checklist", label: "Checklist", icon: "checklist", color: "#10B981" },
{ id: "notes", label: "Notes", icon: "description", color: "#8B5CF6" },
{ id: "schedule", label: "Schedule", icon: "calendar_today", color: "#EC4899" },
{ id: "contacts", label: "Contacts", icon: "contacts", color: "#00A3E0" },
{ id: "budget", label: "Budget", icon: "account_balance", color: "#10B981" },
{ id: "sponsors", label: "Sponsors", icon: "handshake", color: "#F59E0B" },
] as const;
function toggleModule(id: ModuleType) {
if (deptModules.includes(id)) {
deptModules = deptModules.filter((m) => m !== id);
} else {
deptModules = [...deptModules, id];
}
}
let showRoleModal = $state(false); let showRoleModal = $state(false);
let editingRole = $state<EventRole | null>(null); let editingRole = $state<EventRole | null>(null);
let roleName = $state(""); let roleName = $state("");
@@ -132,6 +156,53 @@
"#F97316", "#3B82F6", "#F97316", "#3B82F6",
]; ];
const deptPresets: { name: string; color: string; modules: ModuleType[] }[] = [
{ name: "Logistics", color: "#F59E0B", modules: ["kanban", "files", "checklist"] },
{ name: "IT & Tech", color: "#6366F1", modules: ["kanban", "files", "checklist", "notes"] },
{ name: "Marketing", color: "#EC4899", modules: ["kanban", "files", "notes"] },
{ name: "Finance", color: "#10B981", modules: ["kanban", "files", "checklist", "budget"] },
{ name: "Program", color: "#8B5CF6", modules: ["kanban", "files", "schedule", "notes"] },
{ name: "Sponsorship", color: "#00A3E0", modules: ["kanban", "files", "contacts", "notes", "sponsors"] },
{ name: "Design", color: "#F97316", modules: ["kanban", "files"] },
{ name: "Volunteers", color: "#14B8A6", modules: ["kanban", "files", "checklist", "schedule"] },
{ name: "Venue Management", color: "#3B82F6", modules: ["kanban", "files", "checklist"] },
{ name: "Security", color: "#EF4444", modules: ["kanban", "checklist"] },
{ name: "Bar / Catering", color: "#F59E0B", modules: ["kanban", "files", "checklist"] },
{ name: "Photography", color: "#8B5CF6", modules: ["kanban", "files"] },
{ name: "Registration", color: "#10B981", modules: ["kanban", "checklist"] },
{ name: "Ticket Sales", color: "#EC4899", modules: ["kanban", "files", "checklist"] },
];
const rolePresets: { name: string; color: string }[] = [
{ name: "Head Organizer", color: "#EF4444" },
{ name: "Team Lead", color: "#8B5CF6" },
{ name: "Organizer", color: "#F59E0B" },
{ name: "Volunteer", color: "#10B981" },
{ name: "Sponsor", color: "#00A3E0" },
{ name: "Coordinator", color: "#6366F1" },
{ name: "Designer", color: "#F97316" },
{ name: "Technician", color: "#3B82F6" },
];
// Filter out presets that already exist
const availableDeptPresets = $derived(
deptPresets.filter((p) => !departments.some((d) => d.name === p.name)),
);
const availableRolePresets = $derived(
rolePresets.filter((p) => !roles.some((r) => r.name === p.name)),
);
function autofillDept(preset: { name: string; color: string; modules: ModuleType[] }) {
deptName = preset.name;
deptColor = preset.color;
deptModules = [...preset.modules];
}
function autofillRole(preset: { name: string; color: string }) {
roleName = preset.name;
roleColor = preset.color;
}
function getMemberName(member: EventMemberWithDetails): string { function getMemberName(member: EventMemberWithDetails): string {
return member.profile?.full_name || member.profile?.email || "Unknown"; return member.profile?.full_name || member.profile?.email || "Unknown";
} }
@@ -290,6 +361,7 @@
editingDept = dept ?? null; editingDept = dept ?? null;
deptName = dept?.name ?? ""; deptName = dept?.name ?? "";
deptColor = dept?.color ?? "#00A3E0"; deptColor = dept?.color ?? "#00A3E0";
deptModules = ["kanban", "files", "checklist"];
showDeptModal = true; showDeptModal = true;
} }
@@ -310,13 +382,14 @@
); );
toasts.success(m.team_dept_updated()); toasts.success(m.team_dept_updated());
} else { } else {
const { data: created, error } = await supabase const { data: created, error } = await (supabase as any)
.from("event_departments") .from("event_departments")
.insert({ .insert({
event_id: data.event.id, event_id: data.event.id,
name: deptName.trim(), name: deptName.trim(),
color: deptColor, color: deptColor,
sort_order: departments.length, sort_order: departments.length,
enabled_modules: deptModules,
}) })
.select() .select()
.single(); .single();
@@ -795,8 +868,25 @@
</Modal> </Modal>
<!-- Department Modal --> <!-- Department Modal -->
<Modal isOpen={showDeptModal} onClose={() => (showDeptModal = false)} title={editingDept ? m.team_edit_department() : m.team_add_department()}> <Modal isOpen={showDeptModal} onClose={() => (showDeptModal = false)} title={editingDept ? m.team_edit_department() : m.team_add_department()} size="lg">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
{#if !editingDept && availableDeptPresets.length > 0}
<div class="flex flex-col gap-1.5">
<span class="text-body-sm text-light/60 font-body">Quick add</span>
<div class="flex flex-wrap gap-1.5">
{#each availableDeptPresets as preset}
<button
type="button"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-light/10 hover:border-light/30 transition-all text-[12px] text-light/50 hover:text-white"
onclick={() => autofillDept(preset)}
>
<span class="w-2 h-2 rounded-full" style="background-color: {preset.color}"></span>
{preset.name}
</button>
{/each}
</div>
</div>
{/if}
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label for="dept-name" class="text-body-sm text-light/60 font-body">{m.team_dept_name()}</label> <label for="dept-name" class="text-body-sm text-light/60 font-body">{m.team_dept_name()}</label>
<input id="dept-name" type="text" bind:value={deptName} placeholder={m.team_dept_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" /> <input id="dept-name" type="text" bind:value={deptName} placeholder={m.team_dept_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" />
@@ -805,10 +895,30 @@
<span class="text-body-sm text-light/60 font-body">Color</span> <span class="text-body-sm text-light/60 font-body">Color</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#each presetColors as c} {#each presetColors as c}
<button type="button" class="w-6 h-6 rounded-full border-2 transition-all {deptColor === c ? 'border-white scale-110' : 'border-transparent hover:border-light/30'}" style="background-color: {c}" onclick={() => (deptColor = c)}></button> <button type="button" class="w-6 h-6 rounded-full border-2 transition-all {deptColor === c ? 'border-white scale-110' : 'border-transparent hover:border-light/30'}" style="background-color: {c}" onclick={() => (deptColor = c)} aria-label="Color {c}"></button>
{/each} {/each}
</div> </div>
</div> </div>
{#if !editingDept}
<div class="flex flex-col gap-1.5">
<span class="text-body-sm text-light/60 font-body">Modules</span>
<div class="grid grid-cols-3 gap-2">
{#each allModules as mod}
<button
type="button"
class="flex items-center gap-2 px-3 py-2 rounded-xl border transition-all text-left {deptModules.includes(mod.id) ? 'border-primary/50 bg-primary/10' : 'border-light/10 hover:border-light/20'}"
onclick={() => toggleModule(mod.id)}
>
<span
class="material-symbols-rounded"
style="font-size: 16px; color: {deptModules.includes(mod.id) ? mod.color : 'rgba(255,255,255,0.3)'}; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>{mod.icon}</span>
<span class="text-[12px] {deptModules.includes(mod.id) ? 'text-white' : 'text-light/40'}">{mod.label}</span>
</button>
{/each}
</div>
</div>
{/if}
{#if editingDept} {#if editingDept}
<button type="button" class="text-[11px] text-error hover:underline self-start" onclick={() => { handleDeleteDept(editingDept!); showDeptModal = false; }}> <button type="button" class="text-[11px] text-error hover:underline self-start" onclick={() => { handleDeleteDept(editingDept!); showDeptModal = false; }}>
{m.team_dept_delete_confirm({ name: editingDept.name })} {m.team_dept_delete_confirm({ name: editingDept.name })}
@@ -826,6 +936,23 @@
<!-- Role Modal --> <!-- Role Modal -->
<Modal isOpen={showRoleModal} onClose={() => (showRoleModal = false)} title={editingRole ? m.team_edit_role() : m.team_add_role()}> <Modal isOpen={showRoleModal} onClose={() => (showRoleModal = false)} title={editingRole ? m.team_edit_role() : m.team_add_role()}>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
{#if !editingRole && availableRolePresets.length > 0}
<div class="flex flex-col gap-1.5">
<span class="text-body-sm text-light/60 font-body">Quick add</span>
<div class="flex flex-wrap gap-1.5">
{#each availableRolePresets as preset}
<button
type="button"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-light/10 hover:border-light/30 transition-all text-[12px] text-light/50 hover:text-white"
onclick={() => autofillRole(preset)}
>
<span class="w-2 h-2 rounded-full" style="background-color: {preset.color}"></span>
{preset.name}
</button>
{/each}
</div>
</div>
{/if}
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label for="role-name" class="text-body-sm text-light/60 font-body">{m.team_role_name()}</label> <label for="role-name" class="text-body-sm text-light/60 font-body">{m.team_role_name()}</label>
<input id="role-name" type="text" bind:value={roleName} placeholder={m.team_role_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" /> <input id="role-name" type="text" bind:value={roleName} placeholder={m.team_role_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" />
@@ -834,7 +961,7 @@
<span class="text-body-sm text-light/60 font-body">Color</span> <span class="text-body-sm text-light/60 font-body">Color</span>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#each presetColors as c} {#each presetColors as c}
<button type="button" class="w-6 h-6 rounded-full border-2 transition-all {roleColor === c ? 'border-white scale-110' : 'border-transparent hover:border-light/30'}" style="background-color: {c}" onclick={() => (roleColor = c)}></button> <button type="button" class="w-6 h-6 rounded-full border-2 transition-all {roleColor === c ? 'border-white scale-110' : 'border-transparent hover:border-light/30'}" style="background-color: {c}" onclick={() => (roleColor = c)} aria-label="Color {c}"></button>
{/each} {/each}
</div> </div>
</div> </div>

View File

@@ -56,6 +56,7 @@
const supabase = getContext<SupabaseClient<Database>>("supabase"); const supabase = getContext<SupabaseClient<Database>>("supabase");
const log = createLogger("page.kanban"); const log = createLogger("page.kanban");
// svelte-ignore state_referenced_locally
let boards = $state(data.boards); let boards = $state(data.boards);
$effect(() => { $effect(() => {
boards = data.boards; boards = data.boards;
@@ -543,6 +544,7 @@
<!-- Board toolbar --> <!-- Board toolbar -->
<div class="flex items-center gap-2 px-6 py-3 border-b border-light/5 shrink-0"> <div class="flex items-center gap-2 px-6 py-3 border-b border-light/5 shrink-0">
{#if isRenamingBoard && selectedBoard} {#if isRenamingBoard && selectedBoard}
<!-- svelte-ignore a11y_autofocus -->
<input <input
type="text" type="text"
class="flex-1 bg-dark border border-primary rounded-lg px-3 py-1 text-white font-heading text-body focus:outline-none" class="flex-1 bg-dark border border-primary rounded-lg px-3 py-1 text-white font-heading text-body focus:outline-none"

View File

@@ -102,9 +102,13 @@
]; ];
// Shared state passed to child components // Shared state passed to child components
// svelte-ignore state_referenced_locally
let members = $state<Member[]>(data.members as Member[]); let members = $state<Member[]>(data.members as Member[]);
// svelte-ignore state_referenced_locally
let roles = $state<OrgRole[]>(data.roles as OrgRole[]); let roles = $state<OrgRole[]>(data.roles as OrgRole[]);
// svelte-ignore state_referenced_locally
let invites = $state<Invite[]>(data.invites as Invite[]); let invites = $state<Invite[]>(data.invites as Invite[]);
// svelte-ignore state_referenced_locally
let orgCalendar = $state<OrgCalendar | null>( let orgCalendar = $state<OrgCalendar | null>(
data.orgCalendar as OrgCalendar | null, data.orgCalendar as OrgCalendar | null,
); );
@@ -415,57 +419,66 @@
onClose={() => (showCreateTagModal = false)} onClose={() => (showCreateTagModal = false)}
title={editingTag ? "Edit Tag" : "Create Tag"} title={editingTag ? "Edit Tag" : "Create Tag"}
> >
<div class="space-y-4"> <div class="flex flex-col gap-4">
<Input <div class="flex flex-col gap-1.5">
label="Name" <label for="tag-name" class="text-body-sm text-light/60 font-body">{m.settings_tags_name_placeholder()}</label>
bind:value={tagName} <input
placeholder={m.settings_tags_name_placeholder()} id="tag-name"
/> type="text"
<div> bind:value={tagName}
<span class="block text-sm font-medium text-light mb-2">Color</span> placeholder={m.settings_tags_name_placeholder()}
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 flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each TAG_COLORS as color} {#each TAG_COLORS as color}
<button <button
type="button" type="button"
class="w-8 h-8 rounded-full transition-transform {tagColor === class="w-6 h-6 rounded-full border-2 transition-all {tagColor === color
color ? 'border-white scale-110'
? 'ring-2 ring-white scale-110' : 'border-transparent hover:border-light/30'}"
: ''}"
style="background-color: {color}" style="background-color: {color}"
onclick={() => (tagColor = color)} onclick={() => (tagColor = color)}
aria-label="Color {color}"
></button> ></button>
{/each} {/each}
</div> <label
<div class="flex items-center gap-2 mt-3"> class="w-6 h-6 rounded-full border-2 border-dashed border-light/20 hover:border-light/40 transition-all cursor-pointer flex items-center justify-center overflow-hidden"
<span class="text-xs text-light/40">Custom:</span> title="Custom color"
<input >
type="color" <input
class="w-8 h-8 rounded cursor-pointer border-0 bg-transparent" type="color"
bind:value={tagColor} class="opacity-0 absolute w-0 h-0"
/> bind:value={tagColor}
<span class="text-xs text-light/50">{tagColor}</span> />
<span
class="material-symbols-rounded text-light/30"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>colorize</span>
</label>
</div> </div>
</div> </div>
<div class="flex items-center gap-3 p-3 bg-light/5 rounded-lg"> <div class="flex items-center gap-3 p-3 bg-dark/50 rounded-xl">
<span class="text-sm text-light/50">Preview:</span> <span class="text-body-sm text-light/40">Preview:</span>
<span <span
class="rounded-[4px] px-2 py-1 font-body font-bold text-[13px] text-night leading-none" class="rounded-lg px-2.5 py-1 font-body font-bold text-[12px] text-night leading-none"
style="background-color: {tagColor}" style="background-color: {tagColor}"
> >
{tagName || "Tag name"} {tagName || "Tag name"}
</span> </span>
</div> </div>
<div class="flex justify-end gap-2 pt-2"> <div class="flex items-center justify-end gap-3 pt-2 border-t border-light/5">
<Button <button type="button" class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors" onclick={() => (showCreateTagModal = false)}>{m.btn_cancel()}</button>
variant="tertiary" <button
onclick={() => (showCreateTagModal = false)}>Cancel</Button type="button"
> disabled={!tagName.trim() || isSavingTag}
<Button 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={saveTag} onclick={saveTag}
loading={isSavingTag}
disabled={!tagName.trim()}
>{editingTag ? "Save" : "Create"}</Button
> >
{isSavingTag ? "..." : editingTag ? m.btn_save() : m.btn_create()}
</button>
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@@ -0,0 +1,104 @@
import { error, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
// Cast helper for columns not yet in generated types
function db(supabase: any) {
return supabase as any;
}
export const load: PageServerLoad = async ({ locals }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) {
redirect(303, '/login');
}
// Check platform admin status
const { data: profile } = await db(locals.supabase)
.from('profiles')
.select('is_platform_admin')
.eq('id', user.id)
.single();
if (!profile?.is_platform_admin) {
error(403, 'Access denied. Platform admin only.');
}
// Fetch all platform data in parallel
const [
orgsResult,
profilesResult,
eventsResult,
orgMembersResult,
] = await Promise.all([
db(locals.supabase)
.from('organizations')
.select('*')
.order('created_at', { ascending: false }),
db(locals.supabase)
.from('profiles')
.select('id, email, full_name, avatar_url, is_platform_admin, created_at')
.order('created_at', { ascending: false }),
db(locals.supabase)
.from('events')
.select('id, name, slug, status, start_date, end_date, org_id, created_at')
.order('created_at', { ascending: false }),
db(locals.supabase)
.from('org_members')
.select('id, user_id, org_id, role')
.order('created_at', { ascending: false }),
]);
const organizations = orgsResult.data ?? [];
const profiles = profilesResult.data ?? [];
const events = eventsResult.data ?? [];
const orgMembers = orgMembersResult.data ?? [];
// Compute stats
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const newUsersLast30d = profiles.filter(
(p: any) => p.created_at && new Date(p.created_at) > thirtyDaysAgo
).length;
const newUsersLast7d = profiles.filter(
(p: any) => p.created_at && new Date(p.created_at) > sevenDaysAgo
).length;
const activeEvents = events.filter((e: any) => e.status === 'active').length;
const planningEvents = events.filter((e: any) => e.status === 'planning').length;
// Org member counts
const orgMemberCounts: Record<string, number> = {};
for (const m of orgMembers) {
orgMemberCounts[m.org_id] = (orgMemberCounts[m.org_id] || 0) + 1;
}
// Org event counts
const orgEventCounts: Record<string, number> = {};
for (const e of events) {
if (e.org_id) {
orgEventCounts[e.org_id] = (orgEventCounts[e.org_id] || 0) + 1;
}
}
return {
organizations: organizations.map((o: any) => ({
...o,
memberCount: orgMemberCounts[o.id] || 0,
eventCount: orgEventCounts[o.id] || 0,
})),
profiles,
events,
stats: {
totalUsers: profiles.length,
totalOrgs: organizations.length,
totalEvents: events.length,
totalMemberships: orgMembers.length,
newUsersLast30d,
newUsersLast7d,
activeEvents,
planningEvents,
},
};
};

View File

@@ -0,0 +1,406 @@
<script lang="ts">
import { Button, Badge, Avatar, Card, StatCard, TabBar, Input } from "$lib/components/ui";
let { data } = $props();
let activeTab = $state("overview");
let orgSearch = $state("");
let userSearch = $state("");
let eventSearch = $state("");
const filteredOrgs = $derived(
orgSearch
? data.organizations.filter((o: any) =>
o.name?.toLowerCase().includes(orgSearch.toLowerCase()) ||
o.slug?.toLowerCase().includes(orgSearch.toLowerCase())
)
: data.organizations,
);
const filteredUsers = $derived(
userSearch
? data.profiles.filter((p: any) =>
p.email?.toLowerCase().includes(userSearch.toLowerCase()) ||
p.full_name?.toLowerCase().includes(userSearch.toLowerCase())
)
: data.profiles,
);
const filteredEvents = $derived(
eventSearch
? data.events.filter((e: any) =>
e.name?.toLowerCase().includes(eventSearch.toLowerCase()) ||
e.slug?.toLowerCase().includes(eventSearch.toLowerCase())
)
: data.events,
);
function formatDate(dateStr: string | null) {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
function timeAgo(dateStr: string | null) {
if (!dateStr) return "—";
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return formatDate(dateStr);
}
const statusColors: Record<string, string> = {
planning: "text-amber-400 bg-amber-400/10",
active: "text-emerald-400 bg-emerald-400/10",
completed: "text-blue-400 bg-blue-400/10",
archived: "text-light/40 bg-light/5",
draft: "text-light/40 bg-light/5",
};
// Find org name by id
const orgMap = $derived(
Object.fromEntries(data.organizations.map((o: any) => [o.id, o])),
);
</script>
<svelte:head>
<title>Platform Admin | Root</title>
</svelte:head>
<div class="min-h-screen bg-background">
<!-- Header -->
<header class="border-b border-light/5 bg-dark/30">
<div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<a
href="/"
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
>
<span class="material-symbols-rounded" style="font-size: 20px;">arrow_back</span>
</a>
<div class="flex items-center gap-2">
<span
class="material-symbols-rounded text-primary"
style="font-size: 24px; font-variation-settings: 'FILL' 1;"
>admin_panel_settings</span
>
<span class="font-heading text-body text-white">Platform Admin</span>
</div>
<Badge variant="error" size="sm">Admin Only</Badge>
</div>
<div class="flex items-center gap-2 text-light/40 text-body-sm">
<span class="material-symbols-rounded" style="font-size: 16px;">schedule</span>
{new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
</div>
</div>
</header>
<div class="max-w-7xl mx-auto px-6 py-6 space-y-6">
<!-- Stats Overview -->
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-3">
<StatCard label="Total Users" value={data.stats.totalUsers} icon="group" />
<StatCard label="Organizations" value={data.stats.totalOrgs} icon="business" />
<StatCard label="Total Events" value={data.stats.totalEvents} icon="event" />
<StatCard label="Memberships" value={data.stats.totalMemberships} icon="badge" />
<StatCard label="New (7d)" value={data.stats.newUsersLast7d} icon="person_add" />
<StatCard label="New (30d)" value={data.stats.newUsersLast30d} icon="trending_up" />
<StatCard label="Active Events" value={data.stats.activeEvents} icon="play_circle" />
<StatCard label="Planning" value={data.stats.planningEvents} icon="edit_calendar" />
</div>
<!-- Tab Navigation -->
<TabBar
tabs={[
{ value: "overview", label: "Overview", icon: "dashboard" },
{ value: "organizations", label: "Organizations", icon: "business" },
{ value: "users", label: "Users", icon: "group" },
{ value: "events", label: "Events", icon: "event" },
]}
active={activeTab}
onchange={(v) => (activeTab = v)}
/>
<!-- Tab Content -->
{#if activeTab === "overview"}
<div class="grid lg:grid-cols-2 gap-6">
<!-- Recent Organizations -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="font-heading text-body text-white">Recent Organizations</h3>
<button
type="button"
class="text-[11px] text-primary hover:text-primary/80 transition-colors"
onclick={() => (activeTab = "organizations")}
>
View all →
</button>
</div>
<div class="space-y-2">
{#each data.organizations.slice(0, 5) as org}
<div class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-dark/50 transition-colors">
<div class="flex items-center gap-3 min-w-0">
<Avatar name={org.name ?? "Org"} size="sm" />
<div class="min-w-0">
<p class="text-body-sm text-white truncate">{org.name}</p>
<p class="text-[10px] text-light/30">/{org.slug}</p>
</div>
</div>
<div class="flex items-center gap-3 shrink-0 text-[10px] text-light/40">
<span>{org.memberCount} members</span>
<span>{org.eventCount} events</span>
</div>
</div>
{/each}
{#if data.organizations.length === 0}
<p class="text-body-sm text-light/30 text-center py-4">No organizations yet</p>
{/if}
</div>
</div>
<!-- Recent Users -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="font-heading text-body text-white">Recent Users</h3>
<button
type="button"
class="text-[11px] text-primary hover:text-primary/80 transition-colors"
onclick={() => (activeTab = "users")}
>
View all →
</button>
</div>
<div class="space-y-2">
{#each data.profiles.slice(0, 5) as profile}
<div class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-dark/50 transition-colors">
<div class="flex items-center gap-3 min-w-0">
<Avatar name={profile.full_name ?? profile.email} size="sm" src={profile.avatar_url} />
<div class="min-w-0">
<p class="text-body-sm text-white truncate">{profile.full_name ?? "No name"}</p>
<p class="text-[10px] text-light/30">{profile.email}</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
{#if profile.is_platform_admin}
<Badge variant="error" size="sm">Admin</Badge>
{/if}
<span class="text-[10px] text-light/30">{timeAgo(profile.created_at)}</span>
</div>
</div>
{/each}
{#if data.profiles.length === 0}
<p class="text-body-sm text-light/30 text-center py-4">No users yet</p>
{/if}
</div>
</div>
<!-- Recent Events -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 lg:col-span-2">
<div class="flex items-center justify-between mb-4">
<h3 class="font-heading text-body text-white">Recent Events</h3>
<button
type="button"
class="text-[11px] text-primary hover:text-primary/80 transition-colors"
onclick={() => (activeTab = "events")}
>
View all →
</button>
</div>
{#if data.events.length > 0}
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead>
<tr class="border-b border-light/5">
<th class="text-[10px] text-light/40 font-body pb-2 pr-4">Event</th>
<th class="text-[10px] text-light/40 font-body pb-2 pr-4">Organization</th>
<th class="text-[10px] text-light/40 font-body pb-2 pr-4">Status</th>
<th class="text-[10px] text-light/40 font-body pb-2 pr-4">Dates</th>
<th class="text-[10px] text-light/40 font-body pb-2">Created</th>
</tr>
</thead>
<tbody>
{#each data.events.slice(0, 8) as event}
<tr class="border-b border-light/5 last:border-0 hover:bg-dark/30">
<td class="py-2.5 pr-4">
<p class="text-body-sm text-white">{event.name}</p>
<p class="text-[10px] text-light/30">/{event.slug}</p>
</td>
<td class="py-2.5 pr-4 text-body-sm text-light/50">
{orgMap[event.org_id]?.name ?? "—"}
</td>
<td class="py-2.5 pr-4">
<span class="text-[10px] px-2 py-0.5 rounded-full capitalize {statusColors[event.status] ?? 'text-light/40 bg-light/5'}">
{event.status}
</span>
</td>
<td class="py-2.5 pr-4 text-[11px] text-light/40">
{formatDate(event.start_date)}{formatDate(event.end_date)}
</td>
<td class="py-2.5 text-[11px] text-light/30">{timeAgo(event.created_at)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-body-sm text-light/30 text-center py-4">No events yet</p>
{/if}
</div>
</div>
{:else if activeTab === "organizations"}
<div class="space-y-4">
<div class="max-w-sm">
<Input placeholder="Search organizations..." icon="search" bind:value={orgSearch} />
</div>
<div class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden">
<table class="w-full text-left">
<thead>
<tr class="border-b border-light/5 bg-dark/20">
<th class="text-[10px] text-light/40 font-body py-3 px-4">Organization</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Slug</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Members</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Events</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Created</th>
</tr>
</thead>
<tbody>
{#each filteredOrgs as org}
<tr class="border-b border-light/5 last:border-0 hover:bg-dark/30 transition-colors">
<td class="py-3 px-4">
<div class="flex items-center gap-3">
<Avatar name={org.name ?? "Org"} size="sm" />
<span class="text-body-sm text-white">{org.name}</span>
</div>
</td>
<td class="py-3 px-4 text-body-sm text-light/40">/{org.slug}</td>
<td class="py-3 px-4">
<Badge variant="default" size="sm">{org.memberCount}</Badge>
</td>
<td class="py-3 px-4">
<Badge variant="primary" size="sm">{org.eventCount}</Badge>
</td>
<td class="py-3 px-4 text-[11px] text-light/30">{formatDate(org.created_at)}</td>
</tr>
{/each}
{#if filteredOrgs.length === 0}
<tr>
<td colspan="5" class="py-8 text-center text-body-sm text-light/30">
{orgSearch ? "No organizations match your search" : "No organizations yet"}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
<p class="text-[10px] text-light/30">{filteredOrgs.length} of {data.organizations.length} organizations</p>
</div>
{:else if activeTab === "users"}
<div class="space-y-4">
<div class="max-w-sm">
<Input placeholder="Search users..." icon="search" bind:value={userSearch} />
</div>
<div class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden">
<table class="w-full text-left">
<thead>
<tr class="border-b border-light/5 bg-dark/20">
<th class="text-[10px] text-light/40 font-body py-3 px-4">User</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Email</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Role</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Joined</th>
</tr>
</thead>
<tbody>
{#each filteredUsers as profile}
<tr class="border-b border-light/5 last:border-0 hover:bg-dark/30 transition-colors">
<td class="py-3 px-4">
<div class="flex items-center gap-3">
<Avatar name={profile.full_name ?? profile.email} size="sm" src={profile.avatar_url} />
<span class="text-body-sm text-white">{profile.full_name ?? "No name"}</span>
</div>
</td>
<td class="py-3 px-4 text-body-sm text-light/40">{profile.email}</td>
<td class="py-3 px-4">
{#if profile.is_platform_admin}
<Badge variant="error" size="sm">Platform Admin</Badge>
{:else}
<Badge variant="default" size="sm">User</Badge>
{/if}
</td>
<td class="py-3 px-4 text-[11px] text-light/30">{formatDate(profile.created_at)}</td>
</tr>
{/each}
{#if filteredUsers.length === 0}
<tr>
<td colspan="4" class="py-8 text-center text-body-sm text-light/30">
{userSearch ? "No users match your search" : "No users yet"}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
<p class="text-[10px] text-light/30">{filteredUsers.length} of {data.profiles.length} users</p>
</div>
{:else if activeTab === "events"}
<div class="space-y-4">
<div class="max-w-sm">
<Input placeholder="Search events..." icon="search" bind:value={eventSearch} />
</div>
<div class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden">
<table class="w-full text-left">
<thead>
<tr class="border-b border-light/5 bg-dark/20">
<th class="text-[10px] text-light/40 font-body py-3 px-4">Event</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Organization</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Status</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Start</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">End</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Created</th>
</tr>
</thead>
<tbody>
{#each filteredEvents as event}
<tr class="border-b border-light/5 last:border-0 hover:bg-dark/30 transition-colors">
<td class="py-3 px-4">
<div>
<p class="text-body-sm text-white">{event.name}</p>
<p class="text-[10px] text-light/30">/{event.slug}</p>
</div>
</td>
<td class="py-3 px-4 text-body-sm text-light/40">
{orgMap[event.org_id]?.name ?? "—"}
</td>
<td class="py-3 px-4">
<span class="text-[10px] px-2 py-0.5 rounded-full capitalize {statusColors[event.status] ?? 'text-light/40 bg-light/5'}">
{event.status}
</span>
</td>
<td class="py-3 px-4 text-[11px] text-light/40">{formatDate(event.start_date)}</td>
<td class="py-3 px-4 text-[11px] text-light/40">{formatDate(event.end_date)}</td>
<td class="py-3 px-4 text-[11px] text-light/30">{timeAgo(event.created_at)}</td>
</tr>
{/each}
{#if filteredEvents.length === 0}
<tr>
<td colspan="6" class="py-8 text-center text-body-sm text-light/30">
{eventSearch ? "No events match your search" : "No events yet"}
</td>
</tr>
{/if}
</tbody>
</table>
</div>
<p class="text-[10px] text-light/30">{filteredEvents.length} of {data.events.length} events</p>
</div>
{/if}
</div>
</div>

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { Button, Card } from "$lib/components/ui"; import { Button } from "$lib/components/ui";
import { createLogger } from "$lib/utils/logger"; import { createLogger } from "$lib/utils/logger";
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
@@ -24,6 +24,7 @@
const log = createLogger("page.invite"); const log = createLogger("page.invite");
let isAccepting = $state(false); let isAccepting = $state(false);
// svelte-ignore state_referenced_locally
let error = $state(data.error || ""); let error = $state(data.error || "");
async function acceptInvite() { async function acceptInvite() {
@@ -94,82 +95,66 @@
} }
</script> </script>
<div class="min-h-screen bg-dark flex items-center justify-center p-4"> <div class="min-h-screen bg-background flex items-center justify-center p-4">
<div <div class="w-full max-w-sm">
class="w-full max-w-md bg-dark-light rounded-xl border border-light/10" <div class="bg-surface rounded-2xl border border-light/5 p-6 text-center">
>
<div class="p-6 text-center">
{#if data.error} {#if data.error}
<!-- Invalid/Expired Invite --> <!-- Invalid/Expired Invite -->
<div <div
class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-500/20 flex items-center justify-center" class="w-14 h-14 mx-auto mb-4 rounded-2xl bg-error/10 flex items-center justify-center"
> >
<svg <span
class="w-8 h-8 text-red-400" class="material-symbols-rounded text-error"
viewBox="0 0 24 24" style="font-size: 28px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 28;"
fill="none" >error</span>
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</div> </div>
<h1 class="text-xl font-bold text-light mb-2"> <h1 class="text-body font-heading text-white mb-2">
Invalid Invite Invalid Invite
</h1> </h1>
<p class="text-light/60 mb-6">{data.error}</p> <p class="text-body-sm text-light/40 mb-6">{data.error}</p>
<Button onclick={() => goto("/")}>Go Home</Button> <Button onclick={() => goto("/")}>Go Home</Button>
{:else if data.invite} {:else if data.invite}
<!-- Valid Invite --> <!-- Valid Invite -->
<div <div
class="w-16 h-16 mx-auto mb-4 rounded-full bg-primary/20 flex items-center justify-center" class="w-14 h-14 mx-auto mb-4 rounded-2xl bg-primary/10 flex items-center justify-center"
> >
<svg <span
class="w-8 h-8 text-primary" class="material-symbols-rounded text-primary"
viewBox="0 0 24 24" style="font-size: 28px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 28;"
fill="none" >group_add</span>
stroke="currentColor"
stroke-width="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div> </div>
<h1 class="text-xl font-bold text-light mb-2"> <h1 class="text-body font-heading text-white mb-2">
You're Invited! You're Invited!
</h1> </h1>
<p class="text-light/60 mb-1">You've been invited to join</p> <p class="text-body-sm text-light/40 mb-1">You've been invited to join</p>
<p class="text-2xl font-bold text-primary mb-1"> <p class="text-heading-sm font-heading text-primary mb-1">
{data.invite.org.name} {data.invite.org.name}
</p> </p>
<p class="text-light/50 text-sm mb-6">as {data.invite.role}</p> <p class="text-body-sm text-light/30 mb-6">as <span class="text-light/60 capitalize">{data.invite.role}</span></p>
{#if error} {#if error}
<div <div
class="p-3 mb-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm" class="p-3 mb-4 bg-error/10 border border-error/20 rounded-xl text-error text-body-sm flex items-center gap-2 text-left"
> >
<span class="material-symbols-rounded shrink-0" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">error</span>
{error} {error}
</div> </div>
{/if} {/if}
{#if data.user} {#if data.user}
<!-- User is logged in --> <!-- User is logged in -->
<p class="text-light/60 text-sm mb-4"> <p class="text-body-sm text-light/40 mb-4">
Signed in as <strong class="text-light" Signed in as <strong class="text-white"
>{data.user.email}</strong >{data.user.email}</strong
> >
</p> </p>
<div class="w-full"> <div class="w-full">
<Button onclick={acceptInvite} loading={isAccepting}> <Button fullWidth onclick={acceptInvite} loading={isAccepting}>
Accept Invite & Join Accept Invite & Join
</Button> </Button>
</div> </div>
<p class="text-light/40 text-xs mt-3"> <p class="text-light/30 text-[11px] mt-3">
Wrong account? <a Wrong account? <a
href="/auth/logout" href="/auth/logout"
class="text-primary hover:underline">Sign out</a class="text-primary hover:underline">Sign out</a
@@ -177,12 +162,12 @@
</p> </p>
{:else} {:else}
<!-- User not logged in --> <!-- User not logged in -->
<p class="text-light/60 text-sm mb-4"> <p class="text-body-sm text-light/40 mb-4">
Sign in or create an account to accept this invite. Sign in or create an account to accept this invite.
</p> </p>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Button onclick={goToLogin}>Sign In</Button> <Button fullWidth onclick={goToLogin}>Sign In</Button>
<Button onclick={goToSignup} variant="tertiary" <Button fullWidth onclick={goToSignup} variant="secondary"
>Create Account</Button >Create Account</Button
> >
</div> </div>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Button, Input, Card } from "$lib/components/ui"; import { Button, Input } from "$lib/components/ui";
import { createClient } from "$lib/supabase"; import { createClient } from "$lib/supabase";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/stores";
@@ -91,30 +91,33 @@
<title>{mode === "login" ? "Log In" : "Sign Up"} | Root</title> <title>{mode === "login" ? "Log In" : "Sign Up"} | Root</title>
</svelte:head> </svelte:head>
<div class="min-h-screen bg-dark flex items-center justify-center p-4"> <div class="min-h-screen bg-background flex items-center justify-center p-4">
<div class="w-full max-w-md"> <div class="w-full max-w-sm">
<div class="text-center mb-8"> <div class="text-center mb-8">
<h1 class="text-3xl font-bold text-primary mb-2">{m.app_name()}</h1> <div class="w-12 h-12 mx-auto mb-4 bg-primary/10 rounded-2xl flex items-center justify-center">
<p class="text-light/60">{m.login_subtitle()}</p> <span class="material-symbols-rounded text-primary" style="font-size: 24px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;">hub</span>
</div>
<h1 class="text-heading-sm font-heading text-white mb-1">{m.app_name()}</h1>
<p class="text-body-sm text-light/40">{m.login_subtitle()}</p>
</div> </div>
<Card variant="elevated" padding="lg"> <div class="bg-surface rounded-2xl border border-light/5 p-6">
{#if signupSuccess} {#if signupSuccess}
<div class="text-center py-4"> <div class="text-center py-4">
<div <div
class="w-16 h-16 mx-auto mb-4 rounded-full bg-success/20 flex items-center justify-center" class="w-14 h-14 mx-auto mb-4 rounded-2xl bg-emerald-500/10 flex items-center justify-center"
> >
<span <span
class="material-symbols-rounded text-success" class="material-symbols-rounded text-emerald-400"
style="font-size: 32px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 32;" style="font-size: 28px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 28;"
> >
mark_email_read mark_email_read
</span> </span>
</div> </div>
<h2 class="text-xl font-semibold text-light mb-2"> <h2 class="text-body font-heading text-white mb-2">
{m.login_signup_success_title()} {m.login_signup_success_title()}
</h2> </h2>
<p class="text-light/60 text-sm mb-4"> <p class="text-body-sm text-light/40 mb-6">
{m.login_signup_success_text({ email })} {m.login_signup_success_text({ email })}
</p> </p>
<Button <Button
@@ -128,16 +131,27 @@
</Button> </Button>
</div> </div>
{:else} {:else}
<h2 class="text-xl font-semibold text-light mb-6"> <!-- Tab switcher -->
{mode === "login" <div class="flex items-center gap-1 bg-dark/50 rounded-xl p-1 mb-6">
? m.login_tab_login() <button
: m.login_tab_signup()} class="flex-1 py-2 rounded-lg text-body-sm font-body transition-colors {mode === 'login' ? 'bg-primary text-background' : 'text-light/40 hover:text-white'}"
</h2> onclick={() => (mode = "login")}
>
{m.login_tab_login()}
</button>
<button
class="flex-1 py-2 rounded-lg text-body-sm font-body transition-colors {mode === 'signup' ? 'bg-primary text-background' : 'text-light/40 hover:text-white'}"
onclick={() => (mode = "signup")}
>
{m.login_tab_signup()}
</button>
</div>
{#if error} {#if error}
<div <div
class="mb-4 p-3 bg-error/20 border border-error/30 rounded-xl text-error text-sm" class="mb-4 p-3 bg-error/10 border border-error/20 rounded-xl text-error text-body-sm flex items-center gap-2"
> >
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">error</span>
{error} {error}
</div> </div>
{/if} {/if}
@@ -147,7 +161,7 @@
e.preventDefault(); e.preventDefault();
handleSubmit(); handleSubmit();
}} }}
class="space-y-4" class="flex flex-col gap-4"
> >
<Input <Input
type="email" type="email"
@@ -172,60 +186,39 @@
</Button> </Button>
</form> </form>
<div class="my-6 flex items-center gap-3"> <div class="my-5 flex items-center gap-3">
<div class="flex-1 h-px bg-light/10"></div> <div class="flex-1 h-px bg-light/10"></div>
<span class="text-light/40 text-sm" <span class="text-light/30 text-[11px] uppercase tracking-wider"
>{m.login_or_continue()}</span >{m.login_or_continue()}</span
> >
<div class="flex-1 h-px bg-light/10"></div> <div class="flex-1 h-px bg-light/10"></div>
</div> </div>
<Button <button
variant="secondary" class="w-full flex items-center justify-center gap-2.5 px-4 py-2.5 rounded-xl border border-light/10 hover:border-light/20 hover:bg-light/5 transition-all text-body-sm text-white"
fullWidth
onclick={() => handleOAuth("google")} onclick={() => handleOAuth("google")}
> >
<svg class="w-5 h-5 mr-2" viewBox="0 0 24 24"> <svg class="w-4 h-4" viewBox="0 0 24 24">
<path <path
fill="currentColor" fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/> />
<path <path
fill="currentColor" fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/> />
<path <path
fill="currentColor" fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/> />
<path <path
fill="currentColor" fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/> />
</svg> </svg>
{m.login_google()} {m.login_google()}
</Button> </button>
<p class="mt-6 text-center text-light/60 text-sm">
{#if mode === "login"}
{m.login_signup_prompt()}
<button
class="text-primary hover:underline"
onclick={() => (mode = "signup")}
>
{m.login_tab_signup()}
</button>
{:else}
{m.login_login_prompt()}
<button
class="text-primary hover:underline"
onclick={() => (mode = "login")}
>
{m.login_tab_login()}
</button>
{/if}
</p>
{/if} {/if}
</Card> </div>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

BIN
static/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
static/favicon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

26
static/favicon.svg Normal file
View File

@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1000" height="1000"><style>
#light-icon {
display: inline;
}
#dark-icon {
display: none;
}
@media (prefers-color-scheme: dark) {
#light-icon {
display: none;
}
#dark-icon {
display: inline;
}
}
</style><g id="light-icon"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1000" height="1000"><g><g transform="matrix(20.833333333333332,0,0,20.833333333333332,0,0)"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48"><svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" rx="24" fill="#0A121F"></rect>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.8891 12C12.6303 12 11.4582 12.2221 10.373 12.6652C9.28771 13.0874 8.34336 13.6875 7.54035 14.4684C6.73725 15.2493 6.10767 16.1675 5.65188 17.2227C5.21778 18.278 5 19.4282 5 20.6734C5 21.9186 5.21778 23.0689 5.65188 24.1241C6.10767 25.1793 6.73725 26.1076 7.54035 26.9095C8.34336 27.6904 9.28771 28.3026 10.373 28.7458C11.4582 29.1679 12.6303 29.3799 13.8891 29.3799C15.1696 29.3799 16.3417 29.1679 17.4052 28.7458C18.4905 28.3026 19.4349 27.6904 20.2379 26.9095C21.041 26.1076 21.6587 25.1793 22.0927 24.1241C22.5486 23.0689 22.7762 21.9186 22.7762 20.6734C22.7762 19.4282 22.5486 18.278 22.0927 17.2227C21.6587 16.1675 21.041 15.2493 20.2379 14.4684C19.4349 13.6875 18.4905 13.0874 17.4052 12.6652C16.3417 12.2221 15.1696 12 13.8891 12ZM13.8891 16.717C14.9527 16.717 15.7981 17.0851 16.4275 17.8238C17.0787 18.5414 17.4052 19.4915 17.4052 20.6734C17.4052 21.8342 17.0785 22.7944 16.4275 23.5542C15.7981 24.2929 14.9527 24.6629 13.8891 24.6629C12.8256 24.6629 11.9684 24.2929 11.3172 23.5542C10.6878 22.7944 10.373 21.8342 10.373 20.6734C10.373 19.4915 10.6878 18.5414 11.3172 17.8238C11.9684 17.0851 12.8256 16.717 13.8891 16.717Z" fill="#E5E6F0"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.4859 12C32.2271 12 31.055 12.2221 29.9698 12.6652C28.8845 13.0874 27.9402 13.6875 27.1372 14.4684C26.3341 15.2493 25.7045 16.1675 25.2487 17.2227C24.8146 18.278 24.5968 19.4282 24.5968 20.6734C24.5968 21.9186 24.8146 23.0689 25.2487 24.1241C25.7045 25.1793 26.3341 26.1076 27.1372 26.9095C27.9402 27.6904 28.8845 28.3026 29.9698 28.7458C31.055 29.1679 32.2271 29.3799 33.4859 29.3799C34.7664 29.3799 35.9385 29.1679 37.002 28.7458C38.0873 28.3026 39.0317 27.6904 39.8347 26.9095C40.6378 26.1076 41.2555 25.1793 41.6895 24.1241C42.1454 23.0689 42.373 21.9186 42.373 20.6734C42.373 19.4282 42.1454 18.278 41.6895 17.2227C41.2555 16.1675 40.6378 15.2493 39.8347 14.4684C39.0317 13.6875 38.0873 13.0874 37.002 12.6652C35.9385 12.2221 34.7664 12 33.4859 12ZM33.4859 16.717C34.5495 16.717 35.3949 17.0851 36.0243 17.8238C36.6755 18.5414 37.002 19.4915 37.002 20.6734C37.002 21.8342 36.6753 22.7944 36.0243 23.5542C35.3949 24.2929 34.5495 24.6629 33.4859 24.6629C32.4224 24.6629 31.5652 24.2929 30.914 23.5542C30.2846 22.7944 29.9698 21.8342 29.9698 20.6734C29.9698 19.4915 30.2846 18.5414 30.914 17.8238C31.5652 17.0851 32.4224 16.717 33.4859 16.717Z" fill="#E5E6F0"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.7661 33.2935C14.7661 34.5387 14.9838 35.6888 15.418 36.7442C15.8737 37.7994 16.5033 38.7275 17.3065 39.5296C18.1095 40.3105 19.0538 40.9226 20.1391 41.3658C21.2243 41.7878 22.3964 41.9999 23.6553 41.9999C24.9357 41.9999 26.1078 41.788 27.1713 41.3658C28.2566 40.9226 29.201 40.3105 30.004 39.5296C30.8071 38.7275 31.4268 37.7994 31.8608 36.7442C32.3166 35.6888 32.5443 34.5387 32.5443 33.2935H27.1713C27.1713 34.4541 26.8466 35.4144 26.1955 36.1742C25.5661 36.9129 24.7188 37.2829 23.6553 37.2829C22.5917 37.2829 21.7325 36.9129 21.0813 36.1742C20.4519 35.4144 20.1391 34.4541 20.1391 33.2935H14.7661Z" fill="#E5E6F0"></path>
</svg></svg></g></g></svg></g><g id="dark-icon"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1000" height="1000"><g><g transform="matrix(20.833333333333332,0,0,20.833333333333332,0,0)"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48"><svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.8891 12C12.6303 12 11.4582 12.2221 10.373 12.6652C9.28771 13.0874 8.34336 13.6875 7.54035 14.4684C6.73725 15.2493 6.10767 16.1675 5.65188 17.2227C5.21778 18.278 5 19.4282 5 20.6734C5 21.9186 5.21778 23.0689 5.65188 24.1241C6.10767 25.1793 6.73725 26.1076 7.54035 26.9095C8.34336 27.6904 9.28771 28.3026 10.373 28.7458C11.4582 29.1679 12.6303 29.3799 13.8891 29.3799C15.1696 29.3799 16.3417 29.1679 17.4052 28.7458C18.4905 28.3026 19.4349 27.6904 20.2379 26.9095C21.041 26.1076 21.6587 25.1793 22.0927 24.1241C22.5486 23.0689 22.7762 21.9186 22.7762 20.6734C22.7762 19.4282 22.5486 18.278 22.0927 17.2227C21.6587 16.1675 21.041 15.2493 20.2379 14.4684C19.4349 13.6875 18.4905 13.0874 17.4052 12.6652C16.3417 12.2221 15.1696 12 13.8891 12ZM13.8891 16.717C14.9527 16.717 15.7981 17.0851 16.4275 17.8238C17.0787 18.5414 17.4052 19.4915 17.4052 20.6734C17.4052 21.8342 17.0785 22.7944 16.4275 23.5542C15.7981 24.2929 14.9527 24.6629 13.8891 24.6629C12.8256 24.6629 11.9684 24.2929 11.3172 23.5542C10.6878 22.7944 10.373 21.8342 10.373 20.6734C10.373 19.4915 10.6878 18.5414 11.3172 17.8238C11.9684 17.0851 12.8256 16.717 13.8891 16.717Z" fill="#E5E6F0"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.4859 12C32.2271 12 31.055 12.2221 29.9698 12.6652C28.8845 13.0874 27.9402 13.6875 27.1372 14.4684C26.3341 15.2493 25.7045 16.1675 25.2487 17.2227C24.8146 18.278 24.5968 19.4282 24.5968 20.6734C24.5968 21.9186 24.8146 23.0689 25.2487 24.1241C25.7045 25.1793 26.3341 26.1076 27.1372 26.9095C27.9402 27.6904 28.8845 28.3026 29.9698 28.7458C31.055 29.1679 32.2271 29.3799 33.4859 29.3799C34.7664 29.3799 35.9385 29.1679 37.002 28.7458C38.0873 28.3026 39.0317 27.6904 39.8347 26.9095C40.6378 26.1076 41.2555 25.1793 41.6895 24.1241C42.1454 23.0689 42.373 21.9186 42.373 20.6734C42.373 19.4282 42.1454 18.278 41.6895 17.2227C41.2555 16.1675 40.6378 15.2493 39.8347 14.4684C39.0317 13.6875 38.0873 13.0874 37.002 12.6652C35.9385 12.2221 34.7664 12 33.4859 12ZM33.4859 16.717C34.5495 16.717 35.3949 17.0851 36.0243 17.8238C36.6755 18.5414 37.002 19.4915 37.002 20.6734C37.002 21.8342 36.6753 22.7944 36.0243 23.5542C35.3949 24.2929 34.5495 24.6629 33.4859 24.6629C32.4224 24.6629 31.5652 24.2929 30.914 23.5542C30.2846 22.7944 29.9698 21.8342 29.9698 20.6734C29.9698 19.4915 30.2846 18.5414 30.914 17.8238C31.5652 17.0851 32.4224 16.717 33.4859 16.717Z" fill="#E5E6F0"></path>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.7661 33.2935C14.7661 34.5387 14.9838 35.6888 15.418 36.7442C15.8737 37.7994 16.5033 38.7275 17.3065 39.5296C18.1095 40.3105 19.0538 40.9226 20.1391 41.3658C21.2243 41.7878 22.3964 41.9999 23.6553 41.9999C24.9357 41.9999 26.1078 41.788 27.1713 41.3658C28.2566 40.9226 29.201 40.3105 30.004 39.5296C30.8071 38.7275 31.4268 37.7994 31.8608 36.7442C32.3166 35.6888 32.5443 34.5387 32.5443 33.2935H27.1713C27.1713 34.4541 26.8466 35.4144 26.1955 36.1742C25.5661 36.9129 24.7188 37.2829 23.6553 37.2829C22.5917 37.2829 21.7325 36.9129 21.0813 36.1742C20.4519 35.4144 20.1391 34.4541 20.1391 33.2935H14.7661Z" fill="#E5E6F0"></path>
</svg></svg></g></g></svg></g></svg>

After

Width:  |  Height:  |  Size: 7.4 KiB

21
static/site.webmanifest Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "root",
"short_name": "root",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#0a121f",
"background_color": "#0a121f",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,258 @@
-- Department Dashboards: composable workspace for event departments
-- Each department gets a dashboard with configurable module panels
-- ============================================================
-- 1. Module types enum
-- ============================================================
CREATE TYPE module_type AS ENUM (
'kanban',
'files',
'checklist',
'notes',
'schedule',
'contacts'
);
-- ============================================================
-- 2. Layout presets enum
-- ============================================================
CREATE TYPE layout_preset AS ENUM (
'single',
'split',
'grid',
'focus_sidebar',
'custom'
);
-- ============================================================
-- 3. Department Dashboards
-- ============================================================
CREATE TABLE department_dashboards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
department_id UUID NOT NULL REFERENCES event_departments(id) ON DELETE CASCADE,
layout layout_preset NOT NULL DEFAULT 'split',
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(department_id)
);
CREATE INDEX idx_dept_dashboards_dept ON department_dashboards(department_id);
-- ============================================================
-- 4. Dashboard Panels (modules placed on a dashboard)
-- ============================================================
CREATE TABLE dashboard_panels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
dashboard_id UUID NOT NULL REFERENCES department_dashboards(id) ON DELETE CASCADE,
module module_type NOT NULL,
position INT NOT NULL DEFAULT 0,
width TEXT NOT NULL DEFAULT 'half' CHECK (width IN ('full', 'half', 'third', 'two_thirds')),
config JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(dashboard_id, position)
);
CREATE INDEX idx_dashboard_panels_dashboard ON dashboard_panels(dashboard_id);
-- ============================================================
-- 5. Checklists (scoped to department)
-- ============================================================
CREATE TABLE department_checklists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
department_id UUID NOT NULL REFERENCES event_departments(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT 'Checklist',
sort_order INT NOT NULL DEFAULT 0,
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_dept_checklists_dept ON department_checklists(department_id);
CREATE TABLE department_checklist_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
checklist_id UUID NOT NULL REFERENCES department_checklists(id) ON DELETE CASCADE,
content TEXT NOT NULL,
is_completed BOOLEAN NOT NULL DEFAULT false,
assigned_to UUID REFERENCES auth.users(id) ON DELETE SET NULL,
due_date TIMESTAMPTZ,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_dept_checklist_items_checklist ON department_checklist_items(checklist_id);
CREATE INDEX idx_dept_checklist_items_assigned ON department_checklist_items(assigned_to);
-- ============================================================
-- 6. Department Notes (simple rich text notes)
-- ============================================================
CREATE TABLE department_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
department_id UUID NOT NULL REFERENCES event_departments(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT 'Untitled Note',
content TEXT DEFAULT '',
sort_order INT NOT NULL DEFAULT 0,
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_dept_notes_dept ON department_notes(department_id);
-- ============================================================
-- 7. Add enabled_modules to event_departments
-- ============================================================
ALTER TABLE event_departments
ADD COLUMN enabled_modules module_type[] NOT NULL DEFAULT ARRAY['kanban'::module_type, 'files'::module_type, 'checklist'::module_type];
-- ============================================================
-- 8. RLS Policies
-- ============================================================
ALTER TABLE department_dashboards ENABLE ROW LEVEL SECURITY;
ALTER TABLE dashboard_panels ENABLE ROW LEVEL SECURITY;
ALTER TABLE department_checklists ENABLE ROW LEVEL SECURITY;
ALTER TABLE department_checklist_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE department_notes ENABLE ROW LEVEL SECURITY;
-- Helper: check if user is org member for a department
-- (department → event → org → org_members)
-- Department Dashboards
CREATE POLICY "Org members can view department dashboards" ON department_dashboards FOR SELECT
USING (EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE ed.id = department_dashboards.department_id AND om.user_id = auth.uid()
));
CREATE POLICY "Editors can manage department dashboards" ON department_dashboards FOR ALL
USING (EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE ed.id = department_dashboards.department_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
));
-- Dashboard Panels
CREATE POLICY "Org members can view dashboard panels" ON dashboard_panels FOR SELECT
USING (EXISTS (
SELECT 1 FROM department_dashboards dd
JOIN event_departments ed ON dd.department_id = ed.id
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE dd.id = dashboard_panels.dashboard_id AND om.user_id = auth.uid()
));
CREATE POLICY "Editors can manage dashboard panels" ON dashboard_panels FOR ALL
USING (EXISTS (
SELECT 1 FROM department_dashboards dd
JOIN event_departments ed ON dd.department_id = ed.id
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE dd.id = dashboard_panels.dashboard_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
));
-- Department Checklists
CREATE POLICY "Org members can view department checklists" ON department_checklists FOR SELECT
USING (EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE ed.id = department_checklists.department_id AND om.user_id = auth.uid()
));
CREATE POLICY "Editors can manage department checklists" ON department_checklists FOR ALL
USING (EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE ed.id = department_checklists.department_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
));
-- Department Checklist Items
CREATE POLICY "Org members can view dept checklist items" ON department_checklist_items FOR SELECT
USING (EXISTS (
SELECT 1 FROM department_checklists dc
JOIN event_departments ed ON dc.department_id = ed.id
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE dc.id = department_checklist_items.checklist_id AND om.user_id = auth.uid()
));
CREATE POLICY "Editors can manage dept checklist items" ON department_checklist_items FOR ALL
USING (EXISTS (
SELECT 1 FROM department_checklists dc
JOIN event_departments ed ON dc.department_id = ed.id
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE dc.id = department_checklist_items.checklist_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
));
-- Department Notes
CREATE POLICY "Org members can view department notes" ON department_notes FOR SELECT
USING (EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE ed.id = department_notes.department_id AND om.user_id = auth.uid()
));
CREATE POLICY "Editors can manage department notes" ON department_notes FOR ALL
USING (EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE ed.id = department_notes.department_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
));
-- ============================================================
-- 9. Enable realtime
-- ============================================================
ALTER PUBLICATION supabase_realtime ADD TABLE department_checklists;
ALTER PUBLICATION supabase_realtime ADD TABLE department_checklist_items;
ALTER PUBLICATION supabase_realtime ADD TABLE department_notes;
ALTER PUBLICATION supabase_realtime ADD TABLE dashboard_panels;
-- ============================================================
-- 10. Auto-create dashboard when department is created
-- ============================================================
CREATE OR REPLACE FUNCTION public.create_department_dashboard()
RETURNS TRIGGER AS $$
DECLARE
dash_id UUID;
mod module_type;
pos INT := 0;
BEGIN
-- Create dashboard
INSERT INTO public.department_dashboards (department_id, layout)
VALUES (NEW.id, 'split')
RETURNING id INTO dash_id;
-- Create panels for each enabled module
FOREACH mod IN ARRAY NEW.enabled_modules LOOP
INSERT INTO public.dashboard_panels (dashboard_id, module, position, width)
VALUES (dash_id, mod, pos, CASE WHEN pos = 0 THEN 'half' ELSE 'half' END);
pos := pos + 1;
END LOOP;
-- Auto-create a default checklist if checklist module is enabled
IF 'checklist' = ANY(NEW.enabled_modules) THEN
INSERT INTO public.department_checklists (department_id, title, sort_order)
VALUES (NEW.id, 'General', 0);
END IF;
-- Auto-create a default note if notes module is enabled
IF 'notes' = ANY(NEW.enabled_modules) THEN
INSERT INTO public.department_notes (department_id, title, content, sort_order)
VALUES (NEW.id, 'Meeting Notes', '', 0);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_department_created_setup_dashboard
AFTER INSERT ON event_departments
FOR EACH ROW EXECUTE FUNCTION public.create_department_dashboard();

View File

@@ -0,0 +1,11 @@
-- Remove auto-seeding of departments and roles on event creation.
-- These will now be offered as suggestions in the UI instead.
-- Drop the existing trigger and function
DROP TRIGGER IF EXISTS on_event_created_seed_defaults ON events;
DROP FUNCTION IF EXISTS public.seed_event_defaults();
-- Delete all existing seeded departments and roles
-- (cascades will clean up member-department assignments, dashboards, panels, checklists, notes)
DELETE FROM event_departments;
DELETE FROM event_roles;

View File

@@ -0,0 +1,214 @@
-- Schedule/Timeline + Contacts/Vendor Directory for department dashboards
-- These are self-contained modules that can be added to any department dashboard
-- ============================================================
-- 1. Schedule Stages (rooms/areas where things happen)
-- ============================================================
CREATE TABLE schedule_stages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
department_id UUID NOT NULL REFERENCES event_departments(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT DEFAULT '#6366f1',
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- 2. Schedule Blocks (time blocks in the program)
-- ============================================================
CREATE TABLE schedule_blocks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
department_id UUID NOT NULL REFERENCES event_departments(id) ON DELETE CASCADE,
stage_id UUID REFERENCES schedule_stages(id) ON DELETE SET NULL,
title TEXT NOT NULL,
description TEXT,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
color TEXT DEFAULT '#6366f1',
speaker TEXT,
sort_order INT NOT NULL DEFAULT 0,
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- 3. Contacts / Vendor Directory
-- ============================================================
CREATE TABLE department_contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
department_id UUID NOT NULL REFERENCES event_departments(id) ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT,
company TEXT,
email TEXT,
phone TEXT,
website TEXT,
notes TEXT,
category TEXT DEFAULT 'general',
color TEXT DEFAULT '#00A3E0',
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- 4. Indexes
-- ============================================================
CREATE INDEX idx_schedule_stages_dept ON schedule_stages(department_id);
CREATE INDEX idx_schedule_blocks_dept ON schedule_blocks(department_id);
CREATE INDEX idx_schedule_blocks_stage ON schedule_blocks(stage_id);
CREATE INDEX idx_schedule_blocks_time ON schedule_blocks(start_time, end_time);
CREATE INDEX idx_department_contacts_dept ON department_contacts(department_id);
CREATE INDEX idx_department_contacts_category ON department_contacts(category);
-- ============================================================
-- 5. RLS Policies
-- ============================================================
ALTER TABLE schedule_stages ENABLE ROW LEVEL SECURITY;
ALTER TABLE schedule_blocks ENABLE ROW LEVEL SECURITY;
ALTER TABLE department_contacts ENABLE ROW LEVEL SECURITY;
-- Schedule stages: org members can read, editors can write
CREATE POLICY "schedule_stages_select" ON schedule_stages FOR SELECT
USING (
EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON e.id = ed.event_id
JOIN org_members om ON om.org_id = e.org_id
WHERE ed.id = schedule_stages.department_id
AND om.user_id = auth.uid()
)
);
CREATE POLICY "schedule_stages_insert" ON schedule_stages FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON e.id = ed.event_id
JOIN org_members om ON om.org_id = e.org_id
WHERE ed.id = schedule_stages.department_id
AND om.user_id = auth.uid()
AND om.role IN ('owner', 'admin', 'editor')
)
);
CREATE POLICY "schedule_stages_update" ON schedule_stages FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON e.id = ed.event_id
JOIN org_members om ON om.org_id = e.org_id
WHERE ed.id = schedule_stages.department_id
AND om.user_id = auth.uid()
AND om.role IN ('owner', 'admin', 'editor')
)
);
CREATE POLICY "schedule_stages_delete" ON schedule_stages FOR DELETE
USING (
EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON e.id = ed.event_id
JOIN org_members om ON om.org_id = e.org_id
WHERE ed.id = schedule_stages.department_id
AND om.user_id = auth.uid()
AND om.role IN ('owner', 'admin', 'editor')
)
);
-- Schedule blocks: same pattern
CREATE POLICY "schedule_blocks_select" ON schedule_blocks FOR SELECT
USING (
EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON e.id = ed.event_id
JOIN org_members om ON om.org_id = e.org_id
WHERE ed.id = schedule_blocks.department_id
AND om.user_id = auth.uid()
)
);
CREATE POLICY "schedule_blocks_insert" ON schedule_blocks FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON e.id = ed.event_id
JOIN org_members om ON om.org_id = e.org_id
WHERE ed.id = schedule_blocks.department_id
AND om.user_id = auth.uid()
AND om.role IN ('owner', 'admin', 'editor')
)
);
CREATE POLICY "schedule_blocks_update" ON schedule_blocks FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON e.id = ed.event_id
JOIN org_members om ON om.org_id = e.org_id
WHERE ed.id = schedule_blocks.department_id
AND om.user_id = auth.uid()
AND om.role IN ('owner', 'admin', 'editor')
)
);
CREATE POLICY "schedule_blocks_delete" ON schedule_blocks FOR DELETE
USING (
EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON e.id = ed.event_id
JOIN org_members om ON om.org_id = e.org_id
WHERE ed.id = schedule_blocks.department_id
AND om.user_id = auth.uid()
AND om.role IN ('owner', 'admin', 'editor')
)
);
-- Contacts: same pattern
CREATE POLICY "department_contacts_select" ON department_contacts FOR SELECT
USING (
EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON e.id = ed.event_id
JOIN org_members om ON om.org_id = e.org_id
WHERE ed.id = department_contacts.department_id
AND om.user_id = auth.uid()
)
);
CREATE POLICY "department_contacts_insert" ON department_contacts FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON e.id = ed.event_id
JOIN org_members om ON om.org_id = e.org_id
WHERE ed.id = department_contacts.department_id
AND om.user_id = auth.uid()
AND om.role IN ('owner', 'admin', 'editor')
)
);
CREATE POLICY "department_contacts_update" ON department_contacts FOR UPDATE
USING (
EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON e.id = ed.event_id
JOIN org_members om ON om.org_id = e.org_id
WHERE ed.id = department_contacts.department_id
AND om.user_id = auth.uid()
AND om.role IN ('owner', 'admin', 'editor')
)
);
CREATE POLICY "department_contacts_delete" ON department_contacts FOR DELETE
USING (
EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON e.id = ed.event_id
JOIN org_members om ON om.org_id = e.org_id
WHERE ed.id = department_contacts.department_id
AND om.user_id = auth.uid()
AND om.role IN ('owner', 'admin', 'editor')
)
);

View File

@@ -0,0 +1,202 @@
-- Budget/Finance and Sponsors & Partners modules
-- Adds new module types and creates tables for both
-- ============================================================
-- 1. Extend module_type enum
-- ============================================================
ALTER TYPE module_type ADD VALUE IF NOT EXISTS 'budget';
ALTER TYPE module_type ADD VALUE IF NOT EXISTS 'sponsors';
-- ============================================================
-- 2. Budget Categories
-- ============================================================
CREATE TABLE budget_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
department_id UUID NOT NULL REFERENCES event_departments(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT DEFAULT '#6366f1',
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_budget_categories_dept ON budget_categories(department_id);
-- ============================================================
-- 3. Budget Items (income or expense line items)
-- ============================================================
CREATE TABLE budget_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
department_id UUID NOT NULL REFERENCES event_departments(id) ON DELETE CASCADE,
category_id UUID REFERENCES budget_categories(id) ON DELETE SET NULL,
description TEXT NOT NULL,
item_type TEXT NOT NULL DEFAULT 'expense' CHECK (item_type IN ('income', 'expense')),
planned_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
actual_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
notes TEXT,
sort_order INT NOT NULL DEFAULT 0,
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_budget_items_dept ON budget_items(department_id);
CREATE INDEX idx_budget_items_category ON budget_items(category_id);
-- ============================================================
-- 4. Sponsor Tiers (e.g. Platinum, Gold, Silver)
-- ============================================================
CREATE TABLE sponsor_tiers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
department_id UUID NOT NULL REFERENCES event_departments(id) ON DELETE CASCADE,
name TEXT NOT NULL,
amount NUMERIC(12,2) DEFAULT 0,
color TEXT DEFAULT '#F59E0B',
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_sponsor_tiers_dept ON sponsor_tiers(department_id);
-- ============================================================
-- 5. Sponsors
-- ============================================================
CREATE TABLE sponsors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
department_id UUID NOT NULL REFERENCES event_departments(id) ON DELETE CASCADE,
tier_id UUID REFERENCES sponsor_tiers(id) ON DELETE SET NULL,
name TEXT NOT NULL,
contact_name TEXT,
contact_email TEXT,
contact_phone TEXT,
website TEXT,
logo_url TEXT,
status TEXT NOT NULL DEFAULT 'prospect' CHECK (status IN ('prospect', 'contacted', 'confirmed', 'declined', 'active')),
amount NUMERIC(12,2) DEFAULT 0,
notes TEXT,
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_sponsors_dept ON sponsors(department_id);
CREATE INDEX idx_sponsors_tier ON sponsors(tier_id);
CREATE INDEX idx_sponsors_status ON sponsors(status);
-- ============================================================
-- 6. Sponsor Deliverables (what we owe each sponsor)
-- ============================================================
CREATE TABLE sponsor_deliverables (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sponsor_id UUID NOT NULL REFERENCES sponsors(id) ON DELETE CASCADE,
description TEXT NOT NULL,
is_completed BOOLEAN NOT NULL DEFAULT false,
due_date TIMESTAMPTZ,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_sponsor_deliverables_sponsor ON sponsor_deliverables(sponsor_id);
-- ============================================================
-- 7. RLS Policies
-- ============================================================
ALTER TABLE budget_categories ENABLE ROW LEVEL SECURITY;
ALTER TABLE budget_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE sponsor_tiers ENABLE ROW LEVEL SECURITY;
ALTER TABLE sponsors ENABLE ROW LEVEL SECURITY;
ALTER TABLE sponsor_deliverables ENABLE ROW LEVEL SECURITY;
-- Budget Categories
CREATE POLICY "Org members can view budget categories" ON budget_categories FOR SELECT
USING (EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE ed.id = budget_categories.department_id AND om.user_id = auth.uid()
));
CREATE POLICY "Editors can manage budget categories" ON budget_categories FOR ALL
USING (EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE ed.id = budget_categories.department_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
));
-- Budget Items
CREATE POLICY "Org members can view budget items" ON budget_items FOR SELECT
USING (EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE ed.id = budget_items.department_id AND om.user_id = auth.uid()
));
CREATE POLICY "Editors can manage budget items" ON budget_items FOR ALL
USING (EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE ed.id = budget_items.department_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
));
-- Sponsor Tiers
CREATE POLICY "Org members can view sponsor tiers" ON sponsor_tiers FOR SELECT
USING (EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE ed.id = sponsor_tiers.department_id AND om.user_id = auth.uid()
));
CREATE POLICY "Editors can manage sponsor tiers" ON sponsor_tiers FOR ALL
USING (EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE ed.id = sponsor_tiers.department_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
));
-- Sponsors
CREATE POLICY "Org members can view sponsors" ON sponsors FOR SELECT
USING (EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE ed.id = sponsors.department_id AND om.user_id = auth.uid()
));
CREATE POLICY "Editors can manage sponsors" ON sponsors FOR ALL
USING (EXISTS (
SELECT 1 FROM event_departments ed
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE ed.id = sponsors.department_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
));
-- Sponsor Deliverables
CREATE POLICY "Org members can view sponsor deliverables" ON sponsor_deliverables FOR SELECT
USING (EXISTS (
SELECT 1 FROM sponsors s
JOIN event_departments ed ON s.department_id = ed.id
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE s.id = sponsor_deliverables.sponsor_id AND om.user_id = auth.uid()
));
CREATE POLICY "Editors can manage sponsor deliverables" ON sponsor_deliverables FOR ALL
USING (EXISTS (
SELECT 1 FROM sponsors s
JOIN event_departments ed ON s.department_id = ed.id
JOIN events e ON ed.event_id = e.id
JOIN org_members om ON e.org_id = om.org_id
WHERE s.id = sponsor_deliverables.sponsor_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
));
-- ============================================================
-- 8. Enable realtime
-- ============================================================
ALTER PUBLICATION supabase_realtime ADD TABLE budget_items;
ALTER PUBLICATION supabase_realtime ADD TABLE sponsors;
ALTER PUBLICATION supabase_realtime ADD TABLE sponsor_deliverables;

View File

@@ -0,0 +1,8 @@
-- Platform admin flag on profiles
-- Only platform admins can access the /admin dashboard
ALTER TABLE profiles
ADD COLUMN is_platform_admin BOOLEAN NOT NULL DEFAULT false;
-- Set the initial platform admin (update this UUID to match your user)
-- This will be set via the admin API or manually in the DB

BIN
synapse/data/homeserver.db Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,40 @@
# Configuration file for Synapse.
#
# This is a YAML file: see [1] for a quick introduction. Note in particular
# that *indentation is important*: all the elements of a list or dictionary
# should have the same indentation.
#
# [1] https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html
#
# For more information on how to configure Synapse, including a complete accounting of
# each option, go to docs/usage/configuration/config_documentation.md or
# https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html
server_name: "localhost"
pid_file: /data/homeserver.pid
listeners:
- port: 8008
resources:
- compress: false
names:
- client
- federation
tls: false
type: http
x_forwarded: true
database:
name: sqlite3
args:
database: /data/homeserver.db
log_config: "/data/localhost.log.config"
media_store_path: /data/media_store
registration_shared_secret: "root-org-synapse-secret-change-me"
report_stats: false
macaroon_secret_key: "K3l5o7X-X^R38;PiHAYDE;k-&CiW=2cqcFZbJOR@Q2FT_*KT-y"
form_secret: "X2@N=bAgk*oHscfP,=pz8Je0Pd.zeQAc4DX-oQtQKK*=SJ6raS"
signing_key_path: "/data/localhost.signing.key"
trusted_key_servers:
- server_name: "matrix.org"
enable_registration: true
enable_registration_without_verification: true
# vim:ft=yaml

View File

@@ -0,0 +1,39 @@
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
handlers:
console:
class: logging.StreamHandler
formatter: precise
loggers:
# This is just here so we can leave `loggers` in the config regardless of whether
# we configure other loggers below (avoid empty yaml dict error).
_placeholder:
level: "INFO"
synapse.storage.SQL:
# beware: increasing this to DEBUG will make synapse log sensitive
# information such as access tokens.
level: INFO
root:
level: INFO
handlers: [console]
disable_existing_loggers: false

View File

@@ -0,0 +1 @@
ed25519 a_VwZG y2OyVm2VBqYOuqiBYBb0wQ4r8awyVMceG+KIPK4K6HA