Files
people/src/components/ImportDialog.vue
T
Sebastian b6b3e53095 feat: people import
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
2026-06-22 22:56:31 -04:00

213 lines
6.6 KiB
Vue

<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useImportStore } from '@PeopleManager/stores/importStore'
import { CollectionObject } from '@PeopleManager/models/collection'
// Props
const props = defineProps<{
modelValue: boolean
collections: CollectionObject[]
}>()
// Emits
const emit = defineEmits<{
'update:modelValue': [value: boolean]
// refresh = true once contacts were created/updated, so the caller can reload lists
'close': [refresh: boolean]
}>()
// Store
const importStore = useImportStore()
const { files, sessions, order, stage, running, totals } = storeToRefs(importStore)
// Collection picker options
const collectionOptions = computed(() =>
props.collections.map((collection: CollectionObject) => ({
title: collection.properties.label || 'Unnamed',
value: collection.identifier,
})),
)
const isSelecting = computed(() => stage.value === 'selecting')
const isComplete = computed(() => stage.value === 'completed' || stage.value === 'error')
// Every queued file must have a destination before import can start.
const canImport = computed(() =>
files.value.length > 0 && files.value.every((entry) => entry.collectionId !== null),
)
const progress = computed(() => {
const { discovered, processed } = totals.value
return discovered > 0 ? Math.min(100, Math.round((processed / discovered) * 100)) : 0
})
const counters = computed(() => {
const t = totals.value
return [
{ key: 'discovered', label: 'Discovered', value: t.discovered, color: 'info' },
{ key: 'created', label: 'Created', value: t.created, color: 'success' },
{ key: 'updated', label: 'Updated', value: t.updated, color: 'info' },
{ key: 'exists', label: 'Skipped', value: t.exists, color: 'warning' },
{ key: 'error', label: 'Errors', value: t.error, color: 'error' },
]
})
function setCollection(fileId: number, collectionId: CollectionObject['identifier'] | null) {
importStore.setCollectionForFile(fileId, collectionId)
}
function setSupersede(fileId: number, value: boolean) {
importStore.setOptionsForFile(fileId, { supersede: value })
}
async function startImport() {
try {
await importStore.startImport()
} catch (error) {
console.error('[People] - Import failed:', error)
}
}
function cancel() {
emit('update:modelValue', false)
emit('close', false)
}
function done() {
const refresh = totals.value.created > 0 || totals.value.updated > 0
emit('update:modelValue', false)
emit('close', refresh)
}
</script>
<template>
<v-dialog
:model-value="modelValue"
max-width="640"
persistent
scrollable
>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon icon="mdi-database-import" class="mr-2" />
Import Contacts
</v-card-title>
<v-divider />
<!-- Selecting stage: choose a destination per file -->
<v-card-text v-if="isSelecting" class="pt-4">
<p class="text-body-2 text-medium-emphasis mb-4">
Choose a destination address book for each file. Existing contacts are kept
unless "Overwrite existing" is enabled.
</p>
<v-card
v-for="entry in files"
:key="entry.file.id"
variant="tonal"
class="mb-3 pa-3"
>
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-file-account-outline" size="small" class="mr-2" />
<span class="text-subtitle-2 font-weight-medium text-truncate">{{ entry.file.name }}</span>
</div>
<v-select
:model-value="entry.collectionId"
:items="collectionOptions"
label="Address book"
density="compact"
variant="outlined"
hide-details
class="mb-2"
@update:model-value="setCollection(entry.file.id, $event)"
/>
<v-switch
:model-value="entry.options.supersede"
label="Overwrite existing contacts"
color="primary"
density="compact"
hide-details
@update:model-value="setSupersede(entry.file.id, $event ?? false)"
/>
</v-card>
</v-card-text>
<!-- Importing / completed stage: progress + counters -->
<v-card-text v-else class="pt-4">
<div class="d-flex align-center justify-space-between mb-1">
<span class="text-body-2">
{{ isComplete ? 'Import complete' : 'Importing…' }}
</span>
<span class="text-body-2 text-medium-emphasis">
{{ totals.processed }} / {{ totals.discovered }}
</span>
</div>
<v-progress-linear
:model-value="progress"
:indeterminate="running && totals.discovered === 0"
color="primary"
height="8"
rounded
class="mb-4"
/>
<div class="d-flex flex-wrap ga-2">
<v-chip
v-for="counter in counters"
:key="counter.key"
:color="counter.color"
size="small"
variant="tonal"
>
{{ counter.label }}: {{ counter.value }}
</v-chip>
</div>
<v-list v-if="order.length > 1" density="compact" class="mt-3 bg-transparent">
<v-list-item
v-for="id in order"
:key="id"
:title="sessions[id]?.fileName"
:subtitle="sessions[id]?.lastError ?? undefined"
>
<template #prepend>
<v-icon
:icon="sessions[id]?.status === 'error' ? 'mdi-alert-circle' : sessions[id]?.status === 'completed' ? 'mdi-check-circle' : 'mdi-progress-clock'"
:color="sessions[id]?.status === 'error' ? 'error' : sessions[id]?.status === 'completed' ? 'success' : 'info'"
size="small"
/>
</template>
<template #append>
<span class="text-caption text-medium-emphasis">
{{ sessions[id]?.counters.processed }} / {{ sessions[id]?.counters.discovered }}
</span>
</template>
</v-list-item>
</v-list>
</v-card-text>
<v-divider />
<v-card-actions>
<v-spacer />
<template v-if="isSelecting">
<v-btn variant="text" @click="cancel">Cancel</v-btn>
<v-btn color="primary" variant="flat" :disabled="!canImport" @click="startImport">
Import
</v-btn>
</template>
<template v-else>
<v-btn color="primary" variant="flat" :disabled="running" @click="done">
Close
</v-btn>
</template>
</v-card-actions>
</v-card>
</v-dialog>
</template>