feat(frontend): Improve version comparison

This commit is contained in:
2025-12-30 16:23:50 +01:00
parent dbf68fb4df
commit 551c2b8922
6 changed files with 735 additions and 254 deletions

View File

@@ -11,116 +11,165 @@
<div v-else-if="error" class="text-red-500">{{ $t('versions.comparisonError') }}: {{ error }}</div>
<div v-else-if="diff && hasChanges" class="space-y-6">
<div v-if="diff.elementsAdded.length > 0" class="space-y-2">
<h4 class="font-semibold text-success flex items-center gap-2">
<UIcon name="i-lucide-plus-circle" />
{{ $t('versions.elementsAdded', { count: diff.elementsAdded.length }) }}
</h4>
<UCard v-for="(element, index) in diff.elementsAdded" :key="`added-${index}`" variant="subtle">
<div class="flex items-start gap-2">
<UBadge color="success" variant="subtle">+</UBadge>
<div class="flex-1">
<div class="font-medium">{{ element.title || 'Ohne Titel' }}</div>
<div class="text-sm text-gray-500">Typ: {{ element.type }} · Sektion: {{ element.sectionTitle }}</div>
</div>
</div>
</UCard>
</div>
<div v-else-if="valueDiff && hasChanges" class="space-y-4">
<!-- Summary Alert -->
<UAlert
:title="$t('versions.changesSummary', { count: totalChanges })"
:icon="totalChanges > 0 ? 'i-lucide-file-diff' : 'i-lucide-check'"
color="info"
variant="subtle"
/>
<div v-if="diff.elementsRemoved.length > 0" class="space-y-2">
<h4 class="font-semibold text-error flex items-center gap-2">
<UIcon name="i-lucide-minus-circle" />
{{ $t('versions.elementsRemoved', { count: diff.elementsRemoved.length }) }}
</h4>
<UCard v-for="(element, index) in diff.elementsRemoved" :key="`removed-${index}`" variant="subtle">
<div class="flex items-start gap-2">
<UBadge color="error" variant="subtle">-</UBadge>
<div class="flex-1">
<div class="font-medium">{{ element.title || $t('versions.elementWithoutTitle') }}</div>
<div class="text-sm text-gray-500">
{{ $t('common.type') }}: {{ element.type }} · {{ $t('versions.elementIn') }}
{{ element.sectionTitle }}
<!-- Changes grouped by section -->
<UAccordion
v-if="sectionChanges.length > 0"
:items="accordionItems"
type="multiple"
:default-value="accordionItems.map((_, i) => String(i))"
>
<template #body="{ item }">
<div class="space-y-3">
<div
v-for="(change, changeIdx) in item.changes"
:key="changeIdx"
class="rounded-lg border border-default bg-elevated/50 p-3"
>
<!-- Element title with type indicator -->
<div class="flex items-center gap-2 mb-2">
<UIcon v-if="change.elementType === 'TABLE'" name="i-lucide-table" class="h-4 w-4 text-muted" />
<span class="font-medium text-sm">{{
change.elementTitle || $t('versions.elementWithoutTitle')
}}</span>
<!-- Change type badge -->
<UBadge v-if="isNewAnswer(change)" color="success" variant="subtle" size="xs">
{{ $t('versions.newAnswer') }}
</UBadge>
<UBadge v-else-if="isClearedAnswer(change)" color="warning" variant="subtle" size="xs">
{{ $t('versions.clearedAnswer') }}
</UBadge>
<UBadge v-else color="info" variant="subtle" size="xs">
{{ $t('versions.changedAnswer') }}
</UBadge>
</div>
</div>
</div>
</UCard>
</div>
<div v-if="diff.elementsModified.length > 0" class="space-y-2">
<h4 class="font-semibold text-warning flex items-center gap-2">
<UIcon name="i-lucide-pencil" />
{{ $t('versions.elementsModified', { count: diff.elementsModified.length }) }}
</h4>
<UCard v-for="(element, index) in diff.elementsModified" :key="`modified-${index}`" variant="subtle">
<div class="space-y-2">
<div class="flex items-start gap-2">
<UBadge color="warning" variant="subtle">~</UBadge>
<div class="flex-1">
<div class="font-medium">{{ $t('versions.elementIn') }} {{ element.sectionTitle }}</div>
<!-- Table diff display -->
<div v-if="change.elementType === 'TABLE' && change.tableDiff" class="mt-3">
<!-- Table summary -->
<div class="flex items-center gap-4 text-xs text-muted mb-2">
<span v-if="change.tableDiff.addedCount > 0" class="text-success">
+{{ change.tableDiff.addedCount }} {{ $t('versions.tableRowsAdded') }}
</span>
<span v-if="change.tableDiff.removedCount > 0" class="text-error">
-{{ change.tableDiff.removedCount }} {{ $t('versions.tableRowsRemoved') }}
</span>
<span v-if="change.tableDiff.modifiedCount > 0" class="text-warning">
~{{ change.tableDiff.modifiedCount }} {{ $t('versions.tableRowsModified') }}
</span>
</div>
<div
v-if="
element.optionsAdded.length > 0 ||
element.optionsRemoved.length > 0 ||
element.optionsModified.length > 0
"
class="mt-3 pl-4 border-l-2 border-gray-200 space-y-2"
>
<div v-if="element.optionsAdded.length > 0">
<div class="text-sm font-medium text-success">
{{ $t('versions.optionsAdded', { count: element.optionsAdded.length }) }}:
</div>
<div
v-for="(option, optIdx) in element.optionsAdded"
:key="`opt-add-${optIdx}`"
class="text-sm text-gray-700 ml-2"
>
<UBadge color="success" size="xs" class="mr-1">+</UBadge>
{{ option.label }} ({{ option.value }})
</div>
<!-- Scrollable table container -->
<div class="overflow-x-auto border border-default rounded-lg">
<table class="min-w-full text-xs">
<thead class="bg-elevated">
<tr>
<th
class="px-3 py-2 text-left font-medium text-muted whitespace-nowrap left-0 bg-elevated z-10"
>
#
</th>
<th
class="px-3 py-2 text-left font-medium text-muted whitespace-nowrap left-8 bg-elevated z-10"
>
{{ $t('versions.tableStatus') }}
</th>
<th
v-for="col in change.tableDiff.columns"
:key="col"
class="px-3 py-2 text-left font-medium text-muted whitespace-nowrap"
>
{{ col }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-default">
<template v-for="row in change.tableDiff.rows" :key="row.rowIndex">
<!-- Show only changed rows (added, removed, modified) -->
<tr v-if="row.changeType !== 'unchanged'" :class="getRowClass(row.changeType)">
<td
class="px-3 py-2 text-muted whitespace-nowrap left-0 z-10"
:class="getRowBgClass(row.changeType)"
>
{{ row.rowIndex + 1 }}
</td>
<td class="px-3 py-2 whitespace-nowrap left-8 z-10" :class="getRowBgClass(row.changeType)">
<UBadge :color="getStatusBadgeColor(row.changeType)" variant="subtle" size="xs">
{{ getStatusLabel(row.changeType) }}
</UBadge>
</td>
<td v-for="col in change.tableDiff.columns" :key="col" class="px-3 py-2 whitespace-nowrap">
<template v-if="row.changeType === 'added'">
<span class="text-success">{{ row.currentValues[col] || '-' }}</span>
</template>
<template v-else-if="row.changeType === 'removed'">
<span class="text-error line-through">{{ row.previousValues[col] || '-' }}</span>
</template>
<template v-else-if="row.changeType === 'modified'">
<div v-if="row.previousValues[col] !== row.currentValues[col]" class="space-y-0.5">
<div class="text-error line-through text-[10px]">
{{ row.previousValues[col] || '-' }}
</div>
<div class="text-success">{{ row.currentValues[col] || '-' }}</div>
</div>
<span v-else class="text-muted">{{ row.currentValues[col] || '-' }}</span>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- Non-table Before/After display -->
<div v-else class="space-y-2 text-sm">
<!-- Previous value -->
<div class="flex items-start gap-2">
<div class="flex items-center gap-1.5 text-muted min-w-[60px] shrink-0">
<UIcon name="i-lucide-circle" class="h-3 w-3 text-muted" />
<span>{{ $t('versions.before') }}:</span>
</div>
<span :class="change.previousLabel ? 'text-default' : 'text-muted italic'">
{{ change.previousLabel || $t('versions.noValue') }}
</span>
</div>
<div v-if="element.optionsRemoved.length > 0">
<div class="text-sm font-medium text-error">
{{ $t('versions.optionsRemoved', { count: element.optionsRemoved.length }) }}:
</div>
<div
v-for="(option, optIdx) in element.optionsRemoved"
:key="`opt-rem-${optIdx}`"
class="text-sm text-gray-700 ml-2"
>
<UBadge color="error" size="xs" class="mr-1">-</UBadge>
{{ option.label }} ({{ option.value }})
</div>
</div>
<!-- Arrow indicator -->
<div class="flex items-center gap-2 pl-1">
<UIcon name="i-lucide-arrow-down" class="h-3 w-3 text-muted" />
</div>
<div v-if="element.optionsModified.length > 0">
<div class="text-sm font-medium text-warning">
{{ $t('versions.optionsModified', { count: element.optionsModified.length }) }}:
</div>
<div
v-for="(option, optIdx) in element.optionsModified"
:key="`opt-mod-${optIdx}`"
class="text-sm text-gray-700 ml-2"
>
<div>
<span class="font-medium">{{ option.value }}:</span>
<span class="text-error line-through ml-1">{{ option.labelChanged.from }}</span>
<UIcon name="i-lucide-arrow-right" class="mx-1 inline-block h-3 w-3" />
<span class="text-success">{{ option.labelChanged.to }}</span>
</div>
</div>
<!-- Current value -->
<div class="flex items-start gap-2">
<div class="flex items-center gap-1.5 text-primary min-w-[60px] shrink-0">
<UIcon name="i-lucide-circle-dot" class="h-3 w-3" />
<span>{{ $t('versions.after') }}:</span>
</div>
<span :class="change.currentLabel ? 'text-default font-medium' : 'text-muted italic'">
{{ change.currentLabel || $t('versions.noValue') }}
</span>
</div>
</div>
</div>
</div>
</UCard>
</div>
</template>
</UAccordion>
</div>
<div v-else class="text-center py-8 text-gray-500">{{ $t('versions.noChanges') }}</div>
<div v-else class="text-center py-8">
<UIcon name="i-lucide-check-circle" class="h-12 w-12 text-success mx-auto mb-3" />
<p class="text-muted">{{ $t('versions.noChanges') }}</p>
</div>
</template>
<template #footer>
@@ -132,9 +181,10 @@
</template>
<script setup lang="ts">
import type { AccordionItem } from '@nuxt/ui'
import type { ApplicationFormDto, ApplicationFormVersionDto } from '~~/.api-client'
import type { FormDiff } from '~~/types/formDiff'
import { compareApplicationForms } from '~/utils/formDiff'
import type { FormValueDiff, ValueChange, SectionChanges, TableRowDiff } from '~~/types/formDiff'
import { compareApplicationFormValues, groupChangesBySection } from '~/utils/formDiff'
const props = defineProps<{
open: boolean
@@ -152,22 +202,117 @@ const { t: $t } = useI18n()
const loading = ref(false)
const error = ref<string | null>(null)
const diff = ref<FormDiff | null>(null)
const valueDiff = ref<FormValueDiff | null>(null)
const versionData = ref<ApplicationFormVersionDto | null>(null)
const hasChanges = computed(() => {
if (!diff.value) return false
const sectionChanges = computed<SectionChanges[]>(() => {
if (!valueDiff.value) return []
return groupChangesBySection(valueDiff.value)
})
const totalChanges = computed(() => {
if (!valueDiff.value) return 0
return (
diff.value.elementsAdded.length > 0 ||
diff.value.elementsRemoved.length > 0 ||
diff.value.elementsModified.length > 0
valueDiff.value.newAnswers.length + valueDiff.value.changedAnswers.length + valueDiff.value.clearedAnswers.length
)
})
const hasChanges = computed(() => totalChanges.value > 0)
interface AccordionItemWithChanges extends AccordionItem {
changes: ValueChange[]
}
const accordionItems = computed<AccordionItemWithChanges[]>(() => {
return sectionChanges.value.map((section, index) => ({
label: `${section.sectionTitle} (${$t('versions.changesCount', { count: section.changes.length })})`,
icon: 'i-lucide-folder',
value: String(index),
changes: section.changes
}))
})
function isNewAnswer(change: ValueChange): boolean {
return !change.previousLabel && !!change.currentLabel
}
function isClearedAnswer(change: ValueChange): boolean {
return !!change.previousLabel && !change.currentLabel
}
function getRowClass(changeType: TableRowDiff['changeType']): string {
switch (changeType) {
case 'added':
return 'bg-success/5'
case 'removed':
return 'bg-error/5'
case 'modified':
return 'bg-warning/5'
default:
return ''
}
}
function getRowBgClass(changeType: TableRowDiff['changeType']): string {
switch (changeType) {
case 'added':
return 'bg-success/10'
case 'removed':
return 'bg-error/10'
case 'modified':
return 'bg-warning/10'
default:
return 'bg-elevated'
}
}
function getStatusBadgeColor(changeType: TableRowDiff['changeType']): 'success' | 'error' | 'warning' | 'neutral' {
switch (changeType) {
case 'added':
return 'success'
case 'removed':
return 'error'
case 'modified':
return 'warning'
default:
return 'neutral'
}
}
function getStatusLabel(changeType: TableRowDiff['changeType']): string {
switch (changeType) {
case 'added':
return $t('versions.rowAdded')
case 'removed':
return $t('versions.rowRemoved')
case 'modified':
return $t('versions.rowModified')
default:
return ''
}
}
// Track which version was loaded to detect when we need to reload
const loadedVersionNumber = ref<number | null>(null)
watch(
() => props.open,
async (isOpen) => {
if (isOpen && !versionData.value) {
if (isOpen) {
// Always load if version changed or no data loaded yet
if (loadedVersionNumber.value !== props.versionNumber || !versionData.value) {
await loadVersionAndCompare()
}
}
},
{ immediate: true }
)
// Also reload when version number changes while modal is open
watch(
() => props.versionNumber,
async (newVersion, oldVersion) => {
if (newVersion !== oldVersion && props.open) {
await loadVersionAndCompare()
}
}
@@ -176,21 +321,15 @@ watch(
async function loadVersionAndCompare() {
loading.value = true
error.value = null
valueDiff.value = null // Reset diff while loading
try {
versionData.value = await getVersion(props.applicationFormId, props.versionNumber)
diff.value = compareApplicationForms(props.currentForm, versionData.value.snapshot)
valueDiff.value = compareApplicationFormValues(props.currentForm, versionData.value.snapshot)
loadedVersionNumber.value = props.versionNumber
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : $t('versions.unknownError')
} finally {
loading.value = false
}
}
watch(
() => props.versionNumber,
() => {
versionData.value = null
diff.value = null
}
)
</script>