Files
people/src/components/PersonList.vue
2026-02-25 00:17:21 -05:00

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>