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

@@ -20,7 +20,7 @@ pnpm install
```env
VITE_API_URL=http://localhost:8000/api/v1
VITE_APP_NAME="Band Management Admin"
VITE_APP_NAME="Event Crew Admin"
```
4. Start development:

View File

@@ -370,7 +370,6 @@ declare module 'vue' {
EnableOneTimePasswordDialog: typeof import('./src/components/dialogs/EnableOneTimePasswordDialog.vue')['default']
ErrorHeader: typeof import('./src/components/ErrorHeader.vue')['default']
I18n: typeof import('./src/@core/components/I18n.vue')['default']
InviteMembersDialog: typeof import('./src/components/events/InviteMembersDialog.vue')['default']
MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default']
Notifications: typeof import('./src/@core/components/Notifications.vue')['default']
PaymentProvidersDialog: typeof import('./src/components/dialogs/PaymentProvidersDialog.vue')['default']

View File

@@ -6,7 +6,7 @@
<link rel="icon" href="/favicon.ico" />
<meta name="robots" content="noindex, nofollow" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Band Management Admin</title>
<title>Event Crew Admin</title>
<link rel="stylesheet" type="text/css" href="/loader.css" />
</head>

View File

@@ -1,5 +1,5 @@
{
"name": "vuexy-vuejs-admin-template",
"name": "eventcrew-admin",
"version": "9.5.0",
"private": true,
"type": "module",
@@ -19,6 +19,7 @@
"@floating-ui/dom": "1.6.8",
"@formkit/drag-and-drop": "0.1.6",
"@sindresorhus/is": "7.1.0",
"@tanstack/vue-query": "^5.95.2",
"@tiptap/extension-highlight": "^2.27.1",
"@tiptap/extension-image": "^2.27.1",
"@tiptap/extension-link": "^2.27.1",
@@ -28,6 +29,7 @@
"@tiptap/vue-3": "^2.27.1",
"@types/jquery": "^3.5.33",
"@types/moment": "^2.13.0",
"@vee-validate/zod": "^4.15.1",
"@vueuse/core": "10.11.1",
"@vueuse/math": "10.11.1",
"apexcharts": "3.54.1",
@@ -50,6 +52,7 @@
"swiper": "11.2.10",
"ufo": "1.6.1",
"unplugin-vue-define-options": "1.5.5",
"vee-validate": "^4.15.1",
"vue": "3.5.22",
"vue-chartjs": "5.3.2",
"vue-flatpickr-component": "11.0.5",
@@ -59,7 +62,8 @@
"vue3-apexcharts": "1.5.3",
"vue3-perfect-scrollbar": "2.0.0",
"vuetify": "3.10.8",
"webfontloader": "1.6.28"
"webfontloader": "1.6.28",
"zod": "^3.25.76"
},
"devDependencies": {
"@antfu/eslint-config-vue": "0.43.1",

View File

@@ -27,6 +27,9 @@ importers:
'@sindresorhus/is':
specifier: 7.1.0
version: 7.1.0
'@tanstack/vue-query':
specifier: ^5.95.2
version: 5.95.2(vue@3.5.22(typescript@5.9.3))
'@tiptap/extension-highlight':
specifier: ^2.27.1
version: 2.27.1(@tiptap/core@2.27.1(@tiptap/pm@2.27.1))
@@ -54,6 +57,9 @@ importers:
'@types/moment':
specifier: ^2.13.0
version: 2.13.0
'@vee-validate/zod':
specifier: ^4.15.1
version: 4.15.1(vue@3.5.22(typescript@5.9.3))(zod@3.25.76)
'@vueuse/core':
specifier: 10.11.1
version: 10.11.1(vue@3.5.22(typescript@5.9.3))
@@ -120,6 +126,9 @@ importers:
unplugin-vue-define-options:
specifier: 1.5.5
version: 1.5.5(vue@3.5.22(typescript@5.9.3))
vee-validate:
specifier: ^4.15.1
version: 4.15.1(vue@3.5.22(typescript@5.9.3))
vue:
specifier: 3.5.22
version: 3.5.22(typescript@5.9.3)
@@ -150,6 +159,9 @@ importers:
webfontloader:
specifier: 1.6.28
version: 1.6.28
zod:
specifier: ^3.25.76
version: 3.25.76
devDependencies:
'@antfu/eslint-config-vue':
specifier: 0.43.1
@@ -1043,56 +1055,67 @@ packages:
resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.52.5':
resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.52.5':
resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.52.5':
resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.52.5':
resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.52.5':
resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.52.5':
resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.52.5':
resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.52.5':
resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.52.5':
resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.52.5':
resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.52.5':
resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==}
@@ -1178,6 +1201,22 @@ packages:
peerDependencies:
stylelint: ^16.0.2
'@tanstack/match-sorter-utils@8.19.4':
resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==}
engines: {node: '>=12'}
'@tanstack/query-core@5.95.2':
resolution: {integrity: sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==}
'@tanstack/vue-query@5.95.2':
resolution: {integrity: sha512-GleO0GrUPdvObtff/D3iQ5kUERQM3dM6vT5pWl4zC3ap2JO84x4SQbUa1G7czKx96lETRiHnw7ZuatSRaaZqQQ==}
peerDependencies:
'@vue/composition-api': ^1.1.2
vue: ^2.6.0 || ^3.3.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
'@tiptap/core@2.27.1':
resolution: {integrity: sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==}
peerDependencies:
@@ -1678,41 +1717,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -1734,6 +1781,11 @@ packages:
cpu: [x64]
os: [win32]
'@vee-validate/zod@4.15.1':
resolution: {integrity: sha512-329Z4TDBE5Vx0FdbA8S4eR9iGCFFUNGbxjpQ20ff5b5wGueScjocUIx9JHPa79LTG06RnlUR4XogQsjN4tecKA==}
peerDependencies:
zod: ^3.24.0
'@vitejs/plugin-vue-jsx@5.1.1':
resolution: {integrity: sha512-uQkfxzlF8SGHJJVH966lFTdjM/lGcwJGzwAHpVqAPDD/QcsqoUGa+q31ox1BrUfi+FLP2ChVp7uLXE3DkHyDdQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3896,6 +3948,9 @@ packages:
resolution: {integrity: sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==}
hasBin: true
remove-accents@0.5.0:
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
@@ -4536,6 +4591,11 @@ packages:
validate-npm-package-license@3.0.4:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
vee-validate@4.15.1:
resolution: {integrity: sha512-DkFsiTwEKau8VIxyZBGdO6tOudD+QoUBPuHj3e6QFqmbfCRj1ArmYWue9lEp6jLSWBIw4XPlDLjFIZNLdRAMSg==}
peerDependencies:
vue: ^3.4.26
vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -4838,6 +4898,9 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -5727,6 +5790,20 @@ snapshots:
style-search: 0.1.0
stylelint: 16.8.0(typescript@5.9.3)
'@tanstack/match-sorter-utils@8.19.4':
dependencies:
remove-accents: 0.5.0
'@tanstack/query-core@5.95.2': {}
'@tanstack/vue-query@5.95.2(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@tanstack/match-sorter-utils': 8.19.4
'@tanstack/query-core': 5.95.2
'@vue/devtools-api': 6.6.4
vue: 3.5.22(typescript@5.9.3)
vue-demi: 0.14.10(vue@3.5.22(typescript@5.9.3))
'@tiptap/core@2.27.1(@tiptap/pm@2.27.1)':
dependencies:
'@tiptap/pm': 2.27.1
@@ -6335,6 +6412,14 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.1':
optional: true
'@vee-validate/zod@4.15.1(vue@3.5.22(typescript@5.9.3))(zod@3.25.76)':
dependencies:
type-fest: 4.41.0
vee-validate: 4.15.1(vue@3.5.22(typescript@5.9.3))
zod: 3.25.76
transitivePeerDependencies:
- vue
'@vitejs/plugin-vue-jsx@5.1.1(vite@7.1.12(@types/node@24.9.2)(sass@1.76.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@babel/core': 7.28.5
@@ -8904,6 +8989,8 @@ snapshots:
dependencies:
jsesc: 0.5.0
remove-accents@0.5.0: {}
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
@@ -9707,6 +9794,12 @@ snapshots:
spdx-correct: 3.2.0
spdx-expression-parse: 3.0.1
vee-validate@4.15.1(vue@3.5.22(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 7.7.7
type-fest: 4.41.0
vue: 3.5.22(typescript@5.9.3)
vfile-message@4.0.3:
dependencies:
'@types/unist': 3.0.3
@@ -10020,4 +10113,6 @@ snapshots:
yoctocolors@2.1.2: {}
zod@3.25.76: {}
zwitch@2.0.4: {}

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' },
]
}

View File

@@ -106,6 +106,15 @@ export default defineConfig({
'@api-utils': fileURLToPath(new URL('./src/plugins/fake-api/utils/', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
chunkSizeWarningLimit: 5000,
},