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>
|
||||
Reference in New Issue
Block a user