trainUs/src/components/ImportDialog.vue
David 352a3d5e88
Some checks are pending
CI / Lint & Format (push) Waiting to run
CI / Build (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (chromium) (push) Waiting to run
CI / Tests (${{ matrix.browser }}) (firefox) (push) Waiting to run
Initial commit
2026-06-18 13:06:57 +02:00

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>