Files
crewli/apps/app/src/components/persons/PersonDetailPanel.vue
bert.hausmans 0ca7c0f20f refactor(members): consolidate Platform Admin + Org members into shared useMembers
- 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.
2026-04-16 22:30:42 +02:00

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>