Files
crewli/apps/app/src/components/form-failures/FormFailuresTable.vue
bert.hausmans 4c80289c47 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>
2026-04-29 00:14:18 +02:00

460 lines
13 KiB
Vue

<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>