Files
InvoiceShelf/resources/scripts/features/company/invoices/components/SendInvoiceModal.vue
Darko Gjorgjijoski f3ab0f22fc chore(frontend): fix ESLint, add Pint+ESLint pre-commit hook, centralize v-html
Fix the broken ESLint setup: add vue-eslint-parser and @typescript-eslint/parser
and wire the TS parser into eslint.config.mjs so .ts and <script lang=ts> parse
(was failing outright). Clear the resulting backlog to a clean 0/0 baseline —
fix genuine issues, relax two intentional-pattern rules (multi-word-component-names,
no-required-prop-with-default).

Add a committed .githooks/pre-commit (enabled via core.hooksPath, auto-set by the
prepare script) that runs Pint on staged PHP and ESLint --max-warnings 0 on staged
resources/scripts JS/TS/Vue, blocking on failure. Add composer/npm lint scripts and
document the gate in CLAUDE.md.

Replace every scattered v-html with a single audited BaseSanitizedHtml component
that DOMPurify-sanitizes its input (new utils/markdown.ts sanitizeHtml), so
server/registry-provided HTML is actually sanitized and vue/no-v-html stays enabled
everywhere but one reviewed sink.
2026-06-11 11:11:12 +02:00

379 lines
9.4 KiB
Vue

<template>
<BaseModal
:show="modalActive"
@close="closeModal"
@open="setInitialData"
>
<template #header>
<div class="flex justify-between w-full">
{{ modalTitle }}
<BaseIcon
name="XMarkIcon"
class="w-6 h-6 text-muted cursor-pointer"
@click="closeModal"
/>
</div>
</template>
<form v-if="!isPreview" @submit.prevent>
<div class="px-8 py-8 sm:p-6">
<BaseInputGrid layout="one-column" class="col-span-7">
<BaseInputGroup
:label="$t('general.from')"
required
:error="
v$.from.$error ? (v$.from.$errors[0]?.$message as string) : undefined
"
>
<BaseInput
v-model="form.from"
type="text"
:invalid="v$.from.$error"
@input="v$.from.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('general.to')"
required
:error="
v$.to.$error ? (v$.to.$errors[0]?.$message as string) : undefined
"
>
<BaseInput
v-model="form.to"
type="text"
:invalid="v$.to.$error"
@input="v$.to.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('general.cc')"
:error="
v$.cc.$error ? (v$.cc.$errors[0]?.$message as string) : undefined
"
>
<BaseInput
v-model="form.cc"
type="text"
:invalid="v$.cc.$error"
placeholder="Optional: CC recipient"
@input="v$.cc.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('general.bcc')"
:error="
v$.bcc.$error ? (v$.bcc.$errors[0]?.$message as string) : undefined
"
>
<BaseInput
v-model="form.bcc"
type="text"
:invalid="v$.bcc.$error"
placeholder="Optional: BCC recipient"
@input="v$.bcc.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('general.subject')"
required
:error="
v$.subject.$error
? (v$.subject.$errors[0]?.$message as string)
: undefined
"
>
<BaseInput
v-model="form.subject"
type="text"
:invalid="v$.subject.$error"
@input="v$.subject.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('general.body')"
required
:error="
v$.body.$error ? (v$.body.$errors[0]?.$message as string) : undefined
"
>
<BaseCustomInput
v-model="form.body"
:fields="invoiceMailFields"
/>
</BaseInputGroup>
</BaseInputGrid>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
>
<BaseButton
class="mr-3"
variant="primary-outline"
type="button"
@click="closeModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isLoading"
:disabled="isLoading"
variant="primary"
type="button"
class="mr-3"
@click="submitForm"
>
<template #left="slotProps">
<BaseIcon
v-if="!isLoading"
:class="slotProps.class"
name="PhotoIcon"
/>
</template>
{{ $t('general.preview') }}
</BaseButton>
</div>
</form>
<div v-else>
<div class="my-6 mx-4 border border-line-default relative">
<BaseButton
class="absolute top-4 right-4"
:disabled="isLoading"
variant="primary-outline"
@click="cancelPreview"
>
<BaseIcon name="PencilIcon" class="h-5 mr-2" />
{{ $t('general.edit') }}
</BaseButton>
<iframe
:src="templateUrl"
frameborder="0"
class="w-full"
style="min-height: 500px"
/>
</div>
<div
class="z-0 flex justify-end p-4 border-t border-line-default border-solid"
>
<BaseButton
class="mr-3"
variant="primary-outline"
type="button"
@click="closeModal"
>
{{ $t('general.cancel') }}
</BaseButton>
<BaseButton
:loading="isLoading"
:disabled="isLoading"
variant="primary"
type="button"
@click="submitForm"
>
<BaseIcon
v-if="!isLoading"
name="PaperAirplaneIcon"
class="h-5 mr-2"
/>
{{ $t('general.send') }}
</BaseButton>
</div>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, computed, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { useVuelidate } from '@vuelidate/core'
import { required, email, helpers } from '@vuelidate/validators'
import { useModalStore } from '../../../../stores/modal.store'
import { useCompanyStore } from '../../../../stores/company.store'
import { useNotificationStore } from '../../../../stores/notification.store'
import { useInvoiceStore } from '../store'
interface InvoiceMailForm {
id: number | string | null
from: string | null
to: string | null
cc: string | null
bcc: string | null
subject: string
body: string | null
}
type FieldType = 'customer' | 'customerCustom' | 'invoice' | 'invoiceCustom' | 'company'
const modalStore = useModalStore()
const companyStore = useCompanyStore()
const notificationStore = useNotificationStore()
const invoiceStore = useInvoiceStore()
const { t } = useI18n()
const isLoading = ref<boolean>(false)
const templateUrl = ref<string>('')
const isPreview = ref<boolean>(false)
const invoiceMailFields = ref<FieldType[]>([
'customer',
'customerCustom',
'invoice',
'invoiceCustom',
'company',
])
const form = reactive<InvoiceMailForm>({
id: null,
from: null,
to: null,
cc: null,
bcc: null,
subject: t('invoices.new_invoice'),
body: null,
})
const modalActive = computed<boolean>(() => {
return modalStore.active && modalStore.componentName === 'SendInvoiceModal'
})
const modalTitle = computed<string>(() => {
return modalStore.title
})
const modalData = computed<Record<string, unknown> | null>(() => {
return modalStore.data as Record<string, unknown> | null
})
const rules = computed(() => ({
from: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
to: {
required: helpers.withMessage(t('validation.required'), required),
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
cc: {
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
bcc: {
email: helpers.withMessage(t('validation.email_incorrect'), email),
},
subject: {
required: helpers.withMessage(t('validation.required'), required),
},
body: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(rules, form)
function cancelPreview(): void {
isPreview.value = false
}
async function setInitialData(): Promise<void> {
try {
const admin = await companyStore.fetchBasicMailConfig()
form.id = modalStore.id
if (admin?.from_mail) {
form.from = admin.from_mail as string
}
if (modalData.value?.customer) {
const customer = modalData.value.customer as Record<string, unknown>
form.to = (customer.email as string) ?? null
}
form.body = companyStore.selectedCompanySettings.invoice_mail_body ?? null
} catch {
// Silently handle initialization errors
}
}
async function submitForm(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
try {
isLoading.value = true
if (!isPreview.value) {
const previewResponse = await invoiceStore.previewInvoice({
id: form.id as number,
from: form.from,
to: form.to,
cc: form.cc,
bcc: form.bcc,
subject: form.subject,
body: form.body,
})
isLoading.value = false
isPreview.value = true
const blob = new Blob(
[(previewResponse as { data: string }).data ?? previewResponse],
{ type: 'text/html' },
)
templateUrl.value = URL.createObjectURL(blob)
return
}
await invoiceStore.sendInvoice({
id: form.id as number,
from: form.from,
to: form.to,
cc: form.cc,
bcc: form.bcc,
subject: form.subject,
body: form.body,
})
isLoading.value = false
notificationStore.showNotification({
type: 'success',
message: 'invoices.invoice_sent_successfully',
})
if (modalStore.refreshData) {
modalStore.refreshData()
}
closeModal()
} catch {
isLoading.value = false
notificationStore.showNotification({
type: 'error',
message: t('invoices.something_went_wrong'),
})
}
}
function closeModal(): void {
modalStore.closeModal()
setTimeout(() => {
v$.value.$reset()
isPreview.value = false
templateUrl.value = ''
}, 300)
}
</script>