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:
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
28
apps/admin/src/composables/useOrganisationContext.ts
Normal file
28
apps/admin/src/composables/useOrganisationContext.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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('/')
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
23
apps/admin/src/utils/auth-ability.ts
Normal file
23
apps/admin/src/utils/auth-ability.ts
Normal 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' },
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user