monacousa-portal/components/EventDetailsDialog.vue

585 lines
16 KiB
Vue
Raw Normal View History

<template>
<v-dialog v-model="show" max-width="600" persistent>
<v-card v-if="event">
2025-08-13 12:27:21 +02:00
<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>
2025-08-13 12:27:21 +02:00
<span>{{ event?.title || 'Event Details' }}</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>
<!-- Display HTML content safely -->
<div
class="text-body-2 rich-text-content"
v-html="event.description"
/>
</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">
2025-08-13 12:27:21 +02:00
<v-form v-model="rsvpValid">
<v-textarea
v-model="rsvpNotes"
label="Notes (optional)"
rows="2"
variant="outlined"
class="mb-3"
/>
2025-08-13 12:27:21 +02:00
<div class="d-flex justify-space-between gap-4">
<v-btn
@click="submitRSVP('confirmed')"
color="success"
:loading="rsvpLoading"
:disabled="isEventFull && !isWaitlistAvailable"
2025-08-13 12:27:21 +02:00
size="large"
class="flex-grow-1"
>
<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"
2025-08-13 12:27:21 +02:00
size="large"
class="flex-grow-1"
>
<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);
2025-08-13 12:27:21 +02:00
const currentAttendees = typeof props.event.current_attendees === 'string'
? parseInt(props.event.current_attendees) || 0
: 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);
2025-08-13 12:27:21 +02:00
const current = typeof props.event.current_attendees === 'string'
? parseInt(props.event.current_attendees) || 0
: 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 {
2025-08-13 14:14:58 +02:00
// Extract database ID - props.event is a raw Event object, not FullCalendar object
// Database ID is stored in 'Id' field (capital I) from NocoDB
const databaseId = (props.event as any).Id || (props.event as any).extendedProps?.database_id || props.event.id;
console.log('[EventDetailsDialog] Using database ID for RSVP:', databaseId);
console.log('[EventDetailsDialog] Event object keys:', Object.keys(props.event));
console.log('[EventDetailsDialog] Event Id field:', (props.event as any).Id);
if (!databaseId) {
throw new Error('Unable to determine database ID for event');
}
await rsvpToEvent(databaseId, {
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);
} catch (error) {
console.error('Error copying to clipboard:', error);
}
};
</script>
<style scoped>
.v-card {
max-height: 90vh;
overflow-y: auto;
}
.text-medium-emphasis {
opacity: 0.7;
}
.v-progress-linear {
max-width: 200px;
}
/* Rich text content styling */
.rich-text-content {
word-wrap: break-word;
line-height: 1.5;
}
.rich-text-content :deep(h1),
.rich-text-content :deep(h2),
.rich-text-content :deep(h3) {
color: rgb(var(--v-theme-on-surface));
font-weight: 600;
margin: 16px 0 8px 0;
}
.rich-text-content :deep(h1) {
font-size: 1.5rem;
}
.rich-text-content :deep(h2) {
font-size: 1.25rem;
}
.rich-text-content :deep(h3) {
font-size: 1.125rem;
}
.rich-text-content :deep(p) {
margin: 8px 0;
}
.rich-text-content :deep(ul),
.rich-text-content :deep(ol) {
padding-left: 20px;
margin: 8px 0;
}
.rich-text-content :deep(li) {
margin: 4px 0;
}
.rich-text-content :deep(strong) {
font-weight: 600;
}
.rich-text-content :deep(em) {
font-style: italic;
}
.rich-text-content :deep(u) {
text-decoration: underline;
}
.rich-text-content :deep(a) {
color: rgb(var(--v-theme-primary));
text-decoration: none;
}
.rich-text-content :deep(a:hover) {
text-decoration: underline;
}
</style>