Refactor event form to use separate date/time inputs with validation
Build And Push Image / docker (push) Successful in 1m26s
Details
Build And Push Image / docker (push) Successful in 1m26s
Details
- Split combined datetime pickers into separate date and time fields - Add validation for past dates and time consistency - Implement error message display with dismissible alerts - Add watchers to combine date/time values into ISO strings - Set minimum date constraints to prevent past date selection - Add delete endpoint for events
This commit is contained in:
parent
9ee0b2f14e
commit
44aee8f2f9
|
|
@ -81,23 +81,58 @@
|
|||
<!-- Date and Time -->
|
||||
<v-col cols="12" md="6">
|
||||
<VDateInput
|
||||
v-model="startDateModel"
|
||||
label="Start Date & Time*"
|
||||
:rules="[v => !!v || 'Start date is required']"
|
||||
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="endDateModel"
|
||||
label="End Date & Time*"
|
||||
:rules="[v => !!v || 'End date is required']"
|
||||
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="startDateModel"
|
||||
: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>
|
||||
|
|
@ -241,6 +276,18 @@
|
|||
</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
|
||||
|
|
@ -297,7 +344,13 @@ const memberPricingEnabled = ref(true);
|
|||
const isRecurring = ref(false);
|
||||
const recurrenceFrequency = ref('weekly');
|
||||
|
||||
// Date picker state
|
||||
// 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);
|
||||
|
||||
|
|
@ -427,30 +480,45 @@ watch(recurrenceFrequency, (newValue) => {
|
|||
}
|
||||
});
|
||||
|
||||
// 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 startDate = new Date(newDate);
|
||||
startDateModel.value = startDate;
|
||||
eventData.start_datetime = startDate.toISOString();
|
||||
|
||||
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 endDate = new Date(startDate);
|
||||
endDate.setHours(endDate.getHours() + 2);
|
||||
endDateModel.value = endDate;
|
||||
eventData.end_datetime = endDate.toISOString();
|
||||
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 endDate = new Date(newEndDate);
|
||||
endDateModel.value = endDate;
|
||||
eventData.end_datetime = endDate.toISOString();
|
||||
|
||||
const prefillEndDate = new Date(newEndDate);
|
||||
endDate.value = prefillEndDate.toISOString().split('T')[0];
|
||||
endTime.value = prefillEndDate.toTimeString().substring(0, 5);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
|
|
@ -472,6 +540,22 @@ const onDatePickerClosed = () => {
|
|||
// 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 = '';
|
||||
|
|
@ -512,31 +596,81 @@ const close = () => {
|
|||
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 {
|
||||
// Ensure datetime strings are properly formatted
|
||||
const startDate = new Date(eventData.start_datetime);
|
||||
const endDate = new Date(eventData.end_datetime);
|
||||
// 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: startDate.toISOString(),
|
||||
end_datetime: endDate.toISOString()
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -352,6 +352,47 @@ export const useEvents = () => {
|
|||
cache.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an event (board/admin only)
|
||||
*/
|
||||
const deleteEvent = async (eventId: string) => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await $fetch<{ success: boolean; message: string; deleted: any }>(`/api/events/${eventId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// Remove event from local state
|
||||
const eventIndex = events.value.findIndex(e =>
|
||||
e.event_id === eventId ||
|
||||
e.id === eventId ||
|
||||
(e as any).Id === eventId
|
||||
);
|
||||
|
||||
if (eventIndex !== -1) {
|
||||
events.value.splice(eventIndex, 1);
|
||||
}
|
||||
|
||||
// Clear cache and refresh
|
||||
clearCache();
|
||||
await fetchEvents({ force: true });
|
||||
|
||||
return response;
|
||||
} else {
|
||||
throw new Error(response.message || 'Failed to delete event');
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || 'Failed to delete event';
|
||||
console.error('Error deleting event:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh events data
|
||||
*/
|
||||
|
|
@ -385,7 +426,9 @@ export const useEvents = () => {
|
|||
// Methods
|
||||
fetchEvents,
|
||||
createEvent,
|
||||
deleteEvent,
|
||||
rsvpToEvent,
|
||||
cancelRSVP,
|
||||
updateAttendance,
|
||||
getCalendarEvents,
|
||||
getUpcomingEvents,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
// server/api/events/[id].delete.ts
|
||||
import { createNocoDBEventsClient } from '~/server/utils/nocodb-events';
|
||||
import { createSessionManager } from '~/server/utils/session';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[api/events/[id].delete] =========================');
|
||||
console.log('[api/events/[id].delete] DELETE /api/events/[id] - Delete event');
|
||||
console.log('[api/events/[id].delete] Request from:', getClientIP(event));
|
||||
|
||||
try {
|
||||
const eventId = getRouterParam(event, 'id');
|
||||
|
||||
if (!eventId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Event ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[api/events/[id].delete] Deleting event:', eventId);
|
||||
|
||||
// Get user session using the working session manager
|
||||
const sessionManager = createSessionManager();
|
||||
const cookieHeader = getHeader(event, 'cookie');
|
||||
const session = sessionManager.getSession(cookieHeader);
|
||||
|
||||
if (!session || !session.user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user has permission to delete events (board or admin only)
|
||||
const userTier = session.user.tier;
|
||||
if (userTier !== 'board' && userTier !== 'admin') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Only board members and admins can delete events'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[api/events/[id].delete] ✅ User authorized to delete events:', session.user.email, 'tier:', userTier);
|
||||
|
||||
const eventsClient = createNocoDBEventsClient();
|
||||
|
||||
// First, get the event to verify it exists and get the event_id for RSVP cleanup
|
||||
let eventToDelete;
|
||||
try {
|
||||
eventToDelete = await eventsClient.findOne(eventId);
|
||||
console.log('[api/events/[id].delete] Found event to delete:', eventToDelete.title, 'event_id:', eventToDelete.event_id);
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 404) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Event not found'
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Get all RSVPs for this event to clean them up
|
||||
const eventIdentifier = eventToDelete.event_id || eventToDelete.id || (eventToDelete as any).Id;
|
||||
console.log('[api/events/[id].delete] Getting RSVPs for event identifier:', eventIdentifier);
|
||||
|
||||
const eventRSVPs = await eventsClient.getEventRSVPs(eventIdentifier.toString());
|
||||
console.log('[api/events/[id].delete] Found', eventRSVPs.length, 'RSVPs to delete');
|
||||
|
||||
// Delete all RSVPs first
|
||||
let deletedRSVPs = 0;
|
||||
for (const rsvp of eventRSVPs) {
|
||||
try {
|
||||
// Delete RSVP using its database Id
|
||||
const rsvpId = rsvp.Id || rsvp.id;
|
||||
if (rsvpId) {
|
||||
console.log('[api/events/[id].delete] Deleting RSVP:', rsvpId);
|
||||
await $fetch(eventsClient.constructor.prototype.createEventTableUrl('EventRSVPs'), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'xc-token': eventsClient.constructor.prototype.getNocoDbConfiguration().token,
|
||||
},
|
||||
body: {
|
||||
Id: parseInt(rsvpId.toString())
|
||||
}
|
||||
});
|
||||
deletedRSVPs++;
|
||||
}
|
||||
} catch (rsvpError: any) {
|
||||
console.log('[api/events/[id].delete] ⚠️ Error deleting RSVP:', rsvp.Id || rsvp.id, rsvpError);
|
||||
// Continue with other RSVPs even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[api/events/[id].delete] Deleted', deletedRSVPs, 'RSVPs');
|
||||
|
||||
// Now delete the event itself using its database Id
|
||||
const eventDatabaseId = (eventToDelete as any).Id || eventToDelete.id;
|
||||
if (!eventDatabaseId) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Could not determine event database ID for deletion'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[api/events/[id].delete] Deleting event with database ID:', eventDatabaseId);
|
||||
await eventsClient.delete(eventDatabaseId.toString());
|
||||
|
||||
console.log('[api/events/[id].delete] ✅ Successfully deleted event and all associated RSVPs');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Event "${eventToDelete.title}" and ${deletedRSVPs} associated RSVPs deleted successfully`,
|
||||
deleted: {
|
||||
event: {
|
||||
id: eventDatabaseId,
|
||||
event_id: eventToDelete.event_id,
|
||||
title: eventToDelete.title
|
||||
},
|
||||
rsvps_deleted: deletedRSVPs
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[api/events/[id].delete] ❌ Error deleting event:', error);
|
||||
|
||||
if (error.statusCode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to delete event'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -274,22 +274,52 @@ export function createNocoDBEventsClient() {
|
|||
},
|
||||
|
||||
/**
|
||||
* Find a single event by ID
|
||||
* Find a single event by ID (supports both database Id and business event_id)
|
||||
*/
|
||||
async findOne(id: string) {
|
||||
console.log('[nocodb-events] Fetching event ID:', id);
|
||||
|
||||
try {
|
||||
// First, try to fetch by database Id (numeric)
|
||||
if (/^\d+$/.test(id)) {
|
||||
console.log('[nocodb-events] Using database Id lookup for:', id);
|
||||
const result = await $fetch<Event>(`${createEventTableUrl(EventTable.Events)}/${id}`, {
|
||||
headers: {
|
||||
"xc-token": getNocoDbConfiguration().token,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[nocodb-events] Successfully retrieved event:', result.id || (result as any).Id);
|
||||
console.log('[nocodb-events] Successfully retrieved event by database Id:', result.id || (result as any).Id);
|
||||
return normalizeEventFieldsFromNocoDB(result);
|
||||
}
|
||||
|
||||
// Otherwise, search by business event_id
|
||||
console.log('[nocodb-events] Using event_id lookup for:', id);
|
||||
const results = await $fetch<{list: Event[]}>(`${createEventTableUrl(EventTable.Events)}`, {
|
||||
headers: {
|
||||
"xc-token": getNocoDbConfiguration().token,
|
||||
},
|
||||
params: {
|
||||
where: `(event_id,eq,${id})`,
|
||||
limit: 1
|
||||
}
|
||||
});
|
||||
|
||||
if (results.list && results.list.length > 0) {
|
||||
console.log('[nocodb-events] Successfully found event by event_id:', results.list[0].id || (results.list[0] as any).Id);
|
||||
return normalizeEventFieldsFromNocoDB(results.list[0]);
|
||||
}
|
||||
|
||||
console.log('[nocodb-events] No event found with event_id:', id);
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Event not found'
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[nocodb-events] Error fetching event:', error);
|
||||
if (error.statusCode === 404) {
|
||||
throw error; // Re-throw 404 errors as-is
|
||||
}
|
||||
handleNocoDbError(error, 'getEventById', 'Event');
|
||||
throw error;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue