- useMembers.ts gains a scope param ('organisation' | 'platform') on list,
invite, update-role, and remove; endpoints branch accordingly.
- Platform Admin's [id].vue now consumes useMembers via scope='platform';
deleted the duplicated useInviteOrganisationMember / useRemoveOrganisationMember
/ useUpdateOrganisationMemberRole helpers from useAdmin.ts.
- Deduplicated InviteMemberPayload / UpdateMemberRolePayload / AdminOrganisationMember
from types/admin.ts; Member is now the canonical type.
- SettingsMembers.vue and EditMemberRoleDialog.vue removed (no remaining imports).
- InviteMemberDialog accepts an optional scope prop and is restricted to the
two organisation-level roles matching the /members UX.
618 lines
17 KiB
Vue
618 lines
17 KiB
Vue
<script setup lang="ts">
|
|
import { usePersonDetail, useUpdatePerson } from '@/composables/api/usePersons'
|
|
import { useConfirmMatch, useDismissMatch, useManualLinkPerson, useUnlinkPerson } from '@/composables/api/useIdentityMatches'
|
|
import { useMembersList } from '@/composables/api/useMembers'
|
|
import type { Person, PersonStatus } from '@/types/person'
|
|
import type { Member } from '@/types/member'
|
|
|
|
const props = defineProps<{
|
|
eventId: string
|
|
orgId: string
|
|
personId: string | null
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
edit: [person: Person]
|
|
}>()
|
|
|
|
const modelValue = defineModel<boolean>({ required: true })
|
|
|
|
const orgIdRef = computed(() => props.orgId)
|
|
const eventIdRef = computed(() => props.eventId)
|
|
const personIdRef = computed(() => props.personId ?? '')
|
|
|
|
const { data: person, isLoading } = usePersonDetail(orgIdRef, eventIdRef, personIdRef)
|
|
const { mutate: updatePerson } = useUpdatePerson(orgIdRef, eventIdRef)
|
|
|
|
// Identity matching mutations
|
|
const { mutate: confirmMatch, isPending: isConfirming } = useConfirmMatch(orgIdRef)
|
|
const { mutate: dismissMatch, isPending: isDismissing } = useDismissMatch(orgIdRef)
|
|
const { mutate: manualLink, isPending: isManualLinking } = useManualLinkPerson(orgIdRef, eventIdRef)
|
|
const { mutate: unlinkPerson, isPending: isUnlinking } = useUnlinkPerson(orgIdRef, eventIdRef)
|
|
|
|
// Members for manual link
|
|
const { data: membersResponse } = useMembersList('organisation', orgIdRef)
|
|
|
|
const activeTab = ref('info')
|
|
|
|
const statusColor: Record<PersonStatus, string> = {
|
|
pending: 'default',
|
|
applied: 'info',
|
|
invited: 'cyan',
|
|
approved: 'success',
|
|
rejected: 'error',
|
|
no_show: 'warning',
|
|
}
|
|
|
|
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
})
|
|
|
|
const dobFormatter = new Intl.DateTimeFormat('nl-NL', {
|
|
day: 'numeric',
|
|
month: 'long',
|
|
year: 'numeric',
|
|
})
|
|
|
|
function formatDate(iso: string) {
|
|
return dateFormatter.format(new Date(iso))
|
|
}
|
|
|
|
function formatDateOfBirth(dateStr: string) {
|
|
return dobFormatter.format(new Date(`${dateStr}T00:00:00`))
|
|
}
|
|
|
|
/** Age in full years from a YYYY-MM-DD string; null if invalid or out of range. */
|
|
function calculateAge(dateStr: string): number | null {
|
|
const birth = new Date(`${dateStr}T12:00:00`)
|
|
if (Number.isNaN(birth.getTime())) {
|
|
return null
|
|
}
|
|
const today = new Date()
|
|
let age = today.getFullYear() - birth.getFullYear()
|
|
const monthDiff = today.getMonth() - birth.getMonth()
|
|
const dayDiff = today.getDate() - birth.getDate()
|
|
if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) {
|
|
age--
|
|
}
|
|
if (age < 0 || age > 130) {
|
|
return null
|
|
}
|
|
return age
|
|
}
|
|
|
|
function formatDateOfBirthDisplay(dateStr: string): string {
|
|
const formatted = formatDateOfBirth(dateStr)
|
|
const age = calculateAge(dateStr)
|
|
return age !== null ? `${formatted} (${age} jaar)` : formatted
|
|
}
|
|
|
|
function getInitials(name: string) {
|
|
return name
|
|
.split(' ')
|
|
.map(p => p[0])
|
|
.join('')
|
|
.toUpperCase()
|
|
.slice(0, 2)
|
|
}
|
|
|
|
// Admin notes inline edit
|
|
const adminNotes = ref('')
|
|
|
|
watch(() => person.value?.admin_notes, (val) => {
|
|
adminNotes.value = val ?? ''
|
|
}, { immediate: true })
|
|
|
|
function onAdminNotesBlur() {
|
|
if (!person.value) return
|
|
if (adminNotes.value === (person.value.admin_notes ?? '')) return
|
|
|
|
updatePerson({
|
|
id: person.value.id,
|
|
admin_notes: adminNotes.value || undefined,
|
|
})
|
|
}
|
|
|
|
// Blacklisted toggle
|
|
function onBlacklistToggle(val: boolean | null) {
|
|
if (!person.value) return
|
|
|
|
updatePerson({
|
|
id: person.value.id,
|
|
is_blacklisted: !!val,
|
|
})
|
|
}
|
|
|
|
// Identity matching handlers
|
|
function handleConfirm() {
|
|
if (!person.value?.pending_identity_match) return
|
|
confirmMatch(person.value.pending_identity_match.match_id)
|
|
}
|
|
|
|
function handleDismiss() {
|
|
if (!person.value?.pending_identity_match) return
|
|
dismissMatch(person.value.pending_identity_match.match_id)
|
|
}
|
|
|
|
// Unlink confirmation
|
|
const showUnlinkConfirm = ref(false)
|
|
|
|
function handleUnlink() {
|
|
if (!person.value) return
|
|
unlinkPerson(person.value.id, {
|
|
onSuccess: () => {
|
|
showUnlinkConfirm.value = false
|
|
},
|
|
})
|
|
}
|
|
|
|
// Manual link dialog
|
|
const showManualLinkDialog = ref(false)
|
|
const linkSearchQuery = ref('')
|
|
|
|
const filteredOrgMembers = computed<Member[]>(() => {
|
|
const members = membersResponse.value?.data ?? []
|
|
const query = linkSearchQuery.value.toLowerCase().trim()
|
|
|
|
if (!query) return members
|
|
|
|
return members.filter(
|
|
m => m.full_name.toLowerCase().includes(query) || m.email.toLowerCase().includes(query),
|
|
)
|
|
})
|
|
|
|
function handleManualLink(userId: string) {
|
|
if (!person.value) return
|
|
manualLink(
|
|
{ personId: person.value.id, userId },
|
|
{
|
|
onSuccess: () => {
|
|
showManualLinkDialog.value = false
|
|
linkSearchQuery.value = ''
|
|
},
|
|
},
|
|
)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<VNavigationDrawer
|
|
v-model="modelValue"
|
|
location="end"
|
|
temporary
|
|
:width="480"
|
|
>
|
|
<!-- Loading -->
|
|
<div
|
|
v-if="isLoading"
|
|
class="pa-6"
|
|
>
|
|
<VSkeletonLoader type="article" />
|
|
</div>
|
|
|
|
<template v-else-if="person">
|
|
<!-- Header -->
|
|
<div class="pa-6">
|
|
<div class="d-flex justify-space-between align-start mb-4">
|
|
<div class="d-flex align-center gap-x-4">
|
|
<VAvatar
|
|
size="56"
|
|
color="primary"
|
|
variant="tonal"
|
|
>
|
|
<span class="text-h6">{{ getInitials(person.full_name) }}</span>
|
|
</VAvatar>
|
|
<div>
|
|
<h5 class="text-h5 mb-1">
|
|
{{ person.full_name }}
|
|
</h5>
|
|
<p class="text-body-2 text-disabled mb-2">
|
|
{{ person.email }}
|
|
</p>
|
|
<div class="d-flex gap-x-2">
|
|
<VChip
|
|
:color="statusColor[person.status]"
|
|
size="small"
|
|
>
|
|
{{ person.status }}
|
|
</VChip>
|
|
<VChip
|
|
v-if="person.crowd_type"
|
|
:color="person.crowd_type.color"
|
|
size="small"
|
|
>
|
|
{{ person.crowd_type.name }}
|
|
</VChip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex gap-x-1">
|
|
<VBtn
|
|
icon="tabler-edit"
|
|
variant="text"
|
|
size="small"
|
|
title="Bewerken"
|
|
@click="emit('edit', person)"
|
|
/>
|
|
<VBtn
|
|
icon="tabler-x"
|
|
variant="text"
|
|
size="small"
|
|
title="Sluiten"
|
|
@click="modelValue = false"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Identity matching banners -->
|
|
<!-- State 1: Pending match detected -->
|
|
<VAlert
|
|
v-if="person.pending_identity_match"
|
|
:type="person.pending_identity_match.confidence === 'high' ? 'info' : 'warning'"
|
|
variant="tonal"
|
|
class="mb-3"
|
|
>
|
|
<div class="d-flex align-center justify-space-between flex-wrap ga-2">
|
|
<div>
|
|
<div class="font-weight-medium">
|
|
Mogelijke match gevonden
|
|
<VChip
|
|
size="x-small"
|
|
class="ml-1"
|
|
variant="outlined"
|
|
>
|
|
{{ person.pending_identity_match.confidence_label }}
|
|
</VChip>
|
|
</div>
|
|
<div class="text-body-2 mt-1">
|
|
{{ person.pending_identity_match.matched_on_label }}:
|
|
<strong>{{ person.pending_identity_match.matched_user.full_name }}</strong>
|
|
({{ person.pending_identity_match.matched_user.email }})
|
|
</div>
|
|
<div
|
|
v-if="person.pending_identity_match.match_details?.matched_fields?.includes('date_of_birth')"
|
|
class="text-caption mt-1 text-success"
|
|
>
|
|
Geboortedatum komt overeen
|
|
</div>
|
|
<div
|
|
v-if="person.pending_identity_match.match_details?.matched_fields?.some((f: string) => f.includes('fuzzy'))"
|
|
class="text-caption mt-1 text-warning"
|
|
>
|
|
Naam is vergelijkbaar maar niet exact
|
|
</div>
|
|
</div>
|
|
<div class="d-flex ga-2">
|
|
<VBtn
|
|
size="small"
|
|
color="success"
|
|
variant="flat"
|
|
:loading="isConfirming"
|
|
@click="handleConfirm"
|
|
>
|
|
<VIcon
|
|
start
|
|
size="16"
|
|
>
|
|
tabler-check
|
|
</VIcon>
|
|
Koppelen
|
|
</VBtn>
|
|
<VBtn
|
|
size="small"
|
|
color="grey"
|
|
variant="tonal"
|
|
:loading="isDismissing"
|
|
@click="handleDismiss"
|
|
>
|
|
Niet dezelfde persoon
|
|
</VBtn>
|
|
</div>
|
|
</div>
|
|
</VAlert>
|
|
|
|
<!-- State 2: Linked (has_user_account) -->
|
|
<VAlert
|
|
v-else-if="person.has_user_account"
|
|
type="success"
|
|
variant="tonal"
|
|
density="compact"
|
|
class="mb-3"
|
|
>
|
|
<div class="d-flex align-center justify-space-between">
|
|
<div>
|
|
<VIcon
|
|
start
|
|
size="16"
|
|
>
|
|
tabler-link
|
|
</VIcon>
|
|
Gekoppeld aan platformaccount:
|
|
<strong>{{ person.user_account?.full_name }}</strong>
|
|
({{ person.user_account?.email }})
|
|
</div>
|
|
<VBtn
|
|
size="x-small"
|
|
variant="text"
|
|
color="error"
|
|
@click="showUnlinkConfirm = true"
|
|
>
|
|
Ontkoppelen
|
|
</VBtn>
|
|
</div>
|
|
</VAlert>
|
|
|
|
<!-- State 3: Not linked -->
|
|
<VAlert
|
|
v-else
|
|
type="warning"
|
|
variant="tonal"
|
|
density="compact"
|
|
class="mb-3"
|
|
>
|
|
<div class="d-flex align-center justify-space-between">
|
|
<div>
|
|
<VIcon
|
|
start
|
|
size="16"
|
|
>
|
|
tabler-unlink
|
|
</VIcon>
|
|
Niet gekoppeld aan een platformaccount.
|
|
Kan niet inloggen op het portaal.
|
|
</div>
|
|
<VBtn
|
|
size="x-small"
|
|
variant="tonal"
|
|
color="primary"
|
|
@click="showManualLinkDialog = true"
|
|
>
|
|
Handmatig koppelen
|
|
</VBtn>
|
|
</div>
|
|
</VAlert>
|
|
</div>
|
|
|
|
<VDivider />
|
|
|
|
<!-- Tabs -->
|
|
<VTabs
|
|
v-model="activeTab"
|
|
class="px-6"
|
|
>
|
|
<VTab value="info">
|
|
Informatie
|
|
</VTab>
|
|
<VTab value="shifts">
|
|
Shifts
|
|
</VTab>
|
|
<VTab value="accreditation">
|
|
Accreditatie
|
|
</VTab>
|
|
</VTabs>
|
|
|
|
<VDivider />
|
|
|
|
<VTabsWindow
|
|
v-model="activeTab"
|
|
class="pa-6"
|
|
>
|
|
<!-- Tab: Informatie -->
|
|
<VTabsWindowItem value="info">
|
|
<VList class="pa-0">
|
|
<VListItem>
|
|
<template #prepend>
|
|
<VIcon
|
|
icon="tabler-cake"
|
|
class="me-3"
|
|
/>
|
|
</template>
|
|
<VListItemTitle>Geboortedatum</VListItemTitle>
|
|
<VListItemSubtitle>{{ person.date_of_birth ? formatDateOfBirthDisplay(person.date_of_birth) : 'Niet opgegeven' }}</VListItemSubtitle>
|
|
</VListItem>
|
|
|
|
<VListItem>
|
|
<template #prepend>
|
|
<VIcon
|
|
icon="tabler-phone"
|
|
class="me-3"
|
|
/>
|
|
</template>
|
|
<VListItemTitle>Telefoonnummer</VListItemTitle>
|
|
<VListItemSubtitle>{{ person.phone ?? 'Niet opgegeven' }}</VListItemSubtitle>
|
|
</VListItem>
|
|
|
|
<VListItem>
|
|
<template #prepend>
|
|
<VIcon
|
|
icon="tabler-building"
|
|
class="me-3"
|
|
/>
|
|
</template>
|
|
<VListItemTitle>Bedrijf</VListItemTitle>
|
|
<VListItemSubtitle>{{ person.company?.name ?? 'Geen bedrijf' }}</VListItemSubtitle>
|
|
</VListItem>
|
|
|
|
<VListItem>
|
|
<template #prepend>
|
|
<VIcon
|
|
icon="tabler-calendar"
|
|
class="me-3"
|
|
/>
|
|
</template>
|
|
<VListItemTitle>Aangemaakt op</VListItemTitle>
|
|
<VListItemSubtitle>{{ formatDate(person.created_at) }}</VListItemSubtitle>
|
|
</VListItem>
|
|
</VList>
|
|
|
|
<VDivider class="my-4" />
|
|
|
|
<!-- Admin notes -->
|
|
<h6 class="text-h6 mb-2">
|
|
Admin notities
|
|
</h6>
|
|
<VTextarea
|
|
v-model="adminNotes"
|
|
variant="outlined"
|
|
rows="3"
|
|
placeholder="Notities over deze persoon..."
|
|
@blur="onAdminNotesBlur"
|
|
/>
|
|
|
|
<VDivider class="my-4" />
|
|
|
|
<!-- Blacklisted toggle -->
|
|
<VSwitch
|
|
:model-value="person.is_blacklisted"
|
|
label="Geblokkeerd"
|
|
color="error"
|
|
@update:model-value="onBlacklistToggle"
|
|
/>
|
|
</VTabsWindowItem>
|
|
|
|
<!-- Tab: Shifts (placeholder) -->
|
|
<VTabsWindowItem value="shifts">
|
|
<VCard
|
|
variant="outlined"
|
|
class="text-center pa-8"
|
|
>
|
|
<VIcon
|
|
icon="tabler-clock"
|
|
size="48"
|
|
class="mb-4 text-disabled"
|
|
/>
|
|
<p class="text-body-1 text-disabled">
|
|
Shift toewijzingen worden hier getoond zodra de shift planning module beschikbaar is.
|
|
</p>
|
|
</VCard>
|
|
</VTabsWindowItem>
|
|
|
|
<!-- Tab: Accreditatie (placeholder) -->
|
|
<VTabsWindowItem value="accreditation">
|
|
<VCard
|
|
variant="outlined"
|
|
class="text-center pa-8"
|
|
>
|
|
<VIcon
|
|
icon="tabler-id-badge-2"
|
|
size="48"
|
|
class="mb-4 text-disabled"
|
|
/>
|
|
<p class="text-body-1 text-disabled">
|
|
Accreditatie wordt hier getoond zodra de accreditatie module beschikbaar is.
|
|
</p>
|
|
</VCard>
|
|
</VTabsWindowItem>
|
|
</VTabsWindow>
|
|
</template>
|
|
|
|
<!-- Unlink confirmation dialog -->
|
|
<VDialog
|
|
v-model="showUnlinkConfirm"
|
|
max-width="420"
|
|
>
|
|
<VCard>
|
|
<VCardTitle class="text-h6 pt-5 px-5">
|
|
Koppeling verbreken?
|
|
</VCardTitle>
|
|
<VCardText class="px-5">
|
|
Weet je zeker dat je de koppeling met
|
|
<strong>{{ person?.user_account?.email }}</strong> wilt verbreken?
|
|
<br><br>
|
|
<strong>Let op:</strong> deze persoon kan daarna niet meer inloggen
|
|
op het portaal en ziet geen shifts meer.
|
|
</VCardText>
|
|
<VCardActions class="px-5 pb-5">
|
|
<VSpacer />
|
|
<VBtn
|
|
variant="tonal"
|
|
@click="showUnlinkConfirm = false"
|
|
>
|
|
Annuleren
|
|
</VBtn>
|
|
<VBtn
|
|
color="error"
|
|
variant="flat"
|
|
:loading="isUnlinking"
|
|
@click="handleUnlink"
|
|
>
|
|
Ontkoppelen
|
|
</VBtn>
|
|
</VCardActions>
|
|
</VCard>
|
|
</VDialog>
|
|
|
|
<!-- Manual link dialog -->
|
|
<VDialog
|
|
v-model="showManualLinkDialog"
|
|
max-width="520"
|
|
>
|
|
<VCard>
|
|
<VCardTitle class="text-h6 pt-5 px-5">
|
|
Handmatig koppelen
|
|
</VCardTitle>
|
|
<VCardText class="px-5">
|
|
<p class="text-body-2 text-medium-emphasis mb-4">
|
|
Kies een organisatielid om te koppelen aan
|
|
<strong>{{ person?.full_name }}</strong>
|
|
</p>
|
|
<VTextField
|
|
v-model="linkSearchQuery"
|
|
prepend-inner-icon="tabler-search"
|
|
placeholder="Zoek op naam of e-mail..."
|
|
density="compact"
|
|
hide-details
|
|
clearable
|
|
class="mb-3"
|
|
/>
|
|
<VList
|
|
v-if="filteredOrgMembers.length"
|
|
density="compact"
|
|
class="border rounded-lg"
|
|
style="max-height: 250px; overflow-y: auto"
|
|
>
|
|
<VListItem
|
|
v-for="member in filteredOrgMembers"
|
|
:key="member.id"
|
|
class="cursor-pointer"
|
|
:disabled="isManualLinking"
|
|
@click="handleManualLink(member.id)"
|
|
>
|
|
<template #prepend>
|
|
<VAvatar
|
|
size="32"
|
|
color="primary"
|
|
variant="tonal"
|
|
>
|
|
{{ (member.first_name[0] + member.last_name[0]).toUpperCase() }}
|
|
</VAvatar>
|
|
</template>
|
|
<VListItemTitle>{{ member.full_name }}</VListItemTitle>
|
|
<VListItemSubtitle>{{ member.email }}</VListItemSubtitle>
|
|
</VListItem>
|
|
</VList>
|
|
<VAlert
|
|
v-else-if="linkSearchQuery"
|
|
type="info"
|
|
variant="tonal"
|
|
density="compact"
|
|
>
|
|
Geen leden gevonden voor "{{ linkSearchQuery }}"
|
|
</VAlert>
|
|
</VCardText>
|
|
<VCardActions class="px-5 pb-5">
|
|
<VSpacer />
|
|
<VBtn
|
|
variant="tonal"
|
|
@click="showManualLinkDialog = false"
|
|
>
|
|
Sluiten
|
|
</VBtn>
|
|
</VCardActions>
|
|
</VCard>
|
|
</VDialog>
|
|
</VNavigationDrawer>
|
|
</template>
|