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

- 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:
Matt 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>

315
composables/useEvents.ts Normal file
View File

@ -0,0 +1,315 @@
// composables/useEvents.ts
import type { Event, EventsResponse, EventFilters, EventCreateRequest, EventRSVPRequest } from '~/utils/types';
export const useEvents = () => {
const events = ref<Event[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const upcomingEvent = ref<Event | null>(null);
const cache = reactive<Map<string, { data: Event[]; timestamp: number }>>(new Map());
const CACHE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
// Get authenticated user info
const { user, userTier } = useAuth();
/**
* Fetch events with optional filtering and caching
*/
const fetchEvents = async (filters?: EventFilters & { force?: boolean }) => {
loading.value = true;
error.value = null;
try {
// Create cache key
const cacheKey = JSON.stringify(filters || {});
const cached = cache.get(cacheKey);
// Check cache if not forcing refresh
if (!filters?.force && cached) {
const now = Date.now();
if (now - cached.timestamp < CACHE_TIMEOUT) {
events.value = cached.data;
loading.value = false;
return cached.data;
}
}
// Default date range (current month + 2 months ahead)
const defaultFilters: EventFilters = {
start_date: startOfMonth(new Date()).toISOString(),
end_date: endOfMonth(addMonths(new Date(), 2)).toISOString(),
user_role: userTier.value,
...filters
};
const response = await $fetch<EventsResponse>('/api/events', {
query: {
...defaultFilters,
calendar_format: 'false'
}
});
if (response.success) {
events.value = response.data;
// Cache the results
cache.set(cacheKey, {
data: response.data,
timestamp: Date.now()
});
// Update upcoming event
updateUpcomingEvent(response.data);
return response.data;
} else {
throw new Error(response.message || 'Failed to fetch events');
}
} catch (err: any) {
error.value = err.message || 'Failed to load events';
console.error('Error fetching events:', err);
throw err;
} finally {
loading.value = false;
}
};
/**
* Create a new event (board/admin only)
*/
const createEvent = async (eventData: EventCreateRequest) => {
loading.value = true;
error.value = null;
try {
const response = await $fetch<{ success: boolean; data: Event; message: string }>('/api/events', {
method: 'POST',
body: eventData
});
if (response.success) {
// Clear cache and refresh events
cache.clear();
await fetchEvents({ force: true });
return response.data;
} else {
throw new Error(response.message || 'Failed to create event');
}
} catch (err: any) {
error.value = err.message || 'Failed to create event';
console.error('Error creating event:', err);
throw err;
} finally {
loading.value = false;
}
};
/**
* RSVP to an event
*/
const rsvpToEvent = async (eventId: string, rsvpData: Omit<EventRSVPRequest, 'event_id'>) => {
loading.value = true;
error.value = null;
try {
const response = await $fetch(`/api/events/${eventId}/rsvp`, {
method: 'POST',
body: {
...rsvpData,
event_id: eventId,
member_id: user.value?.id || ''
}
});
if (response.success) {
// Update local event data
const eventIndex = events.value.findIndex(e => e.id === eventId);
if (eventIndex !== -1) {
events.value[eventIndex].user_rsvp = response.data;
// Update attendee count if confirmed
if (rsvpData.rsvp_status === 'confirmed') {
const currentCount = events.value[eventIndex].current_attendees || 0;
events.value[eventIndex].current_attendees = currentCount + 1;
}
}
// Clear cache
cache.clear();
return response.data;
} else {
throw new Error(response.message || 'Failed to RSVP');
}
} catch (err: any) {
error.value = err.message || 'Failed to RSVP to event';
console.error('Error RSVPing to event:', err);
throw err;
} finally {
loading.value = false;
}
};
/**
* Update attendance for an event (board/admin only)
*/
const updateAttendance = async (eventId: string, memberId: string, attended: boolean) => {
loading.value = true;
error.value = null;
try {
const response = await $fetch(`/api/events/${eventId}/attendees`, {
method: 'PATCH',
body: {
event_id: eventId,
member_id: memberId,
attended
}
});
if (response.success) {
// Update local event data
const eventIndex = events.value.findIndex(e => e.id === eventId);
if (eventIndex !== -1 && events.value[eventIndex].attendee_list) {
const attendeeIndex = events.value[eventIndex].attendee_list!.findIndex(
a => a.member_id === memberId
);
if (attendeeIndex !== -1) {
events.value[eventIndex].attendee_list![attendeeIndex].attended = attended ? 'true' : 'false';
}
}
return response.data;
} else {
throw new Error(response.message || 'Failed to update attendance');
}
} catch (err: any) {
error.value = err.message || 'Failed to update attendance';
console.error('Error updating attendance:', err);
throw err;
} finally {
loading.value = false;
}
};
/**
* Get events for calendar display
*/
const getCalendarEvents = async (start: string, end: string) => {
try {
const response = await $fetch<EventsResponse>('/api/events', {
query: {
start_date: start,
end_date: end,
user_role: userTier.value,
calendar_format: 'true'
}
});
if (response.success) {
return response.data;
}
return [];
} catch (err) {
console.error('Error fetching calendar events:', err);
return [];
}
};
/**
* Get upcoming events for banners/widgets
*/
const getUpcomingEvents = (limit = 5): Event[] => {
const now = new Date();
return events.value
.filter(event => new Date(event.start_datetime) >= now)
.sort((a, b) => new Date(a.start_datetime).getTime() - new Date(b.start_datetime).getTime())
.slice(0, limit);
};
/**
* Find event by ID
*/
const findEventById = (eventId: string): Event | undefined => {
return events.value.find(event => event.id === eventId);
};
/**
* Check if user has RSVP'd to an event
*/
const hasUserRSVP = (eventId: string): boolean => {
const event = findEventById(eventId);
return !!event?.user_rsvp;
};
/**
* Get user's RSVP status for an event
*/
const getUserRSVPStatus = (eventId: string): string | null => {
const event = findEventById(eventId);
return event?.user_rsvp?.rsvp_status || null;
};
/**
* Update the upcoming event reference
*/
const updateUpcomingEvent = (eventList: Event[]) => {
const upcoming = eventList
.filter(event => new Date(event.start_datetime) >= new Date())
.sort((a, b) => new Date(a.start_datetime).getTime() - new Date(b.start_datetime).getTime());
upcomingEvent.value = upcoming.length > 0 ? upcoming[0] : null;
};
/**
* Clear cache manually
*/
const clearCache = () => {
cache.clear();
};
/**
* Refresh events data
*/
const refreshEvents = async () => {
clearCache();
return await fetchEvents({ force: true });
};
// Utility functions for date handling
function startOfMonth(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), 1);
}
function endOfMonth(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth() + 1, 0);
}
function addMonths(date: Date, months: number): Date {
const result = new Date(date);
result.setMonth(result.getMonth() + months);
return result;
}
return {
// Reactive state
events: readonly(events),
loading: readonly(loading),
error: readonly(error),
upcomingEvent: readonly(upcomingEvent),
// Methods
fetchEvents,
createEvent,
rsvpToEvent,
updateAttendance,
getCalendarEvents,
getUpcomingEvents,
findEventById,
hasUserRSVP,
getUserRSVPStatus,
clearCache,
refreshEvents
};
};

View File

@ -26,6 +26,13 @@
value="dashboard"
/>
<v-list-item
to="/dashboard/events"
prepend-icon="mdi-calendar"
title="Events"
value="events"
/>
<v-list-item
to="/dashboard/user"
prepend-icon="mdi-account"

View File

@ -116,6 +116,8 @@ export default defineNuxtConfig({
url: process.env.NUXT_NOCODB_URL || "",
token: process.env.NUXT_NOCODB_TOKEN || "",
baseId: process.env.NUXT_NOCODB_BASE_ID || "",
eventsBaseId: process.env.NUXT_NOCODB_EVENTS_BASE_ID || "",
eventsTableId: process.env.NUXT_NOCODB_EVENTS_TABLE_ID || "",
},
minio: {
endPoint: process.env.NUXT_MINIO_ENDPOINT || "s3.monacousa.org",

72
package-lock.json generated
View File

@ -7,6 +7,11 @@
"name": "monacousa-portal",
"hasInstallScript": true,
"dependencies": {
"@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/list": "^6.1.19",
"@fullcalendar/vue3": "^6.1.19",
"@nuxt/ui": "^3.2.0",
"@nuxtjs/device": "^3.2.4",
"@types/handlebars": "^4.0.40",
@ -14,6 +19,7 @@
"@types/nodemailer": "^6.4.17",
"@vite-pwa/nuxt": "^0.10.8",
"cookie": "^0.6.0",
"date-fns": "^4.1.0",
"flag-icons": "^7.5.0",
"formidable": "^3.5.4",
"handlebars": "^4.7.8",
@ -2238,6 +2244,52 @@
}
}
},
"node_modules/@fullcalendar/core": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.19.tgz",
"integrity": "sha512-z0aVlO5e4Wah6p6mouM0UEqtRf1MZZPt4mwzEyU6kusaNL+dlWQgAasF2cK23hwT4cmxkEmr4inULXgpyeExdQ==",
"license": "MIT",
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.19.tgz",
"integrity": "sha512-IAAfnMICnVWPjpT4zi87i3FEw0xxSza0avqY/HedKEz+l5MTBYvCDPOWDATpzXoLut3aACsjktIyw9thvIcRYQ==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@fullcalendar/interaction": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.19.tgz",
"integrity": "sha512-GOciy79xe8JMVp+1evAU3ytdwN/7tv35t5i1vFkifiuWcQMLC/JnLg/RA2s4sYmQwoYhTw/p4GLcP0gO5B3X5w==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@fullcalendar/list": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.19.tgz",
"integrity": "sha512-knZHpAVF0LbzZpSJSUmLUUzF0XlU/MRGK+Py2s0/mP93bCtno1k2L3XPs/kzh528hSjehwLm89RgKTSfW1P6cA==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.19"
}
},
"node_modules/@fullcalendar/vue3": {
"version": "6.1.19",
"resolved": "https://registry.npmjs.org/@fullcalendar/vue3/-/vue3-6.1.19.tgz",
"integrity": "sha512-j5eUSxx0xIy3ADljo0f5B9PhjqXnCQ+7nUMPfsslc2eGVjp4F74YvY3dyd6OBbg13IvpsjowkjncGipYMQWmTA==",
"license": "MIT",
"peerDependencies": {
"@fullcalendar/core": "~6.1.19",
"vue": "^3.0.11"
}
},
"node_modules/@iconify/collections": {
"version": "1.0.576",
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.576.tgz",
@ -8477,6 +8529,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/db0": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.2.tgz",
@ -14430,6 +14492,16 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/precinct": {
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/precinct/-/precinct-12.2.0.tgz",

View File

@ -10,6 +10,11 @@
"typecheck": "nuxt typecheck"
},
"dependencies": {
"@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/list": "^6.1.19",
"@fullcalendar/vue3": "^6.1.19",
"@nuxt/ui": "^3.2.0",
"@nuxtjs/device": "^3.2.4",
"@types/handlebars": "^4.0.40",
@ -17,6 +22,7 @@
"@types/nodemailer": "^6.4.17",
"@vite-pwa/nuxt": "^0.10.8",
"cookie": "^0.6.0",
"date-fns": "^4.1.0",
"flag-icons": "^7.5.0",
"formidable": "^3.5.4",
"handlebars": "^4.7.8",

View File

@ -311,6 +311,20 @@
</v-card>
</v-dialog>
<!-- View Member Dialog -->
<ViewMemberDialog
v-model="showViewDialog"
:member="selectedMember"
@edit="handleEditMember"
/>
<!-- Edit Member Dialog -->
<EditMemberDialog
v-model="showEditDialog"
:member="selectedMember"
@member-updated="handleMemberUpdated"
/>
<!-- Create User Dialog -->
<v-dialog v-model="showCreateUserDialog" max-width="600">
<v-card>
@ -408,6 +422,11 @@ const overdueCount = ref(0);
const overdueRefreshTrigger = ref(0);
const duesRefreshTrigger = ref(0);
// Member dialog state
const showViewDialog = ref(false);
const showEditDialog = ref(false);
const selectedMember = ref(null);
// Create user dialog data
const createUserValid = ref(false);
const creatingUser = ref(false);
@ -673,9 +692,16 @@ const handleStatusesUpdated = async (updatedCount: number) => {
};
const handleViewMember = (member: any) => {
// Navigate to member details or open modal
console.log('View member:', member.FullName || `${member.first_name} ${member.last_name}`);
navigateTo('/dashboard/member-list');
// Open the view dialog instead of navigating away
selectedMember.value = member;
showViewDialog.value = true;
};
const handleEditMember = (member: any) => {
// Close the view dialog and open the edit dialog
showViewDialog.value = false;
selectedMember.value = member;
showEditDialog.value = true;
};
const navigateToMembers = () => {
@ -686,6 +712,9 @@ const navigateToMembers = () => {
const handleMemberUpdated = (member: any) => {
console.log('Member updated:', member.FullName || `${member.first_name} ${member.last_name}`);
// Close edit dialog
showEditDialog.value = false;
// Trigger dues refresh
duesRefreshTrigger.value += 1;
};

View File

@ -235,6 +235,20 @@
</v-card>
</v-col>
</v-row>
<!-- View Member Dialog -->
<ViewMemberDialog
v-model="showViewDialog"
:member="selectedMember"
@edit="handleEditMember"
/>
<!-- Edit Member Dialog -->
<EditMemberDialog
v-model="showEditDialog"
:member="selectedMember"
@member-updated="handleMemberUpdated"
/>
</v-container>
</template>
@ -261,6 +275,11 @@ onMounted(() => {
// Dues management state
const duesRefreshTrigger = ref(0);
// Member dialog state
const showViewDialog = ref(false);
const showEditDialog = ref(false);
const selectedMember = ref(null);
// Mock data for board dashboard
const stats = ref({
totalMembers: 156,
@ -300,15 +319,24 @@ const recentActivity = ref([
// Dues management handlers
const handleViewMember = (member: Member) => {
// Navigate to member details or open modal
console.log('View member:', member.FullName || `${member.first_name} ${member.last_name}`);
// You could implement member detail view here
navigateToMembers();
// Open the view dialog instead of navigating away
selectedMember.value = member;
showViewDialog.value = true;
};
const handleEditMember = (member: Member) => {
// Close the view dialog and open the edit dialog
showViewDialog.value = false;
selectedMember.value = member;
showEditDialog.value = true;
};
const handleMemberUpdated = (member: Member) => {
console.log('Member updated:', member.FullName || `${member.first_name} ${member.last_name}`);
// Close edit dialog
showEditDialog.value = false;
// Trigger dues refresh to update the lists
duesRefreshTrigger.value += 1;

464
pages/dashboard/events.vue Normal file
View File

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

View File

@ -0,0 +1,95 @@
// server/api/events/[id]/attendees.patch.ts
import { createNocoDBEventsClient } from '~/server/utils/nocodb-events';
import type { EventAttendanceRequest } from '~/utils/types';
export default defineEventHandler(async (event) => {
try {
const eventId = getRouterParam(event, 'id');
const body = await readBody(event) as EventAttendanceRequest;
if (!eventId) {
throw createError({
statusCode: 400,
statusMessage: 'Event ID is required'
});
}
// Get user session
const session = await getUserSession(event);
if (!session || !session.user) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
});
}
// Check if user has permission to mark attendance (board or admin only)
if (session.user.tier !== 'board' && session.user.tier !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Only board members and administrators can mark attendance'
});
}
const eventsClient = createNocoDBEventsClient();
// Verify event exists
const eventDetails = await eventsClient.findOne(eventId);
if (!eventDetails) {
throw createError({
statusCode: 404,
statusMessage: 'Event not found'
});
}
// Find the user's RSVP record
const userRSVP = await eventsClient.findUserRSVP(eventId, body.member_id);
if (!userRSVP) {
throw createError({
statusCode: 404,
statusMessage: 'RSVP record not found for this member'
});
}
// Update attendance status
const updatedRSVP = await eventsClient.updateRSVP(userRSVP.id, {
attended: body.attended ? 'true' : 'false',
updated_at: new Date().toISOString()
});
return {
success: true,
data: updatedRSVP,
message: `Attendance ${body.attended ? 'marked' : 'unmarked'} successfully`
};
} catch (error) {
console.error('Error updating attendance:', error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to update attendance'
});
}
});
// Helper function
async function getUserSession(event: any) {
try {
const sessionCookie = getCookie(event, 'session') || getHeader(event, 'authorization');
if (!sessionCookie) return null;
return {
user: {
id: 'user-id',
tier: 'board' // Replace with actual session logic
}
};
} catch {
return null;
}
}

View File

@ -0,0 +1,199 @@
// server/api/events/[id]/rsvp.post.ts
import { createNocoDBEventsClient } from '~/server/utils/nocodb-events';
import { createNocoDBClient } from '~/server/utils/nocodb';
import type { EventRSVPRequest } from '~/utils/types';
export default defineEventHandler(async (event) => {
try {
const eventId = getRouterParam(event, 'id');
const body = await readBody(event) as EventRSVPRequest;
if (!eventId) {
throw createError({
statusCode: 400,
statusMessage: 'Event ID is required'
});
}
// Get user session
const session = await getUserSession(event);
if (!session || !session.user) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
});
}
const eventsClient = createNocoDBEventsClient();
const membersClient = createNocoDBClient();
// Get the event details
const eventDetails = await eventsClient.findOne(eventId);
if (!eventDetails) {
throw createError({
statusCode: 404,
statusMessage: 'Event not found'
});
}
// Check if event is active
if (eventDetails.status !== 'active') {
throw createError({
statusCode: 400,
statusMessage: 'Cannot RSVP to inactive events'
});
}
// Check if event is in the past
const eventDate = new Date(eventDetails.start_datetime);
const now = new Date();
if (eventDate < now) {
throw createError({
statusCode: 400,
statusMessage: 'Cannot RSVP to past events'
});
}
// Get member details for pricing logic
const member = await membersClient.findByKeycloakId(session.user.id);
if (!member) {
throw createError({
statusCode: 404,
statusMessage: 'Member record not found'
});
}
// Check if user already has an RSVP
const existingRSVP = await eventsClient.findUserRSVP(eventId, member.member_id || member.Id);
if (existingRSVP && body.rsvp_status === 'confirmed') {
// Update existing RSVP instead of creating new one
const updatedRSVP = await eventsClient.updateRSVP(existingRSVP.id, {
rsvp_status: body.rsvp_status,
rsvp_notes: body.rsvp_notes || '',
updated_at: new Date().toISOString()
});
return {
success: true,
data: updatedRSVP,
message: 'RSVP updated successfully'
};
}
// Check event capacity if confirming
if (body.rsvp_status === 'confirmed') {
const isFull = await eventsClient.isEventFull(eventId);
if (isFull) {
// Add to waitlist instead
body.rsvp_status = 'waitlist';
}
}
// Determine pricing and payment status
let paymentStatus = 'not_required';
let isMemberPricing = 'false';
if (eventDetails.is_paid === 'true' && body.rsvp_status === 'confirmed') {
paymentStatus = 'pending';
// Check if member qualifies for member pricing
const isDuesCurrent = member.current_year_dues_paid === 'true';
const memberPricingEnabled = eventDetails.member_pricing_enabled === 'true';
if (isDuesCurrent && memberPricingEnabled) {
isMemberPricing = 'true';
}
}
// Generate payment reference
const paymentReference = eventsClient.generatePaymentReference(
member.member_id || member.Id
);
// Create RSVP record
const rsvpData = {
event_id: eventId,
member_id: member.member_id || member.Id,
rsvp_status: body.rsvp_status,
payment_status: paymentStatus,
payment_reference: paymentReference,
attended: 'false',
rsvp_notes: body.rsvp_notes || '',
is_member_pricing: isMemberPricing
};
const newRSVP = await eventsClient.createRSVP(rsvpData);
// Update event attendee count if confirmed
if (body.rsvp_status === 'confirmed') {
const currentCount = parseInt(eventDetails.current_attendees) || 0;
await eventsClient.updateAttendeeCount(eventId, currentCount + 1);
}
// Include payment information in response for paid events
let responseData: any = newRSVP;
if (eventDetails.is_paid === 'true' && paymentStatus === 'pending') {
const registrationConfig = await getRegistrationConfig();
responseData = {
...newRSVP,
payment_info: {
cost: isMemberPricing === 'true' ?
eventDetails.cost_members :
eventDetails.cost_non_members,
iban: registrationConfig.iban,
recipient: registrationConfig.accountHolder,
reference: paymentReference,
member_pricing: isMemberPricing === 'true'
}
};
}
return {
success: true,
data: responseData,
message: body.rsvp_status === 'waitlist' ?
'Added to waitlist - event is full' :
'RSVP submitted successfully'
};
} catch (error) {
console.error('Error processing RSVP:', error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to process RSVP'
});
}
});
// Helper functions
async function getUserSession(event: any) {
try {
const sessionCookie = getCookie(event, 'session') || getHeader(event, 'authorization');
if (!sessionCookie) return null;
return {
user: {
id: 'user-id',
tier: 'user'
}
};
} catch {
return null;
}
}
async function getRegistrationConfig() {
// This should fetch from your admin config system
return {
iban: 'FR76 1234 5678 9012 3456 7890 123',
accountHolder: 'MonacoUSA Association'
};
}

View File

@ -0,0 +1,209 @@
// server/api/events/calendar-feed.get.ts
import { createNocoDBEventsClient } from '~/server/utils/nocodb-events';
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event);
const { user_id, user_role, format } = query;
if (!user_id) {
throw createError({
statusCode: 400,
statusMessage: 'User ID is required'
});
}
const eventsClient = createNocoDBEventsClient();
// Get events for the user (next 6 months)
const now = new Date();
const sixMonthsLater = new Date();
sixMonthsLater.setMonth(now.getMonth() + 6);
const filters = {
start_date: now.toISOString(),
end_date: sixMonthsLater.toISOString(),
user_role: (user_role as string) || 'user',
status: 'active'
};
const response = await eventsClient.findUserEvents(user_id as string, filters);
const events = response.list || [];
// Generate iCal content
const icalContent = generateICalContent(events, {
calendarName: 'MonacoUSA Events',
timezone: 'Europe/Paris',
includeRSVPStatus: true
});
// Set appropriate headers for calendar subscription
setHeader(event, 'Content-Type', 'text/calendar; charset=utf-8');
setHeader(event, 'Content-Disposition', 'attachment; filename="monacousa-events.ics"');
setHeader(event, 'Cache-Control', 'no-cache, must-revalidate');
return icalContent;
} catch (error) {
console.error('Error generating calendar feed:', error);
throw createError({
statusCode: 500,
statusMessage: 'Failed to generate calendar feed'
});
}
});
/**
* Generate iCal content from events array
*/
function generateICalContent(events: any[], options: {
calendarName: string;
timezone: string;
includeRSVPStatus: boolean;
}): string {
const now = new Date();
const lines: string[] = [];
// iCal header
lines.push('BEGIN:VCALENDAR');
lines.push('VERSION:2.0');
lines.push('PRODID:-//MonacoUSA//Portal//EN');
lines.push(`X-WR-CALNAME:${options.calendarName}`);
lines.push(`X-WR-TIMEZONE:${options.timezone}`);
lines.push('X-WR-CALDESC:Events from MonacoUSA Portal');
lines.push('CALSCALE:GREGORIAN');
lines.push('METHOD:PUBLISH');
// Add timezone definition
lines.push('BEGIN:VTIMEZONE');
lines.push(`TZID:${options.timezone}`);
lines.push('BEGIN:STANDARD');
lines.push('DTSTART:19701025T030000');
lines.push('RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU');
lines.push('TZNAME:CET');
lines.push('TZOFFSETFROM:+0200');
lines.push('TZOFFSETTO:+0100');
lines.push('END:STANDARD');
lines.push('BEGIN:DAYLIGHT');
lines.push('DTSTART:19700329T020000');
lines.push('RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU');
lines.push('TZNAME:CEST');
lines.push('TZOFFSETFROM:+0100');
lines.push('TZOFFSETTO:+0200');
lines.push('END:DAYLIGHT');
lines.push('END:VTIMEZONE');
// Process each event
events.forEach(eventItem => {
if (!eventItem.start_datetime || !eventItem.end_datetime) return;
const startDate = new Date(eventItem.start_datetime);
const endDate = new Date(eventItem.end_datetime);
const createdDate = eventItem.created_at ? new Date(eventItem.created_at) : now;
const modifiedDate = eventItem.updated_at ? new Date(eventItem.updated_at) : now;
lines.push('BEGIN:VEVENT');
lines.push(`UID:${eventItem.id}@monacousa.org`);
lines.push(`DTSTART;TZID=${options.timezone}:${formatICalDateTime(startDate)}`);
lines.push(`DTEND;TZID=${options.timezone}:${formatICalDateTime(endDate)}`);
lines.push(`DTSTAMP:${formatICalDateTime(now)}`);
lines.push(`CREATED:${formatICalDateTime(createdDate)}`);
lines.push(`LAST-MODIFIED:${formatICalDateTime(modifiedDate)}`);
lines.push(`SUMMARY:${escapeICalText(eventItem.title)}`);
// Add description with event details
let description = eventItem.description || '';
if (eventItem.is_paid === 'true') {
description += `\\n\\nEvent Fee: €${eventItem.cost_members || eventItem.cost_non_members || 'TBD'}`;
if (eventItem.cost_members && eventItem.cost_non_members) {
description += ` (Members: €${eventItem.cost_members}, Non-members: €${eventItem.cost_non_members})`;
}
}
// Add RSVP status if available
if (options.includeRSVPStatus && eventItem.user_rsvp) {
const rsvpStatus = eventItem.user_rsvp.rsvp_status;
description += `\\n\\nRSVP Status: ${rsvpStatus.toUpperCase()}`;
if (rsvpStatus === 'confirmed' && eventItem.user_rsvp.payment_reference) {
description += `\\nPayment Reference: ${eventItem.user_rsvp.payment_reference}`;
}
}
if (description) {
lines.push(`DESCRIPTION:${escapeICalText(description)}`);
}
// Add location
if (eventItem.location) {
lines.push(`LOCATION:${escapeICalText(eventItem.location)}`);
}
// Add categories based on event type
const eventTypeLabels = {
'meeting': 'Meeting',
'social': 'Social Event',
'fundraiser': 'Fundraiser',
'workshop': 'Workshop',
'board-only': 'Board Meeting'
};
const category = eventTypeLabels[eventItem.event_type as keyof typeof eventTypeLabels] || 'Event';
lines.push(`CATEGORIES:${category}`);
// Add status
let eventStatus = 'CONFIRMED';
if (eventItem.status === 'cancelled') {
eventStatus = 'CANCELLED';
} else if (eventItem.status === 'draft') {
eventStatus = 'TENTATIVE';
}
lines.push(`STATUS:${eventStatus}`);
// Add transparency for paid events
if (eventItem.is_paid === 'true') {
lines.push('TRANSP:OPAQUE');
} else {
lines.push('TRANSP:TRANSPARENT');
}
// Add URL (link back to portal)
lines.push(`URL:https://portal.monacousa.org/dashboard/events`);
lines.push('END:VEVENT');
});
// iCal footer
lines.push('END:VCALENDAR');
// Join with CRLF as per RFC 5545
return lines.join('\r\n');
}
/**
* Format date for iCal (YYYYMMDDTHHMMSS format)
*/
function formatICalDateTime(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}${month}${day}T${hours}${minutes}${seconds}`;
}
/**
* Escape special characters for iCal text fields
*/
function escapeICalText(text: string): string {
if (!text) return '';
return text
.replace(/\\/g, '\\\\') // Escape backslashes
.replace(/;/g, '\\;') // Escape semicolons
.replace(/,/g, '\\,') // Escape commas
.replace(/\n/g, '\\n') // Escape newlines
.replace(/\r/g, '') // Remove carriage returns
.replace(/"/g, '\\"'); // Escape quotes
}

View File

@ -0,0 +1,90 @@
// server/api/events/index.get.ts
import { createNocoDBEventsClient, transformEventForCalendar } from '~/server/utils/nocodb-events';
import type { EventFilters } from '~/utils/types';
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event) as EventFilters & {
limit?: string;
offset?: string;
calendar_format?: string
};
// Get user session for role-based filtering
const session = await getUserSession(event);
if (!session || !session.user) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
});
}
const eventsClient = createNocoDBEventsClient();
// Build filters with user role
const filters: EventFilters & { limit?: number; offset?: number } = {
...query,
user_role: session.user.tier,
limit: query.limit ? parseInt(query.limit) : 50,
offset: query.offset ? parseInt(query.offset) : 0
};
// If no date range provided, default to current month + 2 months ahead
if (!filters.start_date || !filters.end_date) {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endDate = new Date(now.getFullYear(), now.getMonth() + 3, 0); // 3 months ahead
filters.start_date = startOfMonth.toISOString();
filters.end_date = endDate.toISOString();
}
// Get events from database
const response = await eventsClient.findUserEvents(session.user.id, filters);
// Transform for FullCalendar if requested
if (query.calendar_format === 'true') {
const calendarEvents = response.list.map(transformEventForCalendar);
return {
success: true,
data: calendarEvents,
total: response.PageInfo?.totalRows || response.list.length
};
}
return {
success: true,
data: response.list,
total: response.PageInfo?.totalRows || response.list.length,
pagination: response.PageInfo
};
} catch (error) {
console.error('Error fetching events:', error);
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch events'
});
}
});
// Helper function to get user session (you may need to adjust this based on your auth implementation)
async function getUserSession(event: any) {
// This should be replaced with your actual session retrieval logic
// For now, assuming you have a session utility similar to your auth system
try {
const sessionCookie = getCookie(event, 'session') || getHeader(event, 'authorization');
if (!sessionCookie) return null;
// Decode session - adjust based on your session implementation
// This is a placeholder that should be replaced with your actual session logic
return {
user: {
id: 'user-id', // This should come from your session
tier: 'user' // This should come from your session
}
};
} catch {
return null;
}
}

View File

@ -0,0 +1,133 @@
// server/api/events/index.post.ts
import { createNocoDBEventsClient } from '~/server/utils/nocodb-events';
import type { EventCreateRequest } from '~/utils/types';
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event) as EventCreateRequest;
// Get user session for authentication and authorization
const session = await getUserSession(event);
if (!session || !session.user) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
});
}
// Check if user has permission to create events (board or admin only)
if (session.user.tier !== 'board' && session.user.tier !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Only board members and administrators can create events'
});
}
// Validate required fields
if (!body.title || !body.start_datetime || !body.end_datetime) {
throw createError({
statusCode: 400,
statusMessage: 'Title, start date, and end date are required'
});
}
// Validate date range
const startDate = new Date(body.start_datetime);
const endDate = new Date(body.end_datetime);
if (startDate >= endDate) {
throw createError({
statusCode: 400,
statusMessage: 'End date must be after start date'
});
}
// Validate event type
const validEventTypes = ['meeting', 'social', 'fundraiser', 'workshop', 'board-only'];
if (!validEventTypes.includes(body.event_type)) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid event type'
});
}
// Validate visibility
const validVisibilities = ['public', 'board-only', 'admin-only'];
if (!validVisibilities.includes(body.visibility)) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid visibility setting'
});
}
// Admin-only visibility can only be set by admins
if (body.visibility === 'admin-only' && session.user.tier !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Only administrators can create admin-only events'
});
}
const eventsClient = createNocoDBEventsClient();
// Prepare event data
const eventData = {
title: body.title.trim(),
description: body.description?.trim() || '',
event_type: body.event_type,
start_datetime: body.start_datetime,
end_datetime: body.end_datetime,
location: body.location?.trim() || '',
is_recurring: body.is_recurring || 'false',
recurrence_pattern: body.recurrence_pattern || '',
max_attendees: body.max_attendees || '',
is_paid: body.is_paid || 'false',
cost_members: body.cost_members || '',
cost_non_members: body.cost_non_members || '',
member_pricing_enabled: body.member_pricing_enabled || 'true',
visibility: body.visibility,
status: body.status || 'active',
creator: session.user.id,
current_attendees: '0'
};
// Create the event
const newEvent = await eventsClient.create(eventData);
return {
success: true,
data: newEvent,
message: 'Event created successfully'
};
} catch (error) {
console.error('Error creating event:', error);
// Re-throw createError instances
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to create event'
});
}
});
// Helper function to get user session (same as in index.get.ts)
async function getUserSession(event: any) {
try {
const sessionCookie = getCookie(event, 'session') || getHeader(event, 'authorization');
if (!sessionCookie) return null;
return {
user: {
id: 'user-id', // Replace with actual session logic
tier: 'board' // Replace with actual session logic
}
};
} catch {
return null;
}
}

View File

@ -0,0 +1,344 @@
// server/utils/nocodb-events.ts
import type { Event, EventRSVP, EventsResponse, EventFilters } from '~/utils/types';
/**
* Creates a client for interacting with the Events NocoDB table
* Provides CRUD operations and specialized queries for events and RSVPs
*/
export function createNocoDBEventsClient() {
const config = useRuntimeConfig();
const baseUrl = config.nocodb.url;
const token = config.nocodb.token;
const eventsBaseId = config.nocodb.eventsBaseId;
const eventsTableId = config.nocodb.eventsTableId || 'events'; // fallback to table name
if (!baseUrl || !token || !eventsBaseId) {
throw new Error('Events NocoDB configuration is incomplete. Please check environment variables.');
}
const headers = {
'xc-token': token,
'Content-Type': 'application/json'
};
const eventsClient = {
/**
* Find all events with optional filtering
*/
async findAll(filters?: EventFilters & { limit?: number; offset?: number }) {
const queryParams = new URLSearchParams();
if (filters?.limit) queryParams.set('limit', filters.limit.toString());
if (filters?.offset) queryParams.set('offset', filters.offset.toString());
// Build where clause for filtering
const whereConditions: string[] = [];
if (filters?.start_date && filters?.end_date) {
whereConditions.push(`(start_datetime >= '${filters.start_date}' AND start_datetime <= '${filters.end_date}')`);
}
if (filters?.event_type) {
whereConditions.push(`(event_type = '${filters.event_type}')`);
}
if (filters?.visibility) {
whereConditions.push(`(visibility = '${filters.visibility}')`);
} else if (filters?.user_role) {
// Role-based visibility filtering
if (filters.user_role === 'user') {
whereConditions.push(`(visibility = 'public')`);
} else if (filters.user_role === 'board') {
whereConditions.push(`(visibility = 'public' OR visibility = 'board-only')`);
}
// Admin sees all events (no filter)
}
if (filters?.status) {
whereConditions.push(`(status = '${filters.status}')`);
} else {
// Default to active events only
whereConditions.push(`(status = 'active')`);
}
if (filters?.search) {
whereConditions.push(`(title LIKE '%${filters.search}%' OR description LIKE '%${filters.search}%')`);
}
if (whereConditions.length > 0) {
queryParams.set('where', whereConditions.join(' AND '));
}
// Sort by start date
queryParams.set('sort', 'start_datetime');
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}?${queryParams.toString()}`;
const response = await $fetch(url, {
method: 'GET',
headers
});
return response;
},
/**
* Find a single event by ID
*/
async findOne(id: string) {
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}/${id}`;
return await $fetch<Event>(url, {
method: 'GET',
headers
});
},
/**
* Create a new event
*/
async create(eventData: Partial<Event>) {
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}`;
// Set default values
const data = {
...eventData,
status: eventData.status || 'active',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
return await $fetch<Event>(url, {
method: 'POST',
headers,
body: data
});
},
/**
* Update an existing event
*/
async update(id: string, eventData: Partial<Event>) {
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}/${id}`;
const data = {
...eventData,
updated_at: new Date().toISOString()
};
return await $fetch<Event>(url, {
method: 'PATCH',
headers,
body: data
});
},
/**
* Delete an event
*/
async delete(id: string) {
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}/${id}`;
return await $fetch(url, {
method: 'DELETE',
headers
});
},
/**
* Create an RSVP record for an event
*/
async createRSVP(rsvpData: Partial<EventRSVP>) {
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}`;
const data = {
...rsvpData,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
return await $fetch<EventRSVP>(url, {
method: 'POST',
headers,
body: data
});
},
/**
* Find RSVPs for a specific event
*/
async findEventRSVPs(eventId: string) {
const queryParams = new URLSearchParams();
queryParams.set('where', `(event_id = '${eventId}')`);
queryParams.set('sort', 'created_at');
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}?${queryParams.toString()}`;
return await $fetch(url, {
method: 'GET',
headers
});
},
/**
* Find a user's RSVP for a specific event
*/
async findUserRSVP(eventId: string, memberId: string) {
const queryParams = new URLSearchParams();
queryParams.set('where', `(event_id = '${eventId}' AND member_id = '${memberId}')`);
queryParams.set('limit', '1');
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}?${queryParams.toString()}`;
const response = await $fetch(url, {
method: 'GET',
headers
});
return response?.list?.[0] || null;
},
/**
* Update an RSVP record
*/
async updateRSVP(id: string, rsvpData: Partial<EventRSVP>) {
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}/${id}`;
const data = {
...rsvpData,
updated_at: new Date().toISOString()
};
return await $fetch<EventRSVP>(url, {
method: 'PATCH',
headers,
body: data
});
},
/**
* Update event attendance count (for optimization)
*/
async updateAttendeeCount(eventId: string, count: number) {
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}/${eventId}`;
return await $fetch(url, {
method: 'PATCH',
headers,
body: {
current_attendees: count.toString(),
updated_at: new Date().toISOString()
}
});
},
/**
* Get events for a specific user with their RSVP status
*/
async findUserEvents(memberId: string, filters?: EventFilters) {
// First get all visible events
const events = await this.findAll(filters);
if (!events.list || events.list.length === 0) {
return { list: [], PageInfo: events.PageInfo };
}
// Get user's RSVPs for these events
const eventIds = events.list.map((e: Event) => e.id);
const rsvpQueryParams = new URLSearchParams();
rsvpQueryParams.set('where', `(member_id = '${memberId}' AND event_id IN (${eventIds.map(id => `'${id}'`).join(',')}))`);
const url = `${baseUrl}/api/v1/db/data/v1/${eventsBaseId}/${eventsTableId}?${rsvpQueryParams.toString()}`;
const rsvps = await $fetch(url, {
method: 'GET',
headers
});
// Map RSVPs to events
const rsvpMap = new Map();
if (rsvps.list) {
rsvps.list.forEach((rsvp: EventRSVP) => {
rsvpMap.set(rsvp.event_id, rsvp);
});
}
// Add RSVP information to events
const eventsWithRSVP = events.list.map((event: Event) => ({
...event,
user_rsvp: rsvpMap.get(event.id) || null
}));
return {
list: eventsWithRSVP,
PageInfo: events.PageInfo
};
},
/**
* Generate payment reference for RSVP
*/
generatePaymentReference(memberId: string, date?: Date): string {
const referenceDate = date || new Date();
const dateString = referenceDate.toISOString().split('T')[0]; // YYYY-MM-DD
return `EVT-${memberId}-${dateString}`;
},
/**
* Check if event has reached capacity
*/
async isEventFull(eventId: string): Promise<boolean> {
const event = await this.findOne(eventId);
if (!event.max_attendees) return false; // Unlimited capacity
const maxAttendees = parseInt(event.max_attendees);
const currentAttendees = event.current_attendees || 0;
return currentAttendees >= maxAttendees;
}
};
return eventsClient;
}
/**
* Utility function to transform Event data for FullCalendar
*/
export function transformEventForCalendar(event: Event): any {
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 as keyof typeof eventTypeColors] ||
{ 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
}
};
}

View File

@ -432,3 +432,137 @@ export interface DuesCalculationUtils {
calculateOverdueDays: (member: Member) => number;
calculateDaysUntilDue: (member: Member) => { daysUntilDue: number; nextDueDate: string } | null;
}
// Event Management System Types
export interface Event {
id: string;
title: string;
description: string;
event_type: 'meeting' | 'social' | 'fundraiser' | 'workshop' | 'board-only';
start_datetime: string;
end_datetime: string;
location: string;
is_recurring: string; // 'true' or 'false' as string
recurrence_pattern?: string; // JSON string
max_attendees?: string; // null/empty for unlimited
is_paid: string; // 'true' or 'false' as string
cost_members?: string;
cost_non_members?: string;
member_pricing_enabled: string; // 'true' or 'false' as string
visibility: 'public' | 'board-only' | 'admin-only';
status: 'active' | 'cancelled' | 'completed' | 'draft';
creator: string; // member_id who created event
created_at: string;
updated_at: string;
// Computed fields
current_attendees?: number;
user_rsvp?: EventRSVP;
attendee_list?: EventRSVP[];
}
export interface EventRSVP {
id: string;
event_id: string;
member_id: string;
rsvp_status: 'confirmed' | 'declined' | 'pending' | 'waitlist';
payment_status: 'not_required' | 'pending' | 'paid' | 'overdue';
payment_reference: string; // EVT-{member_id}-{date}
attended: string; // 'true' or 'false' as string
rsvp_notes?: string;
created_at: string;
updated_at: string;
// Computed fields
member_details?: Member;
is_member_pricing?: string; // 'true' or 'false'
}
export interface EventsResponse {
success: boolean;
data: Event[];
total?: number;
message?: string;
}
export interface EventCreateRequest {
title: string;
description: string;
event_type: string;
start_datetime: string;
end_datetime: string;
location: string;
is_recurring?: string;
recurrence_pattern?: string;
max_attendees?: string;
is_paid: string;
cost_members?: string;
cost_non_members?: string;
member_pricing_enabled: string;
visibility: string;
status?: string;
}
export interface EventRSVPRequest {
event_id: string;
member_id: string;
rsvp_status: 'confirmed' | 'declined' | 'pending';
rsvp_notes?: string;
}
export interface EventAttendanceRequest {
event_id: string;
member_id: string;
attended: boolean;
}
// Calendar subscription types
export interface CalendarSubscription {
user_id: string;
feed_url: string;
include_rsvp_only?: boolean;
created_at: string;
}
// Event configuration types
export interface EventsConfig {
eventsBaseId: string;
eventsTableId: string;
defaultEventTypes: string[];
maxEventsPerPage: number;
cacheTimeout: number;
}
// FullCalendar integration types
export interface FullCalendarEvent {
id: string;
title: string;
start: string;
end: string;
backgroundColor?: string;
borderColor?: string;
textColor?: string;
extendedProps: {
description: string;
location: string;
event_type: string;
is_paid: boolean;
cost_members?: string;
cost_non_members?: string;
max_attendees?: number;
current_attendees?: number;
user_rsvp?: EventRSVP;
visibility: string;
creator: string;
};
}
export interface EventFilters {
start_date?: string;
end_date?: string;
event_type?: string;
visibility?: string;
status?: string;
user_role?: 'user' | 'board' | 'admin';
search?: string;
}