667 lines
19 KiB
Vue
667 lines
19 KiB
Vue
<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({})
|
|
|
|
// Die Vorlagen sind nach Dokumentart getrennt; der Umschalter lädt jeweils deren Blöcke neu.
|
|
const documentType = ref(null)
|
|
const documentTypes = 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 query = documentType.value ? '?type=' + encodeURIComponent(documentType.value) : ''
|
|
const data = await request('/api/v1/admin/document-templates' + query, {method: 'GET'})
|
|
if (!data) {
|
|
toast.error('Die Vorlage konnte nicht geladen werden.')
|
|
return
|
|
}
|
|
|
|
documentType.value = data.documentType
|
|
documentTypes.value = data.documentTypes ?? []
|
|
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()
|
|
}
|
|
|
|
/** Beim Wechsel der Dokumentart alles neu holen -- Blöcke und Platzhalter sind je Art andere. */
|
|
async function selectDocumentType(type) {
|
|
if (type === documentType.value) return
|
|
|
|
documentType.value = type
|
|
caret.value = null
|
|
await load()
|
|
}
|
|
|
|
/** 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: {type: documentType.value, 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({type: documentType.value, 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="Dokumentvorlagen">
|
|
<shadowed-box style="width: 95%; margin: 20px auto; padding: 20px;">
|
|
<p class="intro">
|
|
Die Vorlagen gelten für <strong>alle Mandanten</strong>; die eingesetzten Werte kommen je
|
|
Dokument aus dem jeweiligen Mandanten. Sie liegen in der Datenbank und werden von einem
|
|
Update nicht überschrieben.
|
|
</p>
|
|
|
|
<div class="type-switch">
|
|
<label for="document-type">Dokumentart</label>
|
|
<select
|
|
id="document-type"
|
|
class="form-input"
|
|
:value="documentType"
|
|
@change="selectDocumentType($event.target.value)"
|
|
>
|
|
<option v-for="type in documentTypes" :key="type.value" :value="type.value">
|
|
{{ type.label }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<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 des Dokuments 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;
|
|
}
|
|
|
|
.type-switch {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 18px;
|
|
}
|
|
|
|
.type-switch label {
|
|
font-weight: bold;
|
|
font-size: 0.9rem;
|
|
color: #4b5563;
|
|
}
|
|
|
|
.type-switch select {
|
|
width: auto;
|
|
min-width: 220px;
|
|
}
|
|
|
|
.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>
|