Add series management and ticketing features: Introduce series event functionality in event creation, enhance event display with series information, and implement ticketing options for public events. Update layouts and improve form handling for better user experience.
This commit is contained in:
parent
c3a29fa47c
commit
a88aa62198
24 changed files with 2897 additions and 44 deletions
504
app/pages/admin/series-management.vue
Normal file
504
app/pages/admin/series-management.vue
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="bg-white border-b">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="py-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Series Management</h1>
|
||||
<p class="text-gray-600">Manage event series and their relationships</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Series Overview -->
|
||||
<div class="mb-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 bg-purple-100 rounded-full">
|
||||
<Icon name="heroicons:squares-2x2" class="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-500">Active Series</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">{{ activeSeries.length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 bg-blue-100 rounded-full">
|
||||
<Icon name="heroicons:calendar-days" class="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-500">Total Series Events</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">{{ totalSeriesEvents }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-3 bg-green-100 rounded-full">
|
||||
<Icon name="heroicons:chart-bar" class="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm text-gray-500">Avg Events/Series</p>
|
||||
<p class="text-2xl font-semibold text-gray-900">
|
||||
{{ activeSeries.length > 0 ? Math.round(totalSeriesEvents / activeSeries.length) : 0 }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Bar -->
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<div class="flex gap-4 items-center">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search series..."
|
||||
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
<select
|
||||
v-model="statusFilter"
|
||||
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="completed">Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
@click="showBulkModal = true"
|
||||
class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 inline-flex items-center"
|
||||
>
|
||||
<Icon name="heroicons:cog-6-tooth" class="w-4 h-4 mr-2" />
|
||||
Bulk Operations
|
||||
</button>
|
||||
<NuxtLink
|
||||
to="/admin/series/create"
|
||||
class="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 inline-flex items-center"
|
||||
>
|
||||
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
|
||||
Create Series
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Series List -->
|
||||
<div v-if="pending" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mx-auto mb-4"></div>
|
||||
<p class="text-gray-600">Loading series...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredSeries.length > 0" class="space-y-6">
|
||||
<div
|
||||
v-for="series in filteredSeries"
|
||||
:key="series.id"
|
||||
class="bg-white rounded-lg shadow overflow-hidden"
|
||||
>
|
||||
<!-- Series Header -->
|
||||
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div :class="[
|
||||
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
|
||||
getSeriesTypeBadgeClass(series.type)
|
||||
]">
|
||||
{{ formatSeriesType(series.type) }}
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ series.title }}</h3>
|
||||
<p class="text-sm text-gray-600">{{ series.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span :class="[
|
||||
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
|
||||
series.status === 'active' ? 'bg-green-100 text-green-700' :
|
||||
series.status === 'upcoming' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
]">
|
||||
{{ series.status }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ series.eventCount }} events
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Series Events -->
|
||||
<div class="divide-y divide-gray-200">
|
||||
<div
|
||||
v-for="event in series.events"
|
||||
:key="event.id"
|
||||
class="px-6 py-4 hover:bg-gray-50"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-8 h-8 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center text-sm font-semibold">
|
||||
{{ event.series?.position || '?' }}
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900">{{ event.title }}</h4>
|
||||
<p class="text-xs text-gray-500">{{ formatEventDate(event.startDate) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="[
|
||||
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
|
||||
getEventStatusClass(event)
|
||||
]">
|
||||
{{ getEventStatus(event) }}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<NuxtLink
|
||||
:to="`/events/${event.slug || event.id}`"
|
||||
class="p-1 text-gray-400 hover:text-gray-600 rounded"
|
||||
title="View Event"
|
||||
>
|
||||
<Icon name="heroicons:eye" class="w-4 h-4" />
|
||||
</NuxtLink>
|
||||
<button
|
||||
@click="editEvent(event)"
|
||||
class="p-1 text-gray-400 hover:text-purple-600 rounded"
|
||||
title="Edit Event"
|
||||
>
|
||||
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="removeFromSeries(event)"
|
||||
class="p-1 text-gray-400 hover:text-red-600 rounded"
|
||||
title="Remove from Series"
|
||||
>
|
||||
<Icon name="heroicons:x-mark" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Series Actions -->
|
||||
<div class="px-6 py-3 bg-gray-50 border-t border-gray-200">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ formatDateRange(series.startDate, series.endDate) }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="addEventToSeries(series)"
|
||||
class="text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
Add Event
|
||||
</button>
|
||||
<button
|
||||
@click="duplicateSeries(series)"
|
||||
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
Duplicate Series
|
||||
</button>
|
||||
<button
|
||||
@click="deleteSeries(series)"
|
||||
class="text-sm text-red-600 hover:text-red-700 font-medium"
|
||||
>
|
||||
Delete Series
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-12 bg-white rounded-lg shadow">
|
||||
<Icon name="heroicons:squares-2x2" class="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||
<p class="text-gray-600">No event series found</p>
|
||||
<p class="text-sm text-gray-500 mt-2">Create events and group them into series to get started</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Operations Modal -->
|
||||
<div v-if="showBulkModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Bulk Series Operations</h3>
|
||||
<button @click="showBulkModal = false" class="text-gray-400 hover:text-gray-600">
|
||||
<Icon name="heroicons:x-mark" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Series Management Tools</h4>
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
@click="reorderAllSeries"
|
||||
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon name="heroicons:arrows-up-down" class="w-5 h-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Auto-Reorder Series</p>
|
||||
<p class="text-xs text-gray-500">Fix position numbers based on event dates</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="validateAllSeries"
|
||||
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon name="heroicons:check-circle" class="w-5 h-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Validate Series Data</p>
|
||||
<p class="text-xs text-gray-500">Check for consistency issues</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="exportSeriesData"
|
||||
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<Icon name="heroicons:document-arrow-down" class="w-5 h-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Export Series Data</p>
|
||||
<p class="text-xs text-gray-500">Download series information as JSON</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t border-gray-200 flex justify-end">
|
||||
<button
|
||||
@click="showBulkModal = false"
|
||||
class="px-4 py-2 text-gray-600 hover:text-gray-700"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const showBulkModal = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref('')
|
||||
|
||||
// Fetch series data
|
||||
const { data: seriesData, pending, refresh } = await useFetch('/api/admin/series')
|
||||
|
||||
// Computed properties
|
||||
const activeSeries = computed(() => {
|
||||
if (!seriesData.value) return []
|
||||
return seriesData.value
|
||||
})
|
||||
|
||||
const totalSeriesEvents = computed(() => {
|
||||
return activeSeries.value.reduce((sum, series) => sum + (series.eventCount || 0), 0)
|
||||
})
|
||||
|
||||
const filteredSeries = computed(() => {
|
||||
if (!activeSeries.value) return []
|
||||
|
||||
return activeSeries.value.filter(series => {
|
||||
const matchesSearch = !searchQuery.value ||
|
||||
series.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
series.description.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
|
||||
const matchesStatus = !statusFilter.value || series.status === statusFilter.value
|
||||
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
const formatSeriesType = (type) => {
|
||||
const types = {
|
||||
'workshop_series': 'Workshop Series',
|
||||
'recurring_meetup': 'Recurring Meetup',
|
||||
'multi_day': 'Multi-Day Event',
|
||||
'course': 'Course',
|
||||
'tournament': 'Tournament'
|
||||
}
|
||||
return types[type] || type
|
||||
}
|
||||
|
||||
const getSeriesTypeBadgeClass = (type) => {
|
||||
const classes = {
|
||||
'workshop_series': 'bg-emerald-100 text-emerald-700',
|
||||
'recurring_meetup': 'bg-blue-100 text-blue-700',
|
||||
'multi_day': 'bg-purple-100 text-purple-700',
|
||||
'course': 'bg-amber-100 text-amber-700',
|
||||
'tournament': 'bg-red-100 text-red-700'
|
||||
}
|
||||
return classes[type] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
const formatEventDate = (date) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const formatDateRange = (startDate, endDate) => {
|
||||
if (!startDate || !endDate) return 'No dates'
|
||||
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
|
||||
return `${formatter.format(start)} - ${formatter.format(end)}`
|
||||
}
|
||||
|
||||
const getEventStatus = (event) => {
|
||||
const now = new Date()
|
||||
const startDate = new Date(event.startDate)
|
||||
const endDate = new Date(event.endDate)
|
||||
|
||||
if (now < startDate) return 'Upcoming'
|
||||
if (now >= startDate && now <= endDate) return 'Ongoing'
|
||||
return 'Completed'
|
||||
}
|
||||
|
||||
const getEventStatusClass = (event) => {
|
||||
const status = getEventStatus(event)
|
||||
const classes = {
|
||||
'Upcoming': 'bg-blue-100 text-blue-700',
|
||||
'Ongoing': 'bg-green-100 text-green-700',
|
||||
'Completed': 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
return classes[status] || 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
// Actions
|
||||
const editEvent = (event) => {
|
||||
navigateTo(`/admin/events/create?edit=${event.id}`)
|
||||
}
|
||||
|
||||
const removeFromSeries = async (event) => {
|
||||
if (!confirm(`Remove "${event.title}" from its series?`)) return
|
||||
|
||||
try {
|
||||
await $fetch(`/api/admin/events/${event.id}`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
...event,
|
||||
series: {
|
||||
isSeriesEvent: false,
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'workshop_series',
|
||||
position: 1,
|
||||
totalEvents: null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
console.error('Failed to remove event from series:', error)
|
||||
alert('Failed to remove event from series')
|
||||
}
|
||||
}
|
||||
|
||||
const addEventToSeries = (series) => {
|
||||
// Navigate to create page with series pre-filled
|
||||
const seriesData = {
|
||||
series: {
|
||||
isSeriesEvent: true,
|
||||
id: series.id,
|
||||
title: series.title,
|
||||
description: series.description,
|
||||
type: series.type,
|
||||
position: (series.eventCount || 0) + 1,
|
||||
totalEvents: series.totalEvents
|
||||
}
|
||||
}
|
||||
|
||||
sessionStorage.setItem('seriesEventData', JSON.stringify(seriesData))
|
||||
navigateTo('/admin/events/create?series=true')
|
||||
}
|
||||
|
||||
const duplicateSeries = (series) => {
|
||||
// TODO: Implement series duplication
|
||||
alert('Series duplication coming soon!')
|
||||
}
|
||||
|
||||
const deleteSeries = async (series) => {
|
||||
if (!confirm(`Delete the entire "${series.title}" series? This will remove the series relationship from all ${series.eventCount} events.`)) return
|
||||
|
||||
try {
|
||||
// Update all events to remove series relationship
|
||||
for (const event of series.events) {
|
||||
await $fetch(`/api/admin/events/${event.id}`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
...event,
|
||||
series: {
|
||||
isSeriesEvent: false,
|
||||
id: '',
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'workshop_series',
|
||||
position: 1,
|
||||
totalEvents: null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await refresh()
|
||||
alert('Series deleted and events converted to standalone events')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete series:', error)
|
||||
alert('Failed to delete series')
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk operations
|
||||
const reorderAllSeries = async () => {
|
||||
// TODO: Implement auto-reordering
|
||||
alert('Auto-reorder feature coming soon!')
|
||||
}
|
||||
|
||||
const validateAllSeries = async () => {
|
||||
// TODO: Implement validation
|
||||
alert('Validation feature coming soon!')
|
||||
}
|
||||
|
||||
const exportSeriesData = () => {
|
||||
const dataStr = JSON.stringify(activeSeries.value, null, 2)
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(dataBlob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'event-series-data.json'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue