Mega push vol 7 mvp lesgoooo
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
354
src/lib/api/department-dashboard.ts
Normal 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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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>
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;"
|
||||||
|
|||||||
438
src/lib/components/modules/BudgetWidget.svelte
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { BudgetCategory, BudgetItem } from '$lib/supabase/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
categories: BudgetCategory[];
|
||||||
|
items: BudgetItem[];
|
||||||
|
isEditor: boolean;
|
||||||
|
fullscreen?: boolean;
|
||||||
|
onCreateCategory: (name: string, color: string) => void;
|
||||||
|
onDeleteCategory: (categoryId: string) => void;
|
||||||
|
onCreateItem: (params: {
|
||||||
|
description: string;
|
||||||
|
item_type: 'income' | 'expense';
|
||||||
|
planned_amount?: number;
|
||||||
|
actual_amount?: number;
|
||||||
|
category_id?: string | null;
|
||||||
|
notes?: string;
|
||||||
|
}) => void;
|
||||||
|
onUpdateItem: (
|
||||||
|
itemId: string,
|
||||||
|
params: Partial<Pick<BudgetItem, 'description' | 'item_type' | 'planned_amount' | 'actual_amount' | 'category_id' | 'notes'>>
|
||||||
|
) => void;
|
||||||
|
onDeleteItem: (itemId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
categories,
|
||||||
|
items,
|
||||||
|
isEditor,
|
||||||
|
fullscreen = false,
|
||||||
|
onCreateCategory,
|
||||||
|
onDeleteCategory,
|
||||||
|
onCreateItem,
|
||||||
|
onUpdateItem,
|
||||||
|
onDeleteItem,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let viewMode = $state<'overview' | 'income' | 'expense'>('overview');
|
||||||
|
let showAddItemModal = $state(false);
|
||||||
|
let showAddCategoryModal = $state(false);
|
||||||
|
let editingItem = $state<BudgetItem | null>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let newCategoryName = $state('');
|
||||||
|
let newCategoryColor = $state('#6366f1');
|
||||||
|
let formDescription = $state('');
|
||||||
|
let formType = $state<'income' | 'expense'>('expense');
|
||||||
|
let formPlanned = $state('0');
|
||||||
|
let formActual = $state('0');
|
||||||
|
let formCategoryId = $state<string | null>(null);
|
||||||
|
let formNotes = $state('');
|
||||||
|
|
||||||
|
const CATEGORY_COLORS = ['#6366f1', '#10B981', '#F59E0B', '#EF4444', '#EC4899', '#8B5CF6', '#06B6D4', '#F97316'];
|
||||||
|
|
||||||
|
// Computed totals
|
||||||
|
const incomeItems = $derived(items.filter((i) => i.item_type === 'income'));
|
||||||
|
const expenseItems = $derived(items.filter((i) => i.item_type === 'expense'));
|
||||||
|
|
||||||
|
const totalPlannedIncome = $derived(incomeItems.reduce((s, i) => s + Number(i.planned_amount), 0));
|
||||||
|
const totalActualIncome = $derived(incomeItems.reduce((s, i) => s + Number(i.actual_amount), 0));
|
||||||
|
const totalPlannedExpense = $derived(expenseItems.reduce((s, i) => s + Number(i.planned_amount), 0));
|
||||||
|
const totalActualExpense = $derived(expenseItems.reduce((s, i) => s + Number(i.actual_amount), 0));
|
||||||
|
|
||||||
|
const plannedBalance = $derived(totalPlannedIncome - totalPlannedExpense);
|
||||||
|
const actualBalance = $derived(totalActualIncome - totalActualExpense);
|
||||||
|
|
||||||
|
const filteredItems = $derived(
|
||||||
|
viewMode === 'income'
|
||||||
|
? incomeItems
|
||||||
|
: viewMode === 'expense'
|
||||||
|
? expenseItems
|
||||||
|
: items
|
||||||
|
);
|
||||||
|
|
||||||
|
function getCategoryName(categoryId: string | null): string {
|
||||||
|
if (!categoryId) return 'Uncategorized';
|
||||||
|
return categories.find((c) => c.id === categoryId)?.name ?? 'Uncategorized';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryColor(categoryId: string | null): string {
|
||||||
|
if (!categoryId) return '#64748b';
|
||||||
|
return categories.find((c) => c.id === categoryId)?.color ?? '#64748b';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddItem(type: 'income' | 'expense' = 'expense') {
|
||||||
|
editingItem = null;
|
||||||
|
formDescription = '';
|
||||||
|
formType = type;
|
||||||
|
formPlanned = '0';
|
||||||
|
formActual = '0';
|
||||||
|
formCategoryId = null;
|
||||||
|
formNotes = '';
|
||||||
|
showAddItemModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditItem(item: BudgetItem) {
|
||||||
|
editingItem = item;
|
||||||
|
formDescription = item.description;
|
||||||
|
formType = item.item_type;
|
||||||
|
formPlanned = String(item.planned_amount);
|
||||||
|
formActual = String(item.actual_amount);
|
||||||
|
formCategoryId = item.category_id;
|
||||||
|
formNotes = item.notes ?? '';
|
||||||
|
showAddItemModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmitItem() {
|
||||||
|
if (!formDescription.trim()) return;
|
||||||
|
if (editingItem) {
|
||||||
|
onUpdateItem(editingItem.id, {
|
||||||
|
description: formDescription.trim(),
|
||||||
|
item_type: formType,
|
||||||
|
planned_amount: parseFloat(formPlanned) || 0,
|
||||||
|
actual_amount: parseFloat(formActual) || 0,
|
||||||
|
category_id: formCategoryId,
|
||||||
|
notes: formNotes.trim() || undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onCreateItem({
|
||||||
|
description: formDescription.trim(),
|
||||||
|
item_type: formType,
|
||||||
|
planned_amount: parseFloat(formPlanned) || 0,
|
||||||
|
actual_amount: parseFloat(formActual) || 0,
|
||||||
|
category_id: formCategoryId,
|
||||||
|
notes: formNotes.trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showAddItemModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddCategory() {
|
||||||
|
if (!newCategoryName.trim()) return;
|
||||||
|
onCreateCategory(newCategoryName.trim(), newCategoryColor);
|
||||||
|
newCategoryName = '';
|
||||||
|
newCategoryColor = '#6366f1';
|
||||||
|
showAddCategoryModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group items by category for overview
|
||||||
|
const itemsByCategory = $derived(() => {
|
||||||
|
const map = new Map<string | null, BudgetItem[]>();
|
||||||
|
for (const item of filteredItems) {
|
||||||
|
const key = item.category_id;
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(item);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 {fullscreen ? 'h-full' : ''}" >
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<div class="grid grid-cols-2 {fullscreen ? 'md:grid-cols-4' : ''} gap-2">
|
||||||
|
<div class="bg-emerald-500/10 border border-emerald-500/20 rounded-xl p-3">
|
||||||
|
<p class="text-[11px] text-emerald-400/70 uppercase tracking-wide">Income</p>
|
||||||
|
<p class="text-body font-heading text-emerald-400">{formatCurrency(totalActualIncome)}</p>
|
||||||
|
<p class="text-[11px] text-light/30">Planned: {formatCurrency(totalPlannedIncome)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-red-500/10 border border-red-500/20 rounded-xl p-3">
|
||||||
|
<p class="text-[11px] text-red-400/70 uppercase tracking-wide">Expenses</p>
|
||||||
|
<p class="text-body font-heading text-red-400">{formatCurrency(totalActualExpense)}</p>
|
||||||
|
<p class="text-[11px] text-light/30">Planned: {formatCurrency(totalPlannedExpense)}</p>
|
||||||
|
</div>
|
||||||
|
{#if fullscreen}
|
||||||
|
<div class="bg-blue-500/10 border border-blue-500/20 rounded-xl p-3">
|
||||||
|
<p class="text-[11px] text-blue-400/70 uppercase tracking-wide">Planned Balance</p>
|
||||||
|
<p class="text-body font-heading {plannedBalance >= 0 ? 'text-blue-400' : 'text-red-400'}">{formatCurrency(plannedBalance)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-light/5 border border-light/10 rounded-xl p-3">
|
||||||
|
<p class="text-[11px] text-light/50 uppercase tracking-wide">Actual Balance</p>
|
||||||
|
<p class="text-body font-heading {actualBalance >= 0 ? 'text-emerald-400' : 'text-red-400'}">{formatCurrency(actualBalance)}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<div class="flex items-center gap-1 bg-dark/50 rounded-lg p-0.5">
|
||||||
|
{#each ['overview', 'income', 'expense'] as mode}
|
||||||
|
<button
|
||||||
|
class="px-2.5 py-1 rounded text-[11px] transition-colors {viewMode === mode ? 'bg-primary text-background' : 'text-light/40 hover:text-light/70'}"
|
||||||
|
onclick={() => (viewMode = mode as 'overview' | 'income' | 'expense')}
|
||||||
|
>
|
||||||
|
{mode === 'overview' ? 'All' : mode === 'income' ? 'Income' : 'Expenses'}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if isEditor}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{#if fullscreen}
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 px-2 py-1 rounded-lg bg-light/5 hover:bg-light/10 text-light/60 text-[11px] transition-colors"
|
||||||
|
onclick={() => (showAddCategoryModal = true)}
|
||||||
|
>
|
||||||
|
<span class="material-symbols-rounded" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;">category</span>
|
||||||
|
Category
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 px-2 py-1 rounded-lg bg-primary/10 hover:bg-primary/20 text-primary text-[11px] transition-colors"
|
||||||
|
onclick={() => openAddItem(viewMode === 'income' ? 'income' : 'expense')}
|
||||||
|
>
|
||||||
|
<span class="material-symbols-rounded" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;">add</span>
|
||||||
|
Add Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items list -->
|
||||||
|
<div class="flex-1 overflow-auto space-y-1">
|
||||||
|
{#if filteredItems.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center py-8 text-light/30 gap-2">
|
||||||
|
<span class="material-symbols-rounded" style="font-size: 32px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 32;">account_balance</span>
|
||||||
|
<p class="text-body-sm">No budget items yet</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Table header -->
|
||||||
|
<div class="grid grid-cols-12 gap-2 px-3 py-1.5 text-[10px] uppercase tracking-wider text-light/30">
|
||||||
|
<div class="col-span-1">Type</div>
|
||||||
|
<div class="{fullscreen ? 'col-span-3' : 'col-span-4'}">Description</div>
|
||||||
|
<div class="col-span-2">Category</div>
|
||||||
|
<div class="col-span-2 text-right">Planned</div>
|
||||||
|
<div class="col-span-2 text-right">Actual</div>
|
||||||
|
{#if fullscreen}
|
||||||
|
<div class="col-span-1 text-right">Diff</div>
|
||||||
|
<div class="col-span-1"></div>
|
||||||
|
{:else}
|
||||||
|
<div class="col-span-1"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each filteredItems as item (item.id)}
|
||||||
|
{@const diff = Number(item.item_type === 'income' ? item.actual_amount - item.planned_amount : item.planned_amount - item.actual_amount)}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-12 gap-2 px-3 py-2 rounded-lg hover:bg-light/5 transition-colors w-full text-left items-center cursor-pointer"
|
||||||
|
onclick={() => isEditor && openEditItem(item)}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && isEditor && openEditItem(item)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div class="col-span-1">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center justify-center w-5 h-5 rounded {item.item_type === 'income' ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'}"
|
||||||
|
>
|
||||||
|
<span class="material-symbols-rounded" style="font-size: 12px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 12;">
|
||||||
|
{item.item_type === 'income' ? 'arrow_downward' : 'arrow_upward'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="{fullscreen ? 'col-span-3' : 'col-span-4'} text-body-sm text-white truncate">{item.description}</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px]"
|
||||||
|
style="background-color: {getCategoryColor(item.category_id)}20; color: {getCategoryColor(item.category_id)}"
|
||||||
|
>
|
||||||
|
{getCategoryName(item.category_id)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2 text-right text-body-sm text-light/60">{formatCurrency(Number(item.planned_amount))}</div>
|
||||||
|
<div class="col-span-2 text-right text-body-sm text-white">{formatCurrency(Number(item.actual_amount))}</div>
|
||||||
|
{#if fullscreen}
|
||||||
|
<div class="col-span-1 text-right text-[11px] {diff >= 0 ? 'text-emerald-400' : 'text-red-400'}">
|
||||||
|
{diff >= 0 ? '+' : ''}{formatCurrency(diff)}
|
||||||
|
</div>
|
||||||
|
<div class="col-span-1 text-right">
|
||||||
|
{#if isEditor}
|
||||||
|
<button
|
||||||
|
class="p-0.5 rounded hover:bg-error/10 transition-colors"
|
||||||
|
onclick={(e) => { e.stopPropagation(); onDeleteItem(item.id); }}
|
||||||
|
>
|
||||||
|
<span class="material-symbols-rounded text-light/30 hover:text-error" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;">delete</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="col-span-1 text-right">
|
||||||
|
{#if isEditor}
|
||||||
|
<button
|
||||||
|
class="p-0.5 rounded hover:bg-error/10 transition-colors"
|
||||||
|
onclick={(e) => { e.stopPropagation(); onDeleteItem(item.id); }}
|
||||||
|
>
|
||||||
|
<span class="material-symbols-rounded text-light/30 hover:text-error" style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;">delete</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Totals row -->
|
||||||
|
<div class="grid grid-cols-12 gap-2 px-3 py-2 border-t border-light/10 mt-2">
|
||||||
|
<div class="col-span-1"></div>
|
||||||
|
<div class="{fullscreen ? 'col-span-3' : 'col-span-4'} text-body-sm font-heading text-white">Total</div>
|
||||||
|
<div class="col-span-2"></div>
|
||||||
|
<div class="col-span-2 text-right text-body-sm font-heading text-light/60">
|
||||||
|
{formatCurrency(filteredItems.reduce((s, i) => s + Number(i.planned_amount), 0))}
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2 text-right text-body-sm font-heading text-white">
|
||||||
|
{formatCurrency(filteredItems.reduce((s, i) => s + Number(i.actual_amount), 0))}
|
||||||
|
</div>
|
||||||
|
{#if fullscreen}
|
||||||
|
<div class="col-span-2"></div>
|
||||||
|
{:else}
|
||||||
|
<div class="col-span-1"></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add/Edit Item Modal -->
|
||||||
|
{#if showAddItemModal}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="fixed inset-0 z-[60] bg-black/60 flex items-center justify-center p-4" onclick={() => (showAddItemModal = false)} onkeydown={(e) => e.key === 'Escape' && (showAddItemModal = false)}>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="bg-surface rounded-2xl border border-light/10 p-5 w-full max-w-md space-y-4" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 class="text-body font-heading text-white">{editingItem ? 'Edit' : 'Add'} Budget Item</h3>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] text-light/50 mb-1" for="budget-desc">Description</label>
|
||||||
|
<input id="budget-desc" type="text" bind:value={formDescription} placeholder="e.g. Venue rental"
|
||||||
|
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] text-light/50 mb-1" for="budget-type">Type</label>
|
||||||
|
<select id="budget-type" bind:value={formType}
|
||||||
|
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary/50">
|
||||||
|
<option value="expense">Expense</option>
|
||||||
|
<option value="income">Income</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] text-light/50 mb-1" for="budget-cat">Category</label>
|
||||||
|
<select id="budget-cat" bind:value={formCategoryId}
|
||||||
|
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary/50">
|
||||||
|
<option value={null}>Uncategorized</option>
|
||||||
|
{#each categories as cat}
|
||||||
|
<option value={cat.id}>{cat.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] text-light/50 mb-1" for="budget-planned">Planned Amount</label>
|
||||||
|
<input id="budget-planned" type="number" step="0.01" bind:value={formPlanned}
|
||||||
|
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary/50" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] text-light/50 mb-1" for="budget-actual">Actual Amount</label>
|
||||||
|
<input id="budget-actual" type="number" step="0.01" bind:value={formActual}
|
||||||
|
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary/50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] text-light/50 mb-1" for="budget-notes">Notes</label>
|
||||||
|
<textarea id="budget-notes" bind:value={formNotes} rows="2" placeholder="Optional notes..."
|
||||||
|
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50 resize-none"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button class="px-3 py-1.5 rounded-lg text-body-sm text-light/60 hover:text-white transition-colors" onclick={() => (showAddItemModal = false)}>Cancel</button>
|
||||||
|
<button class="px-3 py-1.5 rounded-lg bg-primary text-background text-body-sm font-heading hover:bg-primary/90 transition-colors" onclick={handleSubmitItem}>
|
||||||
|
{editingItem ? 'Save' : 'Add'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Add Category Modal -->
|
||||||
|
{#if showAddCategoryModal}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="fixed inset-0 z-[60] bg-black/60 flex items-center justify-center p-4" onclick={() => (showAddCategoryModal = false)} onkeydown={(e) => e.key === 'Escape' && (showAddCategoryModal = false)}>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="bg-surface rounded-2xl border border-light/10 p-5 w-full max-w-sm space-y-4" onclick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 class="text-body font-heading text-white">Add Category</h3>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-[11px] text-light/50 mb-1" for="cat-name">Name</label>
|
||||||
|
<input id="cat-name" type="text" bind:value={newCategoryName} placeholder="e.g. Venue"
|
||||||
|
class="w-full bg-dark/50 border border-light/10 rounded-lg px-3 py-2 text-body-sm text-white placeholder:text-light/20 focus:outline-none focus:border-primary/50" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-[11px] text-light/50 mb-1">Color</p>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
{#each CATEGORY_COLORS as color}
|
||||||
|
<button
|
||||||
|
class="w-6 h-6 rounded-full border-2 transition-all {newCategoryColor === color ? 'border-white scale-110' : 'border-transparent'}"
|
||||||
|
style="background-color: {color}"
|
||||||
|
onclick={() => (newCategoryColor = color)}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Existing categories -->
|
||||||
|
{#if categories.length > 0}
|
||||||
|
<div>
|
||||||
|
<p class="text-[11px] text-light/50 mb-1">Existing Categories</p>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{#each categories as cat}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px]" style="background-color: {cat.color}20; color: {cat.color}">
|
||||||
|
{cat.name}
|
||||||
|
{#if isEditor}
|
||||||
|
<button class="hover:text-white transition-colors" onclick={() => onDeleteCategory(cat.id)}>
|
||||||
|
<span class="material-symbols-rounded" style="font-size: 12px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 12;">close</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button class="px-3 py-1.5 rounded-lg text-body-sm text-light/60 hover:text-white transition-colors" onclick={() => (showAddCategoryModal = false)}>Close</button>
|
||||||
|
<button class="px-3 py-1.5 rounded-lg bg-primary text-background text-body-sm font-heading hover:bg-primary/90 transition-colors" onclick={handleAddCategory}>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
279
src/lib/components/modules/ChecklistWidget.svelte
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ChecklistWithItems } from "$lib/api/department-dashboard";
|
||||||
|
import { Button } from "$lib/components/ui";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
checklists: ChecklistWithItems[];
|
||||||
|
isEditor: boolean;
|
||||||
|
fullscreen?: boolean;
|
||||||
|
onAddItem: (checklistId: string, content: string) => void;
|
||||||
|
onToggleItem: (itemId: string, checked: boolean) => void;
|
||||||
|
onDeleteItem: (itemId: string) => void;
|
||||||
|
onUpdateItem: (itemId: string, content: string) => void;
|
||||||
|
onAddChecklist: (title: string) => void;
|
||||||
|
onDeleteChecklist: (checklistId: string) => void;
|
||||||
|
onRenameChecklist: (checklistId: string, title: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
checklists,
|
||||||
|
isEditor,
|
||||||
|
fullscreen = false,
|
||||||
|
onAddItem,
|
||||||
|
onToggleItem,
|
||||||
|
onDeleteItem,
|
||||||
|
onUpdateItem,
|
||||||
|
onAddChecklist,
|
||||||
|
onDeleteChecklist,
|
||||||
|
onRenameChecklist,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let newItemContent: Record<string, string> = $state({});
|
||||||
|
let editingItemId = $state<string | null>(null);
|
||||||
|
let editingContent = $state("");
|
||||||
|
let showNewChecklist = $state(false);
|
||||||
|
let newChecklistTitle = $state("");
|
||||||
|
let renamingId = $state<string | null>(null);
|
||||||
|
let renamingTitle = $state("");
|
||||||
|
|
||||||
|
function handleAddItem(checklistId: string) {
|
||||||
|
const content = (newItemContent[checklistId] ?? "").trim();
|
||||||
|
if (!content) return;
|
||||||
|
onAddItem(checklistId, content);
|
||||||
|
newItemContent[checklistId] = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(itemId: string, content: string) {
|
||||||
|
editingItemId = itemId;
|
||||||
|
editingContent = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmEdit() {
|
||||||
|
if (editingItemId && editingContent.trim()) {
|
||||||
|
onUpdateItem(editingItemId, editingContent.trim());
|
||||||
|
}
|
||||||
|
editingItemId = null;
|
||||||
|
editingContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRename(checklistId: string, title: string) {
|
||||||
|
renamingId = checklistId;
|
||||||
|
renamingTitle = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRename() {
|
||||||
|
if (renamingId && renamingTitle.trim()) {
|
||||||
|
onRenameChecklist(renamingId, renamingTitle.trim());
|
||||||
|
}
|
||||||
|
renamingId = null;
|
||||||
|
renamingTitle = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateChecklist() {
|
||||||
|
if (!newChecklistTitle.trim()) return;
|
||||||
|
onAddChecklist(newChecklistTitle.trim());
|
||||||
|
newChecklistTitle = "";
|
||||||
|
showNewChecklist = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function completionPercent(cl: ChecklistWithItems): number {
|
||||||
|
if (cl.items.length === 0) return 0;
|
||||||
|
return Math.round(
|
||||||
|
(cl.items.filter((i) => i.is_completed).length / cl.items.length) *
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4 {fullscreen ? 'max-w-2xl mx-auto' : ''}">
|
||||||
|
{#each checklists as cl (cl.id)}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<!-- Checklist header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
{#if renamingId === cl.id}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={renamingTitle}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter") confirmRename();
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
renamingId = null;
|
||||||
|
renamingTitle = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onblur={confirmRename}
|
||||||
|
class="bg-transparent text-body-sm font-heading text-white border-b border-primary outline-none px-0 py-0.5"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h3 class="text-body-sm font-heading text-white">
|
||||||
|
{cl.title}
|
||||||
|
</h3>
|
||||||
|
<span class="text-[11px] text-light/30">
|
||||||
|
{cl.items.filter((i) => i.is_completed).length}/{cl
|
||||||
|
.items.length}
|
||||||
|
</span>
|
||||||
|
{#if cl.items.length > 0}
|
||||||
|
<div
|
||||||
|
class="w-16 h-1 rounded-full bg-light/10 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full bg-emerald-400 rounded-full transition-all"
|
||||||
|
style="width: {completionPercent(cl)}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if isEditor}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
class="p-0.5 rounded hover:bg-light/10 transition-colors"
|
||||||
|
onclick={() => startRename(cl.id, cl.title)}
|
||||||
|
title="Rename"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-light/30"
|
||||||
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||||
|
>edit</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-0.5 rounded hover:bg-error/10 transition-colors"
|
||||||
|
onclick={() => onDeleteChecklist(cl.id)}
|
||||||
|
title="Delete checklist"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-light/30 hover:text-error"
|
||||||
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||||
|
>delete</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items -->
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
{#each cl.items as item (item.id)}
|
||||||
|
<div
|
||||||
|
class="group flex items-start gap-2 px-2 py-1.5 rounded-lg hover:bg-light/5 transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.is_completed}
|
||||||
|
onchange={() =>
|
||||||
|
onToggleItem(item.id, !item.is_completed)}
|
||||||
|
class="mt-0.5 w-4 h-4 rounded border-light/20 text-primary accent-primary cursor-pointer"
|
||||||
|
/>
|
||||||
|
{#if editingItemId === item.id}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editingContent}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter") confirmEdit();
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
editingItemId = null;
|
||||||
|
editingContent = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onblur={confirmEdit}
|
||||||
|
class="flex-1 bg-transparent text-body-sm text-light border-b border-primary outline-none"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="flex-1 text-left text-body-sm {item.is_completed
|
||||||
|
? 'text-light/30 line-through'
|
||||||
|
: 'text-light'}"
|
||||||
|
ondblclick={() =>
|
||||||
|
isEditor &&
|
||||||
|
startEdit(item.id, item.content)}
|
||||||
|
>
|
||||||
|
{item.content}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if isEditor}
|
||||||
|
<button
|
||||||
|
class="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all"
|
||||||
|
onclick={() => onDeleteItem(item.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-light/30 hover:text-error"
|
||||||
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||||
|
>close</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add item input -->
|
||||||
|
{#if isEditor}
|
||||||
|
<div class="flex items-center gap-2 px-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add item..."
|
||||||
|
bind:value={newItemContent[cl.id]}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter") handleAddItem(cl.id);
|
||||||
|
}}
|
||||||
|
class="flex-1 bg-transparent text-body-sm text-light placeholder:text-light/20 border-b border-light/10 focus:border-primary outline-none py-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="p-1 rounded-lg hover:bg-light/10 transition-colors"
|
||||||
|
onclick={() => handleAddItem(cl.id)}
|
||||||
|
disabled={!(newItemContent[cl.id] ?? "").trim()}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-light/40"
|
||||||
|
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||||
|
>add</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Add checklist -->
|
||||||
|
{#if isEditor}
|
||||||
|
{#if showNewChecklist}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Checklist name..."
|
||||||
|
bind:value={newChecklistTitle}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter") handleCreateChecklist();
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
showNewChecklist = false;
|
||||||
|
newChecklistTitle = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="flex-1 bg-transparent text-body-sm text-light placeholder:text-light/20 border-b border-primary outline-none py-1"
|
||||||
|
/>
|
||||||
|
<Button size="sm" onclick={handleCreateChecklist}>Create</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1.5 text-body-sm text-light/30 hover:text-light/60 transition-colors"
|
||||||
|
onclick={() => (showNewChecklist = true)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||||
|
>add</span
|
||||||
|
>
|
||||||
|
Add checklist
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if checklists.length === 0}
|
||||||
|
<p class="text-body-sm text-light/30 text-center py-4">
|
||||||
|
No checklists yet
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
547
src/lib/components/modules/ContactsWidget.svelte
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { DepartmentContact } from "$lib/supabase/types";
|
||||||
|
import {
|
||||||
|
CONTACT_CATEGORIES,
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
CATEGORY_ICONS,
|
||||||
|
} from "$lib/api/contacts";
|
||||||
|
import { Button, Modal } from "$lib/components/ui";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
contacts: DepartmentContact[];
|
||||||
|
isEditor: boolean;
|
||||||
|
fullscreen?: boolean;
|
||||||
|
onCreate: (params: {
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
company?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
website?: string;
|
||||||
|
notes?: string;
|
||||||
|
category?: string;
|
||||||
|
color?: string;
|
||||||
|
}) => void;
|
||||||
|
onUpdate: (
|
||||||
|
contactId: string,
|
||||||
|
params: Partial<
|
||||||
|
Pick<
|
||||||
|
DepartmentContact,
|
||||||
|
| "name"
|
||||||
|
| "role"
|
||||||
|
| "company"
|
||||||
|
| "email"
|
||||||
|
| "phone"
|
||||||
|
| "website"
|
||||||
|
| "notes"
|
||||||
|
| "category"
|
||||||
|
| "color"
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
) => void;
|
||||||
|
onDelete: (contactId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
contacts,
|
||||||
|
isEditor,
|
||||||
|
fullscreen = false,
|
||||||
|
onCreate,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Filter
|
||||||
|
let filterCategory = $state<string>("all");
|
||||||
|
let searchQuery = $state("");
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
let showContactModal = $state(false);
|
||||||
|
let editingContact = $state<DepartmentContact | null>(null);
|
||||||
|
let contactName = $state("");
|
||||||
|
let contactRole = $state("");
|
||||||
|
let contactCompany = $state("");
|
||||||
|
let contactEmail = $state("");
|
||||||
|
let contactPhone = $state("");
|
||||||
|
let contactWebsite = $state("");
|
||||||
|
let contactNotes = $state("");
|
||||||
|
let contactCategory = $state("general");
|
||||||
|
|
||||||
|
// Expanded contact detail
|
||||||
|
let expandedId = $state<string | null>(null);
|
||||||
|
|
||||||
|
const filteredContacts = $derived.by(() => {
|
||||||
|
let result = contacts;
|
||||||
|
if (filterCategory !== "all") {
|
||||||
|
result = result.filter((c) => c.category === filterCategory);
|
||||||
|
}
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
result = result.filter(
|
||||||
|
(c) =>
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
(c.company ?? "").toLowerCase().includes(q) ||
|
||||||
|
(c.role ?? "").toLowerCase().includes(q) ||
|
||||||
|
(c.email ?? "").toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Categories that have contacts
|
||||||
|
const usedCategories = $derived(
|
||||||
|
[...new Set(contacts.map((c) => c.category))].sort(),
|
||||||
|
);
|
||||||
|
|
||||||
|
function openContactModal(contact?: DepartmentContact) {
|
||||||
|
if (contact) {
|
||||||
|
editingContact = contact;
|
||||||
|
contactName = contact.name;
|
||||||
|
contactRole = contact.role ?? "";
|
||||||
|
contactCompany = contact.company ?? "";
|
||||||
|
contactEmail = contact.email ?? "";
|
||||||
|
contactPhone = contact.phone ?? "";
|
||||||
|
contactWebsite = contact.website ?? "";
|
||||||
|
contactNotes = contact.notes ?? "";
|
||||||
|
contactCategory = contact.category;
|
||||||
|
} else {
|
||||||
|
editingContact = null;
|
||||||
|
contactName = "";
|
||||||
|
contactRole = "";
|
||||||
|
contactCompany = "";
|
||||||
|
contactEmail = "";
|
||||||
|
contactPhone = "";
|
||||||
|
contactWebsite = "";
|
||||||
|
contactNotes = "";
|
||||||
|
contactCategory = "general";
|
||||||
|
}
|
||||||
|
showContactModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveContact() {
|
||||||
|
if (!contactName.trim()) return;
|
||||||
|
const params = {
|
||||||
|
name: contactName.trim(),
|
||||||
|
role: contactRole.trim() || undefined,
|
||||||
|
company: contactCompany.trim() || undefined,
|
||||||
|
email: contactEmail.trim() || undefined,
|
||||||
|
phone: contactPhone.trim() || undefined,
|
||||||
|
website: contactWebsite.trim() || undefined,
|
||||||
|
notes: contactNotes.trim() || undefined,
|
||||||
|
category: contactCategory,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingContact) {
|
||||||
|
onUpdate(editingContact.id, params);
|
||||||
|
} else {
|
||||||
|
onCreate(params);
|
||||||
|
}
|
||||||
|
showContactModal = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 {fullscreen
|
||||||
|
? 'max-w-3xl mx-auto'
|
||||||
|
: ''} h-full"
|
||||||
|
>
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded absolute left-2 top-1/2 -translate-y-1/2 text-light/30"
|
||||||
|
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||||
|
>search</span
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
placeholder="Search contacts..."
|
||||||
|
class="w-full pl-8 pr-3 py-1.5 bg-dark/50 border border-light/10 rounded-lg text-[12px] text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category filter -->
|
||||||
|
<select
|
||||||
|
bind:value={filterCategory}
|
||||||
|
class="bg-dark/50 border border-light/10 rounded-lg px-2 py-1.5 text-[12px] text-white focus:outline-none focus:border-primary"
|
||||||
|
>
|
||||||
|
<option value="all">All</option>
|
||||||
|
{#each CONTACT_CATEGORIES as cat}
|
||||||
|
<option value={cat}>{CATEGORY_LABELS[cat] ?? cat}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{#if isEditor}
|
||||||
|
<Button size="sm" onclick={() => openContactModal()}>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||||
|
>add</span
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact list -->
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
{#if filteredContacts.length === 0}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center h-full gap-2 text-light/30"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 36px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 36;"
|
||||||
|
>contacts</span
|
||||||
|
>
|
||||||
|
<p class="text-body-sm">
|
||||||
|
{contacts.length === 0
|
||||||
|
? "No contacts yet"
|
||||||
|
: "No matches found"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{#each filteredContacts as contact (contact.id)}
|
||||||
|
<div class="rounded-xl border border-light/5 overflow-hidden">
|
||||||
|
<!-- Contact row -->
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-light/5 transition-colors text-left group"
|
||||||
|
onclick={() =>
|
||||||
|
(expandedId =
|
||||||
|
expandedId === contact.id
|
||||||
|
? null
|
||||||
|
: contact.id)}
|
||||||
|
>
|
||||||
|
<!-- Category icon -->
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
|
||||||
|
style="background-color: {contact.color ??
|
||||||
|
'#00A3E0'}20"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 16px; color: {contact.color ??
|
||||||
|
'#00A3E0'}; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||||
|
>
|
||||||
|
{CATEGORY_ICONS[contact.category] ??
|
||||||
|
"person"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name & company -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
class="text-body-sm text-white font-medium truncate block"
|
||||||
|
>{contact.name}</span
|
||||||
|
>
|
||||||
|
{#if contact.company || contact.role}
|
||||||
|
<span
|
||||||
|
class="text-[11px] text-light/40 truncate block"
|
||||||
|
>
|
||||||
|
{[contact.role, contact.company]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick actions -->
|
||||||
|
{#if contact.email}
|
||||||
|
<a
|
||||||
|
href="mailto:{contact.email}"
|
||||||
|
class="p-1 rounded-lg hover:bg-light/10 transition-colors shrink-0"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
title={contact.email}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-light/30 hover:text-primary"
|
||||||
|
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||||
|
>mail</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if contact.phone}
|
||||||
|
<a
|
||||||
|
href="tel:{contact.phone}"
|
||||||
|
class="p-1 rounded-lg hover:bg-light/10 transition-colors shrink-0"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
title={contact.phone}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-light/30 hover:text-primary"
|
||||||
|
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||||
|
>phone</span
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Category badge -->
|
||||||
|
<span
|
||||||
|
class="text-[10px] px-1.5 py-0.5 rounded bg-light/5 text-light/30 shrink-0"
|
||||||
|
>
|
||||||
|
{CATEGORY_LABELS[contact.category] ??
|
||||||
|
contact.category}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Expanded detail -->
|
||||||
|
{#if expandedId === contact.id}
|
||||||
|
<div
|
||||||
|
class="px-3 pb-3 pt-1 border-t border-light/5 bg-dark/30"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-2 gap-2 text-[11px]"
|
||||||
|
>
|
||||||
|
{#if contact.email}
|
||||||
|
<div>
|
||||||
|
<span class="text-light/30"
|
||||||
|
>Email</span
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="mailto:{contact.email}"
|
||||||
|
class="block text-primary hover:underline truncate"
|
||||||
|
>{contact.email}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if contact.phone}
|
||||||
|
<div>
|
||||||
|
<span class="text-light/30"
|
||||||
|
>Phone</span
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="tel:{contact.phone}"
|
||||||
|
class="block text-primary hover:underline"
|
||||||
|
>{contact.phone}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if contact.website}
|
||||||
|
<div>
|
||||||
|
<span class="text-light/30"
|
||||||
|
>Website</span
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={contact.website.startsWith(
|
||||||
|
"http",
|
||||||
|
)
|
||||||
|
? contact.website
|
||||||
|
: `https://${contact.website}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="block text-primary hover:underline truncate"
|
||||||
|
>{contact.website}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if contact.role}
|
||||||
|
<div>
|
||||||
|
<span class="text-light/30"
|
||||||
|
>Role</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="block text-light/60"
|
||||||
|
>{contact.role}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if contact.notes}
|
||||||
|
<div class="mt-2 text-[11px]">
|
||||||
|
<span class="text-light/30"
|
||||||
|
>Notes</span
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="text-light/60 whitespace-pre-wrap"
|
||||||
|
>
|
||||||
|
{contact.notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if isEditor}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 mt-3 pt-2 border-t border-light/5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] text-light/40 hover:text-white hover:bg-light/10 transition-colors"
|
||||||
|
onclick={() =>
|
||||||
|
openContactModal(contact)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||||
|
>edit</span
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] text-light/40 hover:text-error hover:bg-error/10 transition-colors"
|
||||||
|
onclick={() =>
|
||||||
|
onDelete(contact.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||||
|
>delete</span
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Modal -->
|
||||||
|
<Modal
|
||||||
|
isOpen={showContactModal}
|
||||||
|
onClose={() => (showContactModal = false)}
|
||||||
|
title={editingContact ? "Edit Contact" : "Add Contact"}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="contact-name"
|
||||||
|
class="text-body-sm text-light/60 font-body">Name *</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="contact-name"
|
||||||
|
type="text"
|
||||||
|
bind:value={contactName}
|
||||||
|
placeholder="Full name"
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="contact-company"
|
||||||
|
class="text-body-sm text-light/60 font-body">Company</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="contact-company"
|
||||||
|
type="text"
|
||||||
|
bind:value={contactCompany}
|
||||||
|
placeholder="Company name"
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="contact-role"
|
||||||
|
class="text-body-sm text-light/60 font-body">Role</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="contact-role"
|
||||||
|
type="text"
|
||||||
|
bind:value={contactRole}
|
||||||
|
placeholder="e.g. Account Manager"
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="contact-category"
|
||||||
|
class="text-body-sm text-light/60 font-body">Category</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="contact-category"
|
||||||
|
bind:value={contactCategory}
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
|
||||||
|
>
|
||||||
|
{#each CONTACT_CATEGORIES as cat}
|
||||||
|
<option value={cat}
|
||||||
|
>{CATEGORY_LABELS[cat] ?? cat}</option
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="contact-email"
|
||||||
|
class="text-body-sm text-light/60 font-body">Email</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="contact-email"
|
||||||
|
type="email"
|
||||||
|
bind:value={contactEmail}
|
||||||
|
placeholder="email@example.com"
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="contact-phone"
|
||||||
|
class="text-body-sm text-light/60 font-body">Phone</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="contact-phone"
|
||||||
|
type="tel"
|
||||||
|
bind:value={contactPhone}
|
||||||
|
placeholder="+372 ..."
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="contact-website"
|
||||||
|
class="text-body-sm text-light/60 font-body">Website</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="contact-website"
|
||||||
|
type="url"
|
||||||
|
bind:value={contactWebsite}
|
||||||
|
placeholder="https://..."
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="contact-notes"
|
||||||
|
class="text-body-sm text-light/60 font-body">Notes</label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
id="contact-notes"
|
||||||
|
bind:value={contactNotes}
|
||||||
|
placeholder="Additional notes..."
|
||||||
|
rows="2"
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary resize-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
|
||||||
|
onclick={() => (showContactModal = false)}>Cancel</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!contactName.trim()}
|
||||||
|
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
onclick={handleSaveContact}
|
||||||
|
>
|
||||||
|
{editingContact ? "Save" : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
32
src/lib/components/modules/FilesWidget.svelte
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
departmentId: string;
|
||||||
|
orgSlug: string;
|
||||||
|
fullscreen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { departmentId, orgSlug, fullscreen = false }: Props = $props();
|
||||||
|
|
||||||
|
// Files module links to the org documents page
|
||||||
|
// In the future, this could be scoped to a department subfolder
|
||||||
|
const filesPath = $derived(`/${orgSlug}/documents`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center h-full gap-3 {fullscreen ? 'py-12' : 'py-6'}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-light/20"
|
||||||
|
style="font-size: 36px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 36;"
|
||||||
|
>folder</span
|
||||||
|
>
|
||||||
|
<p class="text-body-sm text-light/40">Department files and documents</p>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded-xl bg-primary/10 text-primary text-body-sm font-heading hover:bg-primary/20 transition-colors"
|
||||||
|
onclick={() => goto(filesPath)}
|
||||||
|
>
|
||||||
|
Open Files
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
37
src/lib/components/modules/KanbanWidget.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
departmentId: string;
|
||||||
|
eventId: string;
|
||||||
|
fullscreen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { departmentId, eventId, fullscreen = false }: Props = $props();
|
||||||
|
|
||||||
|
// Kanban is already built as a full page — link to the event tasks page
|
||||||
|
const tasksPath = $derived(() => {
|
||||||
|
const base = $page.url.pathname.split("/dept/")[0];
|
||||||
|
return `${base}/tasks`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center h-full gap-3 {fullscreen ? 'py-12' : 'py-6'}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-light/20"
|
||||||
|
style="font-size: 36px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 36;"
|
||||||
|
>view_kanban</span
|
||||||
|
>
|
||||||
|
<p class="text-body-sm text-light/40">
|
||||||
|
Task board for this department
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded-xl bg-primary/10 text-primary text-body-sm font-heading hover:bg-primary/20 transition-colors"
|
||||||
|
onclick={() => goto(tasksPath())}
|
||||||
|
>
|
||||||
|
Open Tasks Board
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
178
src/lib/components/modules/NotesWidget.svelte
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { DepartmentNote } from "$lib/supabase/types";
|
||||||
|
import { Button } from "$lib/components/ui";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
notes: DepartmentNote[];
|
||||||
|
isEditor: boolean;
|
||||||
|
fullscreen?: boolean;
|
||||||
|
onCreate: (title: string) => void;
|
||||||
|
onUpdate: (noteId: string, params: { title?: string; content?: string }) => void;
|
||||||
|
onDelete: (noteId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
notes,
|
||||||
|
isEditor,
|
||||||
|
fullscreen = false,
|
||||||
|
onCreate,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let selectedNoteId = $state<string | null>(notes.length > 0 ? notes[0].id : null);
|
||||||
|
let editingTitle = $state(false);
|
||||||
|
let titleInput = $state("");
|
||||||
|
let showNewNote = $state(false);
|
||||||
|
let newNoteTitle = $state("");
|
||||||
|
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const selectedNote = $derived(notes.find((n) => n.id === selectedNoteId) ?? null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (notes.length > 0 && !notes.find((n) => n.id === selectedNoteId)) {
|
||||||
|
selectedNoteId = notes[0].id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleContentChange(e: Event) {
|
||||||
|
const target = e.target as HTMLTextAreaElement;
|
||||||
|
if (!selectedNoteId) return;
|
||||||
|
|
||||||
|
if (saveTimeout) clearTimeout(saveTimeout);
|
||||||
|
saveTimeout = setTimeout(() => {
|
||||||
|
onUpdate(selectedNoteId!, { content: target.value });
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTitleEdit() {
|
||||||
|
if (!selectedNote || !isEditor) return;
|
||||||
|
editingTitle = true;
|
||||||
|
titleInput = selectedNote.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmTitleEdit() {
|
||||||
|
if (selectedNoteId && titleInput.trim()) {
|
||||||
|
onUpdate(selectedNoteId, { title: titleInput.trim() });
|
||||||
|
}
|
||||||
|
editingTitle = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateNote() {
|
||||||
|
if (!newNoteTitle.trim()) return;
|
||||||
|
onCreate(newNoteTitle.trim());
|
||||||
|
newNoteTitle = "";
|
||||||
|
showNewNote = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex {fullscreen ? 'h-full' : 'h-full min-h-[200px]'} gap-0">
|
||||||
|
<!-- Note list sidebar -->
|
||||||
|
<div
|
||||||
|
class="w-40 shrink-0 border-r border-light/5 flex flex-col {fullscreen ? 'w-56' : ''}"
|
||||||
|
>
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
{#each notes as note (note.id)}
|
||||||
|
<button
|
||||||
|
class="w-full text-left px-3 py-2 text-body-sm transition-colors truncate {selectedNoteId ===
|
||||||
|
note.id
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'text-light/60 hover:text-white hover:bg-light/5'}"
|
||||||
|
onclick={() => (selectedNoteId = note.id)}
|
||||||
|
>
|
||||||
|
{note.title}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if isEditor}
|
||||||
|
{#if showNewNote}
|
||||||
|
<div class="p-2 border-t border-light/5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Note title..."
|
||||||
|
bind:value={newNoteTitle}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter") handleCreateNote();
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
showNewNote = false;
|
||||||
|
newNoteTitle = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="w-full bg-transparent text-body-sm text-light placeholder:text-light/20 border-b border-primary outline-none py-1 px-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 px-3 py-2 border-t border-light/5 text-body-sm text-light/30 hover:text-light/60 transition-colors"
|
||||||
|
onclick={() => (showNewNote = true)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||||
|
>add</span
|
||||||
|
>
|
||||||
|
New note
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note content -->
|
||||||
|
<div class="flex-1 flex flex-col">
|
||||||
|
{#if selectedNote}
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="px-4 py-2 border-b border-light/5 flex items-center justify-between">
|
||||||
|
{#if editingTitle}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={titleInput}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter") confirmTitleEdit();
|
||||||
|
if (e.key === "Escape") (editingTitle = false);
|
||||||
|
}}
|
||||||
|
onblur={confirmTitleEdit}
|
||||||
|
class="bg-transparent text-body font-heading text-white border-b border-primary outline-none flex-1"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="text-body font-heading text-white text-left flex-1"
|
||||||
|
ondblclick={startTitleEdit}
|
||||||
|
>
|
||||||
|
{selectedNote.title}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if isEditor}
|
||||||
|
<button
|
||||||
|
class="p-1 rounded-lg hover:bg-error/10 transition-colors"
|
||||||
|
onclick={() => {
|
||||||
|
if (selectedNoteId) onDelete(selectedNoteId);
|
||||||
|
}}
|
||||||
|
title="Delete note"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-light/30 hover:text-error"
|
||||||
|
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||||
|
>delete</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<textarea
|
||||||
|
class="flex-1 w-full bg-transparent text-body-sm text-light p-4 outline-none resize-none placeholder:text-light/20 {fullscreen ? 'text-body' : ''}"
|
||||||
|
placeholder="Start writing..."
|
||||||
|
value={selectedNote.content ?? ""}
|
||||||
|
oninput={handleContentChange}
|
||||||
|
disabled={!isEditor}
|
||||||
|
></textarea>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center h-full text-light/30 text-body-sm"
|
||||||
|
>
|
||||||
|
{notes.length === 0 ? "No notes yet" : "Select a note"}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
660
src/lib/components/modules/ScheduleWidget.svelte
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ScheduleStage, ScheduleBlock } from "$lib/supabase/types";
|
||||||
|
import { Button, Modal } from "$lib/components/ui";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
stages: ScheduleStage[];
|
||||||
|
blocks: ScheduleBlock[];
|
||||||
|
isEditor: boolean;
|
||||||
|
fullscreen?: boolean;
|
||||||
|
onCreateStage: (name: string, color: string) => void;
|
||||||
|
onDeleteStage: (stageId: string) => void;
|
||||||
|
onCreateBlock: (params: {
|
||||||
|
title: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
stage_id?: string | null;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
speaker?: string;
|
||||||
|
}) => void;
|
||||||
|
onUpdateBlock: (
|
||||||
|
blockId: string,
|
||||||
|
params: Partial<
|
||||||
|
Pick<
|
||||||
|
ScheduleBlock,
|
||||||
|
| "title"
|
||||||
|
| "description"
|
||||||
|
| "start_time"
|
||||||
|
| "end_time"
|
||||||
|
| "stage_id"
|
||||||
|
| "color"
|
||||||
|
| "speaker"
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
) => void;
|
||||||
|
onDeleteBlock: (blockId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
stages,
|
||||||
|
blocks,
|
||||||
|
isEditor,
|
||||||
|
fullscreen = false,
|
||||||
|
onCreateStage,
|
||||||
|
onDeleteStage,
|
||||||
|
onCreateBlock,
|
||||||
|
onUpdateBlock,
|
||||||
|
onDeleteBlock,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// View mode: timeline or list
|
||||||
|
let viewMode = $state<"timeline" | "list">("timeline");
|
||||||
|
|
||||||
|
// Add block modal
|
||||||
|
let showBlockModal = $state(false);
|
||||||
|
let editingBlock = $state<ScheduleBlock | null>(null);
|
||||||
|
let blockTitle = $state("");
|
||||||
|
let blockDescription = $state("");
|
||||||
|
let blockDate = $state("");
|
||||||
|
let blockStartTime = $state("09:00");
|
||||||
|
let blockEndTime = $state("10:00");
|
||||||
|
let blockStageId = $state<string | null>(null);
|
||||||
|
let blockColor = $state("#6366f1");
|
||||||
|
let blockSpeaker = $state("");
|
||||||
|
|
||||||
|
// Add stage modal
|
||||||
|
let showStageModal = $state(false);
|
||||||
|
let stageName = $state("");
|
||||||
|
let stageColor = $state("#6366f1");
|
||||||
|
|
||||||
|
const PRESET_COLORS = [
|
||||||
|
"#6366f1",
|
||||||
|
"#EC4899",
|
||||||
|
"#10B981",
|
||||||
|
"#F59E0B",
|
||||||
|
"#00A3E0",
|
||||||
|
"#EF4444",
|
||||||
|
"#8B5CF6",
|
||||||
|
"#14B8A6",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Group blocks by date
|
||||||
|
const blocksByDate = $derived.by(() => {
|
||||||
|
const groups: Record<string, ScheduleBlock[]> = {};
|
||||||
|
for (const block of blocks) {
|
||||||
|
const date = new Date(block.start_time).toLocaleDateString("en-CA");
|
||||||
|
if (!groups[date]) groups[date] = [];
|
||||||
|
groups[date].push(block);
|
||||||
|
}
|
||||||
|
// Sort dates
|
||||||
|
const sorted: [string, ScheduleBlock[]][] = Object.entries(groups).sort(
|
||||||
|
([a], [b]) => a.localeCompare(b),
|
||||||
|
);
|
||||||
|
return sorted;
|
||||||
|
});
|
||||||
|
|
||||||
|
function openBlockModal(block?: ScheduleBlock) {
|
||||||
|
if (block) {
|
||||||
|
editingBlock = block;
|
||||||
|
blockTitle = block.title;
|
||||||
|
blockDescription = block.description ?? "";
|
||||||
|
const start = new Date(block.start_time);
|
||||||
|
blockDate = start.toLocaleDateString("en-CA");
|
||||||
|
blockStartTime = start.toTimeString().slice(0, 5);
|
||||||
|
const end = new Date(block.end_time);
|
||||||
|
blockEndTime = end.toTimeString().slice(0, 5);
|
||||||
|
blockStageId = block.stage_id;
|
||||||
|
blockColor = block.color ?? "#6366f1";
|
||||||
|
blockSpeaker = block.speaker ?? "";
|
||||||
|
} else {
|
||||||
|
editingBlock = null;
|
||||||
|
blockTitle = "";
|
||||||
|
blockDescription = "";
|
||||||
|
blockDate = new Date().toLocaleDateString("en-CA");
|
||||||
|
blockStartTime = "09:00";
|
||||||
|
blockEndTime = "10:00";
|
||||||
|
blockStageId = null;
|
||||||
|
blockColor = "#6366f1";
|
||||||
|
blockSpeaker = "";
|
||||||
|
}
|
||||||
|
showBlockModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveBlock() {
|
||||||
|
if (!blockTitle.trim() || !blockDate) return;
|
||||||
|
const start_time = new Date(
|
||||||
|
`${blockDate}T${blockStartTime}:00`,
|
||||||
|
).toISOString();
|
||||||
|
const end_time = new Date(
|
||||||
|
`${blockDate}T${blockEndTime}:00`,
|
||||||
|
).toISOString();
|
||||||
|
|
||||||
|
if (editingBlock) {
|
||||||
|
onUpdateBlock(editingBlock.id, {
|
||||||
|
title: blockTitle.trim(),
|
||||||
|
description: blockDescription.trim() || null,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
stage_id: blockStageId,
|
||||||
|
color: blockColor,
|
||||||
|
speaker: blockSpeaker.trim() || null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onCreateBlock({
|
||||||
|
title: blockTitle.trim(),
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
stage_id: blockStageId,
|
||||||
|
description: blockDescription.trim() || undefined,
|
||||||
|
color: blockColor,
|
||||||
|
speaker: blockSpeaker.trim() || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showBlockModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateStage() {
|
||||||
|
if (!stageName.trim()) return;
|
||||||
|
onCreateStage(stageName.trim(), stageColor);
|
||||||
|
stageName = "";
|
||||||
|
stageColor = "#6366f1";
|
||||||
|
showStageModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLabel(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr + "T00:00:00");
|
||||||
|
return d.toLocaleDateString(undefined, {
|
||||||
|
weekday: "long",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function durationMinutes(block: ScheduleBlock): number {
|
||||||
|
return (
|
||||||
|
(new Date(block.end_time).getTime() -
|
||||||
|
new Date(block.start_time).getTime()) /
|
||||||
|
60000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageName_for(stageId: string | null): string {
|
||||||
|
if (!stageId) return "";
|
||||||
|
return stages.find((s) => s.id === stageId)?.name ?? "";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-3 {fullscreen
|
||||||
|
? 'max-w-3xl mx-auto'
|
||||||
|
: ''} h-full"
|
||||||
|
>
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="flex items-center justify-between gap-2 shrink-0">
|
||||||
|
<div class="flex items-center gap-1 bg-dark/50 rounded-lg p-0.5">
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 rounded text-[11px] transition-colors {viewMode ===
|
||||||
|
'timeline'
|
||||||
|
? 'bg-primary text-background'
|
||||||
|
: 'text-light/40 hover:text-light/70'}"
|
||||||
|
onclick={() => (viewMode = "timeline")}
|
||||||
|
>
|
||||||
|
Timeline
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="px-2 py-1 rounded text-[11px] transition-colors {viewMode ===
|
||||||
|
'list'
|
||||||
|
? 'bg-primary text-background'
|
||||||
|
: 'text-light/40 hover:text-light/70'}"
|
||||||
|
onclick={() => (viewMode = "list")}
|
||||||
|
>
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if isEditor}
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] text-light/40 hover:text-light/70 hover:bg-light/5 transition-colors"
|
||||||
|
onclick={() => (showStageModal = true)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||||
|
>add</span
|
||||||
|
>
|
||||||
|
Stage
|
||||||
|
</button>
|
||||||
|
<Button size="sm" onclick={() => openBlockModal()}>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||||
|
>add</span
|
||||||
|
>
|
||||||
|
Block
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stages bar -->
|
||||||
|
{#if stages.length > 0}
|
||||||
|
<div class="flex items-center gap-2 flex-wrap shrink-0">
|
||||||
|
{#each stages as stage (stage.id)}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-dark/50 border border-light/5 group"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style="background-color: {stage.color}"
|
||||||
|
></div>
|
||||||
|
<span class="text-[11px] text-light/60">{stage.name}</span>
|
||||||
|
{#if isEditor}
|
||||||
|
<button
|
||||||
|
class="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all"
|
||||||
|
onclick={() => onDeleteStage(stage.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-light/30 hover:text-error"
|
||||||
|
style="font-size: 12px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 12;"
|
||||||
|
>close</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
{#if blocks.length === 0}
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center h-full gap-2 text-light/30"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 36px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 36;"
|
||||||
|
>calendar_today</span
|
||||||
|
>
|
||||||
|
<p class="text-body-sm">No schedule blocks yet</p>
|
||||||
|
</div>
|
||||||
|
{:else if viewMode === "timeline"}
|
||||||
|
<!-- Timeline view: grouped by date -->
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
{#each blocksByDate as [date, dayBlocks] (date)}
|
||||||
|
<div>
|
||||||
|
<h3
|
||||||
|
class="text-body-sm font-heading text-light/50 mb-3 sticky top-0 bg-surface/80 backdrop-blur-sm py-1 z-10"
|
||||||
|
>
|
||||||
|
{formatDateLabel(date)}
|
||||||
|
</h3>
|
||||||
|
<div class="flex flex-col gap-1 relative ml-3">
|
||||||
|
<!-- Timeline line -->
|
||||||
|
<div
|
||||||
|
class="absolute left-0 top-2 bottom-2 w-px bg-light/10"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{#each dayBlocks as block (block.id)}
|
||||||
|
{@const mins = durationMinutes(block)}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="relative pl-6 py-1.5 group {isEditor
|
||||||
|
? 'cursor-pointer hover:bg-light/5 rounded-lg'
|
||||||
|
: ''}"
|
||||||
|
onclick={() =>
|
||||||
|
isEditor && openBlockModal(block)}
|
||||||
|
>
|
||||||
|
<!-- Dot on timeline -->
|
||||||
|
<div
|
||||||
|
class="absolute left-[-3px] top-3 w-[7px] h-[7px] rounded-full border-2 border-surface"
|
||||||
|
style="background-color: {block.color ??
|
||||||
|
'#6366f1'}"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<div class="shrink-0 w-24">
|
||||||
|
<span
|
||||||
|
class="text-[11px] text-light/40 font-mono"
|
||||||
|
>
|
||||||
|
{formatTime(block.start_time)} – {formatTime(
|
||||||
|
block.end_time,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="block text-[10px] text-light/20"
|
||||||
|
>{mins}min</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-1 h-4 rounded-full shrink-0"
|
||||||
|
style="background-color: {block.color ??
|
||||||
|
'#6366f1'}"
|
||||||
|
></div>
|
||||||
|
<span
|
||||||
|
class="text-body-sm text-white font-medium truncate"
|
||||||
|
>{block.title}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if block.speaker}
|
||||||
|
<span
|
||||||
|
class="text-[11px] text-light/40 ml-3"
|
||||||
|
>{block.speaker}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if block.stage_id}
|
||||||
|
<span
|
||||||
|
class="text-[10px] text-light/30 ml-3"
|
||||||
|
>{stageName_for(
|
||||||
|
block.stage_id,
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if isEditor}
|
||||||
|
<button
|
||||||
|
class="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all shrink-0"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteBlock(block.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-light/30 hover:text-error"
|
||||||
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||||
|
>delete</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- List view: simple table -->
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{#each blocks as block (block.id)}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-light/5 transition-colors group {isEditor
|
||||||
|
? 'cursor-pointer'
|
||||||
|
: ''}"
|
||||||
|
onclick={() => isEditor && openBlockModal(block)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-1.5 h-8 rounded-full shrink-0"
|
||||||
|
style="background-color: {block.color ?? '#6366f1'}"
|
||||||
|
></div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span
|
||||||
|
class="text-body-sm text-white font-medium truncate block"
|
||||||
|
>{block.title}</span
|
||||||
|
>
|
||||||
|
{#if block.speaker}
|
||||||
|
<span class="text-[11px] text-light/40"
|
||||||
|
>{block.speaker}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="text-right shrink-0">
|
||||||
|
<span class="text-[11px] text-light/40 font-mono">
|
||||||
|
{formatTime(block.start_time)} – {formatTime(
|
||||||
|
block.end_time,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span class="block text-[10px] text-light/20">
|
||||||
|
{new Date(
|
||||||
|
block.start_time,
|
||||||
|
).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if block.stage_id}
|
||||||
|
<span
|
||||||
|
class="text-[10px] px-1.5 py-0.5 rounded bg-light/5 text-light/30 shrink-0"
|
||||||
|
>{stageName_for(block.stage_id)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if isEditor}
|
||||||
|
<button
|
||||||
|
class="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all shrink-0"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteBlock(block.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded text-light/30 hover:text-error"
|
||||||
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||||
|
>delete</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Block Modal -->
|
||||||
|
<Modal
|
||||||
|
isOpen={showBlockModal}
|
||||||
|
onClose={() => (showBlockModal = false)}
|
||||||
|
title={editingBlock ? "Edit Block" : "Add Schedule Block"}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="block-title"
|
||||||
|
class="text-body-sm text-light/60 font-body">Title</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="block-title"
|
||||||
|
type="text"
|
||||||
|
bind:value={blockTitle}
|
||||||
|
placeholder="e.g. Opening Ceremony"
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-3">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="block-date"
|
||||||
|
class="text-body-sm text-light/60 font-body">Date</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="block-date"
|
||||||
|
type="date"
|
||||||
|
bind:value={blockDate}
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="block-start"
|
||||||
|
class="text-body-sm text-light/60 font-body">Start</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="block-start"
|
||||||
|
type="time"
|
||||||
|
bind:value={blockStartTime}
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="block-end"
|
||||||
|
class="text-body-sm text-light/60 font-body">End</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="block-end"
|
||||||
|
type="time"
|
||||||
|
bind:value={blockEndTime}
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="block-speaker"
|
||||||
|
class="text-body-sm text-light/60 font-body"
|
||||||
|
>Speaker / Host</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="block-speaker"
|
||||||
|
type="text"
|
||||||
|
bind:value={blockSpeaker}
|
||||||
|
placeholder="Optional"
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if stages.length > 0}
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="block-stage"
|
||||||
|
class="text-body-sm text-light/60 font-body">Stage</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="block-stage"
|
||||||
|
bind:value={blockStageId}
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
|
||||||
|
>
|
||||||
|
<option value={null}>No stage</option>
|
||||||
|
{#each stages as stage (stage.id)}
|
||||||
|
<option value={stage.id}>{stage.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="block-desc"
|
||||||
|
class="text-body-sm text-light/60 font-body"
|
||||||
|
>Description</label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
id="block-desc"
|
||||||
|
bind:value={blockDescription}
|
||||||
|
placeholder="Optional description..."
|
||||||
|
rows="2"
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary resize-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-body-sm text-light/60 font-body">Color</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#each PRESET_COLORS as c}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-6 h-6 rounded-full border-2 transition-all {blockColor ===
|
||||||
|
c
|
||||||
|
? 'border-white scale-110'
|
||||||
|
: 'border-transparent hover:border-light/30'}"
|
||||||
|
style="background-color: {c}"
|
||||||
|
onclick={() => (blockColor = c)}
|
||||||
|
aria-label="Color {c}"
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
|
||||||
|
onclick={() => (showBlockModal = false)}>Cancel</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!blockTitle.trim() || !blockDate}
|
||||||
|
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
onclick={handleSaveBlock}
|
||||||
|
>
|
||||||
|
{editingBlock ? "Save" : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<!-- Stage Modal -->
|
||||||
|
<Modal
|
||||||
|
isOpen={showStageModal}
|
||||||
|
onClose={() => (showStageModal = false)}
|
||||||
|
title="Add Stage"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="stage-name"
|
||||||
|
class="text-body-sm text-light/60 font-body">Name</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="stage-name"
|
||||||
|
type="text"
|
||||||
|
bind:value={stageName}
|
||||||
|
placeholder="e.g. Main Stage, Room A"
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-body-sm text-light/60 font-body">Color</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#each PRESET_COLORS as c}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-6 h-6 rounded-full border-2 transition-all {stageColor ===
|
||||||
|
c
|
||||||
|
? 'border-white scale-110'
|
||||||
|
: 'border-transparent hover:border-light/30'}"
|
||||||
|
style="background-color: {c}"
|
||||||
|
onclick={() => (stageColor = c)}
|
||||||
|
aria-label="Color {c}"
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
|
||||||
|
onclick={() => (showStageModal = false)}>Cancel</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!stageName.trim()}
|
||||||
|
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
onclick={handleCreateStage}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
571
src/lib/components/modules/SponsorsWidget.svelte
Normal file
@@ -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}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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[
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
1092
src/routes/[orgSlug]/events/[eventSlug]/dept/[deptId]/+page.svelte
Normal 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>();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
104
src/routes/admin/+page.server.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
406
src/routes/admin/+page.svelte
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
BIN
static/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
static/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
static/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
26
static/favicon.svg
Normal 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
@@ -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"
|
||||||
|
}
|
||||||
BIN
static/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
static/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
258
supabase/migrations/026_department_dashboards.sql
Normal 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();
|
||||||
11
supabase/migrations/027_remove_default_seeds.sql
Normal 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;
|
||||||
214
supabase/migrations/028_schedule_and_contacts.sql
Normal 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')
|
||||||
|
)
|
||||||
|
);
|
||||||
202
supabase/migrations/029_budget_and_sponsors.sql
Normal 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;
|
||||||
8
supabase/migrations/030_platform_admin.sql
Normal 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
BIN
synapse/data/homeserver.db-shm
Normal file
BIN
synapse/data/homeserver.db-wal
Normal file
40
synapse/data/homeserver.yaml
Normal 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
|
||||||
39
synapse/data/localhost.log.config
Normal 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
|
||||||
1
synapse/data/localhost.signing.key
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ed25519 a_VwZG y2OyVm2VBqYOuqiBYBb0wQ4r8awyVMceG+KIPK4K6HA
|
||||||