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:
@@ -311,6 +311,20 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- View Member Dialog -->
|
||||
<ViewMemberDialog
|
||||
v-model="showViewDialog"
|
||||
:member="selectedMember"
|
||||
@edit="handleEditMember"
|
||||
/>
|
||||
|
||||
<!-- Edit Member Dialog -->
|
||||
<EditMemberDialog
|
||||
v-model="showEditDialog"
|
||||
:member="selectedMember"
|
||||
@member-updated="handleMemberUpdated"
|
||||
/>
|
||||
|
||||
<!-- Create User Dialog -->
|
||||
<v-dialog v-model="showCreateUserDialog" max-width="600">
|
||||
<v-card>
|
||||
@@ -408,6 +422,11 @@ const overdueCount = ref(0);
|
||||
const overdueRefreshTrigger = ref(0);
|
||||
const duesRefreshTrigger = ref(0);
|
||||
|
||||
// Member dialog state
|
||||
const showViewDialog = ref(false);
|
||||
const showEditDialog = ref(false);
|
||||
const selectedMember = ref(null);
|
||||
|
||||
// Create user dialog data
|
||||
const createUserValid = ref(false);
|
||||
const creatingUser = ref(false);
|
||||
@@ -673,9 +692,16 @@ const handleStatusesUpdated = async (updatedCount: number) => {
|
||||
};
|
||||
|
||||
const handleViewMember = (member: any) => {
|
||||
// Navigate to member details or open modal
|
||||
console.log('View member:', member.FullName || `${member.first_name} ${member.last_name}`);
|
||||
navigateTo('/dashboard/member-list');
|
||||
// Open the view dialog instead of navigating away
|
||||
selectedMember.value = member;
|
||||
showViewDialog.value = true;
|
||||
};
|
||||
|
||||
const handleEditMember = (member: any) => {
|
||||
// Close the view dialog and open the edit dialog
|
||||
showViewDialog.value = false;
|
||||
selectedMember.value = member;
|
||||
showEditDialog.value = true;
|
||||
};
|
||||
|
||||
const navigateToMembers = () => {
|
||||
@@ -686,6 +712,9 @@ const navigateToMembers = () => {
|
||||
const handleMemberUpdated = (member: any) => {
|
||||
console.log('Member updated:', member.FullName || `${member.first_name} ${member.last_name}`);
|
||||
|
||||
// Close edit dialog
|
||||
showEditDialog.value = false;
|
||||
|
||||
// Trigger dues refresh
|
||||
duesRefreshTrigger.value += 1;
|
||||
};
|
||||
|
||||
@@ -235,6 +235,20 @@
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- View Member Dialog -->
|
||||
<ViewMemberDialog
|
||||
v-model="showViewDialog"
|
||||
:member="selectedMember"
|
||||
@edit="handleEditMember"
|
||||
/>
|
||||
|
||||
<!-- Edit Member Dialog -->
|
||||
<EditMemberDialog
|
||||
v-model="showEditDialog"
|
||||
:member="selectedMember"
|
||||
@member-updated="handleMemberUpdated"
|
||||
/>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
@@ -261,6 +275,11 @@ onMounted(() => {
|
||||
// Dues management state
|
||||
const duesRefreshTrigger = ref(0);
|
||||
|
||||
// Member dialog state
|
||||
const showViewDialog = ref(false);
|
||||
const showEditDialog = ref(false);
|
||||
const selectedMember = ref(null);
|
||||
|
||||
// Mock data for board dashboard
|
||||
const stats = ref({
|
||||
totalMembers: 156,
|
||||
@@ -300,15 +319,24 @@ const recentActivity = ref([
|
||||
|
||||
// Dues management handlers
|
||||
const handleViewMember = (member: Member) => {
|
||||
// Navigate to member details or open modal
|
||||
console.log('View member:', member.FullName || `${member.first_name} ${member.last_name}`);
|
||||
// You could implement member detail view here
|
||||
navigateToMembers();
|
||||
// Open the view dialog instead of navigating away
|
||||
selectedMember.value = member;
|
||||
showViewDialog.value = true;
|
||||
};
|
||||
|
||||
const handleEditMember = (member: Member) => {
|
||||
// Close the view dialog and open the edit dialog
|
||||
showViewDialog.value = false;
|
||||
selectedMember.value = member;
|
||||
showEditDialog.value = true;
|
||||
};
|
||||
|
||||
const handleMemberUpdated = (member: Member) => {
|
||||
console.log('Member updated:', member.FullName || `${member.first_name} ${member.last_name}`);
|
||||
|
||||
// Close edit dialog
|
||||
showEditDialog.value = false;
|
||||
|
||||
// Trigger dues refresh to update the lists
|
||||
duesRefreshTrigger.value += 1;
|
||||
|
||||
|
||||
464
pages/dashboard/events.vue
Normal file
464
pages/dashboard/events.vue
Normal file
@@ -0,0 +1,464 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
<!-- Upcoming Event Banner -->
|
||||
<UpcomingEventBanner
|
||||
v-if="upcomingEvent"
|
||||
:event="upcomingEvent"
|
||||
class="mb-4"
|
||||
@event-click="handleEventClick"
|
||||
/>
|
||||
|
||||
<!-- Page Header -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" md="8">
|
||||
<h1 class="text-h4 font-weight-bold text-primary">
|
||||
<v-icon class="me-2">mdi-calendar</v-icon>
|
||||
Events Calendar
|
||||
</h1>
|
||||
<p class="text-body-1 text-medium-emphasis">
|
||||
View and manage events for the MonacoUSA community
|
||||
</p>
|
||||
</v-col>
|
||||
<v-col cols="12" md="4" class="d-flex justify-end align-start ga-2">
|
||||
<v-btn
|
||||
v-if="isBoard || isAdmin"
|
||||
@click="showCreateDialog = true"
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
size="large"
|
||||
>
|
||||
Create Event
|
||||
</v-btn>
|
||||
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-download"
|
||||
size="large"
|
||||
>
|
||||
Subscribe
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item @click="exportCalendar">
|
||||
<v-list-item-title>
|
||||
<v-icon start>mdi-calendar-export</v-icon>
|
||||
Export Calendar
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="subscribeCalendar">
|
||||
<v-list-item-title>
|
||||
<v-icon start>mdi-calendar-sync</v-icon>
|
||||
Subscribe (iOS/Android)
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Filters Row -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="filters.event_type"
|
||||
:items="eventTypeOptions"
|
||||
label="Event Type"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
clearable
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="filters.visibility"
|
||||
:items="visibilityOptions"
|
||||
label="Visibility"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
clearable
|
||||
@update:model-value="applyFilters"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-text-field
|
||||
v-model="filters.search"
|
||||
label="Search events..."
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
@update:model-value="debounceSearch"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3" class="d-flex align-center">
|
||||
<v-btn
|
||||
@click="clearFilters"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-filter-off"
|
||||
:disabled="!hasActiveFilters"
|
||||
>
|
||||
Clear Filters
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Main Calendar -->
|
||||
<EventCalendar
|
||||
ref="calendarRef"
|
||||
:events="events"
|
||||
:loading="loading"
|
||||
:show-create-button="false"
|
||||
@event-click="handleEventClick"
|
||||
@date-click="handleDateClick"
|
||||
@view-change="handleViewChange"
|
||||
@date-range-change="handleDateRangeChange"
|
||||
/>
|
||||
|
||||
<!-- Stats Row (if admin/board) -->
|
||||
<v-row v-if="isBoard || isAdmin" class="mt-6">
|
||||
<v-col cols="12" md="3">
|
||||
<v-card variant="outlined">
|
||||
<v-card-text class="text-center">
|
||||
<div class="text-h4 text-primary font-weight-bold">{{ totalEvents }}</div>
|
||||
<div class="text-body-2">Total Events</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-card variant="outlined">
|
||||
<v-card-text class="text-center">
|
||||
<div class="text-h4 text-success font-weight-bold">{{ totalRSVPs }}</div>
|
||||
<div class="text-body-2">Total RSVPs</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-card variant="outlined">
|
||||
<v-card-text class="text-center">
|
||||
<div class="text-h4 text-warning font-weight-bold">{{ upcomingEventsCount }}</div>
|
||||
<div class="text-body-2">Upcoming Events</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-card variant="outlined">
|
||||
<v-card-text class="text-center">
|
||||
<div class="text-h4 text-info font-weight-bold">{{ thisMonthEventsCount }}</div>
|
||||
<div class="text-body-2">This Month</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<CreateEventDialog
|
||||
v-model="showCreateDialog"
|
||||
:prefilled-date="prefilledDate"
|
||||
:prefilled-end-date="prefilledEndDate"
|
||||
@event-created="handleEventCreated"
|
||||
/>
|
||||
|
||||
<EventDetailsDialog
|
||||
v-model="showDetailsDialog"
|
||||
:event="selectedEvent"
|
||||
@rsvp-updated="handleRSVPUpdated"
|
||||
/>
|
||||
|
||||
<!-- Error Snackbar -->
|
||||
<v-snackbar
|
||||
v-model="showError"
|
||||
color="error"
|
||||
:timeout="5000"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
<template #actions>
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="showError = false"
|
||||
>
|
||||
Close
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
|
||||
<!-- Success Snackbar -->
|
||||
<v-snackbar
|
||||
v-model="showSuccess"
|
||||
color="success"
|
||||
:timeout="3000"
|
||||
>
|
||||
{{ successMessage }}
|
||||
<template #actions>
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="showSuccess = false"
|
||||
>
|
||||
Close
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Event, EventFilters } from '~/utils/types';
|
||||
import { useAuth } from '~/composables/useAuth';
|
||||
import { useEvents } from '~/composables/useEvents';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'dashboard',
|
||||
middleware: 'auth'
|
||||
});
|
||||
|
||||
const { isBoard, isAdmin, user } = useAuth();
|
||||
const {
|
||||
events,
|
||||
loading,
|
||||
error,
|
||||
upcomingEvent,
|
||||
fetchEvents,
|
||||
getUpcomingEvents,
|
||||
clearCache
|
||||
} = useEvents();
|
||||
|
||||
// Component refs
|
||||
const calendarRef = ref();
|
||||
|
||||
// Reactive state
|
||||
const showCreateDialog = ref(false);
|
||||
const showDetailsDialog = ref(false);
|
||||
const selectedEvent = ref<Event | null>(null);
|
||||
const prefilledDate = ref<string>('');
|
||||
const prefilledEndDate = ref<string>('');
|
||||
|
||||
// Filter state
|
||||
const filters = reactive<EventFilters>({
|
||||
event_type: undefined,
|
||||
visibility: undefined,
|
||||
search: undefined,
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
// Notification state
|
||||
const showError = ref(false);
|
||||
const showSuccess = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const successMessage = ref('');
|
||||
|
||||
// Search debouncing
|
||||
let searchTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
// Computed properties
|
||||
const eventTypeOptions = [
|
||||
{ title: 'All Types', value: undefined },
|
||||
{ title: 'Meeting', value: 'meeting' },
|
||||
{ title: 'Social Event', value: 'social' },
|
||||
{ title: 'Fundraiser', value: 'fundraiser' },
|
||||
{ title: 'Workshop', value: 'workshop' },
|
||||
{ title: 'Board Only', value: 'board-only' }
|
||||
];
|
||||
|
||||
const visibilityOptions = computed(() => {
|
||||
const options = [
|
||||
{ title: 'All Events', value: undefined },
|
||||
{ title: 'Public', value: 'public' },
|
||||
{ title: 'Board Only', value: 'board-only' }
|
||||
];
|
||||
|
||||
if (isAdmin.value) {
|
||||
options.push({ title: 'Admin Only', value: 'admin-only' });
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return filters.event_type || filters.visibility || filters.search;
|
||||
});
|
||||
|
||||
const totalEvents = computed(() => events.value.length);
|
||||
|
||||
const totalRSVPs = computed(() => {
|
||||
return events.value.reduce((count, event) => {
|
||||
return count + (event.current_attendees || 0);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const upcomingEventsCount = computed(() => {
|
||||
const now = new Date();
|
||||
return events.value.filter(event => new Date(event.start_datetime) >= now).length;
|
||||
});
|
||||
|
||||
const thisMonthEventsCount = computed(() => {
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
return events.value.filter(event => {
|
||||
const eventDate = new Date(event.start_datetime);
|
||||
return eventDate >= startOfMonth && eventDate <= endOfMonth;
|
||||
}).length;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const applyFilters = async () => {
|
||||
try {
|
||||
await fetchEvents(filters);
|
||||
} catch (err: any) {
|
||||
showErrorMessage('Failed to apply filters');
|
||||
}
|
||||
};
|
||||
|
||||
const debounceSearch = () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(() => {
|
||||
applyFilters();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const clearFilters = async () => {
|
||||
filters.event_type = undefined;
|
||||
filters.visibility = undefined;
|
||||
filters.search = undefined;
|
||||
|
||||
await applyFilters();
|
||||
};
|
||||
|
||||
const handleEventClick = (eventInfo: any) => {
|
||||
selectedEvent.value = eventInfo.eventData || eventInfo.event || eventInfo;
|
||||
showDetailsDialog.value = true;
|
||||
};
|
||||
|
||||
const handleDateClick = (dateInfo: any) => {
|
||||
if (isBoard.value || isAdmin.value) {
|
||||
prefilledDate.value = dateInfo.date;
|
||||
prefilledEndDate.value = dateInfo.endDate || '';
|
||||
showCreateDialog.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewChange = (viewInfo: any) => {
|
||||
// Handle calendar view changes if needed
|
||||
console.log('View changed:', viewInfo);
|
||||
};
|
||||
|
||||
const handleDateRangeChange = async (start: string, end: string) => {
|
||||
// Fetch events for the new date range
|
||||
const rangeFilters = {
|
||||
...filters,
|
||||
start_date: start,
|
||||
end_date: end
|
||||
};
|
||||
|
||||
try {
|
||||
await fetchEvents(rangeFilters);
|
||||
} catch (err: any) {
|
||||
showErrorMessage('Failed to load events for date range');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEventCreated = (event: Event) => {
|
||||
showSuccessMessage('Event created successfully!');
|
||||
refreshCalendar();
|
||||
};
|
||||
|
||||
const handleRSVPUpdated = (event: Event) => {
|
||||
showSuccessMessage('RSVP updated successfully!');
|
||||
refreshCalendar();
|
||||
};
|
||||
|
||||
const refreshCalendar = () => {
|
||||
calendarRef.value?.refetchEvents?.();
|
||||
clearCache();
|
||||
};
|
||||
|
||||
const exportCalendar = () => {
|
||||
// Create download link for iCal export
|
||||
const feedUrl = `/api/events/calendar-feed?user_id=${user.value?.id}&format=ical`;
|
||||
const link = document.createElement('a');
|
||||
link.href = feedUrl;
|
||||
link.download = 'monacousa-events.ics';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
showSuccessMessage('Calendar export started!');
|
||||
};
|
||||
|
||||
const subscribeCalendar = async () => {
|
||||
try {
|
||||
const feedUrl = `${window.location.origin}/api/events/calendar-feed?user_id=${user.value?.id}&format=ical`;
|
||||
|
||||
await navigator.clipboard.writeText(feedUrl);
|
||||
showSuccessMessage('Calendar subscription URL copied to clipboard!');
|
||||
} catch (error) {
|
||||
showErrorMessage('Failed to copy subscription URL');
|
||||
}
|
||||
};
|
||||
|
||||
const showErrorMessage = (message: string) => {
|
||||
errorMessage.value = message;
|
||||
showError.value = true;
|
||||
};
|
||||
|
||||
const showSuccessMessage = (message: string) => {
|
||||
successMessage.value = message;
|
||||
showSuccess.value = true;
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await fetchEvents({ status: 'active' });
|
||||
} catch (err: any) {
|
||||
showErrorMessage('Failed to load events');
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for errors from composable
|
||||
watchEffect(() => {
|
||||
if (error.value) {
|
||||
showErrorMessage(error.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
onUnmounted(() => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-medium-emphasis {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.v-container {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* Ensure calendar takes full width */
|
||||
:deep(.event-calendar) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 600px) {
|
||||
.v-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.text-h4 {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user