Add event management system with calendar and CRUD operations
Some checks failed
Build And Push Image / docker (push) Failing after 2m37s

- Add EventCalendar component with FullCalendar integration
- Create event CRUD dialogs and upcoming event banner
- Implement server-side events API and database utilities
- Add events dashboard page and navigation
- Improve dues calculation with better overdue day logic
- Install FullCalendar and date-fns dependencies
This commit is contained in:
2025-08-12 04:25:35 +02:00
parent a555584b2c
commit f096897129
21 changed files with 3855 additions and 20 deletions

View File

@@ -0,0 +1,471 @@
<template>
<v-dialog v-model="show" max-width="800" persistent>
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon class="me-2">mdi-calendar-plus</v-icon>
<span>Create New Event</span>
</div>
<v-btn
@click="close"
icon
variant="text"
size="small"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<v-form ref="form" v-model="valid" @submit.prevent="handleSubmit">
<v-row>
<!-- Basic Information -->
<v-col cols="12">
<v-text-field
v-model="eventData.title"
label="Event Title*"
:rules="[v => !!v || 'Title is required']"
variant="outlined"
required
/>
</v-col>
<v-col cols="12">
<v-textarea
v-model="eventData.description"
label="Description"
variant="outlined"
rows="3"
auto-grow
/>
</v-col>
<!-- Event Type and Visibility -->
<v-col cols="12" md="6">
<v-select
v-model="eventData.event_type"
:items="eventTypes"
label="Event Type*"
:rules="[v => !!v || 'Event type is required']"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="eventData.visibility"
:items="visibilityOptions"
label="Visibility*"
:rules="[v => !!v || 'Visibility is required']"
variant="outlined"
required
/>
</v-col>
<!-- Date and Time -->
<v-col cols="12" md="6">
<v-text-field
v-model="eventData.start_datetime"
label="Start Date & Time*"
type="datetime-local"
:rules="[v => !!v || 'Start date is required']"
variant="outlined"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="eventData.end_datetime"
label="End Date & Time*"
type="datetime-local"
:rules="[
v => !!v || 'End date is required',
v => !eventData.start_datetime || new Date(v) > new Date(eventData.start_datetime) || 'End date must be after start date'
]"
variant="outlined"
required
/>
</v-col>
<!-- Location -->
<v-col cols="12">
<v-text-field
v-model="eventData.location"
label="Location"
variant="outlined"
/>
</v-col>
<!-- Capacity Settings -->
<v-col cols="12" md="6">
<v-text-field
v-model="eventData.max_attendees"
label="Maximum Attendees"
type="number"
variant="outlined"
hint="Leave empty for unlimited capacity"
persistent-hint
/>
</v-col>
<!-- Payment Settings -->
<v-col cols="12" md="6">
<v-switch
v-model="isPaidEvent"
label="Paid Event"
color="primary"
inset
/>
</v-col>
<!-- Payment Details (shown when paid event) -->
<template v-if="isPaidEvent">
<v-col cols="12" md="6">
<v-text-field
v-model="eventData.cost_members"
label="Cost for Members (€)"
type="number"
step="0.01"
variant="outlined"
:rules="isPaidEvent ? [v => !!v || 'Member cost is required'] : []"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="eventData.cost_non_members"
label="Cost for Non-Members (€)"
type="number"
step="0.01"
variant="outlined"
:rules="isPaidEvent ? [v => !!v || 'Non-member cost is required'] : []"
/>
</v-col>
<v-col cols="12">
<v-switch
v-model="memberPricingEnabled"
label="Enable Member Pricing"
color="primary"
inset
hint="Allow current members to pay member rates"
persistent-hint
/>
</v-col>
</template>
<!-- Advanced Options -->
<v-col cols="12">
<v-expansion-panels variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title>
<v-icon start>mdi-cog</v-icon>
Advanced Options
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-row>
<v-col cols="12" md="6">
<v-switch
v-model="isRecurring"
label="Recurring Event"
color="primary"
inset
hint="Create a series of events"
persistent-hint
/>
</v-col>
<v-col v-if="isRecurring" cols="12" md="6">
<v-select
v-model="recurrenceFrequency"
:items="recurrenceOptions"
label="Frequency"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="eventData.status"
:items="statusOptions"
label="Status"
variant="outlined"
/>
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer />
<v-btn
@click="close"
variant="outlined"
:disabled="loading"
>
Cancel
</v-btn>
<v-btn
@click="handleSubmit"
color="primary"
:loading="loading"
:disabled="!valid"
>
Create Event
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { EventCreateRequest } from '~/utils/types';
import { useAuth } from '~/composables/useAuth';
import { useEvents } from '~/composables/useEvents';
interface Props {
modelValue: boolean;
prefilledDate?: string;
prefilledEndDate?: string;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
prefilledDate: undefined,
prefilledEndDate: undefined
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'event-created': [event: any];
}>();
const { isAdmin } = useAuth();
const { createEvent } = useEvents();
// Reactive state
const form = ref();
const valid = ref(false);
const loading = ref(false);
const isPaidEvent = ref(false);
const memberPricingEnabled = ref(true);
const isRecurring = ref(false);
const recurrenceFrequency = ref('weekly');
// Form data
const eventData = reactive<EventCreateRequest>({
title: '',
description: '',
event_type: 'social',
start_datetime: '',
end_datetime: '',
location: '',
max_attendees: '',
is_paid: 'false',
cost_members: '',
cost_non_members: '',
member_pricing_enabled: 'true',
visibility: 'public',
status: 'active'
});
// Computed
const show = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
// Options
const eventTypes = [
{ title: 'Social Event', value: 'social' },
{ title: 'Meeting', value: 'meeting' },
{ title: 'Fundraiser', value: 'fundraiser' },
{ title: 'Workshop', value: 'workshop' },
{ title: 'Board Only', value: 'board-only' }
];
const visibilityOptions = computed(() => {
const options = [
{ title: 'Public', value: 'public' },
{ title: 'Board Only', value: 'board-only' }
];
if (isAdmin.value) {
options.push({ title: 'Admin Only', value: 'admin-only' });
}
return options;
});
const statusOptions = [
{ title: 'Active', value: 'active' },
{ title: 'Draft', value: 'draft' }
];
const recurrenceOptions = [
{ title: 'Weekly', value: 'weekly' },
{ title: 'Monthly', value: 'monthly' },
{ title: 'Yearly', value: 'yearly' }
];
// Watchers
watch(isPaidEvent, (newValue) => {
eventData.is_paid = newValue ? 'true' : 'false';
});
watch(memberPricingEnabled, (newValue) => {
eventData.member_pricing_enabled = newValue ? 'true' : 'false';
});
watch(isRecurring, (newValue) => {
eventData.is_recurring = newValue ? 'true' : 'false';
if (newValue) {
eventData.recurrence_pattern = JSON.stringify({
frequency: recurrenceFrequency.value,
interval: 1,
end_date: null
});
} else {
eventData.recurrence_pattern = '';
}
});
watch(recurrenceFrequency, (newValue) => {
if (isRecurring.value) {
eventData.recurrence_pattern = JSON.stringify({
frequency: newValue,
interval: 1,
end_date: null
});
}
});
// Watch for prefilled dates
watch(() => props.prefilledDate, (newDate) => {
if (newDate) {
eventData.start_datetime = newDate;
// Set end date 2 hours later if not provided
if (!props.prefilledEndDate) {
const endDate = new Date(newDate);
endDate.setHours(endDate.getHours() + 2);
eventData.end_datetime = endDate.toISOString().slice(0, 16);
}
}
}, { immediate: true });
watch(() => props.prefilledEndDate, (newEndDate) => {
if (newEndDate) {
eventData.end_datetime = newEndDate;
}
}, { immediate: true });
// Methods
const resetForm = () => {
eventData.title = '';
eventData.description = '';
eventData.event_type = 'social';
eventData.start_datetime = '';
eventData.end_datetime = '';
eventData.location = '';
eventData.max_attendees = '';
eventData.is_paid = 'false';
eventData.cost_members = '';
eventData.cost_non_members = '';
eventData.member_pricing_enabled = 'true';
eventData.visibility = 'public';
eventData.status = 'active';
eventData.is_recurring = 'false';
eventData.recurrence_pattern = '';
isPaidEvent.value = false;
memberPricingEnabled.value = true;
isRecurring.value = false;
recurrenceFrequency.value = 'weekly';
form.value?.resetValidation();
};
const close = () => {
show.value = false;
resetForm();
};
const handleSubmit = async () => {
if (!form.value) return;
const isValid = await form.value.validate();
if (!isValid.valid) return;
loading.value = true;
try {
// Ensure datetime strings are properly formatted
const startDate = new Date(eventData.start_datetime);
const endDate = new Date(eventData.end_datetime);
const formattedEventData = {
...eventData,
start_datetime: startDate.toISOString(),
end_datetime: endDate.toISOString()
};
const newEvent = await createEvent(formattedEventData);
emit('event-created', newEvent);
// Show success message
// TODO: Add toast/snackbar notification
console.log('Event created successfully:', newEvent);
close();
} catch (error: any) {
console.error('Error creating event:', error);
// TODO: Add error toast/snackbar notification
} finally {
loading.value = false;
}
};
// Initialize form when dialog opens
watch(show, (isOpen) => {
if (isOpen && props.prefilledDate) {
eventData.start_datetime = props.prefilledDate;
if (props.prefilledEndDate) {
eventData.end_datetime = props.prefilledEndDate;
} else {
// Set end date 2 hours later
const endDate = new Date(props.prefilledDate);
endDate.setHours(endDate.getHours() + 2);
eventData.end_datetime = endDate.toISOString().slice(0, 16);
}
}
});
</script>
<style scoped>
.v-card {
max-height: 90vh;
overflow-y: auto;
}
.v-expansion-panel-title {
font-weight: 500;
}
.v-switch {
flex: 0 0 auto;
}
.v-text-field :deep(.v-field__input) {
min-height: 56px;
}
</style>

View File

@@ -59,7 +59,7 @@
Days Overdue
</span>
<span class="text-body-2 font-weight-bold text-error">
{{ member.overdueDays || 0 }} days
{{ calculateDisplayOverdueDays(member) }} days
</span>
</div>
@@ -234,6 +234,9 @@ interface DuesMember extends Member {
overdueReason?: string;
daysUntilDue?: number;
nextDueDate?: string;
membership_date_paid?: string;
payment_due_date?: string;
current_year_dues_paid?: string;
}
interface Props {
@@ -316,6 +319,47 @@ const daysDifference = computed(() => {
});
// Methods
const calculateDisplayOverdueDays = (member: DuesMember): number => {
// First try to use the pre-calculated overdue days from the API
if (member.overdueDays !== undefined && member.overdueDays > 0) {
return member.overdueDays;
}
// Fallback calculation if not provided
const today = new Date();
const DAYS_IN_YEAR = 365;
// Check if payment is over 1 year old
if (member.membership_date_paid) {
try {
const lastPaidDate = new Date(member.membership_date_paid);
const oneYearFromPayment = new Date(lastPaidDate);
oneYearFromPayment.setFullYear(oneYearFromPayment.getFullYear() + 1);
if (today > oneYearFromPayment) {
const daysSincePayment = Math.floor((today.getTime() - lastPaidDate.getTime()) / (1000 * 60 * 60 * 24));
return Math.max(0, daysSincePayment - DAYS_IN_YEAR);
}
} catch {
// Fall through to due date check
}
}
// Check if past due date
if (member.payment_due_date) {
try {
const dueDate = new Date(member.payment_due_date);
if (today > dueDate) {
return Math.floor((today.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24));
}
} catch {
// Invalid date
}
}
return 0;
};
const formatDate = (dateString: string): string => {
if (!dateString) return '';

View File

@@ -209,20 +209,19 @@ const isPaymentOverOneYear = computed(() => {
});
/**
* Check if dues are actually current
* Uses the same logic as dues-status API and MemberCard
* Check if dues need to be paid (either overdue or in grace period)
* Banner should show when payment is needed
*/
const isDuesActuallyCurrent = computed(() => {
const needsPayment = computed(() => {
if (!memberData.value) return false;
const paymentTooOld = isPaymentOverOneYear.value;
const duesCurrentlyPaid = memberData.value.current_year_dues_paid === 'true';
const gracePeriod = isInGracePeriod.value;
const paymentTooOld = isPaymentOverOneYear.value;
// Member is NOT overdue if they're in grace period OR (dues paid AND payment not too old)
const isOverdue = paymentTooOld || (!duesCurrentlyPaid && !gracePeriod);
return !isOverdue;
// Show banner if:
// 1. Dues are not currently paid (regardless of grace period)
// 2. OR payment is over 1 year old (even if marked as paid)
return !duesCurrentlyPaid || paymentTooOld;
});
// Computed properties
@@ -230,8 +229,8 @@ const shouldShowBanner = computed(() => {
if (!user.value || !memberData.value) return false;
if (dismissed.value) return false;
// Show banner if dues are NOT current
return !isDuesActuallyCurrent.value;
// Show banner when payment is needed
return needsPayment.value;
});
const daysRemaining = computed(() => {
@@ -334,7 +333,7 @@ async function loadMemberData() {
// Load configuration and check banner visibility
async function loadConfig() {
try {
const response = await $fetch('/api/admin/registration-config') as any;
const response = await $fetch('/api/registration-config') as any;
if (response?.success) {
config.value = response.data;
}

View File

@@ -0,0 +1,410 @@
<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>

View File

@@ -0,0 +1,502 @@
<template>
<v-dialog v-model="show" max-width="600" persistent>
<v-card v-if="event">
<v-card-title class="d-flex justify-space-between align-center">
<div class="d-flex align-center">
<v-icon class="me-2" :color="eventTypeColor">{{ eventTypeIcon }}</v-icon>
<span>{{ event.title }}</span>
</div>
<v-btn
@click="close"
icon
variant="text"
size="small"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<!-- Event Type Badge -->
<v-chip
:color="eventTypeColor"
size="small"
variant="tonal"
class="mb-4"
>
<v-icon start>{{ eventTypeIcon }}</v-icon>
{{ eventTypeLabel }}
</v-chip>
<!-- Event Details -->
<v-row class="mb-4">
<!-- Date & Time -->
<v-col cols="12">
<div class="d-flex align-center mb-2">
<v-icon class="me-2">mdi-calendar-clock</v-icon>
<div>
<div class="font-weight-medium">{{ formatEventDate }}</div>
<div class="text-body-2 text-medium-emphasis">{{ formatEventTime }}</div>
</div>
</div>
</v-col>
<!-- Location -->
<v-col v-if="event.location" cols="12">
<div class="d-flex align-center mb-2">
<v-icon class="me-2">mdi-map-marker</v-icon>
<span>{{ event.location }}</span>
</div>
</v-col>
<!-- Description -->
<v-col v-if="event.description" cols="12">
<div class="d-flex align-start mb-2">
<v-icon class="me-2 mt-1">mdi-text</v-icon>
<div>
<div class="font-weight-medium mb-1">Description</div>
<div class="text-body-2">{{ event.description }}</div>
</div>
</div>
</v-col>
<!-- Capacity -->
<v-col v-if="event.max_attendees" cols="12">
<div class="d-flex align-center mb-2">
<v-icon class="me-2">mdi-account-group</v-icon>
<div>
<span class="font-weight-medium">Capacity:</span>
<span class="ms-2">
{{ event.current_attendees || 0 }} / {{ event.max_attendees }}
</span>
<v-progress-linear
:model-value="capacityPercentage"
:color="capacityColor"
height="4"
class="mt-1"
rounded
/>
</div>
</div>
</v-col>
</v-row>
<!-- Payment Information -->
<v-alert
v-if="event.is_paid === 'true'"
type="info"
variant="tonal"
class="mb-4"
>
<v-alert-title>
<v-icon start>mdi-currency-eur</v-icon>
Payment Required
</v-alert-title>
<div class="mt-2">
<div v-if="memberPrice && nonMemberPrice">
<strong>Members:</strong> {{ memberPrice }}<br>
<strong>Non-Members:</strong> {{ nonMemberPrice }}
</div>
<div v-else-if="memberPrice">
<strong>Cost:</strong> {{ memberPrice }}
</div>
<div v-if="event.member_pricing_enabled === 'false'" class="text-caption mt-1">
<v-icon size="small">mdi-information</v-icon>
Member pricing is not available for this event
</div>
</div>
</v-alert>
<!-- RSVP Status -->
<v-card
v-if="hasRSVP"
variant="outlined"
class="mb-4"
:color="rsvpStatusColor"
>
<v-card-text class="py-3">
<div class="d-flex align-center justify-space-between">
<div class="d-flex align-center">
<v-icon :color="rsvpStatusColor" class="me-2">{{ rsvpStatusIcon }}</v-icon>
<div>
<div class="font-weight-medium">{{ rsvpStatusText }}</div>
<div v-if="userRSVP?.rsvp_notes" class="text-caption">{{ userRSVP.rsvp_notes }}</div>
</div>
</div>
<v-btn
@click="changeRSVP"
size="small"
variant="outlined"
:color="rsvpStatusColor"
>
Change
</v-btn>
</div>
</v-card-text>
</v-card>
<!-- Payment Details (if RSVP'd to paid event) -->
<v-card
v-if="showPaymentDetails"
variant="outlined"
class="mb-4"
>
<v-card-title class="py-3">
<v-icon class="me-2">mdi-bank-transfer</v-icon>
Payment Details
</v-card-title>
<v-card-text class="pt-0">
<v-row dense>
<v-col cols="12">
<div class="text-body-2">
<strong>Amount:</strong> €{{ paymentAmount }}
</div>
</v-col>
<v-col cols="12">
<div class="text-body-2">
<strong>IBAN:</strong> {{ paymentInfo.iban }}
</div>
</v-col>
<v-col cols="12">
<div class="text-body-2">
<strong>Recipient:</strong> {{ paymentInfo.recipient }}
</div>
</v-col>
<v-col cols="12">
<div class="text-body-2">
<strong>Reference:</strong> {{ userRSVP?.payment_reference }}
</div>
</v-col>
</v-row>
<v-btn
@click="copyPaymentDetails"
size="small"
variant="outlined"
class="mt-3"
prepend-icon="mdi-content-copy"
>
Copy Details
</v-btn>
</v-card-text>
</v-card>
<!-- RSVP Form -->
<v-card v-if="!hasRSVP && canRSVP" variant="outlined">
<v-card-title class="py-3">
<v-icon class="me-2">mdi-account-check</v-icon>
RSVP to this Event
</v-card-title>
<v-card-text class="pt-0">
<v-form v-model="rsvpValid" @submit.prevent="submitRSVP">
<v-textarea
v-model="rsvpNotes"
label="Notes (optional)"
rows="2"
variant="outlined"
class="mb-3"
/>
<div class="d-flex gap-2">
<v-btn
@click="submitRSVP('confirmed')"
color="success"
:loading="rsvpLoading"
:disabled="isEventFull && !isWaitlistAvailable"
>
<v-icon start>mdi-check</v-icon>
{{ isEventFull ? 'Join Waitlist' : 'Confirm Attendance' }}
</v-btn>
<v-btn
@click="submitRSVP('declined')"
color="error"
variant="outlined"
:loading="rsvpLoading"
>
<v-icon start>mdi-close</v-icon>
Decline
</v-btn>
</div>
</v-form>
</v-card-text>
</v-card>
<!-- Event Full Message -->
<v-alert
v-if="isEventFull && !hasRSVP && !isWaitlistAvailable"
type="warning"
variant="tonal"
>
<v-alert-title>Event Full</v-alert-title>
This event has reached maximum capacity and waitlist is not available.
</v-alert>
<!-- Past Event Message -->
<v-alert
v-if="isPastEvent"
type="info"
variant="tonal"
>
<v-alert-title>Past Event</v-alert-title>
This event has already occurred.
</v-alert>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer />
<v-btn
@click="close"
variant="outlined"
>
Close
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { Event, EventRSVP } from '~/utils/types';
import { useEvents } from '~/composables/useEvents';
import { format } from 'date-fns';
interface Props {
modelValue: boolean;
event: Event | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'rsvp-updated': [event: Event];
}>();
const { rsvpToEvent } = useEvents();
// Reactive state
const rsvpValid = ref(false);
const rsvpLoading = ref(false);
const rsvpNotes = ref('');
// Computed properties
const show = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const userRSVP = computed((): EventRSVP | null => {
return props.event?.user_rsvp || null;
});
const hasRSVP = computed(() => !!userRSVP.value);
const canRSVP = computed(() => {
return props.event && !isPastEvent.value;
});
const isPastEvent = computed(() => {
if (!props.event) return false;
return new Date(props.event.start_datetime) < new Date();
});
const isEventFull = computed(() => {
if (!props.event?.max_attendees) return false;
const maxAttendees = parseInt(props.event.max_attendees);
const currentAttendees = props.event.current_attendees || 0;
return currentAttendees >= maxAttendees;
});
const isWaitlistAvailable = computed(() => true); // Always allow waitlist for now
const eventTypeColor = computed(() => {
const colors = {
'meeting': 'blue',
'social': 'green',
'fundraiser': 'orange',
'workshop': 'purple',
'board-only': 'red'
};
return colors[props.event?.event_type as keyof typeof colors] || 'grey';
});
const eventTypeIcon = computed(() => {
const icons = {
'meeting': 'mdi-account-group',
'social': 'mdi-party-popper',
'fundraiser': 'mdi-heart',
'workshop': 'mdi-school',
'board-only': 'mdi-shield-account'
};
return icons[props.event?.event_type as keyof typeof icons] || 'mdi-calendar';
});
const eventTypeLabel = computed(() => {
const labels = {
'meeting': 'Meeting',
'social': 'Social Event',
'fundraiser': 'Fundraiser',
'workshop': 'Workshop',
'board-only': 'Board Only'
};
return labels[props.event?.event_type as keyof typeof labels] || 'Event';
});
const formatEventDate = computed(() => {
if (!props.event) return '';
const startDate = new Date(props.event.start_datetime);
const endDate = new Date(props.event.end_datetime);
if (startDate.toDateString() === endDate.toDateString()) {
return format(startDate, 'EEEE, MMMM d, yyyy');
} else {
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d, yyyy')}`;
}
});
const formatEventTime = computed(() => {
if (!props.event) return '';
const startDate = new Date(props.event.start_datetime);
const endDate = new Date(props.event.end_datetime);
return `${format(startDate, 'HH:mm')} - ${format(endDate, 'HH:mm')}`;
});
const capacityPercentage = computed(() => {
if (!props.event?.max_attendees) return 0;
const max = parseInt(props.event.max_attendees);
const current = props.event.current_attendees || 0;
return (current / max) * 100;
});
const capacityColor = computed(() => {
const percentage = capacityPercentage.value;
if (percentage >= 100) return 'error';
if (percentage >= 80) return 'warning';
return 'success';
});
const memberPrice = computed(() => props.event?.cost_members);
const nonMemberPrice = computed(() => props.event?.cost_non_members);
const rsvpStatusColor = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'success';
case 'waitlist': return 'warning';
case 'declined': return 'error';
default: return 'info';
}
});
const rsvpStatusIcon = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'mdi-check-circle';
case 'waitlist': return 'mdi-clock';
case 'declined': return 'mdi-close-circle';
default: return 'mdi-help-circle';
}
});
const rsvpStatusText = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'You are attending this event';
case 'waitlist': return 'You are on the waitlist';
case 'declined': return 'You declined this event';
default: return 'Status unknown';
}
});
const showPaymentDetails = computed(() => {
return props.event?.is_paid === 'true' &&
userRSVP.value?.rsvp_status === 'confirmed' &&
userRSVP.value?.payment_status === 'pending';
});
const paymentAmount = computed(() => {
if (!userRSVP.value || !props.event) return '0';
const isMemberPricing = userRSVP.value.is_member_pricing === 'true';
return isMemberPricing ? props.event.cost_members : props.event.cost_non_members;
});
const paymentInfo = computed(() => ({
iban: 'FR76 1234 5678 9012 3456 7890 123', // This should come from config
recipient: 'MonacoUSA Association' // This should come from config
}));
// Methods
const close = () => {
show.value = false;
rsvpNotes.value = '';
};
const submitRSVP = async (status: 'confirmed' | 'declined') => {
if (!props.event) return;
rsvpLoading.value = true;
try {
await rsvpToEvent(props.event.id, {
member_id: '', // This will be filled by the composable
rsvp_status: status,
rsvp_notes: rsvpNotes.value
});
emit('rsvp-updated', props.event);
// TODO: Show success message
} catch (error) {
console.error('Error submitting RSVP:', error);
// TODO: Show error message
} finally {
rsvpLoading.value = false;
}
};
const changeRSVP = () => {
// For now, just allow re-submitting RSVP
// In the future, this could open an edit dialog
if (userRSVP.value?.rsvp_status === 'confirmed') {
submitRSVP('declined');
} else if (userRSVP.value?.rsvp_status === 'declined') {
submitRSVP('confirmed');
}
};
const copyPaymentDetails = async () => {
const details = `
Event: ${props.event?.title}
Amount: €${paymentAmount.value}
IBAN: ${paymentInfo.value.iban}
Recipient: ${paymentInfo.value.recipient}
Reference: ${userRSVP.value?.payment_reference}
`.trim();
try {
await navigator.clipboard.writeText(details);
// TODO: Show success toast
console.log('Payment details copied to clipboard');
} catch (error) {
console.error('Error copying to clipboard:', error);
// TODO: Show error toast
}
};
</script>
<style scoped>
.v-card {
max-height: 90vh;
overflow-y: auto;
}
.text-medium-emphasis {
opacity: 0.7;
}
.v-progress-linear {
max-width: 200px;
}
</style>

View File

@@ -0,0 +1,282 @@
<template>
<v-banner
v-if="event"
:color="bannerColor"
lines="two"
elevation="2"
rounded
>
<template #prepend>
<v-icon :color="iconColor" size="large">{{ eventIcon }}</v-icon>
</template>
<template #text>
<div class="d-flex flex-column">
<div class="text-h6 font-weight-bold mb-1">{{ event.title }}</div>
<div class="d-flex flex-wrap align-center ga-4 text-body-2">
<div class="d-flex align-center">
<v-icon size="small" class="me-1">mdi-calendar-clock</v-icon>
<span>{{ formatEventDate }}</span>
</div>
<div v-if="event.location" class="d-flex align-center">
<v-icon size="small" class="me-1">mdi-map-marker</v-icon>
<span>{{ event.location }}</span>
</div>
<div v-if="event.is_paid === 'true'" class="d-flex align-center">
<v-icon size="small" class="me-1">mdi-currency-eur</v-icon>
<span>{{ memberPrice }}</span>
</div>
<div v-if="capacityInfo" class="d-flex align-center">
<v-icon size="small" class="me-1">mdi-account-group</v-icon>
<span>{{ capacityInfo }}</span>
</div>
</div>
</div>
</template>
<template #actions>
<div class="d-flex flex-column flex-sm-row ga-2">
<!-- RSVP Status -->
<v-chip
v-if="userRSVP"
:color="rsvpStatusColor"
size="small"
variant="flat"
>
<v-icon start size="small">{{ rsvpStatusIcon }}</v-icon>
{{ rsvpStatusText }}
</v-chip>
<!-- Action Buttons -->
<v-btn
@click="handleViewEvent"
variant="elevated"
color="white"
size="small"
prepend-icon="mdi-eye"
>
View Details
</v-btn>
<v-btn
v-if="!userRSVP && canRSVP"
@click="handleQuickRSVP"
:color="quickRSVPColor"
size="small"
prepend-icon="mdi-check"
>
Quick RSVP
</v-btn>
</div>
</template>
</v-banner>
</template>
<script setup lang="ts">
import type { Event, EventRSVP } from '~/utils/types';
import { format, isWithinInterval, addDays } from 'date-fns';
interface Props {
event: Event | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'event-click': [event: Event];
'quick-rsvp': [event: Event];
}>();
// Computed properties
const userRSVP = computed((): EventRSVP | null => {
return props.event?.user_rsvp || null;
});
const canRSVP = computed(() => {
if (!props.event) return false;
const eventDate = new Date(props.event.start_datetime);
const now = new Date();
return eventDate > now; // Can RSVP to future events
});
const eventIcon = computed(() => {
if (!props.event) return 'mdi-calendar';
const icons = {
'meeting': 'mdi-account-group',
'social': 'mdi-party-popper',
'fundraiser': 'mdi-heart',
'workshop': 'mdi-school',
'board-only': 'mdi-shield-account'
};
return icons[props.event.event_type as keyof typeof icons] || 'mdi-calendar';
});
const bannerColor = computed(() => {
if (!props.event) return 'primary';
// Check if event is soon (within 24 hours)
const eventDate = new Date(props.event.start_datetime);
const now = new Date();
const isSoon = isWithinInterval(eventDate, {
start: now,
end: addDays(now, 1)
});
if (isSoon) return 'warning';
const colors = {
'meeting': 'blue',
'social': 'green',
'fundraiser': 'orange',
'workshop': 'purple',
'board-only': 'red'
};
return colors[props.event.event_type as keyof typeof colors] || 'primary';
});
const iconColor = computed(() => {
// Use white for better contrast on colored backgrounds
return 'white';
});
const formatEventDate = computed(() => {
if (!props.event) return '';
const startDate = new Date(props.event.start_datetime);
const endDate = new Date(props.event.end_datetime);
const now = new Date();
// Different formats based on timing
if (startDate.toDateString() === now.toDateString()) {
return `Today at ${format(startDate, 'HH:mm')}`;
}
if (startDate.toDateString() === addDays(now, 1).toDateString()) {
return `Tomorrow at ${format(startDate, 'HH:mm')}`;
}
if (startDate.toDateString() === endDate.toDateString()) {
return format(startDate, 'EEE, MMM d • HH:mm');
}
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`;
});
const memberPrice = computed(() => {
if (!props.event || props.event.is_paid !== 'true') return '';
if (props.event.cost_members && props.event.cost_non_members) {
return `${props.event.cost_members} (Members)`;
}
return `${props.event.cost_members || props.event.cost_non_members}`;
});
const capacityInfo = computed(() => {
if (!props.event?.max_attendees) return '';
const current = props.event.current_attendees || 0;
const max = parseInt(props.event.max_attendees);
return `${current}/${max} attending`;
});
const rsvpStatusColor = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'success';
case 'waitlist': return 'warning';
case 'declined': return 'error';
default: return 'info';
}
});
const rsvpStatusIcon = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'mdi-check';
case 'waitlist': return 'mdi-clock';
case 'declined': return 'mdi-close';
default: return 'mdi-help';
}
});
const rsvpStatusText = computed(() => {
const status = userRSVP.value?.rsvp_status;
switch (status) {
case 'confirmed': return 'Attending';
case 'waitlist': return 'Waitlisted';
case 'declined': return 'Declined';
default: return 'Unknown';
}
});
const quickRSVPColor = computed(() => {
return bannerColor.value === 'warning' ? 'success' : 'white';
});
// Methods
const handleViewEvent = () => {
if (props.event) {
emit('event-click', props.event);
}
};
const handleQuickRSVP = () => {
if (props.event) {
emit('quick-rsvp', props.event);
}
};
</script>
<style scoped>
.v-banner :deep(.v-banner__wrapper) {
padding: 16px 24px;
}
.v-banner :deep(.v-banner__prepend) {
margin-inline-end: 16px;
}
.v-banner :deep(.v-banner__actions) {
margin-inline-start: 16px;
}
/* Mobile optimizations */
@media (max-width: 600px) {
.v-banner :deep(.v-banner__wrapper) {
padding: 12px 16px;
}
.v-banner :deep(.v-banner__prepend) {
margin-inline-end: 12px;
}
.v-banner :deep(.v-banner__actions) {
margin-inline-start: 0;
margin-top: 8px;
}
.text-h6 {
font-size: 1.1rem !important;
}
}
/* Ensure proper spacing on different screen sizes */
.ga-4 {
gap: 16px;
}
.ga-2 {
gap: 8px;
}
@media (max-width: 600px) {
.ga-4 {
gap: 8px;
}
}
</style>