feat(portal): horizontal navbar layout with avatar menu and profile restructuring
Replace the simple inline header with a proper Vuexy-style horizontal navbar featuring left (logo + event switcher), center (conditional menu items based on approval status), and right (avatar dropdown with profile link and logout) sections. Move profile page from /profile to /profiel as a platform-level page with "Mijn evenementen" section, removing the event-scoped status card and remarks field. Registration and success pages now use the portal layout with hideEventMenu meta so they get the navbar when logged in but no event menu items. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
apps/portal/components.d.ts
vendored
1
apps/portal/components.d.ts
vendored
@@ -46,5 +46,6 @@ declare module 'vue' {
|
||||
TheCustomizer: typeof import('./src/@core/components/TheCustomizer.vue')['default']
|
||||
ThemeSwitcher: typeof import('./src/@core/components/ThemeSwitcher.vue')['default']
|
||||
TiptapEditor: typeof import('./src/@core/components/TiptapEditor.vue')['default']
|
||||
UserAvatarMenu: typeof import('./src/components/portal/UserAvatarMenu.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
1
apps/portal/env.d.ts
vendored
1
apps/portal/env.d.ts
vendored
@@ -7,5 +7,6 @@ declare module 'vue-router' {
|
||||
requiresAuth?: boolean
|
||||
requiresToken?: boolean
|
||||
public?: boolean
|
||||
hideEventMenu?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ const registeredLabel = computed(() => {
|
||||
sm="4"
|
||||
>
|
||||
<VCard
|
||||
:to="{ name: 'portal-profile' }"
|
||||
:to="{ name: 'portal-profiel' }"
|
||||
class="h-100 text-decoration-none portal-action-card"
|
||||
elevation="1"
|
||||
>
|
||||
|
||||
75
apps/portal/src/components/portal/UserAvatarMenu.vue
Normal file
75
apps/portal/src/components/portal/UserAvatarMenu.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const userInitials = computed(() => {
|
||||
const user = authStore.user
|
||||
if (!user) return '?'
|
||||
const first = user.first_name?.charAt(0) ?? ''
|
||||
const last = user.last_name?.charAt(0) ?? ''
|
||||
|
||||
return (first + last).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
const userFullName = computed(() => authStore.user?.full_name ?? '')
|
||||
const userEmail = computed(() => authStore.user?.email ?? '')
|
||||
|
||||
async function logout() {
|
||||
await authStore.logout()
|
||||
await router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VMenu
|
||||
location="bottom end"
|
||||
:close-on-content-click="false"
|
||||
min-width="220"
|
||||
>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<VAvatar
|
||||
v-bind="menuProps"
|
||||
size="36"
|
||||
color="primary"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<span class="text-body-2 font-weight-medium text-white">
|
||||
{{ userInitials }}
|
||||
</span>
|
||||
</VAvatar>
|
||||
</template>
|
||||
|
||||
<VList density="compact">
|
||||
<!-- User info -->
|
||||
<VListItem class="pb-0">
|
||||
<VListItemTitle class="font-weight-bold text-body-1">
|
||||
{{ userFullName }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption text-medium-emphasis">
|
||||
{{ userEmail }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
|
||||
<VDivider class="my-2" />
|
||||
|
||||
<!-- Profile link -->
|
||||
<VListItem
|
||||
to="/profiel"
|
||||
prepend-icon="tabler-user"
|
||||
title="Mijn Profiel"
|
||||
/>
|
||||
|
||||
<VDivider class="my-2" />
|
||||
|
||||
<!-- Logout -->
|
||||
<VListItem
|
||||
prepend-icon="tabler-logout"
|
||||
title="Uitloggen"
|
||||
class="text-error"
|
||||
@click="logout"
|
||||
/>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import EventSwitcher from '@/components/portal/EventSwitcher.vue'
|
||||
import UserAvatarMenu from '@/components/portal/UserAvatarMenu.vue'
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { usePortalStore } from '@/stores/usePortalStore'
|
||||
|
||||
@@ -9,17 +10,28 @@ injectSkinClasses()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const portal = usePortalStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const isMobileMenuOpen = ref(false)
|
||||
|
||||
const hideEventMenu = computed(() => route.meta.hideEventMenu === true)
|
||||
|
||||
const isApproved = computed(() => portal.currentPerson?.status === 'approved')
|
||||
|
||||
const navItems = computed(() => {
|
||||
if (!authStore.isAuthenticated) return []
|
||||
const hasActiveEvent = computed(() => !!portal.activeEventId)
|
||||
|
||||
const showEventMenu = computed(() => {
|
||||
if (hideEventMenu.value) return false
|
||||
if (!hasActiveEvent.value) return false
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const menuItems = computed(() => {
|
||||
if (!showEventMenu.value) return []
|
||||
|
||||
const items = [
|
||||
{ title: 'Dashboard', to: '/dashboard', icon: 'tabler-dashboard' },
|
||||
{ title: 'Dashboard', to: '/dashboard', icon: 'tabler-layout-dashboard' },
|
||||
]
|
||||
|
||||
if (isApproved.value) {
|
||||
@@ -29,7 +41,14 @@ const navItems = computed(() => {
|
||||
)
|
||||
}
|
||||
|
||||
items.push({ title: 'Mijn Profiel', to: '/profile', icon: 'tabler-user' })
|
||||
return items
|
||||
})
|
||||
|
||||
// Mobile drawer items include profile + logout
|
||||
const mobileNavItems = computed(() => {
|
||||
const items = [...menuItems.value]
|
||||
|
||||
items.push({ title: 'Mijn Profiel', to: '/profiel', icon: 'tabler-user' })
|
||||
|
||||
return items
|
||||
})
|
||||
@@ -45,14 +64,11 @@ watch([isFallbackStateActive, refLoadingIndicator], () => {
|
||||
refLoadingIndicator.value.resolveHandle()
|
||||
}, { immediate: true })
|
||||
|
||||
async function logoutAndRedirect(): Promise<void> {
|
||||
await authStore.logout()
|
||||
await router.push('/login')
|
||||
}
|
||||
|
||||
async function logoutFromDrawer(): Promise<void> {
|
||||
async function logout() {
|
||||
isMobileMenuOpen.value = false
|
||||
await logoutAndRedirect()
|
||||
await authStore.logout()
|
||||
const router = useRouter()
|
||||
await router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -60,20 +76,22 @@ async function logoutFromDrawer(): Promise<void> {
|
||||
<VApp>
|
||||
<AppLoadingIndicator ref="refLoadingIndicator" />
|
||||
|
||||
<!-- Navbar: only shown when authenticated -->
|
||||
<VAppBar
|
||||
v-if="authStore.isAuthenticated"
|
||||
flat
|
||||
color="surface"
|
||||
border="b"
|
||||
density="comfortable"
|
||||
height="64"
|
||||
>
|
||||
<VContainer
|
||||
fluid
|
||||
class="d-flex align-center py-0"
|
||||
style="max-inline-size: 1440px;"
|
||||
>
|
||||
<!-- Logo & Brand -->
|
||||
<!-- Left section: Logo + Event Switcher -->
|
||||
<RouterLink
|
||||
to="/"
|
||||
to="/dashboard"
|
||||
class="d-flex align-center gap-x-2 text-decoration-none flex-shrink-0"
|
||||
>
|
||||
<VIcon
|
||||
@@ -81,32 +99,27 @@ async function logoutFromDrawer(): Promise<void> {
|
||||
size="26"
|
||||
color="primary"
|
||||
/>
|
||||
<span class="text-h6 font-weight-bold text-high-emphasis">
|
||||
<span class="text-h6 font-weight-bold text-high-emphasis d-none d-sm-inline">
|
||||
Crewli
|
||||
</span>
|
||||
</RouterLink>
|
||||
|
||||
<EventSwitcher
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="min-w-0 flex-grow-1 flex-sm-grow-0"
|
||||
/>
|
||||
<EventSwitcher class="min-w-0 flex-grow-1 flex-sm-grow-0" />
|
||||
|
||||
<!-- Mobile nav toggle -->
|
||||
<VAppBarNavIcon
|
||||
class="d-sm-none ms-auto"
|
||||
@click="isMobileMenuOpen = !isMobileMenuOpen"
|
||||
/>
|
||||
|
||||
<!-- Desktop navigation -->
|
||||
<div class="d-none d-sm-flex align-center gap-1 ms-6">
|
||||
<!-- Center section: Desktop menu items -->
|
||||
<div
|
||||
v-if="menuItems.length > 0"
|
||||
class="d-none d-md-flex align-center gap-1 ms-4"
|
||||
>
|
||||
<VBtn
|
||||
v-for="item in navItems"
|
||||
v-for="item in menuItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
variant="text"
|
||||
color="default"
|
||||
size="small"
|
||||
class="text-medium-emphasis"
|
||||
exact
|
||||
class="portal-nav-btn"
|
||||
>
|
||||
<VIcon
|
||||
start
|
||||
@@ -119,48 +132,54 @@ async function logoutFromDrawer(): Promise<void> {
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<!-- Auth actions -->
|
||||
<VBtn
|
||||
v-if="authStore.isAuthenticated"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
size="small"
|
||||
@click="logoutAndRedirect"
|
||||
>
|
||||
<VIcon
|
||||
start
|
||||
icon="tabler-logout"
|
||||
size="18"
|
||||
/>
|
||||
Uitloggen
|
||||
</VBtn>
|
||||
<!-- Right section: Avatar menu (desktop) -->
|
||||
<div class="d-none d-md-flex align-center">
|
||||
<UserAvatarMenu />
|
||||
</div>
|
||||
|
||||
<VBtn
|
||||
v-else
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
size="small"
|
||||
to="/login"
|
||||
>
|
||||
<VIcon
|
||||
start
|
||||
icon="tabler-login"
|
||||
size="18"
|
||||
/>
|
||||
Inloggen
|
||||
</VBtn>
|
||||
<!-- Mobile nav toggle -->
|
||||
<VAppBarNavIcon
|
||||
class="d-md-none"
|
||||
@click="isMobileMenuOpen = !isMobileMenuOpen"
|
||||
/>
|
||||
</VContainer>
|
||||
</VAppBar>
|
||||
|
||||
<!-- Mobile navigation drawer -->
|
||||
<VNavigationDrawer
|
||||
v-if="authStore.isAuthenticated"
|
||||
v-model="isMobileMenuOpen"
|
||||
temporary
|
||||
class="d-sm-none"
|
||||
location="end"
|
||||
class="d-md-none"
|
||||
>
|
||||
<!-- User info header -->
|
||||
<div class="pa-4 pb-2">
|
||||
<div class="d-flex align-center gap-3">
|
||||
<VAvatar
|
||||
size="40"
|
||||
color="primary"
|
||||
>
|
||||
<span class="text-body-2 font-weight-medium text-white">
|
||||
{{ (authStore.user?.first_name?.charAt(0) ?? '') + (authStore.user?.last_name?.charAt(0) ?? '') }}
|
||||
</span>
|
||||
</VAvatar>
|
||||
<div class="min-w-0">
|
||||
<div class="text-body-1 font-weight-bold text-truncate">
|
||||
{{ authStore.user?.full_name }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis text-truncate">
|
||||
{{ authStore.user?.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VList nav>
|
||||
<VListItem
|
||||
v-for="item in navItems"
|
||||
v-for="item in mobileNavItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:prepend-icon="item.icon"
|
||||
@@ -171,18 +190,10 @@ async function logoutFromDrawer(): Promise<void> {
|
||||
<VDivider class="my-2" />
|
||||
|
||||
<VListItem
|
||||
v-if="authStore.isAuthenticated"
|
||||
prepend-icon="tabler-logout"
|
||||
title="Uitloggen"
|
||||
@click="logoutFromDrawer"
|
||||
/>
|
||||
|
||||
<VListItem
|
||||
v-else
|
||||
to="/login"
|
||||
prepend-icon="tabler-login"
|
||||
title="Inloggen"
|
||||
@click="isMobileMenuOpen = false"
|
||||
class="text-error"
|
||||
@click="logout"
|
||||
/>
|
||||
</VList>
|
||||
</VNavigationDrawer>
|
||||
@@ -204,5 +215,42 @@ async function logoutFromDrawer(): Promise<void> {
|
||||
</RouterView>
|
||||
</VContainer>
|
||||
</VMain>
|
||||
|
||||
<!-- Footer -->
|
||||
<VFooter
|
||||
app
|
||||
color="transparent"
|
||||
class="justify-center text-caption text-medium-emphasis py-3"
|
||||
>
|
||||
Powered by Crewli
|
||||
</VFooter>
|
||||
</VApp>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.portal-nav-btn {
|
||||
position: relative;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.portal-nav-btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: rgb(var(--v-theme-primary));
|
||||
transition: width 0.2s ease, left 0.2s ease;
|
||||
}
|
||||
|
||||
.portal-nav-btn.router-link-active {
|
||||
color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
|
||||
.portal-nav-btn.router-link-active::after {
|
||||
width: 60%;
|
||||
left: 20%;
|
||||
}
|
||||
</style>
|
||||
|
||||
426
apps/portal/src/pages/profiel.vue
Normal file
426
apps/portal/src/pages/profiel.vue
Normal file
@@ -0,0 +1,426 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { usePortalStore } from '@/stores/usePortalStore'
|
||||
import { useUpdateProfile, useUpdatePassword } from '@/composables/api/usePortalProfile'
|
||||
|
||||
definePage({
|
||||
name: 'portal-profiel',
|
||||
meta: {
|
||||
layout: 'portal',
|
||||
requiresAuth: true,
|
||||
hideEventMenu: true,
|
||||
},
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const portal = usePortalStore()
|
||||
const router = useRouter()
|
||||
|
||||
const updateProfileMutation = useUpdateProfile()
|
||||
const updatePasswordMutation = useUpdatePassword()
|
||||
|
||||
const snackbar = ref(false)
|
||||
const snackbarMessage = ref('')
|
||||
const snackbarColor = ref('success')
|
||||
|
||||
// Profile form
|
||||
const profileForm = ref({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: '',
|
||||
date_of_birth: '',
|
||||
})
|
||||
const profileError = ref<string | null>(null)
|
||||
|
||||
// Password form
|
||||
const passwordForm = ref({
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
})
|
||||
const passwordError = ref<string | null>(null)
|
||||
const passwordFieldErrors = ref<Record<string, string>>({})
|
||||
const showCurrentPassword = ref(false)
|
||||
const showNewPassword = ref(false)
|
||||
const showConfirmPassword = ref(false)
|
||||
|
||||
// Populate profile form from auth user / current person data
|
||||
watch(
|
||||
[() => authStore.user, () => portal.currentPerson],
|
||||
([user, person]) => {
|
||||
profileForm.value = {
|
||||
first_name: person?.first_name ?? user?.first_name ?? '',
|
||||
last_name: person?.last_name ?? user?.last_name ?? '',
|
||||
phone: person?.phone ?? '',
|
||||
date_of_birth: person?.date_of_birth ?? '',
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Status helpers for event list
|
||||
function statusColor(status: string): string {
|
||||
if (status === 'approved') return 'success'
|
||||
if (status === 'pending' || status === 'applied' || status === 'invited') return 'warning'
|
||||
if (status === 'rejected') return 'error'
|
||||
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'In behandeling',
|
||||
applied: 'In behandeling',
|
||||
invited: 'Uitgenodigd',
|
||||
approved: 'Goedgekeurd',
|
||||
rejected: 'Afgewezen',
|
||||
no_show: 'Niet verschenen',
|
||||
}
|
||||
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
function formatEventDates(startDate: string, endDate: string): string {
|
||||
try {
|
||||
const start = new Date(`${startDate}T12:00:00`)
|
||||
const end = new Date(`${endDate}T12:00:00`)
|
||||
const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', year: 'numeric' }
|
||||
|
||||
return `${start.toLocaleDateString('nl-NL', opts)} – ${end.toLocaleDateString('nl-NL', opts)}`
|
||||
}
|
||||
catch {
|
||||
return `${startDate} – ${endDate}`
|
||||
}
|
||||
}
|
||||
|
||||
function viewEvent(eventId: string) {
|
||||
portal.setActiveEvent(eventId)
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
profileError.value = null
|
||||
|
||||
if (!portal.activeEventId) return
|
||||
|
||||
try {
|
||||
const result = await updateProfileMutation.mutateAsync({
|
||||
event_id: portal.activeEventId,
|
||||
...profileForm.value,
|
||||
phone: profileForm.value.phone || null,
|
||||
date_of_birth: profileForm.value.date_of_birth || null,
|
||||
})
|
||||
|
||||
// Refresh person data and auth user
|
||||
await Promise.all([
|
||||
portal.fetchCurrentPerson(),
|
||||
authStore.fetchUser(),
|
||||
])
|
||||
|
||||
snackbarMessage.value = result.message
|
||||
snackbarColor.value = 'success'
|
||||
snackbar.value = true
|
||||
}
|
||||
catch (err: any) {
|
||||
const data = err?.response?.data
|
||||
if (data?.errors) {
|
||||
const firstError = Object.values(data.errors).flat()[0] as string
|
||||
profileError.value = firstError
|
||||
}
|
||||
else {
|
||||
profileError.value = data?.message ?? 'Er is een fout opgetreden.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
passwordError.value = null
|
||||
passwordFieldErrors.value = {}
|
||||
|
||||
try {
|
||||
const result = await updatePasswordMutation.mutateAsync(passwordForm.value)
|
||||
|
||||
passwordForm.value = {
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
}
|
||||
|
||||
snackbarMessage.value = result.message
|
||||
snackbarColor.value = 'success'
|
||||
snackbar.value = true
|
||||
}
|
||||
catch (err: any) {
|
||||
const data = err?.response?.data
|
||||
if (data?.errors) {
|
||||
passwordFieldErrors.value = {}
|
||||
for (const [key, messages] of Object.entries(data.errors)) {
|
||||
passwordFieldErrors.value[key] = (messages as string[])[0]
|
||||
}
|
||||
}
|
||||
else {
|
||||
passwordError.value = data?.message ?? 'Er is een fout opgetreden.'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VRow justify="center">
|
||||
<VCol
|
||||
cols="12"
|
||||
md="8"
|
||||
lg="6"
|
||||
>
|
||||
<h4 class="text-h4 mb-4">
|
||||
Mijn Profiel
|
||||
</h4>
|
||||
|
||||
<!-- Profile form -->
|
||||
<VCard class="mb-4">
|
||||
<VCardTitle>Persoonlijke gegevens</VCardTitle>
|
||||
<VCardText>
|
||||
<VAlert
|
||||
v-if="profileError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ profileError }}
|
||||
</VAlert>
|
||||
|
||||
<VForm @submit.prevent="saveProfile">
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="profileForm.first_name"
|
||||
label="Voornaam"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="profileForm.last_name"
|
||||
label="Achternaam"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
:model-value="authStore.user?.email"
|
||||
label="E-mailadres"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
readonly
|
||||
prepend-inner-icon="tabler-lock"
|
||||
/>
|
||||
<p class="text-caption text-medium-emphasis mt-1 mb-0">
|
||||
Je e-mailadres kan niet worden gewijzigd.
|
||||
</p>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="profileForm.phone"
|
||||
label="Telefoonnummer"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
prepend-inner-icon="tabler-phone"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="profileForm.date_of_birth"
|
||||
label="Geboortedatum"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
type="date"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<VIcon
|
||||
icon="tabler-calendar"
|
||||
size="20"
|
||||
/>
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<div class="d-flex justify-end mt-4">
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="updateProfileMutation.isPending.value"
|
||||
>
|
||||
Opslaan
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Password change -->
|
||||
<VCard class="mb-4">
|
||||
<VCardTitle>Wachtwoord wijzigen</VCardTitle>
|
||||
<VCardText>
|
||||
<VAlert
|
||||
v-if="passwordError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ passwordError }}
|
||||
</VAlert>
|
||||
|
||||
<VForm @submit.prevent="savePassword">
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="passwordForm.current_password"
|
||||
label="Huidig wachtwoord"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:type="showCurrentPassword ? 'text' : 'password'"
|
||||
:append-inner-icon="showCurrentPassword ? 'tabler-eye-off' : 'tabler-eye'"
|
||||
:error-messages="passwordFieldErrors.current_password"
|
||||
@click:append-inner="showCurrentPassword = !showCurrentPassword"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="passwordForm.password"
|
||||
label="Nieuw wachtwoord"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:type="showNewPassword ? 'text' : 'password'"
|
||||
:append-inner-icon="showNewPassword ? 'tabler-eye-off' : 'tabler-eye'"
|
||||
:error-messages="passwordFieldErrors.password"
|
||||
@click:append-inner="showNewPassword = !showNewPassword"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="passwordForm.password_confirmation"
|
||||
label="Bevestig nieuw wachtwoord"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
:append-inner-icon="showConfirmPassword ? 'tabler-eye-off' : 'tabler-eye'"
|
||||
:error-messages="passwordFieldErrors.password_confirmation"
|
||||
@click:append-inner="showConfirmPassword = !showConfirmPassword"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<div class="d-flex justify-end mt-4">
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="updatePasswordMutation.isPending.value"
|
||||
>
|
||||
Wachtwoord wijzigen
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- My events -->
|
||||
<VCard v-if="portal.userEvents.length > 0">
|
||||
<VCardTitle>Mijn evenementen</VCardTitle>
|
||||
<VCardText class="pa-0">
|
||||
<VList>
|
||||
<VListItem
|
||||
v-for="ev in portal.userEvents"
|
||||
:key="ev.event_id"
|
||||
class="py-3"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
icon="tabler-calendar-event"
|
||||
size="24"
|
||||
color="primary"
|
||||
class="me-1"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<VListItemTitle class="font-weight-medium">
|
||||
{{ ev.event_name }}
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle class="text-caption mt-1">
|
||||
{{ formatEventDates(ev.start_date, ev.end_date) }}
|
||||
</VListItemSubtitle>
|
||||
|
||||
<template #append>
|
||||
<div class="d-flex align-center gap-2">
|
||||
<VChip
|
||||
:color="statusColor(ev.person_status)"
|
||||
size="small"
|
||||
label
|
||||
>
|
||||
{{ statusLabel(ev.person_status) }}
|
||||
</VChip>
|
||||
<VBtn
|
||||
variant="text"
|
||||
color="primary"
|
||||
size="small"
|
||||
@click="viewEvent(ev.event_id)"
|
||||
>
|
||||
Bekijk
|
||||
</VBtn>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- No events -->
|
||||
<VAlert
|
||||
v-else
|
||||
type="info"
|
||||
variant="tonal"
|
||||
>
|
||||
Je bent nog niet aangemeld voor een evenement.
|
||||
</VAlert>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<VSnackbar
|
||||
v-model="snackbar"
|
||||
:color="snackbarColor"
|
||||
:timeout="4000"
|
||||
>
|
||||
{{ snackbarMessage }}
|
||||
</VSnackbar>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</template>
|
||||
@@ -1,389 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/useAuthStore'
|
||||
import { usePortalStore } from '@/stores/usePortalStore'
|
||||
import { useUpdateProfile, useUpdatePassword } from '@/composables/api/usePortalProfile'
|
||||
|
||||
definePage({
|
||||
name: 'portal-profile',
|
||||
meta: {
|
||||
layout: 'portal',
|
||||
requiresAuth: true,
|
||||
},
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const portal = usePortalStore()
|
||||
|
||||
const updateProfileMutation = useUpdateProfile()
|
||||
const updatePasswordMutation = useUpdatePassword()
|
||||
|
||||
const snackbar = ref(false)
|
||||
const snackbarMessage = ref('')
|
||||
const snackbarColor = ref('success')
|
||||
|
||||
// Profile form
|
||||
const profileForm = ref({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: '',
|
||||
date_of_birth: '',
|
||||
remarks: '',
|
||||
})
|
||||
const profileError = ref<string | null>(null)
|
||||
|
||||
// Password form
|
||||
const passwordForm = ref({
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
})
|
||||
const passwordError = ref<string | null>(null)
|
||||
const passwordFieldErrors = ref<Record<string, string>>({})
|
||||
const showCurrentPassword = ref(false)
|
||||
const showNewPassword = ref(false)
|
||||
const showConfirmPassword = ref(false)
|
||||
|
||||
// Populate profile form from current person data
|
||||
watch(() => portal.currentPerson, (person) => {
|
||||
if (person) {
|
||||
profileForm.value = {
|
||||
first_name: person.first_name ?? authStore.user?.first_name ?? '',
|
||||
last_name: person.last_name ?? authStore.user?.last_name ?? '',
|
||||
phone: person.phone ?? '',
|
||||
date_of_birth: person.date_of_birth ?? '',
|
||||
remarks: person.remarks ?? '',
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const statusConfig: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: 'In afwachting', color: 'warning' },
|
||||
approved: { label: 'Goedgekeurd', color: 'success' },
|
||||
rejected: { label: 'Afgewezen', color: 'error' },
|
||||
}
|
||||
|
||||
const effectiveStatus = computed(() => {
|
||||
const s = portal.currentPerson?.status ?? portal.activeEvent?.person_status ?? 'pending'
|
||||
|
||||
return statusConfig[s] ?? statusConfig.pending
|
||||
})
|
||||
|
||||
async function saveProfile() {
|
||||
profileError.value = null
|
||||
|
||||
if (!portal.activeEventId) return
|
||||
|
||||
try {
|
||||
const result = await updateProfileMutation.mutateAsync({
|
||||
event_id: portal.activeEventId,
|
||||
...profileForm.value,
|
||||
phone: profileForm.value.phone || null,
|
||||
date_of_birth: profileForm.value.date_of_birth || null,
|
||||
remarks: profileForm.value.remarks || null,
|
||||
})
|
||||
|
||||
// Refresh person data and auth user
|
||||
await Promise.all([
|
||||
portal.fetchCurrentPerson(),
|
||||
authStore.fetchUser(),
|
||||
])
|
||||
|
||||
snackbarMessage.value = result.message
|
||||
snackbarColor.value = 'success'
|
||||
snackbar.value = true
|
||||
}
|
||||
catch (err: any) {
|
||||
const data = err?.response?.data
|
||||
if (data?.errors) {
|
||||
const firstError = Object.values(data.errors).flat()[0] as string
|
||||
profileError.value = firstError
|
||||
}
|
||||
else {
|
||||
profileError.value = data?.message ?? 'Er is een fout opgetreden.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
passwordError.value = null
|
||||
passwordFieldErrors.value = {}
|
||||
|
||||
try {
|
||||
const result = await updatePasswordMutation.mutateAsync(passwordForm.value)
|
||||
|
||||
passwordForm.value = {
|
||||
current_password: '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
}
|
||||
|
||||
snackbarMessage.value = result.message
|
||||
snackbarColor.value = 'success'
|
||||
snackbar.value = true
|
||||
}
|
||||
catch (err: any) {
|
||||
const data = err?.response?.data
|
||||
if (data?.errors) {
|
||||
passwordFieldErrors.value = {}
|
||||
for (const [key, messages] of Object.entries(data.errors)) {
|
||||
passwordFieldErrors.value[key] = (messages as string[])[0]
|
||||
}
|
||||
}
|
||||
else {
|
||||
passwordError.value = data?.message ?? 'Er is een fout opgetreden.'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VRow justify="center">
|
||||
<VCol
|
||||
cols="12"
|
||||
md="8"
|
||||
lg="6"
|
||||
>
|
||||
<h4 class="text-h4 mb-4">
|
||||
Mijn Profiel
|
||||
</h4>
|
||||
|
||||
<!-- Loading state -->
|
||||
<VSkeletonLoader
|
||||
v-if="portal.isLoadingPerson && !portal.currentPerson"
|
||||
type="card"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<template v-else-if="portal.currentPerson">
|
||||
<!-- Status & Event info -->
|
||||
<VCard class="mb-4">
|
||||
<VCardText>
|
||||
<div class="d-flex align-center justify-space-between flex-wrap gap-2">
|
||||
<div>
|
||||
<div class="text-body-2 text-medium-emphasis">
|
||||
Evenement
|
||||
</div>
|
||||
<div class="text-body-1 font-weight-medium">
|
||||
{{ portal.activeEvent?.event_name }}
|
||||
</div>
|
||||
</div>
|
||||
<VChip
|
||||
:color="effectiveStatus.color"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{ effectiveStatus.label }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Profile form -->
|
||||
<VCard class="mb-4">
|
||||
<VCardTitle>Persoonlijke gegevens</VCardTitle>
|
||||
<VCardText>
|
||||
<VAlert
|
||||
v-if="profileError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ profileError }}
|
||||
</VAlert>
|
||||
|
||||
<VForm @submit.prevent="saveProfile">
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="profileForm.first_name"
|
||||
label="Voornaam"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="profileForm.last_name"
|
||||
label="Achternaam"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
:model-value="portal.currentPerson.email || authStore.user?.email"
|
||||
label="E-mailadres"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
readonly
|
||||
prepend-inner-icon="tabler-lock"
|
||||
/>
|
||||
<p class="text-caption text-medium-emphasis mt-1 mb-0">
|
||||
Je e-mailadres kan niet worden gewijzigd.
|
||||
</p>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="profileForm.phone"
|
||||
label="Telefoonnummer"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
prepend-inner-icon="tabler-phone"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="profileForm.date_of_birth"
|
||||
label="Geboortedatum"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
type="date"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<VIcon
|
||||
icon="tabler-calendar"
|
||||
size="20"
|
||||
/>
|
||||
</template>
|
||||
</VTextField>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextarea
|
||||
v-model="profileForm.remarks"
|
||||
label="Opmerkingen"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
rows="3"
|
||||
placeholder="Allergieën, dieetwensen, overige opmerkingen..."
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<div class="d-flex justify-end mt-4">
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="updateProfileMutation.isPending.value"
|
||||
>
|
||||
Opslaan
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<!-- Password change -->
|
||||
<VCard>
|
||||
<VCardTitle>Wachtwoord wijzigen</VCardTitle>
|
||||
<VCardText>
|
||||
<VAlert
|
||||
v-if="passwordError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-4"
|
||||
>
|
||||
{{ passwordError }}
|
||||
</VAlert>
|
||||
|
||||
<VForm @submit.prevent="savePassword">
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="passwordForm.current_password"
|
||||
label="Huidig wachtwoord"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:type="showCurrentPassword ? 'text' : 'password'"
|
||||
:append-inner-icon="showCurrentPassword ? 'tabler-eye-off' : 'tabler-eye'"
|
||||
:error-messages="passwordFieldErrors.current_password"
|
||||
@click:append-inner="showCurrentPassword = !showCurrentPassword"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="passwordForm.password"
|
||||
label="Nieuw wachtwoord"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:type="showNewPassword ? 'text' : 'password'"
|
||||
:append-inner-icon="showNewPassword ? 'tabler-eye-off' : 'tabler-eye'"
|
||||
:error-messages="passwordFieldErrors.password"
|
||||
@click:append-inner="showNewPassword = !showNewPassword"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VTextField
|
||||
v-model="passwordForm.password_confirmation"
|
||||
label="Bevestig nieuw wachtwoord"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details="auto"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
:append-inner-icon="showConfirmPassword ? 'tabler-eye-off' : 'tabler-eye'"
|
||||
:error-messages="passwordFieldErrors.password_confirmation"
|
||||
@click:append-inner="showConfirmPassword = !showConfirmPassword"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<div class="d-flex justify-end mt-4">
|
||||
<VBtn
|
||||
type="submit"
|
||||
color="primary"
|
||||
:loading="updatePasswordMutation.isPending.value"
|
||||
>
|
||||
Wachtwoord wijzigen
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</template>
|
||||
|
||||
<VAlert
|
||||
v-else
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
>
|
||||
We konden je profiel niet laden. Probeer het later opnieuw.
|
||||
</VAlert>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<VSnackbar
|
||||
v-model="snackbar"
|
||||
:color="snackbarColor"
|
||||
:timeout="4000"
|
||||
>
|
||||
{{ snackbarMessage }}
|
||||
</VSnackbar>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</template>
|
||||
@@ -21,8 +21,9 @@ import type {
|
||||
definePage({
|
||||
name: 'volunteer-register',
|
||||
meta: {
|
||||
layout: 'blank',
|
||||
layout: 'portal',
|
||||
requiresAuth: false,
|
||||
hideEventMenu: true,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useAuthStore } from '@/stores/useAuthStore'
|
||||
definePage({
|
||||
name: 'register-success',
|
||||
meta: {
|
||||
layout: 'blank',
|
||||
layout: 'portal',
|
||||
requiresAuth: false,
|
||||
hideEventMenu: true,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
2
apps/portal/typed-router.d.ts
vendored
2
apps/portal/typed-router.d.ts
vendored
@@ -25,7 +25,7 @@ declare module 'vue-router/auto-routes' {
|
||||
'portal-claim-shifts': RouteRecordInfo<'portal-claim-shifts', '/dashboard/claim-shifts', Record<never, never>, Record<never, never>>,
|
||||
'portal-my-shifts': RouteRecordInfo<'portal-my-shifts', '/dashboard/my-shifts', Record<never, never>, Record<never, never>>,
|
||||
'login': RouteRecordInfo<'login', '/login', Record<never, never>, Record<never, never>>,
|
||||
'portal-profile': RouteRecordInfo<'portal-profile', '/profile', Record<never, never>, Record<never, never>>,
|
||||
'portal-profiel': RouteRecordInfo<'portal-profiel', '/profiel', Record<never, never>, Record<never, never>>,
|
||||
'volunteer-register': RouteRecordInfo<'volunteer-register', '/register/:eventSlug', { eventSlug: ParamValue<true> }, { eventSlug: ParamValue<false> }>,
|
||||
'register-success': RouteRecordInfo<'register-success', '/register/success', Record<never, never>, Record<never, never>>,
|
||||
'volunteer-register-info': RouteRecordInfo<'volunteer-register-info', '/registreren', Record<never, never>, Record<never, never>>,
|
||||
|
||||
Reference in New Issue
Block a user