Files
skrift-directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js
s4luorth 850ba38578 Neuauftrag: Empfaenger-Datei-Import (Excel/CSV) nach Spaltennamen - wie app.skrift.de
- neuauftrag: Datei-Upload (.xlsx/.xls/.csv) + Vorlage-Download + Einfuegen; Spalten
  werden per Ueberschrift automatisch zugeordnet (Reihenfolge egal, Zusatzspalten ->
  Platzhalter); Backend-Entries je nach Modus (Einzelfelder / 5 Zeilen / Ind. Text)
  in Empfaengerzeilen gewandelt; Rueckmeldung erkannter Spalten + Fehleranzeige
- skrift-orders: Proxy POST /skrift-orders/parse-recipients -> Backend /api/order/parse-recipients

Braucht Backend >= v1.0.31 (parst XLSX/CSV serverseitig via exceljs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-01 17:39:07 +02:00

1099 lines
74 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
// Klar umrandetes, theme-sicheres Eingabefeld (dezente Füllung → sieht wie ein Feld aus).
const WFELD = 'background:#eef2f8;border:1px solid #b4c0d2;border-radius:8px;padding:10px 12px;font:inherit;font-size:14px;color:#1b2430;outline:none;';
const WSELECT = WFELD + 'cursor:pointer;';
// Bereich = klar abgegrenzte weiße Karte mit sichtbarem Rahmen + feinem Schatten.
const BEREICH = 'background:#ffffff;border:1px solid #b4c0d2;border-radius:12px;padding:20px 22px;box-shadow:0 1px 2px rgba(16,24,40,.05);';
const STANDARD_PLATZHALTER = ['Anrede', 'Vorname', 'Nachname', 'Strasse', 'PLZ', 'Ort', 'Land', 'Briefnummer'];
// Echtes Directus-Icon (Material Symbols) statt Emoji.
const icon = (name, extra = {}) => h(resolveComponent('v-icon'), { name, small: true, ...extra });
function btn(label, onClick, kind, ic) {
const base = 'cursor:pointer;border-radius:8px;padding:9px 16px;font:inherit;font-size:14px;font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid;';
const style = kind === 'primary' ? base + `border-color:${ACCENT};background:${ACCENT};color:#fff;`
: base + 'border-color:#b4c0d2;background:#ffffff;color:#1b2430;';
return h('button', { type: 'button', onClick, style }, [ic ? icon(ic) : null, label].filter(Boolean));
}
function subdued(t, extra) { return h('div', { style: `color:#566070;font-size:13px;${extra || ''}` }, t); }
function segToggle(value, opts, onSel) {
return h('div', { style: 'display:flex;width:100%;border-radius:8px;overflow:hidden;border:1px solid #b4c0d2;background:#ffffff;' },
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:500;` : `background:transparent;color:#1b2430;`),
}, o.label)));
}
function bereichKopf(ic, txt) { return h('div', { style: 'display:flex;align-items:center;gap:8px;font-size:15px;font-weight:600;color:#1b2430;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid #d2dae6;' }, [h('span', { style: `color:${ACCENT};display:inline-flex;align-items:center;` }, icon(ic)), txt]); }
function label(txt) { return h('label', { style: 'display:block;font-size:11.5px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#566070;margin-bottom:5px;' }, txt); }
const NeuAuftrag = defineComponent({
setup() {
const api = useApi();
const produkte = ref([]);
const formate = ref([]);
// Format-Schlüssel, die Postkarten sind (render_formats.kind === 'postkarte').
const pkFormate = ref(new Set());
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: '',
});
// 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', orderName: '',
// Mails an den Kunden senden (Bestätigung/Status). Pro manuellem Auftrag abschaltbar.
mailsSenden: true,
// Kunden-Check: '', 'pruefe', 'vorhanden', 'neu'. Bei 'neu' werden Stammdaten erzwungen.
kundeStatus: '', kundeVorname: '', kundeNachname: '', kundeFirma: '', kundeStr: '', kundePlz: '', kundeOrt: '',
// 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, sigSpacing: '',
// 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()],
// Kuvertformat: '' = automatisch (A4→DIN Lang, A6→C6), sonst dinlang/c6/c4.
envFormat: '',
// Ohne Kuvert & ohne eigene Platzhalter: einfach N identische Schriftstücke.
anzahl: 1,
importOpen: false, importText: '', importFehler: '', importErkannt: [], importAnzahl: 0,
docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '', lbDoc: null,
});
// Ist das gewählte Format eine Postkarte (Text links, Adresse rechts, ein SVG)?
// Kunden-Check: existiert die E-Mail? Bei „neu" werden Stammdaten erzwungen.
let kundeTimer = null;
function pruefeKunde() {
const mail = String(neu.customer_email || '').trim();
clearTimeout(kundeTimer);
if (!mail || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(mail)) { neu.kundeStatus = ''; return; }
neu.kundeStatus = 'pruefe';
kundeTimer = setTimeout(async () => {
try {
const r = await api.get('/users', { params: { filter: { email: { _eq: mail } }, limit: 1, fields: 'id' } });
neu.kundeStatus = (r.data?.data?.[0]) ? 'vorhanden' : 'neu';
} catch { neu.kundeStatus = ''; }
}, 350);
}
const istPostkarte = () => pkFormate.value.has(neu.format);
// Kuverts nur generieren, wenn angehakt (Beschriftung aus der Empfängeradresse).
// Postkarte: KEIN separater Umschlag – der Absender kommt über envelope_sender
// aufs Kartenlayout (rechte Hälfte).
const kuvertPayload = () => {
if (istPostkarte()) {
return { needs_envelope: false, envelope_labeling: 'keine', envelope_text: '',
envelope_sender: (neu.envelopeSender || '').trim(),
envelope_sender_scale: (Number(neu.envelopeSenderScale) || 100) / 100 };
}
return 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,
envelope_format: neu.envFormat || undefined }
: { 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, rf] = 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: [] } })),
api.get('/items/render_formats', { params: { limit: -1, filter: { kind: { _eq: 'postkarte' } }, fields: 'key' } }).catch(() => ({ data: { data: [] } })),
]);
produkte.value = p.data?.data || [];
formate.value = f.data?.data || [];
pkFormate.value = new Set((rf.data?.data || []).map((r) => r.key));
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) => {
const row = {
free_text: String(r.free_text || ''),
placeholders: (r.placeholders && typeof r.placeholders === 'object') ? { ...r.placeholders } : {},
};
// Einzelfelder (Vorname, Nachname, Straße …) mitübernehmen, wenn vorhanden.
for (const [key] of FELDER) if (r[key] != null) row[key] = String(r[key]);
return row;
});
}
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())));
// Ohne Kuvert (und keine Postkarte) sind Empfängeradressen unnötig. Hat der Text
// zudem keine eigenen Platzhalter, sind alle Schriftstücke identisch → dann genügt
// eine Stückzahl statt einzelner Empfängerzeilen.
const keinKuvert = () => !istPostkarte() && !neu.kuverts;
const identischModus = () => keinKuvert() && customPlaceholders().length === 0;
const anzahlNorm = () => Math.max(1, Math.min(1000, Math.round(Number(neu.anzahl) || 1)));
// ── Empfänger-Import: Datei/Einfügen → Backend parst nach SPALTENNAMEN ─────
// (XLSX/CSV, wie in app.skrift.de). Ein Backend-Entry → Empfängerzeile im
// gewählten Modus wandeln.
function entryZuRow(e) {
const row = leereZeile();
if (e.placeholders && typeof e.placeholders === 'object') {
for (const [k, v] of Object.entries(e.placeholders)) if (String(v || '').trim()) row.placeholders = { ...row.placeholders, [k]: saeubere(String(v)) };
}
if (neu.adressModus === 'felder') {
FELDER.forEach(([key]) => { if (e[key]) row[key] = saeubere(String(e[key]).replace(/[\r\n]/g, ' ')); });
} else {
const max = zeilenAnzahl();
let lines;
if (e.free_text && String(e.free_text).trim()) {
lines = String(e.free_text).split('\n');
} else {
const name = [e.salutation, e.first_name, e.last_name].filter(Boolean).join(' ');
const strasse = [e.street, e.house_no].filter(Boolean).join(' ');
const ort = [e.zip, e.city].filter(Boolean).join(' ');
const land = (e.country && !/^(de|deu|d|deutschland|germany)$/i.test(String(e.country).trim())) ? e.country : '';
lines = [name, strasse, ort, land];
}
lines = lines.map((l) => saeubere(String(l || '').replace(/[\r\n]/g, ' ')).trim()).filter(Boolean);
row.free_text = (max === 1) ? lines.join(', ') : lines.slice(0, max).join('\n').replace(/\n+$/, '');
}
return row;
}
function entriesUebernehmen(data) {
const entries = (data && Array.isArray(data.entries)) ? data.entries : [];
if (!entries.length) { neu.importFehler = 'Keine Empfänger in der Datei gefunden.'; return; }
neu.rows = entries.map(entryZuRow);
neu.importErkannt = data.columns || []; neu.importAnzahl = entries.length;
neu.importFehler = ''; neu.importText = ''; neu.importOpen = false; neu.previewSession = null;
neu.msg = `${entries.length} Empfänger übernommen${neu.importErkannt.length ? ' (Spalten: ' + neu.importErkannt.join(', ') + ')' : ''}.`;
setTimeout(() => { if (neu.msg.startsWith(String(entries.length))) neu.msg = ''; }, 6000);
}
async function parseImBackend(dataB64, filename) {
neu.importFehler = ''; neu.pLaedt = true;
try {
const r = await api.post('/skrift-orders/parse-recipients', { data_base64: dataB64, filename });
entriesUebernehmen(r.data || {});
} catch (e) {
neu.importFehler = e?.response?.data?.error || e?.message || 'Datei konnte nicht eingelesen werden.';
} finally { neu.pLaedt = false; }
}
async function dateiHochladen(ev) {
const file = ev.target && ev.target.files && ev.target.files[0];
if (!file) return;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
let bin = ''; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
await parseImBackend(btoa(bin), file.name || 'empfaenger.xlsx');
} catch (e) { neu.importFehler = 'Datei konnte nicht gelesen werden.'; }
finally { ev.target.value = ''; }
}
function importUebernehmen() {
const txt = String(neu.importText || '');
if (!txt.trim()) { neu.importOpen = false; return; }
parseImBackend(btoa(unescape(encodeURIComponent(txt))), 'einfuegen.csv');
}
// Vorlage (CSV, Excel-kompatibel) passend zu Modus + Platzhaltern herunterladen.
function vorlageHerunterladen() {
const ph = customPlaceholders();
const basis = neu.adressModus === 'felder'
? ['Anrede', 'Vorname', 'Nachname', 'Strasse', 'Nr', 'PLZ', 'Ort', 'Land']
: (neu.adressModus === 'individuell' ? ['Zeile 1'] : ['Zeile 1', 'Zeile 2', 'Zeile 3', 'Zeile 4', 'Zeile 5']);
const beispielBasis = neu.adressModus === 'felder'
? ['Frau', 'Erika', 'Mustermann', 'Hauptstraße', '1', '10115', 'Berlin', 'DE']
: (neu.adressModus === 'individuell' ? ['Erika Mustermann, Hauptstraße 1, 10115 Berlin'] : ['Frau Erika Mustermann', 'Hauptstraße 1', '10115 Berlin', '', '']);
const kopf = [...basis, ...ph];
const beispiel = [...beispielBasis.slice(0, basis.length), ...ph.map(() => 'Beispielwert')];
const z = (c) => /[;"\n]/.test(String(c)) ? '"' + String(c).replace(/"/g, '""') + '"' : String(c);
const text = '' + [kopf, beispiel].map((r) => r.map(z).join(';')).join('\r\n') + '\r\n';
const url = URL.createObjectURL(new Blob([text], { type: 'text/csv;charset=utf-8;' }));
const a = document.createElement('a'); a.href = url; a.download = 'skrift-empfaenger-vorlage.csv'; a.click();
setTimeout(() => URL.revokeObjectURL(url), 1500);
}
// ── 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,
// Abstand in mm (leer = Backend-Default = halber Zeilenabstand).
...(String(neu.sigSpacing).trim() !== '' ? { spacing: Number(neu.sigSpacing) || 0 } : {}),
} } }
: {});
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).
// Postkarte braucht immer Text (linke Kartenhälfte).
if (!neu.text.trim() && (istPostkarte() || !neu.kuverts)) { neu.err = 'Bitte Text eingeben' + (istPostkarte() ? '.' : ' – 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,
envelope_format: kv.envelope_format,
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() {
// Neukunde: Stammdaten erzwingen, bevor der Auftrag angelegt wird.
if (neu.kundeStatus === 'neu' && !((neu.kundeVorname.trim() && neu.kundeNachname.trim()) || neu.kundeFirma.trim())) {
neu.err = 'Neuer Kunde: bitte Vor- und Nachnamen oder eine Firma angeben.'; return;
}
// Ohne Kuvert & ohne eigene Platzhalter: N identische Schriftstücke (Blankozeilen).
// Sonst: die erfassten Empfänger – ohne Empfänger genau ein Schriftstück.
let payloadEntries;
if (identischModus()) {
payloadEntries = Array.from({ length: anzahlNorm() }, () => leereZeile());
} else {
const ent = entries();
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, order_name: neu.orderName.trim() || undefined,
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,
envelope_format: kv.envelope_format,
shipping_type: 'sammel',
customer_email: neu.customer_email.trim() || undefined,
mails_senden: neu.mailsSenden,
customer_vorname: neu.kundeVorname.trim() || undefined,
customer_nachname: neu.kundeNachname.trim() || undefined,
customer_company: neu.kundeFirma.trim() || undefined,
customer_address: (neu.kundeStr || neu.kundePlz || neu.kundeOrt) ? { street: neu.kundeStr.trim(), zip: neu.kundePlz.trim(), city: neu.kundeOrt.trim(), country: 'Deutschland' } : 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', orderName: '',
mailsSenden: true, kundeStatus: '', kundeVorname: '', kundeNachname: '', kundeFirma: '', kundeStr: '', kundePlz: '', kundeOrt: '',
fontSizeLetter: 100, fontSizeEnvelope: 100,
sigSvg: '', sigLabel: '', sigX: 0, sigY: 0, sigScale: 100, sigSpacing: '', mehrseitig: false, seitenInfo: '',
adressModus: '5zeilen', kuverts: true, absenderAufKuvert: false, envelopeSender: '', envelopeSenderScale: 100, envFormat: '', realistic: true, customer_email: '', text: '', rows: [leereZeile()],
anzahl: 1,
importOpen: false, importText: '', importFehler: '', importErkannt: [], importAnzahl: 0,
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;
// Vorlage ersetzt den bisherigen Inhalt (nicht anhängen).
neu.text = 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:#566070;' }, 'Dok.'),
h('span', { style: 'font-size:11px;color:#566070;' }, 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:#566070;' }, 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('description', '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('mail', 'Kuvert'),
identischModus() ? null : 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; })]),
]),
// Ohne Kuvert & ohne eigene Platzhalter genügt eine Stückzahl (identische
// Schriftstücke). Sonst die Empfängertabelle mit Zeilen/Import.
identischModus() ? h('div', {}, [
label('Anzahl identischer Schriftstücke'),
h('input', { type: 'number', min: 1, max: 1000, step: 1, value: neu.anzahl, style: WFELD + 'width:170px;',
onInput: (e) => { neu.anzahl = e.target.value; neu.previewSession = null; },
onBlur: (e) => { const n = Math.round(Number(e.target.value)); neu.anzahl = (Number.isFinite(n) && n) ? Math.max(1, Math.min(1000, n)) : 1; } }),
h('div', { style: 'font-size:12px;color:#566070;margin-top:6px;line-height:1.5;' },
'Ohne Kuvert werden keine Empfängeradressen gebraucht – es entstehen so viele gleiche Schriftstücke wie angegeben. '
+ 'Sobald „Kuverts generieren" aktiv ist oder der Text eigene [[Platzhalter]] enthält, erscheint wieder die Empfängertabelle.'),
]) : h('div', {}, [
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:#c0392b;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, undefined, 'person_add'),
btn(neu.importOpen ? 'Import schließen' : 'Datei / Excel importieren', () => { neu.importOpen = !neu.importOpen; neu.importFehler = ''; }, undefined, neu.importOpen ? 'close' : 'upload_file'),
]),
]),
// Postkarte: Hinweis auf das Kartenlayout; kein separates Kuvert.
istPostkarte() ? h('div', { style: 'font-size:12px;color:#566070;margin-top:12px;line-height:1.5;' },
'Postkarte: Text auf der linken Hälfte, Absender + Empfänger rechts, Briefmarkenfeld oben rechts frei. Ein separates Kuvert wird nicht erzeugt.') : null,
!istPostkarte() ? 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',
]) : null,
(!istPostkarte() && neu.kuverts) ? h('div', { style: 'margin-top:8px;max-width:280px;' }, [
label('Kuvertformat'),
h('select', { value: neu.envFormat, style: WFELD + 'width:100%;', onChange: (e) => { neu.envFormat = e.target.value; neu.previewSession = null; } },
[['', 'Automatisch (A4 → DIN Lang, A6 → C6)'], ['dinlang', 'DIN Lang'], ['c6', 'C6'], ['c4', 'C4 (quer)']].map(([v, l]) => h('option', { value: v, selected: neu.envFormat === v }, l))),
]) : null,
(!istPostkarte() && 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,
(istPostkarte() || (neu.kuverts && neu.absenderAufKuvert)) ? h('div', { style: 'margin-top:10px;' }, [
label(istPostkarte() ? 'Absender (rechte Kartenhälfte, max. 5 Zeilen)' : '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:#566070;margin-top:4px;line-height:1.5;' },
istPostkarte() ? 'Empfänger steht darunter in der Anschriftzone (rechte Hälfte).' : 'Der Empfänger rutscht dann auf dem Kuvert automatisch nach unten rechts.'),
]) : null,
(neu.importOpen && !identischModus()) ? (() => {
const ph = customPlaceholders();
const erwartet = (neu.adressModus === 'felder'
? 'Anrede, Vorname, Nachname, Strasse, Nr, PLZ, Ort, Land'
: (neu.adressModus === 'individuell' ? 'Zeile 1' : 'Zeile 1 … Zeile 5'))
+ (ph.length ? ', ' + ph.join(', ') : '');
return h('div', { style: 'margin-top:12px;border:1px solid #d2dae6;border-radius:10px;background:#eef2f8;padding:14px 16px;' }, [
h('div', { style: 'font-size:13px;color:#1b2430;line-height:1.5;margin-bottom:10px;' }, [
h('b', {}, 'Datei importieren (Excel .xlsx/.xls oder CSV). '),
'Die Spalten werden anhand ihrer Überschriften automatisch zugeordnet – die Reihenfolge ist egal, zusätzliche Spalten werden zu [[Platzhaltern]].',
]),
h('div', { style: 'font-size:12px;color:#566070;margin-bottom:12px;' }, ['Erkannte Spalten: ', h('span', { style: 'font-weight:600;color:#1b2430;' }, erwartet)]),
h('div', { style: 'display:flex;gap:10px;flex-wrap:wrap;align-items:center;' }, [
h('label', { style: `cursor:pointer;border-radius:8px;padding:9px 16px;font-size:14px;font-weight:500;display:inline-flex;align-items:center;gap:6px;border:1px solid ${ACCENT};background:${ACCENT};color:#fff;` }, [
icon('upload_file'), neu.pLaedt ? 'Lese Datei …' : 'Datei wählen (Excel/CSV)',
h('input', { type: 'file', accept: '.xlsx,.xls,.csv,.tsv', style: 'display:none;', disabled: neu.pLaedt, onChange: dateiHochladen }),
]),
btn('Vorlage herunterladen', vorlageHerunterladen, undefined, 'download'),
]),
neu.importFehler ? h('div', { style: 'margin-top:10px;background:#fbe3e0;border:1px solid #f3c6bf;color:#c0392b;border-radius:8px;padding:9px 12px;font-size:13px;' }, neu.importFehler) : null,
h('div', { style: 'margin-top:14px;border-top:1px solid #d2dae6;padding-top:12px;' }, [
subdued('Oder Daten aus Excel kopieren und hier einfügen (erste Zeile = Spaltennamen):', 'margin-bottom:6px;'),
h('textarea', { value: neu.importText, rows: 4, placeholder: 'Vorname\tNachname\tPLZ\tOrt\nErika\tMustermann\t10115\tBerlin', style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.importText = e.target.value; } }),
h('div', { style: 'margin-top:6px;' }, [btn('Eingefügte Daten übernehmen', importUebernehmen, 'primary', 'check')]),
]),
]);
})() : 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('edit', '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:#566070;margin-top:4px;line-height:1.5;' },
'Tipp: [[seitenumbruch]] erzwingt an dieser Stelle eine neue Seite (mehrseitiger Brief). [[signatur1]] setzt die Unterschrift. '
+ 'Platzhalter: [[Vorname]], [[Nachname]], [[Anrede]], [[Strasse]], [[PLZ]], [[Ort]] – im 5-Zeilen-Modus außerdem [[Zeile1]]…[[Zeile5]].'),
// ── 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 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; } })]),
h('div', {}, [label('Abstand (mm)'), h('input', { type: 'number', step: 1, min: 0, placeholder: 'Standard', value: neu.sigSpacing, style: WFELD + 'width:100%;', onInput: (e) => { neu.sigSpacing = 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('person', 'Kunde'),
h('div', { style: 'display:flex;gap:16px;flex-wrap:wrap;' }, [
h('div', { style: 'flex:1 1 280px;max-width:420px;' }, [
label('Auftragsname (optional)'),
h('input', { type: 'text', value: neu.orderName, placeholder: 'z. B. EinDollarBrille Dankeskarten', style: WFELD + 'width:100%;', onInput: (e) => { neu.orderName = e.target.value; } }),
]),
h('div', { style: 'flex:1 1 280px;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; pruefeKunde(); } }),
neu.kundeStatus === 'vorhanden' ? h('div', { style: 'font-size:12px;color:#1b6b2e;margin-top:5px;font-weight:600;' }, '✓ Bestehender Kunde wird verknüpft') : null,
neu.kundeStatus === 'neu' ? h('div', { style: 'font-size:12px;color:#1E50A5;margin-top:5px;font-weight:600;' }, 'Neuer Kunde – bitte unten Stammdaten erfassen.') : null,
]),
]),
// Neukundenanlage erzwingen, sobald eine unbekannte E-Mail eingetragen ist.
neu.kundeStatus === 'neu' ? h('div', { style: 'margin-top:14px;border:1px solid #b4c0d2;border-radius:10px;padding:16px;background:#eef2f8;' }, [
h('div', { style: 'font-weight:600;margin-bottom:10px;' }, 'Neuen Kunden anlegen'),
h('div', { style: 'display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;' }, [
h('div', {}, [label('Vorname'), h('input', { value: neu.kundeVorname, style: WFELD + 'width:100%;', onInput: (e) => { neu.kundeVorname = e.target.value; } })]),
h('div', {}, [label('Nachname'), h('input', { value: neu.kundeNachname, style: WFELD + 'width:100%;', onInput: (e) => { neu.kundeNachname = e.target.value; } })]),
h('div', {}, [label('Firma (optional)'), h('input', { value: neu.kundeFirma, style: WFELD + 'width:100%;', onInput: (e) => { neu.kundeFirma = e.target.value; } })]),
h('div', {}, [label('Straße + Nr.'), h('input', { value: neu.kundeStr, style: WFELD + 'width:100%;', onInput: (e) => { neu.kundeStr = e.target.value; } })]),
h('div', {}, [label('PLZ'), h('input', { value: neu.kundePlz, style: WFELD + 'width:100%;', onInput: (e) => { neu.kundePlz = e.target.value; } })]),
h('div', {}, [label('Ort'), h('input', { value: neu.kundeOrt, style: WFELD + 'width:100%;', onInput: (e) => { neu.kundeOrt = e.target.value; } })]),
]),
]) : null,
// Mails an Kunden senden (Bestätigung/Status) – pro Auftrag abschaltbar.
h('label', { style: 'display:flex;align-items:center;gap:8px;margin-top:14px;font-size:14px;cursor:pointer;' }, [
h('input', { type: 'checkbox', checked: neu.mailsSenden, onChange: (e) => { neu.mailsSenden = e.target.checked; }, style: 'width:16px;height:16px;' }),
'Mails an Kunden senden (Bestätigung/Status)',
]),
]);
const aktionen = h('div', { class: 'sk-btnrow', style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
neu.hinweis ? h('div', { style: `color:#c0392b;font-size:13px;width:100%;` }, neu.hinweis) : null,
neu.err ? h('div', { style: 'color:#c0392b;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('visibility', '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:#1b2430;' }, 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; }
sig.err = ''; sig.msg = 'Auftrag wird angelegt …';
try {
const res = await api.post('/skrift-orders/signature-order', { ...sigPayload(), customer_email: sig.customerEmail || undefined });
sig.msg = `✔ Auftrag ${res.data.order_number} angelegt (${res.data.pages} Seite/n). 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:#566070;' }, 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('draw', '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('tune', '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),
]),
]);
const meldung = h('div', {}, [
sig.err ? h('div', { style: 'color:#c0392b;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, 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('visibility', '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:#1b2430;'),
}, 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:#eef2f8;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 }],
};