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:
365
apps/app/src/components/form-failures/FormFailureDetail.vue
Normal file
365
apps/app/src/components/form-failures/FormFailureDetail.vue
Normal 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>
|
||||
Reference in New Issue
Block a user