Files
skrift-directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js
s4luorth ae7ef2422e Statistik-Tab, Unterschriftenservice-Kuverts, Auftragsverknüpfung & Notizen
- Produktion: Statistik-Tab (versendete Briefe je Kunde/Monat), verknüpfter
  Auftrag je Karte anzeigen/ändern/entfernen, Datei-Aufträge werden beim
  Übernehmen wieder in den Signatur-Tab überführt.
- Unterschriftenservice (neuauftrag): optionale Kuverts (je Empfänger ein
  Umschlag, Absender möglich) und Verknüpfung mit vorhandenem Auftrag.
- orders: signature-order erzeugt Kuverts über die Standard-Pipeline
  (Datei-Produkt -> nur Umschläge, Seiten bleiben erhalten) und speichert
  linked_order; nachträgliches Verknüpfen im Board.
- bootstrap: orders.linked_order (Self-Relation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-07 08:27:44 +02:00

949 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Skrift – Modul „Neuer Auftrag"
* ===========================================================================
* Eigenständige, manuelle Auftragsanlage im Admin (eigenes Modul, kein Teil des
* Produktions-Boards). Nutzt ausschließlich die zentralen Backend-Endpunkte:
* POST /skrift-orders/preview – Vorschau (Admin ohne Kontingent)
* POST /skrift-orders/manual-order – Auftrag intern anlegen (keine Zahlung)
* Der Auftrag erscheint danach als „eingegangen" im Produktions-Board.
*
* Von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
*/
import { defineComponent, ref, reactive, onMounted, h, resolveComponent, Teleport } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const ACCENT = '#1E50A5';
// Weißes, randloses Eingabefeld – hebt sich durch Kontrast + leichten Schatten
// vom hellblauen Bereich ab (kein Rahmen).
const WFELD = 'background:#fff;border:none;border-radius:8px;padding:9px 12px;font:inherit;font-size:14px;color:#1A1A1A;outline:none;box-shadow:0 1px 2px rgba(0,0,0,.08);';
const WSELECT = WFELD + 'cursor:pointer;';
// Hellblauer Bereich (kein Rahmen).
const BEREICH = 'background:rgba(30,80,165,.06);border-radius:12px;padding:18px 20px;';
const STANDARD_PLATZHALTER = ['Anrede', 'Vorname', 'Nachname', 'Strasse', 'PLZ', 'Ort', 'Land', 'Briefnummer'];
function btn(label, onClick, kind) {
const base = 'cursor:pointer;border-radius:8px;padding:8px 16px;font:inherit;font-size:14px;border:none;';
const style = kind === 'primary' ? base + `background:${ACCENT};color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.15);`
: base + `background:#fff;color:${ACCENT};box-shadow:0 1px 2px rgba(0,0,0,.1);`;
return h('button', { type: 'button', onClick, style }, label);
}
function subdued(t, extra) { return h('div', { style: `color:var(--foreground-subdued);font-size:13px;${extra || ''}` }, t); }
function segToggle(value, opts, onSel) {
return h('div', { style: 'display:flex;width:100%;border-radius:8px;overflow:hidden;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.08);' },
opts.map((o) => h('button', {
type: 'button', onClick: () => onSel(o.key),
style: `flex:1;padding:10px 8px;font:inherit;font-size:13px;text-align:center;cursor:pointer;border:none;`
+ (value === o.key ? `background:${ACCENT};color:#fff;font-weight:700;` : `background:transparent;color:${ACCENT};`),
}, o.label)));
}
function bereichKopf(txt) { return h('div', { style: `font-size:15px;font-weight:800;color:${ACCENT};margin-bottom:14px;` }, txt); }
function label(txt) { return h('label', { style: 'display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);margin-bottom:4px;' }, txt); }
const NeuAuftrag = defineComponent({
setup() {
const api = useApi();
const produkte = ref([]);
const formate = ref([]);
const verbotenRe = ref(null);
const musterTexte = ref({ text_du: '', text_sie: '' });
// Frei anlegbare Mustertexte (Collection muster_texts). Fallback: Du/Sie aus muster_settings.
const musterListe = ref([]);
// Vorhandene Kundensignaturen (für „Unterschrift auf den Brief setzen").
const signaturListe = ref([]);
const geladen = ref(false);
// Aktives Unterwerkzeug: 'neu' (manueller Druck) oder 'signatur' (Unterschriftenservice).
const ansicht = ref('neu');
// Unterschriftenservice: Auftrag laden → Dokument + Kundensignaturen wählen →
// Vorschau (Marker + Offsets) → A4-SVGs herunterladen.
const sig = reactive({
modus: 'auftrag', // 'auftrag' (aus Auftrag laden) oder 'manuell' (direkt hochladen)
orderNr: '', order: null, docs: [], docId: null, docName: '', signaturen: [],
customerEmail: '', laden: false, pdf: '', msg: '', err: '',
// Kuverts zum Unterschriftenservice (wie manueller Druck): je Empfänger ein Umschlag.
kuverts: false, kuvertFormat: 'dinlang', empfBlocks: '',
absenderAuf: false, absender: '', absenderScale: 100,
// Verknüpfung mit vorhandenem Auftrag (dessen Kuverts genutzt werden).
linkNr: '',
});
// Datei nach Directus hochladen (liefert die Datei-ID).
async function sigUpload(file) {
const fd = new FormData();
fd.append('file', file);
const res = await api.post('/files', fd);
return res.data?.data?.id;
}
const leereZeile = () => ({ free_text: '', placeholders: {} });
const neu = reactive({
product: '', format: '', font: 'tilda',
// Relative Schriftgröße in % (getrennt Brief/Kuvert); 100 = Standard.
fontSizeLetter: 100, fontSizeEnvelope: 100,
// Unterschrift auf dem Brief ([[signatur1]]): SVG-Inhalt + Feinjustierung.
sigSvg: '', sigLabel: '', sigX: 0, sigY: 0, sigScale: 100,
// Mehrseitiges Schriftstück (Auto-Fluss über mehrere A4-Seiten).
mehrseitig: false, seitenInfo: '',
// Kuvert-Adresse je Empfänger: '5zeilen' (5 Zeilen) oder 'individuell' (einzeilig).
adressModus: '5zeilen', kuverts: true, absenderAufKuvert: false, envelopeSender: '', envelopeSenderScale: 100, realistic: true, customer_email: '', text: '', rows: [leereZeile()],
importOpen: false, importText: '',
docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '', lbDoc: null,
});
// Kuverts nur generieren, wenn angehakt (Beschriftung aus der Empfängeradresse).
const kuvertPayload = () => (neu.kuverts
? { needs_envelope: true, envelope_labeling: 'empfaenger', envelope_text: '',
envelope_sender: neu.absenderAufKuvert ? (neu.envelopeSender || '').trim() : '',
envelope_sender_scale: (Number(neu.envelopeSenderScale) || 100) / 100 }
: { needs_envelope: false, envelope_labeling: 'keine', envelope_text: '', envelope_sender: '', envelope_sender_scale: 1 });
async function laden() {
try {
const [p, f, ps, mt, mtx, sg] = await Promise.all([
api.get('/items/products', { params: { limit: -1, filter: { active: { _eq: true }, type: { _in: ['letter', 'postcard'] } }, fields: 'key,name,type' } }),
api.get('/items/formats', { params: { limit: -1, fields: '*,product.key' } }),
api.get('/items/pricing_settings', { params: { fields: 'char_whitelist' } }).catch(() => ({ data: { data: {} } })),
api.get('/items/muster_settings', { params: { fields: 'text_du,text_sie' } }).catch(() => ({ data: { data: {} } })),
api.get('/items/muster_texts', { params: { limit: -1, filter: { active: { _eq: true } }, sort: 'sort', fields: 'id,label,text' } }).catch(() => ({ data: { data: [] } })),
api.get('/items/signatures', { params: { limit: -1, sort: '-date_created', fields: 'id,label,file,marker_key,offset_x_mm,offset_y_mm,width_mm,customer.first_name,customer.last_name' } }).catch(() => ({ data: { data: [] } })),
]);
produkte.value = p.data?.data || [];
formate.value = f.data?.data || [];
musterTexte.value = mt.data?.data || { text_du: '', text_sie: '' };
musterListe.value = mtx.data?.data || [];
signaturListe.value = sg.data?.data || [];
const wl = ps.data?.data?.char_whitelist;
// Zeilenumbrüche immer erlauben (Brieftext ist mehrzeilig). Einzeilige
// Felder entfernen \n/\r ohnehin separat, daher hier gefahrlos. Tabs (\t)
// sind erlaubt, damit man z.B. das Datum an den rechten Rand rücken kann.
try { verbotenRe.value = wl ? new RegExp(`[^${wl}\\n\\r\\t]`, 'g') : null; } catch { verbotenRe.value = null; }
neu.product = produkte.value[0]?.key || 'briefe';
neu.format = neuFormate()[0]?.key || '';
// Übernahme aus dem Produktions-Board (localStorage): Text/Format/Schrift
// und alle Empfänger vorbefüllen, danach den Schlüssel wieder entfernen.
try {
const raw = localStorage.getItem('skrift_neuauftrag_uebernahme');
if (raw) {
localStorage.removeItem('skrift_neuauftrag_uebernahme');
const d = JSON.parse(raw);
if (d && d.zielSignatur) {
// Datei-/Unterschriftenservice-Auftrag → Signatur-Tab, per Nr. laden.
ansicht.value = 'signatur';
sig.modus = 'auftrag';
sig.orderNr = String(d.orderNr || '');
if (sig.orderNr) { sigLaden().catch(() => {}); }
} else if (d && typeof d === 'object') {
if (d.product) neu.product = d.product;
if (d.format) neu.format = d.format;
if (d.font) neu.font = d.font;
if (typeof d.text === 'string') neu.text = d.text;
if (typeof d.multi_page === 'boolean') neu.mehrseitig = d.multi_page;
if (typeof d.realistic === 'boolean') neu.realistic = d.realistic;
if (d.fontSizeLetter) neu.fontSizeLetter = d.fontSizeLetter;
if (d.fontSizeEnvelope) neu.fontSizeEnvelope = d.fontSizeEnvelope;
if (typeof d.kuverts === 'boolean') neu.kuverts = d.kuverts;
if (typeof d.absenderAufKuvert === 'boolean') neu.absenderAufKuvert = d.absenderAufKuvert;
if (typeof d.envelopeSender === 'string') neu.envelopeSender = d.envelopeSender;
if (d.envelopeSenderScale) neu.envelopeSenderScale = d.envelopeSenderScale;
if (d.adressModus) neu.adressModus = d.adressModus;
if (Array.isArray(d.rows) && d.rows.length) {
neu.rows = d.rows.map((r) => ({
free_text: String(r.free_text || ''),
placeholders: (r.placeholders && typeof r.placeholders === 'object') ? { ...r.placeholders } : {},
}));
}
neu.previewSession = null;
neu.msg = 'Aus Auftrag übernommen – anpassen, dann Vorschau/Anlegen.';
}
}
} catch { /* localStorage nicht verfügbar → ignorieren */ }
} catch (e) {
neu.err = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.';
} finally { geladen.value = true; }
}
onMounted(laden);
const saeubere = (v) => {
if (!verbotenRe.value) return v;
const treffer = String(v).match(verbotenRe.value);
if (!treffer || !treffer.length) return v;
const einmalig = [...new Set(treffer)].map((c) => (c === ' ' ? '␣' : c)).join(' ');
neu.hinweis = `Zeichen ${einmalig} ist nicht erlaubt und wurde entfernt.`;
setTimeout(() => { if (neu.hinweis.includes(einmalig)) neu.hinweis = ''; }, 4000);
return String(v).replace(verbotenRe.value, '');
};
// Nur im manuellen Druck sichtbare Formate (show_manual) und passend zum
// gewählten Produkt (Formate ohne Produktbindung gelten für alle).
const neuFormate = () => formate.value.filter((f) =>
f.show_manual !== false && (!f.product?.key || f.product.key === neu.product));
// Wie viele Adresszeilen je Empfänger: 5 („5 Zeilen") bzw. 1 („Individueller Text").
const zeilenAnzahl = () => (neu.adressModus === 'individuell' ? 1 : 5);
const freitext = (e, z) => (String(e.free_text || '').split('\n')[z] ?? '');
const setFreitext = (i, z, v) => {
const max = zeilenAnzahl();
const zeilen = String(neu.rows[i].free_text || '').split('\n');
while (zeilen.length < max) zeilen.push('');
zeilen[z] = saeubere(String(v).replace(/[\r\n]/g, ''));
neu.rows[i].free_text = zeilen.slice(0, max).join('\n').replace(/\n+$/, '');
neu.previewSession = null;
};
// Einzelfeld-Modus: strukturierte Adressfelder je Empfänger (statt freier Zeilen).
const FELDER = [['first_name', 'Vorname'], ['last_name', 'Nachname'], ['street', 'Straße'], ['house_no', 'Nr.'], ['zip', 'PLZ'], ['city', 'Stadt'], ['country', 'Land']];
const setFeld = (i, key, v) => {
neu.rows[i] = { ...neu.rows[i], [key]: saeubere(String(v).replace(/[\r\n]/g, '')) };
neu.previewSession = null;
};
// Individuelle Platzhalter je Empfänger (z. B. [[Gutschein]]).
const setPlatzhalter = (i, name, v) => {
neu.rows[i].placeholders = { ...(neu.rows[i].placeholders || {}), [name]: saeubere(v) };
neu.previewSession = null;
};
// Spalten-Einfügen: eine ganze Excel-Spalte (Strg+V) in EINE Zelle einfügen.
// Zeilenumbrüche trennen die Empfänger; Kommata bleiben Teil des Werts (kein
// Trennzeichen). Legt automatisch so viele Empfänger an wie eingefügte Zeilen
// und füllt die Spalte ab der aktuellen Zeile nach unten.
// Rückgabe true = mehrzeilig eingefügt (Default-Paste unterdrücken).
const spalteEinfuegen = (startI, apply, text) => {
const werte = String(text || '').replace(/\r\n?/g, '\n').split('\n');
while (werte.length > 1 && werte[werte.length - 1].trim() === '') werte.pop();
if (werte.length <= 1) return false; // Einzelwert → normales Einfügen zulassen
while (neu.rows.length < startI + werte.length) neu.rows.push(leereZeile());
werte.forEach((w, k) => apply(startI + k, w));
return true;
};
// Platzhalter aus dem Brieftext ziehen (ohne den automatischen [[Briefnummer]]).
const customPlaceholders = () => {
const set = new Set();
for (const m of String(neu.text || '').matchAll(/\[\[([^\]]+)\]\]/g)) {
const name = m[1].trim();
if (name && name.toLowerCase() !== 'briefnummer') set.add(name);
}
return [...set];
};
const addZeile = () => neu.rows.push(leereZeile());
const delZeile = (i) => { neu.rows.splice(i, 1); if (!neu.rows.length) neu.rows.push(leereZeile()); };
// Empfänger zählt, wenn eine Adresszeile/ein Feld ODER ein Platzhalterwert gefüllt ist.
const felderGefuellt = (e) => FELDER.some(([k]) => String(e[k] || '').trim());
const entries = () => neu.rows.filter((e) => String(e.free_text || '').trim()
|| felderGefuellt(e)
|| (e.placeholders && Object.values(e.placeholders).some((v) => String(v || '').trim())));
// Aus Excel/CSV eingefügte Zeilen übernehmen. Erst die individuellen
// Platzhalter-Spalten, dann die Adresszeilen (Tab/Semikolon/Komma-getrennt).
function importUebernehmen() {
const ph = customPlaceholders();
const max = zeilenAnzahl();
const zeilen = String(neu.importText || '').split(/\r?\n/).map((z) => z.trim()).filter(Boolean);
if (!zeilen.length) { neu.importOpen = false; return; }
const rows = zeilen.map((z) => {
const zellen = z.split(/\t|;|,/).map((c) => saeubere(c.trim()));
const row = leereZeile();
ph.forEach((name, i) => { row.placeholders = { ...row.placeholders, [name]: zellen[i] || '' }; });
if (neu.adressModus === 'felder') {
// Spalten nach den Platzhaltern → Vorname, Nachname, Straße, Nr., PLZ, Stadt, Land.
FELDER.forEach(([key], i) => { row[key] = zellen[ph.length + i] || ''; });
} else {
row.free_text = zellen.slice(ph.length, ph.length + max).join('\n');
}
return row;
});
neu.rows = rows;
neu.importText = ''; neu.importOpen = false; neu.previewSession = null;
}
// ── Unterschrift auf dem Brief ([[signatur1]]) ────────────────────────────
// Sorgt dafür, dass der Platzhalter im Text steht (sonst rendert keine Signatur).
function sigEinfuegen() {
if (!neu.text.includes('[[signatur1]]')) neu.text = `${neu.text.trimEnd()}\n[[signatur1]]`;
neu.previewSession = null;
}
async function sigWaehlen(id) {
neu.sigSvg = ''; neu.sigLabel = '';
if (!id) { neu.previewSession = null; return; }
const s = signaturListe.value.find((x) => String(x.id) === String(id));
if (!s?.file) { neu.err = 'Diese Signatur hat keine SVG-Datei.'; return; }
try {
const res = await api.get(`/assets/${s.file}`, { responseType: 'text' });
neu.sigSvg = typeof res.data === 'string' ? res.data : String(res.data || '');
neu.sigLabel = s.label || 'Unterschrift';
// Versatz aus der hinterlegten Unterschrift übernehmen (X/Y in mm).
neu.sigX = Number(s.offset_x_mm) || 0;
neu.sigY = Number(s.offset_y_mm) || 0;
sigEinfuegen();
} catch { neu.err = 'Signatur konnte nicht geladen werden.'; }
}
function sigDatei(e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
const r = new FileReader();
r.onload = () => { neu.sigSvg = String(r.result || ''); neu.sigLabel = file.name; sigEinfuegen(); };
r.readAsText(file);
}
function sigEntfernen() {
neu.sigSvg = ''; neu.sigLabel = '';
neu.text = neu.text.replace(/\n?\[\[signatur1\]\]/g, '');
neu.previewSession = null;
}
// Signatur-Anteil der Payloads (Vorschau + Anlage). „brief"-Präfix, weil es
// bereits ein sigPayload() für den Unterschriften-Service (PDF) gibt.
const briefSigPayload = () => (neu.sigSvg
? { signatures: { signatur1: neu.sigSvg },
signature_offsets: { signatur1: { x: Number(neu.sigX) || 0, y: Number(neu.sigY) || 0, scale: (Number(neu.sigScale) || 100) / 100 } } }
: {});
async function preview() {
// Vorschau ohne Empfänger erlaubt – das Backend nutzt dann eine Blankozeile.
// Ohne Text nur erlaubt, wenn Kuverts generiert werden (reiner Kuvert-Druck).
if (!neu.text.trim() && !neu.kuverts) { neu.err = 'Bitte Text eingeben – oder „Kuverts generieren" aktivieren.'; return; }
neu.pLaedt = true; neu.err = ''; neu.msg = '';
try {
const kv = kuvertPayload();
const res = await api.post('/skrift-orders/preview', {
text: neu.text, font: neu.font, format: neu.format, realistic: neu.realistic,
envelope_labeling: kv.envelope_labeling, envelope_text: kv.envelope_text, envelope_sender: kv.envelope_sender, envelope_sender_scale: kv.envelope_sender_scale,
font_scale_letter: neu.fontSizeLetter / 100, font_scale_envelope: neu.fontSizeEnvelope / 100,
multi_page: neu.mehrseitig,
...briefSigPayload(),
entries: entries().slice(0, 3),
});
const d = res.data || {};
if (d.has_overflow) {
// Zeigen, wie viele Zeilen zu lang (aus dem ersten überlaufenden Schriftstück).
const of = (d.overflow || [])[0];
const zuViel = of ? Math.max(1, (Number(of.lineCount) || 0) - (Number(of.lineLimit) || 0)) : 0;
neu.err = of
? `Text ist ${zuViel} Zeile${zuViel === 1 ? '' : 'n'} zu lang – ${of.lineCount} statt max. ${of.lineLimit} Zeilen. Bitte kürzen.`
: 'Text ist zu lang für das Format – bitte kürzen.';
neu.docs = []; neu.previewSession = null;
} else { neu.docs = d.docs || d.previews || []; neu.previewSession = d.session || d.sessionId || null; }
} catch (e) { neu.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Vorschau fehlgeschlagen.'; }
finally { neu.pLaedt = false; }
}
// Seiten je Schriftstück berechnen (mehrseitig) und nach Seitenzahl gruppieren:
// „2 Seiten: 1, 3, 4" · „3 Seiten: 2, 6, 7" …
async function seitenBerechnen() {
if (!neu.text.trim()) { neu.err = 'Bitte zuerst einen Text eingeben.'; return; }
neu.seitenInfo = 'Berechne …';
try {
const res = await api.post('/skrift-orders/pagecount', {
text: neu.text, format: neu.format,
font_scale_letter: neu.fontSizeLetter / 100,
entries: entries(),
});
const counts = res.data?.counts || [];
const gruppen = new Map();
counts.forEach((c) => { const g = gruppen.get(c.pages) || []; g.push(c.index); gruppen.set(c.pages, g); });
const zeilen = [...gruppen.keys()].sort((a, b) => a - b)
.map((p) => `${p} Seite${p === 1 ? '' : 'n'}: ${gruppen.get(p).join(', ')}`);
neu.seitenInfo = zeilen.join('\n') || 'Keine Schriftstücke.';
} catch (e) {
neu.seitenInfo = '';
neu.err = e?.response?.data?.error || e?.message || 'Seitenzählung fehlgeschlagen.';
}
}
// Das Backend generiert die Dokumente asynchron (fire-and-forget). Fehler
// landen als status_history-Eintrag 'fehler' am Auftrag, Erfolg als 'in_queue'.
// Wir pollen kurz und zeigen ein Problem DIREKT hier im Dashboard – statt dass
// es still verschluckt wird und „nie Dokumente" entstehen.
async function ueberwacheGenerierung(orderId, nr) {
for (let i = 0; i < 20; i += 1) {
await new Promise((r) => setTimeout(r, 1500));
let sh = [];
try {
sh = (await api.get('/items/status_history', { params: {
'filter[order][_eq]': orderId, sort: '-date_created', limit: 4, fields: 'status,note',
} })).data?.data || [];
} catch { continue; }
const fehler = sh.find((s) => s.status === 'fehler');
if (fehler) {
neu.err = `Generierung fehlgeschlagen: ${fehler.note || 'unbekannter Grund'}`;
neu.msg = '';
return;
}
if (sh.some((s) => s.status === 'in_queue')) {
neu.msg = `✓ Auftrag ${nr} angelegt & generiert – liegt im Produktions-Board unter „Neu". Felder bleiben stehen.`;
return;
}
}
// Kein eindeutiges Signal (langsame Generierung oder Datei-Produkt ohne Kuvert).
neu.msg = `✓ Auftrag ${nr} angelegt – Dokumente werden noch generiert. Status im Produktions-Board prüfen.`;
}
async function absenden() {
// Empfänger sind optional – ohne Empfänger genau ein Schriftstück (Blankozeile).
const ent = entries();
const payloadEntries = ent.length ? ent : [leereZeile()];
neu.err = ''; neu.msg = 'Wird angelegt …';
try {
const kv = kuvertPayload();
const res = await api.post('/skrift-orders/manual-order', {
product: neu.product, format: neu.format, font: neu.font,
realistic: neu.realistic,
font_scale_letter: neu.fontSizeLetter / 100, font_scale_envelope: neu.fontSizeEnvelope / 100,
multi_page: neu.mehrseitig,
...briefSigPayload(),
needs_envelope: kv.needs_envelope, envelope_labeling: kv.envelope_labeling, envelope_text: kv.envelope_text, envelope_sender: kv.envelope_sender, envelope_sender_scale: kv.envelope_sender_scale,
shipping_type: 'sammel',
customer_email: neu.customer_email.trim() || undefined,
text_template: neu.text, preview_session: neu.previewSession || undefined,
entries: payloadEntries,
});
const nr = res.data?.order_number || '';
const oid = res.data?.id;
// Felder bewusst NICHT zurücksetzen – der letzte Auftrag bleibt stehen,
// damit er sich als Vorlage für den nächsten leicht anpassen lässt.
neu.msg = `✓ Auftrag ${nr} angelegt – Dokumente werden generiert …`;
// Generierung im Hintergrund überwachen; ein Backend-Fehler erscheint dann hier.
if (oid) ueberwacheGenerierung(oid, nr);
} catch (e) { neu.err = e?.response?.data?.error || e?.message || 'Anlegen fehlgeschlagen.'; neu.msg = ''; }
}
function zuruecksetzen() {
Object.assign(neu, {
product: produkte.value[0]?.key || 'briefe', format: neuFormate()[0]?.key || '', font: 'tilda',
fontSizeLetter: 100, fontSizeEnvelope: 100,
sigSvg: '', sigLabel: '', sigX: 0, sigY: 0, sigScale: 100, mehrseitig: false, seitenInfo: '',
adressModus: '5zeilen', kuverts: true, absenderAufKuvert: false, envelopeSender: '', envelopeSenderScale: 100, realistic: true, customer_email: '', text: '', rows: [leereZeile()],
importOpen: false, importText: '',
docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '',
});
}
const musterEinfuegen = (wert) => {
// Freie Mustertexte (per ID) bevorzugt; sonst Fallback Du/Sie.
let t = '';
if (wert === 'du' || wert === 'sie') t = wert === 'du' ? musterTexte.value.text_du : musterTexte.value.text_sie;
else t = (musterListe.value.find((m) => String(m.id) === String(wert)) || {}).text || '';
if (!t) return;
neu.text = neu.text.trim() ? `${neu.text}\n\n${t}` : t;
neu.previewSession = null;
};
// Weißes Dropdown / Eingabefeld.
const sel = (val, opts, on, extra) => h('select', { style: WSELECT + 'width:100%;' + (extra || ''), value: val, onChange: on },
opts.map((o) => h('option', { value: o.v, selected: String(val) === String(o.v) }, o.t)));
const svgUri = (svg) => { try { return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); } catch { return null; } };
// Vorschau-Optik „Kugelschreiber" (nur Anzeige/Download): blaue Tinte, etwas
// dickere Striche (1.4) und ein dezenter „Wobble" (feTurbulence + Displacement)
// für die typische, leicht unruhige Kuli-Linie. Im Browser (auch als <img>)
// getestet. WICHTIG: vector-effect="non-scaling-stroke" MUSS bleiben – die
// Glyphen-Pfade haben eigene transform-Skalierungen; ohne non-scaling wird die
// Strichbreite winzig (Text verschwindet). Weißer Grund. Produktion bleibt schwarz.
const BALLPOINT = '#1a2a6c';
const inkify = (svg) => {
if (typeof svg !== 'string') return svg;
return svg
.replace(/stroke="#0{3,6}"/gi, `stroke="${BALLPOINT}" stroke-opacity="0.95"`)
.replace(/stroke-width="0\.6"/gi, 'stroke-width="1.4"')
.replace(/(<svg[^>]*>)/i, '$1<defs><filter id="skink" x="-4%" y="-4%" width="108%" height="108%" color-interpolation-filters="sRGB"><feTurbulence type="fractalNoise" baseFrequency="0.05" numOctaves="1" seed="4" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale="1.3" xChannelSelector="R" yChannelSelector="G"/></filter></defs>')
.replace(/(<rect[^>]*fill="#FFFFFF"[^>]*\/>)/i, '$1<g filter="url(#skink)">')
.replace(/<\/svg>\s*$/i, '</g></svg>');
};
// Vorschau als PNG speichern (wie in der App); Fallback: SVG.
function bildSpeichern(d) {
const svg = d && typeof d.svg === 'string' ? inkify(d.svg) : null;
if (!svg) return;
const name = (d?.art === 'umschlag' ? 'kuvert' : 'schriftstueck') + (d?.page ? `_s${d.page}` : '');
const uri = svgUri(svg);
const img = new Image();
img.onload = () => {
// Natürliche Größe (nicht 2×): non-scaling-stroke wirkt konstant in
// Gerätepixeln – bei 2× würde die Linie relativ dünn wirken. So sieht
// das PNG aus wie die Großansicht.
const scale = 1;
const cv = document.createElement('canvas');
cv.width = (img.naturalWidth || 800) * scale; cv.height = (img.naturalHeight || 1130) * scale;
const ctx = cv.getContext('2d');
ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, cv.width, cv.height);
ctx.drawImage(img, 0, 0, cv.width, cv.height);
cv.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = name + '.png';
document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 4000);
}, 'image/png');
};
img.onerror = () => {
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
const a = document.createElement('a'); a.href = url; a.download = name + '.svg';
document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 4000);
};
img.src = uri;
}
const docBild = (d) => {
const svg = d && typeof d.svg === 'string' ? d.svg : null;
const src = svg ? svgUri(inkify(svg)) : (typeof d === 'string' ? d : (d?.dataUrl || d?.url || d?.image || null));
const seite = (d?.page && d?.pageCount) ? ` · Seite ${d.page}/${d.pageCount}` : '';
const txt = (d?.art === 'umschlag' ? '✉ Kuvert' : '📄 Schriftstück') + seite;
return h('div', { style: 'display:flex;flex-direction:column;align-items:center;gap:4px;' }, [
src ? h('img', { src, onClick: () => { neu.lb = src; neu.lbDoc = d; }, style: 'width:100%;max-height:260px;object-fit:contain;border-radius:6px;background:#fff;cursor:zoom-in;box-shadow:0 1px 3px rgba(0,0,0,.12);' })
: h('div', { style: 'width:100%;height:200px;border-radius:6px;display:flex;align-items:center;justify-content:center;background:#fff;color:var(--foreground-subdued);' }, 'Dok.'),
h('span', { style: 'font-size:11px;color:var(--foreground-subdued);' }, txt),
]);
};
// Weißes, einzeiliges Zellenfeld (feste Breite → Zeile scrollt bei Bedarf).
const feld = (lbl, val, on, ph, onPaste) => h('div', { style: 'display:flex;flex-direction:column;gap:3px;flex:1 1 0;min-width:90px;' }, [
h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);' }, lbl),
h('input', { value: val, placeholder: ph || '', style: WFELD + 'width:100%;padding:7px 9px;font-size:13px;', onInput: on, onPaste }),
]);
function formular() {
// Empfängerzeile: erst individuelle Platzhalter, dann die Adresszeilen.
const zeile = (e, i) => [
...customPlaceholders().map((name) => feld(name, e.placeholders?.[name] || '', (ev) => setPlatzhalter(i, name, ev.target.value), '',
(ev) => { if (spalteEinfuegen(i, (ri, v) => setPlatzhalter(ri, name, v), ev.clipboardData && ev.clipboardData.getData('text'))) ev.preventDefault(); })),
...(neu.adressModus === 'felder'
? FELDER.map(([key, lbl]) => feld(lbl, e[key] || '', (ev) => setFeld(i, key, ev.target.value), '',
(ev) => { if (spalteEinfuegen(i, (ri, v) => setFeld(ri, key, v), ev.clipboardData && ev.clipboardData.getData('text'))) ev.preventDefault(); }))
: Array.from({ length: zeilenAnzahl() }, (_, z) => feld('Zeile ' + (z + 1), freitext(e, z), (ev) => setFreitext(i, z, ev.target.value), '',
(ev) => { if (spalteEinfuegen(i, (ri, v) => setFreitext(ri, z, v), ev.clipboardData && ev.clipboardData.getData('text'))) ev.preventDefault(); }))),
];
// Bereich 1 – Produkt
const b1 = h('div', { style: BEREICH }, [
bereichKopf('📄 Produkt'),
h('div', { style: 'display:grid;grid-template-columns:repeat(3,1fr);gap:12px;' }, [
h('div', {}, [label('Produkt'), sel(neu.product, produkte.value.map((p) => ({ v: p.key, t: p.name })), (e) => { neu.product = e.target.value; neu.format = neuFormate()[0]?.key || ''; neu.previewSession = null; })]),
h('div', {}, [label('Format'), sel(neu.format, neuFormate().map((f) => ({ v: f.key, t: f.name })), (e) => { neu.format = e.target.value; neu.previewSession = null; })]),
h('div', {}, [label('Schrift'), sel(neu.font, [['tilda', 'Tilda'], ['alva', 'Alva'], ['ellie', 'Ellie']].map(([v, t]) => ({ v, t })), (e) => { neu.font = e.target.value; neu.previewSession = null; })]),
]),
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px;' }, [
h('div', {}, [label('Schriftgröße Brief (%)'),
// Frei tippbar; erst beim Verlassen des Feldes auf 80–150 begrenzen.
h('input', { type: 'number', min: 80, max: 150, step: 5, value: neu.fontSizeLetter, style: WFELD + 'width:100%;',
onInput: (e) => { neu.fontSizeLetter = e.target.value; neu.previewSession = null; },
onBlur: (e) => { const n = Math.round(Number(e.target.value)); neu.fontSizeLetter = (Number.isFinite(n) && n) ? Math.max(80, Math.min(150, n)) : 100; } })]),
h('div', {}, [label('Schriftgröße Kuvert (%)'),
h('input', { type: 'number', min: 80, max: 150, step: 5, value: neu.fontSizeEnvelope, style: WFELD + 'width:100%;',
onInput: (e) => { neu.fontSizeEnvelope = e.target.value; neu.previewSession = null; },
onBlur: (e) => { const n = Math.round(Number(e.target.value)); neu.fontSizeEnvelope = (Number.isFinite(n) && n) ? Math.max(80, Math.min(150, n)) : 100; } })]),
]),
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;margin-top:12px;cursor:pointer;' }, [
h('input', { type: 'checkbox', checked: neu.realistic, onChange: (e) => { neu.realistic = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
'Natürliche Handschrift (leichte Variation)',
]),
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;margin-top:8px;cursor:pointer;' }, [
h('input', { type: 'checkbox', checked: neu.mehrseitig, onChange: (e) => { neu.mehrseitig = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
'Mehrseitiges Schriftstück (Text fließt automatisch über mehrere A4-Seiten)',
]),
]);
// Bereich 2 – Kuvert (Empfängertabelle)
const b2 = h('div', { style: BEREICH }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;' }, [
bereichKopf('✉ Kuvert'),
h('div', { style: 'width:420px;max-width:100%;' }, [segToggle(neu.adressModus, [{ key: '5zeilen', label: '5 Zeilen' }, { key: 'felder', label: 'Einzelfelder' }, { key: 'individuell', label: 'Ind. Text' }], (k) => { neu.adressModus = k; neu.previewSession = null; })]),
]),
h('div', { style: 'display:flex;flex-direction:column;gap:8px;max-height:34vh;overflow:auto;' },
neu.rows.map((e, i) => h('div', { style: 'display:flex;gap:8px;align-items:center;' }, [
h('span', { style: `min-width:22px;font-weight:700;color:${ACCENT};` }, `#${i + 1}`),
h('div', { style: 'flex:1;min-width:0;display:flex;gap:6px;flex-wrap:nowrap;overflow-x:auto;padding-bottom:2px;' }, zeile(e, i)),
h('button', { type: 'button', onClick: () => delZeile(i), style: 'cursor:pointer;border:none;background:transparent;color:var(--danger,#c62828);font-size:18px;padding:2px 6px;' }, '×'),
]))),
h('div', { style: 'margin-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:12px;' }, [
btn('+ Empfänger', addZeile),
btn(neu.importOpen ? 'Import schließen' : '⇪ Aus Excel einfügen', () => { neu.importOpen = !neu.importOpen; }),
]),
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;margin-top:12px;' }, [
h('input', { type: 'checkbox', checked: neu.kuverts, onChange: (e) => { neu.kuverts = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
'Kuverts generieren',
]),
neu.kuverts ? h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;margin-top:8px;' }, [
h('input', { type: 'checkbox', checked: neu.absenderAufKuvert, onChange: (e) => { neu.absenderAufKuvert = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
'Absender aufs Kuvert (oben links)',
]) : null,
(neu.kuverts && neu.absenderAufKuvert) ? h('div', { style: 'margin-top:10px;' }, [
label('Absender (max. 5 Zeilen)'),
h('textarea', {
value: neu.envelopeSender, rows: 5,
placeholder: 'Max Mustermann\nMusterstraße 1\n12345 Musterstadt',
style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);',
onInput: (e) => { neu.envelopeSender = saeubere(String(e.target.value)).split('\n').slice(0, 5).join('\n'); neu.previewSession = null; },
}),
h('div', { style: 'margin-top:8px;max-width:170px;' }, [
label('Absender-Größe (%)'),
h('input', { type: 'number', min: 50, max: 200, step: 5, value: neu.envelopeSenderScale, style: WFELD + 'width:100%;',
onInput: (e) => { neu.envelopeSenderScale = e.target.value; neu.previewSession = null; },
onBlur: (e) => { const n = Math.round(Number(e.target.value)); neu.envelopeSenderScale = (Number.isFinite(n) && n) ? Math.max(50, Math.min(200, n)) : 100; } }),
]),
h('div', { style: 'font-size:11px;color:var(--foreground-subdued,#888);margin-top:4px;line-height:1.5;' },
'Der Empfänger rutscht dann auf dem Kuvert automatisch nach unten rechts.'),
]) : null,
neu.importOpen ? h('div', { style: 'margin-top:12px;' }, [
subdued('Aus Excel kopieren und einfügen – eine Zeile pro Empfänger, Spalten mit Tab/Semikolon/Komma. '
+ 'Reihenfolge: erst individuelle Platzhalter, dann ' + (neu.adressModus === 'individuell' ? 'die eine Adresszeile.' : 'Zeile 1–5.'), 'margin-bottom:6px;'),
h('textarea', { value: neu.importText, rows: 5, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.importText = e.target.value; } }),
h('div', { style: 'margin-top:6px;' }, [btn('Übernehmen', importUebernehmen, 'primary')]),
]) : null,
]);
// Bereich 3 – Text (Mustertext-Dropdown direkt darüber)
const b3 = h('div', { style: BEREICH }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;' }, [
bereichKopf('✍ Text'),
h('select', { style: WSELECT + 'font-size:13px;padding:7px 10px;width:auto;', onChange: (e) => { if (e.target.value) { musterEinfuegen(e.target.value); e.target.value = ''; } } },
[h('option', { value: '' }, 'Mustertext einfügen …')].concat(
musterListe.value.length
? musterListe.value.map((m) => h('option', { value: m.id }, m.label || `Text ${m.id}`))
: [h('option', { value: 'sie' }, 'Sie-Form'), h('option', { value: 'du' }, 'Du-Form')],
)),
]),
label(`Brieftext · ${neu.text.length} Zeichen · [[Platzhalter]] erlaubt`),
h('textarea', { value: neu.text, rows: 8, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.text = saeubere(e.target.value); neu.previewSession = null; }, onKeydown: (e) => {
// Tab-Taste fügt einen echten Tabulator ein (statt den Fokus zu verschieben).
if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
e.preventDefault();
const ta = e.target, s = ta.selectionStart, en = ta.selectionEnd;
neu.text = saeubere(neu.text.slice(0, s) + '\t' + neu.text.slice(en));
neu.previewSession = null;
requestAnimationFrame(() => { try { ta.selectionStart = ta.selectionEnd = s + 1; } catch {} });
} }),
h('div', { style: 'font-size:11px;color:var(--foreground-subdued,#888);margin-top:4px;line-height:1.5;' },
'Tipp: [[seitenumbruch]] erzwingt an dieser Stelle eine neue Seite (mehrseitiger Brief). [[signatur1]] setzt die Unterschrift.'),
// ── Unterschrift auf den Brief ─────────────────────────────────────
h('div', { style: 'margin-top:16px;border-top:1px solid rgba(30,80,165,.15);padding-top:14px;' }, [
label('Unterschrift auf den Brief (optional) · Platzhalter [[signatur1]]'),
h('div', { style: 'display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;' }, [
h('select', { style: WSELECT + 'width:100%;', onChange: (e) => sigWaehlen(e.target.value) },
[h('option', { value: '' }, 'Vorhandene Unterschrift wählen …')].concat(
signaturListe.value.map((s) => {
const kunde = [s.customer?.first_name, s.customer?.last_name].filter(Boolean).join(' ');
return h('option', { value: s.id }, (s.label || `Signatur ${s.id}`) + (kunde ? ` · ${kunde}` : ''));
}))),
h('label', { style: 'cursor:pointer;border-radius:8px;padding:8px 16px;font:inherit;font-size:14px;border:none;background:#fff;color:' + ACCENT + ';box-shadow:0 1px 2px rgba(0,0,0,.1);white-space:nowrap;' }, [
'⇪ SVG hochladen',
h('input', { type: 'file', accept: '.svg,image/svg+xml', style: 'display:none;', onChange: sigDatei }),
]),
]),
neu.sigSvg ? h('div', { style: 'margin-top:12px;' }, [
h('div', { style: 'display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;' }, [
h('span', { style: `color:${ACCENT};font-size:13px;font-weight:600;` }, `✓ ${neu.sigLabel} – als [[signatur1]] im Text`),
btn('Entfernen', sigEntfernen),
]),
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;' }, [
h('div', {}, [label('Versatz X (mm)'), h('input', { type: 'number', step: 1, value: neu.sigX, style: WFELD + 'width:100%;', onInput: (e) => { neu.sigX = e.target.value; neu.previewSession = null; } })]),
h('div', {}, [label('Versatz Y (mm)'), h('input', { type: 'number', step: 1, value: neu.sigY, style: WFELD + 'width:100%;', onInput: (e) => { neu.sigY = e.target.value; neu.previewSession = null; } })]),
h('div', {}, [label('Größe (%)'), h('input', { type: 'number', step: 5, value: neu.sigScale, style: WFELD + 'width:100%;', onInput: (e) => { neu.sigScale = e.target.value; neu.previewSession = null; } })]),
]),
subdued('Positiv X = nach rechts, positiv Y = nach unten. Nach dem Anpassen „Aktualisieren" in der Vorschau klicken.', 'margin-top:8px;'),
]) : null,
]),
]);
// Bereich 4 – Kunde
const b4 = h('div', { style: BEREICH }, [
bereichKopf('👤 Kunde'),
h('div', { style: 'max-width:420px;' }, [
label('Kunde verknüpfen (E-Mail, optional)'),
h('input', { type: 'email', value: neu.customer_email, placeholder: 'kunde@example.de', style: WFELD + 'width:100%;', onInput: (e) => { neu.customer_email = e.target.value; } }),
]),
]);
const aktionen = h('div', { class: 'sk-btnrow', style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
neu.hinweis ? h('div', { style: `color:var(--danger,#c62828);font-size:13px;width:100%;` }, neu.hinweis) : null,
neu.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;width:100%;' }, neu.err) : null,
neu.msg ? h('div', { style: 'width:100%;' }, subdued(neu.msg)) : null,
btn(neu.pLaedt ? 'Vorschau …' : '👁 Vorschau', preview),
btn('In Druck geben', absenden, 'primary'),
btn('Formular leeren', zuruecksetzen),
]);
const links = h('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:16px;' }, [b1, b2, b3, b4, aktionen]);
// Vorschau rechts daneben (eigener hellblauer Bereich).
const rechts = h('div', { style: BEREICH + 'flex:0 0 340px;position:sticky;top:20px;align-self:flex-start;' }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;' }, [
bereichKopf('👁 Vorschau'),
btn(neu.pLaedt ? '…' : 'Aktualisieren', preview),
]),
neu.pLaedt ? subdued('Wird erzeugt …')
: neu.docs.length ? h('div', { style: 'display:flex;flex-direction:column;gap:12px;' }, neu.docs.slice(0, 12).map(docBild))
: subdued('Noch keine Vorschau. „Aktualisieren" klicken (Klick aufs Bild = groß).'),
neu.mehrseitig ? h('div', { style: 'margin-top:14px;border-top:1px solid rgba(30,80,165,.15);padding-top:12px;' }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px;' }, [
h('div', { style: `font-size:13px;font-weight:700;color:${ACCENT};` }, 'Seiten je Schriftstück'),
btn('Berechnen', seitenBerechnen),
]),
neu.seitenInfo
? h('pre', { style: 'margin:0;white-space:pre-wrap;font:inherit;font-size:13px;color:var(--foreground);' }, neu.seitenInfo)
: subdued('„Berechnen" klicken – zeigt, wie viele Seiten je Empfänger entstehen.'),
]) : null,
]);
return h('div', { style: 'display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap;' }, [links, rechts]);
}
// ── Unterschriftenservice ────────────────────────────────────────────────
async function sigLaden() {
sig.err = ''; sig.msg = ''; sig.pdf = ''; sig.order = null; sig.signaturen = []; sig.docs = []; sig.docId = null;
const nr = sig.orderNr.trim();
if (!nr) { sig.err = 'Bitte Auftragsnummer eingeben.'; return; }
sig.laden = true;
try {
const o = (await api.get('/items/orders', { params: {
filter: { order_number: { _eq: nr } }, limit: 1,
fields: 'id,order_number,source_file,source_files,customer.id',
} })).data?.data?.[0];
if (!o) { sig.err = `Auftrag ${nr} nicht gefunden.`; sig.laden = false; return; }
sig.order = o;
const docs = Array.isArray(o.source_files) && o.source_files.length
? o.source_files : (o.source_file ? [o.source_file] : []);
sig.docs = docs;
sig.docId = docs[0] || null;
if (o.customer?.id) {
const rows = (await api.get('/items/signatures', { params: {
filter: { customer: { _eq: o.customer.id } }, limit: -1,
fields: 'id,label,file,marker_key,offset_x_mm,offset_y_mm,width_mm',
} })).data?.data || [];
sig.signaturen = rows.filter((r) => r.file).map((r) => ({
file_id: r.file, label: r.label || 'Signatur', aktiv: true,
marker_key: r.marker_key || 'SIG1', position: 'genau',
width_mm: r.width_mm ?? 40, offset_x_mm: r.offset_x_mm ?? 0, offset_y_mm: r.offset_y_mm ?? 0,
}));
}
if (!sig.docs.length) sig.err = 'An diesem Auftrag ist kein Dokument hochgeladen.';
else if (!sig.signaturen.length) sig.err = 'Für diesen Kunden ist keine Unterschrift (mit Datei) hinterlegt.';
} catch (e) { sig.err = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.'; }
finally { sig.laden = false; }
}
const sigAktive = () => sig.signaturen.filter((s) => s.aktiv);
const sigPayload = () => ({ order: sig.order?.id || undefined, document_id: sig.docId || undefined, signatures: sigAktive() });
// PDF direkt hochladen (manueller Modus).
async function sigPdfUpload(file) {
if (!file) return;
sig.err = ''; sig.msg = 'Dokument wird hochgeladen …'; sig.pdf = '';
try { sig.docId = await sigUpload(file); sig.docName = file.name; sig.msg = ''; }
catch (e) { sig.err = 'Dokument-Upload fehlgeschlagen.'; sig.msg = ''; }
}
// Signatur-SVG hochladen und als Zeile hinzufügen (manuell oder ergänzend).
// Bestehende Unterschrift (aus dem signatures-Katalog) zur Liste hinzufügen –
// wie beim manuellen Brief-Signatur-Picker. Nutzt die vorhandene Datei-ID.
function sigBestehendeWaehlen(id) {
const s = signaturListe.value.find((x) => String(x.id) === String(id));
if (!s || !s.file) return;
// Breite/Versatz/Marker aus der hinterlegten Unterschrift übernehmen.
sig.signaturen.push({ file_id: s.file, label: s.label || 'Unterschrift', aktiv: true,
marker_key: s.marker_key || ('SIG' + (sig.signaturen.length + 1)), position: 'genau',
width_mm: s.width_mm ?? 40, offset_x_mm: s.offset_x_mm ?? 0, offset_y_mm: s.offset_y_mm ?? 0 });
sig.pdf = '';
}
async function sigSvgUpload(file) {
if (!file) return;
sig.err = '';
try {
const id = await sigUpload(file);
sig.signaturen.push({ file_id: id, label: file.name, aktiv: true, marker_key: 'SIG' + (sig.signaturen.length + 1), position: 'genau', width_mm: 40, offset_x_mm: 0, offset_y_mm: 0 });
sig.pdf = '';
} catch (e) { sig.err = 'Signatur-Upload fehlgeschlagen.'; }
}
async function sigVorschau() {
if (!sig.docId) { sig.err = 'Bitte ein Dokument wählen/hochladen.'; return; }
if (!sigAktive().length) { sig.err = 'Mindestens eine Unterschrift aktivieren.'; return; }
sig.err = ''; sig.msg = 'Vorschau wird erzeugt …';
if (sig.pdf && sig.pdf.startsWith('blob:')) { try { URL.revokeObjectURL(sig.pdf); } catch { /* egal */ } }
sig.pdf = '';
try {
const res = await api.post('/skrift-orders/signature-preview', sigPayload());
// Chrome rendert data:application/pdf in iframes NICHT → als Blob-URL zeigen.
const bin = atob(res.data.pdf_base64 || '');
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
sig.pdf = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
sig.msg = '';
} catch (e) { sig.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Vorschau fehlgeschlagen.'; sig.msg = ''; }
}
async function sigDownload() {
if (!sig.docId || !sigAktive().length) { sig.err = 'Dokument und Signatur wählen.'; return; }
sig.err = ''; sig.msg = 'A4-Signaturen werden erzeugt …';
try {
const res = await api.post('/skrift-orders/signature-render', sigPayload());
const bin = atob(res.data.zip_base64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
const url = URL.createObjectURL(new Blob([arr], { type: 'application/zip' }));
const a = document.createElement('a');
a.href = url; a.download = `signaturen-${res.data.order_number || sig.order.order_number}.zip`;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
sig.msg = 'ZIP heruntergeladen – A4-Signaturen an den Plotter geben.';
} catch (e) { sig.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Erzeugung fehlgeschlagen.'; sig.msg = ''; }
}
// Legt einen echten Auftrag an: erzeugt je PDF-Seite eine A4-SVG (leer wo kein
// Marker) und schreibt sie als Schriftstück. Danach im Produktions-Board unter
// „Neu" → dort Drucken → Plotter (wie ein normaler Auftrag).
async function sigAuftrag() {
if (!sig.docId) { sig.err = 'Bitte ein Dokument wählen/hochladen.'; return; }
if (!sigAktive().length) { sig.err = 'Mindestens eine Unterschrift aktivieren.'; return; }
if (sig.kuverts && !sig.empfBlocks.trim()) { sig.err = 'Kuverts gewählt, aber keine Empfänger angegeben.'; return; }
sig.err = ''; sig.msg = 'Auftrag wird angelegt …';
try {
const res = await api.post('/skrift-orders/signature-order', {
...sigPayload(), customer_email: sig.customerEmail || undefined,
kuverts: sig.kuverts,
recipients_text: sig.kuverts ? sig.empfBlocks : undefined,
envelope_format: sig.kuverts ? sig.kuvertFormat : undefined,
envelope_sender: sig.kuverts && sig.absenderAuf ? sig.absender.trim() : undefined,
envelope_sender_scale: (Number(sig.absenderScale) || 100) / 100,
link_order_number: sig.linkNr.trim() || undefined,
});
const kt = res.data.kuverts ? `, ${res.data.kuverts} Kuvert/s` : '';
sig.msg = `✔ Auftrag ${res.data.order_number} angelegt (${res.data.pages} Seite/n${kt}). Im Produktions-Board unter „Neu" → Drucken.`;
} catch (e) { sig.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Auftrag konnte nicht angelegt werden.'; sig.msg = ''; }
}
function signaturPanel() {
const sigFeld = (s, k, lbl, step) => h('div', { style: 'display:flex;flex-direction:column;gap:3px;flex:1;min-width:90px;' }, [
h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);' }, lbl),
h('input', { value: s[k], type: k === 'marker_key' ? 'text' : 'number', step: step || 'any',
style: WFELD + 'width:100%;padding:7px 9px;font-size:13px;',
onInput: (e) => { s[k] = k === 'marker_key' ? e.target.value : (parseFloat(e.target.value) || 0); sig.pdf = ''; } }),
]);
const b1 = h('div', { style: BEREICH }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;' }, [
bereichKopf('✍ Unterschriften'),
h('div', { style: 'width:280px;' }, [segToggle(sig.modus, [{ key: 'auftrag', label: 'Aus Auftrag' }, { key: 'manuell', label: 'Manuell' }], (k) => { sig.modus = k; sig.pdf = ''; })]),
]),
sig.modus === 'auftrag'
? h('div', {}, [
h('div', { style: 'display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;' }, [
h('div', { style: 'flex:1;min-width:200px;' }, [label('Auftragsnummer'), h('input', { value: sig.orderNr, placeholder: 'SK-2026-…', style: WFELD + 'width:100%;', onInput: (e) => { sig.orderNr = e.target.value; } })]),
btn(sig.laden ? 'Lädt …' : 'Auftrag laden', sigLaden, 'primary'),
]),
sig.docs.length > 1 ? h('div', { style: 'margin-top:12px;' }, [label('Dokument'), sel(sig.docId, sig.docs.map((d, i) => ({ v: d, t: `Dokument ${i + 1}` })), (e) => { sig.docId = e.target.value; sig.pdf = ''; })]) : null,
])
: h('div', {}, [
label('Dokument (PDF mit Marker [[SIG…]] – Token wie bei Brief-Platzhaltern)'),
h('input', { type: 'file', accept: '.pdf', onChange: (e) => sigPdfUpload(e.target.files && e.target.files[0]), style: 'font-size:13px;' }),
sig.docName ? subdued(`${sig.docName} hochgeladen.`, 'margin-top:6px;') : null,
]),
]);
const b2 = h('div', { style: BEREICH }, [
bereichKopf('Unterschriften & Position'),
subdued(sig.modus === 'auftrag'
? 'Aus dem Kundenkonto vorbelegt (Marker + Feinjustierung), hier überschreibbar. Weitere Unterschrift-SVGs sind ergänzbar.'
: 'Unterschrift(en) als SVG hochladen und Marker/Position setzen.', 'margin-bottom:10px;'),
h('div', { style: 'display:flex;gap:16px;flex-wrap:wrap;align-items:center;margin-bottom:10px;' }, [
// Bestehende Unterschrift aus dem Katalog wählen (wie beim manuellen Brief).
signaturListe.value.filter((s) => s.file).length ? h('select', {
style: WFELD + 'font-size:13px;', value: '',
onChange: (e) => { if (e.target.value) { sigBestehendeWaehlen(e.target.value); e.target.value = ''; } },
}, [
h('option', { value: '' }, '➕ Bestehende Unterschrift wählen …'),
...signaturListe.value.filter((s) => s.file).map((s) => {
const kunde = [s.customer?.first_name, s.customer?.last_name].filter(Boolean).join(' ');
return h('option', { value: s.id }, (s.label || 'Unterschrift') + (kunde ? ` · ${kunde}` : ''));
}),
]) : null,
h('label', { style: `display:inline-block;cursor:pointer;color:${ACCENT};font-size:13px;` }, [
'+ Neue Unterschrift (SVG) hochladen',
h('input', { type: 'file', accept: '.svg', style: 'display:none;', onChange: (e) => sigSvgUpload(e.target.files && e.target.files[0]) }),
]),
]),
...sig.signaturen.map((s) => h('div', { style: 'background:#fff;border-radius:8px;padding:10px 12px;margin-bottom:8px;box-shadow:0 1px 2px rgba(0,0,0,.08);' }, [
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;margin-bottom:8px;cursor:pointer;' }, [
h('input', { type: 'checkbox', checked: s.aktiv, onChange: (e) => { s.aktiv = e.target.checked; sig.pdf = ''; }, style: 'width:16px;height:16px;' }),
s.label,
]),
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
sigFeld(s, 'marker_key', 'Marker'),
sigFeld(s, 'width_mm', 'Breite mm'),
sigFeld(s, 'offset_x_mm', 'X mm'),
sigFeld(s, 'offset_y_mm', 'Y mm'),
]),
])),
// Kunde zuordnen (für den anzulegenden Auftrag) + Aktionen.
h('div', { style: 'margin-top:12px;max-width:360px;' }, [
label('Kundenmail (Auftrag zuordnen, optional)'),
h('input', { value: sig.customerEmail, type: 'email', placeholder: 'kunde@…', style: WFELD + 'width:100%;', onInput: (e) => { sig.customerEmail = e.target.value; } }),
]),
h('div', { class: 'sk-btnrow', style: 'display:flex;gap:12px;flex-wrap:wrap;margin-top:12px;' }, [
btn(sig.msg ? '…' : '👁 Vorschau', sigVorschau, 'primary'),
btn('🖨 Auftrag anlegen', sigAuftrag, 'primary'),
btn('⤓ A4-Signaturen (ZIP)', sigDownload),
]),
]);
// ── Kuverts & Verknüpfung ──────────────────────────────────────────────
const b3 = h('div', { style: BEREICH }, [
bereichKopf('✉ Kuverts & Verknüpfung'),
subdued('Optional: adressierte Kuverts zum Unterschriftenservice erzeugen (wie beim manuellen Druck) – oder den Auftrag mit einem vorhandenen Auftrag verknüpfen, dessen Kuverts genutzt werden.', 'margin-bottom:10px;'),
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;margin-bottom:8px;' }, [
h('input', { type: 'checkbox', checked: sig.kuverts, onChange: (e) => { sig.kuverts = e.target.checked; }, style: 'width:16px;height:16px;' }),
'Kuverts mitdrucken (je Empfänger ein Umschlag)',
]),
sig.kuverts ? h('div', { style: 'padding-left:24px;display:flex;flex-direction:column;gap:12px;margin-bottom:12px;' }, [
h('div', { style: 'max-width:240px;' }, [label('Kuvertformat'), sel(sig.kuvertFormat, [{ v: 'dinlang', t: 'DIN Lang' }, { v: 'c6', t: 'C6' }], (e) => { sig.kuvertFormat = e.target.value; })]),
h('div', {}, [
label('Empfänger (ein Block je Empfänger, durch Leerzeile getrennt)'),
h('textarea', { value: sig.empfBlocks, rows: 6, placeholder: 'Max Mustermann\nMusterstr. 1\n12345 Musterstadt\n\nErika Beispiel\nBeispielweg 2\n54321 Beispielort',
style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { sig.empfBlocks = e.target.value; } }),
]),
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;' }, [
h('input', { type: 'checkbox', checked: sig.absenderAuf, onChange: (e) => { sig.absenderAuf = e.target.checked; }, style: 'width:16px;height:16px;' }),
'Absender aufs Kuvert',
]),
sig.absenderAuf ? h('div', { style: 'display:flex;gap:12px;flex-wrap:wrap;' }, [
h('div', { style: 'flex:1;min-width:200px;' }, [label('Absender (max. 5 Zeilen)'),
h('textarea', { value: sig.absender, rows: 5, style: WFELD + 'width:100%;',
onInput: (e) => { sig.absender = e.target.value.split('\n').slice(0, 5).join('\n'); } })]),
h('div', { style: 'width:120px;' }, [label('Größe (%)'),
h('input', { type: 'number', step: 5, min: 50, max: 200, value: sig.absenderScale, style: WFELD + 'width:100%;', onInput: (e) => { sig.absenderScale = e.target.value; } })]),
]) : null,
]) : null,
h('div', { style: 'max-width:360px;' }, [
label('Mit vorhandenem Auftrag verknüpfen (Nr., optional)'),
h('input', { value: sig.linkNr, placeholder: 'z. B. 07-09-26-003', style: WFELD + 'width:100%;', onInput: (e) => { sig.linkNr = e.target.value; } }),
subdued('Dessen Kuverts werden für diesen Auftrag genutzt. Änderbar im Produktions-Board.', 'margin-top:4px;'),
]),
]);
const meldung = h('div', {}, [
sig.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;margin-top:8px;' }, sig.err) : null,
sig.msg ? subdued(sig.msg, 'margin-top:8px;') : null,
]);
const links = h('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:16px;' }, [b1, b2, b3, meldung]);
const rechts = h('div', { style: BEREICH + 'flex:0 0 420px;position:sticky;top:20px;align-self:flex-start;min-height:200px;' }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:8px;' }, [
bereichKopf('👁 Vorschau'),
sig.pdf ? h('a', { href: sig.pdf, target: '_blank', rel: 'noopener', style: `font-size:12px;color:${ACCENT};text-decoration:none;white-space:nowrap;` }, '↗ Neuer Tab') : null,
]),
sig.pdf
? h('iframe', { src: sig.pdf, style: 'width:100%;height:70vh;border:none;border-radius:8px;background:#fff;' })
: subdued('Noch keine Vorschau. Auftrag laden, Signatur wählen, „Vorschau" klicken.'),
]);
return h('div', { style: 'display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap;' }, [links, rechts]);
}
// Linke Navigationsspalte: Werkzeuge dieses Moduls.
const navItem = (label, aktiv, onClick) => h('div', {
onClick,
style: 'display:flex;align-items:center;gap:8px;border-radius:6px;padding:10px 12px;margin-bottom:3px;font-size:14px;cursor:pointer;'
+ (aktiv ? `background:${ACCENT};color:#fff;font-weight:600;` : 'color:var(--foreground-normal);'),
}, label);
return () => h(resolveComponent('private-view'), { title: ansicht.value === 'signatur' ? 'Unterschriften' : 'Neuer Auftrag' }, {
navigation: () => h('div', { style: 'padding:12px 10px;' }, [
navItem('📝 Neuer Auftrag', ansicht.value === 'neu', () => { ansicht.value = 'neu'; }),
navItem('✍ Unterschriften', ansicht.value === 'signatur', () => { ansicht.value = 'signatur'; }),
]),
default: () => [
// Mobil: Button-Reihen bleiben in einer Zeile und füllen die Breite.
h('style', {}, '@media(max-width:640px){.sk-btnrow>button{flex:1 1 auto!important;}}'),
h('div', { style: 'padding:24px 28px;background:var(--background-subdued,#f1f4f9);min-height:100%;' },
ansicht.value === 'signatur' ? signaturPanel() : (!geladen.value ? subdued('Lädt …') : formular())),
neu.lb ? h(Teleport, { to: 'body' }, [
h('div', {
onClick: () => { neu.lb = ''; neu.lbDoc = null; },
style: 'position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:1100;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;padding:4vh 4vw;cursor:zoom-out;',
}, [
h('img', { src: neu.lb, onClick: (e) => e.stopPropagation(),
style: 'max-width:100%;max-height:82vh;object-fit:contain;border-radius:8px;cursor:default;background:#fff;' }),
// Aktion wie in der App: „Bild speichern" unter der großen Vorschau.
neu.lbDoc ? h('button', { type: 'button', onClick: (e) => { e.stopPropagation(); bildSpeichern(neu.lbDoc); },
style: 'cursor:pointer;border:none;background:#fff;color:#1a1a1a;border-radius:8px;padding:10px 20px;font:inherit;font-size:14px;font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,.3);' }, '💾 Bild speichern') : null,
]),
]) : null,
],
});
},
});
export default {
id: 'skrift-neuauftrag',
name: 'Manueller Druck',
icon: 'print',
routes: [{ path: '', component: NeuAuftrag }],
};