monacousa-portal/components/CreateEventDialog.vue

770 lines
22 KiB
Vue

<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">
<VuetifyTiptap
v-model="eventData.description"
label="Description"
:toolbar="[
'bold',
'italic',
'underline',
'|',
'heading',
'|',
'bulletList',
'orderedList',
'|',
'link',
'|',
'undo',
'redo'
]"
:max-height="200"
placeholder="Enter event description with formatting..."
outlined
/>
</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">
<VDateInput
v-model="startDate"
label="Start Date*"
:rules="[
v => !!v || 'Start date is required',
v => !v || new Date(v).getTime() >= new Date().setHours(0,0,0,0) || 'Start date cannot be in the past'
]"
variant="outlined"
prepend-inner-icon="mdi-calendar"
required
:min="new Date().toISOString().split('T')[0]"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="startTime"
label="Start Time*"
type="time"
:rules="[v => !!v || 'Start time is required']"
variant="outlined"
prepend-inner-icon="mdi-clock"
required
/>
</v-col>
<v-col cols="12" md="6">
<VDateInput
v-model="endDate"
label="End Date*"
:rules="[
v => !!v || 'End date is required',
v => !v || new Date(v).getTime() >= new Date().setHours(0,0,0,0) || 'End date cannot be in the past',
v => !v || !startDate || new Date(v).getTime() >= new Date(startDate).getTime() || 'End date must be same or after start date'
]"
variant="outlined"
prepend-inner-icon="mdi-calendar"
:min="startDate || new Date().toISOString().split('T')[0]"
required
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="endTime"
label="End Time*"
type="time"
:rules="[
v => !!v || 'End time is required',
v => !validateEndTime() || 'End time must be after start time when on same date'
]"
variant="outlined"
prepend-inner-icon="mdi-clock"
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>
<!-- Guest Settings -->
<v-col cols="12" md="6">
<v-switch
v-model="allowGuests"
label="Allow Guests"
color="primary"
inset
hint="Members can bring additional guests"
persistent-hint
/>
</v-col>
<!-- Max Guests Per Person (shown when guests allowed) -->
<v-col v-if="allowGuests" cols="12" md="6">
<v-text-field
v-model="maxGuestsPerPerson"
label="Max Guests Per Person"
type="number"
variant="outlined"
:rules="allowGuests ? [v => v && parseInt(v) > 0 || 'Must allow at least 1 guest'] : []"
hint="Maximum additional guests each member can bring"
persistent-hint
/>
</v-col>
<!-- Payment Settings -->
<v-col cols="12" :md="allowGuests ? 6 : 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>
<!-- Error message display -->
<v-card-text v-if="errorMessage" class="pt-0">
<v-alert
type="error"
variant="tonal"
closable
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
</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');
// Date and time picker state
const startDate = ref<string>('');
const startTime = ref<string>('');
const endDate = ref<string>('');
const endTime = ref<string>('');
// Legacy date model refs for backward compatibility
const startDateModel = ref<Date | null>(null);
const endDateModel = ref<Date | null>(null);
// Date format for display
const dateTimeFormat = 'dd/MM/yyyy HH:mm (Monaco)';
// 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',
guests_permitted: 'false',
max_guests_permitted: '0'
});
// Guest settings
const allowGuests = ref(false);
const maxGuestsPerPerson = ref(1);
// 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(allowGuests, (newValue) => {
eventData.guests_permitted = newValue ? 'true' : 'false';
if (!newValue) {
eventData.max_guests_permitted = '0';
maxGuestsPerPerson.value = 1;
}
});
watch(maxGuestsPerPerson, (newValue) => {
if (allowGuests.value) {
eventData.max_guests_permitted = newValue.toString();
}
});
// Fix date picker binding - ensure proper syncing
watch(startDateModel, (newDate) => {
if (newDate instanceof Date) {
eventData.start_datetime = newDate.toISOString();
console.log('[CreateEventDialog] Start date updated:', eventData.start_datetime);
}
});
watch(endDateModel, (newDate) => {
if (newDate instanceof Date) {
eventData.end_datetime = newDate.toISOString();
console.log('[CreateEventDialog] End date updated:', eventData.end_datetime);
}
});
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 separate date and time changes to combine them
watch([startDate, startTime], ([newDate, newTime]) => {
if (newDate && newTime) {
const combinedDateTime = new Date(`${newDate}T${newTime}`);
eventData.start_datetime = combinedDateTime.toISOString();
console.log('[CreateEventDialog] Combined start datetime:', eventData.start_datetime);
}
});
watch([endDate, endTime], ([newDate, newTime]) => {
if (newDate && newTime) {
const combinedDateTime = new Date(`${newDate}T${newTime}`);
eventData.end_datetime = combinedDateTime.toISOString();
console.log('[CreateEventDialog] Combined end datetime:', eventData.end_datetime);
}
});
// Watch for prefilled dates
watch(() => props.prefilledDate, (newDate) => {
if (newDate) {
const prefillDate = new Date(newDate);
startDate.value = prefillDate.toISOString().split('T')[0];
startTime.value = prefillDate.toTimeString().substring(0, 5);
// Set end date 2 hours later if not provided
if (!props.prefilledEndDate) {
const endDateTime = new Date(prefillDate);
endDateTime.setHours(endDateTime.getHours() + 2);
endDate.value = endDateTime.toISOString().split('T')[0];
endTime.value = endDateTime.toTimeString().substring(0, 5);
}
}
}, { immediate: true });
watch(() => props.prefilledEndDate, (newEndDate) => {
if (newEndDate) {
const prefillEndDate = new Date(newEndDate);
endDate.value = prefillEndDate.toISOString().split('T')[0];
endTime.value = prefillEndDate.toTimeString().substring(0, 5);
}
}, { immediate: true });
// Date picker handlers
const handleStartDateUpdate = (date: Date | null) => {
if (date) {
eventData.start_datetime = date.toISOString();
}
};
const handleEndDateUpdate = (date: Date | null) => {
if (date) {
eventData.end_datetime = date.toISOString();
}
};
const onDatePickerClosed = () => {
console.log('[CreateEventDialog] Date picker closed');
// This handler ensures the date picker behaves correctly on mobile and desktop
};
// Validation functions
const validateEndTime = () => {
if (!startDate.value || !endDate.value || !startTime.value || !endTime.value) {
return false; // Return false (no error) if not all fields are filled
}
// Only validate if start and end are on the same date
if (startDate.value === endDate.value) {
const start = startTime.value;
const end = endTime.value;
return start >= end; // Return true if there's an error (start >= end)
}
return false; // No error if different dates
};
// 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.guests_permitted = 'false';
eventData.max_guests_permitted = '0';
eventData.visibility = 'public';
eventData.status = 'active';
eventData.is_recurring = 'false';
eventData.recurrence_pattern = '';
// Reset date pickers
startDateModel.value = null;
endDateModel.value = null;
// Reset UI state
isPaidEvent.value = false;
memberPricingEnabled.value = true;
isRecurring.value = false;
recurrenceFrequency.value = 'weekly';
allowGuests.value = false;
maxGuestsPerPerson.value = 1;
form.value?.resetValidation();
};
const close = () => {
show.value = false;
resetForm();
};
// Error handling
const errorMessage = ref<string | null>(null);
const handleSubmit = async () => {
if (!form.value) return;
const isValid = await form.value.validate();
if (!isValid.valid) return;
// Clear previous errors
errorMessage.value = null;
// Validate that we have proper date/time combination
if (!startDate.value || !startTime.value) {
errorMessage.value = 'Start date and time are required';
return;
}
if (!endDate.value || !endTime.value) {
errorMessage.value = 'End date and time are required';
return;
}
loading.value = true;
try {
// Combine date and time properly
const startDateTime = new Date(`${startDate.value}T${startTime.value}`);
const endDateTime = new Date(`${endDate.value}T${endTime.value}`);
// Validate start is not in the past
if (startDateTime < new Date()) {
errorMessage.value = 'Event start time cannot be in the past';
loading.value = false;
return;
}
// Validate end is after start
if (endDateTime <= startDateTime) {
errorMessage.value = 'Event end time must be after start time';
loading.value = false;
return;
}
const formattedEventData = {
...eventData,
start_datetime: startDateTime.toISOString(),
end_datetime: endDateTime.toISOString()
};
console.log('[CreateEventDialog] Creating event with data:', formattedEventData);
const newEvent = await createEvent(formattedEventData);
emit('event-created', newEvent);
close();
} catch (error: any) {
console.error('Error creating event:', error);
// Parse error message for better UX
let userErrorMessage = 'Failed to create event';
if (error?.data?.message) {
userErrorMessage = error.data.message;
} else if (error?.message) {
if (error.message.includes('past')) {
userErrorMessage = 'Event date cannot be in the past';
} else if (error.message.includes('validation')) {
userErrorMessage = 'Please check all required fields';
} else {
userErrorMessage = error.message;
}
}
errorMessage.value = userErrorMessage;
} finally {
loading.value = false;
}
};
// Removed duplicate prefilled date logic - handled by watchers above
</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;
}
/* Date picker styling to match Vuetify */
.date-picker-wrapper {
width: 100%;
}
.date-picker-label {
font-size: 16px;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-weight: 400;
line-height: 1.5;
letter-spacing: 0.009375em;
margin-bottom: 8px;
display: block;
}
/* Style the Vue DatePicker to match Vuetify inputs */
:deep(.dp__input) {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
padding: 16px 12px;
padding-right: 48px; /* Make room for calendar icon */
font-size: 16px;
line-height: 1.5;
background: rgb(var(--v-theme-surface));
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
transition: border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
width: 100%;
min-height: 56px;
}
:deep(.dp__input:hover) {
border-color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
:deep(.dp__input:focus) {
border-color: rgb(var(--v-theme-primary));
border-width: 2px;
outline: none;
}
:deep(.dp__input_readonly) {
cursor: pointer;
}
/* Style the date picker dropdown */
:deep(.dp__menu) {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
background: rgb(var(--v-theme-surface));
}
/* Primary color theming for the date picker */
:deep(.dp__primary_color) {
background-color: rgb(var(--v-theme-primary));
}
:deep(.dp__primary_text) {
color: rgb(var(--v-theme-primary));
}
:deep(.dp__active_date) {
background-color: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
}
:deep(.dp__today) {
border: 1px solid rgb(var(--v-theme-primary));
}
</style>