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:
459
apps/app/src/components/form-failures/FormFailuresTable.vue
Normal file
459
apps/app/src/components/form-failures/FormFailuresTable.vue
Normal 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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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' },
|
||||
|
||||
33
apps/app/src/pages/organisation/form-failures/index.vue
Normal file
33
apps/app/src/pages/organisation/form-failures/index.vue
Normal 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>
|
||||
24
apps/app/src/pages/platform/form-failures/index.vue
Normal file
24
apps/app/src/pages/platform/form-failures/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user