Files
crewli/apps/app/src/components/organisation/EmailLogTab.vue
bert.hausmans df68aa8aef 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>
2026-04-15 20:28:38 +02:00

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>