- 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>
1099 lines
74 KiB
JavaScript
1099 lines
74 KiB
JavaScript
/**
|
||
* 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 }],
|
||
};
|