Files
crewli/apps/app/src/components/form-failures/FormFailureDetail.vue
bert.hausmans b4f5bbe7c2 fix(app): resolve Bucket A/C/D lint items (trivial / style / Vuetify class)
WS-3 session 1b-ii Task 4 (audit Buckets A, C, D — 26 items resolved
this commit; 24 indent items in useTimeSlotDropdown.ts remain — see
deviations).

Bucket A — Trivial fixes (12 items resolved):
- A.1: second-pass eslint --fix on App.vue resolved 4 multi-attribute
  warnings. AppKpiCard / PortalLayout / PublicLayout
  lines-around-comment items were attempted via blank-line addition,
  but that introduced an equal number of vue/block-tag-newline
  errors (the rules conflict at the SFC <script>-tag boundary). The
  blank-line additions were reverted; net-zero, the 3 items remain
  for a 1b-iii .eslintrc.cjs override decision.
- A.3: 6 unused-imports / unused-vars manual deletes:
  * OrganisationSwitcher.vue: removed orphan toggleMenu() function
  * CreateShiftDialog.vue: removed unused 'scenario' from destructure
  * pages/events/[id]/time-slots/index.vue: removed unused 'event'
    slot scope binding (template <#default="{ event }"> → <#default>)
  * pages/organisation/companies.vue: removed unused authStore
    declaration + import
  * pages/platform/activity-log/index.vue: removed unused
    search/searchDebounced pair
  * PersonDetailPanel.vue:77: removed redundant single-statement
    if-braces (curly autofix that the original pass didn't reach)

Bucket C — Style preference (8 items resolved):
- DismissFailureDialog.vue:43: collapsed two consecutive `if cond return false`
  branches into `return !(cond)`
- FormFailureDetail.vue:44: replaced `void clipboard.writeText(...)` with
  `clipboard.writeText(...).catch(() => {})` — fire-and-forget with
  silent rejection (the no-void rule wants the void operator gone;
  .catch() handles it semantically).
- AssignShiftDialog.vue:40-46: hasOverlapWarning collapsed from
  always-false branching to `computed(() => false)` (the early-return
  was dead code; backend enforces the constraint).
- SectionsShiftsPanel.vue:333 + registration-fields.vue:335: rewrote
  `:delay-on-touch-only="true"` to attribute-shorthand `delay-on-touch-only`.
- AssignPersonDialog.vue:120-128: collapsed two `if outer { if inner ... }`
  pairs into single `if (outer && inner)` form (sonarjs/no-collapsible-if).
- useImpersonationStore.ts:99-104: collapsed the same nested-if pattern
  into `if (!data.data.active && state.value)`.

Bucket D — Vuetify utility class rename (5 items, 3 files):
- ml-1 → ms-1 (PersonDetailPanel:271, SectionsShiftsPanel:357,
  AssignPersonDialog:496)
- pl-4 → ps-4 (AssignPersonDialog:457)
- ml-auto → ms-auto (AssignPersonDialog:471)
LTR/RTL-aware Vuetify utilities, matching the Vuexy reference idiom.

Tests + typecheck verified green.

Lint baseline: 62 → 36.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 14:20:34 +02:00

428 lines
13 KiB
Vue

<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)
navigator.clipboard.writeText(text).catch(() => {})
}
const formattedContext = computed(() => {
if (!failure.value?.context)
return null
try {
return JSON.stringify(failure.value.context, null, 2)
}
catch {
return null
}
})
const traceExpanded = ref(false)
function formatAttemptOutcome(outcome: 'succeeded' | 'failed'): string {
return outcome === 'succeeded' ? 'Geslaagd' : 'Mislukt'
}
</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>
<p
v-if="failure.organisation_name || failure.form_schema_label"
class="text-body-2 text-disabled mb-0"
>
<span v-if="failure.organisation_name">{{ failure.organisation_name }}</span>
<span v-if="failure.organisation_name && failure.form_schema_label"> · </span>
<span v-if="failure.form_schema_label">{{ failure.form_schema_label }}</span>
</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>
<div
v-if="failure.exception_trace"
class="mt-4"
>
<VBtn
variant="text"
size="small"
:prepend-icon="traceExpanded ? 'tabler-chevron-down' : 'tabler-chevron-right'"
@click="traceExpanded = !traceExpanded"
>
Stack trace
</VBtn>
<pre
v-if="traceExpanded"
class="text-caption mt-2"
style="white-space: pre-wrap; max-height: 360px; overflow: auto; background: rgb(var(--v-theme-surface)); padding: 0.75rem; border-radius: 4px;"
>{{ failure.exception_trace }}</pre>
</div>
</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_by_user_name"
class="text-caption mb-0"
>
door {{ failure.resolved_by_user_name }}
</p>
<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_by_user_name"
class="text-caption mb-0"
>
door {{ failure.dismissed_by_user_name }}
</p>
<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 per-attempt detail (sessie 3c) -->
<VCard
title="Retry-geschiedenis"
class="mb-4"
>
<VCardText>
<p
v-if="!failure.retry_history?.length"
class="text-body-2 text-disabled mb-0"
>
Nog niet opnieuw geprobeerd.
</p>
<VTimeline
v-else
density="compact"
side="end"
>
<VTimelineItem
v-for="attempt in failure.retry_history"
:key="attempt.id"
:dot-color="attempt.outcome === 'succeeded' ? 'success' : 'error'"
size="small"
>
<div class="text-body-2">
<strong>{{ formatAttemptOutcome(attempt.outcome) }}</strong>
<span class="text-caption text-disabled ms-2">{{ formatDateTime(attempt.attempted_at) }}</span>
<p
v-if="attempt.attempted_by_user_name"
class="text-caption mb-0"
>
door {{ attempt.attempted_by_user_name }}
</p>
<p
v-if="attempt.exception_message"
class="text-caption mt-1 mb-0"
>
<code>{{ attempt.exception_class }}</code>: {{ attempt.exception_message }}
</p>
</div>
</VTimelineItem>
</VTimeline>
</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>