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>
350 lines
8.9 KiB
Vue
350 lines
8.9 KiB
Vue
<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>
|