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,179 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { useCreateEvent } from '@/composables/api/useEvents'
import { requiredValidator } from '@core/utils/validators'
const props = defineProps<{
orgId: string
}>()
const modelValue = defineModel<boolean>({ required: true })
const orgIdRef = computed(() => props.orgId)
const form = ref({
name: '',
slug: '',
start_date: '',
end_date: '',
timezone: 'Europe/Amsterdam',
})
const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
const showSuccess = ref(false)
const { mutate: createEvent, isPending } = useCreateEvent(orgIdRef)
const timezoneOptions = [
{ title: 'Europe/Amsterdam', value: 'Europe/Amsterdam' },
{ title: 'Europe/London', value: 'Europe/London' },
{ title: 'Europe/Paris', value: 'Europe/Paris' },
{ title: 'UTC', value: 'UTC' },
]
watch(() => form.value.name, (name) => {
form.value.slug = name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
})
const endDateRule = (v: string) => {
if (!v) return 'Einddatum is verplicht'
if (form.value.start_date && v < form.value.start_date) {
return 'Einddatum moet op of na startdatum liggen'
}
return true
}
function resetForm() {
form.value = { name: '', slug: '', start_date: '', end_date: '', timezone: 'Europe/Amsterdam' }
errors.value = {}
refVForm.value?.resetValidation()
}
function onSubmit() {
refVForm.value?.validate().then(({ valid }) => {
if (!valid) return
errors.value = {}
createEvent(form.value, {
onSuccess: () => {
modelValue.value = false
showSuccess.value = true
resetForm()
},
onError: (err: any) => {
const data = err.response?.data
if (data?.errors) {
errors.value = Object.fromEntries(
Object.entries(data.errors).map(([k, v]) => [k, (v as string[])[0]]),
)
}
else if (data?.message) {
errors.value = { name: data.message }
}
},
})
})
}
</script>
<template>
<VDialog
v-model="modelValue"
max-width="550"
@after-leave="resetForm"
>
<VCard title="Nieuw evenement">
<VCardText>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VRow>
<VCol cols="12">
<AppTextField
v-model="form.name"
label="Naam"
:rules="[requiredValidator]"
:error-messages="errors.name"
autofocus
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="form.slug"
label="Slug"
:rules="[requiredValidator]"
:error-messages="errors.slug"
hint="Wordt gebruikt in de URL"
persistent-hint
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="form.start_date"
label="Startdatum"
type="date"
:rules="[requiredValidator]"
:error-messages="errors.start_date"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="form.end_date"
label="Einddatum"
type="date"
:rules="[requiredValidator, endDateRule]"
:error-messages="errors.end_date"
/>
</VCol>
<VCol cols="12">
<AppSelect
v-model="form.timezone"
label="Tijdzone"
:items="timezoneOptions"
:error-messages="errors.timezone"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Aanmaken
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<VSnackbar
v-model="showSuccess"
color="success"
:timeout="3000"
>
Evenement aangemaakt
</VSnackbar>
</template>

View File

@@ -0,0 +1,199 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { useUpdateEvent } from '@/composables/api/useEvents'
import { requiredValidator } from '@core/utils/validators'
import type { EventStatus, EventType } from '@/types/event'
const props = defineProps<{
event: EventType
orgId: string
}>()
const modelValue = defineModel<boolean>({ required: true })
const orgIdRef = computed(() => props.orgId)
const eventIdRef = computed(() => props.event.id)
const form = ref({
name: '',
slug: '',
start_date: '',
end_date: '',
timezone: '',
status: '' as EventStatus,
})
const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
const showSuccess = ref(false)
const { mutate: updateEvent, isPending } = useUpdateEvent(orgIdRef, eventIdRef)
const timezoneOptions = [
{ title: 'Europe/Amsterdam', value: 'Europe/Amsterdam' },
{ title: 'Europe/London', value: 'Europe/London' },
{ title: 'Europe/Paris', value: 'Europe/Paris' },
{ title: 'UTC', value: 'UTC' },
]
const statusOptions: { title: string; value: EventStatus }[] = [
{ title: 'Draft', value: 'draft' },
{ title: 'Published', value: 'published' },
{ title: 'Registration Open', value: 'registration_open' },
{ title: 'Build-up', value: 'buildup' },
{ title: 'Show Day', value: 'showday' },
{ title: 'Tear-down', value: 'teardown' },
{ title: 'Closed', value: 'closed' },
]
const endDateRule = (v: string) => {
if (!v) return 'Einddatum is verplicht'
if (form.value.start_date && v < form.value.start_date) {
return 'Einddatum moet op of na startdatum liggen'
}
return true
}
watch(() => props.event, (ev) => {
form.value = {
name: ev.name,
slug: ev.slug,
start_date: ev.start_date,
end_date: ev.end_date,
timezone: ev.timezone,
status: ev.status,
}
}, { immediate: true })
function onSubmit() {
refVForm.value?.validate().then(({ valid }) => {
if (!valid) return
errors.value = {}
updateEvent(form.value, {
onSuccess: () => {
modelValue.value = false
showSuccess.value = true
},
onError: (err: any) => {
const data = err.response?.data
if (data?.errors) {
errors.value = Object.fromEntries(
Object.entries(data.errors).map(([k, v]) => [k, (v as string[])[0]]),
)
}
else if (data?.message) {
errors.value = { name: data.message }
}
},
})
})
}
</script>
<template>
<VDialog
v-model="modelValue"
max-width="550"
>
<VCard title="Evenement bewerken">
<VCardText>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<VRow>
<VCol cols="12">
<AppTextField
v-model="form.name"
label="Naam"
:rules="[requiredValidator]"
:error-messages="errors.name"
autofocus
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="form.slug"
label="Slug"
:rules="[requiredValidator]"
:error-messages="errors.slug"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="form.start_date"
label="Startdatum"
type="date"
:rules="[requiredValidator]"
:error-messages="errors.start_date"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="form.end_date"
label="Einddatum"
type="date"
:rules="[requiredValidator, endDateRule]"
:error-messages="errors.end_date"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppSelect
v-model="form.timezone"
label="Tijdzone"
:items="timezoneOptions"
:error-messages="errors.timezone"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppSelect
v-model="form.status"
label="Status"
:items="statusOptions"
:error-messages="errors.status"
/>
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Opslaan
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<VSnackbar
v-model="showSuccess"
color="success"
:timeout="3000"
>
Evenement bijgewerkt
</VSnackbar>
</template>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/useAuthStore'
const authStore = useAuthStore()
const hasMultipleOrgs = computed(() => authStore.organisations.length > 1)
const currentOrgName = computed(() => authStore.currentOrganisation?.name ?? 'Geen organisatie')
</script>
<template>
<div class="organisation-switcher mx-4 mb-2">
<!-- Single org: just show the name -->
<div
v-if="!hasMultipleOrgs"
class="d-flex align-center gap-x-2 pa-2"
>
<VIcon
icon="tabler-building"
size="20"
color="primary"
/>
<span class="text-body-2 font-weight-medium text-truncate">
{{ currentOrgName }}
</span>
</div>
<!-- Multiple orgs: dropdown -->
<VBtn
v-else
variant="tonal"
color="primary"
block
class="text-none justify-start"
prepend-icon="tabler-building"
>
<span class="text-truncate">{{ currentOrgName }}</span>
<VMenu
activator="parent"
width="250"
location="bottom start"
>
<VList density="compact">
<VListItem
v-for="org in authStore.organisations"
:key="org.id"
:active="org.id === authStore.currentOrganisation?.id"
@click="authStore.setActiveOrganisation(org.id)"
>
<VListItemTitle>{{ org.name }}</VListItemTitle>
<template #append>
<VChip
size="x-small"
variant="text"
>
{{ org.role }}
</VChip>
</template>
</VListItem>
</VList>
</VMenu>
</VBtn>
</div>
</template>

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { useUpdateOrganisation } from '@/composables/api/useOrganisations'
import { requiredValidator } from '@core/utils/validators'
import type { Organisation } from '@/types/organisation'
const props = defineProps<{
organisation: Organisation
}>()
const modelValue = defineModel<boolean>({ required: true })
const name = ref('')
const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
const showSuccess = ref(false)
const { mutate: updateOrganisation, isPending } = useUpdateOrganisation()
watch(() => props.organisation, (org) => {
name.value = org.name
}, { immediate: true })
function onSubmit() {
refVForm.value?.validate().then(({ valid }) => {
if (!valid) return
errors.value = {}
updateOrganisation(
{ id: props.organisation.id, name: name.value },
{
onSuccess: () => {
modelValue.value = false
showSuccess.value = true
},
onError: (err: any) => {
const data = err.response?.data
if (data?.errors) {
errors.value = { name: data.errors.name?.[0] ?? '' }
}
else if (data?.message) {
errors.value = { name: data.message }
}
},
},
)
})
}
</script>
<template>
<VDialog
v-model="modelValue"
max-width="450"
>
<VCard title="Naam bewerken">
<VCardText>
<VForm
ref="refVForm"
@submit.prevent="onSubmit"
>
<AppTextField
v-model="name"
label="Organisatienaam"
:rules="[requiredValidator]"
:error-messages="errors.name"
autofocus
/>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="modelValue = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isPending"
@click="onSubmit"
>
Opslaan
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<VSnackbar
v-model="showSuccess"
color="success"
:timeout="3000"
>
Naam bijgewerkt
</VSnackbar>
</template>