feat: email infrastructure frontend — settings, templates, and log tabs
Adds three new tabs to the organisation settings page: - E-mail opmaak: replaces old EmailBrandingTab to use the new organisation_email_settings API (logo, colors, footer, reply-to) - E-mail templates: list/edit/preview/test/reset all 6 template types with variable hints, defaults comparison, and iframe preview - E-mail log: server-side paginated table with filters (search, status, type, date range), status chips, and expandable row details Supporting files: - types/email.ts: TypeScript interfaces for settings, templates, logs - composables/api/useEmail.ts: TanStack Query hooks for all email endpoints Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
349
apps/app/src/components/organisation/EmailLogTab.vue
Normal file
349
apps/app/src/components/organisation/EmailLogTab.vue
Normal file
@@ -0,0 +1,349 @@
|
||||
<script setup lang="ts">
|
||||
import { useEmailLogs } from '@/composables/api/useEmail'
|
||||
import type { EmailLog, EmailLogFilters } from '@/types/email'
|
||||
|
||||
const props = defineProps<{
|
||||
orgId: string
|
||||
}>()
|
||||
|
||||
const orgIdRef = computed(() => props.orgId)
|
||||
|
||||
const filters = ref<EmailLogFilters>({
|
||||
page: 1,
|
||||
perPage: 15,
|
||||
search: '',
|
||||
status: '',
|
||||
templateType: '',
|
||||
eventId: '',
|
||||
from: '',
|
||||
to: '',
|
||||
})
|
||||
|
||||
const searchInput = ref('')
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(searchInput, value => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
filters.value = { ...filters.value, search: value, page: 1 }
|
||||
}, 300)
|
||||
})
|
||||
|
||||
const filtersRef = computed(() => filters.value)
|
||||
const { data: logsResponse, isLoading, isError, refetch } = useEmailLogs(orgIdRef, filtersRef)
|
||||
|
||||
const logs = computed(() => logsResponse.value?.data ?? [])
|
||||
const totalItems = computed(() => logsResponse.value?.meta?.total ?? 0)
|
||||
|
||||
const headers = [
|
||||
{ title: 'Ontvanger', key: 'recipient_email' },
|
||||
{ title: 'Type', key: 'template_label', width: '180px' },
|
||||
{ title: 'Onderwerp', key: 'subject' },
|
||||
{ title: 'Status', key: 'status', width: '120px' },
|
||||
{ title: 'Verstuurd op', key: 'sent_at', width: '160px' },
|
||||
{ title: 'Door', key: 'triggered_by', width: '140px' },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ title: 'Alle', value: '' },
|
||||
{ title: 'Verstuurd', value: 'sent' },
|
||||
{ title: 'In wachtrij', value: 'queued' },
|
||||
{ title: 'Mislukt', value: 'failed' },
|
||||
]
|
||||
|
||||
const templateTypeOptions = [
|
||||
{ title: 'Alle', value: '' },
|
||||
{ title: 'Uitnodiging', value: 'invitation' },
|
||||
{ title: 'Wachtwoord resetten', value: 'password_reset' },
|
||||
{ title: 'E-mailadres verificatie', value: 'email_verification' },
|
||||
{ title: 'Registratie goedgekeurd', value: 'registration_approved' },
|
||||
{ title: 'Registratie afgewezen', value: 'registration_rejected' },
|
||||
{ title: 'Diensttoewijzing', value: 'shift_assignment' },
|
||||
]
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
sent: 'success',
|
||||
queued: 'warning',
|
||||
failed: 'error',
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
sent: 'Verstuurd',
|
||||
queued: 'In wachtrij',
|
||||
failed: 'Mislukt',
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return '-'
|
||||
const d = new Date(value)
|
||||
|
||||
return d.toLocaleDateString('nl-NL', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length > max ? `${text.slice(0, max)}...` : text
|
||||
}
|
||||
|
||||
function onUpdateOptions(options: { page: number; itemsPerPage: number }) {
|
||||
filters.value = {
|
||||
...filters.value,
|
||||
page: options.page,
|
||||
perPage: options.itemsPerPage,
|
||||
}
|
||||
}
|
||||
|
||||
function onStatusFilter(status: string) {
|
||||
filters.value = { ...filters.value, status, page: 1 }
|
||||
}
|
||||
|
||||
function onTypeFilter(templateType: string) {
|
||||
filters.value = { ...filters.value, templateType, page: 1 }
|
||||
}
|
||||
|
||||
// ── Expanded rows ──
|
||||
const expanded = ref<string[]>([])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VCard>
|
||||
<VCardTitle>E-mail log</VCardTitle>
|
||||
<VCardSubtitle>Overzicht van alle verzonden e-mails</VCardSubtitle>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="4"
|
||||
md="3"
|
||||
>
|
||||
<VTextField
|
||||
v-model="searchInput"
|
||||
prepend-inner-icon="tabler-search"
|
||||
placeholder="Zoek op e-mailadres"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
@click:clear="searchInput = ''"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="6"
|
||||
sm="4"
|
||||
md="2"
|
||||
>
|
||||
<VSelect
|
||||
:model-value="filters.status"
|
||||
:items="statusOptions"
|
||||
label="Status"
|
||||
density="compact"
|
||||
hide-details
|
||||
@update:model-value="onStatusFilter"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="6"
|
||||
sm="4"
|
||||
md="3"
|
||||
>
|
||||
<VSelect
|
||||
:model-value="filters.templateType"
|
||||
:items="templateTypeOptions"
|
||||
label="Type"
|
||||
density="compact"
|
||||
hide-details
|
||||
@update:model-value="onTypeFilter"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="6"
|
||||
sm="4"
|
||||
md="2"
|
||||
>
|
||||
<VTextField
|
||||
:model-value="filters.from"
|
||||
type="date"
|
||||
label="Van"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
@update:model-value="(v: string | null) => filters = { ...filters, from: v ?? '', page: 1 }"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="6"
|
||||
sm="4"
|
||||
md="2"
|
||||
>
|
||||
<VTextField
|
||||
:model-value="filters.to"
|
||||
type="date"
|
||||
label="Tot"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
@update:model-value="(v: string | null) => filters = { ...filters, to: v ?? '', page: 1 }"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
|
||||
<!-- Loading -->
|
||||
<VSkeletonLoader
|
||||
v-if="isLoading"
|
||||
type="table-row@5"
|
||||
/>
|
||||
|
||||
<!-- Error -->
|
||||
<VAlert
|
||||
v-else-if="isError"
|
||||
type="error"
|
||||
class="mx-4 mb-4"
|
||||
>
|
||||
Kon e-mail log niet laden.
|
||||
<template #append>
|
||||
<VBtn
|
||||
variant="text"
|
||||
@click="refetch()"
|
||||
>
|
||||
Opnieuw proberen
|
||||
</VBtn>
|
||||
</template>
|
||||
</VAlert>
|
||||
|
||||
<!-- Empty -->
|
||||
<div
|
||||
v-else-if="!logs.length"
|
||||
class="text-center pa-8"
|
||||
>
|
||||
<VIcon
|
||||
icon="tabler-mail-off"
|
||||
size="48"
|
||||
class="mb-4 text-disabled"
|
||||
/>
|
||||
<p class="text-body-1 text-disabled">
|
||||
Nog geen e-mails verzonden
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Data table -->
|
||||
<VDataTableServer
|
||||
v-else
|
||||
v-model:expanded="expanded"
|
||||
:headers="headers"
|
||||
:items="logs"
|
||||
:items-length="totalItems"
|
||||
:items-per-page="filters.perPage"
|
||||
:page="filters.page"
|
||||
item-value="id"
|
||||
hover
|
||||
show-expand
|
||||
@update:options="onUpdateOptions"
|
||||
>
|
||||
<template #item.recipient_email="{ item }">
|
||||
<div>
|
||||
<span>{{ item.recipient_email }}</span>
|
||||
<span
|
||||
v-if="item.recipient_name"
|
||||
class="text-caption text-disabled d-block"
|
||||
>{{ item.recipient_name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.template_label="{ item }">
|
||||
<VChip
|
||||
size="small"
|
||||
color="default"
|
||||
>
|
||||
{{ item.template_label }}
|
||||
</VChip>
|
||||
</template>
|
||||
|
||||
<template #item.subject="{ item }">
|
||||
{{ truncate(item.subject, 50) }}
|
||||
</template>
|
||||
|
||||
<template #item.status="{ item }">
|
||||
<VChip
|
||||
:color="statusColor[item.status] ?? 'default'"
|
||||
size="small"
|
||||
>
|
||||
{{ statusLabel[item.status] ?? item.status }}
|
||||
</VChip>
|
||||
</template>
|
||||
|
||||
<template #item.sent_at="{ item }">
|
||||
{{ formatDateTime(item.sent_at ?? item.queued_at) }}
|
||||
</template>
|
||||
|
||||
<template #item.triggered_by="{ item }">
|
||||
{{ item.triggered_by?.name ?? 'Systeem' }}
|
||||
</template>
|
||||
|
||||
<!-- Expanded row -->
|
||||
<template #expanded-row="{ columns, item }">
|
||||
<tr>
|
||||
<td :colspan="columns.length">
|
||||
<div class="pa-4">
|
||||
<VRow dense>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<p class="text-caption font-weight-bold mb-1">
|
||||
Volledig onderwerp
|
||||
</p>
|
||||
<p class="text-body-2 mb-3">
|
||||
{{ item.subject }}
|
||||
</p>
|
||||
</VCol>
|
||||
<VCol
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<p class="text-caption font-weight-bold mb-1">
|
||||
Tijdstempels
|
||||
</p>
|
||||
<p class="text-body-2 mb-0">
|
||||
In wachtrij: {{ formatDateTime(item.queued_at) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="item.sent_at"
|
||||
class="text-body-2 mb-0"
|
||||
>
|
||||
Verstuurd: {{ formatDateTime(item.sent_at) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="item.failed_at"
|
||||
class="text-body-2 mb-0 text-error"
|
||||
>
|
||||
Mislukt: {{ formatDateTime(item.failed_at) }}
|
||||
</p>
|
||||
</VCol>
|
||||
<VCol
|
||||
v-if="item.error_message"
|
||||
cols="12"
|
||||
>
|
||||
<VAlert
|
||||
type="error"
|
||||
density="compact"
|
||||
variant="tonal"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ item.error_message }}
|
||||
</VAlert>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</VDataTableServer>
|
||||
</VCard>
|
||||
</template>
|
||||
Reference in New Issue
Block a user