280 lines
8.3 KiB
Vue
280 lines
8.3 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, watch } from 'vue'
|
|
import { useEntitiesStore } from '@PeopleManager/stores/entitiesStore'
|
|
import type { CollectionObject } from '@PeopleManager/models/collection'
|
|
import type { EntityObject } from '@PeopleManager/models/entity'
|
|
|
|
// Stores
|
|
const entitiesStore = useEntitiesStore()
|
|
|
|
// Props
|
|
const props = defineProps<{
|
|
selectedCollection?: CollectionObject | null
|
|
selectedEntity?: EntityObject | null
|
|
}>()
|
|
|
|
// Emits
|
|
const emit = defineEmits<{
|
|
'select': [entity: EntityObject]
|
|
'fresh': [type: string]
|
|
}>()
|
|
|
|
// State
|
|
const loading = ref(false)
|
|
const searchQuery = ref('')
|
|
const collectionEntities = ref<EntityObject[]>([])
|
|
|
|
// Computed
|
|
const filteredEntities = computed(() => {
|
|
if (!searchQuery.value) {
|
|
return collectionEntities.value
|
|
}
|
|
const query = searchQuery.value.toLowerCase()
|
|
return collectionEntities.value.filter(entity => {
|
|
const props = (entity.properties as any)
|
|
const given = props?.names?.given || ''
|
|
const family = props?.names?.family || ''
|
|
const full = props?.names?.full || ''
|
|
const label = props?.label || ''
|
|
const name = `${given} ${family} ${full} ${label}`.toLowerCase()
|
|
const emails = props?.emails
|
|
const firstEmail = emails ? Object.values(emails)[0] as any : null
|
|
const email = (firstEmail?.address || '').toLowerCase()
|
|
return name.includes(query) || email.includes(query)
|
|
})
|
|
})
|
|
|
|
// Watchers
|
|
watch(() => props.selectedCollection, async (newCollection) => {
|
|
if (newCollection) {
|
|
loading.value = true
|
|
try {
|
|
const sources = {
|
|
[newCollection.provider]: {
|
|
[String(newCollection.service)]: {
|
|
[String(newCollection.identifier)]: true as const,
|
|
},
|
|
},
|
|
}
|
|
const result = await entitiesStore.list(sources)
|
|
collectionEntities.value = Object.values(result)
|
|
} catch (error) {
|
|
console.error('[People] - Failed to load contacts:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
} else {
|
|
collectionEntities.value = []
|
|
loading.value = false
|
|
}
|
|
}, { immediate: true })
|
|
|
|
// Functions
|
|
function entityType(entity: EntityObject): string {
|
|
return (entity.properties as any)?.type || 'individual'
|
|
}
|
|
|
|
function entityIcon(entity: EntityObject): string {
|
|
const type = entityType(entity)
|
|
switch (type) {
|
|
case 'organization':
|
|
return 'mdi-domain'
|
|
case 'group':
|
|
return 'mdi-account-group'
|
|
default:
|
|
return 'mdi-account'
|
|
}
|
|
}
|
|
|
|
function entityPhoto(entity: EntityObject): string | null {
|
|
// TODO: Implement photo retrieval from entity properties
|
|
return null
|
|
}
|
|
|
|
function entityInitials(entity: EntityObject): string {
|
|
const type = entityType(entity)
|
|
const props = (entity.properties as any)
|
|
|
|
if (type === 'organization') {
|
|
const name = props?.names?.full || props?.label || ''
|
|
return name.substring(0, 2).toUpperCase() || 'OR'
|
|
}
|
|
|
|
if (type === 'group') {
|
|
const name = props?.names?.full || props?.label || ''
|
|
return name.substring(0, 2).toUpperCase() || 'GR'
|
|
}
|
|
|
|
// Individual
|
|
const given = props?.names?.given
|
|
const family = props?.names?.family
|
|
const initials = `${given?.[0] || ''}${family?.[0] || ''}`.toUpperCase()
|
|
return initials || '?'
|
|
}
|
|
|
|
function entityLabel(entity: EntityObject): string {
|
|
const type = entityType(entity)
|
|
const props = (entity.properties as any)
|
|
|
|
if (type === 'organization' || type === 'group') {
|
|
return props?.label || props?.names?.full || 'Unknown'
|
|
}
|
|
|
|
// Individual
|
|
const given = props?.names?.given || ''
|
|
const family = props?.names?.family || ''
|
|
return props?.label || `${given} ${family}`.trim() || 'Unknown'
|
|
}
|
|
|
|
function entityEmail(entity: EntityObject): string | null {
|
|
const emails = (entity.properties as any)?.emails
|
|
if (!emails) return null
|
|
const emailEntries = Object.values(emails) as any[]
|
|
if (emailEntries.length === 0) return null
|
|
emailEntries.sort((a, b) => (a.priority || 999) - (b.priority || 999))
|
|
return emailEntries[0].address || null
|
|
}
|
|
|
|
function entityOrganization(entity: EntityObject): string | null {
|
|
const organizations = (entity.properties as any)?.organizations
|
|
if (!organizations || Object.keys(organizations).length === 0) return null
|
|
const orgEntries = Object.values(organizations) as any[]
|
|
return orgEntries[0].label || null
|
|
}
|
|
|
|
function entityMemberCount(entity: EntityObject): number | null {
|
|
const type = entityType(entity)
|
|
if (type !== 'group') return null
|
|
const members = (entity.properties as any)?.members
|
|
if (!members) return 0
|
|
return Object.keys(members).length
|
|
}
|
|
|
|
function entityAvatarColor(entity: EntityObject): string {
|
|
const type = entityType(entity)
|
|
switch (type) {
|
|
case 'organization':
|
|
return 'blue-darken-1'
|
|
case 'group':
|
|
return 'green-darken-1'
|
|
default:
|
|
return 'primary'
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="person-list-container">
|
|
<div class="person-list-header pa-4">
|
|
<v-text-field
|
|
v-model="searchQuery"
|
|
density="compact"
|
|
variant="outlined"
|
|
label="Search contacts"
|
|
prepend-inner-icon="mdi-magnify"
|
|
clearable
|
|
hide-details
|
|
/>
|
|
</div>
|
|
|
|
<div class="pa-0 person-list-content">
|
|
<v-progress-linear v-if="loading" indeterminate color="primary" />
|
|
|
|
<div v-else-if="!selectedCollection" class="text-center pa-8">
|
|
<v-icon icon="mdi-book-alert" size="64" color="grey" class="mb-4" />
|
|
<p class="text-body-1 text-grey">Select an address book to view contacts</p>
|
|
</div>
|
|
|
|
<div v-else-if="filteredEntities.length === 0" class="text-center pa-8">
|
|
<v-icon icon="mdi-account-search" size="64" color="grey" class="mb-4" />
|
|
<p class="text-body-1 text-grey">No Individuals, Organizations or Groups found</p>
|
|
</div>
|
|
|
|
<v-list v-else density="compact" nav>
|
|
<v-list-item
|
|
v-for="entity in filteredEntities"
|
|
:key="entity.identifier"
|
|
:value="entity.identifier"
|
|
@click="$emit('select', entity)">
|
|
|
|
<template #prepend>
|
|
<v-avatar :color="entityAvatarColor(entity)" size="40">
|
|
<v-img v-if="entityPhoto(entity)" :src="entityPhoto(entity)!" />
|
|
<v-icon v-else-if="entityType(entity) !== 'individual'" :icon="entityIcon(entity)" size="small" />
|
|
<span v-else class="text-subtitle-2">{{ entityInitials(entity) }}</span>
|
|
</v-avatar>
|
|
</template>
|
|
|
|
<v-list-item-title>
|
|
<v-icon :icon="entityIcon(entity)" size="x-small" class="mr-1" />
|
|
{{ entityLabel(entity) }}
|
|
</v-list-item-title>
|
|
|
|
<v-list-item-subtitle>
|
|
<!-- For individuals: show email and organization -->
|
|
<template v-if="entityType(entity) === 'individual'">
|
|
<div v-if="entityEmail(entity)">
|
|
<v-icon icon="mdi-email" size="x-small" class="mr-1" />
|
|
{{ entityEmail(entity) }}
|
|
</div>
|
|
<div v-if="entityOrganization(entity)">
|
|
<v-icon icon="mdi-domain" size="x-small" class="mr-1" />
|
|
{{ entityOrganization(entity) }}
|
|
</div>
|
|
</template>
|
|
|
|
<!-- For organizations: show email -->
|
|
<template v-else-if="entityType(entity) === 'organization'">
|
|
<div v-if="entityEmail(entity)">
|
|
<v-icon icon="mdi-email" size="x-small" class="mr-1" />
|
|
{{ entityEmail(entity) }}
|
|
</div>
|
|
</template>
|
|
|
|
<!-- For groups: show member count -->
|
|
<template v-else-if="entityType(entity) === 'group'">
|
|
<div>
|
|
<v-icon icon="mdi-account-multiple" size="x-small" class="mr-1" />
|
|
{{ entityMemberCount(entity) }} member{{ entityMemberCount(entity) !== 1 ? 's' : '' }}
|
|
</div>
|
|
</template>
|
|
</v-list-item-subtitle>
|
|
</v-list-item>
|
|
</v-list>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.person-list-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
overflow: auto;
|
|
}
|
|
|
|
.person-list-header {
|
|
min-height: 72px;
|
|
display: flex;
|
|
align-items: center;
|
|
border-bottom: 1px solid rgb(var(--v-border-color));
|
|
}
|
|
|
|
.person-list-content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.person-list-container :deep(.v-list) {
|
|
background: transparent;
|
|
}
|
|
|
|
.person-list-container :deep(.v-field) {
|
|
background: transparent;
|
|
}
|
|
|
|
.v-list-item {
|
|
cursor: pointer;
|
|
}
|
|
</style>
|