From d22847f555146300485f11a2f582bb8a5efb8346 Mon Sep 17 00:00:00 2001 From: AlacrisDevs Date: Sat, 7 Feb 2026 21:47:47 +0200 Subject: [PATCH] Mega push vol 7 mvp lesgoooo --- messages/en.json | 25 +- messages/et.json | 25 +- src/app.html | 7 + src/hooks.server.ts | 10 +- src/lib/api/budget.ts | 176 +++ src/lib/api/contacts.ts | 147 +++ src/lib/api/department-dashboard.ts | 354 ++++++ src/lib/api/schedule.ts | 176 +++ src/lib/api/sponsors.ts | 301 +++++ src/lib/assets/favicon.svg | 1 - src/lib/components/calendar/Calendar.svelte | 11 +- src/lib/components/kanban/KanbanBoard.svelte | 2 + src/lib/components/kanban/KanbanCard.svelte | 1 + .../components/matrix/CreateRoomModal.svelte | 2 + .../components/matrix/MatrixProvider.svelte | 10 +- src/lib/components/matrix/MessageInput.svelte | 10 +- .../components/matrix/RoomInfoPanel.svelte | 1 + .../matrix/RoomSettingsModal.svelte | 3 + src/lib/components/matrix/StartDMModal.svelte | 4 + .../components/matrix/UserProfileModal.svelte | 1 + .../message/parts/MessageActions.svelte | 1 + .../components/modules/BudgetWidget.svelte | 438 +++++++ .../components/modules/ChecklistWidget.svelte | 279 +++++ .../components/modules/ContactsWidget.svelte | 547 +++++++++ src/lib/components/modules/FilesWidget.svelte | 32 + .../components/modules/KanbanWidget.svelte | 37 + src/lib/components/modules/NotesWidget.svelte | 178 +++ .../components/modules/ScheduleWidget.svelte | 660 ++++++++++ .../components/modules/SponsorsWidget.svelte | 571 +++++++++ .../settings/SettingsGeneral.svelte | 3 + .../settings/SettingsIntegrations.svelte | 1 + .../settings/SettingsMembers.svelte | 131 +- .../components/settings/SettingsRoles.svelte | 74 +- .../components/ui/ImagePreviewModal.svelte | 2 + src/lib/components/ui/Modal.svelte | 19 +- src/lib/components/ui/Skeleton.svelte | 4 +- src/lib/components/ui/Toast.svelte | 1 + src/lib/components/ui/Toggle.svelte | 1 + src/lib/supabase/types.ts | 339 ++++- src/routes/+layout.svelte | 3 +- src/routes/+page.svelte | 74 +- src/routes/[orgSlug]/account/+page.svelte | 27 +- src/routes/[orgSlug]/calendar/+page.svelte | 2 + src/routes/[orgSlug]/documents/+page.svelte | 1 + .../documents/folder/[id]/+page.svelte | 1 + .../events/[eventSlug]/+layout.svelte | 54 +- .../[eventSlug]/dept/[deptId]/+page.server.ts | 61 + .../[eventSlug]/dept/[deptId]/+page.svelte | 1092 +++++++++++++++++ .../events/[eventSlug]/tasks/+page.svelte | 1 + .../events/[eventSlug]/team/+page.svelte | 135 +- src/routes/[orgSlug]/kanban/+page.svelte | 2 + src/routes/[orgSlug]/settings/+page.svelte | 79 +- src/routes/admin/+page.server.ts | 104 ++ src/routes/admin/+page.svelte | 406 ++++++ src/routes/invite/[token]/+page.svelte | 75 +- src/routes/login/+page.svelte | 93 +- src/routes/style/+page.svelte | 962 +++++++-------- static/apple-touch-icon.png | Bin 0 -> 7075 bytes static/favicon-96x96.png | Bin 0 -> 5095 bytes static/favicon.ico | Bin 0 -> 15086 bytes static/favicon.svg | 26 + static/site.webmanifest | 21 + static/web-app-manifest-192x192.png | Bin 0 -> 10699 bytes static/web-app-manifest-512x512.png | Bin 0 -> 34045 bytes .../migrations/026_department_dashboards.sql | 258 ++++ .../migrations/027_remove_default_seeds.sql | 11 + .../migrations/028_schedule_and_contacts.sql | 214 ++++ .../migrations/029_budget_and_sponsors.sql | 202 +++ supabase/migrations/030_platform_admin.sql | 8 + synapse/data/homeserver.db | Bin 0 -> 2011136 bytes synapse/data/homeserver.db-shm | Bin 0 -> 32768 bytes synapse/data/homeserver.db-wal | Bin 0 -> 1260752 bytes synapse/data/homeserver.yaml | 40 + synapse/data/localhost.log.config | 39 + synapse/data/localhost.signing.key | 1 + 75 files changed, 7685 insertions(+), 892 deletions(-) create mode 100644 src/lib/api/budget.ts create mode 100644 src/lib/api/contacts.ts create mode 100644 src/lib/api/department-dashboard.ts create mode 100644 src/lib/api/schedule.ts create mode 100644 src/lib/api/sponsors.ts delete mode 100644 src/lib/assets/favicon.svg create mode 100644 src/lib/components/modules/BudgetWidget.svelte create mode 100644 src/lib/components/modules/ChecklistWidget.svelte create mode 100644 src/lib/components/modules/ContactsWidget.svelte create mode 100644 src/lib/components/modules/FilesWidget.svelte create mode 100644 src/lib/components/modules/KanbanWidget.svelte create mode 100644 src/lib/components/modules/NotesWidget.svelte create mode 100644 src/lib/components/modules/ScheduleWidget.svelte create mode 100644 src/lib/components/modules/SponsorsWidget.svelte create mode 100644 src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.server.ts create mode 100644 src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.svelte create mode 100644 src/routes/admin/+page.server.ts create mode 100644 src/routes/admin/+page.svelte create mode 100644 static/apple-touch-icon.png create mode 100644 static/favicon-96x96.png create mode 100644 static/favicon.ico create mode 100644 static/favicon.svg create mode 100644 static/site.webmanifest create mode 100644 static/web-app-manifest-192x192.png create mode 100644 static/web-app-manifest-512x512.png create mode 100644 supabase/migrations/026_department_dashboards.sql create mode 100644 supabase/migrations/027_remove_default_seeds.sql create mode 100644 supabase/migrations/028_schedule_and_contacts.sql create mode 100644 supabase/migrations/029_budget_and_sponsors.sql create mode 100644 supabase/migrations/030_platform_admin.sql create mode 100644 synapse/data/homeserver.db create mode 100644 synapse/data/homeserver.db-shm create mode 100644 synapse/data/homeserver.db-wal create mode 100644 synapse/data/homeserver.yaml create mode 100644 synapse/data/localhost.log.config create mode 100644 synapse/data/localhost.signing.key diff --git a/messages/en.json b/messages/en.json index 7fb989e..5e688e9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -387,5 +387,28 @@ "chat_joining": "Setting up your account...", "chat_join_success": "Chat account created! Welcome.", "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" } \ No newline at end of file diff --git a/messages/et.json b/messages/et.json index 5ef4ff8..446b020 100644 --- a/messages/et.json +++ b/messages/et.json @@ -387,5 +387,28 @@ "chat_joining": "Konto seadistamine...", "chat_join_success": "Vestluskonto loodud! Tere tulemast.", "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" } \ No newline at end of file diff --git a/src/app.html b/src/app.html index c4fbc02..67f126a 100644 --- a/src/app.html +++ b/src/app.html @@ -9,6 +9,13 @@ content="width=device-width, initial-scale=1" /> + + + + + + + %sveltekit.head% diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 59e7eda..865567e 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -46,18 +46,14 @@ const originalHandle: Handle = async ({ event, resolve }) => { }); 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(); - if (error) { + if (error || !user) { return { session: null, user: null }; } + const { data: { session } } = await event.locals.supabase.auth.getSession(); + return { session, user }; }; diff --git a/src/lib/api/budget.ts b/src/lib/api/budget.ts new file mode 100644 index 0000000..b1897a9 --- /dev/null +++ b/src/lib/api/budget.ts @@ -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 { + 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 { + 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> +): Promise { + 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 { + 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 { + 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 { + 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> +): Promise { + 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 { + const { error } = await db(supabase) + .from('budget_items') + .delete() + .eq('id', itemId); + + if (error) { + log.error('deleteBudgetItem failed', { error, data: { itemId } }); + throw error; + } +} diff --git a/src/lib/api/contacts.ts b/src/lib/api/contacts.ts new file mode 100644 index 0000000..edbf86e --- /dev/null +++ b/src/lib/api/contacts.ts @@ -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 = { + 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 = { + 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 { + 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 { + 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> +): Promise { + 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 { + const { error } = await db(supabase) + .from('department_contacts') + .delete() + .eq('id', contactId); + + if (error) { + log.error('deleteContact failed', { error, data: { contactId } }); + throw error; + } +} diff --git a/src/lib/api/department-dashboard.ts b/src/lib/api/department-dashboard.ts new file mode 100644 index 0000000..e7b6dc0 --- /dev/null +++ b/src/lib/api/department-dashboard.ts @@ -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, + departmentId: string +): Promise { + 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, + dashboardId: string, + layout: LayoutPreset +): Promise { + 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, + dashboardId: string, + module: ModuleType, + position: number, + width: string = 'half' +): Promise { + 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, + panelId: string, + params: Partial> +): Promise { + 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, + panelId: string +): Promise { + 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, + departmentId: string +): Promise { + 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 = {}; + 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, + departmentId: string, + title: string, + userId?: string +): Promise { + 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, + checklistId: string +): Promise { + 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, + checklistId: string, + title: string +): Promise { + 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, + checklistId: string, + content: string, + sortOrder: number = 0 +): Promise { + 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, + itemId: string, + params: Partial> +): Promise { + 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, + itemId: string +): Promise { + 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, + itemId: string, + isCompleted: boolean +): Promise { + return updateChecklistItem(supabase, itemId, { is_completed: isCompleted }); +} + +// ============================================================ +// Notes +// ============================================================ + +export async function fetchNotes( + supabase: SupabaseClient, + departmentId: string +): Promise { + 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, + departmentId: string, + title: string, + userId?: string +): Promise { + 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, + noteId: string, + params: Partial> +): Promise { + 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, + noteId: string +): Promise { + const { error } = await supabase + .from('department_notes') + .delete() + .eq('id', noteId); + + if (error) { + log.error('deleteNote failed', { error, data: { noteId } }); + throw error; + } +} diff --git a/src/lib/api/schedule.ts b/src/lib/api/schedule.ts new file mode 100644 index 0000000..e25f2f1 --- /dev/null +++ b/src/lib/api/schedule.ts @@ -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 { + 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 { + 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> +): Promise { + 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 { + 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 { + 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 { + 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> +): Promise { + 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 { + const { error } = await db(supabase) + .from('schedule_blocks') + .delete() + .eq('id', blockId); + + if (error) { + log.error('deleteBlock failed', { error, data: { blockId } }); + throw error; + } +} diff --git a/src/lib/api/sponsors.ts b/src/lib/api/sponsors.ts new file mode 100644 index 0000000..7a2c6a9 --- /dev/null +++ b/src/lib/api/sponsors.ts @@ -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 = { + prospect: 'Prospect', + contacted: 'Contacted', + confirmed: 'Confirmed', + declined: 'Declined', + active: 'Active', +}; + +export const STATUS_COLORS: Record = { + prospect: '#94a3b8', + contacted: '#F59E0B', + confirmed: '#10B981', + declined: '#EF4444', + active: '#6366f1', +}; + +// ============================================================ +// Sponsor Tiers +// ============================================================ + +export async function fetchSponsorTiers( + supabase: SupabaseClient, + departmentId: string +): Promise { + 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 { + 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> +): Promise { + 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 { + 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 { + 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 { + 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> +): Promise { + 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 { + 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 { + 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 { + 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 { + 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> +): Promise { + 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 { + const { error } = await db(supabase) + .from('sponsor_deliverables') + .delete() + .eq('id', deliverableId); + + if (error) { + log.error('deleteDeliverable failed', { error, data: { deliverableId } }); + throw error; + } +} diff --git a/src/lib/assets/favicon.svg b/src/lib/assets/favicon.svg deleted file mode 100644 index cc5dc66..0000000 --- a/src/lib/assets/favicon.svg +++ /dev/null @@ -1 +0,0 @@ -svelte-logo \ No newline at end of file diff --git a/src/lib/components/calendar/Calendar.svelte b/src/lib/components/calendar/Calendar.svelte index 20061cc..1e5fff9 100644 --- a/src/lib/components/calendar/Calendar.svelte +++ b/src/lib/components/calendar/Calendar.svelte @@ -19,6 +19,7 @@ }: Props = $props(); let currentDate = $state(new Date()); + // svelte-ignore state_referenced_locally let currentView = $state(initialView); const today = new Date(); @@ -218,16 +219,20 @@ {day.getDate()} {#each dayEvents.slice(0, 2) as event} - + {/each} {#if dayEvents.length > 2} +{dayEvents.length - 2} diff --git a/src/lib/components/kanban/KanbanBoard.svelte b/src/lib/components/kanban/KanbanBoard.svelte index 3442133..1a5a43f 100644 --- a/src/lib/components/kanban/KanbanBoard.svelte +++ b/src/lib/components/kanban/KanbanBoard.svelte @@ -167,6 +167,7 @@
{#if renamingColumnId === column.id} +
{#each column.cards as card, cardIndex} +
diff --git a/src/lib/components/kanban/KanbanCard.svelte b/src/lib/components/kanban/KanbanCard.svelte index 4a87c47..4d3f525 100644 --- a/src/lib/components/kanban/KanbanCard.svelte +++ b/src/lib/components/kanban/KanbanCard.svelte @@ -69,6 +69,7 @@ > {#if ondelete} + + {/each} +
+ {#if isEditor} +
+ {#if fullscreen} + + {/if} + +
+ {/if} +
+ + +
+ {#if filteredItems.length === 0} +
+ account_balance +

No budget items yet

+
+ {:else} + +
+
Type
+
Description
+
Category
+
Planned
+
Actual
+ {#if fullscreen} +
Diff
+
+ {:else} +
+ {/if} +
+ + {#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)} + +
isEditor && openEditItem(item)} + onkeydown={(e) => e.key === 'Enter' && isEditor && openEditItem(item)} + role="button" + tabindex="0" + > +
+ + + {item.item_type === 'income' ? 'arrow_downward' : 'arrow_upward'} + + +
+
{item.description}
+
+ + {getCategoryName(item.category_id)} + +
+
{formatCurrency(Number(item.planned_amount))}
+
{formatCurrency(Number(item.actual_amount))}
+ {#if fullscreen} +
+ {diff >= 0 ? '+' : ''}{formatCurrency(diff)} +
+
+ {#if isEditor} + + {/if} +
+ {:else} +
+ {#if isEditor} + + {/if} +
+ {/if} +
+ {/each} + + +
+
+
Total
+
+
+ {formatCurrency(filteredItems.reduce((s, i) => s + Number(i.planned_amount), 0))} +
+
+ {formatCurrency(filteredItems.reduce((s, i) => s + Number(i.actual_amount), 0))} +
+ {#if fullscreen} +
+ {:else} +
+ {/if} +
+ {/if} +
+
+ + +{#if showAddItemModal} + +
(showAddItemModal = false)} onkeydown={(e) => e.key === 'Escape' && (showAddItemModal = false)}> + +
e.stopPropagation()}> +

{editingItem ? 'Edit' : 'Add'} Budget Item

+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+
+{/if} + + +{#if showAddCategoryModal} + +
(showAddCategoryModal = false)} onkeydown={(e) => e.key === 'Escape' && (showAddCategoryModal = false)}> + +
e.stopPropagation()}> +

Add Category

+ +
+
+ + +
+
+

Color

+
+ {#each CATEGORY_COLORS as color} + + {/each} +
+
+ + + {#if categories.length > 0} +
+

Existing Categories

+
+ {#each categories as cat} + + {cat.name} + {#if isEditor} + + {/if} + + {/each} +
+
+ {/if} +
+ +
+ + +
+
+
+{/if} diff --git a/src/lib/components/modules/ChecklistWidget.svelte b/src/lib/components/modules/ChecklistWidget.svelte new file mode 100644 index 0000000..dc49c9f --- /dev/null +++ b/src/lib/components/modules/ChecklistWidget.svelte @@ -0,0 +1,279 @@ + + +
+ {#each checklists as cl (cl.id)} +
+ +
+ {#if renamingId === cl.id} + { + 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} +
+

+ {cl.title} +

+ + {cl.items.filter((i) => i.is_completed).length}/{cl + .items.length} + + {#if cl.items.length > 0} +
+
+
+ {/if} +
+ {/if} + {#if isEditor} +
+ + +
+ {/if} +
+ + +
+ {#each cl.items as item (item.id)} +
+ + 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} + { + 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} + + {/if} + {#if isEditor} + + {/if} +
+ {/each} +
+ + + {#if isEditor} +
+ { + 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" + /> + +
+ {/if} +
+ {/each} + + + {#if isEditor} + {#if showNewChecklist} +
+ { + 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" + /> + +
+ {:else} + + {/if} + {/if} + + {#if checklists.length === 0} +

+ No checklists yet +

+ {/if} +
diff --git a/src/lib/components/modules/ContactsWidget.svelte b/src/lib/components/modules/ContactsWidget.svelte new file mode 100644 index 0000000..1b385ad --- /dev/null +++ b/src/lib/components/modules/ContactsWidget.svelte @@ -0,0 +1,547 @@ + + +
+ +
+ +
+ search + +
+ + + + + {#if isEditor} + + {/if} +
+ + +
+ {#if filteredContacts.length === 0} +
+ contacts +

+ {contacts.length === 0 + ? "No contacts yet" + : "No matches found"} +

+
+ {:else} +
+ {#each filteredContacts as contact (contact.id)} +
+ + + + + {#if expandedId === contact.id} +
+
+ {#if contact.email} +
+ Email + {contact.email} +
+ {/if} + {#if contact.phone} +
+ Phone + {contact.phone} +
+ {/if} + {#if contact.website} +
+ Website + {contact.website} +
+ {/if} + {#if contact.role} +
+ Role + {contact.role} +
+ {/if} +
+ {#if contact.notes} +
+ Notes +

+ {contact.notes} +

+
+ {/if} + {#if isEditor} +
+ + +
+ {/if} +
+ {/if} +
+ {/each} +
+ {/if} +
+
+ + + (showContactModal = false)} + title={editingContact ? "Edit Contact" : "Add Contact"} +> +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
diff --git a/src/lib/components/modules/FilesWidget.svelte b/src/lib/components/modules/FilesWidget.svelte new file mode 100644 index 0000000..1598b20 --- /dev/null +++ b/src/lib/components/modules/FilesWidget.svelte @@ -0,0 +1,32 @@ + + +
+ folder +

Department files and documents

+ +
diff --git a/src/lib/components/modules/KanbanWidget.svelte b/src/lib/components/modules/KanbanWidget.svelte new file mode 100644 index 0000000..97ac931 --- /dev/null +++ b/src/lib/components/modules/KanbanWidget.svelte @@ -0,0 +1,37 @@ + + +
+ view_kanban +

+ Task board for this department +

+ +
diff --git a/src/lib/components/modules/NotesWidget.svelte b/src/lib/components/modules/NotesWidget.svelte new file mode 100644 index 0000000..15895e2 --- /dev/null +++ b/src/lib/components/modules/NotesWidget.svelte @@ -0,0 +1,178 @@ + + +
+ +
+
+ {#each notes as note (note.id)} + + {/each} +
+ {#if isEditor} + {#if showNewNote} +
+ { + 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" + /> +
+ {:else} + + {/if} + {/if} +
+ + +
+ {#if selectedNote} + +
+ {#if editingTitle} + { + 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} + + {/if} + {#if isEditor} + + {/if} +
+ + + + {:else} +
+ {notes.length === 0 ? "No notes yet" : "Select a note"} +
+ {/if} +
+
diff --git a/src/lib/components/modules/ScheduleWidget.svelte b/src/lib/components/modules/ScheduleWidget.svelte new file mode 100644 index 0000000..d294ccd --- /dev/null +++ b/src/lib/components/modules/ScheduleWidget.svelte @@ -0,0 +1,660 @@ + + +
+ +
+
+ + +
+ {#if isEditor} +
+ + +
+ {/if} +
+ + + {#if stages.length > 0} +
+ {#each stages as stage (stage.id)} +
+
+ {stage.name} + {#if isEditor} + + {/if} +
+ {/each} +
+ {/if} + + +
+ {#if blocks.length === 0} +
+ calendar_today +

No schedule blocks yet

+
+ {:else if viewMode === "timeline"} + +
+ {#each blocksByDate as [date, dayBlocks] (date)} +
+

+ {formatDateLabel(date)} +

+
+ +
+ + {#each dayBlocks as block (block.id)} + {@const mins = durationMinutes(block)} + + +
+ isEditor && openBlockModal(block)} + > + +
+ +
+
+ + {formatTime(block.start_time)} – {formatTime( + block.end_time, + )} + + {mins}min +
+
+
+
+ {block.title} +
+ {#if block.speaker} + {block.speaker} + {/if} + {#if block.stage_id} + {stageName_for( + block.stage_id, + )} + {/if} +
+ {#if isEditor} + + {/if} +
+
+ {/each} +
+
+ {/each} +
+ {:else} + +
+ {#each blocks as block (block.id)} + + +
isEditor && openBlockModal(block)} + > +
+
+ {block.title} + {#if block.speaker} + {block.speaker} + {/if} +
+
+ + {formatTime(block.start_time)} – {formatTime( + block.end_time, + )} + + + {new Date( + block.start_time, + ).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + })} + +
+ {#if block.stage_id} + {stageName_for(block.stage_id)} + {/if} + {#if isEditor} + + {/if} +
+ {/each} +
+ {/if} +
+
+ + + (showBlockModal = false)} + title={editingBlock ? "Edit Block" : "Add Schedule Block"} +> +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + {#if stages.length > 0} +
+ + +
+ {/if} + +
+ + +
+ +
+ Color +
+ {#each PRESET_COLORS as c} + + {/each} +
+
+ +
+ + +
+
+
+ + + (showStageModal = false)} + title="Add Stage" +> +
+
+ + +
+
+ Color +
+ {#each PRESET_COLORS as c} + + {/each} +
+
+
+ + +
+
+
diff --git a/src/lib/components/modules/SponsorsWidget.svelte b/src/lib/components/modules/SponsorsWidget.svelte new file mode 100644 index 0000000..199a975 --- /dev/null +++ b/src/lib/components/modules/SponsorsWidget.svelte @@ -0,0 +1,571 @@ + + +
+ +
+
+

Confirmed

+

{formatCurrency(totalCommitted)}

+

{sponsors.filter((s) => s.status === 'confirmed' || s.status === 'active').length} sponsors

+
+
+

Pipeline

+

{formatCurrency(totalProspect)}

+

{sponsors.filter((s) => s.status === 'prospect' || s.status === 'contacted').length} prospects

+
+ {#if fullscreen} +
+

Total Sponsors

+

{sponsors.length}

+
+
+

Tiers

+

{tiers.length}

+
+ {/if} +
+ + +
+
+ + {#if tiers.length > 0} + + {/if} +
+ {#if isEditor} +
+ {#if fullscreen} + + {/if} + +
+ {/if} +
+ + +
+ {#if filteredSponsors.length === 0} +
+ handshake +

No sponsors yet

+
+ {: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} +
+ + + + + {#if isExpanded} +
+ +
+ {#if sponsor.contact_email} + + mail + {sponsor.contact_email} + + {/if} + {#if sponsor.contact_phone} + + phone + {sponsor.contact_phone} + + {/if} + {#if sponsor.website} + + language + Website + + {/if} +
+ + {#if sponsor.notes} +

{sponsor.notes}

+ {/if} + + +
+

Deliverables

+ {#if sponsorDeliverables.length > 0} +
+ {#each sponsorDeliverables as del (del.id)} +
+ + {del.description} + {#if del.due_date} + {new Date(del.due_date).toLocaleDateString()} + {/if} + {#if isEditor} + + {/if} +
+ {/each} +
+ {/if} + {#if isEditor} +
+ e.key === 'Enter' && handleAddDeliverable(sponsor.id)} + /> + +
+ {/if} +
+ + + {#if isEditor} +
+ + +
+ {/if} +
+ {/if} +
+ {/each} + {/if} +
+
+ + +{#if showAddSponsorModal} + +
(showAddSponsorModal = false)} onkeydown={(e) => e.key === 'Escape' && (showAddSponsorModal = false)}> + +
e.stopPropagation()}> +

{editingSponsor ? 'Edit' : 'Add'} Sponsor

+ +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+
+{/if} + + +{#if showAddTierModal} + +
(showAddTierModal = false)} onkeydown={(e) => e.key === 'Escape' && (showAddTierModal = false)}> + +
e.stopPropagation()}> +

Manage Tiers

+ +
+
+ + +
+
+ + +
+
+

Color

+
+ {#each TIER_COLORS as color} + + {/each} +
+
+ + + {#if tiers.length > 0} +
+

Existing Tiers

+
+ {#each tiers as tier} +
+
+ + {tier.name} + {formatCurrency(Number(tier.amount))} +
+ {#if isEditor} + + {/if} +
+ {/each} +
+
+ {/if} +
+ +
+ + +
+
+
+{/if} diff --git a/src/lib/components/settings/SettingsGeneral.svelte b/src/lib/components/settings/SettingsGeneral.svelte index 644f12a..288f535 100644 --- a/src/lib/components/settings/SettingsGeneral.svelte +++ b/src/lib/components/settings/SettingsGeneral.svelte @@ -20,8 +20,11 @@ let { supabase, org, isOwner, onLeave, onDelete }: Props = $props(); + // svelte-ignore state_referenced_locally let orgName = $state(org.name); + // svelte-ignore state_referenced_locally let orgSlug = $state(org.slug); + // svelte-ignore state_referenced_locally let avatarUrl = $state(org.avatar_url ?? null); let isSaving = $state(false); let isUploading = $state(false); diff --git a/src/lib/components/settings/SettingsIntegrations.svelte b/src/lib/components/settings/SettingsIntegrations.svelte index 91d5702..bc5fdff 100644 --- a/src/lib/components/settings/SettingsIntegrations.svelte +++ b/src/lib/components/settings/SettingsIntegrations.svelte @@ -43,6 +43,7 @@ setTimeout(() => (emailCopied = false), 2000); } + // svelte-ignore state_referenced_locally let showConnectModal = $state(initialShowConnect); let isLoading = $state(false); let calendarUrlInput = $state(""); diff --git a/src/lib/components/settings/SettingsMembers.svelte b/src/lib/components/settings/SettingsMembers.svelte index b5a4d58..ace536b 100644 --- a/src/lib/components/settings/SettingsMembers.svelte +++ b/src/lib/components/settings/SettingsMembers.svelte @@ -184,7 +184,7 @@ {#if invites.length > 0} -
+

{m.settings_members_pending()}

@@ -229,7 +229,7 @@ {/if} -
+
{#each members as member} {@const rawProfile = member.profiles} @@ -288,42 +288,40 @@ onClose={() => (showInviteModal = false)} title="Invite Member" > -
- - +
+
+ + +
+
+ + + {isSendingInvite ? "..." : "Send Invite"} +
@@ -337,46 +335,45 @@ {#if selectedMember} {@const rawP = selectedMember.profiles} {@const memberProfile = Array.isArray(rawP) ? rawP[0] : rawP} -
-
-
- {(memberProfile?.full_name || - memberProfile?.email || - "?")[0].toUpperCase()} -
+
+
+
-

+

{memberProfile?.full_name || "No name"}

-

+

{memberProfile?.email || "No email"}

- -
- - -
+ + + + + +
+ +
+ +
{/if} diff --git a/src/lib/components/settings/SettingsRoles.svelte b/src/lib/components/settings/SettingsRoles.svelte index 4a775f7..e215f21 100644 --- a/src/lib/components/settings/SettingsRoles.svelte +++ b/src/lib/components/settings/SettingsRoles.svelte @@ -203,7 +203,7 @@
{#each roles as role} -
+
(showRoleModal = false)} title={editingRole ? "Edit Role" : "Create Role"} > -
- -
- -
+
+
+ + +
+
+ Color +
{#each roleColors as color}
-
- -
+
+ Permissions +
{#each permissionGroups as group} -
-

+

+

{group.name}

{#each group.permissions as perm} {/each}
@@ -321,16 +319,16 @@ {/each}
-
- - + + {isSavingRole ? "..." : editingRole ? m.btn_save() : m.btn_create()} +
diff --git a/src/lib/components/ui/ImagePreviewModal.svelte b/src/lib/components/ui/ImagePreviewModal.svelte index f1a847c..589b4d2 100644 --- a/src/lib/components/ui/ImagePreviewModal.svelte +++ b/src/lib/components/ui/ImagePreviewModal.svelte @@ -23,6 +23,7 @@ +
diff --git a/src/lib/components/ui/Modal.svelte b/src/lib/components/ui/Modal.svelte index 5dce9dd..896d5b9 100644 --- a/src/lib/components/ui/Modal.svelte +++ b/src/lib/components/ui/Modal.svelte @@ -45,6 +45,7 @@ tabindex="-1" transition:fade={{ duration: 150 }} > +
{title}
{/if} diff --git a/src/lib/components/ui/Skeleton.svelte b/src/lib/components/ui/Skeleton.svelte index f628955..36d06a5 100644 --- a/src/lib/components/ui/Skeleton.svelte +++ b/src/lib/components/ui/Skeleton.svelte @@ -29,8 +29,8 @@ card: { w: '100%', h: '8rem' }, }; - const finalWidth = width || defaultSizes[variant].w; - const finalHeight = height || defaultSizes[variant].h; + const finalWidth = $derived(width || defaultSizes[variant].w); + const finalHeight = $derived(height || defaultSizes[variant].h); {#if variant === 'text' && lines > 1} diff --git a/src/lib/components/ui/Toast.svelte b/src/lib/components/ui/Toast.svelte index acba837..027ee00 100644 --- a/src/lib/components/ui/Toast.svelte +++ b/src/lib/components/ui/Toast.svelte @@ -58,6 +58,7 @@ +
@@ -81,26 +83,37 @@

Your Organizations

Select an organization to get started

- +
{#if organizations.length === 0} -
- groups +
+
+ groups +

No organizations yet

Create your first organization to start collaborating

- +
{:else}
{#each organizations as org} -
+
{org.name.charAt(0).toUpperCase()}
- {org.role} + {org.role}

{org.name}

/{org.slug}

@@ -117,29 +130,32 @@ onClose={() => (showCreateModal = false)} title="Create Organization" > -
- +
+
+ + +
{#if newOrgName} -

- URL: /{generateSlug(newOrgName)} +

+ URL: /{generateSlug(newOrgName)}

{/if} -
- - + +
diff --git a/src/routes/[orgSlug]/account/+page.svelte b/src/routes/[orgSlug]/account/+page.svelte index 42003a7..b0e6fb5 100644 --- a/src/routes/[orgSlug]/account/+page.svelte +++ b/src/routes/[orgSlug]/account/+page.svelte @@ -36,11 +36,17 @@ const supabase = getContext>("supabase"); // Profile state + // svelte-ignore state_referenced_locally let fullName = $state(data.profile.full_name ?? ""); + // svelte-ignore state_referenced_locally let avatarUrl = $state(data.profile.avatar_url ?? null); + // svelte-ignore state_referenced_locally let phone = $state(data.profile.phone ?? ""); + // svelte-ignore state_referenced_locally let discordHandle = $state(data.profile.discord_handle ?? ""); + // svelte-ignore state_referenced_locally let shirtSize = $state(data.profile.shirt_size ?? ""); + // svelte-ignore state_referenced_locally let hoodieSize = $state(data.profile.hoodie_size ?? ""); let isSaving = $state(false); let isUploading = $state(false); @@ -49,8 +55,11 @@ const clothingSizes = ["XS", "S", "M", "L", "XL", "XXL", "3XL"]; // Preferences state + // svelte-ignore state_referenced_locally let theme = $state(data.preferences?.theme ?? "dark"); + // svelte-ignore state_referenced_locally let accentColor = $state(data.preferences?.accent_color ?? "#00A3E0"); + // svelte-ignore state_referenced_locally let useOrgTheme = $state(data.preferences?.use_org_theme ?? true); let currentLocale = $state<(typeof locales)[number]>(getLocale()); @@ -250,7 +259,7 @@
-
+

{m.account_profile()}

@@ -326,7 +335,7 @@
-
+

{m.account_contact_info()}

@@ -378,7 +387,7 @@
-
+

{m.account_appearance()}

@@ -404,17 +413,17 @@ {#each accentColors as color} {/each}
-
+

{m.account_security()}

diff --git a/src/routes/[orgSlug]/calendar/+page.svelte b/src/routes/[orgSlug]/calendar/+page.svelte index a33f42b..ef08d49 100644 --- a/src/routes/[orgSlug]/calendar/+page.svelte +++ b/src/routes/[orgSlug]/calendar/+page.svelte @@ -33,6 +33,7 @@ const supabase = getContext>("supabase"); const log = createLogger("page.calendar"); + // svelte-ignore state_referenced_locally let events = $state(data.events); $effect(() => { events = data.events; @@ -735,6 +736,7 @@ : ''}" style="background-color: {color}" onclick={() => (eventColor = color)} + aria-label="Color {color}" > {/each}
diff --git a/src/routes/[orgSlug]/documents/+page.svelte b/src/routes/[orgSlug]/documents/+page.svelte index 3091165..b29bae8 100644 --- a/src/routes/[orgSlug]/documents/+page.svelte +++ b/src/routes/[orgSlug]/documents/+page.svelte @@ -12,6 +12,7 @@ let { data }: Props = $props(); + // svelte-ignore state_referenced_locally let documents = $state(data.documents); $effect(() => { documents = data.documents; diff --git a/src/routes/[orgSlug]/documents/folder/[id]/+page.svelte b/src/routes/[orgSlug]/documents/folder/[id]/+page.svelte index fd38451..90ef7ac 100644 --- a/src/routes/[orgSlug]/documents/folder/[id]/+page.svelte +++ b/src/routes/[orgSlug]/documents/folder/[id]/+page.svelte @@ -13,6 +13,7 @@ let { data }: Props = $props(); + // svelte-ignore state_referenced_locally let documents = $state(data.documents); $effect(() => { documents = data.documents; diff --git a/src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte b/src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte index 9c8a9c6..f44b672 100644 --- a/src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte +++ b/src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte @@ -30,41 +30,11 @@ icon: "dashboard", 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`, label: m.events_mod_team(), icon: "badge", }, - { - href: `${basePath}/sponsors`, - label: m.events_mod_sponsors(), - icon: "handshake", - }, ]); function isModuleActive(href: string, exact?: boolean): boolean { @@ -162,6 +132,30 @@ {/if} {/each} + + + {#if data.eventDepartments.length > 0} +

+ Departments +

+ {#each data.eventDepartments as dept} +
+ + {dept.name} + {#if isNavigatingToModule(`${basePath}/dept/${dept.id}`)} + + {/if} + + {/each} + {/if} diff --git a/src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.server.ts b/src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.server.ts new file mode 100644 index 0000000..670f1e9 --- /dev/null +++ b/src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.server.ts @@ -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'); + } +}; diff --git a/src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.svelte b/src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.svelte new file mode 100644 index 0000000..2294f23 --- /dev/null +++ b/src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.svelte @@ -0,0 +1,1092 @@ + + + +{#if expandedModule} +
+
+
+ + {moduleInfo[ + dashboard?.panels.find( + (p) => p.id === expandedModule, + )?.module ?? "checklist" + ]?.icon ?? "widgets"} + +

+ {moduleInfo[ + dashboard?.panels.find( + (p) => p.id === expandedModule, + )?.module ?? "checklist" + ]?.label ?? "Module"} +

+
+ +
+
+ {#if expandedPanel?.module === "checklist"} + + {:else if expandedPanel?.module === "notes"} + + {:else if expandedPanel?.module === "kanban"} + + {:else if expandedPanel?.module === "files"} + + {:else if expandedPanel?.module === "schedule"} + + {:else if expandedPanel?.module === "contacts"} + + {:else if expandedPanel?.module === "budget"} + + {:else if expandedPanel?.module === "sponsors"} + + {:else} +
+

Module coming soon

+
+ {/if} +
+
+{/if} + + +
+ +
+
+
+

+ {department.name} +

+ {#if department.description} + — {department.description} + {/if} +
+ {#if isEditor} +
+ +
+ {#each Object.entries(layoutConfigs) as [key, config]} + + {/each} +
+ + + +
+ {/if} +
+ + +
+ {#if !dashboard || dashboard.panels.length === 0} +
+ widgets +

No modules configured yet

+ {#if isEditor} + + {/if} +
+ {:else} +
+ {#each dashboard.panels as panel (panel.id)} +
+ +
+
+ + {moduleInfo[panel.module]?.icon ?? + "widgets"} + + + {moduleInfo[panel.module]?.label ?? + panel.module} + +
+
+ + + + {#if isEditor} + + {/if} +
+
+ + +
+ {#if panel.module === "checklist"} + + {:else if panel.module === "notes"} + + {:else if panel.module === "kanban"} + + {:else if panel.module === "files"} + + {:else if panel.module === "schedule"} + + {:else if panel.module === "contacts"} + + {:else if panel.module === "budget"} + + {:else if panel.module === "sponsors"} + + {:else} +
+ Coming soon +
+ {/if} +
+
+ {/each} +
+ {/if} +
+
+ + + (showAddModuleModal = false)} +> +
+ {#each availableModules() as mod} + + {/each} + {#if availableModules().length === 0} +

+ All modules are already added +

+ {/if} +
+
diff --git a/src/routes/[orgSlug]/events/[eventSlug]/tasks/+page.svelte b/src/routes/[orgSlug]/events/[eventSlug]/tasks/+page.svelte index 049e170..6ea0b00 100644 --- a/src/routes/[orgSlug]/events/[eventSlug]/tasks/+page.svelte +++ b/src/routes/[orgSlug]/events/[eventSlug]/tasks/+page.svelte @@ -39,6 +39,7 @@ const supabase = getContext>("supabase"); + // svelte-ignore state_referenced_locally let taskColumns = $state(data.taskColumns); let realtimeChannel = $state(null); let optimisticMoveIds = new Set(); diff --git a/src/routes/[orgSlug]/events/[eventSlug]/team/+page.svelte b/src/routes/[orgSlug]/events/[eventSlug]/team/+page.svelte index 21780f8..d0eccc4 100644 --- a/src/routes/[orgSlug]/events/[eventSlug]/team/+page.svelte +++ b/src/routes/[orgSlug]/events/[eventSlug]/team/+page.svelte @@ -49,8 +49,11 @@ ); // Local mutable state + // svelte-ignore state_referenced_locally let teamMembers = $state(data.eventMembers); + // svelte-ignore state_referenced_locally let roles = $state(data.eventRoles); + // svelte-ignore state_referenced_locally let departments = $state(data.eventDepartments); $effect(() => { @@ -118,8 +121,29 @@ let editingDept = $state(null); let deptName = $state(""); let deptColor = $state("#00A3E0"); + type ModuleType = "kanban" | "files" | "checklist" | "notes" | "schedule" | "contacts" | "budget" | "sponsors"; + let deptModules = $state(["kanban", "files", "checklist"]); 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 editingRole = $state(null); let roleName = $state(""); @@ -132,6 +156,53 @@ "#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 { return member.profile?.full_name || member.profile?.email || "Unknown"; } @@ -290,6 +361,7 @@ editingDept = dept ?? null; deptName = dept?.name ?? ""; deptColor = dept?.color ?? "#00A3E0"; + deptModules = ["kanban", "files", "checklist"]; showDeptModal = true; } @@ -310,13 +382,14 @@ ); toasts.success(m.team_dept_updated()); } else { - const { data: created, error } = await supabase + const { data: created, error } = await (supabase as any) .from("event_departments") .insert({ event_id: data.event.id, name: deptName.trim(), color: deptColor, sort_order: departments.length, + enabled_modules: deptModules, }) .select() .single(); @@ -795,8 +868,25 @@ - (showDeptModal = false)} title={editingDept ? m.team_edit_department() : m.team_add_department()}> + (showDeptModal = false)} title={editingDept ? m.team_edit_department() : m.team_add_department()} size="lg">
+ {#if !editingDept && availableDeptPresets.length > 0} +
+ Quick add +
+ {#each availableDeptPresets as preset} + + {/each} +
+
+ {/if}
@@ -805,10 +895,30 @@ Color
{#each presetColors as c} - + {/each}
+ {#if !editingDept} +
+ Modules +
+ {#each allModules as mod} + + {/each} +
+
+ {/if} {#if editingDept} + {/each} +
+
+ {/if}
@@ -834,7 +961,7 @@ Color
{#each presetColors as c} - + {/each}
diff --git a/src/routes/[orgSlug]/kanban/+page.svelte b/src/routes/[orgSlug]/kanban/+page.svelte index ced9651..ae25a5a 100644 --- a/src/routes/[orgSlug]/kanban/+page.svelte +++ b/src/routes/[orgSlug]/kanban/+page.svelte @@ -56,6 +56,7 @@ const supabase = getContext>("supabase"); const log = createLogger("page.kanban"); + // svelte-ignore state_referenced_locally let boards = $state(data.boards); $effect(() => { boards = data.boards; @@ -543,6 +544,7 @@
{#if isRenamingBoard && selectedBoard} + (data.members as Member[]); + // svelte-ignore state_referenced_locally let roles = $state(data.roles as OrgRole[]); + // svelte-ignore state_referenced_locally let invites = $state(data.invites as Invite[]); + // svelte-ignore state_referenced_locally let orgCalendar = $state( data.orgCalendar as OrgCalendar | null, ); @@ -415,57 +419,66 @@ onClose={() => (showCreateTagModal = false)} title={editingTag ? "Edit Tag" : "Create Tag"} > -
- -
- Color +
+
+ + +
+
+ Color
{#each TAG_COLORS as color} {/each} -
-
- Custom: - - {tagColor} +
-
- Preview: +
+ Preview: {tagName || "Tag name"}
-
- - + + {isSavingTag ? "..." : editingTag ? m.btn_save() : m.btn_create()} +
diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts new file mode 100644 index 0000000..64bfd69 --- /dev/null +++ b/src/routes/admin/+page.server.ts @@ -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 = {}; + for (const m of orgMembers) { + orgMemberCounts[m.org_id] = (orgMemberCounts[m.org_id] || 0) + 1; + } + + // Org event counts + const orgEventCounts: Record = {}; + 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, + }, + }; +}; diff --git a/src/routes/admin/+page.svelte b/src/routes/admin/+page.svelte new file mode 100644 index 0000000..c4af90b --- /dev/null +++ b/src/routes/admin/+page.svelte @@ -0,0 +1,406 @@ + + + + Platform Admin | Root + + +
+ +
+
+
+ + arrow_back + +
+ admin_panel_settings + Platform Admin +
+ Admin Only +
+
+ schedule + {new Date().toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })} +
+
+
+ +
+ +
+ + + + + + + + +
+ + + (activeTab = v)} + /> + + + {#if activeTab === "overview"} +
+ +
+
+

Recent Organizations

+ +
+
+ {#each data.organizations.slice(0, 5) as org} +
+
+ +
+

{org.name}

+

/{org.slug}

+
+
+
+ {org.memberCount} members + {org.eventCount} events +
+
+ {/each} + {#if data.organizations.length === 0} +

No organizations yet

+ {/if} +
+
+ + +
+
+

Recent Users

+ +
+
+ {#each data.profiles.slice(0, 5) as profile} +
+
+ +
+

{profile.full_name ?? "No name"}

+

{profile.email}

+
+
+
+ {#if profile.is_platform_admin} + Admin + {/if} + {timeAgo(profile.created_at)} +
+
+ {/each} + {#if data.profiles.length === 0} +

No users yet

+ {/if} +
+
+ + +
+
+

Recent Events

+ +
+ {#if data.events.length > 0} +
+ + + + + + + + + + + + {#each data.events.slice(0, 8) as event} + + + + + + + + {/each} + +
EventOrganizationStatusDatesCreated
+

{event.name}

+

/{event.slug}

+
+ {orgMap[event.org_id]?.name ?? "—"} + + + {event.status} + + + {formatDate(event.start_date)} — {formatDate(event.end_date)} + {timeAgo(event.created_at)}
+
+ {:else} +

No events yet

+ {/if} +
+
+ + {:else if activeTab === "organizations"} +
+
+ +
+
+ + + + + + + + + + + + {#each filteredOrgs as org} + + + + + + + + {/each} + {#if filteredOrgs.length === 0} + + + + {/if} + +
OrganizationSlugMembersEventsCreated
+
+ + {org.name} +
+
/{org.slug} + {org.memberCount} + + {org.eventCount} + {formatDate(org.created_at)}
+ {orgSearch ? "No organizations match your search" : "No organizations yet"} +
+
+

{filteredOrgs.length} of {data.organizations.length} organizations

+
+ + {:else if activeTab === "users"} +
+
+ +
+
+ + + + + + + + + + + {#each filteredUsers as profile} + + + + + + + {/each} + {#if filteredUsers.length === 0} + + + + {/if} + +
UserEmailRoleJoined
+
+ + {profile.full_name ?? "No name"} +
+
{profile.email} + {#if profile.is_platform_admin} + Platform Admin + {:else} + User + {/if} + {formatDate(profile.created_at)}
+ {userSearch ? "No users match your search" : "No users yet"} +
+
+

{filteredUsers.length} of {data.profiles.length} users

+
+ + {:else if activeTab === "events"} +
+
+ +
+
+ + + + + + + + + + + + + {#each filteredEvents as event} + + + + + + + + + {/each} + {#if filteredEvents.length === 0} + + + + {/if} + +
EventOrganizationStatusStartEndCreated
+
+

{event.name}

+

/{event.slug}

+
+
+ {orgMap[event.org_id]?.name ?? "—"} + + + {event.status} + + {formatDate(event.start_date)}{formatDate(event.end_date)}{timeAgo(event.created_at)}
+ {eventSearch ? "No events match your search" : "No events yet"} +
+
+

{filteredEvents.length} of {data.events.length} events

+
+ {/if} +
+
diff --git a/src/routes/invite/[token]/+page.svelte b/src/routes/invite/[token]/+page.svelte index a1e2863..c4eb26a 100644 --- a/src/routes/invite/[token]/+page.svelte +++ b/src/routes/invite/[token]/+page.svelte @@ -1,6 +1,6 @@ -
-
-
+
+
+
{#if data.error}
- - - - - + error
-

+

Invalid Invite

-

{data.error}

+

{data.error}

{:else if data.invite}
- - - - - - + group_add
-

+

You're Invited!

-

You've been invited to join

-

+

You've been invited to join

+

{data.invite.org.name}

-

as {data.invite.role}

+

as {data.invite.role}

{#if error}
+ error {error}
{/if} {#if data.user} -

- Signed in as + Signed in as {data.user.email}

-
-

+

Wrong account? Sign out {:else} -

+

Sign in or create an account to accept this invite.

- - +
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 62fb7b3..3d51e6b 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -1,5 +1,5 @@ Style Guide | Root -
- -
-
-
- - arrow_back - - Style Guide - All UI components and their variants -
+
+ +
+
+ +
+ + -
+ +
+
-
+

Colors

-
-
-
-

Background

- #05090F +
+ {#each colors as c} +
+
+

{c.name}

+ {c.hex} +
+ {/each} +
+
+ + +
+

Typography

+
+
+

Headings — Tilt Warp

+
+ {#each typographyScale as t} +
+ {t.label} + {t.text} +
+ {/each} +
-
-
-

Night

- #0A121F -
-
-
-

Dark

- #14243E -
-
-
-

Light

- #E5E6F0 -
-
-
-

Primary

- #00A3E0 -
-
-
-

Success

- #33E000 -
-
-
-

Warning

- #FFAB00 -
-
-
-

Error

- #E03D00 +
+

Body — Work Sans

+
+ {#each bodyScale as b} +
+ {b.label} +

{b.text}

+
+ {/each} +
- -
-

Buttons

- -
+ +
+

Button

+

Variants

- - - - - + {#each ["primary", "secondary", "tertiary", "danger", "success"] as v} + + {/each}
-

Sizes

@@ -123,17 +171,14 @@
-

With Icons

- - +
-

States

@@ -142,534 +187,363 @@
-

Full Width

-
- -
+
- -
-

Inputs

- -
- - - - - - - - + +
+

Input

+
+ + + + + + +
-
+

Textarea

- -
-