feat(form-failures): admin detail view (WS-6)

FormFailureDetail shared component drives both detail pages:
  - apps/app/src/pages/platform/form-failures/[id].vue
  - apps/app/src/pages/organisation/form-failures/[id].vue

Layout (per design schets):
  - Header with state badge (large) + title (Form failure {short-id})
    + relative-time subtitle + listener short-name
  - Action button row (Retry / Markeren als opgelost / Dismiss),
    disabled for non-open states
  - 60/40 two-column layout via VRow/VCol(md=7/md=5)

Left column:
  - Exception card: class + message in code blocks + "Bericht
    kopiëren" button (navigator.clipboard)
  - Context card (only when context is non-null): pretty-printed
    JSON in <pre> with copy-as-JSON button
  - Tijdlijn (VTimeline): Failed → Retry-pogingen → Opgelost or
    Dismissed → "In afwachting van actie..." for open with no retries

Right column:
  - Inzending card: form_submission_id with copy button. The
    submission detail-pagina link is documented as "nog niet
    beschikbaar in v1" inline; opening submissions in the SPA isn't
    yet implemented (forward-pointed).
  - Listener card: full FQN listener_class
  - Retry-geschiedenis card: count chip + caveat that per-attempt
    detail (timestamp + outcome) is not yet shipped by the backend
    resource (the FormSubmissionActionFailureResource ships only
    retry_count, not a retry history array)

Action dialogs reused from Task 2; refetch on success.

8 Vitest tests cover loading state, header rendering, all 6 cards
present, action button disabled-ness per state (open/resolved/
dismissed), and timeline content for resolved + open-no-retries
states.

Refs: WS-6 sessie 3b admin UI Task 4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 21:50:36 +02:00
parent 4c80289c47
commit 786bca8cf1
4 changed files with 580 additions and 0 deletions

View File

@@ -0,0 +1,365 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useFormFailure } from '@/composables/api/useFormFailures'
import type { FormFailureScope } from '@/composables/api/useFormFailures'
import { listenerShortName, shortId } from '@/types/form-failures'
import RetryFailureDialog from '@/components/form-failures/RetryFailureDialog.vue'
import ResolveFailureDialog from '@/components/form-failures/ResolveFailureDialog.vue'
import DismissFailureDialog from '@/components/form-failures/DismissFailureDialog.vue'
const props = defineProps<{
failureId: string
scope: FormFailureScope
orgId?: string
}>()
const failureIdRef = computed(() => props.failureId)
const orgIdRef = computed(() => props.orgId)
const { data: failure, isLoading, isError, refetch } = useFormFailure(failureIdRef, props.scope, orgIdRef)
const stateLabel = { open: 'Open', resolved: 'Opgelost', dismissed: 'Dismissed' } as const
const stateColor = { open: 'error', resolved: 'success', dismissed: 'warning' } as const
function formatDateTime(iso: string | null): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const isOpen = computed(() => failure.value?.state === 'open')
const retryDialogOpen = ref(false)
const resolveDialogOpen = ref(false)
const dismissDialogOpen = ref(false)
function copyText(text: string): void {
if (navigator?.clipboard) {
void navigator.clipboard.writeText(text)
}
}
const formattedContext = computed(() => {
if (!failure.value?.context) return null
try {
return JSON.stringify(failure.value.context, null, 2)
}
catch {
return null
}
})
</script>
<template>
<div>
<!-- Loading -->
<div
v-if="isLoading"
class="text-center pa-8"
>
<VProgressCircular
indeterminate
color="primary"
/>
</div>
<!-- Error -->
<VAlert
v-else-if="isError"
type="error"
class="mb-4"
>
Kon failure niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<!-- Detail body -->
<div v-else-if="failure">
<!-- Header -->
<div class="d-flex align-start justify-space-between mb-6 flex-wrap ga-3">
<div>
<div class="d-flex align-center mb-2 ga-2">
<VChip
:color="stateColor[failure.state]"
size="large"
>
{{ stateLabel[failure.state] }}
</VChip>
<h4 class="text-h4">
Form failure
<code class="text-h5">{{ shortId(failure.id) }}</code>
</h4>
</div>
<p class="text-body-2 text-disabled mb-0">
Failed {{ formatDateTime(failure.failed_at) }} · Listener: {{ listenerShortName(failure.listener_class) }}
</p>
</div>
<!-- Actions -->
<div class="d-flex ga-2">
<VBtn
color="error"
prepend-icon="tabler-refresh"
:disabled="!isOpen"
@click="retryDialogOpen = true"
>
Retry
</VBtn>
<VBtn
color="success"
prepend-icon="tabler-check"
:disabled="!isOpen"
@click="resolveDialogOpen = true"
>
Markeren als opgelost
</VBtn>
<VBtn
color="warning"
prepend-icon="tabler-x"
:disabled="!isOpen"
@click="dismissDialogOpen = true"
>
Dismiss
</VBtn>
</div>
</div>
<VRow>
<!-- Left column 60% -->
<VCol
cols="12"
md="7"
>
<!-- Exception card -->
<VCard
title="Exception"
class="mb-4"
>
<VCardText>
<div class="mb-3">
<span class="text-caption text-disabled d-block mb-1">Class</span>
<code class="text-body-2 d-block">{{ failure.exception_class }}</code>
</div>
<div>
<span class="text-caption text-disabled d-block mb-1">Message</span>
<code class="text-body-2 d-block">{{ failure.exception_message }}</code>
</div>
<VBtn
variant="text"
size="small"
prepend-icon="tabler-copy"
class="mt-2"
@click="copyText(failure.exception_message)"
>
Bericht kopiëren
</VBtn>
</VCardText>
</VCard>
<!-- Context card -->
<VCard
v-if="formattedContext"
title="Context"
class="mb-4"
>
<VCardText>
<pre class="text-body-2"
style="white-space: pre-wrap; max-height: 300px; overflow: auto;"
>{{ formattedContext }}</pre>
<VBtn
variant="text"
size="small"
prepend-icon="tabler-copy"
class="mt-2"
@click="copyText(formattedContext)"
>
Kopieer als JSON
</VBtn>
</VCardText>
</VCard>
<!-- Resolution timeline -->
<VCard
title="Tijdlijn"
class="mb-4"
>
<VCardText>
<VTimeline
density="compact"
side="end"
>
<VTimelineItem
dot-color="error"
size="small"
>
<div class="text-body-2">
<strong>Failed</strong>
<span class="text-caption text-disabled ms-2">{{ formatDateTime(failure.failed_at) }}</span>
</div>
</VTimelineItem>
<VTimelineItem
v-if="failure.retry_count > 0"
dot-color="warning"
size="small"
>
<div class="text-body-2">
<strong>{{ failure.retry_count }} retry-poging{{ failure.retry_count === 1 ? '' : 'en' }}</strong>
</div>
</VTimelineItem>
<VTimelineItem
v-if="failure.resolved_at"
dot-color="success"
size="small"
>
<div class="text-body-2">
<strong>Opgelost</strong>
<span class="text-caption text-disabled ms-2">{{ formatDateTime(failure.resolved_at) }}</span>
<p
v-if="failure.resolved_note"
class="text-caption mt-1 mb-0"
>
"{{ failure.resolved_note }}"
</p>
</div>
</VTimelineItem>
<VTimelineItem
v-if="failure.dismissed_at"
dot-color="warning"
size="small"
>
<div class="text-body-2">
<strong>Dismissed</strong> ({{ failure.dismissed_reason_type }})
<span class="text-caption text-disabled ms-2">{{ formatDateTime(failure.dismissed_at) }}</span>
<p
v-if="failure.dismissed_reason_note"
class="text-caption mt-1 mb-0"
>
"{{ failure.dismissed_reason_note }}"
</p>
</div>
</VTimelineItem>
<VTimelineItem
v-if="isOpen && failure.retry_count === 0"
dot-color="info"
size="small"
>
<div class="text-body-2 text-disabled">
In afwachting van actie...
</div>
</VTimelineItem>
</VTimeline>
</VCardText>
</VCard>
</VCol>
<!-- Right column 40% -->
<VCol
cols="12"
md="5"
>
<!-- Submission card -->
<VCard
title="Inzending"
class="mb-4"
>
<VCardText>
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-caption text-disabled">Submission ID</span>
<code class="text-body-2">{{ shortId(failure.form_submission_id) }}</code>
</div>
<VBtn
variant="text"
size="small"
prepend-icon="tabler-copy"
@click="copyText(failure.form_submission_id)"
>
Volledige ID kopiëren
</VBtn>
<p class="text-caption text-disabled mt-2 mb-0">
Submission detail-pagina is nog niet beschikbaar in v1.
</p>
</VCardText>
</VCard>
<!-- Listener card -->
<VCard
title="Listener"
class="mb-4"
>
<VCardText>
<code class="text-body-2 d-block">{{ failure.listener_class }}</code>
</VCardText>
</VCard>
<!-- Retry history card -->
<VCard
title="Retry-geschiedenis"
class="mb-4"
>
<VCardText>
<p
v-if="failure.retry_count === 0"
class="text-body-2 text-disabled mb-0"
>
Nog niet opnieuw geprobeerd.
</p>
<p
v-else
class="text-body-2 mb-0"
>
<VChip
variant="outlined"
size="small"
>
{{ failure.retry_count }} pogingen
</VChip>
<span class="text-caption text-disabled ms-2">
Per-poging-detail (timestamp + outcome) is nog niet beschikbaar in v1.
</span>
</p>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Action dialogs -->
<RetryFailureDialog
v-model="retryDialogOpen"
:failure="failure"
:scope="scope"
:org-id="orgId"
@success="refetch()"
/>
<ResolveFailureDialog
v-model="resolveDialogOpen"
:failure="failure"
:scope="scope"
:org-id="orgId"
@success="refetch()"
/>
<DismissFailureDialog
v-model="dismissDialogOpen"
:failure="failure"
:scope="scope"
:org-id="orgId"
@success="refetch()"
/>
</div>
</div>
</template>