Add event management system with calendar and CRUD operations
Some checks failed
Build And Push Image / docker (push) Failing after 2m37s
Some checks failed
Build And Push Image / docker (push) Failing after 2m37s
- Add EventCalendar component with FullCalendar integration - Create event CRUD dialogs and upcoming event banner - Implement server-side events API and database utilities - Add events dashboard page and navigation - Improve dues calculation with better overdue day logic - Install FullCalendar and date-fns dependencies
This commit is contained in:
471
components/CreateEventDialog.vue
Normal file
471
components/CreateEventDialog.vue
Normal file
@@ -0,0 +1,471 @@
|
||||
<template>
|
||||
<v-dialog v-model="show" max-width="800" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon class="me-2">mdi-calendar-plus</v-icon>
|
||||
<span>Create New Event</span>
|
||||
</div>
|
||||
<v-btn
|
||||
@click="close"
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-form ref="form" v-model="valid" @submit.prevent="handleSubmit">
|
||||
<v-row>
|
||||
<!-- Basic Information -->
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="eventData.title"
|
||||
label="Event Title*"
|
||||
:rules="[v => !!v || 'Title is required']"
|
||||
variant="outlined"
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-textarea
|
||||
v-model="eventData.description"
|
||||
label="Description"
|
||||
variant="outlined"
|
||||
rows="3"
|
||||
auto-grow
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- Event Type and Visibility -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="eventData.event_type"
|
||||
:items="eventTypes"
|
||||
label="Event Type*"
|
||||
:rules="[v => !!v || 'Event type is required']"
|
||||
variant="outlined"
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="eventData.visibility"
|
||||
:items="visibilityOptions"
|
||||
label="Visibility*"
|
||||
:rules="[v => !!v || 'Visibility is required']"
|
||||
variant="outlined"
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- Date and Time -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="eventData.start_datetime"
|
||||
label="Start Date & Time*"
|
||||
type="datetime-local"
|
||||
:rules="[v => !!v || 'Start date is required']"
|
||||
variant="outlined"
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="eventData.end_datetime"
|
||||
label="End Date & Time*"
|
||||
type="datetime-local"
|
||||
:rules="[
|
||||
v => !!v || 'End date is required',
|
||||
v => !eventData.start_datetime || new Date(v) > new Date(eventData.start_datetime) || 'End date must be after start date'
|
||||
]"
|
||||
variant="outlined"
|
||||
required
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- Location -->
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
v-model="eventData.location"
|
||||
label="Location"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- Capacity Settings -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="eventData.max_attendees"
|
||||
label="Maximum Attendees"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
hint="Leave empty for unlimited capacity"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- Payment Settings -->
|
||||
<v-col cols="12" md="6">
|
||||
<v-switch
|
||||
v-model="isPaidEvent"
|
||||
label="Paid Event"
|
||||
color="primary"
|
||||
inset
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- Payment Details (shown when paid event) -->
|
||||
<template v-if="isPaidEvent">
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="eventData.cost_members"
|
||||
label="Cost for Members (€)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
variant="outlined"
|
||||
:rules="isPaidEvent ? [v => !!v || 'Member cost is required'] : []"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-text-field
|
||||
v-model="eventData.cost_non_members"
|
||||
label="Cost for Non-Members (€)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
variant="outlined"
|
||||
:rules="isPaidEvent ? [v => !!v || 'Non-member cost is required'] : []"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-switch
|
||||
v-model="memberPricingEnabled"
|
||||
label="Enable Member Pricing"
|
||||
color="primary"
|
||||
inset
|
||||
hint="Allow current members to pay member rates"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
</template>
|
||||
|
||||
<!-- Advanced Options -->
|
||||
<v-col cols="12">
|
||||
<v-expansion-panels variant="accordion">
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title>
|
||||
<v-icon start>mdi-cog</v-icon>
|
||||
Advanced Options
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
<v-switch
|
||||
v-model="isRecurring"
|
||||
label="Recurring Event"
|
||||
color="primary"
|
||||
inset
|
||||
hint="Create a series of events"
|
||||
persistent-hint
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col v-if="isRecurring" cols="12" md="6">
|
||||
<v-select
|
||||
v-model="recurrenceFrequency"
|
||||
:items="recurrenceOptions"
|
||||
label="Frequency"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="6">
|
||||
<v-select
|
||||
v-model="eventData.status"
|
||||
:items="statusOptions"
|
||||
label="Status"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
@click="close"
|
||||
variant="outlined"
|
||||
:disabled="loading"
|
||||
>
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-btn
|
||||
@click="handleSubmit"
|
||||
color="primary"
|
||||
:loading="loading"
|
||||
:disabled="!valid"
|
||||
>
|
||||
Create Event
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EventCreateRequest } from '~/utils/types';
|
||||
import { useAuth } from '~/composables/useAuth';
|
||||
import { useEvents } from '~/composables/useEvents';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
prefilledDate?: string;
|
||||
prefilledEndDate?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
prefilledDate: undefined,
|
||||
prefilledEndDate: undefined
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
'event-created': [event: any];
|
||||
}>();
|
||||
|
||||
const { isAdmin } = useAuth();
|
||||
const { createEvent } = useEvents();
|
||||
|
||||
// Reactive state
|
||||
const form = ref();
|
||||
const valid = ref(false);
|
||||
const loading = ref(false);
|
||||
const isPaidEvent = ref(false);
|
||||
const memberPricingEnabled = ref(true);
|
||||
const isRecurring = ref(false);
|
||||
const recurrenceFrequency = ref('weekly');
|
||||
|
||||
// Form data
|
||||
const eventData = reactive<EventCreateRequest>({
|
||||
title: '',
|
||||
description: '',
|
||||
event_type: 'social',
|
||||
start_datetime: '',
|
||||
end_datetime: '',
|
||||
location: '',
|
||||
max_attendees: '',
|
||||
is_paid: 'false',
|
||||
cost_members: '',
|
||||
cost_non_members: '',
|
||||
member_pricing_enabled: 'true',
|
||||
visibility: 'public',
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
// Computed
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
// Options
|
||||
const eventTypes = [
|
||||
{ title: 'Social Event', value: 'social' },
|
||||
{ title: 'Meeting', value: 'meeting' },
|
||||
{ title: 'Fundraiser', value: 'fundraiser' },
|
||||
{ title: 'Workshop', value: 'workshop' },
|
||||
{ title: 'Board Only', value: 'board-only' }
|
||||
];
|
||||
|
||||
const visibilityOptions = computed(() => {
|
||||
const options = [
|
||||
{ title: 'Public', value: 'public' },
|
||||
{ title: 'Board Only', value: 'board-only' }
|
||||
];
|
||||
|
||||
if (isAdmin.value) {
|
||||
options.push({ title: 'Admin Only', value: 'admin-only' });
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
const statusOptions = [
|
||||
{ title: 'Active', value: 'active' },
|
||||
{ title: 'Draft', value: 'draft' }
|
||||
];
|
||||
|
||||
const recurrenceOptions = [
|
||||
{ title: 'Weekly', value: 'weekly' },
|
||||
{ title: 'Monthly', value: 'monthly' },
|
||||
{ title: 'Yearly', value: 'yearly' }
|
||||
];
|
||||
|
||||
// Watchers
|
||||
watch(isPaidEvent, (newValue) => {
|
||||
eventData.is_paid = newValue ? 'true' : 'false';
|
||||
});
|
||||
|
||||
watch(memberPricingEnabled, (newValue) => {
|
||||
eventData.member_pricing_enabled = newValue ? 'true' : 'false';
|
||||
});
|
||||
|
||||
watch(isRecurring, (newValue) => {
|
||||
eventData.is_recurring = newValue ? 'true' : 'false';
|
||||
if (newValue) {
|
||||
eventData.recurrence_pattern = JSON.stringify({
|
||||
frequency: recurrenceFrequency.value,
|
||||
interval: 1,
|
||||
end_date: null
|
||||
});
|
||||
} else {
|
||||
eventData.recurrence_pattern = '';
|
||||
}
|
||||
});
|
||||
|
||||
watch(recurrenceFrequency, (newValue) => {
|
||||
if (isRecurring.value) {
|
||||
eventData.recurrence_pattern = JSON.stringify({
|
||||
frequency: newValue,
|
||||
interval: 1,
|
||||
end_date: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for prefilled dates
|
||||
watch(() => props.prefilledDate, (newDate) => {
|
||||
if (newDate) {
|
||||
eventData.start_datetime = newDate;
|
||||
|
||||
// Set end date 2 hours later if not provided
|
||||
if (!props.prefilledEndDate) {
|
||||
const endDate = new Date(newDate);
|
||||
endDate.setHours(endDate.getHours() + 2);
|
||||
eventData.end_datetime = endDate.toISOString().slice(0, 16);
|
||||
}
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => props.prefilledEndDate, (newEndDate) => {
|
||||
if (newEndDate) {
|
||||
eventData.end_datetime = newEndDate;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// Methods
|
||||
const resetForm = () => {
|
||||
eventData.title = '';
|
||||
eventData.description = '';
|
||||
eventData.event_type = 'social';
|
||||
eventData.start_datetime = '';
|
||||
eventData.end_datetime = '';
|
||||
eventData.location = '';
|
||||
eventData.max_attendees = '';
|
||||
eventData.is_paid = 'false';
|
||||
eventData.cost_members = '';
|
||||
eventData.cost_non_members = '';
|
||||
eventData.member_pricing_enabled = 'true';
|
||||
eventData.visibility = 'public';
|
||||
eventData.status = 'active';
|
||||
eventData.is_recurring = 'false';
|
||||
eventData.recurrence_pattern = '';
|
||||
|
||||
isPaidEvent.value = false;
|
||||
memberPricingEnabled.value = true;
|
||||
isRecurring.value = false;
|
||||
recurrenceFrequency.value = 'weekly';
|
||||
|
||||
form.value?.resetValidation();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
show.value = false;
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.value) return;
|
||||
|
||||
const isValid = await form.value.validate();
|
||||
if (!isValid.valid) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
// Ensure datetime strings are properly formatted
|
||||
const startDate = new Date(eventData.start_datetime);
|
||||
const endDate = new Date(eventData.end_datetime);
|
||||
|
||||
const formattedEventData = {
|
||||
...eventData,
|
||||
start_datetime: startDate.toISOString(),
|
||||
end_datetime: endDate.toISOString()
|
||||
};
|
||||
|
||||
const newEvent = await createEvent(formattedEventData);
|
||||
|
||||
emit('event-created', newEvent);
|
||||
|
||||
// Show success message
|
||||
// TODO: Add toast/snackbar notification
|
||||
console.log('Event created successfully:', newEvent);
|
||||
|
||||
close();
|
||||
} catch (error: any) {
|
||||
console.error('Error creating event:', error);
|
||||
// TODO: Add error toast/snackbar notification
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize form when dialog opens
|
||||
watch(show, (isOpen) => {
|
||||
if (isOpen && props.prefilledDate) {
|
||||
eventData.start_datetime = props.prefilledDate;
|
||||
|
||||
if (props.prefilledEndDate) {
|
||||
eventData.end_datetime = props.prefilledEndDate;
|
||||
} else {
|
||||
// Set end date 2 hours later
|
||||
const endDate = new Date(props.prefilledDate);
|
||||
endDate.setHours(endDate.getHours() + 2);
|
||||
eventData.end_datetime = endDate.toISOString().slice(0, 16);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-card {
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.v-expansion-panel-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v-switch {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.v-text-field :deep(.v-field__input) {
|
||||
min-height: 56px;
|
||||
}
|
||||
</style>
|
||||
@@ -59,7 +59,7 @@
|
||||
Days Overdue
|
||||
</span>
|
||||
<span class="text-body-2 font-weight-bold text-error">
|
||||
{{ member.overdueDays || 0 }} days
|
||||
{{ calculateDisplayOverdueDays(member) }} days
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -234,6 +234,9 @@ interface DuesMember extends Member {
|
||||
overdueReason?: string;
|
||||
daysUntilDue?: number;
|
||||
nextDueDate?: string;
|
||||
membership_date_paid?: string;
|
||||
payment_due_date?: string;
|
||||
current_year_dues_paid?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -316,6 +319,47 @@ const daysDifference = computed(() => {
|
||||
});
|
||||
|
||||
// Methods
|
||||
const calculateDisplayOverdueDays = (member: DuesMember): number => {
|
||||
// First try to use the pre-calculated overdue days from the API
|
||||
if (member.overdueDays !== undefined && member.overdueDays > 0) {
|
||||
return member.overdueDays;
|
||||
}
|
||||
|
||||
// Fallback calculation if not provided
|
||||
const today = new Date();
|
||||
const DAYS_IN_YEAR = 365;
|
||||
|
||||
// Check if payment is over 1 year old
|
||||
if (member.membership_date_paid) {
|
||||
try {
|
||||
const lastPaidDate = new Date(member.membership_date_paid);
|
||||
const oneYearFromPayment = new Date(lastPaidDate);
|
||||
oneYearFromPayment.setFullYear(oneYearFromPayment.getFullYear() + 1);
|
||||
|
||||
if (today > oneYearFromPayment) {
|
||||
const daysSincePayment = Math.floor((today.getTime() - lastPaidDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
return Math.max(0, daysSincePayment - DAYS_IN_YEAR);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to due date check
|
||||
}
|
||||
}
|
||||
|
||||
// Check if past due date
|
||||
if (member.payment_due_date) {
|
||||
try {
|
||||
const dueDate = new Date(member.payment_due_date);
|
||||
if (today > dueDate) {
|
||||
return Math.floor((today.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
} catch {
|
||||
// Invalid date
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
if (!dateString) return '';
|
||||
|
||||
|
||||
@@ -209,20 +209,19 @@ const isPaymentOverOneYear = computed(() => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if dues are actually current
|
||||
* Uses the same logic as dues-status API and MemberCard
|
||||
* Check if dues need to be paid (either overdue or in grace period)
|
||||
* Banner should show when payment is needed
|
||||
*/
|
||||
const isDuesActuallyCurrent = computed(() => {
|
||||
const needsPayment = computed(() => {
|
||||
if (!memberData.value) return false;
|
||||
|
||||
const paymentTooOld = isPaymentOverOneYear.value;
|
||||
const duesCurrentlyPaid = memberData.value.current_year_dues_paid === 'true';
|
||||
const gracePeriod = isInGracePeriod.value;
|
||||
const paymentTooOld = isPaymentOverOneYear.value;
|
||||
|
||||
// Member is NOT overdue if they're in grace period OR (dues paid AND payment not too old)
|
||||
const isOverdue = paymentTooOld || (!duesCurrentlyPaid && !gracePeriod);
|
||||
|
||||
return !isOverdue;
|
||||
// Show banner if:
|
||||
// 1. Dues are not currently paid (regardless of grace period)
|
||||
// 2. OR payment is over 1 year old (even if marked as paid)
|
||||
return !duesCurrentlyPaid || paymentTooOld;
|
||||
});
|
||||
|
||||
// Computed properties
|
||||
@@ -230,8 +229,8 @@ const shouldShowBanner = computed(() => {
|
||||
if (!user.value || !memberData.value) return false;
|
||||
if (dismissed.value) return false;
|
||||
|
||||
// Show banner if dues are NOT current
|
||||
return !isDuesActuallyCurrent.value;
|
||||
// Show banner when payment is needed
|
||||
return needsPayment.value;
|
||||
});
|
||||
|
||||
const daysRemaining = computed(() => {
|
||||
@@ -334,7 +333,7 @@ async function loadMemberData() {
|
||||
// Load configuration and check banner visibility
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await $fetch('/api/admin/registration-config') as any;
|
||||
const response = await $fetch('/api/registration-config') as any;
|
||||
if (response?.success) {
|
||||
config.value = response.data;
|
||||
}
|
||||
|
||||
410
components/EventCalendar.vue
Normal file
410
components/EventCalendar.vue
Normal file
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<v-card elevation="2" class="event-calendar">
|
||||
<v-card-title v-if="!compact" class="d-flex justify-space-between align-center">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon class="me-2">mdi-calendar</v-icon>
|
||||
<span>Events Calendar</span>
|
||||
</div>
|
||||
<div v-if="showCreateButton && (isBoard || isAdmin)" class="d-flex gap-2">
|
||||
<v-btn
|
||||
@click="$emit('create-event')"
|
||||
color="primary"
|
||||
size="small"
|
||||
prepend-icon="mdi-plus"
|
||||
>
|
||||
Create Event
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<!-- Mobile view selector -->
|
||||
<v-row v-if="$vuetify.display.mobile && !compact" class="mb-4">
|
||||
<v-col cols="12">
|
||||
<v-btn-toggle
|
||||
v-model="mobileView"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
mandatory
|
||||
>
|
||||
<v-btn value="month">
|
||||
<v-icon start>mdi-calendar-month</v-icon>
|
||||
Month
|
||||
</v-btn>
|
||||
<v-btn value="list">
|
||||
<v-icon start>mdi-format-list-bulleted</v-icon>
|
||||
Agenda
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Loading state -->
|
||||
<v-skeleton-loader
|
||||
v-if="loading"
|
||||
type="image"
|
||||
:height="calendarHeight"
|
||||
class="rounded"
|
||||
/>
|
||||
|
||||
<!-- FullCalendar component -->
|
||||
<FullCalendar
|
||||
v-else
|
||||
ref="fullCalendar"
|
||||
:options="calendarOptions"
|
||||
class="fc-theme-monacousa"
|
||||
/>
|
||||
|
||||
<!-- No events message -->
|
||||
<v-alert
|
||||
v-if="!loading && events.length === 0"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mt-4"
|
||||
>
|
||||
<v-alert-title>No Events</v-alert-title>
|
||||
No events found for the current time period.
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FullCalendar from '@fullcalendar/vue3';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import listPlugin from '@fullcalendar/list';
|
||||
import type { Event, FullCalendarEvent } from '~/utils/types';
|
||||
import { useAuth } from '~/composables/useAuth';
|
||||
|
||||
interface Props {
|
||||
events?: Event[];
|
||||
loading?: boolean;
|
||||
compact?: boolean;
|
||||
height?: number | string;
|
||||
showCreateButton?: boolean;
|
||||
initialView?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
events: () => [],
|
||||
loading: false,
|
||||
compact: false,
|
||||
height: 600,
|
||||
showCreateButton: true,
|
||||
initialView: 'dayGridMonth'
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'event-click': [event: any];
|
||||
'date-click': [date: any];
|
||||
'view-change': [view: any];
|
||||
'date-range-change': [start: string, end: string];
|
||||
'create-event': [];
|
||||
}>();
|
||||
|
||||
const { isBoard, isAdmin } = useAuth();
|
||||
|
||||
// Reactive state
|
||||
const fullCalendar = ref<InstanceType<typeof FullCalendar>>();
|
||||
const mobileView = ref('month');
|
||||
|
||||
// Computed properties
|
||||
const calendarHeight = computed(() => {
|
||||
if (props.compact) return props.height || 300;
|
||||
if (typeof props.height === 'number') return props.height;
|
||||
return props.height || 600;
|
||||
});
|
||||
|
||||
const currentView = computed(() => {
|
||||
if (props.compact) return 'dayGridMonth';
|
||||
|
||||
// Mobile responsive view switching
|
||||
if (process.client && window.innerWidth < 960) {
|
||||
return mobileView.value === 'list' ? 'listWeek' : 'dayGridMonth';
|
||||
}
|
||||
|
||||
return props.initialView;
|
||||
});
|
||||
|
||||
const transformedEvents = computed((): FullCalendarEvent[] => {
|
||||
return props.events.map((event: Event) => transformEventForCalendar(event));
|
||||
});
|
||||
|
||||
// FullCalendar options
|
||||
const calendarOptions = computed(() => ({
|
||||
plugins: [dayGridPlugin, interactionPlugin, listPlugin],
|
||||
initialView: currentView.value,
|
||||
height: calendarHeight.value,
|
||||
headerToolbar: props.compact ? false : {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: process.client && window.innerWidth < 960 ?
|
||||
'dayGridMonth,listWeek' :
|
||||
'dayGridMonth,dayGridWeek,listWeek'
|
||||
},
|
||||
events: transformedEvents.value,
|
||||
eventClick: handleEventClick,
|
||||
dateClick: handleDateClick,
|
||||
datesSet: handleDatesSet,
|
||||
eventDidMount: handleEventMount,
|
||||
dayMaxEvents: props.compact ? 2 : 5,
|
||||
eventDisplay: 'block',
|
||||
displayEventTime: true,
|
||||
eventTimeFormat: {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
},
|
||||
locale: 'en',
|
||||
firstDay: 1, // Monday
|
||||
weekends: true,
|
||||
navLinks: true,
|
||||
selectable: isBoard.value || isAdmin.value,
|
||||
selectMirror: true,
|
||||
select: handleDateSelect,
|
||||
// Mobile optimizations
|
||||
aspectRatio: process.client && window.innerWidth < 960 ? 1.0 : 1.35,
|
||||
// Responsive behavior
|
||||
windowResizeDelay: 100
|
||||
}));
|
||||
|
||||
// Event handlers
|
||||
function handleEventClick(clickInfo: any) {
|
||||
emit('event-click', {
|
||||
event: clickInfo.event,
|
||||
eventData: clickInfo.event.extendedProps
|
||||
});
|
||||
}
|
||||
|
||||
function handleDateClick(dateInfo: any) {
|
||||
if (isBoard.value || isAdmin.value) {
|
||||
emit('date-click', {
|
||||
date: dateInfo.dateStr,
|
||||
allDay: dateInfo.allDay
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleDateSelect(selectInfo: any) {
|
||||
if (isBoard.value || isAdmin.value) {
|
||||
emit('date-click', {
|
||||
date: selectInfo.startStr,
|
||||
endDate: selectInfo.endStr,
|
||||
allDay: selectInfo.allDay
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleDatesSet(dateInfo: any) {
|
||||
emit('view-change', {
|
||||
view: dateInfo.view.type,
|
||||
start: dateInfo.start,
|
||||
end: dateInfo.end
|
||||
});
|
||||
|
||||
emit('date-range-change',
|
||||
dateInfo.start.toISOString(),
|
||||
dateInfo.end.toISOString()
|
||||
);
|
||||
}
|
||||
|
||||
function handleEventMount(mountInfo: any) {
|
||||
// Add custom styling or tooltips
|
||||
const event = mountInfo.event;
|
||||
const el = mountInfo.el;
|
||||
|
||||
// Add tooltip with event details
|
||||
el.setAttribute('title', `${event.title}\n${event.extendedProps.location || 'No location'}`);
|
||||
|
||||
// Add custom classes based on event properties
|
||||
if (event.extendedProps.is_paid) {
|
||||
el.classList.add('fc-paid-event');
|
||||
}
|
||||
|
||||
if (event.extendedProps.user_rsvp?.rsvp_status === 'confirmed') {
|
||||
el.classList.add('fc-user-rsvp');
|
||||
}
|
||||
}
|
||||
|
||||
// Transform event data for FullCalendar
|
||||
function transformEventForCalendar(event: Event): FullCalendarEvent {
|
||||
const eventTypeColors = {
|
||||
'meeting': { bg: '#2196f3', border: '#1976d2' },
|
||||
'social': { bg: '#4caf50', border: '#388e3c' },
|
||||
'fundraiser': { bg: '#ff9800', border: '#f57c00' },
|
||||
'workshop': { bg: '#9c27b0', border: '#7b1fa2' },
|
||||
'board-only': { bg: '#a31515', border: '#8b1212' }
|
||||
};
|
||||
|
||||
const colors = eventTypeColors[event.event_type] ||
|
||||
{ bg: '#757575', border: '#424242' };
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
start: event.start_datetime,
|
||||
end: event.end_datetime,
|
||||
backgroundColor: colors.bg,
|
||||
borderColor: colors.border,
|
||||
textColor: '#ffffff',
|
||||
extendedProps: {
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
event_type: event.event_type,
|
||||
is_paid: event.is_paid === 'true',
|
||||
cost_members: event.cost_members,
|
||||
cost_non_members: event.cost_non_members,
|
||||
max_attendees: event.max_attendees ? parseInt(event.max_attendees) : null,
|
||||
current_attendees: event.current_attendees || 0,
|
||||
user_rsvp: event.user_rsvp,
|
||||
visibility: event.visibility,
|
||||
creator: event.creator,
|
||||
status: event.status
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Public methods
|
||||
function getCalendarApi() {
|
||||
return fullCalendar.value?.getApi();
|
||||
}
|
||||
|
||||
function refetchEvents() {
|
||||
const api = getCalendarApi();
|
||||
if (api) {
|
||||
api.refetchEvents();
|
||||
}
|
||||
}
|
||||
|
||||
function changeView(viewType: string) {
|
||||
const api = getCalendarApi();
|
||||
if (api) {
|
||||
api.changeView(viewType);
|
||||
}
|
||||
}
|
||||
|
||||
function gotoDate(date: string | Date) {
|
||||
const api = getCalendarApi();
|
||||
if (api) {
|
||||
api.gotoDate(date);
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for mobile view changes
|
||||
watch(mobileView, (newView) => {
|
||||
const viewType = newView === 'list' ? 'listWeek' : 'dayGridMonth';
|
||||
changeView(viewType);
|
||||
});
|
||||
|
||||
// Expose methods to parent components
|
||||
defineExpose({
|
||||
getCalendarApi,
|
||||
refetchEvents,
|
||||
changeView,
|
||||
gotoDate
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event-calendar :deep(.fc) {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-theme-standard .fc-scrollgrid) {
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-theme-standard td),
|
||||
.event-calendar :deep(.fc-theme-standard th) {
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-button-primary) {
|
||||
background-color: #a31515;
|
||||
border-color: #a31515;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-button-primary:hover) {
|
||||
background-color: #8b1212;
|
||||
border-color: #8b1212;
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-button-primary:disabled) {
|
||||
background-color: rgba(163, 21, 21, 0.5);
|
||||
border-color: rgba(163, 21, 21, 0.5);
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-today-button) {
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-toolbar-title) {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #a31515;
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-day-today) {
|
||||
background-color: rgba(163, 21, 21, 0.05) !important;
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-event) {
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-event:hover) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-paid-event) {
|
||||
border-left: 4px solid #ff9800 !important;
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-user-rsvp) {
|
||||
box-shadow: 0 0 0 2px #4caf50;
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-list-event-title) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-list-event-time) {
|
||||
font-weight: 600;
|
||||
color: #a31515;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 600px) {
|
||||
.event-calendar :deep(.fc-toolbar) {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-toolbar-chunk) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-button-group) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-button) {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.event-calendar :deep(.fc-toolbar-title) {
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
502
components/EventDetailsDialog.vue
Normal file
502
components/EventDetailsDialog.vue
Normal file
@@ -0,0 +1,502 @@
|
||||
<template>
|
||||
<v-dialog v-model="show" max-width="600" persistent>
|
||||
<v-card v-if="event">
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon class="me-2" :color="eventTypeColor">{{ eventTypeIcon }}</v-icon>
|
||||
<span>{{ event.title }}</span>
|
||||
</div>
|
||||
<v-btn
|
||||
@click="close"
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<!-- Event Type Badge -->
|
||||
<v-chip
|
||||
:color="eventTypeColor"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
>
|
||||
<v-icon start>{{ eventTypeIcon }}</v-icon>
|
||||
{{ eventTypeLabel }}
|
||||
</v-chip>
|
||||
|
||||
<!-- Event Details -->
|
||||
<v-row class="mb-4">
|
||||
<!-- Date & Time -->
|
||||
<v-col cols="12">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon class="me-2">mdi-calendar-clock</v-icon>
|
||||
<div>
|
||||
<div class="font-weight-medium">{{ formatEventDate }}</div>
|
||||
<div class="text-body-2 text-medium-emphasis">{{ formatEventTime }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<!-- Location -->
|
||||
<v-col v-if="event.location" cols="12">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon class="me-2">mdi-map-marker</v-icon>
|
||||
<span>{{ event.location }}</span>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<!-- Description -->
|
||||
<v-col v-if="event.description" cols="12">
|
||||
<div class="d-flex align-start mb-2">
|
||||
<v-icon class="me-2 mt-1">mdi-text</v-icon>
|
||||
<div>
|
||||
<div class="font-weight-medium mb-1">Description</div>
|
||||
<div class="text-body-2">{{ event.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<!-- Capacity -->
|
||||
<v-col v-if="event.max_attendees" cols="12">
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon class="me-2">mdi-account-group</v-icon>
|
||||
<div>
|
||||
<span class="font-weight-medium">Capacity:</span>
|
||||
<span class="ms-2">
|
||||
{{ event.current_attendees || 0 }} / {{ event.max_attendees }}
|
||||
</span>
|
||||
<v-progress-linear
|
||||
:model-value="capacityPercentage"
|
||||
:color="capacityColor"
|
||||
height="4"
|
||||
class="mt-1"
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Payment Information -->
|
||||
<v-alert
|
||||
v-if="event.is_paid === 'true'"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
>
|
||||
<v-alert-title>
|
||||
<v-icon start>mdi-currency-eur</v-icon>
|
||||
Payment Required
|
||||
</v-alert-title>
|
||||
<div class="mt-2">
|
||||
<div v-if="memberPrice && nonMemberPrice">
|
||||
<strong>Members:</strong> €{{ memberPrice }}<br>
|
||||
<strong>Non-Members:</strong> €{{ nonMemberPrice }}
|
||||
</div>
|
||||
<div v-else-if="memberPrice">
|
||||
<strong>Cost:</strong> €{{ memberPrice }}
|
||||
</div>
|
||||
<div v-if="event.member_pricing_enabled === 'false'" class="text-caption mt-1">
|
||||
<v-icon size="small">mdi-information</v-icon>
|
||||
Member pricing is not available for this event
|
||||
</div>
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<!-- RSVP Status -->
|
||||
<v-card
|
||||
v-if="hasRSVP"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
:color="rsvpStatusColor"
|
||||
>
|
||||
<v-card-text class="py-3">
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon :color="rsvpStatusColor" class="me-2">{{ rsvpStatusIcon }}</v-icon>
|
||||
<div>
|
||||
<div class="font-weight-medium">{{ rsvpStatusText }}</div>
|
||||
<div v-if="userRSVP?.rsvp_notes" class="text-caption">{{ userRSVP.rsvp_notes }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-btn
|
||||
@click="changeRSVP"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
:color="rsvpStatusColor"
|
||||
>
|
||||
Change
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Payment Details (if RSVP'd to paid event) -->
|
||||
<v-card
|
||||
v-if="showPaymentDetails"
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
>
|
||||
<v-card-title class="py-3">
|
||||
<v-icon class="me-2">mdi-bank-transfer</v-icon>
|
||||
Payment Details
|
||||
</v-card-title>
|
||||
<v-card-text class="pt-0">
|
||||
<v-row dense>
|
||||
<v-col cols="12">
|
||||
<div class="text-body-2">
|
||||
<strong>Amount:</strong> €{{ paymentAmount }}
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<div class="text-body-2">
|
||||
<strong>IBAN:</strong> {{ paymentInfo.iban }}
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<div class="text-body-2">
|
||||
<strong>Recipient:</strong> {{ paymentInfo.recipient }}
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<div class="text-body-2">
|
||||
<strong>Reference:</strong> {{ userRSVP?.payment_reference }}
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-btn
|
||||
@click="copyPaymentDetails"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
class="mt-3"
|
||||
prepend-icon="mdi-content-copy"
|
||||
>
|
||||
Copy Details
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- RSVP Form -->
|
||||
<v-card v-if="!hasRSVP && canRSVP" variant="outlined">
|
||||
<v-card-title class="py-3">
|
||||
<v-icon class="me-2">mdi-account-check</v-icon>
|
||||
RSVP to this Event
|
||||
</v-card-title>
|
||||
<v-card-text class="pt-0">
|
||||
<v-form v-model="rsvpValid" @submit.prevent="submitRSVP">
|
||||
<v-textarea
|
||||
v-model="rsvpNotes"
|
||||
label="Notes (optional)"
|
||||
rows="2"
|
||||
variant="outlined"
|
||||
class="mb-3"
|
||||
/>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<v-btn
|
||||
@click="submitRSVP('confirmed')"
|
||||
color="success"
|
||||
:loading="rsvpLoading"
|
||||
:disabled="isEventFull && !isWaitlistAvailable"
|
||||
>
|
||||
<v-icon start>mdi-check</v-icon>
|
||||
{{ isEventFull ? 'Join Waitlist' : 'Confirm Attendance' }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
@click="submitRSVP('declined')"
|
||||
color="error"
|
||||
variant="outlined"
|
||||
:loading="rsvpLoading"
|
||||
>
|
||||
<v-icon start>mdi-close</v-icon>
|
||||
Decline
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Event Full Message -->
|
||||
<v-alert
|
||||
v-if="isEventFull && !hasRSVP && !isWaitlistAvailable"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-alert-title>Event Full</v-alert-title>
|
||||
This event has reached maximum capacity and waitlist is not available.
|
||||
</v-alert>
|
||||
|
||||
<!-- Past Event Message -->
|
||||
<v-alert
|
||||
v-if="isPastEvent"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-alert-title>Past Event</v-alert-title>
|
||||
This event has already occurred.
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
@click="close"
|
||||
variant="outlined"
|
||||
>
|
||||
Close
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Event, EventRSVP } from '~/utils/types';
|
||||
import { useEvents } from '~/composables/useEvents';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
event: Event | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
'rsvp-updated': [event: Event];
|
||||
}>();
|
||||
|
||||
const { rsvpToEvent } = useEvents();
|
||||
|
||||
// Reactive state
|
||||
const rsvpValid = ref(false);
|
||||
const rsvpLoading = ref(false);
|
||||
const rsvpNotes = ref('');
|
||||
|
||||
// Computed properties
|
||||
const show = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
const userRSVP = computed((): EventRSVP | null => {
|
||||
return props.event?.user_rsvp || null;
|
||||
});
|
||||
|
||||
const hasRSVP = computed(() => !!userRSVP.value);
|
||||
|
||||
const canRSVP = computed(() => {
|
||||
return props.event && !isPastEvent.value;
|
||||
});
|
||||
|
||||
const isPastEvent = computed(() => {
|
||||
if (!props.event) return false;
|
||||
return new Date(props.event.start_datetime) < new Date();
|
||||
});
|
||||
|
||||
const isEventFull = computed(() => {
|
||||
if (!props.event?.max_attendees) return false;
|
||||
const maxAttendees = parseInt(props.event.max_attendees);
|
||||
const currentAttendees = props.event.current_attendees || 0;
|
||||
return currentAttendees >= maxAttendees;
|
||||
});
|
||||
|
||||
const isWaitlistAvailable = computed(() => true); // Always allow waitlist for now
|
||||
|
||||
const eventTypeColor = computed(() => {
|
||||
const colors = {
|
||||
'meeting': 'blue',
|
||||
'social': 'green',
|
||||
'fundraiser': 'orange',
|
||||
'workshop': 'purple',
|
||||
'board-only': 'red'
|
||||
};
|
||||
return colors[props.event?.event_type as keyof typeof colors] || 'grey';
|
||||
});
|
||||
|
||||
const eventTypeIcon = computed(() => {
|
||||
const icons = {
|
||||
'meeting': 'mdi-account-group',
|
||||
'social': 'mdi-party-popper',
|
||||
'fundraiser': 'mdi-heart',
|
||||
'workshop': 'mdi-school',
|
||||
'board-only': 'mdi-shield-account'
|
||||
};
|
||||
return icons[props.event?.event_type as keyof typeof icons] || 'mdi-calendar';
|
||||
});
|
||||
|
||||
const eventTypeLabel = computed(() => {
|
||||
const labels = {
|
||||
'meeting': 'Meeting',
|
||||
'social': 'Social Event',
|
||||
'fundraiser': 'Fundraiser',
|
||||
'workshop': 'Workshop',
|
||||
'board-only': 'Board Only'
|
||||
};
|
||||
return labels[props.event?.event_type as keyof typeof labels] || 'Event';
|
||||
});
|
||||
|
||||
const formatEventDate = computed(() => {
|
||||
if (!props.event) return '';
|
||||
const startDate = new Date(props.event.start_datetime);
|
||||
const endDate = new Date(props.event.end_datetime);
|
||||
|
||||
if (startDate.toDateString() === endDate.toDateString()) {
|
||||
return format(startDate, 'EEEE, MMMM d, yyyy');
|
||||
} else {
|
||||
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d, yyyy')}`;
|
||||
}
|
||||
});
|
||||
|
||||
const formatEventTime = computed(() => {
|
||||
if (!props.event) return '';
|
||||
const startDate = new Date(props.event.start_datetime);
|
||||
const endDate = new Date(props.event.end_datetime);
|
||||
|
||||
return `${format(startDate, 'HH:mm')} - ${format(endDate, 'HH:mm')}`;
|
||||
});
|
||||
|
||||
const capacityPercentage = computed(() => {
|
||||
if (!props.event?.max_attendees) return 0;
|
||||
const max = parseInt(props.event.max_attendees);
|
||||
const current = props.event.current_attendees || 0;
|
||||
return (current / max) * 100;
|
||||
});
|
||||
|
||||
const capacityColor = computed(() => {
|
||||
const percentage = capacityPercentage.value;
|
||||
if (percentage >= 100) return 'error';
|
||||
if (percentage >= 80) return 'warning';
|
||||
return 'success';
|
||||
});
|
||||
|
||||
const memberPrice = computed(() => props.event?.cost_members);
|
||||
const nonMemberPrice = computed(() => props.event?.cost_non_members);
|
||||
|
||||
const rsvpStatusColor = computed(() => {
|
||||
const status = userRSVP.value?.rsvp_status;
|
||||
switch (status) {
|
||||
case 'confirmed': return 'success';
|
||||
case 'waitlist': return 'warning';
|
||||
case 'declined': return 'error';
|
||||
default: return 'info';
|
||||
}
|
||||
});
|
||||
|
||||
const rsvpStatusIcon = computed(() => {
|
||||
const status = userRSVP.value?.rsvp_status;
|
||||
switch (status) {
|
||||
case 'confirmed': return 'mdi-check-circle';
|
||||
case 'waitlist': return 'mdi-clock';
|
||||
case 'declined': return 'mdi-close-circle';
|
||||
default: return 'mdi-help-circle';
|
||||
}
|
||||
});
|
||||
|
||||
const rsvpStatusText = computed(() => {
|
||||
const status = userRSVP.value?.rsvp_status;
|
||||
switch (status) {
|
||||
case 'confirmed': return 'You are attending this event';
|
||||
case 'waitlist': return 'You are on the waitlist';
|
||||
case 'declined': return 'You declined this event';
|
||||
default: return 'Status unknown';
|
||||
}
|
||||
});
|
||||
|
||||
const showPaymentDetails = computed(() => {
|
||||
return props.event?.is_paid === 'true' &&
|
||||
userRSVP.value?.rsvp_status === 'confirmed' &&
|
||||
userRSVP.value?.payment_status === 'pending';
|
||||
});
|
||||
|
||||
const paymentAmount = computed(() => {
|
||||
if (!userRSVP.value || !props.event) return '0';
|
||||
|
||||
const isMemberPricing = userRSVP.value.is_member_pricing === 'true';
|
||||
return isMemberPricing ? props.event.cost_members : props.event.cost_non_members;
|
||||
});
|
||||
|
||||
const paymentInfo = computed(() => ({
|
||||
iban: 'FR76 1234 5678 9012 3456 7890 123', // This should come from config
|
||||
recipient: 'MonacoUSA Association' // This should come from config
|
||||
}));
|
||||
|
||||
// Methods
|
||||
const close = () => {
|
||||
show.value = false;
|
||||
rsvpNotes.value = '';
|
||||
};
|
||||
|
||||
const submitRSVP = async (status: 'confirmed' | 'declined') => {
|
||||
if (!props.event) return;
|
||||
|
||||
rsvpLoading.value = true;
|
||||
|
||||
try {
|
||||
await rsvpToEvent(props.event.id, {
|
||||
member_id: '', // This will be filled by the composable
|
||||
rsvp_status: status,
|
||||
rsvp_notes: rsvpNotes.value
|
||||
});
|
||||
|
||||
emit('rsvp-updated', props.event);
|
||||
// TODO: Show success message
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error submitting RSVP:', error);
|
||||
// TODO: Show error message
|
||||
} finally {
|
||||
rsvpLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const changeRSVP = () => {
|
||||
// For now, just allow re-submitting RSVP
|
||||
// In the future, this could open an edit dialog
|
||||
if (userRSVP.value?.rsvp_status === 'confirmed') {
|
||||
submitRSVP('declined');
|
||||
} else if (userRSVP.value?.rsvp_status === 'declined') {
|
||||
submitRSVP('confirmed');
|
||||
}
|
||||
};
|
||||
|
||||
const copyPaymentDetails = async () => {
|
||||
const details = `
|
||||
Event: ${props.event?.title}
|
||||
Amount: €${paymentAmount.value}
|
||||
IBAN: ${paymentInfo.value.iban}
|
||||
Recipient: ${paymentInfo.value.recipient}
|
||||
Reference: ${userRSVP.value?.payment_reference}
|
||||
`.trim();
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(details);
|
||||
// TODO: Show success toast
|
||||
console.log('Payment details copied to clipboard');
|
||||
} catch (error) {
|
||||
console.error('Error copying to clipboard:', error);
|
||||
// TODO: Show error toast
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-card {
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.text-medium-emphasis {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.v-progress-linear {
|
||||
max-width: 200px;
|
||||
}
|
||||
</style>
|
||||
282
components/UpcomingEventBanner.vue
Normal file
282
components/UpcomingEventBanner.vue
Normal file
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<v-banner
|
||||
v-if="event"
|
||||
:color="bannerColor"
|
||||
lines="two"
|
||||
elevation="2"
|
||||
rounded
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :color="iconColor" size="large">{{ eventIcon }}</v-icon>
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<div class="d-flex flex-column">
|
||||
<div class="text-h6 font-weight-bold mb-1">{{ event.title }}</div>
|
||||
<div class="d-flex flex-wrap align-center ga-4 text-body-2">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="small" class="me-1">mdi-calendar-clock</v-icon>
|
||||
<span>{{ formatEventDate }}</span>
|
||||
</div>
|
||||
<div v-if="event.location" class="d-flex align-center">
|
||||
<v-icon size="small" class="me-1">mdi-map-marker</v-icon>
|
||||
<span>{{ event.location }}</span>
|
||||
</div>
|
||||
<div v-if="event.is_paid === 'true'" class="d-flex align-center">
|
||||
<v-icon size="small" class="me-1">mdi-currency-eur</v-icon>
|
||||
<span>{{ memberPrice }}</span>
|
||||
</div>
|
||||
<div v-if="capacityInfo" class="d-flex align-center">
|
||||
<v-icon size="small" class="me-1">mdi-account-group</v-icon>
|
||||
<span>{{ capacityInfo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<div class="d-flex flex-column flex-sm-row ga-2">
|
||||
<!-- RSVP Status -->
|
||||
<v-chip
|
||||
v-if="userRSVP"
|
||||
:color="rsvpStatusColor"
|
||||
size="small"
|
||||
variant="flat"
|
||||
>
|
||||
<v-icon start size="small">{{ rsvpStatusIcon }}</v-icon>
|
||||
{{ rsvpStatusText }}
|
||||
</v-chip>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<v-btn
|
||||
@click="handleViewEvent"
|
||||
variant="elevated"
|
||||
color="white"
|
||||
size="small"
|
||||
prepend-icon="mdi-eye"
|
||||
>
|
||||
View Details
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
v-if="!userRSVP && canRSVP"
|
||||
@click="handleQuickRSVP"
|
||||
:color="quickRSVPColor"
|
||||
size="small"
|
||||
prepend-icon="mdi-check"
|
||||
>
|
||||
Quick RSVP
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-banner>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Event, EventRSVP } from '~/utils/types';
|
||||
import { format, isWithinInterval, addDays } from 'date-fns';
|
||||
|
||||
interface Props {
|
||||
event: Event | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'event-click': [event: Event];
|
||||
'quick-rsvp': [event: Event];
|
||||
}>();
|
||||
|
||||
// Computed properties
|
||||
const userRSVP = computed((): EventRSVP | null => {
|
||||
return props.event?.user_rsvp || null;
|
||||
});
|
||||
|
||||
const canRSVP = computed(() => {
|
||||
if (!props.event) return false;
|
||||
const eventDate = new Date(props.event.start_datetime);
|
||||
const now = new Date();
|
||||
return eventDate > now; // Can RSVP to future events
|
||||
});
|
||||
|
||||
const eventIcon = computed(() => {
|
||||
if (!props.event) return 'mdi-calendar';
|
||||
|
||||
const icons = {
|
||||
'meeting': 'mdi-account-group',
|
||||
'social': 'mdi-party-popper',
|
||||
'fundraiser': 'mdi-heart',
|
||||
'workshop': 'mdi-school',
|
||||
'board-only': 'mdi-shield-account'
|
||||
};
|
||||
|
||||
return icons[props.event.event_type as keyof typeof icons] || 'mdi-calendar';
|
||||
});
|
||||
|
||||
const bannerColor = computed(() => {
|
||||
if (!props.event) return 'primary';
|
||||
|
||||
// Check if event is soon (within 24 hours)
|
||||
const eventDate = new Date(props.event.start_datetime);
|
||||
const now = new Date();
|
||||
const isSoon = isWithinInterval(eventDate, {
|
||||
start: now,
|
||||
end: addDays(now, 1)
|
||||
});
|
||||
|
||||
if (isSoon) return 'warning';
|
||||
|
||||
const colors = {
|
||||
'meeting': 'blue',
|
||||
'social': 'green',
|
||||
'fundraiser': 'orange',
|
||||
'workshop': 'purple',
|
||||
'board-only': 'red'
|
||||
};
|
||||
|
||||
return colors[props.event.event_type as keyof typeof colors] || 'primary';
|
||||
});
|
||||
|
||||
const iconColor = computed(() => {
|
||||
// Use white for better contrast on colored backgrounds
|
||||
return 'white';
|
||||
});
|
||||
|
||||
const formatEventDate = computed(() => {
|
||||
if (!props.event) return '';
|
||||
|
||||
const startDate = new Date(props.event.start_datetime);
|
||||
const endDate = new Date(props.event.end_datetime);
|
||||
const now = new Date();
|
||||
|
||||
// Different formats based on timing
|
||||
if (startDate.toDateString() === now.toDateString()) {
|
||||
return `Today at ${format(startDate, 'HH:mm')}`;
|
||||
}
|
||||
|
||||
if (startDate.toDateString() === addDays(now, 1).toDateString()) {
|
||||
return `Tomorrow at ${format(startDate, 'HH:mm')}`;
|
||||
}
|
||||
|
||||
if (startDate.toDateString() === endDate.toDateString()) {
|
||||
return format(startDate, 'EEE, MMM d • HH:mm');
|
||||
}
|
||||
|
||||
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`;
|
||||
});
|
||||
|
||||
const memberPrice = computed(() => {
|
||||
if (!props.event || props.event.is_paid !== 'true') return '';
|
||||
|
||||
if (props.event.cost_members && props.event.cost_non_members) {
|
||||
return `€${props.event.cost_members} (Members)`;
|
||||
}
|
||||
|
||||
return `€${props.event.cost_members || props.event.cost_non_members}`;
|
||||
});
|
||||
|
||||
const capacityInfo = computed(() => {
|
||||
if (!props.event?.max_attendees) return '';
|
||||
|
||||
const current = props.event.current_attendees || 0;
|
||||
const max = parseInt(props.event.max_attendees);
|
||||
|
||||
return `${current}/${max} attending`;
|
||||
});
|
||||
|
||||
const rsvpStatusColor = computed(() => {
|
||||
const status = userRSVP.value?.rsvp_status;
|
||||
switch (status) {
|
||||
case 'confirmed': return 'success';
|
||||
case 'waitlist': return 'warning';
|
||||
case 'declined': return 'error';
|
||||
default: return 'info';
|
||||
}
|
||||
});
|
||||
|
||||
const rsvpStatusIcon = computed(() => {
|
||||
const status = userRSVP.value?.rsvp_status;
|
||||
switch (status) {
|
||||
case 'confirmed': return 'mdi-check';
|
||||
case 'waitlist': return 'mdi-clock';
|
||||
case 'declined': return 'mdi-close';
|
||||
default: return 'mdi-help';
|
||||
}
|
||||
});
|
||||
|
||||
const rsvpStatusText = computed(() => {
|
||||
const status = userRSVP.value?.rsvp_status;
|
||||
switch (status) {
|
||||
case 'confirmed': return 'Attending';
|
||||
case 'waitlist': return 'Waitlisted';
|
||||
case 'declined': return 'Declined';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
});
|
||||
|
||||
const quickRSVPColor = computed(() => {
|
||||
return bannerColor.value === 'warning' ? 'success' : 'white';
|
||||
});
|
||||
|
||||
// Methods
|
||||
const handleViewEvent = () => {
|
||||
if (props.event) {
|
||||
emit('event-click', props.event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickRSVP = () => {
|
||||
if (props.event) {
|
||||
emit('quick-rsvp', props.event);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-banner :deep(.v-banner__wrapper) {
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.v-banner :deep(.v-banner__prepend) {
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
|
||||
.v-banner :deep(.v-banner__actions) {
|
||||
margin-inline-start: 16px;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 600px) {
|
||||
.v-banner :deep(.v-banner__wrapper) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.v-banner :deep(.v-banner__prepend) {
|
||||
margin-inline-end: 12px;
|
||||
}
|
||||
|
||||
.v-banner :deep(.v-banner__actions) {
|
||||
margin-inline-start: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.text-h6 {
|
||||
font-size: 1.1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure proper spacing on different screen sizes */
|
||||
.ga-4 {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ga-2 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.ga-4 {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user