863 lines
27 KiB
Vue
863 lines
27 KiB
Vue
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useTranslation } from 'i18next-vue'
|
|
import ModalDialog from './ModalDialog.vue'
|
|
import { useJsonImport, COLLISION_ACTION } from '../composables/useJsonImport.js'
|
|
|
|
const props = defineProps({
|
|
envelope: { type: Object, required: true },
|
|
localUuid: { type: String, required: true },
|
|
asMine: { type: Boolean, default: false },
|
|
})
|
|
|
|
const emit = defineEmits(['close', 'done'])
|
|
|
|
const { t } = useTranslation()
|
|
const { analyzeAll, applyAllImport } = useJsonImport()
|
|
|
|
// State machine: 'loading' | 'suffix' | 'preview' | 'collisions' | 'applying' | 'report'
|
|
const step = ref('loading')
|
|
const analysis = ref(null)
|
|
const applying = ref(false)
|
|
const report = ref(null)
|
|
|
|
// Suffix input for different-user imports
|
|
const suffixInput = ref('')
|
|
const suffixError = ref('')
|
|
|
|
// Collision resolutions per entity type (also receives cross-user title-conflict
|
|
// resolutions once they're discovered in onApply — see crossUserConflictsChecked)
|
|
const exerciseResolutions = ref([])
|
|
const comboResolutions = ref([])
|
|
const sessionResolutions = ref([])
|
|
const collectionResolutions = ref([])
|
|
|
|
// Guards against re-running findTitleConflicts after the user has already resolved them
|
|
const crossUserConflictsChecked = ref(false)
|
|
|
|
const summary = computed(() => analysis.value?.summary || {})
|
|
|
|
const totalEntityCount = computed(() => {
|
|
let count = 0
|
|
if (analysis.value?.exercises) count += analysis.value.exercises.total
|
|
if (analysis.value?.combos) count += analysis.value.combos.total
|
|
if (analysis.value?.sessions) count += analysis.value.sessions.total
|
|
if (analysis.value?.collections) count += analysis.value.collections.total
|
|
if (analysis.value?.executionLogs) count += analysis.value.executionLogs.total
|
|
return count
|
|
})
|
|
|
|
// Derived from the resolution refs directly (not from `analysis`) so it stays correct
|
|
// whether resolutions came from same-user id/title collisions or cross-user title conflicts.
|
|
const hasCollisions = computed(() => totalCollisions.value > 0)
|
|
|
|
const totalCollisions = computed(
|
|
() =>
|
|
exerciseResolutions.value.length +
|
|
comboResolutions.value.length +
|
|
sessionResolutions.value.length +
|
|
collectionResolutions.value.length,
|
|
)
|
|
|
|
onMounted(async () => {
|
|
analysis.value = await analyzeAll(props.envelope, { asMine: props.asMine })
|
|
const a = analysis.value
|
|
|
|
if (a.summary.isSameUser) {
|
|
// Initialize collision resolutions (id and title conflicts are both bucketed
|
|
// into sameUserCollisions by useJsonImport.js)
|
|
if (a.exercises?.sameUserCollisions?.length) {
|
|
exerciseResolutions.value = a.exercises.sameUserCollisions.map((c) => ({
|
|
...c,
|
|
action: COLLISION_ACTION.SKIP,
|
|
}))
|
|
}
|
|
if (a.combos?.sameUserCollisions?.length) {
|
|
comboResolutions.value = a.combos.sameUserCollisions.map((c) => ({
|
|
...c,
|
|
action: COLLISION_ACTION.SKIP,
|
|
}))
|
|
}
|
|
if (a.sessions?.sameUserCollisions?.length) {
|
|
sessionResolutions.value = a.sessions.sameUserCollisions.map((c) => ({
|
|
...c,
|
|
action: COLLISION_ACTION.SKIP,
|
|
}))
|
|
}
|
|
if (a.collections?.sameUserCollisions?.length) {
|
|
collectionResolutions.value = a.collections.sameUserCollisions.map((c) => ({
|
|
...c,
|
|
action: COLLISION_ACTION.SKIP,
|
|
}))
|
|
}
|
|
step.value = hasCollisions.value ? 'collisions' : 'preview'
|
|
} else {
|
|
if (a.suffixInfo?.needsSuffix) {
|
|
suffixInput.value = a.summary.exporterName
|
|
step.value = 'suffix'
|
|
} else {
|
|
step.value = 'preview'
|
|
}
|
|
}
|
|
})
|
|
|
|
// --- Actions ---
|
|
|
|
/**
|
|
* Check title conflicts for a list of different-user entities against existing data.
|
|
* Returns the matched existing row alongside the entry so the caller can offer
|
|
* Replace/Skip against it (same as same-user collisions) instead of forcing a rename.
|
|
*/
|
|
async function findTitleConflicts(entries, entityType, titleField, suffix, getAll) {
|
|
if (!entries?.length) return []
|
|
const allEntities = await getAll()
|
|
const conflicts = []
|
|
for (const entry of entries) {
|
|
const suffixedTitle = `${entry.imported[titleField]} [${suffix}]`
|
|
if (entry.action === 'update' && entry.existing?.[titleField] === suffixedTitle) continue
|
|
const conflict = allEntities.find(
|
|
(e) => e[titleField] === suffixedTitle && e.id !== entry.imported.id,
|
|
)
|
|
if (conflict) {
|
|
conflicts.push({ entity: entityType, entry, existing: conflict })
|
|
}
|
|
}
|
|
return conflicts
|
|
}
|
|
|
|
async function onApply() {
|
|
applying.value = true
|
|
step.value = 'applying'
|
|
|
|
try {
|
|
const a = analysis.value
|
|
|
|
// For different-user imports, check title conflicts (on the suffixed title) once,
|
|
// before applying. Found conflicts are folded into the same resolution arrays used
|
|
// for same-user collisions, so the existing Replace/Skip screen handles them too.
|
|
if (!a.summary.isSameUser && !crossUserConflictsChecked.value) {
|
|
crossUserConflictsChecked.value = true
|
|
const suffix = a.suffixInfo?.existingSuffix || suffixInput.value.trim()
|
|
|
|
const [exConflicts, coConflicts, seConflicts, clConflicts] = await Promise.all([
|
|
findTitleConflicts(a.exercises?.differentUser, 'exercise', 'title', suffix, async () => {
|
|
const { exerciseRepository } = await import('../db/repositories/exerciseRepository.js')
|
|
return exerciseRepository.getAll()
|
|
}),
|
|
findTitleConflicts(a.combos?.differentUser, 'combo', 'title', suffix, async () => {
|
|
const { comboRepository } = await import('../db/repositories/comboRepository.js')
|
|
return comboRepository.getAll()
|
|
}),
|
|
findTitleConflicts(a.sessions?.differentUser, 'session', 'title', suffix, async () => {
|
|
const { sessionRepository } = await import('../db/repositories/sessionRepository.js')
|
|
return sessionRepository.getAll()
|
|
}),
|
|
findTitleConflicts(
|
|
a.collections?.differentUser,
|
|
'collection',
|
|
'label',
|
|
suffix,
|
|
async () => {
|
|
const { collectionRepository } =
|
|
await import('../db/repositories/collectionRepository.js')
|
|
return collectionRepository.getAll()
|
|
},
|
|
),
|
|
])
|
|
|
|
const conflicts = [...exConflicts, ...coConflicts, ...seConflicts, ...clConflicts]
|
|
|
|
if (conflicts.length > 0) {
|
|
for (const conflict of conflicts) {
|
|
const resolutions = getResolutionsForType(conflict.entity)
|
|
resolutions.value.push({
|
|
imported: conflict.entry.imported,
|
|
existing: conflict.existing,
|
|
action: COLLISION_ACTION.SKIP,
|
|
})
|
|
}
|
|
step.value = 'collisions'
|
|
applying.value = false
|
|
return
|
|
}
|
|
}
|
|
|
|
// Apply import
|
|
report.value = await applyAllImport(analysis.value, {
|
|
suffix: analysis.value.suffixInfo?.existingSuffix || suffixInput.value.trim(),
|
|
exerciseResolutions: exerciseResolutions.value,
|
|
comboResolutions: comboResolutions.value,
|
|
sessionResolutions: sessionResolutions.value,
|
|
collectionResolutions: collectionResolutions.value,
|
|
})
|
|
|
|
step.value = 'report'
|
|
} catch (e) {
|
|
report.value = {
|
|
exercises: { imported: 0, skipped: 0, replaced: 0, updated: 0 },
|
|
combos: { imported: 0, skipped: 0, replaced: 0, updated: 0 },
|
|
sessions: { imported: 0, skipped: 0, replaced: 0, updated: 0 },
|
|
collections: { imported: 0, skipped: 0, replaced: 0, updated: 0 },
|
|
executionLogs: { imported: 0, skipped: 0 },
|
|
settings: { imported: 0 },
|
|
errors: [e.message],
|
|
}
|
|
step.value = 'report'
|
|
} finally {
|
|
applying.value = false
|
|
}
|
|
}
|
|
|
|
async function onConfirmSuffix() {
|
|
const trimmed = suffixInput.value.trim()
|
|
if (!trimmed) {
|
|
suffixError.value = t('import.suffixRequired')
|
|
return
|
|
}
|
|
|
|
const { suffixRepository } = await import('../db/repositories/suffixRepository.js')
|
|
const existing = await suffixRepository.getBySuffix(trimmed)
|
|
if (existing && existing.user_uuid !== analysis.value.summary.exporterUuid) {
|
|
suffixError.value = t('import.suffixTaken')
|
|
return
|
|
}
|
|
|
|
suffixError.value = ''
|
|
step.value = 'preview'
|
|
}
|
|
|
|
function setAllCollisions(entityType, action) {
|
|
const resolutions = getResolutionsForType(entityType)
|
|
resolutions.value = resolutions.value.map((c) => ({ ...c, action }))
|
|
}
|
|
|
|
function setCollisionAction(entityType, index, action) {
|
|
const resolutions = getResolutionsForType(entityType)
|
|
resolutions.value[index] = { ...resolutions.value[index], action }
|
|
}
|
|
|
|
function getResolutionsForType(entityType) {
|
|
switch (entityType) {
|
|
case 'exercise':
|
|
return exerciseResolutions
|
|
case 'combo':
|
|
return comboResolutions
|
|
case 'session':
|
|
return sessionResolutions
|
|
case 'collection':
|
|
return collectionResolutions
|
|
default:
|
|
return { value: [] }
|
|
}
|
|
}
|
|
|
|
function onDone() {
|
|
emit('done', report.value)
|
|
}
|
|
|
|
function formatDate(isoString) {
|
|
try {
|
|
return new Date(isoString).toLocaleDateString()
|
|
} catch {
|
|
return isoString
|
|
}
|
|
}
|
|
|
|
const totalImported = computed(() => {
|
|
if (!report.value) return 0
|
|
let count = 0
|
|
for (const key of ['exercises', 'combos', 'sessions', 'collections', 'executionLogs']) {
|
|
const r = report.value[key]
|
|
if (r) {
|
|
count += (r.imported || 0) + (r.replaced || 0) + (r.updated || 0)
|
|
}
|
|
}
|
|
return count
|
|
})
|
|
|
|
const totalSkipped = computed(() => {
|
|
if (!report.value) return 0
|
|
let count = 0
|
|
for (const key of ['exercises', 'combos', 'sessions', 'collections', 'executionLogs']) {
|
|
const r = report.value[key]
|
|
if (r) count += r.skipped || 0
|
|
}
|
|
return count
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<ModalDialog
|
|
:title="asMine ? $t('import.titleAsMine') : $t('import.title')"
|
|
wide
|
|
@close="$emit('close')"
|
|
>
|
|
<!-- Loading -->
|
|
<div v-if="step === 'loading'" class="import-loading" data-testid="import-loading">
|
|
<i class="bi bi-arrow-repeat spin"></i>
|
|
<p>{{ $t('app.loading') }}</p>
|
|
</div>
|
|
|
|
<!-- Suffix prompt -->
|
|
<div v-else-if="step === 'suffix'" class="import-suffix" data-testid="import-suffix-step">
|
|
<p>{{ $t('import.suffixExplanation', { name: summary.exporterName }) }}</p>
|
|
<div class="form-group">
|
|
<label for="suffix-input">{{ $t('import.suffixLabel') }}</label>
|
|
<input
|
|
id="suffix-input"
|
|
v-model="suffixInput"
|
|
type="text"
|
|
data-testid="import-suffix-input"
|
|
:placeholder="$t('import.suffixPlaceholder')"
|
|
@keyup.enter="onConfirmSuffix"
|
|
/>
|
|
<p v-if="suffixError" class="field-error" data-testid="import-suffix-error">
|
|
{{ suffixError }}
|
|
</p>
|
|
</div>
|
|
<p class="text-muted import-suffix-preview">
|
|
{{ $t('import.suffixPreview', { title: 'Push-ups', suffix: suffixInput.trim() || '...' }) }}
|
|
</p>
|
|
<div class="import-actions">
|
|
<button class="btn" data-testid="import-cancel" @click="$emit('close')">
|
|
{{ $t('import.cancel') }}
|
|
</button>
|
|
<button
|
|
class="btn btn-primary"
|
|
data-testid="import-suffix-confirm"
|
|
@click="onConfirmSuffix"
|
|
>
|
|
{{ $t('import.continue') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Preview -->
|
|
<div v-else-if="step === 'preview'" class="import-preview" data-testid="import-preview-step">
|
|
<div class="import-summary">
|
|
<p>
|
|
<strong>{{ totalEntityCount }}</strong>
|
|
{{ $t('import.totalCount', { count: totalEntityCount }) }}
|
|
</p>
|
|
<p class="text-muted">
|
|
{{
|
|
$t('import.from', { name: summary.exporterName, date: formatDate(summary.exportDate) })
|
|
}}
|
|
</p>
|
|
<p
|
|
v-if="!summary.isSameUser && (analysis.suffixInfo?.existingSuffix || suffixInput.trim())"
|
|
class="text-muted"
|
|
>
|
|
{{
|
|
$t('import.usingSuffix', {
|
|
suffix: analysis.suffixInfo?.existingSuffix || suffixInput.trim(),
|
|
})
|
|
}}
|
|
</p>
|
|
<!-- Entity breakdown -->
|
|
<ul class="entity-breakdown">
|
|
<li v-if="analysis.exercises">
|
|
{{ analysis.exercises.total }} {{ $t('nav.exercises') }}
|
|
</li>
|
|
<li v-if="analysis.combos">{{ analysis.combos.total }} {{ $t('nav.combos') }}</li>
|
|
<li v-if="analysis.sessions">{{ analysis.sessions.total }} {{ $t('nav.sessions') }}</li>
|
|
<li v-if="analysis.collections">
|
|
{{ analysis.collections.total }} {{ $t('nav.collections') }}
|
|
</li>
|
|
<li v-if="analysis.executionLogs">
|
|
{{ analysis.executionLogs.total }} {{ $t('import.executionLogs') }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div class="import-actions">
|
|
<button class="btn" data-testid="import-cancel" @click="$emit('close')">
|
|
{{ $t('import.cancel') }}
|
|
</button>
|
|
<button class="btn btn-primary" data-testid="import-apply" @click="onApply">
|
|
{{ $t('import.apply') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Same-user collisions -->
|
|
<div
|
|
v-else-if="step === 'collisions'"
|
|
class="import-collisions"
|
|
data-testid="import-collisions-step"
|
|
>
|
|
<p>{{ $t('import.collisionExplanation', { count: totalCollisions }) }}</p>
|
|
|
|
<!-- Exercises collisions -->
|
|
<div
|
|
v-if="exerciseResolutions.length > 0"
|
|
class="collision-group"
|
|
data-testid="collision-group-exercises"
|
|
>
|
|
<h4>{{ $t('nav.exercises') }}</h4>
|
|
<div class="collision-batch">
|
|
<button
|
|
class="btn btn-sm"
|
|
data-testid="exercise-replace-all"
|
|
@click="setAllCollisions('exercise', COLLISION_ACTION.REPLACE)"
|
|
>
|
|
{{ $t('import.replaceAll') }}
|
|
</button>
|
|
<button
|
|
class="btn btn-sm"
|
|
data-testid="exercise-skip-all"
|
|
@click="setAllCollisions('exercise', COLLISION_ACTION.SKIP)"
|
|
>
|
|
{{ $t('import.skipAll') }}
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-for="(collision, index) in exerciseResolutions"
|
|
:key="collision.imported.id"
|
|
class="collision-item"
|
|
:data-testid="'collision-item-exercise-' + index"
|
|
>
|
|
<div class="collision-info">
|
|
<strong>{{ collision.imported.title }}</strong>
|
|
</div>
|
|
<div class="collision-actions">
|
|
<button
|
|
class="btn btn-sm"
|
|
:class="{ 'btn-primary': collision.action === COLLISION_ACTION.REPLACE }"
|
|
:data-testid="'collision-replace-exercise-' + index"
|
|
@click="setCollisionAction('exercise', index, COLLISION_ACTION.REPLACE)"
|
|
>
|
|
{{ $t('import.replace') }}
|
|
</button>
|
|
<button
|
|
class="btn btn-sm"
|
|
:class="{ 'btn-primary': collision.action === COLLISION_ACTION.SKIP }"
|
|
:data-testid="'collision-skip-exercise-' + index"
|
|
@click="setCollisionAction('exercise', index, COLLISION_ACTION.SKIP)"
|
|
>
|
|
{{ $t('import.skip') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Combos collisions -->
|
|
<div
|
|
v-if="comboResolutions.length > 0"
|
|
class="collision-group"
|
|
data-testid="collision-group-combos"
|
|
>
|
|
<h4>{{ $t('nav.combos') }}</h4>
|
|
<div class="collision-batch">
|
|
<button
|
|
class="btn btn-sm"
|
|
data-testid="combo-replace-all"
|
|
@click="setAllCollisions('combo', COLLISION_ACTION.REPLACE)"
|
|
>
|
|
{{ $t('import.replaceAll') }}
|
|
</button>
|
|
<button
|
|
class="btn btn-sm"
|
|
data-testid="combo-skip-all"
|
|
@click="setAllCollisions('combo', COLLISION_ACTION.SKIP)"
|
|
>
|
|
{{ $t('import.skipAll') }}
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-for="(collision, index) in comboResolutions"
|
|
:key="collision.imported.id"
|
|
class="collision-item"
|
|
:data-testid="'collision-item-combo-' + index"
|
|
>
|
|
<div class="collision-info">
|
|
<strong>{{ collision.imported.title }}</strong>
|
|
</div>
|
|
<div class="collision-actions">
|
|
<button
|
|
class="btn btn-sm"
|
|
:class="{ 'btn-primary': collision.action === COLLISION_ACTION.REPLACE }"
|
|
:data-testid="'collision-replace-combo-' + index"
|
|
@click="setCollisionAction('combo', index, COLLISION_ACTION.REPLACE)"
|
|
>
|
|
{{ $t('import.replace') }}
|
|
</button>
|
|
<button
|
|
class="btn btn-sm"
|
|
:class="{ 'btn-primary': collision.action === COLLISION_ACTION.SKIP }"
|
|
:data-testid="'collision-skip-combo-' + index"
|
|
@click="setCollisionAction('combo', index, COLLISION_ACTION.SKIP)"
|
|
>
|
|
{{ $t('import.skip') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sessions collisions -->
|
|
<div
|
|
v-if="sessionResolutions.length > 0"
|
|
class="collision-group"
|
|
data-testid="collision-group-sessions"
|
|
>
|
|
<h4>{{ $t('nav.sessions') }}</h4>
|
|
<div class="collision-batch">
|
|
<button
|
|
class="btn btn-sm"
|
|
data-testid="session-replace-all"
|
|
@click="setAllCollisions('session', COLLISION_ACTION.REPLACE)"
|
|
>
|
|
{{ $t('import.replaceAll') }}
|
|
</button>
|
|
<button
|
|
class="btn btn-sm"
|
|
data-testid="session-skip-all"
|
|
@click="setAllCollisions('session', COLLISION_ACTION.SKIP)"
|
|
>
|
|
{{ $t('import.skipAll') }}
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-for="(collision, index) in sessionResolutions"
|
|
:key="collision.imported.id"
|
|
class="collision-item"
|
|
:data-testid="'collision-item-session-' + index"
|
|
>
|
|
<div class="collision-info">
|
|
<strong>{{ collision.imported.title }}</strong>
|
|
</div>
|
|
<div class="collision-actions">
|
|
<button
|
|
class="btn btn-sm"
|
|
:class="{ 'btn-primary': collision.action === COLLISION_ACTION.REPLACE }"
|
|
:data-testid="'collision-replace-session-' + index"
|
|
@click="setCollisionAction('session', index, COLLISION_ACTION.REPLACE)"
|
|
>
|
|
{{ $t('import.replace') }}
|
|
</button>
|
|
<button
|
|
class="btn btn-sm"
|
|
:class="{ 'btn-primary': collision.action === COLLISION_ACTION.SKIP }"
|
|
:data-testid="'collision-skip-session-' + index"
|
|
@click="setCollisionAction('session', index, COLLISION_ACTION.SKIP)"
|
|
>
|
|
{{ $t('import.skip') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Collections collisions -->
|
|
<div
|
|
v-if="collectionResolutions.length > 0"
|
|
class="collision-group"
|
|
data-testid="collision-group-collections"
|
|
>
|
|
<h4>{{ $t('nav.collections') }}</h4>
|
|
<div class="collision-batch">
|
|
<button
|
|
class="btn btn-sm"
|
|
data-testid="collection-replace-all"
|
|
@click="setAllCollisions('collection', COLLISION_ACTION.REPLACE)"
|
|
>
|
|
{{ $t('import.replaceAll') }}
|
|
</button>
|
|
<button
|
|
class="btn btn-sm"
|
|
data-testid="collection-skip-all"
|
|
@click="setAllCollisions('collection', COLLISION_ACTION.SKIP)"
|
|
>
|
|
{{ $t('import.skipAll') }}
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-for="(collision, index) in collectionResolutions"
|
|
:key="collision.imported.id"
|
|
class="collision-item"
|
|
:data-testid="'collision-item-collection-' + index"
|
|
>
|
|
<div class="collision-info">
|
|
<strong>{{ collision.imported.label }}</strong>
|
|
</div>
|
|
<div class="collision-actions">
|
|
<button
|
|
class="btn btn-sm"
|
|
:class="{ 'btn-primary': collision.action === COLLISION_ACTION.REPLACE }"
|
|
:data-testid="'collision-replace-collection-' + index"
|
|
@click="setCollisionAction('collection', index, COLLISION_ACTION.REPLACE)"
|
|
>
|
|
{{ $t('import.replace') }}
|
|
</button>
|
|
<button
|
|
class="btn btn-sm"
|
|
:class="{ 'btn-primary': collision.action === COLLISION_ACTION.SKIP }"
|
|
:data-testid="'collision-skip-collection-' + index"
|
|
@click="setCollisionAction('collection', index, COLLISION_ACTION.SKIP)"
|
|
>
|
|
{{ $t('import.skip') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="import-actions">
|
|
<button class="btn" data-testid="import-cancel" @click="$emit('close')">
|
|
{{ $t('import.cancel') }}
|
|
</button>
|
|
<button class="btn btn-primary" data-testid="import-apply" @click="onApply">
|
|
{{ $t('import.apply') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Applying -->
|
|
<div v-else-if="step === 'applying'" class="import-loading" data-testid="import-applying">
|
|
<i class="bi bi-arrow-repeat spin"></i>
|
|
<p>{{ $t('import.importing') }}</p>
|
|
</div>
|
|
|
|
<!-- Report -->
|
|
<div v-else-if="step === 'report'" class="import-report" data-testid="import-report-step">
|
|
<div
|
|
class="report-summary"
|
|
:class="report.errors?.length ? 'report-has-errors' : 'report-ok'"
|
|
>
|
|
<i :class="report.errors?.length ? 'bi bi-exclamation-triangle' : 'bi bi-check-circle'"></i>
|
|
<strong>{{ $t('import.reportTitle') }}</strong>
|
|
</div>
|
|
<ul class="report-details">
|
|
<li v-if="totalImported" data-testid="import-report-imported">
|
|
{{ $t('import.reportTotalImported', { count: totalImported }) }}
|
|
</li>
|
|
<li v-if="totalSkipped" data-testid="import-report-skipped">
|
|
{{ $t('import.reportSkipped', { count: totalSkipped }) }}
|
|
</li>
|
|
<!-- Per-entity breakdown -->
|
|
<li v-if="report.exercises?.imported" data-testid="import-report-exercises-imported">
|
|
{{ $t('import.reportImported', { count: report.exercises.imported }) }}
|
|
{{ $t('nav.exercises') }}
|
|
</li>
|
|
<li v-if="report.exercises?.replaced" data-testid="import-report-exercises-replaced">
|
|
{{ $t('import.reportReplaced', { count: report.exercises.replaced }) }}
|
|
{{ $t('nav.exercises') }}
|
|
</li>
|
|
<li v-if="report.exercises?.updated" data-testid="import-report-exercises-updated">
|
|
{{ $t('import.reportUpdated', { count: report.exercises.updated }) }}
|
|
{{ $t('nav.exercises') }}
|
|
</li>
|
|
<li v-if="report.combos?.imported" data-testid="import-report-combos-imported">
|
|
{{ $t('import.reportImported', { count: report.combos.imported }) }}
|
|
{{ $t('nav.combos') }}
|
|
</li>
|
|
<li v-if="report.combos?.replaced" data-testid="import-report-combos-replaced">
|
|
{{ $t('import.reportReplaced', { count: report.combos.replaced }) }}
|
|
{{ $t('nav.combos') }}
|
|
</li>
|
|
<li v-if="report.combos?.updated" data-testid="import-report-combos-updated">
|
|
{{ $t('import.reportUpdated', { count: report.combos.updated }) }}
|
|
{{ $t('nav.combos') }}
|
|
</li>
|
|
<li v-if="report.sessions?.imported" data-testid="import-report-sessions-imported">
|
|
{{ $t('import.reportImported', { count: report.sessions.imported }) }}
|
|
{{ $t('nav.sessions') }}
|
|
</li>
|
|
<li v-if="report.sessions?.replaced" data-testid="import-report-sessions-replaced">
|
|
{{ $t('import.reportReplaced', { count: report.sessions.replaced }) }}
|
|
{{ $t('nav.sessions') }}
|
|
</li>
|
|
<li v-if="report.sessions?.updated" data-testid="import-report-sessions-updated">
|
|
{{ $t('import.reportUpdated', { count: report.sessions.updated }) }}
|
|
{{ $t('nav.sessions') }}
|
|
</li>
|
|
<li v-if="report.collections?.imported" data-testid="import-report-collections-imported">
|
|
{{ $t('import.reportImported', { count: report.collections.imported }) }}
|
|
{{ $t('nav.collections') }}
|
|
</li>
|
|
<li v-if="report.collections?.replaced" data-testid="import-report-collections-replaced">
|
|
{{ $t('import.reportReplaced', { count: report.collections.replaced }) }}
|
|
{{ $t('nav.collections') }}
|
|
</li>
|
|
<li v-if="report.collections?.updated" data-testid="import-report-collections-updated">
|
|
{{ $t('import.reportUpdated', { count: report.collections.updated }) }}
|
|
{{ $t('nav.collections') }}
|
|
</li>
|
|
<li v-if="report.executionLogs?.imported" data-testid="import-report-logs-imported">
|
|
{{ $t('import.reportImported', { count: report.executionLogs.imported }) }}
|
|
{{ $t('import.executionLogs') }}
|
|
</li>
|
|
<li v-if="report.settings?.imported" data-testid="import-report-settings-imported">
|
|
{{ $t('import.reportImported', { count: report.settings.imported }) }}
|
|
{{ $t('nav.settings') }}
|
|
</li>
|
|
<li
|
|
v-for="(err, i) in report.errors"
|
|
:key="i"
|
|
class="report-error-item"
|
|
:data-testid="'import-report-error-' + i"
|
|
>
|
|
{{ err }}
|
|
</li>
|
|
</ul>
|
|
<div class="import-actions">
|
|
<button class="btn btn-primary" data-testid="import-done" @click="onDone">
|
|
{{ $t('import.done') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</ModalDialog>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.import-loading {
|
|
text-align: center;
|
|
padding: 20px 0;
|
|
}
|
|
|
|
.import-loading .spin {
|
|
font-size: 32px;
|
|
display: inline-block;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from {
|
|
transform: rotate(0deg);
|
|
}
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.import-summary {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.import-summary p {
|
|
margin: 4px 0;
|
|
}
|
|
|
|
.entity-breakdown {
|
|
list-style: disc;
|
|
padding-left: 20px;
|
|
margin: 8px 0 0;
|
|
font-size: 14px;
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
.entity-breakdown li {
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.import-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
margin-top: 16px;
|
|
padding-top: 16px;
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
|
|
.form-group {
|
|
margin: 12px 0;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
font-weight: 600;
|
|
font-size: 14px;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.field-error {
|
|
color: var(--btn-danger-bg);
|
|
font-size: 13px;
|
|
margin: 4px 0 0;
|
|
}
|
|
|
|
.import-suffix-preview {
|
|
font-size: 13px;
|
|
font-style: italic;
|
|
}
|
|
|
|
.collision-group {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.collision-group h4 {
|
|
margin: 0 0 8px;
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.collision-batch {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.collision-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.collision-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.collision-info strong {
|
|
display: block;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.collision-info .text-muted {
|
|
font-size: 12px;
|
|
}
|
|
|
|
.collision-actions {
|
|
display: flex;
|
|
gap: 4px;
|
|
flex-shrink: 0;
|
|
margin-left: 8px;
|
|
}
|
|
|
|
.btn-sm {
|
|
font-size: 12px;
|
|
padding: 4px 10px;
|
|
}
|
|
|
|
.report-summary {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 16px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.report-ok {
|
|
color: #28a745;
|
|
}
|
|
|
|
.report-has-errors {
|
|
color: var(--btn-danger-bg);
|
|
}
|
|
|
|
.report-details {
|
|
list-style: disc;
|
|
padding-left: 20px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.report-details li {
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.report-error-item {
|
|
color: var(--btn-danger-bg);
|
|
}
|
|
</style>
|