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:
2
apps/app/components.d.ts
vendored
2
apps/app/components.d.ts
vendored
@@ -59,6 +59,8 @@ declare module 'vue' {
|
|||||||
EditPersonDialog: typeof import('./src/components/persons/EditPersonDialog.vue')['default']
|
EditPersonDialog: typeof import('./src/components/persons/EditPersonDialog.vue')['default']
|
||||||
EditSectionDialog: typeof import('./src/components/sections/EditSectionDialog.vue')['default']
|
EditSectionDialog: typeof import('./src/components/sections/EditSectionDialog.vue')['default']
|
||||||
EmailBrandingTab: typeof import('./src/components/organisation/EmailBrandingTab.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']
|
EnableOneTimePasswordDialog: typeof import('./src/components/dialogs/EnableOneTimePasswordDialog.vue')['default']
|
||||||
ErrorHeader: typeof import('./src/components/ErrorHeader.vue')['default']
|
ErrorHeader: typeof import('./src/components/ErrorHeader.vue')['default']
|
||||||
EventMetricCards: typeof import('./src/components/events/EventMetricCards.vue')['default']
|
EventMetricCards: typeof import('./src/components/events/EventMetricCards.vue')['default']
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { VForm } from 'vuetify/components/VForm'
|
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'
|
import { emailValidator } from '@core/utils/validators'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -9,29 +9,31 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const orgIdRef = computed(() => props.orgId)
|
const orgIdRef = computed(() => props.orgId)
|
||||||
|
|
||||||
const { data: organisation } = useOrganisationDetail(orgIdRef)
|
const { data: settings, isLoading, isError, refetch } = useEmailSettings(orgIdRef)
|
||||||
const { mutate: updateOrg, isPending: isSaving } = useUpdateOrganisation()
|
const { mutate: updateSettings, isPending: isSaving } = useUpdateEmailSettings(orgIdRef)
|
||||||
|
|
||||||
const form = ref<InstanceType<typeof VForm>>()
|
const form = ref<InstanceType<typeof VForm>>()
|
||||||
const snackbar = ref(false)
|
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 serverErrors = ref<Record<string, string[]>>({})
|
||||||
|
|
||||||
const showColorPicker = ref(false)
|
const logoUrl = ref('')
|
||||||
const isDev = import.meta.env.DEV
|
const primaryColor = ref('#6366F1')
|
||||||
|
const secondaryColor = ref('#4F46E5')
|
||||||
|
const footerText = ref('')
|
||||||
|
const replyToEmail = ref('')
|
||||||
|
const replyToName = ref('')
|
||||||
|
|
||||||
watch(organisation, org => {
|
const showPrimaryPicker = ref(false)
|
||||||
if (org) {
|
const showSecondaryPicker = ref(false)
|
||||||
emailLogoUrl.value = org.email_logo_url ?? ''
|
|
||||||
emailPrimaryColor.value = org.email_primary_color ?? ''
|
watch(settings, s => {
|
||||||
emailReplyTo.value = org.email_reply_to ?? ''
|
if (s) {
|
||||||
emailSenderName.value = org.email_sender_name ?? ''
|
logoUrl.value = s.logo_url ?? ''
|
||||||
emailFooterText.value = org.email_footer_text ?? ''
|
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 })
|
}, { immediate: true })
|
||||||
|
|
||||||
@@ -49,22 +51,19 @@ const optionalEmailValidator = (value: unknown) => {
|
|||||||
return emailValidator(value)
|
return emailValidator(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_URL?.replace(/\/api\/v1\/?$/, '') ?? ''
|
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const { valid } = await form.value!.validate()
|
const { valid } = await form.value!.validate()
|
||||||
if (!valid)
|
if (!valid) return
|
||||||
return
|
|
||||||
|
|
||||||
serverErrors.value = {}
|
serverErrors.value = {}
|
||||||
|
|
||||||
updateOrg({
|
updateSettings({
|
||||||
id: props.orgId,
|
logo_url: logoUrl.value || null,
|
||||||
email_logo_url: emailLogoUrl.value || null,
|
primary_color: primaryColor.value || null,
|
||||||
email_primary_color: emailPrimaryColor.value || null,
|
secondary_color: secondaryColor.value || null,
|
||||||
email_reply_to: emailReplyTo.value || null,
|
footer_text: footerText.value || null,
|
||||||
email_sender_name: emailSenderName.value || null,
|
reply_to_email: replyToEmail.value || null,
|
||||||
email_footer_text: emailFooterText.value || null,
|
reply_to_name: replyToName.value || null,
|
||||||
}, {
|
}, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
snackbar.value = true
|
snackbar.value = true
|
||||||
@@ -83,7 +82,28 @@ function fieldErrors(field: string): string | undefined {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<VCardTitle>E-mail opmaak</VCardTitle>
|
||||||
<VCardSubtitle>Pas het uiterlijk van uitgaande e-mails aan</VCardSubtitle>
|
<VCardSubtitle>Pas het uiterlijk van uitgaande e-mails aan</VCardSubtitle>
|
||||||
|
|
||||||
@@ -93,34 +113,45 @@ function fieldErrors(field: string): string | undefined {
|
|||||||
@submit.prevent="onSubmit"
|
@submit.prevent="onSubmit"
|
||||||
>
|
>
|
||||||
<VRow>
|
<VRow>
|
||||||
|
<!-- Logo URL -->
|
||||||
<VCol
|
<VCol
|
||||||
cols="12"
|
cols="12"
|
||||||
md="8"
|
md="8"
|
||||||
>
|
>
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="emailLogoUrl"
|
v-model="logoUrl"
|
||||||
label="Logo URL"
|
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
|
persistent-hint
|
||||||
:error-messages="fieldErrors('email_logo_url')"
|
:error-messages="fieldErrors('logo_url')"
|
||||||
/>
|
/>
|
||||||
<img
|
<div
|
||||||
v-if="emailLogoUrl"
|
v-if="logoUrl"
|
||||||
:src="emailLogoUrl"
|
|
||||||
style="max-height: 48px"
|
|
||||||
class="mt-2"
|
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>
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Primary Color -->
|
||||||
<VCol
|
<VCol
|
||||||
cols="12"
|
cols="12"
|
||||||
md="8"
|
md="8"
|
||||||
>
|
>
|
||||||
<div class="d-flex align-center gap-3">
|
<div class="d-flex align-center gap-3">
|
||||||
<VMenu
|
<VMenu
|
||||||
v-model="showColorPicker"
|
v-model="showPrimaryPicker"
|
||||||
:close-on-content-click="false"
|
:close-on-content-click="false"
|
||||||
location="bottom start"
|
location="bottom start"
|
||||||
>
|
>
|
||||||
@@ -128,72 +159,112 @@ function fieldErrors(field: string): string | undefined {
|
|||||||
<div
|
<div
|
||||||
v-bind="menuProps"
|
v-bind="menuProps"
|
||||||
class="color-swatch rounded cursor-pointer border"
|
class="color-swatch rounded cursor-pointer border"
|
||||||
:style="{ backgroundColor: emailPrimaryColor || '#6366f1' }"
|
:style="{ backgroundColor: primaryColor || '#6366F1' }"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<VCard>
|
<VCard>
|
||||||
<VColorPicker
|
<VColorPicker
|
||||||
v-model="emailPrimaryColor"
|
v-model="primaryColor"
|
||||||
mode="hex"
|
mode="hex"
|
||||||
:modes="['hex']"
|
:modes="['hex']"
|
||||||
/>
|
/>
|
||||||
</VCard>
|
</VCard>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="emailPrimaryColor"
|
v-model="primaryColor"
|
||||||
label="Primaire kleur"
|
label="Primaire kleur"
|
||||||
placeholder="#6366f1"
|
placeholder="#6366F1"
|
||||||
:rules="[hexColorValidator]"
|
:rules="[hexColorValidator]"
|
||||||
:error-messages="fieldErrors('email_primary_color')"
|
:error-messages="fieldErrors('primary_color')"
|
||||||
class="flex-grow-1"
|
class="flex-grow-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</VCol>
|
</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
|
<VCol
|
||||||
cols="12"
|
cols="12"
|
||||||
md="8"
|
md="8"
|
||||||
>
|
>
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="emailSenderName"
|
v-model="footerText"
|
||||||
label="Afzendernaam"
|
label="Footertekst"
|
||||||
hint="De 'Van'-naam in uitgaande e-mails. Leeg = organisatienaam"
|
placeholder="© 2026 Jouw Organisatie"
|
||||||
|
hint="Wordt onderaan elke email getoond"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
:placeholder="organisation?.name"
|
counter="200"
|
||||||
:error-messages="fieldErrors('email_sender_name')"
|
maxlength="200"
|
||||||
|
:error-messages="fieldErrors('footer_text')"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Reply-to Email -->
|
||||||
<VCol
|
<VCol
|
||||||
cols="12"
|
cols="12"
|
||||||
md="8"
|
md="8"
|
||||||
>
|
>
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="emailReplyTo"
|
v-model="replyToEmail"
|
||||||
label="Reply-to e-mailadres"
|
label="Reply-to e-mailadres"
|
||||||
hint="Antwoorden op e-mails worden naar dit adres gestuurd"
|
hint="Ontvangers kunnen op dit adres antwoorden"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
:rules="[optionalEmailValidator]"
|
:rules="[optionalEmailValidator]"
|
||||||
:error-messages="fieldErrors('email_reply_to')"
|
:error-messages="fieldErrors('reply_to_email')"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Reply-to Name -->
|
||||||
<VCol
|
<VCol
|
||||||
cols="12"
|
cols="12"
|
||||||
md="8"
|
md="8"
|
||||||
>
|
>
|
||||||
<VTextarea
|
<VTextField
|
||||||
v-model="emailFooterText"
|
v-model="replyToName"
|
||||||
label="Footertekst"
|
label="Reply-to naam"
|
||||||
hint="Wordt onderaan elke e-mail getoond (bijv. contactgegevens, adres)"
|
counter="100"
|
||||||
persistent-hint
|
maxlength="100"
|
||||||
:rows="3"
|
:error-messages="fieldErrors('reply_to_name')"
|
||||||
:counter="2000"
|
|
||||||
:maxlength="2000"
|
|
||||||
:error-messages="fieldErrors('email_footer_text')"
|
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Submit -->
|
||||||
<VCol
|
<VCol
|
||||||
cols="12"
|
cols="12"
|
||||||
md="8"
|
md="8"
|
||||||
@@ -205,19 +276,6 @@ function fieldErrors(field: string): string | undefined {
|
|||||||
>
|
>
|
||||||
Opslaan
|
Opslaan
|
||||||
</VBtn>
|
</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>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
</VForm>
|
</VForm>
|
||||||
|
|||||||
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>
|
||||||
450
apps/app/src/components/organisation/EmailTemplatesTab.vue
Normal file
450
apps/app/src/components/organisation/EmailTemplatesTab.vue
Normal 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>
|
||||||
164
apps/app/src/composables/api/useEmail.ts
Normal file
164
apps/app/src/composables/api/useEmail.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import PersonTagsTab from '@/components/organisation/PersonTagsTab.vue'
|
|||||||
import RegistrationFieldTemplatesTab from '@/components/organisation/RegistrationFieldTemplatesTab.vue'
|
import RegistrationFieldTemplatesTab from '@/components/organisation/RegistrationFieldTemplatesTab.vue'
|
||||||
import CrowdTypesManager from '@/components/organisations/CrowdTypesManager.vue'
|
import CrowdTypesManager from '@/components/organisations/CrowdTypesManager.vue'
|
||||||
import EmailBrandingTab from '@/components/organisation/EmailBrandingTab.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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -15,7 +17,9 @@ const tabs = [
|
|||||||
{ value: 'tags', label: 'Tags & Vaardigheden', icon: 'tabler-tag' },
|
{ value: 'tags', label: 'Tags & Vaardigheden', icon: 'tabler-tag' },
|
||||||
{ value: 'templates', label: 'Registratieveld-templates', icon: 'tabler-forms' },
|
{ value: 'templates', label: 'Registratieveld-templates', icon: 'tabler-forms' },
|
||||||
{ value: 'crowd-types', label: 'Crowd types', icon: 'tabler-users-group' },
|
{ 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({
|
const activeTab = computed({
|
||||||
@@ -72,6 +76,12 @@ const activeTab = computed({
|
|||||||
<VWindowItem value="email-branding">
|
<VWindowItem value="email-branding">
|
||||||
<EmailBrandingTab :org-id="orgId" />
|
<EmailBrandingTab :org-id="orgId" />
|
||||||
</VWindowItem>
|
</VWindowItem>
|
||||||
|
<VWindowItem value="email-templates">
|
||||||
|
<EmailTemplatesTab :org-id="orgId" />
|
||||||
|
</VWindowItem>
|
||||||
|
<VWindowItem value="email-log">
|
||||||
|
<EmailLogTab :org-id="orgId" />
|
||||||
|
</VWindowItem>
|
||||||
</VWindow>
|
</VWindow>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
102
apps/app/src/types/email.ts
Normal file
102
apps/app/src/types/email.ts
Normal 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}'],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user