411 lines
9.8 KiB
Vue
411 lines
9.8 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.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[] => {
|
||
|
|
return props.events.map((event: Event) => transformEventForCalendar(event));
|
||
|
|
});
|
||
|
|
|
||
|
|
// 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'
|
||
|
|
},
|
||
|
|
events: transformedEvents.value,
|
||
|
|
eventClick: handleEventClick,
|
||
|
|
dateClick: handleDateClick,
|
||
|
|
datesSet: handleDatesSet,
|
||
|
|
eventDidMount: handleEventMount,
|
||
|
|
dayMaxEvents: props.compact ? 2 : 5,
|
||
|
|
eventDisplay: 'block',
|
||
|
|
displayEventTime: true,
|
||
|
|
eventTimeFormat: {
|
||
|
|
hour: '2-digit',
|
||
|
|
minute: '2-digit',
|
||
|
|
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 {
|
||
|
|
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' };
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: event.id,
|
||
|
|
title: event.title,
|
||
|
|
start: event.start_datetime,
|
||
|
|
end: event.end_datetime,
|
||
|
|
backgroundColor: colors.bg,
|
||
|
|
borderColor: colors.border,
|
||
|
|
textColor: '#ffffff',
|
||
|
|
extendedProps: {
|
||
|
|
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) : null,
|
||
|
|
current_attendees: event.current_attendees || 0,
|
||
|
|
user_rsvp: event.user_rsvp,
|
||
|
|
visibility: event.visibility,
|
||
|
|
creator: event.creator,
|
||
|
|
status: event.status
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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>
|