465 lines
12 KiB
Vue
465 lines
12 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) => {
|
|
return count + (event.current_attendees || 0);
|
|
}, 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) => {
|
|
selectedEvent.value = eventInfo.eventData || eventInfo.event || eventInfo;
|
|
showDetailsDialog.value = true;
|
|
};
|
|
|
|
const handleDateClick = (dateInfo: any) => {
|
|
if (isBoard.value || isAdmin.value) {
|
|
prefilledDate.value = dateInfo.date;
|
|
prefilledEndDate.value = dateInfo.endDate || '';
|
|
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 = (event: Event) => {
|
|
showSuccessMessage('Event created successfully!');
|
|
refreshCalendar();
|
|
};
|
|
|
|
const handleRSVPUpdated = (event: Event) => {
|
|
showSuccessMessage('RSVP updated successfully!');
|
|
refreshCalendar();
|
|
};
|
|
|
|
const refreshCalendar = () => {
|
|
calendarRef.value?.refetchEvents?.();
|
|
clearCache();
|
|
};
|
|
|
|
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>
|