Teilnahmerechnungen erstellen
This commit is contained in:
@@ -0,0 +1,618 @@
|
||||
<script setup>
|
||||
import {computed, onMounted, ref} from 'vue'
|
||||
import {toast} from 'vue3-toastify'
|
||||
import AdminAppLayout from '../../../../resources/js/layouts/AdminAppLayout.vue'
|
||||
import ShadowedBox from '../../../Views/Components/ShadowedBox.vue'
|
||||
import TextEditor from '../../../Views/Components/TextEditor.vue'
|
||||
import {useAjax} from '../../../../resources/js/components/ajaxHandler.js'
|
||||
|
||||
const {request} = useAjax()
|
||||
|
||||
const blocks = ref([])
|
||||
const assets = ref([])
|
||||
const tokenGroups = ref({})
|
||||
|
||||
const activeBlock = ref(null)
|
||||
const saving = ref(false)
|
||||
|
||||
// Die Vorschau ist ein PDF, das aus den aktuellen -- auch ungespeicherten -- Blockinhalten entsteht.
|
||||
const previewUrl = ref(null)
|
||||
const previewLoading = ref(false)
|
||||
|
||||
const newAsset = ref({name: '', label: '', file: null})
|
||||
const assetInput = ref(null)
|
||||
|
||||
const current = computed(() => blocks.value.find(b => b.block === activeBlock.value) ?? null)
|
||||
|
||||
/** Beim Blockwechsel die gemerkte Einfügemarke verwerfen -- sie gehört zum vorigen Text. */
|
||||
function selectBlock(block) {
|
||||
activeBlock.value = block
|
||||
caret.value = null
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
const data = await request('/api/v1/admin/document-templates', {method: 'GET'})
|
||||
if (!data) {
|
||||
toast.error('Die Vorlage konnte nicht geladen werden.')
|
||||
return
|
||||
}
|
||||
|
||||
blocks.value = data.blocks ?? []
|
||||
assets.value = data.assets ?? []
|
||||
tokenGroups.value = data.tokenGroups ?? {}
|
||||
activeBlock.value = blocks.value.find(b => b.editable)?.block ?? blocks.value[0]?.block ?? null
|
||||
|
||||
await refreshPreview()
|
||||
}
|
||||
|
||||
/** Nur editierbare Blöcke werden gesendet; der generierte Rechnungsinhalt bleibt unverändert. */
|
||||
function editableBlocks() {
|
||||
return Object.fromEntries(
|
||||
blocks.value.filter(b => b.editable).map(b => [b.block, b.content ?? ''])
|
||||
)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const response = await request('/api/v1/admin/document-templates', {
|
||||
method: 'POST',
|
||||
body: {blocks: editableBlocks()},
|
||||
})
|
||||
|
||||
if (response?.status === 'success') {
|
||||
toast.success(response.message)
|
||||
} else {
|
||||
toast.error(response?.message ?? 'Fehler beim Speichern')
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPreview() {
|
||||
previewLoading.value = true
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/admin/document-templates/preview', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||
},
|
||||
body: JSON.stringify({blocks: editableBlocks()}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error('Die Vorschau konnte nicht erzeugt werden.')
|
||||
return
|
||||
}
|
||||
|
||||
if (previewUrl.value) {
|
||||
URL.revokeObjectURL(previewUrl.value)
|
||||
}
|
||||
|
||||
previewUrl.value = URL.createObjectURL(await response.blob())
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Platzhalter an der Einfügemarke ergänzen. Im WYSIWYG-Editor übernimmt das TinyMCE selbst, im
|
||||
* Quelltext-Editor wird an der zuletzt bekannten Cursorposition eingesetzt.
|
||||
*/
|
||||
function insertToken(token) {
|
||||
const block = current.value
|
||||
if (!block) return
|
||||
|
||||
const placeholder = wrapForBlock('{' + token + '}', token, block)
|
||||
|
||||
if (block.source) {
|
||||
insertIntoTextarea(placeholder)
|
||||
return
|
||||
}
|
||||
|
||||
const editor = window.tinymce?.activeEditor
|
||||
if (editor) {
|
||||
editor.execCommand('mceInsertContent', false, placeholder)
|
||||
} else {
|
||||
block.content = (block.content ?? '') + placeholder
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Bild-Platzhalter allein ist nirgends sinnvoll: er expandiert zum nackten Data-URI, das dann als
|
||||
* Zeichenkette im Dokument steht. Er braucht immer eine Hülle — im CSS url("..."), sonst ein <img>.
|
||||
*
|
||||
* Im CSS ist das zusätzlich eine Frage der Funktionsfähigkeit: ein Data-URI ausserhalb von url()
|
||||
* kann dompdf nicht verarbeiten und läuft ins Zeitlimit.
|
||||
*/
|
||||
function wrapForBlock(placeholder, token, block) {
|
||||
if (!token.startsWith('asset:')) {
|
||||
return placeholder
|
||||
}
|
||||
|
||||
return block.block === 'style'
|
||||
? `url("${placeholder}")`
|
||||
: `<img src="${placeholder}" alt="" />`
|
||||
}
|
||||
|
||||
const sourceArea = ref(null)
|
||||
// Zuletzt bekannte Einfügemarke im Quelltext-Feld. Ohne das würde ein Klick in der weit darunter
|
||||
// liegenden Platzhalter- oder Bilder-Liste an unvorhersehbarer Stelle einfügen.
|
||||
const caret = ref(null)
|
||||
|
||||
function rememberCaret() {
|
||||
const area = sourceArea.value
|
||||
if (!area) return
|
||||
|
||||
caret.value = {start: area.selectionStart, end: area.selectionEnd}
|
||||
}
|
||||
|
||||
function insertIntoTextarea(text) {
|
||||
const block = current.value
|
||||
if (!block) return
|
||||
|
||||
const content = block.content ?? ''
|
||||
const position = caret.value
|
||||
|
||||
if (position === null) {
|
||||
block.content = content + text
|
||||
return
|
||||
}
|
||||
|
||||
block.content = content.slice(0, position.start) + text + content.slice(position.end)
|
||||
caret.value = {start: position.start + text.length, end: position.start + text.length}
|
||||
}
|
||||
|
||||
async function uploadAsset() {
|
||||
const form = new FormData()
|
||||
form.append('name', newAsset.value.name)
|
||||
form.append('label', newAsset.value.label ?? '')
|
||||
if (newAsset.value.file) {
|
||||
form.append('file', newAsset.value.file)
|
||||
}
|
||||
|
||||
const result = await request('/api/v1/admin/document-templates/assets', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
|
||||
if (result?.status === 'success') {
|
||||
toast.success(result.message)
|
||||
newAsset.value = {name: '', label: '', file: null}
|
||||
if (assetInput.value) assetInput.value.value = ''
|
||||
await load()
|
||||
} else {
|
||||
toast.error(result?.message ?? 'Fehler beim Hochladen')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAsset(name) {
|
||||
if (!confirm(`Das Bild „${name}" wirklich löschen?`)) return
|
||||
|
||||
const result = await request('/api/v1/admin/document-templates/assets/' + name, {method: 'DELETE'})
|
||||
|
||||
if (result?.status === 'success') {
|
||||
toast.success(result.message)
|
||||
await load()
|
||||
} else {
|
||||
// Ein noch in der Vorlage referenziertes Bild lehnt der Server ab.
|
||||
toast.error(result?.message ?? 'Das Bild konnte nicht gelöscht werden.')
|
||||
}
|
||||
}
|
||||
|
||||
function copyToken(token) {
|
||||
navigator.clipboard?.writeText(token)
|
||||
toast.success(`${token} kopiert`)
|
||||
}
|
||||
|
||||
function onFileChosen(event) {
|
||||
newAsset.value.file = event.target.files?.[0] ?? null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminAppLayout title="Rechnungsvorlage">
|
||||
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px;">
|
||||
<p class="intro">
|
||||
Die Vorlage gilt für <strong>alle Mandanten</strong>; die eingesetzten Werte kommen je
|
||||
Rechnung aus dem jeweiligen Mandanten. Sie liegt in der Datenbank und wird von einem
|
||||
Update nicht überschrieben.
|
||||
</p>
|
||||
|
||||
<div class="layout">
|
||||
<!-- ── Blöcke und Editor ── -->
|
||||
<div class="editor-column">
|
||||
<div class="block-tabs">
|
||||
<button
|
||||
v-for="block in blocks"
|
||||
:key="block.block"
|
||||
class="block-tab"
|
||||
:class="{active: block.block === activeBlock, readonly: !block.editable}"
|
||||
@click="selectBlock(block.block)"
|
||||
>
|
||||
{{ block.label }}
|
||||
<span v-if="!block.editable" class="badge">generiert</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="current" class="editor-panel">
|
||||
<p v-if="!current.editable" class="notice">
|
||||
Dieser Block wird beim Erzeugen der Rechnung aus den Daten der Anmeldung
|
||||
zusammengesetzt und lässt sich nicht bearbeiten.
|
||||
</p>
|
||||
|
||||
<p v-else-if="current.source" class="notice">
|
||||
Enthält den Seitenaufbau bzw. die Gestaltung und wird deshalb im Quelltext
|
||||
bearbeitet. Für Adresse, Logo oder Fußnote ist das nicht nötig.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
v-if="current.source || !current.editable"
|
||||
ref="sourceArea"
|
||||
v-model="current.content"
|
||||
class="source-editor"
|
||||
:readonly="!current.editable"
|
||||
spellcheck="false"
|
||||
rows="24"
|
||||
@click="rememberCaret"
|
||||
@keyup="rememberCaret"
|
||||
@blur="rememberCaret"
|
||||
></textarea>
|
||||
|
||||
<!-- convert-urls aus: sonst schreibt TinyMCE {asset:...} in einem src um. -->
|
||||
<TextEditor v-else v-model="current.content" :convert-urls="false" />
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-save" :disabled="saving" @click="save">
|
||||
{{ saving ? 'Wird gespeichert…' : 'Speichern' }}
|
||||
</button>
|
||||
<button class="btn-secondary" :disabled="previewLoading" @click="refreshPreview">
|
||||
{{ previewLoading ? 'Wird erzeugt…' : 'Vorschau aktualisieren' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Platzhalter ── -->
|
||||
<div class="tokens">
|
||||
<h3>Platzhalter</h3>
|
||||
<p class="tokens-hint">
|
||||
Klicken fügt den Platzhalter an der Einfügemarke ein — in
|
||||
<strong>{{ current?.label ?? '—' }}</strong>. Mit
|
||||
<code>{if:name}…{/if:name}</code> lässt sich ein Abschnitt weglassen, solange
|
||||
der Platzhalter leer ist.
|
||||
</p>
|
||||
|
||||
<div v-for="(group, key) in tokenGroups" :key="key" class="token-group">
|
||||
<h4>{{ group.label }}</h4>
|
||||
<button
|
||||
v-for="(token, name) in group.tokens"
|
||||
:key="name"
|
||||
class="token"
|
||||
:title="token.description"
|
||||
@click="insertToken(name)"
|
||||
>{{ '{' + name + '}' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Bilder ── -->
|
||||
<div class="assets">
|
||||
<h3>Bilder</h3>
|
||||
<p class="tokens-hint">
|
||||
Liegen in der Datenbank, damit ein Update sie nicht überschreibt. „Einfügen"
|
||||
schreibt in <strong>{{ current?.label ?? '—' }}</strong><template
|
||||
v-if="current?.block === 'style'"> — im CSS als
|
||||
<code>url("…")</code>, anders kann dompdf ein Bild dort nicht verarbeiten</template>.
|
||||
</p>
|
||||
|
||||
<table class="asset-table">
|
||||
<tr v-for="asset in assets" :key="asset.name">
|
||||
<td class="asset-preview"><img :src="asset.preview" :alt="asset.name" /></td>
|
||||
<td>
|
||||
<strong>{{ asset.name }}</strong>
|
||||
<span v-if="asset.label" class="asset-label">{{ asset.label }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<code class="token-code" @click="copyToken(asset.token)">{{ asset.token }}</code>
|
||||
</td>
|
||||
<td class="asset-actions">
|
||||
<button class="btn-link" @click="insertToken('asset:' + asset.name)">Einfügen</button>
|
||||
<button class="btn-link danger" @click="deleteAsset(asset.name)">Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="asset-upload">
|
||||
<input v-model="newAsset.name" type="text" placeholder="Name, z. B. logo" class="form-input" />
|
||||
<input v-model="newAsset.label" type="text" placeholder="Beschreibung (optional)" class="form-input" />
|
||||
<input ref="assetInput" type="file" accept="image/*" @change="onFileChosen" />
|
||||
<button class="btn-secondary" :disabled="!newAsset.name" @click="uploadAsset">
|
||||
Hochladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Vorschau ── -->
|
||||
<div class="preview-column">
|
||||
<h3>Vorschau</h3>
|
||||
<p class="tokens-hint">Mit Beispieldaten, ohne zu speichern.</p>
|
||||
<iframe v-if="previewUrl" :src="previewUrl" class="preview-frame"></iframe>
|
||||
<p v-else class="notice">Noch keine Vorschau erzeugt.</p>
|
||||
</div>
|
||||
</div>
|
||||
</shadowed-box>
|
||||
</AdminAppLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.intro {
|
||||
margin-bottom: 20px;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid #f5c400;
|
||||
background-color: #fffef5;
|
||||
font-size: 0.9rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.editor-column {
|
||||
flex: 1 1 60%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preview-column {
|
||||
flex: 1 1 40%;
|
||||
min-width: 320px;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.block-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.block-tab {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background-color: #ffffff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.block-tab.active {
|
||||
background-color: #1d4899;
|
||||
border-color: #1d4899;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.block-tab.readonly {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-left: 6px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background-color: #e5e7eb;
|
||||
color: #4b5563;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.block-tab.active .badge {
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.source-editor {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-family: 'DejaVu Sans Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.source-editor[readonly] {
|
||||
background-color: #f9fafb;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.btn-save, .btn-secondary {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background-color: #16a34a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-save:hover:not(:disabled) {
|
||||
background-color: #15803d;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: #d1d5db;
|
||||
}
|
||||
|
||||
.btn-save:disabled, .btn-secondary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.tokens, .assets {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tokens h3, .assets h3, .preview-column h3 {
|
||||
margin-bottom: 6px;
|
||||
font-size: 1rem;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.tokens-hint {
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.8rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.tokens-hint code, .token-code {
|
||||
padding: 1px 4px;
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 3px;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.token-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.token-group h4 {
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: normal;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.token {
|
||||
margin: 0 4px 4px 0;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
background-color: #f9fafb;
|
||||
cursor: pointer;
|
||||
font-family: 'DejaVu Sans Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.token:hover {
|
||||
background-color: #eef2ff;
|
||||
border-color: #1d4899;
|
||||
}
|
||||
|
||||
.asset-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.asset-table td {
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
vertical-align: middle;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.asset-preview img {
|
||||
max-width: 60px;
|
||||
max-height: 40px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.asset-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.token-code {
|
||||
cursor: pointer;
|
||||
font-family: 'DejaVu Sans Mono', Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.asset-actions {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
padding: 2px 6px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
color: #1d4899;
|
||||
}
|
||||
|
||||
.btn-link.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.asset-upload {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.preview-frame {
|
||||
width: 100%;
|
||||
height: 780px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-column {
|
||||
position: static;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user