Compare commits
2 Commits
edc5f8af85
...
feature/ev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
676468d3ec | ||
|
|
1f2484da3d |
@@ -175,6 +175,14 @@
|
||||
"account_display_name": "Display Name",
|
||||
"account_display_name_placeholder": "Your name",
|
||||
"account_email": "Email",
|
||||
"account_phone": "Phone",
|
||||
"account_phone_placeholder": "+372 ...",
|
||||
"account_discord": "Discord",
|
||||
"account_discord_placeholder": "username",
|
||||
"account_contact_info": "Contact & Sizing",
|
||||
"account_shirt_size": "Shirt Size",
|
||||
"account_hoodie_size": "Hoodie Size",
|
||||
"account_size_placeholder": "Select size",
|
||||
"account_save_profile": "Save Profile",
|
||||
"account_appearance": "Appearance",
|
||||
"account_theme": "Theme",
|
||||
@@ -341,6 +349,30 @@
|
||||
"team_select_member": "Select a member",
|
||||
"team_select_role": "Select role",
|
||||
"team_already_assigned": "Already on team",
|
||||
"team_departments": "Departments",
|
||||
"team_roles": "Roles",
|
||||
"team_all": "All",
|
||||
"team_no_department": "Unassigned",
|
||||
"team_add_department": "Add Department",
|
||||
"team_add_role": "Add Role",
|
||||
"team_edit_department": "Edit Department",
|
||||
"team_edit_role": "Edit Role",
|
||||
"team_dept_name": "Department name",
|
||||
"team_role_name": "Role name",
|
||||
"team_dept_created": "Department created",
|
||||
"team_dept_updated": "Department updated",
|
||||
"team_dept_deleted": "Department deleted",
|
||||
"team_role_created": "Role created",
|
||||
"team_role_updated": "Role updated",
|
||||
"team_role_deleted": "Role deleted",
|
||||
"team_dept_delete_confirm": "Delete department {name}? Members will be unassigned from it.",
|
||||
"team_role_delete_confirm": "Delete role {name}? Members will lose this role assignment.",
|
||||
"team_view_by_dept": "By department",
|
||||
"team_view_list": "List view",
|
||||
"team_member_count": "{count} members",
|
||||
"team_assign_dept": "Assign departments",
|
||||
"team_notes": "Notes",
|
||||
"team_notes_placeholder": "Optional notes about this member...",
|
||||
"overview_subtitle": "Welcome back. Here's what's happening.",
|
||||
"overview_stat_events": "Events",
|
||||
"overview_upcoming_events": "Upcoming Events",
|
||||
|
||||
@@ -175,6 +175,14 @@
|
||||
"account_display_name": "Kuvatav nimi",
|
||||
"account_display_name_placeholder": "Sinu nimi",
|
||||
"account_email": "E-post",
|
||||
"account_phone": "Telefon",
|
||||
"account_phone_placeholder": "+372 ...",
|
||||
"account_discord": "Discord",
|
||||
"account_discord_placeholder": "kasutajanimi",
|
||||
"account_contact_info": "Kontakt ja suurused",
|
||||
"account_shirt_size": "Särgi suurus",
|
||||
"account_hoodie_size": "Pusa suurus",
|
||||
"account_size_placeholder": "Vali suurus",
|
||||
"account_save_profile": "Salvesta profiil",
|
||||
"account_appearance": "Välimus",
|
||||
"account_theme": "Teema",
|
||||
@@ -341,6 +349,30 @@
|
||||
"team_select_member": "Vali liige",
|
||||
"team_select_role": "Vali roll",
|
||||
"team_already_assigned": "Juba meeskonnas",
|
||||
"team_departments": "Osakonnad",
|
||||
"team_roles": "Rollid",
|
||||
"team_all": "Kõik",
|
||||
"team_no_department": "Määramata",
|
||||
"team_add_department": "Lisa osakond",
|
||||
"team_add_role": "Lisa roll",
|
||||
"team_edit_department": "Muuda osakonda",
|
||||
"team_edit_role": "Muuda rolli",
|
||||
"team_dept_name": "Osakonna nimi",
|
||||
"team_role_name": "Rolli nimi",
|
||||
"team_dept_created": "Osakond loodud",
|
||||
"team_dept_updated": "Osakond uuendatud",
|
||||
"team_dept_deleted": "Osakond kustutatud",
|
||||
"team_role_created": "Roll loodud",
|
||||
"team_role_updated": "Roll uuendatud",
|
||||
"team_role_deleted": "Roll kustutatud",
|
||||
"team_dept_delete_confirm": "Kustuta osakond {name}? Liikmed eemaldatakse sellest.",
|
||||
"team_role_delete_confirm": "Kustuta roll {name}? Liikmed kaotavad selle rolli.",
|
||||
"team_view_by_dept": "Osakondade järgi",
|
||||
"team_view_list": "Nimekirja vaade",
|
||||
"team_member_count": "{count} liiget",
|
||||
"team_assign_dept": "Määra osakonnad",
|
||||
"team_notes": "Märkmed",
|
||||
"team_notes_placeholder": "Valikulised märkmed selle liikme kohta...",
|
||||
"overview_subtitle": "Tere tagasi. Siin on ülevaade toimuvast.",
|
||||
"overview_stat_events": "Üritused",
|
||||
"overview_upcoming_events": "Tulevased üritused",
|
||||
|
||||
@@ -27,9 +27,44 @@ export interface EventMember {
|
||||
event_id: string;
|
||||
user_id: string;
|
||||
role: 'lead' | 'manager' | 'member';
|
||||
role_id: string | null;
|
||||
notes: string | null;
|
||||
assigned_at: string;
|
||||
}
|
||||
|
||||
export interface EventRole {
|
||||
id: string;
|
||||
event_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
sort_order: number;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface EventDepartment {
|
||||
id: string;
|
||||
event_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description: string | null;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface EventMemberDepartment {
|
||||
id: string;
|
||||
event_member_id: string;
|
||||
department_id: string;
|
||||
assigned_at: string;
|
||||
}
|
||||
|
||||
export interface EventMemberWithDetails extends EventMember {
|
||||
profile?: { id: string; email: string; full_name: string | null; avatar_url: string | null; phone: string | null; discord_handle: string | null; shirt_size: string | null; hoodie_size: string | null };
|
||||
event_role?: EventRole;
|
||||
departments: EventDepartment[];
|
||||
}
|
||||
|
||||
export interface EventWithCounts extends Event {
|
||||
member_count: number;
|
||||
}
|
||||
@@ -211,7 +246,7 @@ export async function deleteEvent(
|
||||
export async function fetchEventMembers(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string
|
||||
): Promise<(EventMember & { profile?: { id: string; email: string; full_name: string | null; avatar_url: string | null } })[]> {
|
||||
): Promise<EventMemberWithDetails[]> {
|
||||
const { data: members, error } = await supabase
|
||||
.from('event_members')
|
||||
.select('*')
|
||||
@@ -227,16 +262,49 @@ export async function fetchEventMembers(
|
||||
|
||||
// Fetch profiles separately (same pattern as org_members)
|
||||
const userIds = members.map((m: any) => m.user_id);
|
||||
const { data: profiles } = await supabase
|
||||
const { data: profiles } = await (supabase as any)
|
||||
.from('profiles')
|
||||
.select('id, email, full_name, avatar_url')
|
||||
.select('id, email, full_name, avatar_url, phone, discord_handle, shirt_size, hoodie_size')
|
||||
.in('id', userIds);
|
||||
|
||||
const profileMap = Object.fromEntries((profiles ?? []).map(p => [p.id, p]));
|
||||
const profileMap = Object.fromEntries((profiles ?? []).map((p: any) => [p.id, p]));
|
||||
|
||||
// Fetch roles for this event
|
||||
const { data: roles } = await (supabase as any)
|
||||
.from('event_roles')
|
||||
.select('*')
|
||||
.eq('event_id', eventId);
|
||||
const roleMap = Object.fromEntries((roles ?? []).map((r: any) => [r.id, r]));
|
||||
|
||||
// Fetch member-department assignments
|
||||
const memberIds = members.map((m: any) => m.id);
|
||||
const { data: memberDepts } = await (supabase as any)
|
||||
.from('event_member_departments')
|
||||
.select('*')
|
||||
.in('event_member_id', memberIds);
|
||||
|
||||
// Fetch departments for this event
|
||||
const { data: departments } = await (supabase as any)
|
||||
.from('event_departments')
|
||||
.select('*')
|
||||
.eq('event_id', eventId);
|
||||
const deptMap = Object.fromEntries((departments ?? []).map((d: any) => [d.id, d]));
|
||||
|
||||
// Build member-to-departments map
|
||||
const memberDeptMap: Record<string, EventDepartment[]> = {};
|
||||
for (const md of (memberDepts ?? [])) {
|
||||
const dept = deptMap[(md as any).department_id];
|
||||
if (dept) {
|
||||
if (!memberDeptMap[(md as any).event_member_id]) memberDeptMap[(md as any).event_member_id] = [];
|
||||
memberDeptMap[(md as any).event_member_id].push(dept as unknown as EventDepartment);
|
||||
}
|
||||
}
|
||||
|
||||
return members.map((m: any) => ({
|
||||
...m,
|
||||
profile: profileMap[m.user_id] ?? undefined,
|
||||
event_role: m.role_id ? (roleMap[m.role_id] as unknown as EventRole) ?? undefined : undefined,
|
||||
departments: memberDeptMap[m.id] ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -244,11 +312,17 @@ export async function addEventMember(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string,
|
||||
userId: string,
|
||||
role: 'lead' | 'manager' | 'member' = 'member'
|
||||
params: { role?: 'lead' | 'manager' | 'member'; role_id?: string; notes?: string } = {}
|
||||
): Promise<EventMember> {
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await (supabase as any)
|
||||
.from('event_members')
|
||||
.upsert({ event_id: eventId, user_id: userId, role }, { onConflict: 'event_id,user_id' })
|
||||
.upsert({
|
||||
event_id: eventId,
|
||||
user_id: userId,
|
||||
role: params.role ?? 'member',
|
||||
role_id: params.role_id ?? null,
|
||||
notes: params.notes ?? null,
|
||||
}, { onConflict: 'event_id,user_id' })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
@@ -275,3 +349,202 @@ export async function removeEventMember(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event Roles
|
||||
// ============================================================
|
||||
|
||||
export async function fetchEventRoles(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string
|
||||
): Promise<EventRole[]> {
|
||||
const { data, error } = await (supabase as any)
|
||||
.from('event_roles')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.order('sort_order');
|
||||
|
||||
if (error) {
|
||||
log.error('fetchEventRoles failed', { error, data: { eventId } });
|
||||
throw error;
|
||||
}
|
||||
return (data ?? []) as unknown as EventRole[];
|
||||
}
|
||||
|
||||
export async function createEventRole(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string,
|
||||
params: { name: string; color?: string; sort_order?: number }
|
||||
): Promise<EventRole> {
|
||||
const { data, error } = await (supabase as any)
|
||||
.from('event_roles')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
name: params.name,
|
||||
color: params.color ?? '#6366f1',
|
||||
sort_order: params.sort_order ?? 0,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
log.error('createEventRole failed', { error, data: { eventId, name: params.name } });
|
||||
throw error;
|
||||
}
|
||||
return data as unknown as EventRole;
|
||||
}
|
||||
|
||||
export async function updateEventRole(
|
||||
supabase: SupabaseClient<Database>,
|
||||
roleId: string,
|
||||
params: Partial<Pick<EventRole, 'name' | 'color' | 'sort_order' | 'is_default'>>
|
||||
): Promise<EventRole> {
|
||||
const { data, error } = await (supabase as any)
|
||||
.from('event_roles')
|
||||
.update(params)
|
||||
.eq('id', roleId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
log.error('updateEventRole failed', { error, data: { roleId } });
|
||||
throw error;
|
||||
}
|
||||
return data as unknown as EventRole;
|
||||
}
|
||||
|
||||
export async function deleteEventRole(
|
||||
supabase: SupabaseClient<Database>,
|
||||
roleId: string
|
||||
): Promise<void> {
|
||||
const { error } = await (supabase as any)
|
||||
.from('event_roles')
|
||||
.delete()
|
||||
.eq('id', roleId);
|
||||
|
||||
if (error) {
|
||||
log.error('deleteEventRole failed', { error, data: { roleId } });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event Departments
|
||||
// ============================================================
|
||||
|
||||
export async function fetchEventDepartments(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string
|
||||
): Promise<EventDepartment[]> {
|
||||
const { data, error } = await (supabase as any)
|
||||
.from('event_departments')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.order('sort_order');
|
||||
|
||||
if (error) {
|
||||
log.error('fetchEventDepartments failed', { error, data: { eventId } });
|
||||
throw error;
|
||||
}
|
||||
return (data ?? []) as unknown as EventDepartment[];
|
||||
}
|
||||
|
||||
export async function createEventDepartment(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string,
|
||||
params: { name: string; color?: string; description?: string; sort_order?: number }
|
||||
): Promise<EventDepartment> {
|
||||
const { data, error } = await (supabase as any)
|
||||
.from('event_departments')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
name: params.name,
|
||||
color: params.color ?? '#00A3E0',
|
||||
description: params.description ?? null,
|
||||
sort_order: params.sort_order ?? 0,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
log.error('createEventDepartment failed', { error, data: { eventId, name: params.name } });
|
||||
throw error;
|
||||
}
|
||||
return data as unknown as EventDepartment;
|
||||
}
|
||||
|
||||
export async function updateEventDepartment(
|
||||
supabase: SupabaseClient<Database>,
|
||||
deptId: string,
|
||||
params: Partial<Pick<EventDepartment, 'name' | 'color' | 'description' | 'sort_order'>>
|
||||
): Promise<EventDepartment> {
|
||||
const { data, error } = await (supabase as any)
|
||||
.from('event_departments')
|
||||
.update(params)
|
||||
.eq('id', deptId)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
log.error('updateEventDepartment failed', { error, data: { deptId } });
|
||||
throw error;
|
||||
}
|
||||
return data as unknown as EventDepartment;
|
||||
}
|
||||
|
||||
export async function deleteEventDepartment(
|
||||
supabase: SupabaseClient<Database>,
|
||||
deptId: string
|
||||
): Promise<void> {
|
||||
const { error } = await (supabase as any)
|
||||
.from('event_departments')
|
||||
.delete()
|
||||
.eq('id', deptId);
|
||||
|
||||
if (error) {
|
||||
log.error('deleteEventDepartment failed', { error, data: { deptId } });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Member-Department Assignments
|
||||
// ============================================================
|
||||
|
||||
export async function assignMemberDepartment(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventMemberId: string,
|
||||
departmentId: string
|
||||
): Promise<EventMemberDepartment> {
|
||||
const { data, error } = await (supabase as any)
|
||||
.from('event_member_departments')
|
||||
.upsert(
|
||||
{ event_member_id: eventMemberId, department_id: departmentId },
|
||||
{ onConflict: 'event_member_id,department_id' }
|
||||
)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
log.error('assignMemberDepartment failed', { error, data: { eventMemberId, departmentId } });
|
||||
throw error;
|
||||
}
|
||||
return data as unknown as EventMemberDepartment;
|
||||
}
|
||||
|
||||
export async function unassignMemberDepartment(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventMemberId: string,
|
||||
departmentId: string
|
||||
): Promise<void> {
|
||||
const { error } = await (supabase as any)
|
||||
.from('event_member_departments')
|
||||
.delete()
|
||||
.eq('event_member_id', eventMemberId)
|
||||
.eq('department_id', departmentId);
|
||||
|
||||
if (error) {
|
||||
log.error('unassignMemberDepartment failed', { error, data: { eventMemberId, departmentId } });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,9 +49,9 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
||||
.eq('org_id', org.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10),
|
||||
locals.supabase
|
||||
(locals.supabase as any)
|
||||
.from('profiles')
|
||||
.select('id, email, full_name, avatar_url')
|
||||
.select('id, email, full_name, avatar_url, phone, discord_handle, shirt_size, hoodie_size')
|
||||
.eq('id', user.id)
|
||||
.single(),
|
||||
locals.supabase
|
||||
@@ -108,16 +108,16 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
||||
|
||||
// Fetch profiles separately since org_members.user_id FK points to auth.users, not profiles
|
||||
const memberUserIds = (rawMembers ?? []).map(m => m.user_id).filter((id): id is string => id !== null);
|
||||
let memberProfilesMap: Record<string, { id: string; email: string; full_name: string | null; avatar_url: string | null }> = {};
|
||||
let memberProfilesMap: Record<string, { id: string; email: string; full_name: string | null; avatar_url: string | null; phone: string | null; discord_handle: string | null; shirt_size: string | null; hoodie_size: string | null }> = {};
|
||||
|
||||
if (memberUserIds.length > 0) {
|
||||
const { data: memberProfiles } = await locals.supabase
|
||||
const { data: memberProfiles } = await (locals.supabase as any)
|
||||
.from('profiles')
|
||||
.select('id, email, full_name, avatar_url')
|
||||
.select('id, email, full_name, avatar_url, phone, discord_handle, shirt_size, hoodie_size')
|
||||
.in('id', memberUserIds);
|
||||
|
||||
if (memberProfiles) {
|
||||
memberProfilesMap = Object.fromEntries(memberProfiles.map(p => [p.id, p]));
|
||||
memberProfilesMap = Object.fromEntries(memberProfiles.map((p: any) => [p.id, p]));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
email: string;
|
||||
full_name: string | null;
|
||||
avatar_url: string | null;
|
||||
phone: string | null;
|
||||
discord_handle: string | null;
|
||||
shirt_size: string | null;
|
||||
hoodie_size: string | null;
|
||||
};
|
||||
preferences: {
|
||||
id: string;
|
||||
@@ -34,10 +38,16 @@
|
||||
// Profile state
|
||||
let fullName = $state(data.profile.full_name ?? "");
|
||||
let avatarUrl = $state(data.profile.avatar_url ?? null);
|
||||
let phone = $state(data.profile.phone ?? "");
|
||||
let discordHandle = $state(data.profile.discord_handle ?? "");
|
||||
let shirtSize = $state(data.profile.shirt_size ?? "");
|
||||
let hoodieSize = $state(data.profile.hoodie_size ?? "");
|
||||
let isSaving = $state(false);
|
||||
let isUploading = $state(false);
|
||||
let avatarInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
const clothingSizes = ["XS", "S", "M", "L", "XL", "XXL", "3XL"];
|
||||
|
||||
// Preferences state
|
||||
let theme = $state(data.preferences?.theme ?? "dark");
|
||||
let accentColor = $state(data.preferences?.accent_color ?? "#00A3E0");
|
||||
@@ -57,6 +67,10 @@
|
||||
$effect(() => {
|
||||
fullName = data.profile.full_name ?? "";
|
||||
avatarUrl = data.profile.avatar_url ?? null;
|
||||
phone = data.profile.phone ?? "";
|
||||
discordHandle = data.profile.discord_handle ?? "";
|
||||
shirtSize = data.profile.shirt_size ?? "";
|
||||
hoodieSize = data.profile.hoodie_size ?? "";
|
||||
theme = data.preferences?.theme ?? "dark";
|
||||
accentColor = data.preferences?.accent_color ?? "#00A3E0";
|
||||
useOrgTheme = data.preferences?.use_org_theme ?? true;
|
||||
@@ -161,9 +175,15 @@
|
||||
|
||||
async function saveProfile() {
|
||||
isSaving = true;
|
||||
const { error } = await supabase
|
||||
const { error } = await (supabase as any)
|
||||
.from("profiles")
|
||||
.update({ full_name: fullName || null })
|
||||
.update({
|
||||
full_name: fullName || null,
|
||||
phone: phone || null,
|
||||
discord_handle: discordHandle || null,
|
||||
shirt_size: shirtSize || null,
|
||||
hoodie_size: hoodieSize || null,
|
||||
})
|
||||
.eq("id", data.profile.id);
|
||||
|
||||
if (error) {
|
||||
@@ -305,6 +325,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact & Sizing Section -->
|
||||
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
|
||||
<h2 class="font-heading text-body text-white">
|
||||
{m.account_contact_info()}
|
||||
</h2>
|
||||
|
||||
<Input
|
||||
label={m.account_phone()}
|
||||
bind:value={phone}
|
||||
placeholder={m.account_phone_placeholder()}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={m.account_discord()}
|
||||
bind:value={discordHandle}
|
||||
placeholder={m.account_discord_placeholder()}
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-body text-body-sm text-light/60">{m.account_shirt_size()}</span>
|
||||
<select
|
||||
bind:value={shirtSize}
|
||||
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="">{m.account_size_placeholder()}</option>
|
||||
{#each clothingSizes as size}
|
||||
<option value={size}>{size}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="font-body text-body-sm text-light/60">{m.account_hoodie_size()}</span>
|
||||
<select
|
||||
bind:value={hoodieSize}
|
||||
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="">{m.account_size_placeholder()}</option>
|
||||
{#each clothingSizes as size}
|
||||
<option value={size}>{size}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button onclick={saveProfile} loading={isSaving}>
|
||||
{m.account_save_profile()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appearance Section -->
|
||||
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
|
||||
<h2 class="font-heading text-body text-white">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { fetchEventBySlug, fetchEventMembers } from '$lib/api/events';
|
||||
import { fetchEventBySlug, fetchEventMembers, fetchEventRoles, fetchEventDepartments } from '$lib/api/events';
|
||||
import { createLogger } from '$lib/utils/logger';
|
||||
|
||||
const log = createLogger('page.event-detail');
|
||||
@@ -16,9 +16,13 @@ export const load: LayoutServerLoad = async ({ params, locals, parent }) => {
|
||||
const event = await fetchEventBySlug(locals.supabase, orgId, params.eventSlug);
|
||||
if (!event) error(404, 'Event not found');
|
||||
|
||||
const members = await fetchEventMembers(locals.supabase, event.id);
|
||||
const [members, roles, departments] = await Promise.all([
|
||||
fetchEventMembers(locals.supabase, event.id),
|
||||
fetchEventRoles(locals.supabase, event.id),
|
||||
fetchEventDepartments(locals.supabase, event.id),
|
||||
]);
|
||||
|
||||
return { event, eventMembers: members };
|
||||
return { event, eventMembers: members, eventRoles: roles, eventDepartments: departments };
|
||||
} catch (e: any) {
|
||||
if (e?.status === 404) throw e;
|
||||
log.error('Failed to load event', { error: e, data: { orgId, eventSlug: params.eventSlug } });
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { page } from "$app/stores";
|
||||
import { Avatar } from "$lib/components/ui";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { Event, EventMember } from "$lib/api/events";
|
||||
import type { Event, EventMemberWithDetails, EventRole, EventDepartment } from "$lib/api/events";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
interface Props {
|
||||
@@ -10,14 +10,9 @@
|
||||
org: { id: string; name: string; slug: string };
|
||||
userRole: string;
|
||||
event: Event;
|
||||
eventMembers: (EventMember & {
|
||||
profile?: {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string | null;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
})[];
|
||||
eventMembers: EventMemberWithDetails[];
|
||||
eventRoles: EventRole[];
|
||||
eventDepartments: EventDepartment[];
|
||||
};
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "$lib/supabase/types";
|
||||
import { toasts } from "$lib/stores/ui";
|
||||
import type { Event, EventMember } from "$lib/api/events";
|
||||
import type { Event, EventMemberWithDetails, EventRole, EventDepartment } from "$lib/api/events";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
interface Props {
|
||||
@@ -13,14 +13,9 @@
|
||||
org: { id: string; name: string; slug: string };
|
||||
userRole: string;
|
||||
event: Event;
|
||||
eventMembers: (EventMember & {
|
||||
profile?: {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string | null;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
})[];
|
||||
eventMembers: EventMemberWithDetails[];
|
||||
eventRoles: EventRole[];
|
||||
eventDepartments: EventDepartment[];
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
142
supabase/migrations/023_event_team_management.sql
Normal file
142
supabase/migrations/023_event_team_management.sql
Normal file
@@ -0,0 +1,142 @@
|
||||
-- Event Team Management: departments, roles, and member-department assignments
|
||||
-- Supports real-world event teams where members have roles and belong to departments
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Event Roles: customizable per-event position types
|
||||
-- ============================================================
|
||||
CREATE TABLE event_roles (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#6366f1',
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
is_default BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(event_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_event_roles_event ON event_roles(event_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 2. Event Departments: teams/areas within an event
|
||||
-- ============================================================
|
||||
CREATE TABLE event_departments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#00A3E0',
|
||||
description TEXT,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(event_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_event_departments_event ON event_departments(event_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 3. Evolve event_members: add role_id FK, keep role text as fallback
|
||||
-- ============================================================
|
||||
ALTER TABLE event_members
|
||||
ADD COLUMN role_id UUID REFERENCES event_roles(id) ON DELETE SET NULL,
|
||||
ADD COLUMN notes TEXT;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. Member-Department assignments (many-to-many)
|
||||
-- ============================================================
|
||||
CREATE TABLE event_member_departments (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
event_member_id UUID NOT NULL REFERENCES event_members(id) ON DELETE CASCADE,
|
||||
department_id UUID NOT NULL REFERENCES event_departments(id) ON DELETE CASCADE,
|
||||
assigned_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(event_member_id, department_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_emd_member ON event_member_departments(event_member_id);
|
||||
CREATE INDEX idx_emd_department ON event_member_departments(department_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 5. RLS policies
|
||||
-- ============================================================
|
||||
ALTER TABLE event_roles ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE event_departments ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE event_member_departments ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Event roles: org members can view, editors+ can manage
|
||||
CREATE POLICY "Org members can view event roles" ON event_roles FOR SELECT
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM events e
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE e.id = event_roles.event_id AND om.user_id = auth.uid()
|
||||
));
|
||||
|
||||
CREATE POLICY "Editors can manage event roles" ON event_roles FOR ALL
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM events e
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE e.id = event_roles.event_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
|
||||
));
|
||||
|
||||
-- Event departments: org members can view, editors+ can manage
|
||||
CREATE POLICY "Org members can view event departments" ON event_departments FOR SELECT
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM events e
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE e.id = event_departments.event_id AND om.user_id = auth.uid()
|
||||
));
|
||||
|
||||
CREATE POLICY "Editors can manage event departments" ON event_departments FOR ALL
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM events e
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE e.id = event_departments.event_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
|
||||
));
|
||||
|
||||
-- Member-department assignments: org members can view, editors+ can manage
|
||||
CREATE POLICY "Org members can view member departments" ON event_member_departments FOR SELECT
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM event_members em
|
||||
JOIN events e ON em.event_id = e.id
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE em.id = event_member_departments.event_member_id AND om.user_id = auth.uid()
|
||||
));
|
||||
|
||||
CREATE POLICY "Editors can manage member departments" ON event_member_departments FOR ALL
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM event_members em
|
||||
JOIN events e ON em.event_id = e.id
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE em.id = event_member_departments.event_member_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
|
||||
));
|
||||
|
||||
-- ============================================================
|
||||
-- 6. Auto-seed default roles when a new event is created
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION public.seed_event_defaults()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Default roles (generalized from real event data)
|
||||
INSERT INTO public.event_roles (event_id, name, color, sort_order, is_default) VALUES
|
||||
(NEW.id, 'Head Organizer', '#EF4444', 0, false),
|
||||
(NEW.id, 'Team Lead', '#8B5CF6', 1, false),
|
||||
(NEW.id, 'Organizer', '#F59E0B', 2, true),
|
||||
(NEW.id, 'Volunteer', '#10B981', 3, false),
|
||||
(NEW.id, 'Sponsor', '#00A3E0', 4, false);
|
||||
|
||||
-- Default departments (generalized from real event data)
|
||||
INSERT INTO public.event_departments (event_id, name, color, sort_order) VALUES
|
||||
(NEW.id, 'Logistics', '#F59E0B', 0),
|
||||
(NEW.id, 'IT & Tech', '#6366F1', 1),
|
||||
(NEW.id, 'Marketing', '#EC4899', 2),
|
||||
(NEW.id, 'Finance', '#10B981', 3),
|
||||
(NEW.id, 'Program', '#8B5CF6', 4),
|
||||
(NEW.id, 'Sponsorship', '#00A3E0', 5),
|
||||
(NEW.id, 'Design', '#F97316', 6),
|
||||
(NEW.id, 'Volunteers', '#14B8A6', 7);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
CREATE TRIGGER on_event_created_seed_defaults
|
||||
AFTER INSERT ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION public.seed_event_defaults();
|
||||
8
supabase/migrations/024_profile_extended_fields.sql
Normal file
8
supabase/migrations/024_profile_extended_fields.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Extended profile fields: contact info and clothing sizes
|
||||
-- These are collected during onboarding and visible to event team managers
|
||||
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN phone TEXT,
|
||||
ADD COLUMN discord_handle TEXT,
|
||||
ADD COLUMN shirt_size TEXT,
|
||||
ADD COLUMN hoodie_size TEXT;
|
||||
Reference in New Issue
Block a user