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:
2026-04-15 20:28:38 +02:00
parent 65978104d8
commit df68aa8aef
7 changed files with 1211 additions and 76 deletions

View File

@@ -59,6 +59,8 @@ declare module 'vue' {
EditPersonDialog: typeof import('./src/components/persons/EditPersonDialog.vue')['default']
EditSectionDialog: typeof import('./src/components/sections/EditSectionDialog.vue')['default']
EmailBrandingTab: typeof import('./src/components/organisation/EmailBrandingTab.vue')['default']
EmailLogTab: typeof import('./src/components/organisation/EmailLogTab.vue')['default']
EmailTemplatesTab: typeof import('./src/components/organisation/EmailTemplatesTab.vue')['default']
EnableOneTimePasswordDialog: typeof import('./src/components/dialogs/EnableOneTimePasswordDialog.vue')['default']
ErrorHeader: typeof import('./src/components/ErrorHeader.vue')['default']
EventMetricCards: typeof import('./src/components/events/EventMetricCards.vue')['default']

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import { useOrganisationDetail, useUpdateOrganisation } from '@/composables/api/useOrganisations'
import { useEmailSettings, useUpdateEmailSettings } from '@/composables/api/useEmail'
import { emailValidator } from '@core/utils/validators'
const props = defineProps<{
@@ -9,29 +9,31 @@ const props = defineProps<{
const orgIdRef = computed(() => props.orgId)
const { data: organisation } = useOrganisationDetail(orgIdRef)
const { mutate: updateOrg, isPending: isSaving } = useUpdateOrganisation()
const { data: settings, isLoading, isError, refetch } = useEmailSettings(orgIdRef)
const { mutate: updateSettings, isPending: isSaving } = useUpdateEmailSettings(orgIdRef)
const form = ref<InstanceType<typeof VForm>>()
const snackbar = ref(false)
const emailLogoUrl = ref<string>('')
const emailPrimaryColor = ref<string>('')
const emailReplyTo = ref<string>('')
const emailSenderName = ref<string>('')
const emailFooterText = ref<string>('')
const serverErrors = ref<Record<string, string[]>>({})
const showColorPicker = ref(false)
const isDev = import.meta.env.DEV
const logoUrl = ref('')
const primaryColor = ref('#6366F1')
const secondaryColor = ref('#4F46E5')
const footerText = ref('')
const replyToEmail = ref('')
const replyToName = ref('')
watch(organisation, org => {
if (org) {
emailLogoUrl.value = org.email_logo_url ?? ''
emailPrimaryColor.value = org.email_primary_color ?? ''
emailReplyTo.value = org.email_reply_to ?? ''
emailSenderName.value = org.email_sender_name ?? ''
emailFooterText.value = org.email_footer_text ?? ''
const showPrimaryPicker = ref(false)
const showSecondaryPicker = ref(false)
watch(settings, s => {
if (s) {
logoUrl.value = s.logo_url ?? ''
primaryColor.value = s.primary_color ?? '#6366F1'
secondaryColor.value = s.secondary_color ?? '#4F46E5'
footerText.value = s.footer_text ?? ''
replyToEmail.value = s.reply_to_email ?? ''
replyToName.value = s.reply_to_name ?? ''
}
}, { immediate: true })
@@ -49,22 +51,19 @@ const optionalEmailValidator = (value: unknown) => {
return emailValidator(value)
}
const apiBaseUrl = import.meta.env.VITE_API_URL?.replace(/\/api\/v1\/?$/, '') ?? ''
async function onSubmit() {
const { valid } = await form.value!.validate()
if (!valid)
return
if (!valid) return
serverErrors.value = {}
updateOrg({
id: props.orgId,
email_logo_url: emailLogoUrl.value || null,
email_primary_color: emailPrimaryColor.value || null,
email_reply_to: emailReplyTo.value || null,
email_sender_name: emailSenderName.value || null,
email_footer_text: emailFooterText.value || null,
updateSettings({
logo_url: logoUrl.value || null,
primary_color: primaryColor.value || null,
secondary_color: secondaryColor.value || null,
footer_text: footerText.value || null,
reply_to_email: replyToEmail.value || null,
reply_to_name: replyToName.value || null,
}, {
onSuccess: () => {
snackbar.value = true
@@ -83,7 +82,28 @@ function fieldErrors(field: string): string | undefined {
</script>
<template>
<VCard>
<VSkeletonLoader
v-if="isLoading"
type="card"
/>
<VAlert
v-else-if="isError"
type="error"
class="mb-4"
>
Kon e-mailinstellingen niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<VCard v-else>
<VCardTitle>E-mail opmaak</VCardTitle>
<VCardSubtitle>Pas het uiterlijk van uitgaande e-mails aan</VCardSubtitle>
@@ -93,34 +113,45 @@ function fieldErrors(field: string): string | undefined {
@submit.prevent="onSubmit"
>
<VRow>
<!-- Logo URL -->
<VCol
cols="12"
md="8"
>
<VTextField
v-model="emailLogoUrl"
v-model="logoUrl"
label="Logo URL"
hint="URL naar je organisatielogo (wordt getoond in de e-mailheader)"
hint="Gebruik een URL naar je logo (PNG of SVG, max 200px hoog)"
persistent-hint
:error-messages="fieldErrors('email_logo_url')"
:error-messages="fieldErrors('logo_url')"
/>
<img
v-if="emailLogoUrl"
:src="emailLogoUrl"
style="max-height: 48px"
<div
v-if="logoUrl"
class="mt-2"
@error="($event.target as HTMLImageElement).style.display = 'none'"
@load="($event.target as HTMLImageElement).style.display = 'block'"
>
<img
:src="logoUrl"
style="max-height: 48px"
@error="($event.target as HTMLImageElement).style.display = 'none'"
@load="($event.target as HTMLImageElement).style.display = 'block'"
>
</div>
<p
v-else
class="text-caption text-disabled mt-2"
>
Geen logo ingesteld
</p>
</VCol>
<!-- Primary Color -->
<VCol
cols="12"
md="8"
>
<div class="d-flex align-center gap-3">
<VMenu
v-model="showColorPicker"
v-model="showPrimaryPicker"
:close-on-content-click="false"
location="bottom start"
>
@@ -128,72 +159,112 @@ function fieldErrors(field: string): string | undefined {
<div
v-bind="menuProps"
class="color-swatch rounded cursor-pointer border"
:style="{ backgroundColor: emailPrimaryColor || '#6366f1' }"
:style="{ backgroundColor: primaryColor || '#6366F1' }"
/>
</template>
<VCard>
<VColorPicker
v-model="emailPrimaryColor"
v-model="primaryColor"
mode="hex"
:modes="['hex']"
/>
</VCard>
</VMenu>
<VTextField
v-model="emailPrimaryColor"
v-model="primaryColor"
label="Primaire kleur"
placeholder="#6366f1"
placeholder="#6366F1"
:rules="[hexColorValidator]"
:error-messages="fieldErrors('email_primary_color')"
:error-messages="fieldErrors('primary_color')"
class="flex-grow-1"
/>
</div>
</VCol>
<!-- Secondary Color -->
<VCol
cols="12"
md="8"
>
<div class="d-flex align-center gap-3">
<VMenu
v-model="showSecondaryPicker"
:close-on-content-click="false"
location="bottom start"
>
<template #activator="{ props: menuProps }">
<div
v-bind="menuProps"
class="color-swatch rounded cursor-pointer border"
:style="{ backgroundColor: secondaryColor || '#4F46E5' }"
/>
</template>
<VCard>
<VColorPicker
v-model="secondaryColor"
mode="hex"
:modes="['hex']"
/>
</VCard>
</VMenu>
<VTextField
v-model="secondaryColor"
label="Secundaire kleur"
placeholder="#4F46E5"
:rules="[hexColorValidator]"
:error-messages="fieldErrors('secondary_color')"
class="flex-grow-1"
/>
</div>
</VCol>
<!-- Footer Text -->
<VCol
cols="12"
md="8"
>
<VTextField
v-model="emailSenderName"
label="Afzendernaam"
hint="De 'Van'-naam in uitgaande e-mails. Leeg = organisatienaam"
v-model="footerText"
label="Footertekst"
placeholder="© 2026 Jouw Organisatie"
hint="Wordt onderaan elke email getoond"
persistent-hint
:placeholder="organisation?.name"
:error-messages="fieldErrors('email_sender_name')"
counter="200"
maxlength="200"
:error-messages="fieldErrors('footer_text')"
/>
</VCol>
<!-- Reply-to Email -->
<VCol
cols="12"
md="8"
>
<VTextField
v-model="emailReplyTo"
v-model="replyToEmail"
label="Reply-to e-mailadres"
hint="Antwoorden op e-mails worden naar dit adres gestuurd"
hint="Ontvangers kunnen op dit adres antwoorden"
persistent-hint
:rules="[optionalEmailValidator]"
:error-messages="fieldErrors('email_reply_to')"
:error-messages="fieldErrors('reply_to_email')"
/>
</VCol>
<!-- Reply-to Name -->
<VCol
cols="12"
md="8"
>
<VTextarea
v-model="emailFooterText"
label="Footertekst"
hint="Wordt onderaan elke e-mail getoond (bijv. contactgegevens, adres)"
persistent-hint
:rows="3"
:counter="2000"
:maxlength="2000"
:error-messages="fieldErrors('email_footer_text')"
<VTextField
v-model="replyToName"
label="Reply-to naam"
counter="100"
maxlength="100"
:error-messages="fieldErrors('reply_to_name')"
/>
</VCol>
<!-- Submit -->
<VCol
cols="12"
md="8"
@@ -205,19 +276,6 @@ function fieldErrors(field: string): string | undefined {
>
Opslaan
</VBtn>
<div
v-if="isDev"
class="mt-3"
>
<a
:href="`${apiBaseUrl}/mail-preview/registration-confirmation`"
target="_blank"
class="text-caption text-medium-emphasis"
>
📧 Bekijk een voorbeeld-email
</a>
</div>
</VCol>
</VRow>
</VForm>

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

View File

@@ -0,0 +1,450 @@
<script setup lang="ts">
import { VForm } from 'vuetify/components/VForm'
import {
useEmailTemplates,
usePreviewEmailTemplate,
useResetEmailTemplate,
useSendTestEmail,
useUpdateEmailTemplate,
} from '@/composables/api/useEmail'
import { useAuthStore } from '@/stores/useAuthStore'
import { TEMPLATE_VARIABLES } from '@/types/email'
import type { EmailTemplate, UpdateEmailTemplatePayload } from '@/types/email'
import { emailValidator, requiredValidator } from '@core/utils/validators'
const props = defineProps<{
orgId: string
}>()
const orgIdRef = computed(() => props.orgId)
const authStore = useAuthStore()
const { data: templates, isLoading, isError, refetch } = useEmailTemplates(orgIdRef)
const { mutate: updateTemplate, isPending: isSavingTemplate } = useUpdateEmailTemplate(orgIdRef)
const { mutate: resetTemplate } = useResetEmailTemplate(orgIdRef)
const { mutate: previewTemplate, isPending: isLoadingPreview } = usePreviewEmailTemplate(orgIdRef)
const { mutate: sendTest, isPending: isSendingTest } = useSendTestEmail(orgIdRef)
// ── Edit dialog state ──
const isEditOpen = ref(false)
const editingTemplate = ref<EmailTemplate | null>(null)
const editForm = ref<InstanceType<typeof VForm>>()
const editSubject = ref('')
const editHeading = ref('')
const editBodyText = ref('')
const editButtonText = ref('')
const editErrors = ref<Record<string, string[]>>({})
const showDefaults = ref(false)
const snackbar = ref(false)
const snackbarMessage = ref('')
// ── Preview dialog state ──
const isPreviewOpen = ref(false)
const previewHtml = ref('')
const previewType = ref('')
// ── Send test dialog state ──
const isSendTestOpen = ref(false)
const testEmail = ref('')
const testType = ref('')
// ── Reset confirmation state ──
const isResetOpen = ref(false)
const resettingTemplate = ref<EmailTemplate | null>(null)
const headers = [
{ title: 'Template', key: 'label' },
{ title: 'Status', key: 'is_custom', width: '140px' },
{ title: 'Acties', key: 'actions', sortable: false, align: 'end' as const, width: '200px' },
]
function openEdit(template: EmailTemplate) {
editingTemplate.value = template
editSubject.value = template.subject
editHeading.value = template.heading ?? ''
editBodyText.value = template.body_text
editButtonText.value = template.button_text ?? ''
editErrors.value = {}
showDefaults.value = false
isEditOpen.value = true
}
function onEditSubmit() {
editForm.value?.validate().then(({ valid }) => {
if (!valid || !editingTemplate.value) return
editErrors.value = {}
const payload: UpdateEmailTemplatePayload & { type: string } = {
type: editingTemplate.value.type,
subject: editSubject.value,
heading: editHeading.value || null,
body_text: editBodyText.value,
button_text: editButtonText.value || null,
}
updateTemplate(payload, {
onSuccess: () => {
isEditOpen.value = false
snackbarMessage.value = 'Template opgeslagen'
snackbar.value = true
},
onError: (error: any) => {
if (error.response?.status === 422 && error.response?.data?.errors) {
editErrors.value = error.response.data.errors
}
},
})
})
}
function openPreview(template: EmailTemplate) {
previewType.value = template.type
previewTemplate(template.type, {
onSuccess: (html: string) => {
previewHtml.value = html
isPreviewOpen.value = true
},
})
}
function openSendTest() {
testType.value = previewType.value
testEmail.value = authStore.user?.email ?? ''
isSendTestOpen.value = true
}
function onSendTest() {
sendTest({ type: testType.value, email: testEmail.value }, {
onSuccess: () => {
isSendTestOpen.value = false
isPreviewOpen.value = false
snackbarMessage.value = `Testmail verzonden naar ${testEmail.value}`
snackbar.value = true
},
})
}
function openReset(template: EmailTemplate) {
resettingTemplate.value = template
isResetOpen.value = true
}
function onResetConfirm() {
if (!resettingTemplate.value) return
resetTemplate(resettingTemplate.value.type, {
onSuccess: () => {
isResetOpen.value = false
snackbarMessage.value = 'Template teruggezet naar standaard'
snackbar.value = true
},
})
}
function variablesForType(type: string): string[] {
return TEMPLATE_VARIABLES[type] ?? []
}
</script>
<template>
<VSkeletonLoader
v-if="isLoading"
type="table"
/>
<VAlert
v-else-if="isError"
type="error"
class="mb-4"
>
Kon e-mailtemplates niet laden.
<template #append>
<VBtn
variant="text"
@click="refetch()"
>
Opnieuw proberen
</VBtn>
</template>
</VAlert>
<VCard v-else>
<VCardTitle>E-mailtemplates</VCardTitle>
<VCardSubtitle>Pas de tekst van uitgaande e-mails aan per type</VCardSubtitle>
<VDataTable
:headers="headers"
:items="templates ?? []"
item-value="type"
hover
>
<template #item.label="{ item }">
<span class="font-weight-medium">{{ item.label }}</span>
</template>
<template #item.is_custom="{ item }">
<VChip
:color="item.is_custom ? 'info' : 'default'"
size="small"
>
{{ item.is_custom ? 'Aangepast' : 'Standaard' }}
</VChip>
</template>
<template #item.actions="{ item }">
<div class="d-flex justify-end gap-x-1">
<VBtn
icon="tabler-edit"
variant="text"
size="small"
@click="openEdit(item)"
/>
<VBtn
icon="tabler-eye"
variant="text"
size="small"
:loading="isLoadingPreview && previewType === item.type"
@click="openPreview(item)"
/>
<VBtn
v-if="item.is_custom"
icon="tabler-rotate-2"
variant="text"
size="small"
color="warning"
@click="openReset(item)"
/>
</div>
</template>
</VDataTable>
</VCard>
<!-- Edit Dialog -->
<VDialog
v-model="isEditOpen"
max-width="700"
@after-leave="editingTemplate = null"
>
<VCard :title="`${editingTemplate?.label ?? ''} bewerken`">
<VForm
ref="editForm"
@submit.prevent="onEditSubmit"
>
<VCardText>
<VRow>
<VCol cols="12">
<VTextField
v-model="editSubject"
label="Onderwerp"
:rules="[requiredValidator]"
counter="200"
maxlength="200"
:error-messages="editErrors.subject?.[0]"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="editHeading"
label="Koptekst"
counter="200"
maxlength="200"
:error-messages="editErrors.heading?.[0]"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="editBodyText"
label="Inhoud"
:rules="[requiredValidator]"
:rows="5"
counter="5000"
maxlength="5000"
:error-messages="editErrors.body_text?.[0]"
/>
<div
v-if="editingTemplate && variablesForType(editingTemplate.type).length"
class="text-caption text-disabled mt-1"
>
Beschikbare variabelen: {{ variablesForType(editingTemplate.type).join(', ') }}
</div>
</VCol>
<VCol cols="12">
<VTextField
v-model="editButtonText"
label="Knoptekst"
counter="100"
maxlength="100"
hint="Laat leeg om geen knop te tonen"
persistent-hint
:error-messages="editErrors.button_text?.[0]"
/>
</VCol>
<!-- Defaults comparison -->
<VCol cols="12">
<VBtn
variant="text"
size="small"
:prepend-icon="showDefaults ? 'tabler-chevron-up' : 'tabler-chevron-down'"
@click="showDefaults = !showDefaults"
>
Standaardtekst bekijken
</VBtn>
<VExpandTransition>
<VSheet
v-show="showDefaults && editingTemplate"
class="mt-2 pa-4 rounded"
color="grey-lighten-4"
>
<p class="text-caption font-weight-bold mb-1">
Onderwerp:
</p>
<p class="text-body-2 mb-3">
{{ editingTemplate?.defaults.subject }}
</p>
<p class="text-caption font-weight-bold mb-1">
Koptekst:
</p>
<p class="text-body-2 mb-3">
{{ editingTemplate?.defaults.heading ?? '-' }}
</p>
<p class="text-caption font-weight-bold mb-1">
Inhoud:
</p>
<p class="text-body-2 mb-3">
{{ editingTemplate?.defaults.body_text }}
</p>
<p class="text-caption font-weight-bold mb-1">
Knoptekst:
</p>
<p class="text-body-2 mb-0">
{{ editingTemplate?.defaults.button_text ?? '-' }}
</p>
</VSheet>
</VExpandTransition>
</VCol>
</VRow>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isEditOpen = false"
>
Annuleren
</VBtn>
<VBtn
type="submit"
color="primary"
:loading="isSavingTemplate"
>
Opslaan
</VBtn>
</VCardActions>
</VForm>
</VCard>
</VDialog>
<!-- Preview Dialog -->
<VDialog
v-model="isPreviewOpen"
max-width="680"
>
<VCard title="E-mail voorbeeld">
<VCardText class="pa-0">
<iframe
:srcdoc="previewHtml"
style="inline-size: 100%; block-size: 600px; border: none;"
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isPreviewOpen = false"
>
Sluiten
</VBtn>
<VBtn
color="primary"
prepend-icon="tabler-send"
@click="openSendTest"
>
Testmail versturen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Send Test Dialog -->
<VDialog
v-model="isSendTestOpen"
max-width="400"
>
<VCard title="Testmail versturen">
<VCardText>
<VTextField
v-model="testEmail"
label="E-mailadres"
type="email"
:rules="[requiredValidator, emailValidator]"
autofocus
/>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isSendTestOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="primary"
:loading="isSendingTest"
@click="onSendTest"
>
Versturen
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Reset Confirmation Dialog -->
<VDialog
v-model="isResetOpen"
max-width="400"
>
<VCard title="Template resetten">
<VCardText>
Weet je zeker dat je de tekst voor '<strong>{{ resettingTemplate?.label }}</strong>'
wilt terugzetten naar de standaard?
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="text"
@click="isResetOpen = false"
>
Annuleren
</VBtn>
<VBtn
color="warning"
@click="onResetConfirm"
>
Resetten
</VBtn>
</VCardActions>
</VCard>
</VDialog>
<!-- Snackbar -->
<VSnackbar
v-model="snackbar"
color="success"
:timeout="3000"
>
{{ snackbarMessage }}
</VSnackbar>
</template>

View File

@@ -0,0 +1,164 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { Ref } from 'vue'
import { apiClient } from '@/lib/axios'
import type {
EmailLog,
EmailLogFilters,
EmailSettings,
EmailSettingsDefaults,
EmailTemplate,
UpdateEmailSettingsPayload,
UpdateEmailTemplatePayload,
} from '@/types/email'
interface ApiResponse<T> {
success: boolean
data: T
message?: string
}
interface PaginatedResponse<T> {
data: T[]
links: Record<string, string | null>
meta: {
current_page: number
per_page: number
total: number
last_page: number
}
}
// ── Email Settings ──
export function useEmailSettings(orgId: Ref<string>) {
return useQuery({
queryKey: ['email-settings', orgId],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<EmailSettings | EmailSettingsDefaults>>(
`/organisations/${orgId.value}/email-settings`,
)
return data.data
},
enabled: () => !!orgId.value,
})
}
export function useUpdateEmailSettings(orgId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (payload: UpdateEmailSettingsPayload) => {
const { data } = await apiClient.put<ApiResponse<EmailSettings>>(
`/organisations/${orgId.value}/email-settings`,
payload,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-settings', orgId.value] })
},
})
}
// ── Email Templates ──
export function useEmailTemplates(orgId: Ref<string>) {
return useQuery({
queryKey: ['email-templates', orgId],
queryFn: async () => {
const { data } = await apiClient.get<ApiResponse<EmailTemplate[]>>(
`/organisations/${orgId.value}/email-templates`,
)
return data.data
},
enabled: () => !!orgId.value,
})
}
export function useUpdateEmailTemplate(orgId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ type, ...payload }: UpdateEmailTemplatePayload & { type: string }) => {
const { data } = await apiClient.put<ApiResponse<EmailTemplate>>(
`/organisations/${orgId.value}/email-templates/${type}`,
payload,
)
return data.data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-templates', orgId.value] })
},
})
}
export function useResetEmailTemplate(orgId: Ref<string>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (type: string) => {
await apiClient.delete(`/organisations/${orgId.value}/email-templates/${type}`)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-templates', orgId.value] })
},
})
}
export function usePreviewEmailTemplate(orgId: Ref<string>) {
return useMutation({
mutationFn: async (type: string) => {
const { data } = await apiClient.post<ApiResponse<{ html: string }>>(
`/organisations/${orgId.value}/email-templates/${type}/preview`,
)
return data.data.html
},
})
}
export function useSendTestEmail(orgId: Ref<string>) {
return useMutation({
mutationFn: async ({ type, email }: { type: string; email: string }) => {
const { data } = await apiClient.post<ApiResponse<null>>(
`/organisations/${orgId.value}/email-templates/${type}/send-test`,
{ email },
)
return data
},
})
}
// ── Email Logs ──
export function useEmailLogs(orgId: Ref<string>, filters: Ref<EmailLogFilters>) {
return useQuery({
queryKey: ['email-logs', orgId, filters],
queryFn: async () => {
const params: Record<string, string | number> = {
page: filters.value.page,
per_page: filters.value.perPage,
}
if (filters.value.search) params.search = filters.value.search
if (filters.value.status) params.status = filters.value.status
if (filters.value.templateType) params.template_type = filters.value.templateType
if (filters.value.eventId) params.event_id = filters.value.eventId
if (filters.value.from) params.from = filters.value.from
if (filters.value.to) params.to = filters.value.to
const { data } = await apiClient.get<ApiResponse<PaginatedResponse<EmailLog>>>(
`/organisations/${orgId.value}/email-logs`,
{ params },
)
return data.data
},
enabled: () => !!orgId.value,
})
}

View File

@@ -4,6 +4,8 @@ import PersonTagsTab from '@/components/organisation/PersonTagsTab.vue'
import RegistrationFieldTemplatesTab from '@/components/organisation/RegistrationFieldTemplatesTab.vue'
import CrowdTypesManager from '@/components/organisations/CrowdTypesManager.vue'
import EmailBrandingTab from '@/components/organisation/EmailBrandingTab.vue'
import EmailTemplatesTab from '@/components/organisation/EmailTemplatesTab.vue'
import EmailLogTab from '@/components/organisation/EmailLogTab.vue'
const route = useRoute()
const router = useRouter()
@@ -15,7 +17,9 @@ const tabs = [
{ value: 'tags', label: 'Tags & Vaardigheden', icon: 'tabler-tag' },
{ value: 'templates', label: 'Registratieveld-templates', icon: 'tabler-forms' },
{ value: 'crowd-types', label: 'Crowd types', icon: 'tabler-users-group' },
{ value: 'email-branding', label: 'E-mail opmaak', icon: 'tabler-mail' },
{ value: 'email-branding', label: 'E-mail opmaak', icon: 'tabler-mail-cog' },
{ value: 'email-templates', label: 'E-mail templates', icon: 'tabler-template' },
{ value: 'email-log', label: 'E-mail log', icon: 'tabler-mail-search' },
]
const activeTab = computed({
@@ -72,6 +76,12 @@ const activeTab = computed({
<VWindowItem value="email-branding">
<EmailBrandingTab :org-id="orgId" />
</VWindowItem>
<VWindowItem value="email-templates">
<EmailTemplatesTab :org-id="orgId" />
</VWindowItem>
<VWindowItem value="email-log">
<EmailLogTab :org-id="orgId" />
</VWindowItem>
</VWindow>
</div>
</template>

102
apps/app/src/types/email.ts Normal file
View File

@@ -0,0 +1,102 @@
export interface EmailSettings {
id: string
organisation_id: string
logo_url: string | null
primary_color: string
secondary_color: string
footer_text: string | null
reply_to_email: string | null
reply_to_name: string | null
created_at: string
updated_at: string
}
export interface EmailSettingsDefaults {
logo_url: string | null
primary_color: string
secondary_color: string
footer_text: string
reply_to_email: string | null
reply_to_name: string | null
}
export interface UpdateEmailSettingsPayload {
logo_url?: string | null
primary_color?: string | null
secondary_color?: string | null
footer_text?: string | null
reply_to_email?: string | null
reply_to_name?: string | null
}
export interface EmailTemplate {
type: string
label: string
subject: string
heading: string | null
body_text: string
button_text: string | null
is_custom: boolean
defaults: {
subject: string
heading: string
body_text: string
button_text: string | null
}
}
export interface UpdateEmailTemplatePayload {
subject: string
heading?: string | null
body_text: string
button_text?: string | null
}
export interface EmailLog {
id: string
recipient_email: string
recipient_name: string | null
template_type: string
template_label: string
subject: string
status: 'queued' | 'sent' | 'failed'
error_message: string | null
queued_at: string
sent_at: string | null
failed_at: string | null
triggered_by: { id: string; name: string } | null
event_id: string | null
person_id: string | null
created_at: string
}
export interface EmailLogFilters {
page: number
perPage: number
search: string
status: string
templateType: string
eventId: string
from: string
to: string
}
export const EMAIL_TEMPLATE_TYPES = [
'invitation',
'password_reset',
'email_verification',
'registration_approved',
'registration_rejected',
'shift_assignment',
] as const
export type EmailTemplateType = typeof EMAIL_TEMPLATE_TYPES[number]
export const TEMPLATE_VARIABLES: Record<string, string[]> = {
invitation: ['{organisation_name}'],
password_reset: [],
email_verification: [],
registration_approved: ['{event_name}', '{organisation_name}'],
registration_rejected: ['{event_name}', '{organisation_name}'],
shift_assignment: ['{event_name}', '{shift_title}', '{shift_date}', '{shift_start}', '{shift_end}', '{section_name}'],
}