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>
460 lines
13 KiB
Vue
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>
|