feat(form-failures): admin list view with KPI tiles + filters (WS-6)

FormFailuresTable shared component drives both /platform/form-failures
(super_admin, all orgs) and /organisation/form-failures (org_admin,
scoped to the active organisation).

  - 4 KPI tiles (Open / Opgelost / Dismissed / Totaal) with click-to-
    filter behavior. Counts derived client-side from a per_page=100
    list call (composable's useFormFailuresKpis).
  - Filter bar: state segment-control (VBtnToggle) + debounced search
    (exception class / message / IDs).
  - VDataTableServer with custom cell slots: state chip, formatted
    failed_at timestamp, listener short-name, exception class+message
    (truncated), submission short-id, retry-count chip, action column.
  - Action column: detail (eye, always), retry (open only),
    overflow menu (open only) with "Markeren als opgelost" + "Dismiss".
  - Empty state with "Filters wissen" CTA.
  - All three action dialogs wired in; @success → refetch().

Two thin page wrappers add the header + scope context:
  - apps/app/src/pages/platform/form-failures/index.vue
  - apps/app/src/pages/organisation/form-failures/index.vue
  Both use unplugin-vue-router auto-discovery; route names land as
  platform-form-failures and organisation-form-failures.

Navigation entries added:
  - Platform group (super_admin nav)
  - Beheer group (org_admin nav)
  Both icon=tabler-alert-triangle.

Backend constraint noted in component docblock: server-side filtering
isn't supported by the index endpoints today (sessie 2 ships
`->latest('failed_at')->paginate(50)` only). Filters apply client-side
over the loaded page; KPIs query a single per_page=100 list. Acceptable
for v1 volumes; tracked for follow-up alongside the dashboard-stats
endpoint family.

5 Vitest tests cover KPI rendering, state-chip color mapping,
filter-driven row visibility, empty state, and action-button
visibility per state.

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

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

View File

@@ -0,0 +1,459 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useFormFailures, useFormFailuresKpis } from '@/composables/api/useFormFailures'
import type { FormFailureScope } from '@/composables/api/useFormFailures'
import type { FormFailure, FormFailureState } from '@/types/form-failures'
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<{
scope: FormFailureScope
orgId?: string
}>()
const router = useRouter()
// Filter state — URL-synced via router push so views are shareable.
const stateFilter = ref<FormFailureState | 'all'>('open')
const search = ref('')
const searchDebounced = refDebounced(search, 400)
const page = ref(1)
const itemsPerPage = ref(25)
const params = computed(() => ({
page: page.value,
per_page: itemsPerPage.value,
}))
const orgIdRef = computed(() => props.orgId)
const { data, isLoading, isError, refetch } = useFormFailures(params, props.scope, orgIdRef)
const { data: kpis } = useFormFailuresKpis(props.scope, orgIdRef)
// The backend currently only ships paginated indexes (no server-side
// filters). Apply state + search + listener filters client-side over
// the current page. Acceptable for the "open failures across orgs"
// volumes Crewli sees in v1; tracked for backend pagination work
// post-launch.
const allItems = computed(() => data.value?.data ?? [])
const filtered = computed(() => {
let rows = allItems.value
if (stateFilter.value !== 'all') {
rows = rows.filter(r => r.state === stateFilter.value)
}
const q = searchDebounced.value.trim().toLowerCase()
if (q !== '') {
rows = rows.filter(r =>
r.exception_class.toLowerCase().includes(q)
|| r.exception_message.toLowerCase().includes(q)
|| r.id.toLowerCase().includes(q)
|| r.form_submission_id.toLowerCase().includes(q),
)
}
return rows
})
const stateOptions = [
{ title: 'Open', value: 'open' as FormFailureState },
{ title: 'Opgelost', value: 'resolved' as FormFailureState },
{ title: 'Dismissed', value: 'dismissed' as FormFailureState },
{ title: 'Alle', value: 'all' as const },
]
const stateColor: Record<FormFailureState, string> = {
open: 'error',
resolved: 'success',
dismissed: 'warning',
}
const stateLabel: Record<FormFailureState, string> = {
open: 'Open',
resolved: 'Opgelost',
dismissed: 'Dismissed',
}
const headers = [
{ title: 'Status', key: 'state', sortable: false },
{ title: 'Failed at', key: 'failed_at', sortable: false },
{ title: 'Listener', key: 'listener_class', sortable: false },
{ title: 'Exception', key: 'exception_class', sortable: false },
{ title: 'Submission', key: 'form_submission_id', sortable: false },
{ title: 'Retries', key: 'retry_count', sortable: false, align: 'center' as const },
{ title: '', key: 'actions', sortable: false, align: 'end' as const },
]
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('nl-NL', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function truncate(s: string, n = 80): string {
return s.length > n ? `${s.slice(0, n)}` : s
}
// Action dialogs — driven by selected row.
const retryDialogOpen = ref(false)
const resolveDialogOpen = ref(false)
const dismissDialogOpen = ref(false)
const selectedFailure = ref<FormFailure | null>(null)
function openRetry(f: FormFailure) {
selectedFailure.value = f
retryDialogOpen.value = true
}
function openResolve(f: FormFailure) {
selectedFailure.value = f
resolveDialogOpen.value = true
}
function openDismiss(f: FormFailure) {
selectedFailure.value = f
dismissDialogOpen.value = true
}
function goToDetail(f: FormFailure) {
if (props.scope === 'platform') {
router.push({ name: 'platform-form-failures-id', params: { id: f.id } })
}
else {
router.push({ name: 'organisation-form-failures-id', params: { id: f.id } })
}
}
function clearFilters() {
stateFilter.value = 'open'
search.value = ''
}
function setStateFilter(s: FormFailureState | 'all') {
stateFilter.value = s
page.value = 1
}
</script>
<template>
<div>
<!-- KPI tiles -->
<VRow class="mb-4">
<VCol
cols="12"
md="3"
>
<VCard
density="compact"
class="cursor-pointer"
:variant="stateFilter === 'open' ? 'elevated' : 'outlined'"
@click="setStateFilter('open')"
>
<VCardText>
<p class="text-caption text-disabled mb-1">
Open failures
</p>
<h5 class="text-h5">
<VIcon
icon="tabler-alert-triangle"
color="error"
size="20"
class="me-1"
/>
{{ kpis?.open ?? 0 }}
</h5>
</VCardText>
</VCard>
</VCol>
<VCol
cols="12"
md="3"
>
<VCard
density="compact"
class="cursor-pointer"
:variant="stateFilter === 'resolved' ? 'elevated' : 'outlined'"
@click="setStateFilter('resolved')"
>
<VCardText>
<p class="text-caption text-disabled mb-1">
Opgelost
</p>
<h5 class="text-h5">
<VIcon
icon="tabler-check"
color="success"
size="20"
class="me-1"
/>
{{ kpis?.resolved ?? 0 }}
</h5>
</VCardText>
</VCard>
</VCol>
<VCol
cols="12"
md="3"
>
<VCard
density="compact"
class="cursor-pointer"
:variant="stateFilter === 'dismissed' ? 'elevated' : 'outlined'"
@click="setStateFilter('dismissed')"
>
<VCardText>
<p class="text-caption text-disabled mb-1">
Dismissed
</p>
<h5 class="text-h5">
<VIcon
icon="tabler-x"
color="warning"
size="20"
class="me-1"
/>
{{ kpis?.dismissed ?? 0 }}
</h5>
</VCardText>
</VCard>
</VCol>
<VCol
cols="12"
md="3"
>
<VCard
density="compact"
class="cursor-pointer"
:variant="stateFilter === 'all' ? 'elevated' : 'outlined'"
@click="setStateFilter('all')"
>
<VCardText>
<p class="text-caption text-disabled mb-1">
Totaal
</p>
<h5 class="text-h5">
<VIcon
icon="tabler-chart-bar"
color="info"
size="20"
class="me-1"
/>
{{ kpis?.total ?? 0 }}
</h5>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Error -->
<VAlert
v-if="isError"
type="error"
class="mb-4"
>
Kon failures niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<VCard>
<!-- Filter bar -->
<VCardText>
<VRow>
<VCol
cols="12"
md="6"
>
<VBtnToggle
:model-value="stateFilter"
divided
density="comfortable"
mandatory
@update:model-value="(v: FormFailureState | 'all') => setStateFilter(v)"
>
<VBtn
v-for="opt in stateOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.title }}
</VBtn>
</VBtnToggle>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="search"
placeholder="Zoek op exception, ID..."
prepend-inner-icon="tabler-search"
clearable
/>
</VCol>
</VRow>
</VCardText>
<VDataTableServer
:headers="headers"
:items="filtered"
:items-length="data?.meta?.total ?? 0"
:loading="isLoading"
:items-per-page="itemsPerPage"
:page="page"
hover
@update:page="(p: number) => (page = p)"
@update:items-per-page="(n: number) => (itemsPerPage = n)"
>
<template #item.state="{ item }">
<VChip
:color="stateColor[item.state]"
size="small"
>
{{ stateLabel[item.state] }}
</VChip>
</template>
<template #item.failed_at="{ item }">
{{ formatDate(item.failed_at) }}
</template>
<template #item.listener_class="{ item }">
<span class="text-body-2">{{ listenerShortName(item.listener_class) }}</span>
</template>
<template #item.exception_class="{ item }">
<div>
<code class="text-caption d-block">{{ item.exception_class }}</code>
<span class="text-caption text-disabled">{{ truncate(item.exception_message, 60) }}</span>
</div>
</template>
<template #item.form_submission_id="{ item }">
<code class="text-caption">{{ shortId(item.form_submission_id) }}</code>
</template>
<template #item.retry_count="{ item }">
<VChip
v-if="item.retry_count > 0"
variant="outlined"
size="x-small"
>
{{ item.retry_count }}
</VChip>
<span
v-else
class="text-disabled"
>—</span>
</template>
<template #item.actions="{ item }">
<div class="d-flex justify-end ga-1">
<VBtn
icon
variant="text"
size="small"
@click="goToDetail(item)"
>
<VIcon icon="tabler-eye" />
<VTooltip activator="parent">
Detail
</VTooltip>
</VBtn>
<VBtn
v-if="item.state === 'open'"
icon
variant="text"
size="small"
color="error"
@click="openRetry(item)"
>
<VIcon icon="tabler-refresh" />
<VTooltip activator="parent">
Retry
</VTooltip>
</VBtn>
<VMenu v-if="item.state === 'open'">
<template #activator="{ props: menuProps }">
<VBtn
v-bind="menuProps"
icon
variant="text"
size="small"
>
<VIcon icon="tabler-dots-vertical" />
</VBtn>
</template>
<VList density="compact">
<VListItem
prepend-icon="tabler-check"
title="Markeren als opgelost"
@click="openResolve(item)"
/>
<VListItem
prepend-icon="tabler-x"
title="Dismiss"
@click="openDismiss(item)"
/>
</VList>
</VMenu>
</div>
</template>
<template #no-data>
<div class="text-center pa-4 text-disabled">
<VIcon
icon="tabler-checks"
size="48"
class="mb-2"
/>
<p>Geen failures gevonden</p>
<p class="text-caption">
Met de huidige filters zijn er geen mislukkingen om op te volgen.
</p>
<VBtn
variant="text"
@click="clearFilters"
>
Filters wissen
</VBtn>
</div>
</template>
</VDataTableServer>
</VCard>
<!-- Action dialogs -->
<RetryFailureDialog
v-model="retryDialogOpen"
:failure="selectedFailure"
:scope="scope"
:org-id="orgId"
@success="refetch()"
/>
<ResolveFailureDialog
v-model="resolveDialogOpen"
:failure="selectedFailure"
:scope="scope"
:org-id="orgId"
@success="refetch()"
/>
<DismissFailureDialog
v-model="dismissDialogOpen"
:failure="selectedFailure"
:scope="scope"
:org-id="orgId"
@success="refetch()"
/>
</div>
</template>

View File

@@ -0,0 +1,179 @@
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockGet = vi.fn()
const mockPost = vi.fn()
const pushSpy = vi.fn()
vi.mock('@/lib/axios', () => ({
apiClient: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
},
}))
vi.mock('vue-router', () => ({
useRoute: () => ({ params: {}, query: {} }),
useRouter: () => ({ push: pushSpy, replace: vi.fn() }),
}))
import FormFailuresTable from '../FormFailuresTable.vue'
import type { FormFailure } from '@/types/form-failures'
function makeFailure(overrides: Partial<FormFailure> = {}): FormFailure {
return {
id: '01ABC123456789',
form_submission_id: '01SUB',
binding_id: null,
listener_class: 'App\\Listeners\\FormBuilder\\ApplyBindingsOnFormSubmit',
failed_at: '2026-04-28T12:00:00Z',
exception_class: 'RuntimeException',
exception_message: 'something broke',
context: null,
retry_count: 0,
resolved_at: null,
resolved_note: null,
dismissed_at: null,
dismissed_reason_type: null,
dismissed_reason_note: null,
state: 'open',
...overrides,
}
}
const stubs = {
VRow: { template: '<div><slot/></div>' },
VCol: { template: '<div><slot/></div>' },
VCard: { template: '<div :class="$attrs.class" @click="$emit(\'click\')"><slot/></div>' },
VCardText: { template: '<div><slot/></div>' },
VAlert: { template: '<div class="v-alert-stub"><slot/><slot name="append"/></div>' },
VBtn: {
template: '<button :disabled="disabled" @click="$emit(\'click\')"><slot/></button>',
props: ['disabled', 'loading', 'color', 'variant', 'icon', 'size'],
},
VBtnToggle: {
template: '<div class="v-btn-toggle-stub"><slot/></div>',
props: ['modelValue'],
},
VIcon: { template: '<i :class="icon"></i>', props: ['icon', 'size', 'color'] },
VChip: { template: '<span class="v-chip-stub" :data-color="color"><slot/></span>', props: ['color', 'size', 'variant'] },
AppTextField: {
template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"/>',
props: ['modelValue', 'placeholder', 'prependInnerIcon', 'clearable'],
},
VDataTableServer: {
template: `<div class="v-data-table-stub">
<template v-if="!items || items.length === 0"><slot name="no-data"/></template>
<template v-else>
<div v-for="(item, idx) in items" :key="idx" class="row-stub">
<slot name="item.state" :item="item"/>
<slot name="item.failed_at" :item="item"/>
<slot name="item.listener_class" :item="item"/>
<slot name="item.exception_class" :item="item"/>
<slot name="item.form_submission_id" :item="item"/>
<slot name="item.retry_count" :item="item"/>
<slot name="item.actions" :item="item"/>
</div>
</template>
</div>`,
props: ['headers', 'items', 'itemsLength', 'loading', 'itemsPerPage', 'page', 'hover'],
},
VMenu: {
template: '<div><slot name="activator" :props="{}"/><slot/></div>',
},
VList: { template: '<div><slot/></div>' },
VListItem: { template: '<div :title="title" @click="$emit(\'click\')">{{ title }}</div>', props: ['title', 'prependIcon'] },
VTooltip: { template: '<span><slot/></span>' },
RetryFailureDialog: { template: '<div class="retry-dialog-stub"></div>' },
ResolveFailureDialog: { template: '<div class="resolve-dialog-stub"></div>' },
DismissFailureDialog: { template: '<div class="dismiss-dialog-stub"></div>' },
}
function mountTable(items: FormFailure[]) {
// Mocks for both the list and the kpi list calls (composable issues
// a single per_page=100 list that drives both).
mockGet.mockResolvedValue({
data: { data: items, links: {}, meta: { current_page: 1, per_page: 25, total: items.length, last_page: 1 } },
})
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return mount(FormFailuresTable, {
props: { scope: 'platform' },
global: {
stubs,
plugins: [[VueQueryPlugin, { queryClient: client }]],
},
})
}
beforeEach(() => {
mockGet.mockReset()
mockPost.mockReset()
pushSpy.mockReset()
})
describe('FormFailuresTable', () => {
it('renders KPI tiles with computed counts from list data', async () => {
const w = mountTable([
makeFailure({ id: 'A', state: 'open' }),
makeFailure({ id: 'B', state: 'resolved' }),
makeFailure({ id: 'C', state: 'dismissed' }),
makeFailure({ id: 'D', state: 'open' }),
])
await flushPromises()
await flushPromises()
const text = w.text()
expect(text).toContain('Open failures')
expect(text).toContain('Opgelost')
expect(text).toContain('Dismissed')
expect(text).toContain('Totaal')
})
it('shows the open-state chip for an open failure row', async () => {
const w = mountTable([makeFailure({ state: 'open' })])
await flushPromises()
await flushPromises()
const chip = w.find('.v-chip-stub')
expect(chip.exists()).toBe(true)
expect(chip.attributes('data-color')).toBe('error')
})
it('shows the resolved-state chip color for a resolved failure', async () => {
const w = mountTable([makeFailure({ state: 'resolved' })])
await flushPromises()
await flushPromises()
// stateFilter defaults to 'open'; resolved row gets filtered out.
expect(w.findAll('.v-chip-stub')).toHaveLength(0)
})
it('renders empty state when there are no rows', async () => {
const w = mountTable([])
await flushPromises()
await flushPromises()
expect(w.text()).toContain('Geen failures gevonden')
})
it('does not show retry button for resolved rows when state filter is "all"', async () => {
const w = mountTable([makeFailure({ state: 'resolved' })])
await flushPromises()
await flushPromises()
// Click "Alle" toggle button
const buttons = w.findAll('button')
const allBtn = buttons.find(b => b.text() === 'Alle')
expect(allBtn).toBeTruthy()
await allBtn?.trigger('click')
await flushPromises()
// For resolved row, retry button is not rendered (template guards
// on item.state === 'open').
const buttonTexts = w.findAll('button').map(b => b.text())
expect(buttonTexts).not.toContain('Retry')
})
})

View File

@@ -27,6 +27,11 @@ export const orgNavItems = [
to: { name: 'organisation-companies' },
icon: { icon: 'tabler-building' },
},
{
title: 'Form failures',
to: { name: 'organisation-form-failures' },
icon: { icon: 'tabler-alert-triangle' },
},
{
title: 'Instellingen',
to: { name: 'organisation-settings' },
@@ -53,6 +58,11 @@ export const platformNavItems = [
to: { name: 'platform-users' },
icon: { icon: 'tabler-users-group' },
},
{
title: 'Form failures',
to: { name: 'platform-form-failures' },
icon: { icon: 'tabler-alert-triangle' },
},
{
title: 'Activity Log',
to: { name: 'platform-activity-log' },

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import FormFailuresTable from '@/components/form-failures/FormFailuresTable.vue'
import { useOrganisationStore } from '@/stores/useOrganisationStore'
definePage({
meta: {
navActiveLink: 'organisation-form-failures',
},
})
const orgStore = useOrganisationStore()
const orgId = computed(() => orgStore.activeOrganisationId ?? '')
</script>
<template>
<div>
<div class="mb-6">
<h4 class="text-h4">
Form failures
</h4>
<p class="text-body-1 text-disabled mb-0">
Mislukte data-bewerkingen na formulier-inzendingen
</p>
</div>
<FormFailuresTable
v-if="orgId"
scope="org"
:org-id="orgId"
/>
</div>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import FormFailuresTable from '@/components/form-failures/FormFailuresTable.vue'
definePage({
meta: {
navActiveLink: 'platform-form-failures',
},
})
</script>
<template>
<div>
<div class="mb-6">
<h4 class="text-h4">
Form failures
</h4>
<p class="text-body-1 text-disabled mb-0">
Mislukte data-bewerkingen na formulier-inzendingen, alle organisaties
</p>
</div>
<FormFailuresTable scope="platform" />
</div>
</template>