481 lines
12 KiB
Vue
481 lines
12 KiB
Vue
<template>
|
|
<v-card elevation="2" class="event-calendar">
|
|
<v-card-title v-if="!compact" class="d-flex justify-space-between align-center">
|
|
<div class="d-flex align-center">
|
|
<v-icon class="me-2">mdi-calendar</v-icon>
|
|
<span>Events Calendar</span>
|
|
</div>
|
|
<div v-if="showCreateButton && (isBoard || isAdmin)" class="d-flex gap-2">
|
|
<v-btn
|
|
@click="$emit('create-event')"
|
|
color="primary"
|
|
size="small"
|
|
prepend-icon="mdi-plus"
|
|
>
|
|
Create Event
|
|
</v-btn>
|
|
</div>
|
|
</v-card-title>
|
|
|
|
<v-card-text>
|
|
<!-- Mobile view selector -->
|
|
<v-row v-if="$vuetify.display.mobile && !compact" class="mb-4">
|
|
<v-col cols="12">
|
|
<v-btn-toggle
|
|
v-model="mobileView"
|
|
color="primary"
|
|
variant="outlined"
|
|
density="compact"
|
|
mandatory
|
|
>
|
|
<v-btn value="month">
|
|
<v-icon start>mdi-calendar-month</v-icon>
|
|
Month
|
|
</v-btn>
|
|
<v-btn value="list">
|
|
<v-icon start>mdi-format-list-bulleted</v-icon>
|
|
Agenda
|
|
</v-btn>
|
|
</v-btn-toggle>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Loading state -->
|
|
<v-skeleton-loader
|
|
v-if="loading"
|
|
type="image"
|
|
:height="calendarHeight"
|
|
class="rounded"
|
|
/>
|
|
|
|
<!-- FullCalendar component -->
|
|
<FullCalendar
|
|
v-else
|
|
ref="fullCalendar"
|
|
:options="calendarOptions"
|
|
class="fc-theme-monacousa"
|
|
/>
|
|
|
|
<!-- No events message -->
|
|
<v-alert
|
|
v-if="!loading && (!events || events.length === 0)"
|
|
type="info"
|
|
variant="tonal"
|
|
class="mt-4"
|
|
>
|
|
<v-alert-title>No Events</v-alert-title>
|
|
No events found for the current time period.
|
|
</v-alert>
|
|
</v-card-text>
|
|
</v-card>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import FullCalendar from '@fullcalendar/vue3';
|
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
|
import interactionPlugin from '@fullcalendar/interaction';
|
|
import listPlugin from '@fullcalendar/list';
|
|
import type { Event, FullCalendarEvent } from '~/utils/types';
|
|
import { useAuth } from '~/composables/useAuth';
|
|
|
|
interface Props {
|
|
events?: Event[];
|
|
loading?: boolean;
|
|
compact?: boolean;
|
|
height?: number | string;
|
|
showCreateButton?: boolean;
|
|
initialView?: string;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
events: () => [],
|
|
loading: false,
|
|
compact: false,
|
|
height: 600,
|
|
showCreateButton: true,
|
|
initialView: 'dayGridMonth'
|
|
});
|
|
|
|
const emit = defineEmits<{
|
|
'event-click': [event: any];
|
|
'date-click': [date: any];
|
|
'view-change': [view: any];
|
|
'date-range-change': [start: string, end: string];
|
|
'create-event': [];
|
|
}>();
|
|
|
|
const { isBoard, isAdmin } = useAuth();
|
|
|
|
// Reactive state
|
|
const fullCalendar = ref<InstanceType<typeof FullCalendar>>();
|
|
const mobileView = ref('month');
|
|
|
|
// Computed properties
|
|
const calendarHeight = computed(() => {
|
|
if (props.compact) return props.height || 300;
|
|
if (typeof props.height === 'number') return props.height;
|
|
return props.height || 600;
|
|
});
|
|
|
|
const currentView = computed(() => {
|
|
if (props.compact) return 'dayGridMonth';
|
|
|
|
// Mobile responsive view switching
|
|
if (process.client && window.innerWidth < 960) {
|
|
return mobileView.value === 'list' ? 'listWeek' : 'dayGridMonth';
|
|
}
|
|
|
|
return props.initialView;
|
|
});
|
|
|
|
const transformedEvents = computed((): FullCalendarEvent[] => {
|
|
console.log('[EventCalendar] Raw events received:', props.events.length);
|
|
props.events.forEach((event, index) => {
|
|
console.log(`[EventCalendar] Event ${index + 1}:`, {
|
|
id: event.id,
|
|
title: event.title,
|
|
start_datetime: event.start_datetime,
|
|
end_datetime: event.end_datetime,
|
|
event_type: event.event_type
|
|
});
|
|
});
|
|
|
|
const transformed = props.events.map((event: Event) => transformEventForCalendar(event));
|
|
|
|
console.log('[EventCalendar] Transformed events for FullCalendar:', transformed.length);
|
|
transformed.forEach((event, index) => {
|
|
console.log(`[EventCalendar] Transformed Event ${index + 1}:`, {
|
|
id: event.id,
|
|
title: event.title,
|
|
start: event.start,
|
|
end: event.end,
|
|
backgroundColor: event.backgroundColor
|
|
});
|
|
});
|
|
|
|
return transformed;
|
|
});
|
|
|
|
// FullCalendar options
|
|
const calendarOptions = computed(() => ({
|
|
plugins: [dayGridPlugin, interactionPlugin, listPlugin],
|
|
initialView: currentView.value,
|
|
height: calendarHeight.value,
|
|
headerToolbar: props.compact ? false : {
|
|
left: 'prev,next today',
|
|
center: 'title',
|
|
right: process.client && window.innerWidth < 960 ?
|
|
'dayGridMonth,listWeek' :
|
|
'dayGridMonth,dayGridWeek,listWeek'
|
|
} as any,
|
|
events: transformedEvents.value,
|
|
eventClick: handleEventClick,
|
|
dateClick: handleDateClick,
|
|
datesSet: handleDatesSet,
|
|
eventDidMount: handleEventMount,
|
|
dayMaxEvents: props.compact ? 2 : 5,
|
|
eventDisplay: 'block',
|
|
displayEventTime: true,
|
|
eventTimeFormat: {
|
|
hour: '2-digit' as const,
|
|
minute: '2-digit' as const,
|
|
hour12: false
|
|
},
|
|
locale: 'en',
|
|
firstDay: 1, // Monday
|
|
weekends: true,
|
|
navLinks: true,
|
|
selectable: isBoard.value || isAdmin.value,
|
|
selectMirror: true,
|
|
select: handleDateSelect,
|
|
// Mobile optimizations
|
|
aspectRatio: process.client && window.innerWidth < 960 ? 1.0 : 1.35,
|
|
// Responsive behavior
|
|
windowResizeDelay: 100
|
|
}));
|
|
|
|
// Event handlers
|
|
function handleEventClick(clickInfo: any) {
|
|
emit('event-click', {
|
|
event: clickInfo.event,
|
|
eventData: clickInfo.event.extendedProps
|
|
});
|
|
}
|
|
|
|
function handleDateClick(dateInfo: any) {
|
|
if (isBoard.value || isAdmin.value) {
|
|
emit('date-click', {
|
|
date: dateInfo.dateStr,
|
|
allDay: dateInfo.allDay
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleDateSelect(selectInfo: any) {
|
|
if (isBoard.value || isAdmin.value) {
|
|
emit('date-click', {
|
|
date: selectInfo.startStr,
|
|
endDate: selectInfo.endStr,
|
|
allDay: selectInfo.allDay
|
|
});
|
|
}
|
|
}
|
|
|
|
function handleDatesSet(dateInfo: any) {
|
|
emit('view-change', {
|
|
view: dateInfo.view.type,
|
|
start: dateInfo.start,
|
|
end: dateInfo.end
|
|
});
|
|
|
|
emit('date-range-change',
|
|
dateInfo.start.toISOString(),
|
|
dateInfo.end.toISOString()
|
|
);
|
|
}
|
|
|
|
function handleEventMount(mountInfo: any) {
|
|
// Add custom styling or tooltips
|
|
const event = mountInfo.event;
|
|
const el = mountInfo.el;
|
|
|
|
// Add tooltip with event details
|
|
el.setAttribute('title', `${event.title}\n${event.extendedProps.location || 'No location'}`);
|
|
|
|
// Add custom classes based on event properties
|
|
if (event.extendedProps.is_paid) {
|
|
el.classList.add('fc-paid-event');
|
|
}
|
|
|
|
if (event.extendedProps.user_rsvp?.rsvp_status === 'confirmed') {
|
|
el.classList.add('fc-user-rsvp');
|
|
}
|
|
}
|
|
|
|
// Transform event data for FullCalendar
|
|
function transformEventForCalendar(event: Event): FullCalendarEvent {
|
|
console.log('[EventCalendar] Transforming event:', {
|
|
id: event.id,
|
|
title: event.title,
|
|
start_datetime: event.start_datetime,
|
|
end_datetime: event.end_datetime,
|
|
event_type: event.event_type
|
|
});
|
|
|
|
const eventTypeColors = {
|
|
'meeting': { bg: '#2196f3', border: '#1976d2' },
|
|
'social': { bg: '#4caf50', border: '#388e3c' },
|
|
'fundraiser': { bg: '#ff9800', border: '#f57c00' },
|
|
'workshop': { bg: '#9c27b0', border: '#7b1fa2' },
|
|
'board-only': { bg: '#a31515', border: '#8b1212' }
|
|
};
|
|
|
|
const colors = eventTypeColors[event.event_type] ||
|
|
{ bg: '#757575', border: '#424242' };
|
|
|
|
// Ensure dates are properly formatted for FullCalendar
|
|
let startDate: string | Date;
|
|
let endDate: string | Date;
|
|
|
|
try {
|
|
// Convert to Date objects first to validate, then use ISO strings
|
|
const startDateObj = new Date(event.start_datetime);
|
|
const endDateObj = new Date(event.end_datetime);
|
|
|
|
if (isNaN(startDateObj.getTime()) || isNaN(endDateObj.getTime())) {
|
|
console.error('[EventCalendar] Invalid date values for event:', event.id, {
|
|
start: event.start_datetime,
|
|
end: event.end_datetime
|
|
});
|
|
// Use fallback dates
|
|
startDate = new Date().toISOString();
|
|
endDate = new Date(Date.now() + 3600000).toISOString(); // +1 hour
|
|
} else {
|
|
startDate = startDateObj.toISOString();
|
|
endDate = endDateObj.toISOString();
|
|
}
|
|
} catch (error) {
|
|
console.error('[EventCalendar] Date parsing error for event:', event.id, error);
|
|
// Use fallback dates
|
|
startDate = new Date().toISOString();
|
|
endDate = new Date(Date.now() + 3600000).toISOString(); // +1 hour
|
|
}
|
|
|
|
const transformedEvent = {
|
|
id: event.id,
|
|
title: event.title,
|
|
start: startDate,
|
|
end: endDate,
|
|
backgroundColor: colors.bg,
|
|
borderColor: colors.border,
|
|
textColor: '#ffffff',
|
|
extendedProps: {
|
|
originalEvent: event, // Store original event for debugging
|
|
description: event.description,
|
|
location: event.location,
|
|
event_type: event.event_type,
|
|
is_paid: event.is_paid === 'true',
|
|
cost_members: event.cost_members,
|
|
cost_non_members: event.cost_non_members,
|
|
max_attendees: event.max_attendees ? parseInt(event.max_attendees) : undefined,
|
|
current_attendees: typeof event.current_attendees === 'string' ? parseInt(event.current_attendees) : (event.current_attendees || 0),
|
|
user_rsvp: event.user_rsvp,
|
|
visibility: event.visibility,
|
|
creator: event.creator
|
|
}
|
|
};
|
|
|
|
console.log('[EventCalendar] Transformed event result:', {
|
|
id: transformedEvent.id,
|
|
title: transformedEvent.title,
|
|
start: transformedEvent.start,
|
|
end: transformedEvent.end,
|
|
backgroundColor: transformedEvent.backgroundColor
|
|
});
|
|
|
|
return transformedEvent;
|
|
}
|
|
|
|
// Public methods
|
|
function getCalendarApi() {
|
|
return fullCalendar.value?.getApi();
|
|
}
|
|
|
|
function refetchEvents() {
|
|
const api = getCalendarApi();
|
|
if (api) {
|
|
api.refetchEvents();
|
|
}
|
|
}
|
|
|
|
function changeView(viewType: string) {
|
|
const api = getCalendarApi();
|
|
if (api) {
|
|
api.changeView(viewType);
|
|
}
|
|
}
|
|
|
|
function gotoDate(date: string | Date) {
|
|
const api = getCalendarApi();
|
|
if (api) {
|
|
api.gotoDate(date);
|
|
}
|
|
}
|
|
|
|
// Watch for mobile view changes
|
|
watch(mobileView, (newView) => {
|
|
const viewType = newView === 'list' ? 'listWeek' : 'dayGridMonth';
|
|
changeView(viewType);
|
|
});
|
|
|
|
// Expose methods to parent components
|
|
defineExpose({
|
|
getCalendarApi,
|
|
refetchEvents,
|
|
changeView,
|
|
gotoDate
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.event-calendar :deep(.fc) {
|
|
font-family: 'Roboto', sans-serif;
|
|
}
|
|
|
|
.event-calendar :deep(.fc-theme-standard .fc-scrollgrid) {
|
|
border-color: rgba(0, 0, 0, 0.12);
|
|
}
|
|
|
|
.event-calendar :deep(.fc-theme-standard td),
|
|
.event-calendar :deep(.fc-theme-standard th) {
|
|
border-color: rgba(0, 0, 0, 0.12);
|
|
}
|
|
|
|
.event-calendar :deep(.fc-button-primary) {
|
|
background-color: #a31515;
|
|
border-color: #a31515;
|
|
font-weight: 500;
|
|
text-transform: none;
|
|
}
|
|
|
|
.event-calendar :deep(.fc-button-primary:hover) {
|
|
background-color: #8b1212;
|
|
border-color: #8b1212;
|
|
}
|
|
|
|
.event-calendar :deep(.fc-button-primary:disabled) {
|
|
background-color: rgba(163, 21, 21, 0.5);
|
|
border-color: rgba(163, 21, 21, 0.5);
|
|
}
|
|
|
|
.event-calendar :deep(.fc-today-button) {
|
|
font-weight: 500;
|
|
text-transform: none;
|
|
}
|
|
|
|
.event-calendar :deep(.fc-toolbar-title) {
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
color: #a31515;
|
|
}
|
|
|
|
.event-calendar :deep(.fc-day-today) {
|
|
background-color: rgba(163, 21, 21, 0.05) !important;
|
|
}
|
|
|
|
.event-calendar :deep(.fc-event) {
|
|
border-radius: 4px;
|
|
font-weight: 500;
|
|
font-size: 0.875rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.event-calendar :deep(.fc-event:hover) {
|
|
opacity: 0.85;
|
|
}
|
|
|
|
.event-calendar :deep(.fc-paid-event) {
|
|
border-left: 4px solid #ff9800 !important;
|
|
}
|
|
|
|
.event-calendar :deep(.fc-user-rsvp) {
|
|
box-shadow: 0 0 0 2px #4caf50;
|
|
}
|
|
|
|
.event-calendar :deep(.fc-list-event-title) {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.event-calendar :deep(.fc-list-event-time) {
|
|
font-weight: 600;
|
|
color: #a31515;
|
|
}
|
|
|
|
/* Mobile optimizations */
|
|
@media (max-width: 600px) {
|
|
.event-calendar :deep(.fc-toolbar) {
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.event-calendar :deep(.fc-toolbar-chunk) {
|
|
display: flex;
|
|
justify-content: center;
|
|
}
|
|
|
|
.event-calendar :deep(.fc-button-group) {
|
|
display: flex;
|
|
}
|
|
|
|
.event-calendar :deep(.fc-button) {
|
|
padding: 4px 8px;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.event-calendar :deep(.fc-toolbar-title) {
|
|
font-size: 1.1rem;
|
|
text-align: center;
|
|
}
|
|
}
|
|
</style>
|