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']
|
||||
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']
|
||||
|
||||
@@ -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>
|
||||
|
||||
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 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
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