feat(app): auth, orgs/events UI, router guards, and dev tooling

- Add Sanctum auth flow (store, composables, login, axios interceptors)
- Add dashboard, organisation list/detail, events CRUD dialogs
- Wire router guards, navigation, organisation switcher in layout
- Replace Vuexy @db types in NavSearchBar; add @iconify/types; themeConfig title typing
- Vuetify settings.scss + resolve configFile via fileURLToPath; drop dead path aliases
- Root index redirects to dashboard; fix events table route name
- API: DevSeeder + DatabaseSeeder updates; docs TEST_SCENARIO; corporate identity assets

Made-with: Cursor
This commit is contained in:
2026-04-07 21:51:10 +02:00
parent 0d24506c89
commit c417a6647a
45 changed files with 11554 additions and 832 deletions

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/useAuthStore'
const authStore = useAuthStore()
const stats = [
{ title: 'Evenementen', value: 0, icon: 'tabler-calendar-event', color: 'primary' },
{ title: 'Personen', value: 0, icon: 'tabler-users', color: 'success' },
{ title: 'Shifts', value: 0, icon: 'tabler-clock', color: 'warning' },
{ title: 'Briefings', value: 0, icon: 'tabler-mail', color: 'info' },
]
</script>
<template>
<div>
<VCard class="mb-6">
<VCardText>
<h4 class="text-h4 mb-1">
Welkom, {{ authStore.user?.name ?? 'gebruiker' }}! 👋
</h4>
<p class="text-body-1 mb-0">
{{ authStore.currentOrganisation?.name ?? 'Geen organisatie geselecteerd' }}
</p>
</VCardText>
</VCard>
<VRow>
<VCol
v-for="stat in stats"
:key="stat.title"
cols="12"
sm="6"
md="3"
>
<VCard>
<VCardText class="d-flex align-center gap-x-4">
<VAvatar
:color="stat.color"
variant="tonal"
size="44"
rounded
>
<VIcon
:icon="stat.icon"
size="28"
/>
</VAvatar>
<div>
<p class="text-body-1 mb-0">
{{ stat.title }}
</p>
<h4 class="text-h4">
{{ stat.value }}
</h4>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
</template>

View File

@@ -1,99 +1,241 @@
<script setup lang="ts">
import { useEvents } from '@/composables/useEvents'
import { useRoute } from 'vue-router'
import { useEventDetail } from '@/composables/api/useEvents'
import { useAuthStore } from '@/stores/useAuthStore'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
import EditEventDialog from '@/components/events/EditEventDialog.vue'
import type { EventStatus } from '@/types/event'
definePage({
meta: {
navActiveLink: 'events',
},
})
const route = useRoute()
const { currentEvent, fetchEvent, isLoading } = useEvents()
const authStore = useAuthStore()
const orgStore = useOrganisationStore()
const eventId = computed(() => route.params.id as string)
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
const eventId = computed(() => String((route.params as { id: string }).id))
watch(() => eventId.value, async () => {
if (eventId.value) {
await fetchEvent(eventId.value)
}
const { data: event, isLoading, isError, refetch } = useEventDetail(orgId, eventId)
const isEditDialogOpen = ref(false)
const activeTab = ref('details')
// Set active event in store
watch(eventId, (id) => {
if (id) orgStore.setActiveEvent(id)
}, { immediate: true })
function getStatusColor(status: string): string {
const colors: Record<string, string> = {
draft: 'secondary',
published: 'info',
registration_open: 'success',
buildup: 'warning',
showday: 'primary',
teardown: 'warning',
closed: 'secondary',
}
return colors[status] || 'secondary'
const statusColor: Record<EventStatus, string> = {
draft: 'default',
published: 'info',
registration_open: 'cyan',
buildup: 'warning',
showday: 'success',
teardown: 'warning',
closed: 'error',
}
function formatStatusLabel(status: string): string {
return status.replaceAll('_', ' ')
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
function formatDate(iso: string) {
return dateFormatter.format(new Date(iso))
}
const tiles = [
{ title: 'Secties & Shifts', value: 0, icon: 'tabler-layout-grid', color: 'primary' },
{ title: 'Personen', value: 0, icon: 'tabler-users', color: 'success' },
{ title: 'Artiesten', value: 0, icon: 'tabler-music', color: 'warning' },
{ title: 'Briefings', value: 0, icon: 'tabler-mail', color: 'info' },
]
</script>
<template>
<div>
<VCard v-if="currentEvent">
<VCardTitle class="d-flex align-center gap-2">
<RouterLink
:to="{ name: 'events' }"
class="text-decoration-none"
<!-- Loading -->
<VSkeletonLoader
v-if="isLoading"
type="card"
/>
<!-- Error -->
<VAlert
v-else-if="isError"
type="error"
class="mb-4"
>
Kon evenement niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
<VIcon icon="tabler-arrow-left" />
</RouterLink>
<span class="text-h5">{{ currentEvent.name }}</span>
<VChip
:color="getStatusColor(currentEvent.status)"
size="small"
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<template v-else-if="event">
<!-- Header -->
<div class="d-flex justify-space-between align-center mb-6">
<div class="d-flex align-center gap-x-3">
<VBtn
icon="tabler-arrow-left"
variant="text"
:to="{ name: 'events' }"
/>
<h4 class="text-h4">
{{ event.name }}
</h4>
<VChip
:color="statusColor[event.status]"
size="small"
>
{{ event.status }}
</VChip>
<span class="text-body-1 text-disabled">
{{ formatDate(event.start_date) }} {{ formatDate(event.end_date) }}
</span>
</div>
<VBtn
prepend-icon="tabler-edit"
@click="isEditDialogOpen = true"
>
{{ formatStatusLabel(currentEvent.status) }}
</VChip>
</VCardTitle>
Bewerken
</VBtn>
</div>
<VDivider />
<VCardText>
<div class="mb-4">
<div class="text-body-2 text-medium-emphasis mb-1">
Dates
</div>
<div class="text-body-1">
{{ new Date(currentEvent.start_date).toLocaleDateString() }}
{{ new Date(currentEvent.end_date).toLocaleDateString() }}
</div>
</div>
<div class="mb-4">
<div class="text-body-2 text-medium-emphasis mb-1">
Timezone
</div>
<div class="text-body-1">
{{ currentEvent.timezone }}
</div>
</div>
<div
v-if="currentEvent.organisation"
class="mb-4"
<!-- Stat tiles -->
<VRow class="mb-6">
<VCol
v-for="tile in tiles"
:key="tile.title"
cols="12"
sm="6"
md="3"
>
<div class="text-body-2 text-medium-emphasis mb-1">
Organisation
</div>
<div class="text-body-1">
{{ currentEvent.organisation.name }}
</div>
</div>
</VCardText>
</VCard>
<VCard>
<VCardText class="d-flex align-center gap-x-4">
<VAvatar
:color="tile.color"
variant="tonal"
size="44"
rounded
>
<VIcon
:icon="tile.icon"
size="28"
/>
</VAvatar>
<div>
<p class="text-body-1 mb-0">
{{ tile.title }}
</p>
<h4 class="text-h4">
{{ tile.value }}
</h4>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<VCard v-else-if="isLoading">
<VCardText class="text-center py-12">
<VProgressCircular
indeterminate
color="primary"
/>
</VCardText>
</VCard>
<!-- Tabs -->
<VTabs
v-model="activeTab"
class="mb-6"
>
<VTab value="details">
Details
</VTab>
<VTab value="settings">
Instellingen
</VTab>
</VTabs>
<VTabsWindow v-model="activeTab">
<!-- Tab: Details -->
<VTabsWindowItem value="details">
<VCard>
<VCardText>
<VRow>
<VCol
cols="12"
md="3"
>
<h6 class="text-h6 mb-1">
Slug
</h6>
<p class="text-body-1 text-disabled mb-0">
{{ event.slug }}
</p>
</VCol>
<VCol
cols="12"
md="3"
>
<h6 class="text-h6 mb-1">
Tijdzone
</h6>
<p class="text-body-1 text-disabled mb-0">
{{ event.timezone }}
</p>
</VCol>
<VCol
cols="12"
md="3"
>
<h6 class="text-h6 mb-1">
Organisatie
</h6>
<p class="text-body-1 text-disabled mb-0">
{{ authStore.currentOrganisation?.name ?? '' }}
</p>
</VCol>
<VCol
cols="12"
md="3"
>
<h6 class="text-h6 mb-1">
Aangemaakt op
</h6>
<p class="text-body-1 text-disabled mb-0">
{{ formatDate(event.created_at) }}
</p>
</VCol>
</VRow>
</VCardText>
</VCard>
</VTabsWindowItem>
<!-- Tab: Instellingen -->
<VTabsWindowItem value="settings">
<VCard>
<VCardText class="text-center pa-8">
<VIcon
icon="tabler-settings"
size="48"
class="mb-4 text-disabled"
/>
<p class="text-body-1 text-disabled">
Komt in een volgende fase
</p>
</VCardText>
</VCard>
</VTabsWindowItem>
</VTabsWindow>
<EditEventDialog
v-model="isEditDialogOpen"
:event="event"
:org-id="orgId"
/>
</template>
</div>
</template>

View File

@@ -1,158 +1,163 @@
<script setup lang="ts">
import { useEvents } from '@/composables/useEvents'
import { useEventList } from '@/composables/api/useEvents'
import { useAuthStore } from '@/stores/useAuthStore'
import CreateEventDialog from '@/components/events/CreateEventDialog.vue'
import type { EventStatus, EventType } from '@/types/event'
const { events, pagination, organisationId, isLoading, error, fetchEvents } = useEvents()
const router = useRouter()
const authStore = useAuthStore()
const page = ref(1)
const itemsPerPage = ref(10)
const orgId = computed(() => authStore.currentOrganisation?.id ?? '')
watch([page, itemsPerPage, organisationId], () => {
if (!organisationId.value) {
return
}
fetchEvents({
page: page.value,
per_page: itemsPerPage.value,
})
}, { immediate: true })
const { data: events, isLoading, isError, refetch } = useEventList(orgId)
function getStatusColor(status: string): string {
const colors: Record<string, string> = {
draft: 'secondary',
published: 'info',
registration_open: 'success',
buildup: 'warning',
showday: 'primary',
teardown: 'warning',
closed: 'secondary',
}
return colors[status] || 'secondary'
const isCreateDialogOpen = ref(false)
const headers = [
{ title: 'Naam', key: 'name' },
{ title: 'Status', key: 'status' },
{ title: 'Startdatum', key: 'start_date' },
{ title: 'Einddatum', key: 'end_date' },
{ title: 'Acties', key: 'actions', sortable: false, align: 'end' as const },
]
const statusColor: Record<EventStatus, string> = {
draft: 'default',
published: 'info',
registration_open: 'cyan',
buildup: 'warning',
showday: 'success',
teardown: 'warning',
closed: 'error',
}
function formatStatusLabel(status: string): string {
return status.replaceAll('_', ' ')
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
function formatDate(iso: string) {
return dateFormatter.format(new Date(iso))
}
function navigateToDetail(_event: Event, row: { item: EventType }) {
router.push({ name: 'events-id', params: { id: row.item.id } })
}
</script>
<template>
<div>
<!-- No org selected -->
<VAlert
v-if="!organisationId"
v-if="!orgId"
type="warning"
class="mb-4"
>
You need to belong to an organisation to see events. Ask an administrator to invite you.
Selecteer eerst een organisatie.
<template #append>
<VBtn
variant="text"
:to="{ name: 'organisation' }"
>
Naar organisatie
</VBtn>
</template>
</VAlert>
<VCard>
<VCardTitle>
<h4 class="text-h4 mb-1">
Events
<template v-else>
<div class="d-flex justify-space-between align-center mb-6">
<h4 class="text-h4">
Evenementen
</h4>
<p class="text-body-2 text-medium-emphasis">
Events for your organisation
</p>
</VCardTitle>
<VDivider />
<div
v-if="isLoading"
class="d-flex justify-center align-center py-12"
>
<VProgressCircular
indeterminate
color="primary"
/>
<VBtn
prepend-icon="tabler-plus"
@click="isCreateDialogOpen = true"
>
Nieuw evenement
</VBtn>
</div>
<!-- Loading -->
<VSkeletonLoader
v-if="isLoading"
type="table"
/>
<!-- Error -->
<VAlert
v-else-if="error"
v-else-if="isError"
type="error"
class="ma-4"
class="mb-4"
>
{{ error.message }}
Kon evenementen niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<div
v-else-if="events.length > 0"
class="pa-4"
>
<VRow>
<VCol
v-for="event in events"
:key="event.id"
cols="12"
md="6"
lg="4"
>
<VCard>
<VCardTitle>
<RouterLink
:to="{ name: 'events-view-id', params: { id: event.id } }"
class="text-decoration-none text-high-emphasis"
>
{{ event.name }}
</RouterLink>
</VCardTitle>
<VCardText>
<div class="mb-2">
<VChip
:color="getStatusColor(event.status)"
size="small"
class="mr-2"
>
{{ formatStatusLabel(event.status) }}
</VChip>
</div>
<div class="text-body-2">
{{ new Date(event.start_date).toLocaleDateString() }}
{{ new Date(event.end_date).toLocaleDateString() }}
</div>
<div class="text-body-2 text-medium-emphasis">
{{ event.timezone }}
</div>
</VCardText>
<VCardActions>
<VBtn
:to="{ name: 'events-view-id', params: { id: event.id } }"
variant="text"
>
View details
</VBtn>
</VCardActions>
</VCard>
</VCol>
</VRow>
<div
v-if="pagination && pagination.last_page > 1"
class="d-flex justify-center mt-4"
>
<VPagination
v-model="page"
:length="pagination.last_page"
:total-visible="7"
/>
</div>
</div>
<VCardText
v-else
class="text-center py-12"
<!-- Empty -->
<VCard
v-else-if="!events?.length"
class="text-center pa-8"
>
<VIcon
icon="tabler-calendar-off"
size="64"
class="text-medium-emphasis mb-4"
icon="tabler-calendar-event"
size="48"
class="mb-4 text-disabled"
/>
<p class="text-h6 text-medium-emphasis">
No events yet
<p class="text-body-1 text-disabled">
Nog geen evenementen
</p>
</VCardText>
</VCard>
</VCard>
<!-- Data table -->
<VCard v-else>
<VDataTable
:headers="headers"
:items="events"
item-value="id"
hover
@click:row="navigateToDetail"
>
<template #item.status="{ item }">
<VChip
:color="statusColor[item.status]"
size="small"
>
{{ item.status }}
</VChip>
</template>
<template #item.start_date="{ item }">
{{ formatDate(item.start_date) }}
</template>
<template #item.end_date="{ item }">
{{ formatDate(item.end_date) }}
</template>
<template #item.actions="{ item }">
<VBtn
icon="tabler-eye"
variant="text"
size="small"
:to="{ name: 'events-id', params: { id: item.id } }"
@click.stop
/>
</template>
</VDataTable>
</VCard>
<CreateEventDialog
v-model="isCreateDialogOpen"
:org-id="orgId"
/>
</template>
</div>
</template>

View File

@@ -1,25 +1,10 @@
<template>
<div>
<VCard
class="mb-6"
title="Kick start your project 🚀"
>
<VCardText>All the best for your new project.</VCardText>
<VCardText>
Please make sure to read our <a
href="https://demos.pixinvent.com/vuexy-vuejs-admin-template/documentation/"
target="_blank"
rel="noopener noreferrer"
class="text-decoration-none"
>
Template Documentation
</a> to understand where to go from here and how to use our template.
</VCardText>
</VCard>
<script setup lang="ts">
import { useRouter } from 'vue-router'
<VCard title="Want to integrate JWT? 🔒">
<VCardText>We carefully crafted JWT flow so you can implement JWT with ease and with minimum efforts.</VCardText>
<VCardText>Please read our JWT Documentation to get more out of JWT authentication.</VCardText>
</VCard>
</div>
const router = useRouter()
router.replace({ name: 'dashboard' })
</script>
<template>
<div />
</template>

View File

@@ -10,8 +10,9 @@ 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 { apiClient } from '@/lib/axios'
import { useLogin } from '@/composables/api/useAuth'
import { emailValidator, requiredValidator } from '@core/utils/validators'
import type { LoginCredentials } from '@/types/auth'
definePage({
meta: {
@@ -23,17 +24,18 @@ definePage({
const route = useRoute()
const router = useRouter()
const form = ref({
const form = ref<LoginCredentials & { remember: boolean }>({
email: '',
password: '',
remember: false,
})
const isPasswordVisible = ref(false)
const isLoading = ref(false)
const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
const { mutate: login, isPending } = useLogin()
const authThemeImg = useGenerateImageVariant(
authV2LoginIllustrationLight,
authV2LoginIllustrationDark,
@@ -43,62 +45,35 @@ const authThemeImg = useGenerateImageVariant(
const authThemeMask = useGenerateImageVariant(authV2MaskLight, authV2MaskDark)
async function handleLogin() {
function handleLogin() {
errors.value = {}
isLoading.value = true
try {
const { data } = await apiClient.post('/auth/login', {
email: form.value.email,
password: form.value.password,
})
login(
{ email: form.value.email, password: form.value.password },
{
onSuccess: () => {
const redirectTo = route.query.to ? String(route.query.to) : '/dashboard'
if (data.success && data.data) {
// Store token in cookie (axios interceptor reads from accessToken cookie)
document.cookie = `accessToken=${data.data.token}; path=/`
// Store user data in cookie if needed
if (data.data.user) {
document.cookie = `userData=${JSON.stringify(data.data.user)}; path=/`
}
// Redirect to home or the 'to' query parameter
await nextTick(() => {
const redirectTo = route.query.to ? String(route.query.to) : '/'
router.replace(redirectTo)
})
}
} catch (err: any) {
console.error('Login error:', err)
// Handle API errors
if (err.response?.data) {
const errorData = err.response.data
if (errorData.errors) {
// Validation errors
errors.value = {
email: errorData.errors.email?.[0],
password: errorData.errors.password?.[0],
},
onError: (err: any) => {
const errorData = err.response?.data
if (errorData?.errors) {
errors.value = {
email: errorData.errors.email?.[0] ?? '',
password: errorData.errors.password?.[0] ?? '',
}
}
} else if (errorData.message) {
// General error message
errors.value = {
email: errorData.message,
else if (errorData?.message) {
errors.value = { email: errorData.message }
}
} else {
errors.value = {
email: 'Invalid email or password',
else {
errors.value = { email: 'An error occurred. Please try again.' }
}
}
} else {
errors.value = {
email: 'An error occurred. Please try again.',
}
}
} finally {
isLoading.value = false
}
},
},
)
}
function onSubmit() {
@@ -218,7 +193,7 @@ function onSubmit() {
<VBtn
block
type="submit"
:loading="isLoading"
:loading="isPending"
>
Login
</VBtn>

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import { useMyOrganisation } from '@/composables/api/useOrganisations'
import { useAuthStore } from '@/stores/useAuthStore'
import EditOrganisationDialog from '@/components/organisations/EditOrganisationDialog.vue'
import type { Organisation } from '@/types/organisation'
const authStore = useAuthStore()
const { data: organisation, isLoading, isError, refetch } = useMyOrganisation()
const isEditDialogOpen = ref(false)
const isOrgAdmin = computed(() => {
const role = authStore.currentOrganisation?.role
return role === 'org_admin' || authStore.isSuperAdmin
})
const statusColor: Record<Organisation['billing_status'], string> = {
trial: 'info',
active: 'success',
suspended: 'warning',
cancelled: 'error',
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('nl-NL', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
</script>
<template>
<div>
<!-- Loading -->
<VSkeletonLoader
v-if="isLoading"
type="card"
/>
<!-- Error -->
<VAlert
v-else-if="isError"
type="error"
class="mb-4"
>
Kon organisatie niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<template v-else-if="organisation">
<!-- Header -->
<div class="d-flex justify-space-between align-center mb-6">
<div class="d-flex align-center gap-x-3">
<h4 class="text-h4">
{{ organisation.name }}
</h4>
<VChip
:color="statusColor[organisation.billing_status]"
size="small"
>
{{ organisation.billing_status }}
</VChip>
</div>
<VBtn
v-if="isOrgAdmin"
prepend-icon="tabler-edit"
@click="isEditDialogOpen = true"
>
Naam bewerken
</VBtn>
</div>
<!-- Info card -->
<VCard>
<VCardText>
<VRow>
<VCol
cols="12"
md="4"
>
<h6 class="text-h6 mb-1">
Slug
</h6>
<p class="text-body-1 text-disabled mb-0">
{{ organisation.slug }}
</p>
</VCol>
<VCol
cols="12"
md="4"
>
<h6 class="text-h6 mb-1">
Aangemaakt op
</h6>
<p class="text-body-1 text-disabled mb-0">
{{ formatDate(organisation.created_at) }}
</p>
</VCol>
<VCol
cols="12"
md="4"
>
<h6 class="text-h6 mb-1">
Status
</h6>
<VChip
:color="statusColor[organisation.billing_status]"
size="small"
>
{{ organisation.billing_status }}
</VChip>
</VCol>
</VRow>
</VCardText>
</VCard>
<EditOrganisationDialog
v-model="isEditDialogOpen"
:organisation="organisation"
/>
</template>
</div>
</template>