monacousa-portal/pages/dashboard/events.vue

573 lines
16 KiB
Vue

<template>
<v-container fluid>
<!-- Upcoming Event Banner -->
<UpcomingEventBanner
v-if="upcomingEvent"
:event="upcomingEvent"
class="mb-4"
@event-click="handleEventClick"
/>
<!-- Page Header -->
<v-row class="mb-4">
<v-col cols="12" md="8">
<h1 class="text-h4 font-weight-bold text-primary">
<v-icon class="me-2">mdi-calendar</v-icon>
Events Calendar
</h1>
<p class="text-body-1 text-medium-emphasis">
View and manage events for the MonacoUSA community
</p>
</v-col>
<v-col cols="12" md="4" class="d-flex justify-end align-start ga-2">
<v-btn
v-if="isBoard || isAdmin"
@click="showCreateDialog = true"
color="primary"
prepend-icon="mdi-plus"
size="large"
>
Create Event
</v-btn>
<v-menu>
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="outlined"
prepend-icon="mdi-download"
size="large"
>
Subscribe
</v-btn>
</template>
<v-list>
<v-list-item @click="exportCalendar">
<v-list-item-title>
<v-icon start>mdi-calendar-export</v-icon>
Export Calendar
</v-list-item-title>
</v-list-item>
<v-list-item @click="subscribeCalendar">
<v-list-item-title>
<v-icon start>mdi-calendar-sync</v-icon>
Subscribe (iOS/Android)
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-col>
</v-row>
<!-- Filters Row -->
<v-row class="mb-4">
<v-col cols="12" md="3">
<v-select
v-model="filters.event_type"
:items="eventTypeOptions"
label="Event Type"
variant="outlined"
density="comfortable"
clearable
@update:model-value="applyFilters"
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="filters.visibility"
:items="visibilityOptions"
label="Visibility"
variant="outlined"
density="comfortable"
clearable
@update:model-value="applyFilters"
/>
</v-col>
<v-col cols="12" md="3">
<v-text-field
v-model="filters.search"
label="Search events..."
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-magnify"
clearable
@update:model-value="debounceSearch"
/>
</v-col>
<v-col cols="12" md="3" class="d-flex align-center">
<v-btn
@click="clearFilters"
variant="outlined"
prepend-icon="mdi-filter-off"
:disabled="!hasActiveFilters"
>
Clear Filters
</v-btn>
</v-col>
</v-row>
<!-- Main Calendar -->
<EventCalendar
ref="calendarRef"
:events="events"
:loading="loading"
:show-create-button="false"
@event-click="handleEventClick"
@date-click="handleDateClick"
@view-change="handleViewChange"
@date-range-change="handleDateRangeChange"
/>
<!-- Stats Row (if admin/board) -->
<v-row v-if="isBoard || isAdmin" class="mt-6">
<v-col cols="12" md="3">
<v-card variant="outlined">
<v-card-text class="text-center">
<div class="text-h4 text-primary font-weight-bold">{{ totalEvents }}</div>
<div class="text-body-2">Total Events</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card variant="outlined">
<v-card-text class="text-center">
<div class="text-h4 text-success font-weight-bold">{{ totalRSVPs }}</div>
<div class="text-body-2">Total RSVPs</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card variant="outlined">
<v-card-text class="text-center">
<div class="text-h4 text-warning font-weight-bold">{{ upcomingEventsCount }}</div>
<div class="text-body-2">Upcoming Events</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card variant="outlined">
<v-card-text class="text-center">
<div class="text-h4 text-info font-weight-bold">{{ thisMonthEventsCount }}</div>
<div class="text-body-2">This Month</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Dialogs -->
<CreateEventDialog
v-model="showCreateDialog"
:prefilled-date="prefilledDate"
:prefilled-end-date="prefilledEndDate"
@event-created="handleEventCreated"
/>
<EventDetailsDialog
v-model="showDetailsDialog"
:event="selectedEvent"
@rsvp-updated="handleRSVPUpdated"
/>
<!-- Error Snackbar -->
<v-snackbar
v-model="showErrorSnackbar"
color="error"
:timeout="5000"
>
{{ errorMessage }}
<template #actions>
<v-btn
variant="text"
@click="showErrorSnackbar = false"
>
Close
</v-btn>
</template>
</v-snackbar>
<!-- Success Snackbar -->
<v-snackbar
v-model="showSuccessSnackbar"
color="success"
:timeout="3000"
>
{{ successMessage }}
<template #actions>
<v-btn
variant="text"
@click="showSuccessSnackbar = false"
>
Close
</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup lang="ts">
import type { Event, EventFilters } from '~/utils/types';
import { useAuth } from '~/composables/useAuth';
import { useEvents } from '~/composables/useEvents';
definePageMeta({
layout: 'dashboard',
middleware: 'auth'
});
const { isBoard, isAdmin, user } = useAuth();
const {
events,
loading,
error,
upcomingEvent,
fetchEvents,
getUpcomingEvents,
clearCache
} = useEvents();
// Component refs
const calendarRef = ref();
// Reactive state
const showCreateDialog = ref(false);
const showDetailsDialog = ref(false);
const selectedEvent = ref<Event | null>(null);
const prefilledDate = ref<string>('');
const prefilledEndDate = ref<string>('');
// Filter state
const filters = reactive<EventFilters>({
event_type: undefined,
visibility: undefined,
search: undefined,
status: 'active'
});
// Notification state
const showErrorSnackbar = ref(false);
const showSuccessSnackbar = ref(false);
const errorMessage = ref('');
const successMessage = ref('');
// Search debouncing
let searchTimeout: NodeJS.Timeout | null = null;
// Computed properties
const eventTypeOptions = [
{ title: 'All Types', value: undefined },
{ title: 'Meeting', value: 'meeting' },
{ title: 'Social Event', value: 'social' },
{ title: 'Fundraiser', value: 'fundraiser' },
{ title: 'Workshop', value: 'workshop' },
{ title: 'Board Only', value: 'board-only' }
];
const visibilityOptions = computed(() => {
const options = [
{ title: 'All Events', value: undefined },
{ title: 'Public', value: 'public' },
{ title: 'Board Only', value: 'board-only' }
];
if (isAdmin.value) {
options.push({ title: 'Admin Only', value: 'admin-only' });
}
return options;
});
const hasActiveFilters = computed(() => {
return filters.event_type || filters.visibility || filters.search;
});
const totalEvents = computed(() => events.value.length);
const totalRSVPs = computed(() => {
return events.value.reduce((count, event) => {
const attendees = typeof event.current_attendees === 'string'
? parseInt(event.current_attendees) || 0
: event.current_attendees || 0;
return count + attendees;
}, 0);
});
const upcomingEventsCount = computed(() => {
const now = new Date();
return events.value.filter(event => new Date(event.start_datetime) >= now).length;
});
const thisMonthEventsCount = computed(() => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return events.value.filter(event => {
const eventDate = new Date(event.start_datetime);
return eventDate >= startOfMonth && eventDate <= endOfMonth;
}).length;
});
// Methods
const applyFilters = async () => {
try {
await fetchEvents(filters);
} catch (err: any) {
showErrorMessage('Failed to apply filters');
}
};
const debounceSearch = () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(() => {
applyFilters();
}, 500);
};
const clearFilters = async () => {
filters.event_type = undefined;
filters.visibility = undefined;
filters.search = undefined;
await applyFilters();
};
const handleEventClick = (eventInfo: any) => {
console.log('[Events] EVENT CLICK HANDLER CALLED');
console.log('[Events] Raw eventInfo received:', eventInfo);
// Extract the original event data from FullCalendar's extendedProps
const calendarEvent = eventInfo.event || eventInfo;
const originalEvent = calendarEvent.extendedProps?.originalEvent;
console.log('[Events] Calendar event:', calendarEvent);
console.log('[Events] Original event from extendedProps:', originalEvent);
// Use original event if available, otherwise reconstruct from calendar event
if (originalEvent) {
selectedEvent.value = originalEvent as Event;
console.log('[Events] Using original event from extendedProps');
} else {
console.log('[Events] Reconstructing event from calendar data');
// Fallback: reconstruct event from FullCalendar event data
selectedEvent.value = {
id: calendarEvent.id,
title: calendarEvent.title,
description: calendarEvent.extendedProps?.description || '',
event_type: calendarEvent.extendedProps?.event_type || 'meeting',
start_datetime: calendarEvent.start?.toISOString() || calendarEvent.startStr,
end_datetime: calendarEvent.end?.toISOString() || calendarEvent.endStr,
location: calendarEvent.extendedProps?.location || '',
visibility: calendarEvent.extendedProps?.visibility || 'public',
is_paid: calendarEvent.extendedProps?.is_paid ? 'true' : 'false',
cost_members: calendarEvent.extendedProps?.cost_members || '',
cost_non_members: calendarEvent.extendedProps?.cost_non_members || '',
max_attendees: calendarEvent.extendedProps?.max_attendees?.toString() || '',
current_attendees: calendarEvent.extendedProps?.current_attendees?.toString() || '0',
user_rsvp: calendarEvent.extendedProps?.user_rsvp || null,
creator: calendarEvent.extendedProps?.creator || '',
status: 'active'
} as Event;
}
console.log('[Events] Final selected event for dialog:', {
id: selectedEvent.value.id,
title: selectedEvent.value.title,
event_type: selectedEvent.value.event_type,
full_event: selectedEvent.value
});
console.log('[Events] About to show dialog...');
console.log('[Events] showDetailsDialog current value:', showDetailsDialog.value);
showDetailsDialog.value = true;
console.log('[Events] showDetailsDialog after setting to true:', showDetailsDialog.value);
// Force Vue to update
nextTick(() => {
console.log('[Events] After nextTick - showDetailsDialog:', showDetailsDialog.value);
console.log('[Events] After nextTick - selectedEvent:', selectedEvent.value?.title);
});
};
const handleDateClick = (dateInfo: any) => {
if (isBoard.value || isAdmin.value) {
// Debug: Log the date format being passed
console.log('[Events] Date clicked:', dateInfo);
// Create proper ISO datetime strings (full format)
let formattedDate = '';
let formattedEndDate = '';
if (dateInfo.date) {
// Convert to proper Date object and set default time
const clickedDate = new Date(dateInfo.date);
// Set default time to 6 PM in the user's local timezone
clickedDate.setHours(18, 0, 0, 0); // Default to 6 PM
formattedDate = clickedDate.toISOString(); // Full ISO string
// Set end date 2 hours later if not provided
if (dateInfo.endDate) {
const endDate = new Date(dateInfo.endDate);
endDate.setHours(20, 0, 0, 0); // Default to 8 PM for end date
formattedEndDate = endDate.toISOString();
} else {
const endDate = new Date(clickedDate);
endDate.setHours(20, 0, 0, 0); // Default to 8 PM (2 hours later)
formattedEndDate = endDate.toISOString();
}
}
// Clear previous values first to trigger watchers properly
prefilledDate.value = '';
prefilledEndDate.value = '';
// Set new values
nextTick(() => {
prefilledDate.value = formattedDate;
prefilledEndDate.value = formattedEndDate;
console.log('[Events] Prefilled dates set:', {
date: formattedDate,
endDate: formattedEndDate
});
showCreateDialog.value = true;
});
}
};
const handleViewChange = (viewInfo: any) => {
// Handle calendar view changes if needed
console.log('View changed:', viewInfo);
};
const handleDateRangeChange = async (start: string, end: string) => {
// Fetch events for the new date range
const rangeFilters = {
...filters,
start_date: start,
end_date: end
};
try {
await fetchEvents(rangeFilters);
} catch (err: any) {
showErrorMessage('Failed to load events for date range');
}
};
const handleEventCreated = async (event: Event) => {
showSuccessMessage('Event created successfully!');
await refreshCalendar();
};
const handleRSVPUpdated = async (event: Event) => {
showSuccessMessage('RSVP updated successfully!');
await refreshCalendar();
};
const refreshCalendar = async () => {
try {
// Clear cache and force refresh events data
clearCache();
await fetchEvents({ force: true });
// Also refresh the calendar component
calendarRef.value?.refetchEvents?.();
console.log('Calendar refreshed successfully');
} catch (error) {
console.error('Error refreshing calendar:', error);
showErrorMessage('Failed to refresh calendar');
}
};
const exportCalendar = () => {
// Create download link for iCal export
const feedUrl = `/api/events/calendar-feed?user_id=${user.value?.id}&format=ical`;
const link = document.createElement('a');
link.href = feedUrl;
link.download = 'monacousa-events.ics';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showSuccessMessage('Calendar export started!');
};
const subscribeCalendar = async () => {
try {
const feedUrl = `${window.location.origin}/api/events/calendar-feed?user_id=${user.value?.id}&format=ical`;
await navigator.clipboard.writeText(feedUrl);
showSuccessMessage('Calendar subscription URL copied to clipboard!');
} catch (error) {
showErrorMessage('Failed to copy subscription URL');
}
};
const showErrorMessage = (message: string) => {
errorMessage.value = message;
showErrorSnackbar.value = true;
};
const showSuccessMessage = (message: string) => {
successMessage.value = message;
showSuccessSnackbar.value = true;
};
// Lifecycle
onMounted(async () => {
try {
await fetchEvents({ status: 'active' });
} catch (err: any) {
showErrorMessage('Failed to load events');
}
});
// Watch for errors from composable
watchEffect(() => {
if (error.value) {
showErrorMessage(error.value);
}
});
// Cleanup
onUnmounted(() => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
});
</script>
<style scoped>
.text-medium-emphasis {
opacity: 0.7;
}
.v-container {
max-width: 1400px;
}
/* Ensure calendar takes full width */
:deep(.event-calendar) {
width: 100%;
}
/* Mobile optimizations */
@media (max-width: 600px) {
.v-container {
padding: 12px;
}
.text-h4 {
font-size: 1.5rem !important;
}
}
</style>