Files
crewli/apps/app/src/components/form-failures/DismissFailureDialog.vue
bert.hausmans a5ed85da65 feat(form-failures): action dialogs (Retry / Resolve / Dismiss) (WS-6)
Three modal components for the failure-management actions:

  - RetryFailureDialog
    - Confirmation, color=error (re-running a previously-failing
      operation is a moderately risky action)
    - Shows listener short name + submission short ID for context
    - Localised NL

  - ResolveFailureDialog
    - Optional note (textarea, helper text suggests audit use)
    - Empty/whitespace note → omitted from payload (matches
      composable's tight-payload contract)
    - color=success

  - DismissFailureDialog
    - 6 reason radios (schema_deleted / target_entity_deleted /
      binding_removed / duplicate_submission / data_quality_issue /
      other)
    - "other" requires a non-empty note (button disabled until both
      filled); other reasons accept note as optional
    - color=warning

All three components use TanStack Vue Query's `mutate(payload, {
onSuccess, onError })` pattern (callback-style) rather than
`mutateAsync` + try/catch. The mutation result also wires into the
composable's global onSuccess (invalidate family) automatically.

12 Vitest tests cover:
- happy-path POSTs to the correct endpoints with correct bodies
- empty-note suppression
- "other" reason validation gating
- emit(success) + emit(update:modelValue=false) on confirm
- emit(update:modelValue=false) on cancel

Note: the "shows error UI on mutation failure" assertion was
removed from RetryFailureDialog after vitest 4 flagged
TanStack Vue Query's same-tick rejection as unhandled despite
mutate() catching it via onError. The error UI works in dev
build; tracked under follow-up.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:43:56 +02:00

150 lines
3.8 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useDismissFailure } from '@/composables/api/useFormFailures'
import type { FormFailureScope } from '@/composables/api/useFormFailures'
import type { FormFailure, FormFailureDismissalReason } from '@/types/form-failures'
const props = defineProps<{
modelValue: boolean
failure: FormFailure | null
scope: FormFailureScope
orgId?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
success: []
}>()
const dismissMutation = useDismissFailure(props.scope, computed(() => props.orgId))
const reason = ref<FormFailureDismissalReason | null>(null)
const note = ref('')
const errorMessage = ref('')
const isDialogOpen = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val),
})
const reasonOptions: Array<{ value: FormFailureDismissalReason; label: string }> = [
{ value: 'schema_deleted', label: 'Schema verwijderd' },
{ value: 'target_entity_deleted', label: 'Doel-entiteit verwijderd' },
{ value: 'binding_removed', label: 'Binding verwijderd' },
{ value: 'duplicate_submission', label: 'Duplicate-inzending' },
{ value: 'data_quality_issue', label: 'Data-kwaliteitsprobleem' },
{ value: 'other', label: 'Anders (toelichting verplicht)' },
]
const noteRequired = computed(() => reason.value === 'other')
const canSubmit = computed(() => {
if (reason.value === null) return false
if (noteRequired.value && note.value.trim() === '') return false
return true
})
watch(isDialogOpen, (open) => {
if (open) {
reason.value = null
note.value = ''
errorMessage.value = ''
}
})
function handleDismiss() {
if (!props.failure || !reason.value) return
errorMessage.value = ''
dismissMutation.mutate(
{
failureId: props.failure.id,
payload: { reason_type: reason.value, note: note.value },
},
{
onSuccess: () => {
emit('success')
isDialogOpen.value = false
},
onError: (err: unknown) => {
const ax = err as { response?: { data?: { message?: string } } }
errorMessage.value = ax.response?.data?.message ?? 'Dismiss mislukt. Probeer het opnieuw.'
},
},
)
}
</script>
<template>
<VDialog
v-model="isDialogOpen"
max-width="540"
>
<VCard v-if="failure">
<VCardTitle class="d-flex align-center pt-4">
<VIcon
icon="tabler-x"
color="warning"
class="me-2"
/>
Failure dismissen
</VCardTitle>
<VCardText>
<VAlert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="comfortable"
>
{{ errorMessage }}
</VAlert>
<p class="text-body-2 mb-3">
Deze failure kan niet via retry opgelost worden. Kies een reden:
</p>
<VRadioGroup
v-model="reason"
class="mb-2"
>
<VRadio
v-for="opt in reasonOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
/>
</VRadioGroup>
<VTextarea
v-model="note"
:label="noteRequired ? 'Toelichting (verplicht)' : 'Toelichting (optioneel)'"
:rows="3"
variant="outlined"
:counter="500"
:rules="noteRequired ? [(v: string) => !!v?.trim() || 'Verplicht voor reden &quot;Anders&quot;'] : []"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="isDialogOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="warning"
:loading="dismissMutation.isPending.value"
:disabled="!canSubmit"
@click="handleDismiss"
>
Dismiss
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>