refactor: align codebase with EventCrew domain and trim legacy band stack

- Update API: events, users, policies, routes, resources, migrations
- Remove deprecated models/resources (customers, setlists, invitations, etc.)
- Refresh admin app and docs; remove apps/band

Made-with: Cursor
This commit is contained in:
2026-03-29 23:19:06 +02:00
parent 34e12e00b3
commit 1cb7674d52
1034 changed files with 7453 additions and 8743 deletions

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useEvents } from '@/composables/useEvents'
import { useRoute, useRouter } from 'vue-router'
import type { UpdateEventData } from '@/types/events'
import type { EventCrewEventStatus, UpdateEventData } from '@/types/events'
definePage({
meta: {
@@ -17,62 +17,57 @@ const eventId = computed(() => route.params.id as string)
const formData = ref<UpdateEventData>({})
const statusOptions = [
const statusOptions: { title: string; value: EventCrewEventStatus }[] = [
{ title: 'Draft', value: 'draft' },
{ title: 'Pending', value: 'pending' },
{ title: 'Confirmed', value: 'confirmed' },
{ title: 'Completed', value: 'completed' },
{ title: 'Cancelled', value: 'cancelled' },
]
const visibilityOptions = [
{ title: 'Private', value: 'private' },
{ title: 'Members', value: 'members' },
{ title: 'Public', value: 'public' },
{ title: 'Published', value: 'published' },
{ title: 'Registration open', value: 'registration_open' },
{ title: 'Build-up', value: 'buildup' },
{ title: 'Show day', value: 'showday' },
{ title: 'Teardown', value: 'teardown' },
{ title: 'Closed', value: 'closed' },
]
const errors = ref<Record<string, string>>({})
// Load event data
watch(() => eventId.value, async () => {
if (eventId.value) {
await fetchEvent(eventId.value)
if (currentEvent.value) {
formData.value = {
title: currentEvent.value.title,
description: currentEvent.value.description || '',
event_date: currentEvent.value.event_date,
start_time: currentEvent.value.start_time,
end_time: currentEvent.value.end_time || '',
load_in_time: currentEvent.value.load_in_time || '',
soundcheck_time: currentEvent.value.soundcheck_time || '',
location_id: currentEvent.value.location?.id || '',
customer_id: currentEvent.value.customer?.id || '',
setlist_id: currentEvent.value.setlist?.id || '',
fee: currentEvent.value.fee || undefined,
currency: currentEvent.value.currency,
status: currentEvent.value.status,
visibility: currentEvent.value.visibility,
rsvp_deadline: currentEvent.value.rsvp_deadline || '',
notes: currentEvent.value.notes || '',
internal_notes: currentEvent.value.internal_notes || '',
is_public_setlist: currentEvent.value.is_public_setlist,
}
if (!eventId.value) {
return
}
await fetchEvent(eventId.value)
if (currentEvent.value) {
const e = currentEvent.value
formData.value = {
name: e.name,
slug: e.slug,
start_date: e.start_date,
end_date: e.end_date,
timezone: e.timezone,
status: e.status,
}
}
}, { immediate: true })
function flattenValidationErrors(raw: Record<string, string[] | string>): Record<string, string> {
const out: Record<string, string> = {}
for (const [key, val] of Object.entries(raw)) {
out[key] = Array.isArray(val) ? val[0]! : val
}
return out
}
async function handleSubmit() {
errors.value = {}
try {
await updateEvent(eventId.value, formData.value)
router.push(`/events/${eventId.value}`)
} catch (err: any) {
if (err.response?.data?.errors) {
errors.value = err.response.data.errors
} else {
errors.value._general = err.message || 'Failed to update event'
try {
await updateEvent(eventId.value, formData.value)
await router.push(`/events/${eventId.value}`)
}
catch (err: unknown) {
const ax = err as { response?: { data?: { errors?: Record<string, string[] | string>; message?: string } } }
if (ax.response?.data?.errors) {
errors.value = flattenValidationErrors(ax.response.data.errors)
}
else {
errors.value._general = ax.response?.data?.message ?? (err instanceof Error ? err.message : 'Failed to update event')
}
}
}
@@ -98,20 +93,19 @@ async function handleSubmit() {
<VRow>
<VCol cols="12">
<AppTextField
v-model="formData.title"
label="Title"
placeholder="Event Title"
:error-messages="errors.title"
v-model="formData.name"
label="Name"
:error-messages="errors.name"
required
/>
</VCol>
<VCol cols="12">
<AppTextarea
v-model="formData.description"
label="Description"
placeholder="Event Description"
rows="3"
<AppTextField
v-model="formData.slug"
label="Slug"
:error-messages="errors.slug"
required
/>
</VCol>
@@ -120,11 +114,10 @@ async function handleSubmit() {
md="6"
>
<AppTextField
v-model="formData.event_date"
label="Event Date"
v-model="formData.start_date"
label="Start date"
type="date"
:error-messages="errors.event_date"
required
:error-messages="errors.start_date"
/>
</VCol>
@@ -133,126 +126,27 @@ async function handleSubmit() {
md="6"
>
<AppTextField
v-model="formData.start_time"
label="Start Time"
type="time"
:error-messages="errors.start_time"
required
v-model="formData.end_date"
label="End date"
type="date"
:error-messages="errors.end_date"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<VCol cols="12">
<AppTextField
v-model="formData.end_time"
label="End Time"
type="time"
v-model="formData.timezone"
label="Timezone"
:error-messages="errors.timezone"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="formData.load_in_time"
label="Load-in Time"
type="time"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="formData.soundcheck_time"
label="Soundcheck Time"
type="time"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="formData.rsvp_deadline"
label="RSVP Deadline"
type="datetime-local"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model.number="formData.fee"
label="Fee"
type="number"
step="0.01"
prefix="€"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppSelect
v-model="formData.currency"
label="Currency"
:items="[{ title: 'EUR', value: 'EUR' }, { title: 'USD', value: 'USD' }]"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<VCol cols="12">
<AppSelect
v-model="formData.status"
label="Status"
:items="statusOptions"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppSelect
v-model="formData.visibility"
label="Visibility"
:items="visibilityOptions"
/>
</VCol>
<VCol cols="12">
<AppTextarea
v-model="formData.notes"
label="Notes"
placeholder="Public notes"
rows="3"
/>
</VCol>
<VCol cols="12">
<AppTextarea
v-model="formData.internal_notes"
label="Internal Notes"
placeholder="Private notes (only visible to admins)"
rows="3"
/>
</VCol>
<VCol cols="12">
<VCheckbox
v-model="formData.is_public_setlist"
label="Public Setlist"
:error-messages="errors.status"
/>
</VCol>
@@ -271,11 +165,10 @@ async function handleSubmit() {
color="primary"
:loading="isLoading"
>
Update Event
Save
</VBtn>
<VBtn
variant="tonal"
color="secondary"
:to="`/events/${eventId}`"
>
Cancel
@@ -287,7 +180,7 @@ async function handleSubmit() {
</VCardText>
</VCard>
<VCard v-else>
<VCard v-else-if="isLoading">
<VCardText class="text-center py-12">
<VProgressCircular
indeterminate
@@ -297,4 +190,3 @@ async function handleSubmit() {
</VCard>
</div>
</template>

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { useEvents } from '@/composables/useEvents'
import { useRoute } from 'vue-router'
import InviteMembersDialog from '@/components/events/InviteMembersDialog.vue'
definePage({
meta: {
@@ -13,9 +12,7 @@ const route = useRoute()
const { currentEvent, fetchEvent, isLoading } = useEvents()
const eventId = computed(() => route.params.id as string)
const isInviteDialogOpen = ref(false)
// Load event
watch(() => eventId.value, async () => {
if (eventId.value) {
await fetchEvent(eventId.value)
@@ -25,29 +22,25 @@ watch(() => eventId.value, async () => {
function getStatusColor(status: string): string {
const colors: Record<string, string> = {
draft: 'secondary',
pending: 'warning',
confirmed: 'success',
completed: 'info',
cancelled: 'error',
published: 'info',
registration_open: 'success',
buildup: 'warning',
showday: 'primary',
teardown: 'warning',
closed: 'secondary',
}
return colors[status] || 'secondary'
}
function getRsvpColor(status: string): string {
const colors: Record<string, string> = {
pending: 'warning',
available: 'success',
unavailable: 'error',
tentative: 'info',
}
return colors[status] || 'secondary'
function formatStatusLabel(status: string): string {
return status.replaceAll('_', ' ')
}
</script>
<template>
<div>
<VCard v-if="currentEvent">
<VCardTitle class="d-flex justify-space-between align-center">
<VCardTitle class="d-flex justify-space-between align-center flex-wrap gap-4">
<div>
<div class="d-flex align-center gap-2 mb-2">
<RouterLink
@@ -56,31 +49,22 @@ function getRsvpColor(status: string): string {
>
<VIcon icon="tabler-arrow-left" />
</RouterLink>
<span class="text-h5">{{ currentEvent.title }}</span>
<span class="text-h5">{{ currentEvent.name }}</span>
</div>
<VChip
:color="getStatusColor(currentEvent.status)"
size="small"
class="mt-2"
>
{{ currentEvent.status_label }}
{{ formatStatusLabel(currentEvent.status) }}
</VChip>
</div>
<div class="d-flex gap-2">
<VBtn
:to="`/events/${eventId}/edit`"
prepend-icon="tabler-pencil"
>
Edit
</VBtn>
<VBtn
color="primary"
prepend-icon="tabler-user-plus"
@click="isInviteDialogOpen = true"
>
Invite Members
</VBtn>
</div>
<VBtn
:to="`/events/${eventId}/edit`"
prepend-icon="tabler-pencil"
>
Edit
</VBtn>
</VCardTitle>
<VDivider />
@@ -93,70 +77,37 @@ function getRsvpColor(status: string): string {
>
<div class="mb-4">
<div class="text-body-2 text-medium-emphasis mb-1">
Event Date
Slug
</div>
<div class="text-body-1">
{{ new Date(currentEvent.event_date).toLocaleDateString() }}
{{ currentEvent.slug }}
</div>
</div>
<div class="mb-4">
<div class="text-body-2 text-medium-emphasis mb-1">
Start Time
Start date
</div>
<div class="text-body-1">
{{ currentEvent.start_time }}
{{ new Date(currentEvent.start_date).toLocaleDateString() }}
</div>
</div>
<div
v-if="currentEvent.end_time"
class="mb-4"
>
<div class="mb-4">
<div class="text-body-2 text-medium-emphasis mb-1">
End Time
End date
</div>
<div class="text-body-1">
{{ currentEvent.end_time }}
{{ new Date(currentEvent.end_date).toLocaleDateString() }}
</div>
</div>
<div
v-if="currentEvent.location"
class="mb-4"
>
<div class="mb-4">
<div class="text-body-2 text-medium-emphasis mb-1">
Location
Timezone
</div>
<div class="text-body-1">
{{ currentEvent.location.name }}<br>
<span class="text-medium-emphasis">
{{ currentEvent.location.address }}, {{ currentEvent.location.city }}
</span>
</div>
</div>
<div
v-if="currentEvent.customer"
class="mb-4"
>
<div class="text-body-2 text-medium-emphasis mb-1">
Customer
</div>
<div class="text-body-1">
{{ currentEvent.customer.name }}
</div>
</div>
<div
v-if="currentEvent.fee"
class="mb-4"
>
<div class="text-body-2 text-medium-emphasis mb-1">
Fee
</div>
<div class="text-body-1">
{{ currentEvent.currency }} {{ currentEvent.fee }}
{{ currentEvent.timezone }}
</div>
</div>
</VCol>
@@ -166,94 +117,19 @@ function getRsvpColor(status: string): string {
md="6"
>
<div
v-if="currentEvent.description"
v-if="currentEvent.organisation"
class="mb-4"
>
<div class="text-body-2 text-medium-emphasis mb-1">
Description
Organisation
</div>
<div class="text-body-1">
{{ currentEvent.description }}
</div>
</div>
<div
v-if="currentEvent.notes"
class="mb-4"
>
<div class="text-body-2 text-medium-emphasis mb-1">
Notes
</div>
<div class="text-body-1">
{{ currentEvent.notes }}
</div>
</div>
<div
v-if="currentEvent.internal_notes"
class="mb-4"
>
<div class="text-body-2 text-medium-emphasis mb-1">
Internal Notes
</div>
<div class="text-body-1">
{{ currentEvent.internal_notes }}
{{ currentEvent.organisation.name }}
</div>
</div>
</VCol>
</VRow>
</VCardText>
<VDivider />
<!-- Invitations Section -->
<VCardText>
<div class="d-flex justify-space-between align-center mb-4">
<VCardTitle class="pa-0">
Invitations ({{ currentEvent.invitations?.length || 0 }})
</VCardTitle>
</div>
<VTable v-if="currentEvent.invitations && currentEvent.invitations.length > 0">
<thead>
<tr>
<th>Member</th>
<th>Email</th>
<th>RSVP Status</th>
<th>Response Date</th>
<th>Note</th>
</tr>
</thead>
<tbody>
<tr
v-for="invitation in currentEvent.invitations"
:key="invitation.id"
>
<td>{{ invitation.user?.name || '-' }}</td>
<td>{{ invitation.user?.email || '-' }}</td>
<td>
<VChip
:color="getRsvpColor(invitation.rsvp_status)"
size="small"
>
{{ invitation.rsvp_status }}
</VChip>
</td>
<td>
{{ invitation.rsvp_responded_at ? new Date(invitation.rsvp_responded_at).toLocaleString() : '-' }}
</td>
<td>{{ invitation.rsvp_note || '-' }}</td>
</tr>
</tbody>
</VTable>
<VAlert
v-else
type="info"
>
No members invited yet. Click "Invite Members" to send invitations.
</VAlert>
</VCardText>
</VCard>
<VCard v-else-if="isLoading">
@@ -264,13 +140,6 @@ function getRsvpColor(status: string): string {
/>
</VCardText>
</VCard>
<!-- Invite Members Dialog -->
<InviteMembersDialog
v-model:is-dialog-open="isInviteDialogOpen"
:event-id="eventId"
@invited="fetchEvent(eventId)"
/>
</div>
</template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useEvents } from '@/composables/useEvents'
import { useRouter } from 'vue-router'
import type { CreateEventData } from '@/types/events'
import type { CreateEventData, EventCrewEventStatus } from '@/types/events'
definePage({
meta: {
@@ -10,115 +10,68 @@ definePage({
})
const router = useRouter()
const { createEvent, isLoading } = useEvents()
const { createEvent, isLoading, organisationId } = useEvents()
const formData = ref<CreateEventData>({
title: '',
description: '',
event_date: '',
start_time: '',
end_time: '',
load_in_time: '',
soundcheck_time: '',
location_id: '',
customer_id: '',
setlist_id: '',
fee: undefined,
currency: 'EUR',
name: '',
slug: '',
start_date: '',
end_date: '',
timezone: 'Europe/Amsterdam',
status: 'draft',
visibility: 'members',
rsvp_deadline: '',
notes: '',
internal_notes: '',
is_public_setlist: false,
})
// Separate date/time values for the pickers (format: "YYYY-MM-DD HH:mm")
const startDateTime = ref('')
const endDateTime = ref('')
// Watch startDateTime and sync to formData + auto-populate endDateTime
watch(startDateTime, (newValue) => {
if (newValue) {
// Parse "YYYY-MM-DD HH:mm" format
const parts = newValue.split(' ')
if (parts.length === 2) {
formData.value.event_date = parts[0] // YYYY-MM-DD
formData.value.start_time = parts[1] // HH:mm
}
// Auto-populate end date/time if empty
if (!endDateTime.value) {
endDateTime.value = newValue
}
// If end date is before start date, update it to match start
else if (new Date(endDateTime.value) < new Date(newValue)) {
endDateTime.value = newValue
}
}
})
// Watch endDateTime and sync to formData
watch(endDateTime, (newValue) => {
if (newValue) {
const parts = newValue.split(' ')
if (parts.length === 2) {
formData.value.end_time = parts[1] // HH:mm
}
}
})
// Computed minDate for end date picker (can't be before start date)
const endDateMinDate = computed(() => {
if (startDateTime.value) {
return startDateTime.value.split(' ')[0] // Return just the date part
}
return undefined
})
const statusOptions = [
const statusOptions: { title: string; value: EventCrewEventStatus }[] = [
{ title: 'Draft', value: 'draft' },
{ title: 'Pending', value: 'pending' },
{ title: 'Confirmed', value: 'confirmed' },
]
const visibilityOptions = [
{ title: 'Private', value: 'private' },
{ title: 'Members', value: 'members' },
{ title: 'Public', value: 'public' },
{ title: 'Published', value: 'published' },
{ title: 'Registration open', value: 'registration_open' },
{ title: 'Build-up', value: 'buildup' },
{ title: 'Show day', value: 'showday' },
{ title: 'Teardown', value: 'teardown' },
{ title: 'Closed', value: 'closed' },
]
const errors = ref<Record<string, string>>({})
function flattenValidationErrors(raw: Record<string, string[] | string>): Record<string, string> {
const out: Record<string, string> = {}
for (const [key, val] of Object.entries(raw)) {
out[key] = Array.isArray(val) ? val[0]! : val
}
return out
}
async function handleSubmit() {
errors.value = {}
// Basic validation
if (!formData.value.title) {
errors.value.title = 'Title is required'
if (!formData.value.name) {
errors.value.name = 'Name is required'
return
}
if (!startDateTime.value) {
errors.value.event_date = 'Start date and time is required'
if (!formData.value.slug) {
errors.value.slug = 'Slug is required'
return
}
if (!formData.value.event_date) {
errors.value.event_date = 'Event date is required'
if (!formData.value.start_date) {
errors.value.start_date = 'Start date is required'
return
}
if (!formData.value.start_time) {
errors.value.start_time = 'Start time is required'
if (!formData.value.end_date) {
errors.value.end_date = 'End date is required'
return
}
try {
const event = await createEvent(formData.value)
router.push(`/events/${event.id}`)
} catch (err: any) {
if (err.response?.data?.errors) {
errors.value = err.response.data.errors
} else {
errors.value._general = err.message || 'Failed to create event'
await router.push(`/events/${event.id}`)
}
catch (err: unknown) {
const ax = err as { response?: { data?: { errors?: Record<string, string[] | string>; message?: string } } }
if (ax.response?.data?.errors) {
errors.value = flattenValidationErrors(ax.response.data.errors)
}
else {
errors.value._general = ax.response?.data?.message ?? (err instanceof Error ? err.message : 'Failed to create event')
}
}
}
@@ -126,10 +79,21 @@ async function handleSubmit() {
<template>
<div>
<VAlert
v-if="!organisationId"
type="warning"
class="mb-4"
>
You need an organisation before creating events. Log in with a user that belongs to an organisation.
</VAlert>
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-arrow-left" />
<RouterLink to="/events" class="text-decoration-none">
<RouterLink
to="/events"
class="text-decoration-none"
>
Back to Events
</RouterLink>
</VCardTitle>
@@ -140,88 +104,93 @@ async function handleSubmit() {
<VForm @submit.prevent="handleSubmit">
<VRow>
<VCol cols="12">
<AppTextField v-model="formData.title" label="Title" placeholder="Event Title"
:error-messages="errors.title" required />
<AppTextField
v-model="formData.name"
label="Name"
placeholder="Summer Festival 2026"
:error-messages="errors.name"
required
/>
</VCol>
<VCol cols="12">
<AppTextarea v-model="formData.description" label="Description" placeholder="Event Description"
rows="3" />
<AppTextField
v-model="formData.slug"
label="Slug"
placeholder="summer-festival-2026"
hint="Lowercase letters, numbers, and hyphens only"
:error-messages="errors.slug"
required
/>
</VCol>
<VCol cols="12" md="6">
<AppDateTimePicker v-model="startDateTime" label="Start Date & Time"
placeholder="Select start date and time" :error-messages="errors.event_date || errors.start_time"
:config="{
enableTime: true,
time_24hr: true,
dateFormat: 'Y-m-d H:i',
minDate: 'today',
}" />
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="formData.start_date"
label="Start date"
type="date"
:error-messages="errors.start_date"
required
/>
</VCol>
<VCol cols="12" md="6">
<AppDateTimePicker v-model="endDateTime" label="End Date & Time" placeholder="Select end date and time"
:error-messages="errors.end_time" :config="{
enableTime: true,
time_24hr: true,
dateFormat: 'Y-m-d H:i',
minDate: endDateMinDate,
}" />
</VCol>
<VCol cols="12" md="6">
<AppTextField v-model="formData.load_in_time" label="Load-in Time" type="time" />
</VCol>
<VCol cols="12" md="6">
<AppTextField v-model="formData.soundcheck_time" label="Soundcheck Time" type="time" />
</VCol>
<VCol cols="12" md="6">
<AppTextField v-model="formData.rsvp_deadline" label="RSVP Deadline" type="datetime-local" />
</VCol>
<VCol cols="12" md="6">
<AppTextField v-model.number="formData.fee" label="Fee" type="number" step="0.01" prefix="€" />
</VCol>
<VCol cols="12" md="6">
<AppSelect v-model="formData.currency" label="Currency"
:items="[{ title: 'EUR', value: 'EUR' }, { title: 'USD', value: 'USD' }]" />
</VCol>
<VCol cols="12" md="6">
<AppSelect v-model="formData.status" label="Status" :items="statusOptions" />
</VCol>
<VCol cols="12" md="6">
<AppSelect v-model="formData.visibility" label="Visibility" :items="visibilityOptions" />
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="formData.end_date"
label="End date"
type="date"
:error-messages="errors.end_date"
required
/>
</VCol>
<VCol cols="12">
<AppTextarea v-model="formData.notes" label="Notes" placeholder="Public notes" rows="3" />
<AppTextField
v-model="formData.timezone"
label="Timezone"
placeholder="Europe/Amsterdam"
:error-messages="errors.timezone"
/>
</VCol>
<VCol cols="12">
<AppTextarea v-model="formData.internal_notes" label="Internal Notes"
placeholder="Private notes (only visible to admins)" rows="3" />
<AppSelect
v-model="formData.status"
label="Status"
:items="statusOptions"
:error-messages="errors.status"
/>
</VCol>
<VCol cols="12">
<VCheckbox v-model="formData.is_public_setlist" label="Public Setlist" />
</VCol>
<VCol cols="12">
<VAlert v-if="errors._general" type="error" class="mb-4">
<VAlert
v-if="errors._general"
type="error"
class="mb-4"
>
{{ errors._general }}
</VAlert>
<div class="d-flex gap-4">
<VBtn type="submit" color="primary" :loading="isLoading">
<VBtn
type="submit"
color="primary"
:loading="isLoading"
:disabled="!organisationId"
>
Create Event
</VBtn>
<VBtn variant="tonal" color="secondary" to="/events">
<VBtn
variant="tonal"
color="secondary"
to="/events"
>
Cancel
</VBtn>
</div>

View File

@@ -1,94 +1,82 @@
<script setup lang="ts">
import { useEvents } from '@/composables/useEvents'
import type { Event } from '@/types/events'
definePage({
meta: {
navActiveLink: 'events',
},
})
const { events, pagination, isLoading, error, fetchEvents, deleteEvent } = useEvents()
const { events, pagination, organisationId, isLoading, error, fetchEvents } = useEvents()
const searchQuery = ref('')
const selectedStatus = ref<string | undefined>()
const selectedRows = ref([])
// Data table options
const itemsPerPage = ref(10)
const page = ref(1)
const statusOptions = [
{ title: 'Draft', value: 'draft' },
{ title: 'Pending', value: 'pending' },
{ title: 'Confirmed', value: 'confirmed' },
{ title: 'Completed', value: 'completed' },
{ title: 'Cancelled', value: 'cancelled' },
]
const headers = [
{ title: 'Title', key: 'title' },
{ title: 'Date', key: 'event_date' },
{ title: 'Time', key: 'start_time' },
{ title: 'Location', key: 'location' },
{ title: 'Name', key: 'name' },
{ title: 'Start', key: 'start_date' },
{ title: 'End', key: 'end_date' },
{ title: 'Timezone', key: 'timezone' },
{ title: 'Status', key: 'status' },
{ title: 'Actions', key: 'actions', sortable: false },
]
const isDeleteDialogOpen = ref(false)
const eventToDelete = ref<Event | null>(null)
// Fetch events on mount and when filters change
watch([page, itemsPerPage, selectedStatus, searchQuery], async () => {
watch([page, itemsPerPage], async () => {
if (!organisationId.value) {
return
}
try {
await fetchEvents({
page: page.value,
per_page: itemsPerPage.value,
status: selectedStatus.value,
})
} catch (err) {
// Error is already handled in the composable
}
catch (err) {
console.error('Failed to fetch events:', err)
}
}, { immediate: true })
function handleDelete(event: Event) {
eventToDelete.value = event
isDeleteDialogOpen.value = true
}
async function confirmDelete() {
if (eventToDelete.value) {
watch(organisationId, async (id) => {
if (id) {
try {
await deleteEvent(eventToDelete.value.id)
isDeleteDialogOpen.value = false
eventToDelete.value = null
// Refresh list
await fetchEvents({
page: page.value,
per_page: itemsPerPage.value,
status: selectedStatus.value,
})
} catch (err) {
console.error('Failed to delete event:', err)
}
catch (err) {
console.error('Failed to fetch events:', err)
}
}
}
})
function getStatusColor(status: string): string {
const colors: Record<string, string> = {
draft: 'secondary',
pending: 'warning',
confirmed: 'success',
completed: 'info',
cancelled: 'error',
published: 'info',
registration_open: 'success',
buildup: 'warning',
showday: 'primary',
teardown: 'warning',
closed: 'secondary',
}
return colors[status] || 'secondary'
}
function formatStatusLabel(status: string): string {
return status.replaceAll('_', ' ')
}
</script>
<template>
<div>
<VAlert
v-if="!organisationId"
type="warning"
class="mb-4"
>
You need an organisation to manage events. Create an organisation in the API or attach your user to one, then log in again.
</VAlert>
<VCard>
<VCardText class="d-flex justify-space-between align-center flex-wrap gap-4">
<div class="d-flex gap-4 align-center flex-wrap">
@@ -104,38 +92,26 @@ function getStatusColor(status: string): string {
color="primary"
prepend-icon="tabler-plus"
to="/events/create"
:disabled="!organisationId"
>
Create Event
</VBtn>
</div>
<div class="d-flex align-center flex-wrap gap-4">
<AppTextField
v-model="searchQuery"
placeholder="Search Events"
style="inline-size: 200px;"
/>
<AppSelect
v-model="selectedStatus"
placeholder="Filter by Status"
clearable
:items="statusOptions"
style="inline-size: 180px;"
/>
</div>
</VCardText>
<VDivider />
<!-- Loading State -->
<div v-if="isLoading" class="d-flex justify-center align-center py-12">
<div
v-if="isLoading"
class="d-flex justify-center align-center py-12"
>
<VProgressCircular
indeterminate
color="primary"
/>
</div>
<!-- Error State -->
<VAlert
v-else-if="error"
type="error"
@@ -144,36 +120,30 @@ function getStatusColor(status: string): string {
{{ error.message }}
</VAlert>
<!-- Events Table -->
<VDataTable
v-else
v-model:items-per-page="itemsPerPage"
v-model:model-value="selectedRows"
v-model:page="page"
:headers="headers"
:items="events"
:items-length="pagination?.total || 0"
:items-length="pagination?.total || events.length"
class="text-no-wrap"
>
<template #item.title="{ item }">
<template #item.name="{ item }">
<RouterLink
:to="`/events/${item.id}`"
class="text-high-emphasis text-decoration-none"
>
{{ item.title }}
{{ item.name }}
</RouterLink>
</template>
<template #item.event_date="{ item }">
{{ new Date(item.event_date).toLocaleDateString() }}
<template #item.start_date="{ item }">
{{ new Date(item.start_date).toLocaleDateString() }}
</template>
<template #item.start_time="{ item }">
{{ item.start_time }}
</template>
<template #item.location="{ item }">
{{ item.location?.name || '-' }}
<template #item.end_date="{ item }">
{{ new Date(item.end_date).toLocaleDateString() }}
</template>
<template #item.status="{ item }">
@@ -181,28 +151,17 @@ function getStatusColor(status: string): string {
:color="getStatusColor(item.status)"
size="small"
>
{{ item.status_label }}
{{ formatStatusLabel(item.status) }}
</VChip>
</template>
<template #item.actions="{ item }">
<IconBtn
:to="`/events/${item.id}`"
>
<IconBtn :to="`/events/${item.id}`">
<VIcon icon="tabler-eye" />
</IconBtn>
<IconBtn
:to="`/events/${item.id}/edit`"
>
<IconBtn :to="`/events/${item.id}/edit`">
<VIcon icon="tabler-pencil" />
</IconBtn>
<IconBtn
@click="handleDelete(item)"
>
<VIcon icon="tabler-trash" />
</IconBtn>
</template>
<template #bottom>
@@ -216,34 +175,5 @@ function getStatusColor(status: string): string {
</template>
</VDataTable>
</VCard>
<!-- Delete Confirmation Dialog -->
<VDialog
v-model="isDeleteDialogOpen"
max-width="400"
>
<VCard>
<VCardTitle>Delete Event?</VCardTitle>
<VCardText>
Are you sure you want to delete "{{ eventToDelete?.title }}"? This action cannot be undone.
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isDeleteDialogOpen = false"
>
Cancel
</VBtn>
<VBtn
color="error"
@click="confirmDelete"
>
Delete
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -11,6 +11,18 @@ import authV2MaskDark from '@images/pages/misc-mask-dark.png'
import authV2MaskLight from '@images/pages/misc-mask-light.png'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import { getUserAbilityRules } from '@/utils/auth-ability'
import type { Rule } from '@/plugins/casl/ability'
import type { AuthUserCookie } from '@/composables/useOrganisationContext'
interface LoginApiPayload {
success: boolean
data: {
user: AuthUserCookie & Record<string, unknown>
token: string
}
message?: string
}
const authThemeImg = useGenerateImageVariant(authV2LoginIllustrationLight, authV2LoginIllustrationDark, authV2LoginIllustrationBorderedLight, authV2LoginIllustrationBorderedDark, true)
@@ -46,7 +58,7 @@ const rememberMe = ref(false)
const login = async () => {
try {
const res = await $api('/auth/login', {
const res = await $api<LoginApiPayload>('/auth/login', {
method: 'POST',
body: {
email: credentials.value.email,
@@ -71,14 +83,14 @@ const login = async () => {
const userData = data.user
const accessToken = data.token
// Set ability rules based on user role
const userAbilityRules = getUserAbilityRules(userData.role)
const roles = Array.isArray(userData.roles) ? userData.roles : []
const userAbilityRules = getUserAbilityRules(roles)
useCookie('userAbilityRules').value = userAbilityRules
useCookie<Rule[]>('userAbilityRules').value = userAbilityRules
ability.update(userAbilityRules)
useCookie('userData').value = userData
useCookie('accessToken').value = accessToken
useCookie<AuthUserCookie>('userData').value = userData
useCookie<string>('accessToken').value = accessToken
// Redirect to `to` query if exist or redirect to index route
await nextTick()
@@ -89,42 +101,6 @@ const login = async () => {
}
}
// Generate ability rules based on user role
function getUserAbilityRules(role: string | null) {
// Admin can do everything
if (role === 'admin') {
return [{ action: 'manage', subject: 'all' }]
}
// Booking agent can manage events and customers
if (role === 'booking_agent') {
return [
{ action: 'read', subject: 'all' },
{ action: 'manage', subject: 'Event' },
{ action: 'manage', subject: 'Customer' },
{ action: 'manage', subject: 'Location' },
{ action: 'manage', subject: 'BookingRequest' },
]
}
// Music manager can manage music and setlists
if (role === 'music_manager') {
return [
{ action: 'read', subject: 'all' },
{ action: 'manage', subject: 'MusicNumber' },
{ action: 'manage', subject: 'Setlist' },
]
}
// Default member permissions
return [
{ action: 'read', subject: 'Event' },
{ action: 'read', subject: 'MusicNumber' },
{ action: 'read', subject: 'Setlist' },
{ action: 'manage', subject: 'User', conditions: { id: '{{ user.id }}' } },
]
}
const onSubmit = () => {
refVForm.value?.validate()
.then(({ valid: isValid }) => {

View File

@@ -4,6 +4,18 @@ import { VForm } from 'vuetify/components/VForm'
import AuthProvider from '@/views/pages/authentication/AuthProvider.vue'
import { VNodeRenderer } from '@layouts/components/VNodeRenderer'
import { themeConfig } from '@themeConfig'
import { getUserAbilityRules } from '@/utils/auth-ability'
import type { Rule } from '@/plugins/casl/ability'
import type { AuthUserCookie } from '@/composables/useOrganisationContext'
interface RegisterApiPayload {
success: boolean
data: {
user: AuthUserCookie & Record<string, unknown>
token: string
}
message?: string
}
import authV2RegisterIllustrationBorderedDark from '@images/pages/auth-v2-register-illustration-bordered-dark.png'
import authV2RegisterIllustrationBorderedLight from '@images/pages/auth-v2-register-illustration-bordered-light.png'
@@ -50,7 +62,7 @@ const refVForm = ref<VForm>()
const register = async () => {
isLoading.value = true
try {
const res = await $api('/auth/register', {
const res = await $api<RegisterApiPayload>('/auth/register', {
method: 'POST',
body: {
name: form.value.name,
@@ -77,18 +89,13 @@ const register = async () => {
const userData = data.user
const accessToken = data.token
// Set ability rules based on user role
const userAbilityRules = [
{ action: 'read', subject: 'Event' },
{ action: 'read', subject: 'MusicNumber' },
{ action: 'read', subject: 'Setlist' },
{ action: 'manage', subject: 'User', conditions: { id: userData.id } },
]
const roles = Array.isArray(userData.roles) ? userData.roles : []
const userAbilityRules = getUserAbilityRules(roles)
useCookie('userAbilityRules').value = userAbilityRules
useCookie<Rule[]>('userAbilityRules').value = userAbilityRules
ability.update(userAbilityRules)
useCookie('userData').value = userData
useCookie('accessToken').value = accessToken
useCookie<AuthUserCookie>('userData').value = userData
useCookie<string>('accessToken').value = accessToken
await nextTick(() => {
router.replace('/')