573 lines
16 KiB
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>
|