monacousa-portal/pages/dashboard/events.vue

542 lines
14 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="showError"
color="error"
:timeout="5000"
>
{{ errorMessage }}
<template #actions>
<v-btn
variant="text"
@click="showError = false"
>
Close
</v-btn>
</template>
</v-snackbar>
<!-- Success Snackbar -->
<v-snackbar
v-model="showSuccess"
color="success"
:timeout="3000"
>
{{ successMessage }}
<template #actions>
<v-btn
variant="text"
@click="showSuccess = 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 showError = ref(false);
const showSuccess = 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) => {
// Extract the original event data from FullCalendar's extendedProps
const calendarEvent = eventInfo.event || eventInfo;
const originalEvent = calendarEvent.extendedProps?.originalEvent;
// Use original event if available, otherwise reconstruct from calendar event
if (originalEvent) {
selectedEvent.value = originalEvent as Event;
} else {
// 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] Selected event for dialog:', {
id: selectedEvent.value.id,
title: selectedEvent.value.title,
event_type: selectedEvent.value.event_type
});
showDetailsDialog.value = true;
};
const handleDateClick = (dateInfo: any) => {
if (isBoard.value || isAdmin.value) {
// Debug: Log the date format being passed
console.log('[Events] Date clicked:', dateInfo);
// Ensure proper ISO format for datetime-local inputs
let formattedDate = '';
let formattedEndDate = '';
if (dateInfo.date) {
// Convert to local datetime-local format: YYYY-MM-DDTHH:mm
const clickedDate = new Date(dateInfo.date);
clickedDate.setHours(18, 0, 0, 0); // Default to 6 PM
formattedDate = clickedDate.toISOString().slice(0, 16);
// Set end date 2 hours later if not provided
if (dateInfo.endDate) {
formattedEndDate = new Date(dateInfo.endDate).toISOString().slice(0, 16);
} else {
const endDate = new Date(clickedDate);
endDate.setHours(20, 0, 0, 0); // Default to 8 PM
formattedEndDate = endDate.toISOString().slice(0, 16);
}
}
prefilledDate.value = formattedDate;
prefilledEndDate.value = formattedEndDate;
console.log('[Events] Prefilled dates:', {
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;
showError.value = true;
};
const showSuccessMessage = (message: string) => {
successMessage.value = message;
showSuccess.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>