ghostguild-org/app/pages/admin/series-management.vue

504 lines
No EOL
18 KiB
Vue

<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>