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,176 +0,0 @@
<script setup lang="ts">
import { $api } from '@/utils/api'
import type { User, ApiResponse } from '@/types/events'
const props = defineProps<{
isDialogOpen: boolean
eventId: string
}>()
const emit = defineEmits<{
(e: 'update:isDialogOpen', val: boolean): void
(e: 'invited'): void
}>()
const selectedUserIds = ref<string[]>([])
const availableUsers = ref<User[]>([])
const isLoading = ref(false)
const isInviting = ref(false)
const searchQuery = ref('')
const isDialogOpenModel = computed({
get: () => props.isDialogOpen,
set: (val) => emit('update:isDialogOpen', val),
})
// Fetch available users (members)
async function fetchUsers() {
isLoading.value = true
try {
// TODO: Replace with actual users/members endpoint when available
// For now, this will fail gracefully and show an empty list
// Expected endpoint: GET /users or GET /members with filters for type=member
const response = await $api<ApiResponse<User[]>>('/users', {
method: 'GET',
query: {
type: 'member',
status: 'active',
},
})
availableUsers.value = response.data
} catch (err) {
console.error('Failed to fetch users. Make sure a /users endpoint exists:', err)
// Set empty array on error so UI doesn't break
availableUsers.value = []
} finally {
isLoading.value = false
}
}
// Watch dialog open to fetch users
watch(() => props.isDialogOpen, (isOpen) => {
if (isOpen) {
fetchUsers()
selectedUserIds.value = []
}
})
async function handleInvite() {
if (selectedUserIds.value.length === 0) {
return
}
isInviting.value = true
try {
await $api(`/events/${props.eventId}/invite`, {
method: 'POST',
body: {
user_ids: selectedUserIds.value,
},
})
emit('invited')
isDialogOpenModel.value = false
} catch (err) {
console.error('Failed to invite members:', err)
} finally {
isInviting.value = false
}
}
const filteredUsers = computed(() => {
if (!searchQuery.value) {
return availableUsers.value
}
const query = searchQuery.value.toLowerCase()
return availableUsers.value.filter(
user =>
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
)
})
</script>
<template>
<VDialog
v-model="isDialogOpenModel"
max-width="600"
>
<VCard>
<VCardTitle>Invite Members to Event</VCardTitle>
<VDivider />
<VCardText>
<AppTextField
v-model="searchQuery"
placeholder="Search members..."
prepend-inner-icon="tabler-search"
class="mb-4"
/>
<div
v-if="isLoading"
class="text-center py-8"
>
<VProgressCircular
indeterminate
color="primary"
/>
</div>
<div
v-else
class="member-list"
style="max-height: 400px; overflow-y: auto;"
>
<VCheckbox
v-for="user in filteredUsers"
:key="user.id"
v-model="selectedUserIds"
:value="user.id"
class="mb-2"
>
<template #label>
<div>
<div class="text-body-1">
{{ user.name }}
</div>
<div class="text-body-2 text-medium-emphasis">
{{ user.email }}
</div>
</div>
</template>
</VCheckbox>
<VAlert
v-if="filteredUsers.length === 0"
type="info"
>
No members found
</VAlert>
</div>
</VCardText>
<VDivider />
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isDialogOpenModel = false"
>
Cancel
</VBtn>
<VBtn
color="primary"
:disabled="selectedUserIds.length === 0"
:loading="isInviting"
@click="handleInvite"
>
Invite {{ selectedUserIds.length }} Member(s)
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -1,41 +0,0 @@
import { createFetch } from '@vueuse/core'
import { destr } from 'destr'
export const useApi = createFetch({
baseUrl: import.meta.env.VITE_API_BASE_URL || '/api',
fetchOptions: {
headers: {
Accept: 'application/json',
},
},
options: {
refetch: true,
async beforeFetch({ options }) {
const accessToken = useCookie('accessToken').value
if (accessToken) {
options.headers = {
...options.headers,
Authorization: `Bearer ${accessToken}`,
}
}
return { options }
},
afterFetch(ctx) {
const { data, response } = ctx
// Parse data if it's JSON
let parsedData = null
try {
parsedData = destr(data)
}
catch (error) {
console.error(error)
}
return { data: parsedData, response }
},
},
})

View File

@@ -1,177 +1,137 @@
import { ref, computed } from "vue";
import { $api } from "@/utils/api";
import type {
Event,
CreateEventData,
UpdateEventData,
InviteToEventData,
ApiResponse,
Pagination,
} from "@/types/events";
import { computed, ref } from 'vue'
import { apiClient } from '@/lib/api-client'
import { useCurrentOrganisationId } from '@/composables/useOrganisationContext'
import type { ApiResponse, CreateEventData, Event, Pagination, UpdateEventData } from '@/types/events'
/** Laravel paginated JSON resource response (no `success` wrapper). */
interface LaravelPaginatedEventsBody {
data: Event[]
meta: {
current_page: number
per_page: number
total: number
last_page: number
from: number | null
to: number | null
}
}
function requireOrganisationId(organisationId: string | null): string {
if (!organisationId) {
throw new Error('No organisation in session. Log in again or select an organisation.')
}
return organisationId
}
export function useEvents() {
const events = ref<Event[]>([]);
const currentEvent = ref<Event | null>(null);
const pagination = ref<Pagination | null>(null);
const isLoading = ref(false);
const error = ref<Error | null>(null);
const { organisationId } = useCurrentOrganisationId()
const events = ref<Event[]>([])
const currentEvent = ref<Event | null>(null)
const pagination = ref<Pagination | null>(null)
const isLoading = ref(false)
const error = ref<Error | null>(null)
function eventsPath(): string {
const id = requireOrganisationId(organisationId.value)
return `/organisations/${id}/events`
}
// Fetch all events
async function fetchEvents(params?: {
page?: number;
per_page?: number;
status?: string;
page?: number
per_page?: number
}) {
isLoading.value = true;
error.value = null;
isLoading.value = true
error.value = null
try {
const response = await $api<ApiResponse<Event[]>>("/events", {
method: "GET",
query: params,
});
events.value = response.data;
pagination.value = response.meta?.pagination || null;
} catch (err) {
error.value =
err instanceof Error ? err : new Error("Failed to fetch events");
throw error.value;
} finally {
isLoading.value = false;
const { data } = await apiClient.get<LaravelPaginatedEventsBody>(eventsPath(), { params })
events.value = data.data
pagination.value = {
current_page: data.meta.current_page,
per_page: data.meta.per_page,
total: data.meta.total,
last_page: data.meta.last_page,
from: data.meta.from,
to: data.meta.to,
}
}
catch (err) {
error.value = err instanceof Error ? err : new Error('Failed to fetch events')
throw error.value
}
finally {
isLoading.value = false
}
}
// Fetch single event
async function fetchEvent(id: string) {
isLoading.value = true;
error.value = null;
isLoading.value = true
error.value = null
try {
const response = await $api<ApiResponse<Event>>(`/events/${id}`, {
method: "GET",
});
currentEvent.value = response.data;
return response.data;
} catch (err) {
error.value =
err instanceof Error ? err : new Error("Failed to fetch event");
throw error.value;
} finally {
isLoading.value = false;
const { data } = await apiClient.get<ApiResponse<Event>>(`${eventsPath()}/${id}`)
currentEvent.value = data.data
return data.data
}
catch (err) {
error.value = err instanceof Error ? err : new Error('Failed to fetch event')
throw error.value
}
finally {
isLoading.value = false
}
}
// Create event
async function createEvent(eventData: CreateEventData) {
isLoading.value = true;
error.value = null;
isLoading.value = true
error.value = null
try {
const response = await $api<ApiResponse<Event>>("/events", {
method: "POST",
body: eventData,
});
events.value.unshift(response.data);
return response.data;
} catch (err) {
error.value =
err instanceof Error ? err : new Error("Failed to create event");
throw error.value;
} finally {
isLoading.value = false;
const { data } = await apiClient.post<ApiResponse<Event>>(eventsPath(), eventData)
events.value.unshift(data.data)
return data.data
}
catch (err) {
error.value = err instanceof Error ? err : new Error('Failed to create event')
throw error.value
}
finally {
isLoading.value = false
}
}
// Update event
async function updateEvent(id: string, eventData: UpdateEventData) {
isLoading.value = true;
error.value = null;
isLoading.value = true
error.value = null
try {
const response = await $api<ApiResponse<Event>>(`/events/${id}`, {
method: "PUT",
body: eventData,
});
const index = events.value.findIndex((e) => e.id === id);
if (index !== -1) {
events.value[index] = response.data;
}
if (currentEvent.value?.id === id) {
currentEvent.value = response.data;
}
return response.data;
} catch (err) {
error.value =
err instanceof Error ? err : new Error("Failed to update event");
throw error.value;
} finally {
isLoading.value = false;
const { data } = await apiClient.put<ApiResponse<Event>>(`${eventsPath()}/${id}`, eventData)
const index = events.value.findIndex(e => e.id === id)
if (index !== -1)
events.value[index] = data.data
if (currentEvent.value?.id === id)
currentEvent.value = data.data
return data.data
}
}
// Delete event
async function deleteEvent(id: string) {
isLoading.value = true;
error.value = null;
try {
await $api(`/events/${id}`, {
method: "DELETE",
});
events.value = events.value.filter((e) => e.id !== id);
if (currentEvent.value?.id === id) {
currentEvent.value = null;
}
} catch (err) {
error.value =
err instanceof Error ? err : new Error("Failed to delete event");
throw error.value;
} finally {
isLoading.value = false;
catch (err) {
error.value = err instanceof Error ? err : new Error('Failed to update event')
throw error.value
}
}
// Invite members to event
async function inviteToEvent(eventId: string, inviteData: InviteToEventData) {
isLoading.value = true;
error.value = null;
try {
const response = await $api<ApiResponse<Event["invitations"]>>(
`/events/${eventId}/invite`,
{
method: "POST",
body: inviteData,
}
);
// Refresh event to get updated invitations
if (currentEvent.value?.id === eventId) {
await fetchEvent(eventId);
}
return response.data;
} catch (err) {
error.value =
err instanceof Error ? err : new Error("Failed to invite members");
throw error.value;
} finally {
isLoading.value = false;
finally {
isLoading.value = false
}
}
return {
// State
organisationId: computed(() => organisationId.value),
events: computed(() => events.value),
currentEvent: computed(() => currentEvent.value),
pagination: computed(() => pagination.value),
isLoading: computed(() => isLoading.value),
error: computed(() => error.value),
// Actions
fetchEvents,
fetchEvent,
createEvent,
updateEvent,
deleteEvent,
inviteToEvent,
};
}
}

View File

@@ -0,0 +1,28 @@
import { useCookie } from '@core/composable/useCookie'
import { computed } from 'vue'
export interface AuthOrganisationSummary {
id: string
name: string
slug: string
role: string
}
export interface AuthUserCookie {
id: string
name: string
email: string
roles?: string[]
organisations?: AuthOrganisationSummary[]
}
/**
* First organisation from the session cookie (set at login). Super-admins still need an organisation context for nested event routes.
*/
export function useCurrentOrganisationId() {
const userData = useCookie<AuthUserCookie | null>('userData')
const organisationId = computed(() => userData.value?.organisations?.[0]?.id ?? null)
return { organisationId }
}

View File

@@ -1,24 +1,36 @@
import axios from 'axios'
import { parse } from 'cookie-es'
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
/**
* Single axios instance for the real Laravel API (VITE_API_URL).
* Auth: Bearer token from cookie 'accessToken' (set by login).
* Use this for all event-crew API calls; useApi (composables/useApi) stays for Vuexy demo/mock endpoints.
*/
const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
Accept: 'application/json',
},
timeout: 30000,
})
// Request interceptor - add auth token
function getAccessToken(): string | null {
if (typeof document === 'undefined') return null
const cookies = parse(document.cookie)
const token = cookies.accessToken
return token ?? null
}
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('auth_token')
const token = getAccessToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// Log in development
if (import.meta.env.DEV) {
console.log(`🚀 ${config.method?.toUpperCase()} ${config.url}`, config.data)
}
@@ -28,7 +40,6 @@ apiClient.interceptors.request.use(
error => Promise.reject(error),
)
// Response interceptor - handle errors
apiClient.interceptors.response.use(
response => {
if (import.meta.env.DEV) {
@@ -39,13 +50,20 @@ apiClient.interceptors.response.use(
},
error => {
if (import.meta.env.DEV) {
console.error(`${error.response?.status} ${error.config?.url}`, error.response?.data)
console.error(
`${error.response?.status} ${error.config?.url}`,
error.response?.data,
)
}
// Handle 401 - redirect to login
if (error.response?.status === 401) {
localStorage.removeItem('auth_token')
window.location.href = '/login'
// Clear auth cookies (align with utils/api.ts / login flow)
document.cookie = 'accessToken=; path=/; max-age=0'
document.cookie = 'userData=; path=/; max-age=0'
document.cookie = 'userAbilityRules=; path=/; max-age=0'
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
}
return Promise.reject(error)
@@ -53,4 +71,3 @@ apiClient.interceptors.response.use(
)
export { apiClient }

View File

@@ -1,4 +1,5 @@
import { createApp } from 'vue'
import { VueQueryPlugin } from '@tanstack/vue-query'
import App from '@/App.vue'
import { registerPlugins } from '@core/utils/plugins'
@@ -17,6 +18,14 @@ app.config.errorHandler = (err, instance, info) => {
}
// Register plugins
app.use(VueQueryPlugin, {
queryClientConfig: {
defaultOptions: {
queries: { staleTime: 1000 * 60 * 5, retry: 1 },
},
},
})
try {
registerPlugins(app)
} catch (error) {

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('/')

View File

@@ -2,8 +2,7 @@ import { createMongoAbility } from '@casl/ability'
export type Actions = 'create' | 'read' | 'update' | 'delete' | 'manage'
// ex: Post, Comment, User, etc. We haven't used any of these in our demo though.
export type Subjects = 'Post' | 'Comment' | 'all'
export type Subjects = 'Post' | 'Comment' | 'User' | 'Event' | 'Organisation' | 'all'
export interface Rule { action: Actions; subject: Subjects }

View File

@@ -1,4 +1,4 @@
// API Response wrapper
// API Response wrapper (used by endpoints that use the ApiResponse trait)
export interface ApiResponse<T = unknown> {
success: boolean
data: T
@@ -13,145 +13,47 @@ export interface Pagination {
per_page: number
total: number
last_page: number
from: number
to: number
from: number | null
to: number | null
}
// Event types
export type EventStatus = 'draft' | 'pending' | 'confirmed' | 'completed' | 'cancelled'
export type EventVisibility = 'private' | 'members' | 'public'
export type RsvpStatus = 'pending' | 'available' | 'unavailable' | 'tentative'
/** EventCrew festival / multi-day event (API resource). */
export type EventCrewEventStatus =
| 'draft'
| 'published'
| 'registration_open'
| 'buildup'
| 'showday'
| 'teardown'
| 'closed'
export interface Location {
export interface OrganisationSummary {
id: string
name: string
address: string
city: string
postal_code: string | null
country: string
latitude: number | null
longitude: number | null
capacity: number | null
contact_name: string | null
contact_email: string | null
contact_phone: string | null
created_at: string
updated_at: string
}
export interface Customer {
id: string
name: string
company_name: string | null
type: 'individual' | 'company'
email: string | null
phone: string | null
address: string | null
city: string | null
postal_code: string | null
country: string
is_portal_enabled: boolean
created_at: string
updated_at: string
}
export interface Setlist {
id: string
name: string
description: string | null
total_duration_seconds: number | null
is_template: boolean
is_archived: boolean
created_at: string
updated_at: string
}
export interface User {
id: string
name: string
email: string
phone: string | null
bio: string | null
instruments: string[] | null
avatar_path: string | null
type: 'member' | 'customer'
role: 'admin' | 'booking_agent' | 'music_manager' | 'member' | null
status: 'active' | 'inactive'
created_at: string
updated_at: string
}
export interface EventInvitation {
id: string
event_id: string
user_id: string
rsvp_status: RsvpStatus
rsvp_note: string | null
rsvp_responded_at: string | null
invited_at: string
user?: User
slug: string
}
export interface Event {
id: string
title: string
description: string | null
event_date: string
start_time: string
end_time: string | null
load_in_time: string | null
soundcheck_time: string | null
fee: number | null
currency: string
status: EventStatus
status_label: string
status_color: string
visibility: EventVisibility
visibility_label: string
rsvp_deadline: string | null
notes: string | null
internal_notes: string | null
is_public_setlist: boolean
location: Location | null
customer: Customer | null
setlist: Setlist | null
invitations: EventInvitation[]
creator: User | null
organisation_id: string
name: string
slug: string
start_date: string
end_date: string
timezone: string
status: EventCrewEventStatus
created_at: string
updated_at: string
organisation?: OrganisationSummary
}
// Form types for creating/updating
export interface CreateEventData {
title: string
description?: string
location_id?: string
customer_id?: string
setlist_id?: string
event_date: string
start_time: string
end_time?: string
load_in_time?: string
soundcheck_time?: string
fee?: number
currency?: string
status?: EventStatus
visibility?: EventVisibility
rsvp_deadline?: string
notes?: string
internal_notes?: string
is_public_setlist?: boolean
name: string
slug: string
start_date: string
end_date: string
timezone?: string
status?: EventCrewEventStatus
}
export interface UpdateEventData extends Partial<CreateEventData> {}
export interface InviteToEventData {
user_ids: string[]
}
export interface RsvpEventData {
status: RsvpStatus
note?: string
}

View File

@@ -1,49 +1,40 @@
import { ofetch } from 'ofetch'
import type { AxiosRequestConfig } from 'axios'
import { apiClient } from '@/lib/api-client'
export const $api = ofetch.create({
baseURL: import.meta.env.VITE_API_URL || '/api',
async onRequest({ options }) {
options.headers = options.headers || new Headers()
type ApiOptions = {
method?: string
body?: unknown
query?: Record<string, string | number | boolean | undefined>
onResponseError?: (ctx: { response: { status: number; _data?: { errors?: Record<string, string[]>; message?: string } } }) => void
}
const accessToken = useCookie('accessToken').value
if (accessToken) {
if (options.headers instanceof Headers) {
options.headers.set('Authorization', `Bearer ${accessToken}`)
}
/**
* Thin ofetch-style wrapper around the single axios client (lib/axios).
* Use apiClient from @/lib/axios directly in new code; $api remains for Vuexy template compatibility.
*/
export async function $api<T = unknown>(url: string, options: ApiOptions = {}): Promise<T> {
const { method = 'GET', body, query, onResponseError } = options
const config: AxiosRequestConfig = {
method: method.toLowerCase() as AxiosRequestConfig['method'],
url,
params: query,
data: body,
}
try {
const response = await apiClient.request<T>(config)
return response.data
}
catch (error: any) {
if (onResponseError && error.response) {
onResponseError({
response: {
status: error.response.status,
_data: error.response.data,
},
})
}
// Set default headers
if (options.headers instanceof Headers) {
options.headers.set('Accept', 'application/json')
options.headers.set('Content-Type', 'application/json')
}
},
async onResponseError({ response }) {
// Handle 401 by redirecting to login
if (response.status === 401) {
if (import.meta.env.DEV) {
console.error('❌ API 401 Error:', {
url: response.url,
pathname: window.location.pathname,
willRedirect: window.location.pathname !== '/login',
})
}
// Clear auth data
useCookie('accessToken').value = null
useCookie('userData').value = null
useCookie('userAbilityRules').value = null
// Only redirect if not already on login page
// Add a small delay to prevent redirect loops
if (window.location.pathname !== '/login') {
// Use setTimeout to break any potential redirect loops
setTimeout(() => {
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
}, 100)
}
}
},
})
throw error
}
}

View File

@@ -0,0 +1,23 @@
import type { Rule } from '@/plugins/casl/ability'
/**
* CASL rules from Spatie role names returned by the API (`/auth/login`, etc.).
*/
export function getUserAbilityRules(roles: string[]): Rule[] {
if (roles.includes('super_admin')) {
return [{ action: 'manage', subject: 'all' }]
}
if (roles.includes('org_admin')) {
return [
{ action: 'read', subject: 'all' },
{ action: 'manage', subject: 'Event' },
{ action: 'manage', subject: 'Organisation' },
]
}
return [
{ action: 'read', subject: 'Event' },
{ action: 'read', subject: 'Organisation' },
]
}