Add event management system with calendar and CRUD operations
Build And Push Image / docker (push) Failing after 2m37s
Details
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:
parent
a555584b2c
commit
f096897129
|
|
@ -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>
|
||||||
|
|
@ -59,7 +59,7 @@
|
||||||
Days Overdue
|
Days Overdue
|
||||||
</span>
|
</span>
|
||||||
<span class="text-body-2 font-weight-bold text-error">
|
<span class="text-body-2 font-weight-bold text-error">
|
||||||
{{ member.overdueDays || 0 }} days
|
{{ calculateDisplayOverdueDays(member) }} days
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -234,6 +234,9 @@ interface DuesMember extends Member {
|
||||||
overdueReason?: string;
|
overdueReason?: string;
|
||||||
daysUntilDue?: number;
|
daysUntilDue?: number;
|
||||||
nextDueDate?: string;
|
nextDueDate?: string;
|
||||||
|
membership_date_paid?: string;
|
||||||
|
payment_due_date?: string;
|
||||||
|
current_year_dues_paid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -316,6 +319,47 @@ const daysDifference = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Methods
|
// 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 => {
|
const formatDate = (dateString: string): string => {
|
||||||
if (!dateString) return '';
|
if (!dateString) return '';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -209,20 +209,19 @@ const isPaymentOverOneYear = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if dues are actually current
|
* Check if dues need to be paid (either overdue or in grace period)
|
||||||
* Uses the same logic as dues-status API and MemberCard
|
* Banner should show when payment is needed
|
||||||
*/
|
*/
|
||||||
const isDuesActuallyCurrent = computed(() => {
|
const needsPayment = computed(() => {
|
||||||
if (!memberData.value) return false;
|
if (!memberData.value) return false;
|
||||||
|
|
||||||
const paymentTooOld = isPaymentOverOneYear.value;
|
|
||||||
const duesCurrentlyPaid = memberData.value.current_year_dues_paid === 'true';
|
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)
|
// Show banner if:
|
||||||
const isOverdue = paymentTooOld || (!duesCurrentlyPaid && !gracePeriod);
|
// 1. Dues are not currently paid (regardless of grace period)
|
||||||
|
// 2. OR payment is over 1 year old (even if marked as paid)
|
||||||
return !isOverdue;
|
return !duesCurrentlyPaid || paymentTooOld;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
|
|
@ -230,8 +229,8 @@ const shouldShowBanner = computed(() => {
|
||||||
if (!user.value || !memberData.value) return false;
|
if (!user.value || !memberData.value) return false;
|
||||||
if (dismissed.value) return false;
|
if (dismissed.value) return false;
|
||||||
|
|
||||||
// Show banner if dues are NOT current
|
// Show banner when payment is needed
|
||||||
return !isDuesActuallyCurrent.value;
|
return needsPayment.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const daysRemaining = computed(() => {
|
const daysRemaining = computed(() => {
|
||||||
|
|
@ -334,7 +333,7 @@ async function loadMemberData() {
|
||||||
// Load configuration and check banner visibility
|
// Load configuration and check banner visibility
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
try {
|
try {
|
||||||
const response = await $fetch('/api/admin/registration-config') as any;
|
const response = await $fetch('/api/registration-config') as any;
|
||||||
if (response?.success) {
|
if (response?.success) {
|
||||||
config.value = response.data;
|
config.value = response.data;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -25,6 +25,13 @@
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
value="dashboard"
|
value="dashboard"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
to="/dashboard/events"
|
||||||
|
prepend-icon="mdi-calendar"
|
||||||
|
title="Events"
|
||||||
|
value="events"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-list-item
|
<v-list-item
|
||||||
to="/dashboard/user"
|
to="/dashboard/user"
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,8 @@ export default defineNuxtConfig({
|
||||||
url: process.env.NUXT_NOCODB_URL || "",
|
url: process.env.NUXT_NOCODB_URL || "",
|
||||||
token: process.env.NUXT_NOCODB_TOKEN || "",
|
token: process.env.NUXT_NOCODB_TOKEN || "",
|
||||||
baseId: process.env.NUXT_NOCODB_BASE_ID || "",
|
baseId: process.env.NUXT_NOCODB_BASE_ID || "",
|
||||||
|
eventsBaseId: process.env.NUXT_NOCODB_EVENTS_BASE_ID || "",
|
||||||
|
eventsTableId: process.env.NUXT_NOCODB_EVENTS_TABLE_ID || "",
|
||||||
},
|
},
|
||||||
minio: {
|
minio: {
|
||||||
endPoint: process.env.NUXT_MINIO_ENDPOINT || "s3.monacousa.org",
|
endPoint: process.env.NUXT_MINIO_ENDPOINT || "s3.monacousa.org",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@
|
||||||
"name": "monacousa-portal",
|
"name": "monacousa-portal",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"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",
|
"@nuxt/ui": "^3.2.0",
|
||||||
"@nuxtjs/device": "^3.2.4",
|
"@nuxtjs/device": "^3.2.4",
|
||||||
"@types/handlebars": "^4.0.40",
|
"@types/handlebars": "^4.0.40",
|
||||||
|
|
@ -14,6 +19,7 @@
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@vite-pwa/nuxt": "^0.10.8",
|
"@vite-pwa/nuxt": "^0.10.8",
|
||||||
"cookie": "^0.6.0",
|
"cookie": "^0.6.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"flag-icons": "^7.5.0",
|
"flag-icons": "^7.5.0",
|
||||||
"formidable": "^3.5.4",
|
"formidable": "^3.5.4",
|
||||||
"handlebars": "^4.7.8",
|
"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": {
|
"node_modules/@iconify/collections": {
|
||||||
"version": "1.0.576",
|
"version": "1.0.576",
|
||||||
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.576.tgz",
|
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.576.tgz",
|
||||||
|
|
@ -8477,6 +8529,16 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/db0": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.2.tgz",
|
"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": "^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": {
|
"node_modules/precinct": {
|
||||||
"version": "12.2.0",
|
"version": "12.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/precinct/-/precinct-12.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/precinct/-/precinct-12.2.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@
|
||||||
"typecheck": "nuxt typecheck"
|
"typecheck": "nuxt typecheck"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@nuxt/ui": "^3.2.0",
|
||||||
"@nuxtjs/device": "^3.2.4",
|
"@nuxtjs/device": "^3.2.4",
|
||||||
"@types/handlebars": "^4.0.40",
|
"@types/handlebars": "^4.0.40",
|
||||||
|
|
@ -17,6 +22,7 @@
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@vite-pwa/nuxt": "^0.10.8",
|
"@vite-pwa/nuxt": "^0.10.8",
|
||||||
"cookie": "^0.6.0",
|
"cookie": "^0.6.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"flag-icons": "^7.5.0",
|
"flag-icons": "^7.5.0",
|
||||||
"formidable": "^3.5.4",
|
"formidable": "^3.5.4",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,20 @@
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</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 -->
|
<!-- Create User Dialog -->
|
||||||
<v-dialog v-model="showCreateUserDialog" max-width="600">
|
<v-dialog v-model="showCreateUserDialog" max-width="600">
|
||||||
<v-card>
|
<v-card>
|
||||||
|
|
@ -408,6 +422,11 @@ const overdueCount = ref(0);
|
||||||
const overdueRefreshTrigger = ref(0);
|
const overdueRefreshTrigger = ref(0);
|
||||||
const duesRefreshTrigger = 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
|
// Create user dialog data
|
||||||
const createUserValid = ref(false);
|
const createUserValid = ref(false);
|
||||||
const creatingUser = ref(false);
|
const creatingUser = ref(false);
|
||||||
|
|
@ -673,9 +692,16 @@ const handleStatusesUpdated = async (updatedCount: number) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewMember = (member: any) => {
|
const handleViewMember = (member: any) => {
|
||||||
// Navigate to member details or open modal
|
// Open the view dialog instead of navigating away
|
||||||
console.log('View member:', member.FullName || `${member.first_name} ${member.last_name}`);
|
selectedMember.value = member;
|
||||||
navigateTo('/dashboard/member-list');
|
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 = () => {
|
const navigateToMembers = () => {
|
||||||
|
|
@ -686,6 +712,9 @@ const navigateToMembers = () => {
|
||||||
const handleMemberUpdated = (member: any) => {
|
const handleMemberUpdated = (member: any) => {
|
||||||
console.log('Member updated:', member.FullName || `${member.first_name} ${member.last_name}`);
|
console.log('Member updated:', member.FullName || `${member.first_name} ${member.last_name}`);
|
||||||
|
|
||||||
|
// Close edit dialog
|
||||||
|
showEditDialog.value = false;
|
||||||
|
|
||||||
// Trigger dues refresh
|
// Trigger dues refresh
|
||||||
duesRefreshTrigger.value += 1;
|
duesRefreshTrigger.value += 1;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,20 @@
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</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>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -261,6 +275,11 @@ onMounted(() => {
|
||||||
// Dues management state
|
// Dues management state
|
||||||
const duesRefreshTrigger = ref(0);
|
const duesRefreshTrigger = ref(0);
|
||||||
|
|
||||||
|
// Member dialog state
|
||||||
|
const showViewDialog = ref(false);
|
||||||
|
const showEditDialog = ref(false);
|
||||||
|
const selectedMember = ref(null);
|
||||||
|
|
||||||
// Mock data for board dashboard
|
// Mock data for board dashboard
|
||||||
const stats = ref({
|
const stats = ref({
|
||||||
totalMembers: 156,
|
totalMembers: 156,
|
||||||
|
|
@ -300,15 +319,24 @@ const recentActivity = ref([
|
||||||
|
|
||||||
// Dues management handlers
|
// Dues management handlers
|
||||||
const handleViewMember = (member: Member) => {
|
const handleViewMember = (member: Member) => {
|
||||||
// Navigate to member details or open modal
|
// Open the view dialog instead of navigating away
|
||||||
console.log('View member:', member.FullName || `${member.first_name} ${member.last_name}`);
|
selectedMember.value = member;
|
||||||
// You could implement member detail view here
|
showViewDialog.value = true;
|
||||||
navigateToMembers();
|
};
|
||||||
|
|
||||||
|
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) => {
|
const handleMemberUpdated = (member: Member) => {
|
||||||
console.log('Member updated:', member.FullName || `${member.first_name} ${member.last_name}`);
|
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
|
// Trigger dues refresh to update the lists
|
||||||
duesRefreshTrigger.value += 1;
|
duesRefreshTrigger.value += 1;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
134
utils/types.ts
134
utils/types.ts
|
|
@ -432,3 +432,137 @@ export interface DuesCalculationUtils {
|
||||||
calculateOverdueDays: (member: Member) => number;
|
calculateOverdueDays: (member: Member) => number;
|
||||||
calculateDaysUntilDue: (member: Member) => { daysUntilDue: number; nextDueDate: string } | null;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue