Files
skrift-programme/Webseite/skrift-job-manager/assets/js/job-app.js
Lucas Orth 0341c4eaea Initiale Ablage der umsortierten Skrift-Programme
Struktur: Docker/ (skrift-backend, signature-service), Webseite/
(skrift-configurator, skrift-job-manager), Produktion/ (skrift-produktion,
AutoRecover). Aufgeräumt: signature-to-svg entfernt, Backend-PayPal-Service
entfernt (PayPal läuft client-seitig im Configurator), Dev-/Test-Artefakte
und Deployment-Dokus bereinigt, .dockerignore repariert. CLAUDE.md mit
Architektur ergänzt. Secrets (.env, config.json) via .gitignore ausgeschlossen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:21:08 +02:00

2631 lines
117 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Skrift Kundenauftrag App
* ES6-Modul – wird via [skrift_kundenauftrag] eingebunden.
* Nutzt window.XLSX (SheetJS, als reguläres Script geladen).
*/
// ─── Konfiguration ────────────────────────────────────────────────────────────
const CFG = window.SkriftJobApp || {};
const REST_URL = CFG.restUrl || '/wp-json/';
const NONCE = CFG.nonce || '';
const API_KEY = CFG.apiKey || '';
const SETTINGS = CFG.settings || {};
const BACKEND_CFG = SETTINGS.backend_connection || {};
const BACKEND_URL = (BACKEND_CFG.api_url || '').replace(/\/$/, '');
const REDIRECT_URL = CFG.redirectUrl || '';
// ─── Schriften ────────────────────────────────────────────────────────────────
const FONTS = [
{ key: 'tilda', label: 'Tilda', desc: 'Druckschrift – leicht lesbar und steril' },
{ key: 'alva', label: 'Alva', desc: 'Druckschrift – organisch und informell' },
{ key: 'ellie', label: 'Ellie', desc: 'Schreibschrift – elegant und geschwungen' },
];
const STEP = {
VERIFY: 'verify',
OVERVIEW: 'overview',
ADDRESS: 'address',
LETTERTEXT: 'lettertext',
PRINTED_UPLOAD: 'printed_upload',
FONT: 'font',
SIGNATURE: 'signature',
CONFIRM: 'confirm',
STATUS: 'status',
};
const STEP_LABELS = {
verify: 'Auftrag',
overview: 'Übersicht',
address: 'Adressdaten',
lettertext: 'Brieftext',
printed_upload: 'Brief-Upload',
font: 'Schrift & Vorschau',
signature: 'Signatur',
confirm: 'Abschluss',
status: 'Status',
};
const STATUS_LABELS = {
waiting: 'Warten auf Daten',
waiting_routine: 'Warten auf Daten Routine',
text_proposal: 'Textvorschlag hinterlegt',
submitted: 'Daten übermittelt',
error: 'Daten fehlerhaft',
printing: 'Druck abgeschlossen',
shipped: 'Versendet',
};
// ─── State ────────────────────────────────────────────────────────────────────
let S = {
step: STEP.VERIFY,
jobIdInput: '',
job: null,
verifyError: null,
verifying: false,
quantity: 0,
addressMode: 'classic',
addressInputMode: 'file', // 'file' | 'table' – Datei-Upload oder Direkteingabe
rows: [],
uploadDone: false,
uploadError: null,
uploadCharsetWarning: false,
uploadStrippedChars: '',
customUploadCharsetWarning: false,
customUploadStrippedChars: '',
briefText: '',
customRows: [], // [{phName: value, …}] – eine Zeile pro Empfänger
customInputMode: 'file', // 'file' | 'table' – Datei-Upload oder Direkteingabe
customUploadDone: false,
customUploadError: null,
customUploadLoading: false,
font: 'tilda',
realisticHandwriting: false,
sessionId: null,
previews: [], // Brief-Previews (von Backend, mit url/index)
envelopePreviews: [], // Umschlag-Previews
previewIndex: 0, // gemeinsam für Brief & Umschlag
previewLoading: false,
previewError: null,
hasOverflow: false,
overflowFiles: [], // [{index, lineCount, lineLimit}]
previewBatchSize: 15, // Anzahl Vorschauen pro Ladeschritt
previewTotalLoaded: 0, // Wie viele Briefe bisher ans Backend gesendet wurden
shippingApproved: false,
// Gedruckte Briefe
printedLetterFile: null,
printedLetterDone: false,
printedLetterError: null,
printedExcelFile: null,
printedExcelDone: false,
// Signatur
signatures: [], // SVG-String je Signaturfeld
signatureStrokes: [], // [[{x,y}[]]] – aktuelle Zeichnungs-Pfade je Canvas
// Unteraufträge / Routine-Modus
isSubOrder: false,
subOrderNumber: null, // Bestellnummer des aktuell laufenden Unterauftrags – dient als eindeutiger Dateispeicher-Schlüssel
subOrders: [], // aus Server geladen
acceptedAgb: false,
acceptedPrivacy: false,
acceptedFinal: false,
submitting: false,
submitError: null,
};
// ─── Step-Liste ───────────────────────────────────────────────────────────────
function stepList() {
if (!S.job) return [STEP.VERIFY];
const isPrinted = S.job.produkt === 'Gedruckte Briefe';
const l = [STEP.VERIFY, STEP.OVERVIEW];
if (S.job.kuvert !== 'keine') l.push(STEP.ADDRESS);
if ((S.job.signatur || 0) > 0) l.push(STEP.SIGNATURE);
if (isPrinted) {
l.push(STEP.PRINTED_UPLOAD);
if (S.job.kuvert !== 'keine') l.push(STEP.FONT); // Umschlag-Vorschau & Schriftwahl
} else {
if (!S.job.textservice || S.job.status === 'text_proposal') l.push(STEP.LETTERTEXT);
l.push(STEP.FONT);
}
l.push(STEP.CONFIRM);
return l;
}
function go(dir) {
const l = stepList();
const idx = l.indexOf(S.step);
const nxt = l[idx + dir];
if (nxt) { S.step = nxt; render(); window.scrollTo({ top: 0, behavior: 'smooth' }); }
}
// ─── API ──────────────────────────────────────────────────────────────────────
function hdrs() {
const h = { 'Content-Type': 'application/json', 'X-WP-Nonce': NONCE };
if (API_KEY) h['X-Skrift-API-Key'] = API_KEY;
return h;
}
async function post(ep, body) {
const r = await fetch(REST_URL + ep, { method: 'POST', headers: hdrs(), body: JSON.stringify(body) });
return r.json();
}
async function verifyJob(id) {
const r = await fetch(`${REST_URL}skrift/v1/job/verify?id=${encodeURIComponent(id)}`, { headers: hdrs() });
return r.json();
}
async function genOrderNumber() {
try {
const d = await post('skrift/v1/order/generate-number', {});
return d.orderNumber || fallbackNum();
} catch { return fallbackNum(); }
}
function fallbackNum() {
const d = new Date();
return `S-${d.getFullYear()}-${p2(d.getMonth()+1)}-${p2(d.getDate())}-${rnd3()}`;
}
const p2 = n => String(n).padStart(2, '0');
const rnd3 = () => String(Math.floor(Math.random() * 1000)).padStart(3, '0');
async function genPreviewBatch(letters) {
if (!S.sessionId) S.sessionId = `job-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
const r = await fetch(`${REST_URL}skrift/v1/proxy/preview/batch`, {
method: 'POST', headers: hdrs(),
body: JSON.stringify({ sessionId: S.sessionId, letters }),
});
// JSON immer lesen – Backend liefert auch bei Fehler-Status auswertbare Overflow-Daten
const data = await r.json().catch(() => null);
if (!data) throw new Error(`Server antwortete mit Status ${r.status}`);
return data;
}
async function genOrder(orderNumber, letters, envelopes, metadata) {
const docs = [
...letters.map((l, i) => ({ ...l, index: l.index ?? i, type: 'letter' })),
...envelopes.map((e, i) => ({ ...e, index: e.index ?? i, type: 'envelope' })),
];
const r = await fetch(`${REST_URL}skrift/v1/proxy/order/generate`, {
method: 'POST', headers: hdrs(),
body: JSON.stringify({ orderNumber, letters: docs, metadata }),
});
return r.json();
}
// Order aus Preview-Cache finalisieren: liefert genau die SVGs, die der Nutzer gesehen hat (1:1-Garantie)
async function finalizeOrder(orderNumber, metadata) {
const r = await fetch(`${REST_URL}skrift/v1/proxy/order/finalize`, {
method: 'POST', headers: hdrs(),
body: JSON.stringify({ sessionId: S.sessionId, orderNumber, metadata }),
});
return r.json();
}
async function updateCustomerEmail(jobId, email) {
return post('skrift/v1/job/customer-email', { jobId, email });
}
async function approveShipping(jobId, token) {
return post('skrift/v1/job/approve-shipping', { jobId, token });
}
async function uploadSignatureSvg(jobId, index, svg) {
return post('skrift/v1/job/upload-signature', { jobId, index, svg });
}
async function notifySubOrder(jobId, orderNumber, quantity, briefText, countDE, countAbroad) {
return post('skrift/v1/job/sub-order/notify', { jobId, orderNumber, quantity, briefText, countDE, countAbroad });
}
// Datei serverseitig speichern (best-effort, blockiert den Ablauf nicht)
// subDir: bei Unteraufträgen die Unterauftrags-Bestellnummer, damit jeder Unterauftrag
// seine eigenen Dateien bekommt statt die des vorherigen zu überschreiben.
async function uploadFileToServer(file, jobId, fileType, subDir) {
if (!file || !jobId) return;
const formData = new FormData();
formData.append('file', file);
formData.append('jobId', jobId);
formData.append('fileType', fileType);
if (subDir) formData.append('subDir', subDir);
const headers = { 'X-WP-Nonce': NONCE };
if (API_KEY) headers['X-Skrift-API-Key'] = API_KEY;
// Kein Content-Type setzen – Browser setzt multipart/form-data + Boundary automatisch
const resp = await fetch(`${REST_URL}skrift/v1/job/upload-file`, { method: 'POST', headers, body: formData });
// Fehler nicht verschlucken: Ein abgelehnter Upload (z.B. MIME-Prüfung) führte bisher dazu,
// dass die Datei nie beim Weiterleiten ans Docker-Backend ankam – ohne jede Rückmeldung.
if (!resp.ok) {
const detail = await resp.text().catch(() => '');
console.warn(`[Skrift] Datei-Upload fehlgeschlagen (${fileType}): HTTP ${resp.status} ${detail}`);
}
}
// Preview-Cache invalidieren wenn sich relevante Daten ändern
function invalidatePreviews() {
S.previews = [];
S.envelopePreviews = [];
S.previewIndex = 0;
S.hasOverflow = false;
S.overflowFiles = [];
S.sessionId = null;
S.previewError = null;
S.previewTotalLoaded = 0;
}
// ─── Format-Mapping ───────────────────────────────────────────────────────────
function fmtKey(txt) {
const t = (txt || '').toLowerCase();
if (t.includes('a4')) return 'a4';
if (t.includes('a6') && /hoch|portrait/.test(t)) return 'a6p';
if (t.includes('a6') && /quer|landscape/.test(t)) return 'a6l';
if (t.includes('a6')) return 'a6p';
return 'a4';
}
// Preview-API erwartet Kleinbuchstaben-Keys ('a4', 'a6p', 'a6l') – identisch mit dem Konfigurator
const fmtBackend = txt => fmtKey(txt);
// Order-Finalisierung nutzt Session-Cache, daher kein gesondertes Uppercase-Mapping nötig
// Envelope-Format bleibt wie vom Backend erwartet: 'DIN_LANG' (A4) oder 'C6' (A6)
const fmtEnvelope = txt => fmtKey(txt) === 'a4' ? 'DIN_LANG' : 'C6';
// Lesbare Format-/Umschlag-Labels für order-metadata.json – identisch zum Hauptkonfigurator,
// damit das Backend nicht nur 'null' schreibt (siehe order-controller: meta.letterFormat …).
function orderMetaFormats() {
const k = fmtKey(S.job?.format);
const hasEnvelope = S.job?.kuvert && S.job.kuvert !== 'keine';
const beschriftung = { adresse: 'Empfängeradresse', individueller_text: 'Individueller Text', keine: 'Keine Beschriftung' };
return {
letterFormat: { a4: 'A4', a6p: 'A6 Hochformat', a6l: 'A6 Querformat' }[k] || S.job?.format || null,
envelopeFormat: hasEnvelope ? (k === 'a4' ? 'DIN Lang' : 'C6') : null,
envelopeBeschriftung: beschriftung[S.job?.kuvert] || null,
};
}
const fontBack = f => ({ tilda: 'tilda', alva: 'alva', ellie: 'ellie' }[f] || 'tilda');
// ─── Daten für Backend ────────────────────────────────────────────────────────
function rowPlaceholders(row) {
const k = S.job.kuvert, m = S.addressMode;
if (k === 'adresse' && m === 'classic') {
return {
vorname: row.firstName || '',
nachname: row.lastName || '',
strasse: [row.street, row.houseNumber].filter(Boolean).join(' '),
plz: row.zip || '',
ort: row.city || '',
land: row.country || '',
};
}
if (k === 'adresse' && m === 'free') {
return { zeile1: row.line1||'', zeile2: row.line2||'', zeile3: row.line3||'', zeile4: row.line4||'', zeile5: row.line5||'' };
}
if (k === 'individueller_text') return { kuverttext: row.text || '' };
return {};
}
function phNames() {
const k = S.job?.kuvert, m = S.addressMode;
if (!k || k === 'keine') return [];
if (k === 'adresse' && m === 'classic') return ['vorname', 'nachname', 'strasse', 'plz', 'ort', 'land'];
if (k === 'adresse' && m === 'free') return ['zeile1', 'zeile2', 'zeile3', 'zeile4', 'zeile5'];
if (k === 'individueller_text') return ['kuverttext'];
return [];
}
// ─── Direkteingabe-Tabelle (Alternative zum Datei-Upload) ─────────────────────
const MAX_TABLE_ROWS = 15; // ab dieser Menge nur noch Datei-Upload anbieten
function addressTableColumns() {
if (S.job.kuvert === 'individueller_text') return [{ key: 'text', label: 'Kuverttext' }];
if (S.addressMode === 'free') {
return [1, 2, 3, 4, 5].map(n => ({ key: `line${n}`, label: `Zeile ${n}` }));
}
return [
{ key: 'firstName', label: 'Vorname' },
{ key: 'lastName', label: 'Nachname' },
{ key: 'street', label: 'Straße' },
{ key: 'houseNumber', label: 'Hausnr.' },
{ key: 'zip', label: 'PLZ' },
{ key: 'city', label: 'Ort' },
{ key: 'country', label: 'Land' },
];
}
// Sorgt dafür, dass ein Zeilen-Array immer genau `quantity` Einträge hat (bestehende Werte bleiben erhalten)
function syncTableRows(arr, quantity) {
if (arr.length === quantity) return arr;
return Array.from({ length: quantity }, (_, i) => arr[i] || {});
}
// Gibt alle [[x]] aus dem Brieftext zurück, die NICHT aus Adressdaten kommen
function customPlaceholderNames() {
if (!S.briefText) return [];
const addrSet = new Set(phNames());
const all = [...S.briefText.matchAll(/\[\[([^\]]+)\]\]/g)].map(m => m[1].trim());
return [...new Set(all)].filter(p => !addrSet.has(p));
}
// Tatsächlich zu erzeugende Dokumentenzahl: Liegen weniger Datensätze vor als die
// Auftragsmenge (z.B. 10 Adressen bei einer Menge von 200), werden nur so viele
// Briefe/Umschläge erzeugt wie Datensätze vorhanden sind – keine leeren Dokumente.
function effectiveQuantity() {
if (S.job?.kuvert !== 'keine' && S.rows.length) {
return Math.min(S.quantity, S.rows.length);
}
return S.quantity;
}
function buildLetters(qty) {
const q = Math.min(qty ?? S.quantity, effectiveQuantity());
const font = fontBack(S.font);
const format = fmtBackend(S.job.format);
const text = S.briefText || '';
// Signaturen als Platzhalter mitgeben (signatur1, signatur2, signatur3)
const sigPlaceholders = {};
(S.signatures || []).forEach((svg, j) => {
if (svg) sigPlaceholders[`signatur${j + 1}`] = svg;
});
return Array.from({ length: q }, (_, i) => ({
index: i, text, font, format,
placeholders: {
...rowPlaceholders(S.rows[i] || {}),
...(S.customRows[i] || {}),
...sigPlaceholders,
},
type: 'letter', realisticHandwriting: S.realisticHandwriting,
}));
}
function buildEnvelopes() {
if (S.job.kuvert === 'keine') return [];
const font = fontBack(S.font);
const format = fmtEnvelope(S.job.format);
return Array.from({ length: effectiveQuantity() }, (_, i) => {
const row = S.rows[i] || {};
let text = '', envType = 'recipient';
if (S.job.kuvert === 'adresse' && S.addressMode === 'classic') {
const lines = [];
const name = [row.firstName, row.lastName].filter(Boolean).join(' ');
if (name) lines.push(name);
const st = [row.street, row.houseNumber].filter(Boolean).join(' ');
if (st) lines.push(st);
const loc = [row.zip, row.city].filter(Boolean).join(' ');
if (loc) lines.push(loc);
if (row.country && row.country !== 'Deutschland') lines.push(row.country);
text = lines.join('\n');
} else if (S.job.kuvert === 'adresse' && S.addressMode === 'free') {
text = [row.line1, row.line2, row.line3, row.line4, row.line5].filter(Boolean).join('\n');
} else if (S.job.kuvert === 'individueller_text') {
text = row.text || ''; envType = 'custom';
}
return { index: i, text, font, format, placeholders: {}, type: 'envelope', envelopeType: envType, realisticHandwriting: S.realisticHandwriting };
});
}
// ─── Länder-Erkennung (DE / Ausland) ──────────────────────────────────────────
// Erkennt in der letzten belegten Freitext-Zeile Hinweise auf Deutschland:
// "Deutschland", "Germany", "DE" (als eigenes Wort/Zeile) oder "D-<PLZ>" (z.B. "D-66679").
function isGermanFreeTextLine(line) {
const s = (line || '').trim();
if (!s) return true; // keine Angabe → wie bei "classic" als Inland gewertet
const upper = s.toUpperCase();
if (/\bDEUTSCHLAND\b/.test(upper)) return true;
if (/\bGERMANY\b/.test(upper)) return true;
if (/\bD-\d{4,5}\b/.test(upper)) return true;
if (/(^|\s)DE(\s|$)/.test(upper)) return true;
return false;
}
// Letzte nicht-leere Zeile einer Freitext-Adresse (dort steht typischerweise das Land/PLZ+Land)
function lastFreeAddressLine(row) {
const lines = [row.line1, row.line2, row.line3, row.line4, row.line5];
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i] && lines[i].trim()) return lines[i];
}
return '';
}
// Ermittelt die DE/Ausland-Verteilung der Adressdaten – classic über die Land-Spalte,
// free über die letzte belegte Freitext-Zeile. Bei anderen Kuvert-Arten nicht verfügbar.
function countryStats() {
if (S.job?.kuvert !== 'adresse') return { countDE: null, countAbroad: null };
if (S.addressMode === 'classic') {
return {
countDE: S.rows.filter(r => !r.country || r.country.trim().toLowerCase() === 'deutschland').length,
countAbroad: S.rows.filter(r => r.country && r.country.trim().toLowerCase() !== 'deutschland').length,
};
}
if (S.addressMode === 'free') {
const countDE = S.rows.filter(r => isGermanFreeTextLine(lastFreeAddressLine(r))).length;
return { countDE, countAbroad: S.rows.length - countDE };
}
return { countDE: null, countAbroad: null };
}
// ─── Admin-Benachrichtigung ───────────────────────────────────────────────────
async function notifyAdmin(orderNumber) {
const { countDE, countAbroad } = countryStats();
try {
await post('skrift/v1/job/notify', {
jobId: S.job.id,
orderNumber,
quantity: S.quantity,
countDE,
countAbroad,
addressMode: S.addressMode,
briefText: S.briefText || '',
});
} catch (e) {
console.warn('[JobApp] Admin-Benachrichtigung fehlgeschlagen:', e);
}
}
// ─── Template herunterladen ───────────────────────────────────────────────────
function dlTemplate(type) {
const menge = S.quantity || 10;
const a = document.createElement('a');
a.href = `${REST_URL}skrift/v1/job/template?type=${encodeURIComponent(type)}&menge=${menge}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// Platzhalter-Vorlage als xlsx client-seitig generieren und herunterladen
function dlCustomTemplate() {
const XLSX = window.XLSX;
if (!XLSX) { alert('SheetJS nicht verfügbar – bitte Seite neu laden.'); return; }
const phs = customPlaceholderNames();
if (!phs.length) return;
// Include Vorname/Nachname/Ort from address rows for orientation (read-only on import)
const hasAddr = S.job.kuvert === 'adresse' && S.addressMode === 'classic' && S.rows.length;
const addrCols = hasAddr ? ['Vorname', 'Nachname', 'Ort'] : [];
const header = [...addrCols, ...phs];
const data = [header];
for (let i = 0; i < S.quantity; i++) {
const existing = S.customRows[i] || {};
const addr = hasAddr ? [
S.rows[i]?.firstName || '',
S.rows[i]?.lastName || '',
S.rows[i]?.city || '',
] : [];
data.push([...addr, ...phs.map(p => existing[p] ?? '')]);
}
const ws = XLSX.utils.aoa_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Platzhalter');
XLSX.writeFile(wb, 'platzhalter-vorlage.xlsx');
}
// ─── Direkt eingegebene Tabellendaten serverseitig als Excel-Datei hinterlegen ─
function rowsToXlsxFile(header, rows, filename) {
const XLSX = window.XLSX;
if (!XLSX) return null;
const ws = XLSX.utils.aoa_to_sheet([header, ...rows]);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Daten');
const buf = XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
return new File([buf], filename, { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
}
// Best-effort: dieselbe Ablage wie beim Datei-Upload, damit das Backend auch bei Direkteingabe eine Excel-Datei vorliegen hat
function persistTableAsFile(type) {
if (!S.job?.id) return;
let header, rows, filename;
if (type === 'adresse') {
const cols = addressTableColumns();
header = cols.map(c => c.label);
rows = S.rows.map(r => cols.map(c => r[c.key] || ''));
filename = 'adresse.xlsx';
} else {
const phs = customPlaceholderNames();
header = phs;
rows = S.customRows.map(r => phs.map(p => r[p] || ''));
filename = 'platzhalter.xlsx';
}
const file = rowsToXlsxFile(header, rows, filename);
if (file) uploadFileToServer(file, S.job.id, type, S.isSubOrder ? S.subOrderNumber : null).catch(() => {});
}
// ─── Mengen-Popup ─────────────────────────────────────────────────────────────
function showQuantityDialog(detected, current) {
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'sk-job-modal-overlay';
overlay.innerHTML = `
<div class="sk-job-modal" role="dialog" aria-modal="true" aria-labelledby="sk-modal-title">
<h3 class="sk-job-modal-title" id="sk-modal-title">Mehr Datensätze als Auftragsmenge</h3>
<p class="sk-job-modal-text">
In der Datei wurden <strong>${detected}</strong> Datensätze gefunden,
die aktuelle Auftragsmenge beträgt jedoch <strong>${current}</strong>.
</p>
<p class="sk-job-modal-text">Soll die Menge auf <strong>${detected}</strong> angepasst werden?</p>
<div class="sk-job-modal-actions">
<button class="sk-btn sk-btn-secondary" id="sk-modal-no">${current} behalten</button>
<button class="sk-btn sk-btn-primary" id="sk-modal-yes">Ja, auf ${detected} anpassen</button>
</div>
</div>`;
document.body.appendChild(overlay);
const close = result => { overlay.remove(); resolve(result); };
overlay.querySelector('#sk-modal-yes').addEventListener('click', () => close(true));
overlay.querySelector('#sk-modal-no').addEventListener('click', () => close(false));
const onKey = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', onKey); close(false); } };
document.addEventListener('keydown', onKey);
});
}
// Popup bei abweichender Zeilenzahl in der Direkteingabe-Tabelle (z.B. nach Excel-Einfügen):
// Menge auf die Empfängerzahl erhöhen ('add') oder aktuelle Menge beibehalten ('keep').
function showRowCountDialog(detected, current) {
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'sk-job-modal-overlay';
overlay.innerHTML = `
<div class="sk-job-modal" role="dialog" aria-modal="true" aria-labelledby="sk-rowcount-title">
<h3 class="sk-job-modal-title" id="sk-rowcount-title">Anzahl weicht von der Menge ab</h3>
<p class="sk-job-modal-text">
Sie haben <strong>${detected}</strong> Empfänger in der Tabelle,
die aktuelle Auftragsmenge beträgt jedoch <strong>${current}</strong>.
</p>
<p class="sk-job-modal-text">Wie möchten Sie fortfahren?</p>
<div class="sk-job-modal-actions">
<button class="sk-btn sk-btn-secondary" id="sk-rowcount-keep">Menge beibehalten (${current})</button>
<button class="sk-btn sk-btn-primary" id="sk-rowcount-add">Zusätzliche Schriftstücke ergänzen (${detected})</button>
</div>
</div>`;
document.body.appendChild(overlay);
const close = result => { document.removeEventListener('keydown', onKey); overlay.remove(); resolve(result); };
overlay.querySelector('#sk-rowcount-add') .addEventListener('click', () => close('add'));
overlay.querySelector('#sk-rowcount-keep').addEventListener('click', () => close('keep'));
const onKey = e => { if (e.key === 'Escape') close('keep'); };
document.addEventListener('keydown', onKey);
});
}
// ─── Datei parsen (via window.XLSX von SheetJS) ───────────────────────────────
async function parseFile(file) {
const XLSX = window.XLSX;
if (!XLSX) throw new Error('SheetJS nicht verfügbar. Bitte Seite neu laden.');
const buf = await file.arrayBuffer();
const wb = XLSX.read(buf, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
const raw = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });
if (!raw.length) throw new Error('Die Datei ist leer.');
const headers = (raw[0] || []).map(h => String(h).trim().toLowerCase());
const dataRows = raw.slice(1).filter(r => r.some(c => String(c).trim() !== ''));
if (!dataRows.length) throw new Error('Keine Datenzeilen gefunden.');
return { headers, dataRows };
}
function mapClassic(headers, row) {
const get = (...aliases) => {
for (const a of aliases) {
const i = headers.indexOf(a);
if (i !== -1 && row[i] !== undefined) return sanitizeText(String(row[i]).trim(), false);
}
return '';
};
return {
firstName: get('vorname'),
lastName: get('nachname'),
street: get('straße', 'strasse'),
houseNumber: get('hausnummer'),
zip: get('plz'),
city: get('ort'),
country: get('land') || 'Deutschland',
};
}
function mapFree(headers, row) {
const g = (alias, fallback) => {
const i = headers.indexOf(alias);
return sanitizeText(String((i !== -1 ? row[i] : row[fallback]) ?? '').trim(), false);
};
return { line1: g('zeile 1', 0), line2: g('zeile 2', 1), line3: g('zeile 3', 2), line4: g('zeile 4', 3), line5: g('zeile 5', 4) };
}
function mapCustomText(headers, row) {
const i = headers.indexOf('kuverttext');
return { text: sanitizeText(String((i !== -1 ? row[i] : row[0]) ?? '').trim(), false) };
}
// ─── Zeichenfilter ────────────────────────────────────────────────────────────
// Erlaubte Zeichen: A-Z a-z Ä Ö Ü ä ö ü ß + ASCII-Druckzeichen + ¡£§«»¿ + ‐–— + ‘’\”\” + € ₤
const ALLOWED_CHARS_RE = /[^\x20-\x5F\x61-\x7B\x7D\n\r\tÄÖÜäöüß¡£§«»¿‐–—‘’“”€]/g;
const ALLOWED_CHARS_NO_NL_RE = /[^\x20-\x5F\x61-\x7B\x7DÄÖÜäöüß¡£§«»¿‐–—‘’“”€]/g;
let _charsetStripped = false;
let _strippedChars = new Set();
function sanitizeText(str, allowNewlines = true) {
const s = String(str ?? '');
const re = allowNewlines ? ALLOWED_CHARS_RE : ALLOWED_CHARS_NO_NL_RE;
const cleaned = s.replace(re, '');
if (cleaned !== s) {
_charsetStripped = true;
const found = s.match(new RegExp(re.source, 'g')) || [];
found.forEach(ch => _strippedChars.add(ch));
}
return cleaned;
}
// ─── CAPS-Warnung ─────────────────────────────────────────────────────────────
function hasCapsWarn(text) {
return text.split(/\s+/).some(w => {
const letters = w.replace(/[^a-zA-ZÄÖÜäöü]/g, '');
return letters.length >= 7 && letters === letters.toUpperCase() && /[A-ZÄÖÜ]/.test(letters);
});
}
// ─── HTML-Helfer ──────────────────────────────────────────────────────────────
const esc = s => String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
function stepper() {
const list = stepList();
const curIdx = list.indexOf(S.step);
return `<div class="sk-stepper">${list.map((s, i) => {
const done = i < curIdx;
const active = i === curIdx;
const cls = done ? 'sk-chip is-complete' : active ? 'sk-chip is-active' : 'sk-chip';
const inner = done ? `<span class="sk-chip-check">✓</span> ${STEP_LABELS[s]}` : STEP_LABELS[s];
const arrow = i < list.length - 1 ? '<span class="sk-stepper-arrow">›</span>' : '';
return `<button class="${cls}" disabled style="cursor:default;">${inner}</button>${arrow}`;
}).join('')}</div>`;
}
function navBar(showPrev, nextLabel, nextId, nextDisabled) {
return `<div class="sk-nav">
${showPrev ? `<button class="sk-btn sk-btn-secondary" id="sk-job-prev">← Zurück</button>` : ''}
<button class="sk-btn sk-btn-primary sk-btn-large"
id="${nextId || 'sk-job-next'}"
${nextDisabled ? 'disabled' : ''}>
${nextLabel || 'Weiter →'}
</button>
</div>`;
}
// ─── Steps ────────────────────────────────────────────────────────────────────
function renderVerify() {
return `
${stepper()}
<div class="sk-card">
<div class="sk-card-head">
<h2 class="sk-card-title">Auftrag abrufen</h2>
<p class="sk-card-subtitle">Geben Sie Ihre Auftragsnummer ein, die Sie von uns erhalten haben.</p>
</div>
<div class="sk-card-body">
<div class="sk-field">
<label class="sk-field-label" for="sk-job-id">Auftragsnummer</label>
<div class="sk-job-verify-row">
<input class="sk-input" type="text" id="sk-job-id"
value="${esc(S.jobIdInput)}" placeholder="z. B. AUF-2026-001"
autocomplete="off" spellcheck="false">
<button class="sk-btn sk-btn-primary" id="sk-job-verify-btn"
${S.verifying ? 'disabled' : ''}>
${S.verifying ? '<span class="sk-job-spinner"></span> Lädt…' : 'Abrufen'}
</button>
</div>
${S.verifyError ? `<div class="sk-alert sk-alert-error" style="margin-top:12px;">${esc(S.verifyError)}</div>` : ''}
</div>
</div>
</div>`;
}
function renderSubOrdersPanel() {
if (S.job?.status !== 'waiting_routine' || S.isSubOrder) return '';
const subs = S.subOrders.length ? S.subOrders : (S.job?.subOrders || []);
const badgeCls = st => ({
waiting: 'sk-badge-waiting', submitted: 'sk-badge-submitted',
error: 'sk-badge-error', printing: 'sk-badge-printing', shipped: 'sk-badge-shipped',
}[st] || 'sk-badge-waiting');
const rows = subs.length
? subs.map(sub => {
const ts = sub.createdAt
? new Date(sub.createdAt.replace(' ', 'T') + 'Z').toLocaleString('de-DE',
{ day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' })
: '–';
return `<tr>
<td><strong>${esc(sub.id)}</strong>${sub.orderNumber ? `<br><small style="color:#94a3b8;">${esc(sub.orderNumber)}</small>` : ''}</td>
<td><span class="sk-badge ${badgeCls(sub.status)}">${esc(STATUS_LABELS[sub.status] || sub.status)}</span></td>
<td>${esc(String(sub.quantity || '–'))}</td>
<td style="font-size:12px;color:#64748b;">${esc(ts)}</td>
</tr>`;
}).join('')
: `<tr><td colspan="4" style="color:#94a3b8;font-style:italic;">Noch keine Unteraufträge vorhanden.</td></tr>`;
return `
<div class="sk-card" style="margin-top:12px;">
<div class="sk-card-head" style="padding-bottom:10px;">
<h3 class="sk-card-title" style="font-size:15px;margin:0;">Bisherige Unteraufträge</h3>
</div>
<div class="sk-card-body" style="padding-top:0;">
<div class="sk-table-wrapper">
<table class="sk-table">
<thead><tr><th>Unterauftrag</th><th>Status</th><th>Menge</th><th>Datum</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
<div style="margin-top:16px;">
<button class="sk-btn sk-btn-primary sk-btn-large" id="sk-routine-new" type="button">
Neuen Datensatz hochladen →
</button>
</div>
</div>
</div>`;
}
function renderOverview() {
const j = S.job;
const kuvertLabel = { keine: 'Keine', adresse: 'Adresse', individueller_text: 'Individueller Text' };
const isError = j.status === 'error';
return `
${stepper()}
${isError ? `
<div class="sk-alert sk-alert-error" style="margin-bottom:16px;">
<strong>Ihre Daten konnten nicht verarbeitet werden.</strong><br>
${esc(j.statusReason || 'Bitte laden Sie Ihre Daten korrigiert erneut hoch.')}
</div>` : ''}
${j.status === 'text_proposal' ? `
<div class="sk-alert sk-alert-info" style="margin-bottom:16px;">
<strong>Textvorschlag verfügbar</strong><br>
Skrift hat Ihnen einen Brieftext-Vorschlag hinterlegt. Sie finden ihn im Schritt <em>Brieftext</em> – dort können Sie ihn lesen und anpassen.
</div>` : ''}
<div class="sk-card">
<div class="sk-card-head">
<h2 class="sk-card-title">${S.isSubOrder ? 'Unterauftrag – Übersicht' : 'Ihr Auftrag'}</h2>
<p class="sk-card-subtitle">Auftragsnummer: <strong>${esc(j.id)}</strong></p>
</div>
<div class="sk-card-body">
<div class="sk-job-overview-grid">
<div class="sk-field">
<label class="sk-field-label">Produktart</label>
<div class="sk-job-locked">${esc(j.produkt)}</div>
</div>
<div class="sk-field">
<label class="sk-field-label">Format</label>
<div class="sk-job-locked">${esc(j.format || '–')}</div>
</div>
<div class="sk-field">
<label class="sk-field-label">Kuvertbeschriftung</label>
<div class="sk-job-locked">${kuvertLabel[j.kuvert] || esc(j.kuvert)}</div>
</div>
<div class="sk-field">
<label class="sk-field-label" for="sk-job-qty">Menge</label>
<input class="sk-input" type="number" id="sk-job-qty" min="1" value="${S.quantity}" style="max-width:140px;">
</div>
</div>
${j.textservice ? `<div class="sk-alert sk-alert-success" style="margin-top:8px;">
Textservice aktiv – Skrift verfasst den Brieftext für Sie.
</div>` : ''}
<div class="sk-sep"></div>
<div class="sk-field">
<label class="sk-field-label" for="sk-customer-email">Ihre E-Mail-Adresse</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<input class="sk-input" type="email" id="sk-customer-email"
value="${esc(j.customerEmail || '')}" placeholder="ihre@email.de" style="flex:1;min-width:200px;">
<button class="sk-btn sk-btn-secondary" id="sk-save-email" type="button">Speichern</button>
</div>
<p class="sk-help" style="margin-top:4px;">
Wir informieren Sie per E-Mail über den Status Ihres Auftrags.
${!j.customerEmail ? '<strong>Bitte hinterlegen Sie Ihre E-Mail-Adresse.</strong>' : ''}
</p>
</div>
</div>
</div>
${renderSubOrdersPanel()}
${(S.job?.status === 'waiting_routine' && !S.isSubOrder) ? '' : navBar(false, 'Weiter →', 'sk-job-next', false)}`;
}
function renderAddress() {
return S.job.kuvert === 'individueller_text' ? renderCustomTextUpload() : renderAddressUpload();
}
function renderAddressUpload() {
const showTableToggle = S.quantity > 0 && S.quantity <= MAX_TABLE_ROWS;
if (!showTableToggle) S.addressInputMode = 'file';
const tableMode = showTableToggle && S.addressInputMode === 'table';
return `
${stepper()}
<div class="sk-card">
<div class="sk-card-head">
<h2 class="sk-card-title">Adressdaten</h2>
<p class="sk-card-subtitle">Laden Sie eine Vorlage herunter, füllen Sie sie aus und laden Sie die Datei zurück hoch.</p>
</div>
<div class="sk-card-body">
<div class="sk-options" id="sk-addr-options">
<label class="sk-option ${S.addressMode === 'classic' ? 'is-selected' : ''}">
<input type="radio" name="sk-addr-mode" value="classic" ${S.addressMode === 'classic' ? 'checked' : ''}>
<div class="sk-option-content">
<div class="sk-option-label">Standard-Adressliste</div>
<div class="sk-option-desc">Felder: Vorname, Nachname, Straße, Hausnummer, PLZ, Ort, Land</div>
</div>
</label>
<label class="sk-option ${S.addressMode === 'free' ? 'is-selected' : ''}">
<input type="radio" name="sk-addr-mode" value="free" ${S.addressMode === 'free' ? 'checked' : ''}>
<div class="sk-option-content">
<div class="sk-option-label">Freitext-Adressliste</div>
<div class="sk-option-desc">5 frei beschreibbare Zeilen pro Empfänger</div>
</div>
</label>
</div>
<div class="sk-sep"></div>
${showTableToggle ? renderInputModeToggle(S.addressInputMode, 'sk-addr-input-mode') : ''}
${tableMode ? renderAddressTable() : `
<div class="sk-job-dl-row">
${S.addressMode === 'classic'
? `<button class="sk-job-dl-btn" id="sk-dl-standard" type="button">Standard-Vorlage herunterladen</button>`
: `<button class="sk-job-dl-btn" id="sk-dl-free" type="button">Freitext-Vorlage herunterladen</button>`
}
</div>
<div class="sk-job-upload-zone ${S.uploadDone ? 'is-success' : ''}" id="sk-upload-zone">
<input type="file" id="sk-file-input" accept=".xlsx,.xls,.csv">
<div class="sk-job-upload-title">
${S.uploadDone ? `${S.rows.length}&nbsp;${S.rows.length === 1 ? 'Datensatz' : 'Datensätze'} geladen&nbsp;– erneut klicken zum Ersetzen` : 'Ausgefüllte Datei hier ablegen oder klicken'}
</div>
<div class="sk-job-upload-sub">.xlsx oder .csv</div>
</div>
${S.uploadError ? `<div class="sk-alert sk-alert-error" style="margin-top:12px;">${esc(S.uploadError)}</div>` : ''}
${S.uploadCharsetWarning ? `<div class="sk-alert sk-alert-warning" style="margin-top:12px;"><strong>Hinweis:</strong>&nbsp;Folgende Zeichen werden nicht unterstützt und wurden entfernt:&nbsp;<strong>${esc(S.uploadStrippedChars)}</strong></div>` : ''}
${S.uploadDone ? mismatchWarning() + uploadTable() : ''}
`}
</div>
</div>
${navBar(true, 'Weiter →', 'sk-job-next', !S.uploadDone)}`;
}
function renderCustomTextUpload() {
const showTableToggle = S.quantity > 0 && S.quantity <= MAX_TABLE_ROWS;
if (!showTableToggle) S.addressInputMode = 'file';
const tableMode = showTableToggle && S.addressInputMode === 'table';
return `
${stepper()}
<div class="sk-card">
<div class="sk-card-head">
<h2 class="sk-card-title">Individueller Kuverttext</h2>
<p class="sk-card-subtitle">Laden Sie die Vorlage herunter, tragen Sie den gewünschten Text pro Umschlag ein und laden Sie die Datei hoch.</p>
</div>
<div class="sk-card-body">
${showTableToggle ? renderInputModeToggle(S.addressInputMode, 'sk-addr-input-mode') : ''}
${tableMode ? renderAddressTable() : `
<div class="sk-job-dl-row" style="margin-bottom:16px;">
<button class="sk-job-dl-btn" id="sk-dl-customtext" type="button">Vorlage herunterladen</button>
</div>
<div class="sk-job-upload-zone ${S.uploadDone ? 'is-success' : ''}" id="sk-upload-zone">
<input type="file" id="sk-file-input" accept=".xlsx,.xls,.csv">
<div class="sk-job-upload-title">
${S.uploadDone ? `${S.rows.length}&nbsp;${S.rows.length === 1 ? 'Zeile' : 'Zeilen'} geladen&nbsp;– erneut klicken zum Ersetzen` : 'Ausgefüllte Datei hier ablegen oder klicken'}
</div>
<div class="sk-job-upload-sub">.xlsx oder .csv</div>
</div>
${S.uploadError ? `<div class="sk-alert sk-alert-error" style="margin-top:12px;">${esc(S.uploadError)}</div>` : ''}
${S.uploadCharsetWarning ? `<div class="sk-alert sk-alert-warning" style="margin-top:12px;"><strong>Hinweis:</strong>&nbsp;Folgende Zeichen werden nicht unterstützt und wurden entfernt:&nbsp;<strong>${esc(S.uploadStrippedChars)}</strong></div>` : ''}
${S.uploadDone ? mismatchWarning() + uploadTable() : ''}
`}
</div>
</div>
${navBar(true, 'Weiter →', 'sk-job-next', !S.uploadDone)}`;
}
function renderPrintedUpload() {
return `
${stepper()}
<div class="sk-card">
<div class="sk-card-head">
<h2 class="sk-card-title">Brief-Upload</h2>
<p class="sk-card-subtitle">Laden Sie das Briefdokument hoch (Word oder PDF). Bei Briefen mit Platzhaltern können Sie zusätzlich eine Excel-Datei hochladen.</p>
</div>
<div class="sk-card-body">
<div class="sk-field">
<label class="sk-field-label">Briefdokument <span style="color:red">*</span></label>
<div class="sk-job-upload-zone ${S.printedLetterDone ? 'is-success' : ''}" id="sk-printed-letter-zone">
<input type="file" id="sk-printed-letter-input" accept=".pdf,.doc,.docx">
<div class="sk-job-upload-title">
${S.printedLetterDone
? `✓ ${esc(S.printedLetterFile?.name || 'Datei geladen')} – erneut klicken zum Ersetzen`
: 'Word- oder PDF-Datei hier ablegen oder klicken'}
</div>
<div class="sk-job-upload-sub">.pdf, .doc oder .docx</div>
</div>
${S.printedLetterError ? `<div class="sk-alert sk-alert-error" style="margin-top:8px;">${esc(S.printedLetterError)}</div>` : ''}
</div>
<div class="sk-sep"></div>
<div class="sk-field">
<label class="sk-field-label">Platzhalter-Daten (optional)</label>
<p class="sk-help">Enthält Ihr Brief Word-Platzhalter (z.&nbsp;B. «Vorname»), laden Sie hier die Excel-Datei mit den Werten hoch.</p>
<div class="sk-job-upload-zone ${S.printedExcelDone ? 'is-success' : ''}" id="sk-printed-excel-zone">
<input type="file" id="sk-printed-excel-input" accept=".xlsx,.xls,.csv">
<div class="sk-job-upload-title">
${S.printedExcelDone
? `✓ ${esc(S.printedExcelFile?.name || 'Datei geladen')} – erneut klicken zum Ersetzen`
: 'Excel-Datei hier ablegen oder klicken (optional)'}
</div>
<div class="sk-job-upload-sub">.xlsx oder .csv</div>
</div>
</div>
</div>
</div>
${navBar(true, 'Weiter →', 'sk-job-next', !S.printedLetterDone)}`;
}
function mismatchWarning() {
if (!S.rows.length || S.rows.length === S.quantity) return '';
const diff = S.rows.length - S.quantity;
const more = diff > 0;
const action = more
? `Es werden nur die ersten <strong>${S.quantity}</strong>&nbsp;Datensätze verwendet, oder erhöhen Sie die Auftragsmenge in Schritt&nbsp;„Übersicht".`
: `Es werden nur <strong>${S.rows.length}</strong>&nbsp;Briefe/Umschläge erzeugt (keine Leerdokumente), oder ergänzen Sie die fehlenden <strong>${Math.abs(diff)}</strong>&nbsp;Datensätze bzw. reduzieren Sie die Auftragsmenge in Schritt&nbsp;„Übersicht".`;
return `<div class="sk-alert sk-alert-warning" style="margin-top:16px;">
<strong>Anzahl weicht ab:</strong> Die Datei enthält <strong>${S.rows.length}</strong>&nbsp;Datensätze, die Auftragsmenge beträgt <strong>${S.quantity}</strong>. ${action}
</div>`;
}
function uploadTable() {
if (!S.rows.length) return '';
const keys = Object.keys(S.rows[0]);
const label = { firstName:'Vorname', lastName:'Nachname', street:'Straße', houseNumber:'Nr.', zip:'PLZ', city:'Ort', country:'Land', line1:'Zeile 1', line2:'Zeile 2', line3:'Zeile 3', line4:'Zeile 4', line5:'Zeile 5', text:'Kuverttext' };
return `
<div class="sk-table-wrapper" style="margin-top:16px;">
<table class="sk-table">
<thead><tr><th class="sk-col-nr">#</th>${keys.map(k => `<th>${label[k] || k}</th>`).join('')}</tr></thead>
<tbody>${S.rows.map((r, i) => `<tr><td class="sk-col-nr">${i+1}</td>${keys.map(k => `<td>${esc(r[k])}</td>`).join('')}</tr>`).join('')}</tbody>
</table>
</div>
<p class="sk-help" style="margin-top:8px;">
${S.rows.length}&nbsp;${S.rows.length === 1 ? 'Datensatz' : 'Datensätze'} geladen.
</p>`;
}
function customTable(phs) {
if (!S.customRows.length) return '';
const hasAddr = S.job.kuvert === 'adresse' && S.addressMode === 'classic' && S.rows.length;
return `
<div class="sk-table-wrapper" style="margin-top:16px;">
<table class="sk-table">
<thead><tr>
<th class="sk-col-nr">#</th>
${hasAddr ? '<th>Vorname</th><th>Nachname</th><th>Ort</th>' : ''}
${phs.map(p => `<th>[[${esc(p)}]]</th>`).join('')}
</tr></thead>
<tbody>${S.customRows.map((r, i) => `<tr>
<td class="sk-col-nr">${i+1}</td>
${hasAddr ? `<td>${esc(S.rows[i]?.firstName||'')}</td><td>${esc(S.rows[i]?.lastName||'')}</td><td>${esc(S.rows[i]?.city||'')}</td>` : ''}
${phs.map(p => `<td>${esc(r[p] || '')}</td>`).join('')}
</tr>`).join('')}</tbody>
</table>
</div>
<p class="sk-help" style="margin-top:8px;">
${S.customRows.length}&nbsp;${S.customRows.length === 1 ? 'Zeile' : 'Zeilen'} importiert.
</p>`;
}
function renderInputModeToggle(mode, name) {
return `
<div class="sk-options" style="margin-bottom:16px;">
<label class="sk-option ${mode === 'file' ? 'is-selected' : ''}">
<input type="radio" name="${name}" value="file" ${mode === 'file' ? 'checked' : ''}>
<div class="sk-option-content">
<div class="sk-option-label">Excel-Datei hochladen</div>
</div>
</label>
<label class="sk-option ${mode === 'table' ? 'is-selected' : ''}">
<input type="radio" name="${name}" value="table" ${mode === 'table' ? 'checked' : ''}>
<div class="sk-option-content">
<div class="sk-option-label">Direkt in Tabelle eingeben</div>
<div class="sk-option-desc">Bis ${MAX_TABLE_ROWS} Empfänger – ohne Datei-Upload</div>
</div>
</label>
</div>`;
}
function renderAddressTable() {
S.rows = syncTableRows(S.rows, S.quantity);
const cols = addressTableColumns();
return `
<div class="sk-table-wrapper">
<table class="sk-table" id="sk-address-table">
<thead><tr><th class="sk-col-nr">#</th>${cols.map(c => `<th>${esc(c.label)}</th>`).join('')}</tr></thead>
<tbody>${S.rows.map((r, i) => `<tr>
<td class="sk-col-nr">${i + 1}</td>
${cols.map(c => `<td><input type="text" class="sk-table-input" data-row="${i}" data-key="${c.key}" value="${esc(r[c.key] || '')}"></td>`).join('')}
</tr>`).join('')}</tbody>
</table>
</div>
<p class="sk-help" style="margin-top:8px;">${S.rows.length}&nbsp;Empfänger – wird automatisch übernommen.</p>`;
}
function renderCustomTableEditable(phs) {
S.customRows = syncTableRows(S.customRows, S.quantity);
const hasAddr = S.job.kuvert === 'adresse' && S.addressMode === 'classic' && S.rows.length === S.quantity;
return `
<div class="sk-table-wrapper">
<table class="sk-table" id="sk-custom-table">
<thead><tr>
<th class="sk-col-nr">#</th>
${hasAddr ? '<th>Vorname</th><th>Nachname</th><th>Ort</th>' : ''}
${phs.map(p => `<th>[[${esc(p)}]]</th>`).join('')}
</tr></thead>
<tbody>${S.customRows.map((r, i) => `<tr>
<td class="sk-col-nr">${i + 1}</td>
${hasAddr ? `<td class="sk-table-readonly">${esc(S.rows[i]?.firstName || '')}</td><td class="sk-table-readonly">${esc(S.rows[i]?.lastName || '')}</td><td class="sk-table-readonly">${esc(S.rows[i]?.city || '')}</td>` : ''}
${phs.map(p => `<td><input type="text" class="sk-table-input" data-row="${i}" data-key="${esc(p)}" value="${esc(r[p] || '')}"></td>`).join('')}
</tr>`).join('')}</tbody>
</table>
</div>
<p class="sk-help" style="margin-top:8px;">${S.customRows.length}&nbsp;Empfänger – wird automatisch übernommen.</p>`;
}
function renderLetterText() {
const addrNames = phNames();
const allChips = addrNames.map(n =>
`<span class="sk-job-ph-chip" data-ph="${esc(n)}">[[${n}]]</span>`
).join('');
const customPhs = customPlaceholderNames();
const customTableToggle = S.quantity > 0 && S.quantity <= MAX_TABLE_ROWS;
if (!customTableToggle) S.customInputMode = 'file';
const customTableMode = customTableToggle && S.customInputMode === 'table';
return `
${stepper()}
<div class="sk-card">
<div class="sk-card-head">
<h2 class="sk-card-title">Brieftext</h2>
<p class="sk-card-subtitle">Verwenden Sie <code>[[Platzhalter]]</code> für personalisierte Inhalte – sie werden pro Empfänger ersetzt.</p>
</div>
<div class="sk-card-body">
${S.job?.status === 'text_proposal' ? `
<div class="sk-alert sk-alert-info" style="margin-bottom:16px;">
<strong>Textvorschlag von Skrift</strong><br>
Wir haben Ihnen einen Brieftext-Vorschlag hinterlegt. Prüfen Sie den Text und passen Sie ihn bei Bedarf an – er ist vollständig editierbar.
</div>` : ''}
<div class="sk-field">
<label class="sk-field-label" for="sk-brieftext">Text eingeben</label>
<textarea class="sk-textarea" id="sk-brieftext" rows="8"
placeholder="Sehr geehrte/r [[vorname]] [[nachname]],&#10;&#10;…">${esc(S.briefText)}</textarea>
<div id="sk-caps-warn" style="margin-top:6px;padding:8px 12px;background:#fff3cd;border:1px solid #ffc107;border-radius:4px;color:#856404;font-size:13px;${hasCapsWarn(S.briefText) ? '' : 'display:none;'}">
⚠️ Wörter in GROSSBUCHSTABEN (7+ Zeichen) sehen im Handschriftdruck sehr unschön aus. Bitte prüfen Sie den Text.
</div>
<div id="sk-charset-info" style="display:none;margin-top:6px;padding:8px 12px;background:#fef3c7;border:1px solid #f59e0b;border-radius:4px;color:#92400e;font-size:13px;">
<strong>Hinweis:</strong>&nbsp;Folgende Zeichen werden nicht unterstützt und wurden entfernt:&nbsp;<strong id="sk-charset-chars"></strong>
</div>
</div>
${addrNames.length ? `
<div class="sk-alert sk-alert-info" style="margin-top:4px;">
<strong>Verfügbare Platzhalter aus Adressdaten</strong> – klicken zum Einfügen:
<div class="sk-job-ph-chips">${allChips}</div>
</div>` : ''}
${(S.job?.signatur || 0) > 0 ? `
<div class="sk-alert sk-alert-info" style="margin-top:8px;">
<strong>Signatur-Platzhalter</strong> – klicken zum Einfügen:
<div class="sk-job-ph-chips">
${Array.from({length: S.job.signatur}, (_, i) =>
`<span class="sk-job-ph-chip" data-ph="signatur${i+1}" style="background:#e0e7ff;">[[signatur${i+1}]]</span>`
).join('')}
</div>
<small style="display:block;margin-top:6px;opacity:.8;">Die Signatur wird an dieser Stelle des Briefs eingebettet.</small>
</div>` : ''}
<div class="sk-alert sk-alert-info" style="margin-top:8px;">
<strong>Eigene Platzhalter:</strong> Mit <code>[[bezeichnung]]</code> direkt im Text einfügen, z.&nbsp;B. <code>[[spende]]</code> oder <code>[[firmenname]]</code>. Diese werden im nächsten Abschnitt pro Empfänger befüllt.
</div>
${customPhs.length ? `
<div class="sk-sep"></div>
<div class="sk-field">
<label class="sk-field-label">Individuelle Platzhalter</label>
<div class="sk-alert sk-alert-info" style="margin-bottom:12px; font-size:13px;">
Die Spaltenstruktur wird durch den Brieftext bestimmt. Spalten wie Vorname, Nachname und Ort werden aus den Adressdaten mitbefüllt, dienen nur zur Orientierung und werden beim Import ignoriert.
</div>
${customTableToggle ? renderInputModeToggle(S.customInputMode, 'sk-custom-input-mode') : ''}
${customTableMode ? renderCustomTableEditable(customPhs) : `
<div class="sk-job-dl-row">
<button class="sk-job-dl-btn" id="sk-dl-custom-tpl" type="button">Platzhalter-Vorlage herunterladen</button>
</div>
<div class="sk-job-upload-zone ${S.customUploadDone ? 'is-success' : ''} ${S.customUploadLoading ? 'is-loading' : ''}" id="sk-custom-upload-zone">
${S.customUploadLoading ? `
<div class="sk-job-upload-icon"><span class="sk-job-spinner sk-job-spinner--dark" style="width:24px;height:24px;"></span></div>
<div class="sk-job-upload-title">Wird verarbeitet…</div>
` : `
<input type="file" id="sk-custom-file-input" accept=".xlsx,.xls,.csv">
<div class="sk-job-upload-title">
${S.customUploadDone
? `${S.customRows.length}&nbsp;${S.customRows.length === 1 ? 'Zeile' : 'Zeilen'} importiert&nbsp;– erneut klicken zum Ersetzen`
: 'Ausgefüllte Vorlage hier ablegen oder klicken'}
</div>
<div class="sk-job-upload-sub">.xlsx oder .csv</div>
`}
</div>
${S.customUploadError ? `<div class="sk-alert sk-alert-error" style="margin-top:12px;">${esc(S.customUploadError)}</div>` : ''}
${S.customUploadCharsetWarning ? `<div class="sk-alert sk-alert-warning" style="margin-top:12px;"><strong>Hinweis:</strong>&nbsp;Folgende Zeichen werden nicht unterstützt und wurden entfernt:&nbsp;<strong>${esc(S.customUploadStrippedChars)}</strong></div>` : ''}
${S.customUploadDone ? customTable(customPhs) : ''}
`}
</div>` : ''}
</div>
</div>
${S.hasOverflow ? `
<div style="margin-top:8px;">${overflowWarning()}</div>
<p class="sk-help" style="color:#dc2626;margin-top:4px;">
Bitte kürzen Sie den Brieftext oder die Umschlag-Adresse, damit Sie fortfahren können.
</p>` : ''}
${navBar(true, 'Weiter →', 'sk-job-next', !S.briefText.trim() || S.hasOverflow || S.previewLoading)}`;
}
function renderFont() {
const isPrinted = S.job.produkt === 'Gedruckte Briefe';
const fontCards = FONTS.map(f => `
<div class="sk-selection-card ${S.font === f.key ? 'is-selected' : ''}" data-font="${f.key}" style="cursor:pointer;">
<div class="sk-selection-card-title">${f.label}</div>
<div class="sk-selection-card-desc">${f.desc}</div>
</div>`).join('');
return `
${stepper()}
<div class="sk-card">
<div class="sk-card-head">
<h2 class="sk-card-title">${isPrinted ? 'Umschlag: Schrift & Vorschau' : 'Schrift & Vorschau'}</h2>
<p class="sk-card-subtitle">${isPrinted
? 'Wählen Sie eine Schriftart für den Umschlag und generieren Sie eine Vorschau.'
: 'Wählen Sie eine Schriftart und generieren Sie eine Vorschau.'}</p>
</div>
<div class="sk-card-body">
<div class="sk-field">
<label class="sk-field-label">Schriftart</label>
<div class="sk-selection-grid" id="sk-font-grid">${fontCards}</div>
</div>
<div class="sk-field">
<div class="sk-option-full ${S.realisticHandwriting ? 'is-selected' : ''}" id="sk-realistic-opt" style="cursor:pointer;">
<input type="checkbox" id="sk-realistic" ${S.realisticHandwriting ? 'checked' : ''}>
<div class="sk-option-content">
<div class="sk-option-label">Realistische Handschrift</div>
<div class="sk-option-desc">Leichte Unregelmäßigkeiten für ein authentischeres Erscheinungsbild.</div>
</div>
</div>
</div>
<button class="sk-btn sk-btn-secondary" id="sk-gen-preview" type="button"
${S.previewLoading ? 'disabled' : ''}>
${S.previewLoading ? '<span class="sk-job-spinner sk-job-spinner--dark"></span> Vorschau wird generiert…' : 'Vorschau generieren'}
</button>
${overflowWarning()}
<div id="sk-preview-area">${renderPreviewContent()}</div>
${!S.previews.length && !S.envelopePreviews.length && !S.previewLoading ? `
<p class="sk-help" style="margin-top:12px;">
Die Vorschau ist optional – falls Sie sie überspringen, wird beim Abschicken automatisch eine erstellt.
</p>` : ''}
</div>
</div>
${navBar(true, 'Weiter →', 'sk-job-next', S.hasOverflow || S.previewLoading)}`;
}
// ─── Signatur ─────────────────────────────────────────────────────────────────
function pathsToSvg(paths, w, h) {
const elements = paths.map(pts => {
if (pts.length < 2) return '';
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' ');
return `<path d="${d}" stroke="#1e3a6a" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`;
}).join('');
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">${elements}</svg>`;
}
function renderSignature() {
const count = S.job?.signatur || 0;
const allSigned = count > 0 && Array.from({length: count}, (_, i) => !!S.signatures[i]).every(Boolean);
const fields = Array.from({length: count}, (_, i) => {
const hasSig = !!S.signatures[i];
return `
<div class="sk-field" style="margin-bottom:24px;">
${count > 1 ? `<label class="sk-field-label">Signatur ${i + 1}</label>` : '<label class="sk-field-label">Unterschrift</label>'}
<div style="position:relative;border:2px solid ${hasSig ? 'var(--sk-primary,#1e3a6a)' : '#cbd5e1'};border-radius:6px;background:#f8fafc;overflow:hidden;">
<canvas id="sk-sig-canvas-${i}" width="600" height="160"
style="display:block;width:100%;height:160px;cursor:crosshair;touch-action:none;"></canvas>
<div style="position:absolute;inset:0;pointer-events:none;border-bottom:1px dashed #cbd5e1;top:auto;bottom:40px;"></div>
</div>
<div style="display:flex;align-items:center;gap:10px;margin-top:6px;">
<button class="sk-btn sk-btn-secondary" id="sk-sig-clear-${i}" type="button"
style="font-size:12px;padding:4px 12px;">Löschen</button>
${hasSig
? '<span style="color:#16a34a;font-size:13px;">✓ Signatur gespeichert</span>'
: '<span style="font-size:13px;color:#94a3b8;">Bitte hier unterschreiben</span>'}
</div>
</div>`;
}).join('');
return `
${stepper()}
<div class="sk-card">
<div class="sk-card-head">
<h2 class="sk-card-title">${count > 1 ? 'Signaturen' : 'Signatur'}</h2>
<p class="sk-card-subtitle">Bitte ${count > 1 ? 'alle Felder' : 'das Feld'} mit Ihrer Unterschrift ausfüllen. Maus oder Finger verwenden.</p>
</div>
<div class="sk-card-body">
${fields}
</div>
</div>
${navBar(true, 'Weiter →', 'sk-job-next', !allSigned)}`;
}
function overflowWarning() {
if (!S.hasOverflow || !S.overflowFiles.length) return '';
const max = 10;
const show = S.overflowFiles.slice(0, max);
const rest = S.overflowFiles.length - show.length;
const items = show.map(f => `
<li class="sk-overflow-item">
<span>Empfänger in Tabellenzeile&nbsp;<strong>${f.index + 1}</strong></span>
<span class="sk-overflow-lines">${f.lineCount}&nbsp;/&nbsp;${f.lineLimit}&nbsp;Zeilen</span>
</li>`).join('');
const restItem = rest > 0
? `<li class="sk-overflow-item sk-overflow-more">… und ${rest}&nbsp;weitere Empfänger</li>`
: '';
return `
<div class="sk-alert sk-alert-error sk-overflow-warning" style="margin-top:16px;">
<div class="sk-overflow-title">Text zu lang für das gewählte Format</div>
<ul class="sk-overflow-list">${items}${restItem}</ul>
<p class="sk-overflow-hint">
Bitte den Brieftext kürzen oder lange Platzhalterwerte (z.&nbsp;B. Namen) prüfen, um fortzufahren.
Die Zeilennummern beziehen sich auf die Empfänger-Tabelle aus Schritt&nbsp;„Adressdaten".
</p>
</div>`;
}
function renderPreviewContent() {
if (S.previewLoading) return `
<div class="sk-job-preview-box" style="margin-top:16px;">
<span class="sk-job-spinner sk-job-spinner--dark" style="width:32px;height:32px;"></span>
</div>`;
if (S.previewError) return `<div class="sk-alert sk-alert-error" style="margin-top:16px;">${esc(S.previewError)}</div>`;
const isPrinted = S.job.produkt === 'Gedruckte Briefe';
const list = isPrinted ? S.envelopePreviews : S.previews;
if (!list.length) return '';
const cur = S.previews[S.previewIndex];
const env = S.envelopePreviews[S.previewIndex];
const loaded = list.length;
const total = effectiveQuantity();
const hasMore = S.previewTotalLoaded < total && loaded < total;
const buildUrl = u => !u ? '' : (u.startsWith('http') ? `${u}?t=${S.sessionId}` : `${BACKEND_URL}${u}?t=${S.sessionId}`);
const letterUrl = buildUrl(cur?.url);
const envelopeUrl = buildUrl(env?.url);
// Nächster Button: navigieren wenn Vorschau vorhanden, Batch laden wenn am Ende
const atEnd = S.previewIndex >= loaded - 1;
const nextLabel = (atEnd && hasMore) ? (S.previewLoading ? '…' : '→ Weitere laden') : '→';
const nextDisabled = (atEnd && !hasMore) || (atEnd && hasMore && S.previewLoading);
return `
${!isPrinted ? `
<div class="sk-job-preview-section">
<div class="sk-job-preview-section-title">Brief ${S.previewIndex + 1}</div>
<div class="sk-job-preview-box" data-preview-url="${esc(letterUrl)}" title="Klicken zum Vergrößern">
<img src="${esc(letterUrl)}" alt="Briefvorschau ${S.previewIndex + 1}" data-preview-img style="cursor:zoom-in;">
</div>
</div>` : ''}
${envelopeUrl ? `
<div class="sk-job-preview-section">
<div class="sk-job-preview-section-title">Umschlag ${S.previewIndex + 1}</div>
<div class="sk-job-preview-box sk-job-preview-box--envelope" data-preview-url="${esc(envelopeUrl)}" title="Klicken zum Vergrößern">
<img src="${esc(envelopeUrl)}" alt="Umschlagvorschau ${S.previewIndex + 1}" data-preview-img style="cursor:zoom-in;">
</div>
</div>` : ''}
<div class="sk-job-preview-nav">
<button class="sk-btn sk-btn-secondary" id="sk-prev-preview"
${S.previewIndex === 0 ? 'disabled' : ''} style="padding:8px 14px;">←</button>
<span class="sk-job-preview-counter">${S.previewIndex + 1}&nbsp;/&nbsp;${total}</span>
<button class="sk-btn sk-btn-secondary" id="sk-next-preview"
${nextDisabled ? 'disabled' : ''} style="padding:8px 14px;">${nextLabel}</button>
</div>
`;
}
// ─── Lightbox: Vorschau-Bild maximieren ───────────────────────────────────────
function openLightbox(src) {
const overlay = document.createElement('div');
overlay.className = 'sk-job-lightbox';
overlay.innerHTML = `
<button class="sk-job-lightbox-close" type="button" aria-label="Schließen">&times;</button>
<img src="${esc(src)}" alt="Briefvorschau vergrößert">`;
document.body.appendChild(overlay);
document.body.style.overflow = 'hidden';
const close = () => {
overlay.remove();
document.body.style.overflow = '';
document.removeEventListener('keydown', onKey);
};
const onKey = e => { if (e.key === 'Escape') close(); };
overlay.addEventListener('click', e => {
if (e.target === overlay || e.target.classList.contains('sk-job-lightbox-close')) close();
});
document.addEventListener('keydown', onKey);
}
function refreshPreview() {
const area = document.getElementById('sk-preview-area');
if (area) area.innerHTML = renderPreviewContent();
const root = document.querySelector('[data-skrift-kundenauftrag="1"]');
bindPreviewNav(root);
}
function renderConfirm() {
const canSubmit = S.acceptedAgb && S.acceptedPrivacy && S.acceptedFinal;
return `
${stepper()}
<div class="sk-card">
<div class="sk-card-head">
<h2 class="sk-card-title">Abschluss</h2>
<p class="sk-card-subtitle">Bitte bestätigen Sie die folgenden Punkte, bevor der Auftrag übermittelt wird.</p>
</div>
<div class="sk-card-body">
<div class="sk-options">
<div class="sk-option-full ${S.acceptedAgb ? 'is-selected' : ''}" id="sk-agb-opt" style="cursor:pointer;">
<input type="checkbox" id="sk-agb" ${S.acceptedAgb ? 'checked' : ''}>
<div class="sk-option-content">
<div class="sk-option-label">
Ich habe die <a href="https://skrift.de/agb/" target="_blank" rel="noopener noreferrer"
onclick="event.stopPropagation()">Allgemeinen Geschäftsbedingungen</a> gelesen und akzeptiere deren Geltung.
Ein Widerrufsrecht besteht gemäß § 312g Abs. 2 Nr. 1 BGB nicht, da Skrift ausschließlich Waren anbietet,
die nicht vorgefertigt sind und für deren Herstellung eine individuelle Auswahl oder Bestimmung durch den
Kunden maßgeblich ist oder die eindeutig auf die persönlichen Bedürfnisse des Kunden zugeschnitten sind.
</div>
</div>
</div>
<div class="sk-option-full ${S.acceptedPrivacy ? 'is-selected' : ''}" id="sk-privacy-opt" style="cursor:pointer;">
<input type="checkbox" id="sk-privacy" ${S.acceptedPrivacy ? 'checked' : ''}>
<div class="sk-option-content">
<div class="sk-option-label">
Ja, ich habe die <a href="https://skrift.de/datenschutz/" target="_blank" rel="noopener noreferrer"
onclick="event.stopPropagation()">Datenschutzerklärung</a> zur Kenntnis genommen und bin damit einverstanden,
dass die von mir angegebenen Daten elektronisch erhoben und gespeichert werden. Meine Daten werden dabei nur
streng zweckgebunden zur Bearbeitung und Beantwortung meiner Anfrage benutzt. Mit dem Absenden des Kontaktformulars erkläre ich mich
mit der Verarbeitung einverstanden. Die Daten werden im Falle eines ausbleibenden Auftrags gelöscht.
</div>
</div>
</div>
<div class="sk-option-full sk-option-full--warn ${S.acceptedFinal ? 'is-selected' : ''}" id="sk-final-opt" style="cursor:pointer;">
<input type="checkbox" id="sk-final" ${S.acceptedFinal ? 'checked' : ''}>
<div class="sk-option-content">
<div class="sk-option-label">Druckdaten und Schriftauswahl sind final</div>
<div class="sk-option-desc">Ich bestätige, dass Schriftart, Brieftext und alle Empfängerdaten endgültig sind. Der Auftrag wird unmittelbar in den Druck gegeben und kann danach nicht mehr geändert werden.</div>
</div>
</div>
</div>
${S.submitError ? `<div class="sk-alert sk-alert-error" style="margin-top:16px;">${esc(S.submitError)}</div>` : ''}
</div>
</div>
<div class="sk-nav">
<button class="sk-btn sk-btn-secondary" id="sk-job-prev">← Zurück</button>
<button class="sk-btn sk-btn-primary sk-btn-large" id="sk-job-submit"
${(!canSubmit || S.submitting) ? 'disabled' : ''}>
${S.submitting ? '<span class="sk-job-spinner"></span> Wird übermittelt…' : 'Auftrag abschließen →'}
</button>
</div>`;
}
function renderStatus() {
const j = S.job;
const labels = {
submitted: { text: 'Daten erfolgreich übermittelt', cls: 'sk-alert-success' },
printing: { text: 'Im Druck', cls: 'sk-alert-success' },
shipped: { text: 'Versendet', cls: 'sk-alert-success' },
waiting_routine: { text: 'Routine-Auftrag aktiv', cls: 'sk-alert-info' },
};
const info = labels[j.status] || { text: j.status, cls: 'sk-alert-info' };
// Timestamp ist UTC (WordPress current_time): 'Z' anhängen damit Browser korrekt in Lokalzeit umrechnet
const updatedAt = j.statusUpdatedAt
? new Date(j.statusUpdatedAt.replace(' ', 'T') + 'Z').toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
: '';
const statusMessages = {
submitted: 'Ihre Daten wurden erfolgreich übermittelt. Wir machen uns nun an die Arbeit und informieren Sie über den nächsten Schritt.',
printing: 'Ihre Briefe sind fertig gedruckt und bereit zum Versand. Bitte erteilen Sie unten die Versandfreigabe.',
shipped: 'Ihre Briefe wurden versendet. Vielen Dank für Ihren Auftrag!',
waiting_routine: 'Ihr Auftrag ist eingerichtet. Wir melden uns, sobald weitere Schritte erforderlich sind.',
};
return `
<div class="sk-card">
<div class="sk-card-head">
<h2 class="sk-card-title">Auftrag ${esc(j.id)}</h2>
<p class="sk-card-subtitle">${esc(j.produkt)}${j.format ? ' · ' + esc(j.format) : ''} · ${S.quantity}&nbsp;Stück</p>
</div>
<div class="sk-card-body">
<div class="sk-alert ${info.cls}">
<strong>${info.text}</strong>
${updatedAt ? `<br><small style="opacity:.8;">Zuletzt aktualisiert: ${updatedAt}</small>` : ''}
</div>
<p style="margin-top:16px;color:var(--sk-muted,#64748b);">
${esc(statusMessages[j.status] || '')}
</p>
<div class="sk-alert sk-alert-info" style="margin-top:16px;">
Mit Ihrer Auftragsnummer <strong>${esc(j.id)}</strong> können Sie jederzeit wieder hier einloggen, um den aktuellen Status zu sehen.
</div>
${j.status === 'printing' ? `
<div class="sk-sep" style="margin:20px 0 0;"></div>
${S.shippingApproved ? `
<div class="sk-alert sk-alert-success" style="margin-top:16px;">
✓ Versandfreigabe erteilt. Ihre Briefe werden in Kürze auf den Weg gebracht. Sie erhalten eine Benachrichtigung per E-Mail.
</div>
` : `
<div style="margin-top:16px;">
<button class="sk-btn sk-btn-primary sk-btn-large" id="sk-approve-shipping" type="button">
Versandfreigabe erteilen →
</button>
<p class="sk-help" style="margin-top:8px;">
Mit Ihrer Bestätigung geben Sie den Versand frei. Ihre Briefe werden daraufhin unverzüglich verschickt.
</p>
</div>
`}` : ''}
</div>
</div>`;
}
function refreshConfirm() {
const inner = document.getElementById('sk-job-inner');
if (!inner) return;
inner.innerHTML = renderConfirm();
bindEvents(document.querySelector('[data-skrift-kundenauftrag="1"]'));
}
// ─── Preview generieren ───────────────────────────────────────────────────────
// Ersten Batch starten (15 Briefe) – reset aller Preview-Daten
async function doGenPreview() {
invalidatePreviews();
await doGenPreviewBatch(0, S.previewBatchSize);
}
// Einen Batch von `count` Briefen ab Index `startIdx` generieren und anhängen
async function doGenPreviewBatch(startIdx, count) {
if (S.previewLoading) return;
const actualCount = Math.min(count, effectiveQuantity() - startIdx);
if (actualCount <= 0) return;
S.previewLoading = true;
S.previewError = null;
refreshFontStep();
// Nur die Briefe/Umschläge dieses Batches bauen
// buildLetters/buildEnvelopes setzen index: i bereits korrekt (0..quantity-1),
// daher liefert slice(startIdx, ...) Elemente mit bereits korrekten absoluten Indizes.
// Gedruckte Briefe: kein Brieftext vorhanden (physisches Dokument) → nur Umschlag-Vorschau
const isPrinted = S.job.produkt === 'Gedruckte Briefe';
const letters = isPrinted ? [] : buildLetters(S.quantity).slice(startIdx, startIdx + actualCount);
const envelopes = buildEnvelopes().slice(startIdx, startIdx + actualCount);
if (!isPrinted && S.job.textservice && !S.briefText) {
letters.forEach(l => { l.text = 'Sehr geehrte Damen und Herren,\n\nIhr Brief wird vom Skrift-Textservice verfasst.\n\nMit freundlichen Grüßen'; });
}
const allDocs = [...letters, ...envelopes];
try {
const res = await genPreviewBatch(allDocs);
if (res.sessionId) S.sessionId = res.sessionId;
const files = res.files || res.previews || [];
if (files.length) {
const isEnvelope = f => (f.filename || '').startsWith('envelope_') || f.type === 'envelope';
const newLetters = files.filter(f => !isEnvelope(f)).sort((a, b) => a.index - b.index);
const newEnvelopes = files.filter(isEnvelope).sort((a, b) => a.index - b.index);
// An bestehende Liste anhängen (erster Batch setzt, folgende hängen an)
S.previews = [...S.previews, ...newLetters];
S.envelopePreviews = [...S.envelopePreviews, ...newEnvelopes];
S.previewTotalLoaded = startIdx + actualCount;
// Overflow aus diesem Batch akkumulieren
if (res.hasOverflow) S.hasOverflow = true;
const newOverflow = (res.overflowFiles || newLetters.filter(f => f.overflow)).map(f => ({
index: f.index, lineCount: f.lineCount, lineLimit: f.lineLimit,
}));
S.overflowFiles = [...S.overflowFiles, ...newOverflow];
} else if (res.hasOverflow) {
// Backend hat alle Briefe als zu lang erkannt (keine files, aber Overflow-Info vorhanden)
S.hasOverflow = true;
const newOverflow = (res.overflowFiles || []).map(f => ({
index: f.index, lineCount: f.lineCount, lineLimit: f.lineLimit,
}));
S.overflowFiles = [...S.overflowFiles, ...newOverflow];
} else {
S.previewError = res.error || res.message || 'Vorschau konnte nicht geladen werden.';
}
} catch (e) {
S.previewError = `Verbindungsfehler: ${e.message}`;
}
S.previewLoading = false;
refreshFontStep();
}
// Re-render the whole FONT step (needed because the overflow warning is OUTSIDE the preview area)
function refreshFontStep() {
if (S.step !== STEP.FONT) { refreshPreview(); return; }
render();
}
// ─── Weiter auf Brieftext-Schritt: Overflow-Prüfung via Preview ───────────────
async function doLetterTextNext() {
// Aktuellen Textarea-Inhalt sichern
const ta = document.getElementById('sk-brieftext');
if (ta) S.briefText = ta.value;
if (!S.briefText.trim()) return; // Button ist disabled – sollte nicht passieren
// Direkt eingegebene Platzhalter-Tabelle zusätzlich als Datei am Server hinterlegen (best-effort)
if (S.customInputMode === 'table') persistTableAsFile('platzhalter');
// Vorschau schon gültig vorhanden? → Overflow-Status bekannt, entsprechend reagieren
if (S.sessionId && S.previews.length) {
if (S.hasOverflow) {
render(); // Overflow-Warnung auf Brieftext-Schritt zeigen, Navigation blockieren
} else {
go(1);
}
return;
}
// Button deaktivieren und Ladeindikator zeigen
const root = document.querySelector('[data-skrift-kundenauftrag="1"]');
const btn = root?.querySelector('#sk-job-next');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<span class="sk-job-spinner sk-job-spinner--dark"></span> Text wird geprüft…';
}
// Vorschau generieren – serverseitig wird Overflow erkannt
await doGenPreview();
if (S.hasOverflow) {
render(); // Brieftext-Schritt neu rendern mit Overflow-Warnung → Navigation gesperrt
} else {
go(1);
}
}
// ─── Signaturen hochladen ─────────────────────────────────────────────────────
async function doUploadSignatures() {
const count = S.job?.signatur || 0;
for (let i = 0; i < count; i++) {
if (!S.signatures[i] || !S.job?.id) continue;
try {
await uploadSignatureSvg(S.job.id, i, S.signatures[i]);
} catch (e) {
console.warn('[JobApp] Signatur-Upload fehlgeschlagen:', e);
}
}
}
// ─── Abschicken (Unterauftrag) ────────────────────────────────────────────────
async function doSubmitSubOrder() {
S.submitting = true; S.submitError = null; refreshConfirm();
try {
// Bestellnummer wurde bereits beim Start des Unterauftrags reserviert (siehe "sk-routine-new"),
// damit alle Uploads dieses Durchlaufs (Adresse/Platzhalter/Brief) unter demselben Schlüssel
// landen und nicht den vorherigen Unterauftrag überschreiben.
const orderNumber = S.subOrderNumber || await genOrderNumber();
const btn = document.getElementById('sk-job-submit');
if (btn) btn.innerHTML = '<span class="sk-job-spinner"></span> Unterauftrag wird übermittelt…';
// Signaturen hochladen (optional – Fehler blockieren nicht die Auftragsübermittlung)
try { await doUploadSignatures(); } catch (e) { console.warn('[Skrift] Signatur-Upload fehlgeschlagen:', e.message); }
// Dateien hochladen – mit orderNumber als Speicher-Schlüssel für diesen Unterauftrag
if (S.printedLetterFile && S.job?.id) {
await uploadFileToServer(S.printedLetterFile, S.job.id, 'brief', orderNumber);
}
if (S.printedExcelFile && S.job?.id) {
await uploadFileToServer(S.printedExcelFile, S.job.id, 'brief_excel', orderNumber);
}
const metadata = {
customer: { type: 'job', jobId: S.job.id, subOrder: true },
orderDate: new Date().toISOString(),
product: S.job.produkt,
quantity: S.quantity,
format: S.job.format,
...orderMetaFormats(),
};
const isPrinted = S.job.produkt === 'Gedruckte Briefe';
if (isPrinted && S.job.kuvert === 'keine') {
// Gedruckte Briefe ohne Umschlag: Unterauftrag im Docker-Backend nur mit Metadaten registrieren
try {
const regResp = await fetch(`${REST_URL}skrift/v1/job/register-order`, {
method: 'POST', headers: hdrs(),
body: JSON.stringify({ orderNumber, metadata }),
});
if (!regResp.ok) console.warn('[Skrift] Backend-Registrierung (Unterauftrag) Status:', regResp.status, await regResp.text());
} catch (e) { console.warn('[Skrift] Backend-Registrierung (Unterauftrag) fehlgeschlagen:', e.message); }
} else {
// Gedruckte Briefe mit Umschlag: nur Umschlag-SVGs generieren (Brief liegt bereits als Datei vor)
// Reguläre Briefprodukte: Brief- und Umschlag-SVGs wie beim Hauptauftrag generieren
try {
const letters = isPrinted ? [] : buildLetters(S.quantity);
const envelopes = buildEnvelopes();
if (!isPrinted && S.job.textservice && !S.briefText) {
letters.forEach(l => {
l.text = 'Sehr geehrte Damen und Herren,\n\nIhr Brief wird vom Skrift-Textservice verfasst.\n\nMit freundlichen Grüßen';
});
}
const result = await genOrder(orderNumber, letters, envelopes, metadata);
if (result?.error || result?.code) {
console.warn('[Skrift] Backend-Generierung (Unterauftrag) fehlgeschlagen:', result.error || result.message);
}
} catch (e) { console.warn('[Skrift] Backend-Generierung (Unterauftrag) fehlgeschlagen:', e.message); }
}
// Hochgeladene Dateien dieses Unterauftrags zusätzlich in den Backend-Auftragsordner kopieren
try {
const fwResp = await fetch(`${REST_URL}skrift/v1/job/forward-files`, {
method: 'POST', headers: hdrs(),
body: JSON.stringify({ jobId: S.job.id, orderNumber, subDir: orderNumber }),
});
if (!fwResp.ok) console.warn('[Skrift] Datei-Weiterleitung (Unterauftrag) Status:', fwResp.status, await fwResp.text());
} catch (e) { console.warn('[Skrift] Datei-Weiterleitung (Unterauftrag) fehlgeschlagen:', e.message); }
const { countDE, countAbroad } = countryStats();
const res = await notifySubOrder(S.job.id, orderNumber, S.quantity, S.briefText || '', countDE, countAbroad);
if (!res?.success) throw new Error(res?.message || 'Unterauftrag konnte nicht angelegt werden.');
// Sub-Order-Liste aktualisieren und zurück zum Status
S.subOrders = [...S.subOrders, {
id: res.subOrderId,
status: 'submitted',
quantity: S.quantity,
createdAt: new Date().toISOString().replace('T', ' ').slice(0, 19),
}];
S.isSubOrder = false;
S.subOrderNumber = null;
S.step = STEP.STATUS;
S.submitting = false;
// State für nächsten Unterauftrag zurücksetzen
S.printedLetterFile = null; S.printedLetterDone = false;
S.printedExcelFile = null; S.printedExcelDone = false;
S.signatures = []; S.signatureStrokes = [];
S.briefText = ''; S.rows = []; S.uploadDone = false;
invalidatePreviews();
render();
} catch (e) {
S.submitError = e.message || 'Unbekannter Fehler';
S.submitting = false;
refreshConfirm();
}
}
// ─── Abschicken ───────────────────────────────────────────────────────────────
async function doSubmit() {
if (S.isSubOrder) return doSubmitSubOrder();
S.submitting = true; S.submitError = null; refreshConfirm();
try {
const isPrinted = S.job?.produkt === 'Gedruckte Briefe';
// Vorschau sicherstellen – nicht bei gedruckten Briefen (kein Handschriftinhalt)
if (!isPrinted && (!S.sessionId || !S.previews.length)) {
await doGenPreview();
if (S.previewError) {
console.warn('[Skrift] Vorschau fehlgeschlagen, Auftrag wird trotzdem übermittelt:', S.previewError);
S.previewError = null;
}
}
// Overflow prüfen (Brief- oder Umschlagtext zu lang)
if (!isPrinted && S.hasOverflow) {
throw new Error('Der Text ist zu lang für das gewählte Format. Bitte gehen Sie zurück und kürzen Sie den Text.');
}
const orderNumber = await genOrderNumber();
const metadata = {
customer: { type: 'job', jobId: S.job.id },
orderDate: new Date().toISOString(),
product: S.job.produkt,
quantity: S.quantity,
format: S.job.format,
...orderMetaFormats(),
};
const btn = document.getElementById('sk-job-submit');
if (btn) btn.innerHTML = '<span class="sk-job-spinner"></span> Auftrag wird übermittelt…';
// Signaturen hochladen (optional – Fehler blockieren nicht die Auftragsübermittlung)
try { await doUploadSignatures(); } catch (e) { console.warn('[Skrift] Signatur-Upload fehlgeschlagen:', e.message); }
// Gedruckte Briefe: Dateien hochladen, Umschlag generieren (falls gewählt), Metadaten im Backend speichern
if (S.job.produkt === 'Gedruckte Briefe') {
if (S.printedLetterFile && S.job?.id) {
await uploadFileToServer(S.printedLetterFile, S.job.id, 'brief');
}
if (S.printedExcelFile && S.job?.id) {
await uploadFileToServer(S.printedExcelFile, S.job.id, 'brief_excel');
}
if (S.job.kuvert !== 'keine') {
// Umschlag-SVGs beim Backend generieren (keine Briefe – das Schriftstück ist bereits als Datei hochgeladen)
try {
const envelopes = buildEnvelopes();
const result = await genOrder(orderNumber, [], envelopes, metadata);
if (result?.error || result?.code) {
console.warn('[Skrift] Umschlag-Generierung fehlgeschlagen:', result.error || result.message);
}
} catch (e) { console.warn('[Skrift] Umschlag-Generierung fehlgeschlagen:', e.message); }
} else {
// Kein Umschlag: Auftrag im Docker-Backend nur mit Metadaten registrieren (Verzeichnis anlegen)
try {
const regResp = await fetch(`${REST_URL}skrift/v1/job/register-order`, {
method: 'POST', headers: hdrs(),
body: JSON.stringify({ orderNumber, metadata }),
});
if (!regResp.ok) console.warn('[Skrift] Backend-Registrierung Status:', regResp.status, await regResp.text());
} catch (e) { console.warn('[Skrift] Backend-Registrierung fehlgeschlagen:', e.message); }
}
// Hochgeladene Dateien (Brief, Excel-Tabellen) zusätzlich in den Backend-Auftragsordner kopieren
try {
const fwResp = await fetch(`${REST_URL}skrift/v1/job/forward-files`, {
method: 'POST', headers: hdrs(),
body: JSON.stringify({ jobId: S.job.id, orderNumber }),
});
if (!fwResp.ok) console.warn('[Skrift] Datei-Weiterleitung Status:', fwResp.status, await fwResp.text());
} catch (e) { console.warn('[Skrift] Datei-Weiterleitung fehlgeschlagen:', e.message); }
await notifyAdmin(orderNumber);
if (REDIRECT_URL) {
const u = new URL(REDIRECT_URL);
u.searchParams.set('orderNumber', orderNumber);
u.searchParams.set('jobId', S.job.id);
window.location.href = u.toString();
} else {
alert(`Ihre Daten wurden erfolgreich übermittelt!\nBestellnummer: ${orderNumber}\n\nVielen Dank!`);
S.submitting = false; refreshConfirm();
}
return;
}
// Alle Briefe + Umschläge direkt ans Backend senden
const allLetters = buildLetters(S.quantity);
const allEnvelopes = buildEnvelopes();
if (S.job.textservice && !S.briefText) {
allLetters.forEach(l => {
l.text = 'Sehr geehrte Damen und Herren,\n\nIhr Brief wird vom Skrift-Textservice verfasst.\n\nMit freundlichen Grüßen';
});
}
const result = await genOrder(orderNumber, allLetters, allEnvelopes, metadata);
if (result?.error || result?.code) {
throw new Error(result.error || result.message || 'Backend-Generierung fehlgeschlagen.');
}
await notifyAdmin(orderNumber);
if (REDIRECT_URL) {
const u = new URL(REDIRECT_URL);
u.searchParams.set('orderNumber', orderNumber);
u.searchParams.set('jobId', S.job.id);
window.location.href = u.toString();
} else {
alert(`Ihre Daten wurden erfolgreich übermittelt!\nBestellnummer: ${orderNumber}\n\nVielen Dank!`);
S.submitting = false; refreshConfirm();
}
} catch (e) {
S.submitError = e.message || 'Unbekannter Fehler';
S.submitting = false;
refreshConfirm();
}
}
// ─── Datei-Upload (Adressen) ──────────────────────────────────────────────────
async function doUpload(file) {
S.uploadError = null; S.uploadDone = false; S.rows = []; S.uploadCharsetWarning = false;
const zone = document.getElementById('sk-upload-zone');
if (zone) zone.innerHTML = `
<div class="sk-job-upload-icon">
<span class="sk-job-spinner sk-job-spinner--dark" style="width:24px;height:24px;"></span>
</div>
<div class="sk-job-upload-title">Wird verarbeitet…</div>`;
try {
const { headers, dataRows } = await parseFile(file);
const k = S.job.kuvert;
let mapped;
_charsetStripped = false; _strippedChars = new Set();
if (k === 'adresse' && S.addressMode === 'classic') {
mapped = dataRows.map(r => mapClassic(headers, r));
} else if (k === 'adresse' && S.addressMode === 'free') {
mapped = dataRows.map(r => mapFree(headers, r));
} else {
mapped = dataRows.map(r => mapCustomText(headers, r));
}
if (_charsetStripped) {
S.uploadCharsetWarning = true;
S.uploadStrippedChars = [..._strippedChars].join(' ');
}
if (!mapped.length) throw new Error('Keine Datenzeilen gefunden.');
S.rows = mapped;
S.uploadDone = true;
invalidatePreviews(); // neue Adressen → vorhandene Previews sind nicht mehr 1:1
if (mapped.length > S.quantity) {
const adjust = await showQuantityDialog(mapped.length, S.quantity);
if (adjust) S.quantity = mapped.length;
}
// Datei serverseitig speichern (best-effort)
if (S.job?.id) uploadFileToServer(file, S.job.id, 'adresse', S.isSubOrder ? S.subOrderNumber : null).catch(() => {});
} catch (e) {
S.uploadError = e.message || 'Fehler beim Lesen der Datei.';
}
render();
}
// ─── Datei-Upload (individuelle Platzhalter) ──────────────────────────────────
async function doCustomUpload(file) {
// 1. Aktuellen Textarea-Wert in State übernehmen (vor jeglicher DOM-Manipulation)
const ta = document.getElementById('sk-brieftext');
if (ta) S.briefText = ta.value;
// 2. Loading-State setzen und einmal rendern (zeigt Spinner)
S.customUploadError = null;
S.customUploadCharsetWarning = false;
S.customUploadLoading = true;
render();
// 3. Datei parsen und mappen
try {
const phs = customPlaceholderNames();
const { headers, dataRows } = await parseFile(file);
_charsetStripped = false; _strippedChars = new Set();
const mapped = dataRows.map(row => {
const obj = {};
phs.forEach(ph => {
const idx = headers.findIndex(h => h.toLowerCase() === ph.toLowerCase());
obj[ph] = sanitizeText(idx !== -1 ? String(row[idx] ?? '').trim() : '', false);
});
return obj;
});
if (_charsetStripped) {
S.customUploadCharsetWarning = true;
S.customUploadStrippedChars = [..._strippedChars].join(' ');
}
if (!mapped.length) throw new Error('Keine Datenzeilen gefunden.');
S.customRows = mapped;
S.customUploadDone = true;
if (S.job?.id) uploadFileToServer(file, S.job.id, 'platzhalter', S.isSubOrder ? S.subOrderNumber : null).catch(() => {});
invalidatePreviews(); // neue Platzhalter-Werte → vorhandene Previews sind nicht mehr 1:1
} catch (e) {
S.customUploadError = e.message || 'Fehler beim Lesen der Datei.';
}
// 4. Finalen Zustand rendern (zeigt Tabelle oder Fehler)
S.customUploadLoading = false;
render();
// 5. Tabelle ins Sichtfeld scrollen, falls erfolgreich
if (S.customUploadDone) {
requestAnimationFrame(() => {
const tbl = document.querySelector('#sk-job-inner .sk-table-wrapper');
if (tbl) tbl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
}
}
// ─── Signatur-Canvas Events ───────────────────────────────────────────────────
function bindSignatureCanvases(root) {
const count = S.job?.signatur || 0;
for (let i = 0; i < count; i++) {
const canvas = root.querySelector(`#sk-sig-canvas-${i}`);
if (!canvas) continue;
const ctx = canvas.getContext('2d');
ctx.strokeStyle = '#1e3a6a';
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Bestehende Signatur neu zeichnen
if (!S.signatureStrokes[i]) S.signatureStrokes[i] = [];
const redraw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
S.signatureStrokes[i].forEach(pts => {
if (!pts.length) return;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
pts.slice(1).forEach(p => ctx.lineTo(p[0], p[1]));
ctx.stroke();
});
};
redraw();
let drawing = false;
let cur = [];
const scalePos = (e) => {
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / rect.width;
const sy = canvas.height / rect.height;
const src = e.touches ? e.touches[0] : e;
return [(src.clientX - rect.left) * sx, (src.clientY - rect.top) * sy];
};
const onStart = (e) => {
e.preventDefault();
drawing = true;
const [x, y] = scalePos(e);
cur = [[x, y]];
ctx.beginPath();
ctx.moveTo(x, y);
};
const onMove = (e) => {
if (!drawing) return;
e.preventDefault();
const [x, y] = scalePos(e);
cur.push([x, y]);
ctx.lineTo(x, y);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, y);
};
const onEnd = () => {
if (!drawing) return;
drawing = false;
if (cur.length >= 2) {
S.signatureStrokes[i].push(cur);
S.signatures[i] = pathsToSvg(S.signatureStrokes[i], canvas.width, canvas.height);
// Weiter-Button aktualisieren
const nxt = root.querySelector('#sk-job-next');
if (nxt) {
const cnt = S.job?.signatur || 0;
nxt.disabled = !Array.from({length: cnt}, (_, j) => !!S.signatures[j]).every(Boolean);
}
// Status-Text aktualisieren
const wrap = canvas.parentElement?.nextElementSibling;
if (wrap) {
const span = wrap.querySelectorAll('span')[0];
if (span && !S.signatures[i].includes('Fehler')) span.textContent = '✓ Signatur gespeichert';
}
}
cur = [];
};
canvas.addEventListener('mousedown', onStart);
canvas.addEventListener('mousemove', onMove);
canvas.addEventListener('mouseup', onEnd);
canvas.addEventListener('mouseleave', onEnd);
canvas.addEventListener('touchstart', onStart, { passive: false });
canvas.addEventListener('touchmove', onMove, { passive: false });
canvas.addEventListener('touchend', onEnd);
// Löschen-Button
const clearBtn = root.querySelector(`#sk-sig-clear-${i}`);
if (clearBtn) clearBtn.addEventListener('click', () => {
S.signatureStrokes[i] = [];
S.signatures[i] = null;
ctx.clearRect(0, 0, canvas.width, canvas.height);
render();
});
}
}
// ─── Editierbare Tabelle (Direkteingabe Adresse/Platzhalter) ──────────────────
function bindEditableTableInputs(root, tableId, targetArray) {
root.querySelectorAll(`#${tableId} .sk-table-input`).forEach(input => {
input.addEventListener('input', () => {
const i = Number(input.dataset.row);
const k = input.dataset.key;
const original = input.value;
const cleaned = sanitizeText(original, false);
if (cleaned !== original) {
const cursorPos = input.selectionStart;
const strippedBefore = original.slice(0, cursorPos).replace(ALLOWED_CHARS_NO_NL_RE, '').length;
input.value = cleaned;
input.setSelectionRange(cursorPos - strippedBefore, cursorPos - strippedBefore);
}
if (!targetArray[i]) targetArray[i] = {};
targetArray[i][k] = input.value;
invalidatePreviews();
});
// Mehrspaltiges/mehrzeiliges Einfügen aus Excel (TSV) über mehrere Zellen verteilen
input.addEventListener('paste', e => handleTablePaste(e, input, root, tableId, targetArray));
});
}
// Verteilt eine aus Excel kopierte Zellmatrix (Tab = Spalte, Zeilenumbruch = Zeile)
// ab der eingefügten Zelle nach rechts und unten über die Tabelle. Übersteigt die
// Empfängerzahl die Auftragsmenge, fragt ein Popup, ob die Menge erhöht werden soll.
function handleTablePaste(e, input, root, tableId, targetArray) {
const clip = e.clipboardData || window.clipboardData;
const text = clip ? clip.getData('text') : '';
// Einzelwert ohne Tab/Zeilenumbruch: normales Einfügen zulassen
if (!text || !/[\t\r\n]/.test(text)) return;
e.preventDefault();
// TSV in Matrix zerlegen und leere Abschlusszeilen (Excel-typisch) entfernen
const matrix = text.replace(/\r\n?/g, '\n').split('\n').map(line => line.split('\t'));
while (matrix.length > 1 && matrix[matrix.length - 1].every(c => c === '')) matrix.pop();
// Spaltenreihenfolge der EDITIERBAREN Felder aus der ersten Zeile ermitteln
const keys = Array.from(root.querySelectorAll(`#${tableId} .sk-table-input[data-row="0"]`))
.map(el => el.dataset.key);
const startRow = Number(input.dataset.row);
const startCol = keys.indexOf(input.dataset.key);
if (startCol < 0) return;
// Höchstens bis MAX_TABLE_ROWS befüllen (darüber ist nur der Datei-Upload vorgesehen)
const endRow = Math.min(startRow + matrix.length, MAX_TABLE_ROWS);
for (let r = startRow; r < endRow; r++) {
const srcRow = matrix[r - startRow];
if (!targetArray[r]) targetArray[r] = {};
for (let c = 0; c < srcRow.length && startCol + c < keys.length; c++) {
targetArray[r][keys[startCol + c]] = sanitizeText(srcRow[c], false);
}
}
invalidatePreviews();
if (endRow > S.quantity) {
showRowCountDialog(endRow, S.quantity).then(choice => {
if (choice === 'add') {
S.quantity = endRow; // Menge auf Empfängerzahl anheben
} else {
targetArray.splice(S.quantity); // Menge beibehalten: überzählige Zeilen verwerfen
}
render();
});
} else {
render();
}
}
// ─── Printed-Letter Upload ────────────────────────────────────────────────────
function bindPrintedUpload(root) {
const letterZone = root.querySelector('#sk-printed-letter-zone');
const letterInput = root.querySelector('#sk-printed-letter-input');
const excelZone = root.querySelector('#sk-printed-excel-zone');
const excelInput = root.querySelector('#sk-printed-excel-input');
const allowedLetter = ['application/pdf', 'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
const handleLetter = (file) => {
if (!file) return;
if (!allowedLetter.includes(file.type) && !/\.(pdf|doc|docx)$/i.test(file.name)) {
S.printedLetterError = 'Nur .pdf, .doc oder .docx erlaubt.';
S.printedLetterDone = false;
render(); return;
}
S.printedLetterFile = file;
S.printedLetterDone = true;
S.printedLetterError = null;
render();
};
const handleExcel = (file) => {
if (!file) return;
S.printedExcelFile = file;
S.printedExcelDone = true;
render();
};
if (letterZone && letterInput) {
letterZone.addEventListener('click', () => letterInput.click());
letterZone.addEventListener('dragover', e => { e.preventDefault(); letterZone.classList.add('is-dragover'); });
letterZone.addEventListener('dragleave', () => letterZone.classList.remove('is-dragover'));
letterZone.addEventListener('drop', e => {
e.preventDefault(); letterZone.classList.remove('is-dragover');
handleLetter(e.dataTransfer?.files[0]);
});
letterInput.addEventListener('change', () => handleLetter(letterInput.files[0]));
}
if (excelZone && excelInput) {
excelZone.addEventListener('click', () => excelInput.click());
excelZone.addEventListener('dragover', e => { e.preventDefault(); excelZone.classList.add('is-dragover'); });
excelZone.addEventListener('dragleave', () => excelZone.classList.remove('is-dragover'));
excelZone.addEventListener('drop', e => {
e.preventDefault(); excelZone.classList.remove('is-dragover');
handleExcel(e.dataTransfer?.files[0]);
});
excelInput.addEventListener('change', () => handleExcel(excelInput.files[0]));
}
}
// ─── Preview-Navigation (partial re-bind) ─────────────────────────────────────
function bindPreviewNav(root) {
if (!root) return;
root.querySelector('#sk-prev-preview')?.addEventListener('click', () => {
if (S.previewIndex > 0) { S.previewIndex--; refreshPreview(); }
});
root.querySelector('#sk-next-preview')?.addEventListener('click', async () => {
// Gedruckte Briefe haben keine Brief-Vorschau, nur Umschläge – entsprechende Liste verwenden
const isPrinted = S.job?.produkt === 'Gedruckte Briefe';
const list = isPrinted ? S.envelopePreviews : S.previews;
const loaded = list.length;
const total = effectiveQuantity();
const hasMore = S.previewTotalLoaded < total && loaded < total;
const atEnd = S.previewIndex >= loaded - 1;
if (!atEnd) {
// Nächste bereits geladene Vorschau anzeigen
S.previewIndex++;
refreshPreview();
} else if (hasMore && !S.previewLoading) {
// Nächsten Batch vom Backend laden
const nextStart = S.previewTotalLoaded;
await doGenPreviewBatch(nextStart, S.previewBatchSize);
// Nach dem Laden: automatisch eine Seite weiter (wenn verfügbar)
const newList = isPrinted ? S.envelopePreviews : S.previews;
if (newList.length > S.previewIndex + 1) {
S.previewIndex++;
}
refreshPreview();
}
});
// Preview-Images: Fehler-Handling und Klick-zu-Lightbox (sowohl Brief als auch Umschlag)
root.querySelectorAll('[data-preview-img]').forEach(img => {
img.addEventListener('error', () => {
const box = img.closest('[data-preview-url]');
if (!box) return;
const url = box.getAttribute('data-preview-url') || '';
box.innerHTML = `<div class="sk-alert sk-alert-error">Vorschau-Bild konnte nicht geladen werden.<br><small>${esc(url)}</small></div>`;
});
img.addEventListener('click', () => {
const box = img.closest('[data-preview-url]');
const url = box?.getAttribute('data-preview-url') || img.src;
openLightbox(url);
});
});
}
// ─── Events binden ────────────────────────────────────────────────────────────
function bindCheckbox(root, cbId, optId, stateKey, afterToggle) {
const cb = root.querySelector(`#${cbId}`);
const opt = root.querySelector(`#${optId}`);
if (!cb || !opt) return;
const sync = () => { opt.classList.toggle('is-selected', S[stateKey]); afterToggle?.(); };
cb.addEventListener('change', () => { S[stateKey] = cb.checked; sync(); });
opt.addEventListener('click', e => {
if (e.target !== cb) { cb.checked = !cb.checked; S[stateKey] = cb.checked; sync(); }
});
}
function bindEvents(root) {
if (!root) return;
// Verify
const verifyBtn = root.querySelector('#sk-job-verify-btn');
const idInput = root.querySelector('#sk-job-id');
if (verifyBtn) verifyBtn.addEventListener('click', async () => {
const id = (idInput?.value || '').trim();
if (!id) return;
S.jobIdInput = id; S.verifyError = null; S.verifying = true; render();
const d = await verifyJob(id);
S.verifying = false;
if (d.success && d.job) {
S.job = d.job;
S.quantity = d.job.menge;
// Brieftext wiederherstellen
// Bei text_proposal IMMER die Serverversion nehmen – Admin kann den Vorschlag aktualisiert haben
// (veralteter localStorage-Cache darf den neuen Vorschlag nicht überschreiben)
try {
const cached = localStorage.getItem(`sk_brieftext_${d.job.id}`);
if (d.job.status === 'text_proposal' && d.job.proposedText) {
S.briefText = d.job.proposedText;
localStorage.setItem(`sk_brieftext_${d.job.id}`, d.job.proposedText);
} else if (cached) {
S.briefText = cached;
} else if (d.job.proposedText) {
S.briefText = d.job.proposedText;
localStorage.setItem(`sk_brieftext_${d.job.id}`, d.job.proposedText);
}
} catch (_) {}
// Status-basiertes Routing
const st = d.job.status || 'waiting';
S.subOrders = d.job.subOrders || [];
if (st === 'submitted' || st === 'printing' || st === 'shipped') {
S.step = STEP.STATUS;
} else {
S.step = STEP.OVERVIEW;
}
} else {
S.verifyError = d.message || 'Auftrag nicht gefunden. Bitte Auftragsnummer prüfen.';
}
render();
});
if (idInput) idInput.addEventListener('keydown', e => e.key === 'Enter' && verifyBtn?.click());
// Menge: bei Änderung wird Preview-Cache invalidiert (andere Anzahl SVGs nötig)
const qtyInput = root.querySelector('#sk-job-qty');
if (qtyInput) qtyInput.addEventListener('input', () => {
const newQty = Math.max(1, parseInt(qtyInput.value) || 1);
if (newQty !== S.quantity) {
S.quantity = newQty;
invalidatePreviews();
}
});
// Kunden-E-Mail speichern
root.querySelector('#sk-save-email')?.addEventListener('click', async btn => {
const input = root.querySelector('#sk-customer-email');
if (!input || !S.job) return;
const email = input.value.trim();
btn = root.querySelector('#sk-save-email');
if (btn) { btn.disabled = true; btn.textContent = '…'; }
try {
await updateCustomerEmail(S.job.id, email);
S.job.customerEmail = email;
if (btn) { btn.textContent = 'Gespeichert ✓'; setTimeout(() => { if (btn) btn.textContent = 'Speichern'; btn.disabled = false; }, 2000); }
} catch (_) {
if (btn) { btn.textContent = 'Fehler'; btn.disabled = false; }
}
});
// Adressmodus: bei Wechsel Adress-Upload zurücksetzen und Preview invalidieren
root.querySelectorAll('input[name="sk-addr-mode"]').forEach(r => {
r.addEventListener('change', () => {
S.addressMode = r.value;
S.rows = [];
S.uploadDone = S.addressInputMode === 'table';
invalidatePreviews();
render();
});
});
// Eingabemodus Adresse/Kuverttext: Datei-Upload vs. Direkteingabe in Tabelle
root.querySelectorAll('input[name="sk-addr-input-mode"]').forEach(r => {
r.addEventListener('change', () => {
S.addressInputMode = r.value;
S.uploadDone = r.value === 'table';
S.uploadError = null;
invalidatePreviews();
render();
});
});
// Eingabemodus individuelle Platzhalter: Datei-Upload vs. Direkteingabe in Tabelle
root.querySelectorAll('input[name="sk-custom-input-mode"]').forEach(r => {
r.addEventListener('change', () => {
S.customInputMode = r.value;
S.customUploadDone = r.value === 'table';
S.customUploadError = null;
invalidatePreviews();
render();
});
});
// Editierbare Tabellen (Adresse / individuelle Platzhalter): Eingaben direkt in den State schreiben
if (S.step === STEP.ADDRESS && S.addressInputMode === 'table') {
bindEditableTableInputs(root, 'sk-address-table', S.rows);
}
if (S.step === STEP.LETTERTEXT && S.customInputMode === 'table') {
bindEditableTableInputs(root, 'sk-custom-table', S.customRows);
}
// Download (Adress-Vorlagen)
root.querySelector('#sk-dl-standard') ?.addEventListener('click', e => { e.stopPropagation(); dlTemplate('adresse-standard'); });
root.querySelector('#sk-dl-free') ?.addEventListener('click', e => { e.stopPropagation(); dlTemplate('adresse-frei'); });
root.querySelector('#sk-dl-customtext') ?.addEventListener('click', () => dlTemplate('individueller-text'));
// Download (Platzhalter-Vorlage)
root.querySelector('#sk-dl-custom-tpl')?.addEventListener('click', e => { e.stopPropagation(); dlCustomTemplate(); });
// Upload-Zone (Adressen)
const zone = root.querySelector('#sk-upload-zone');
const fInput = root.querySelector('#sk-file-input');
if (zone && fInput) {
zone.addEventListener('click', () => fInput.click());
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('is-dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('is-dragover'));
zone.addEventListener('drop', e => {
e.preventDefault(); zone.classList.remove('is-dragover');
const f = e.dataTransfer?.files[0];
if (f) doUpload(f);
});
fInput.addEventListener('change', () => { if (fInput.files[0]) doUpload(fInput.files[0]); });
}
// Upload-Zone (individuelle Platzhalter)
const customZone = root.querySelector('#sk-custom-upload-zone');
const customInput = root.querySelector('#sk-custom-file-input');
if (customZone && customInput) {
customZone.addEventListener('click', () => customInput.click());
customZone.addEventListener('dragover', e => { e.preventDefault(); customZone.classList.add('is-dragover'); });
customZone.addEventListener('dragleave', () => customZone.classList.remove('is-dragover'));
customZone.addEventListener('drop', e => {
e.preventDefault(); customZone.classList.remove('is-dragover');
const f = e.dataTransfer?.files[0];
if (f) doCustomUpload(f);
});
customInput.addEventListener('change', () => { if (customInput.files[0]) doCustomUpload(customInput.files[0]); });
}
// Brieftext
const ta = root.querySelector('#sk-brieftext');
if (ta) {
// Tab-Taste im Textfeld → \t einfügen statt Fokus zu wechseln
ta.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
e.preventDefault();
const s = ta.selectionStart, end = ta.selectionEnd;
ta.value = ta.value.slice(0, s) + '\t' + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = s + 1;
S.briefText = ta.value;
try { if (S.job?.id) localStorage.setItem(`sk_brieftext_${S.job.id}`, S.briefText); } catch (_) {}
invalidatePreviews();
const nx = root.querySelector('#sk-job-next');
if (nx) nx.disabled = !S.briefText.trim();
});
let prevCustomCount = customPlaceholderNames().length;
let prevText = S.briefText;
ta.addEventListener('input', () => {
// Nicht erlaubte Zeichen sofort entfernen, Cursor-Position erhalten
const original = ta.value;
const cleaned = sanitizeText(original);
if (cleaned !== original) {
const cursorPos = ta.selectionStart;
const strippedBefore = original.slice(0, cursorPos).replace(ALLOWED_CHARS_RE, '').length;
const newCursor = cursorPos - strippedBefore;
ta.value = cleaned;
ta.setSelectionRange(newCursor, newCursor);
const charInfo = root.querySelector('#sk-charset-info');
if (charInfo) {
const removed = [...new Set((original.match(new RegExp(ALLOWED_CHARS_RE.source, 'g')) || []))].join(' ');
const charsEl = charInfo.querySelector('#sk-charset-chars');
if (charsEl) charsEl.textContent = removed;
charInfo.style.display = '';
clearTimeout(charInfo._hideTimer);
charInfo._hideTimer = setTimeout(() => { charInfo.style.display = 'none'; }, 4000);
}
}
S.briefText = ta.value;
// Brieftext pro Auftrag im Browser cachen (für Seitenreload)
try { if (S.job?.id) localStorage.setItem(`sk_brieftext_${S.job.id}`, S.briefText); } catch (_) {}
const nx = root.querySelector('#sk-job-next');
if (nx) nx.disabled = !S.briefText.trim();
// CAPS-Warnung dynamisch aktualisieren
const capsWarn = root.querySelector('#sk-caps-warn');
if (capsWarn) capsWarn.style.display = hasCapsWarn(S.briefText) ? '' : 'none';
// Brieftext geändert → vorherige Vorschau & Overflow-Validierung invalidieren
if (S.briefText !== prevText) {
prevText = S.briefText;
invalidatePreviews();
}
// Wenn sich Anzahl/Set der individuellen Platzhalter ändert: Re-render damit die Sektion live erscheint/verschwindet
const newCustomCount = customPlaceholderNames().length;
if (newCustomCount !== prevCustomCount) {
prevCustomCount = newCustomCount;
const start = ta.selectionStart, end = ta.selectionEnd;
render();
const newTa = document.getElementById('sk-brieftext');
if (newTa) { newTa.focus(); newTa.setSelectionRange(start, end); }
}
});
}
// Platzhalter-Chips (Adresse + Signaturen)
root.querySelectorAll('.sk-job-ph-chip').forEach(chip => {
chip.addEventListener('click', () => {
const t = root.querySelector('#sk-brieftext');
if (!t) return;
const ph = `[[${chip.dataset.ph}]]`;
const s = t.selectionStart, e = t.selectionEnd;
t.value = t.value.slice(0, s) + ph + t.value.slice(e);
t.setSelectionRange(s + ph.length, s + ph.length);
S.briefText = t.value; t.focus();
});
});
// Schriftart: bei Wechsel werden Vorschau & Overflow-Status invalidiert (andere Schrift = andere Zeilenbreite)
root.querySelectorAll('#sk-font-grid .sk-selection-card').forEach(card => {
card.addEventListener('click', () => {
if (S.font === card.dataset.font) return;
S.font = card.dataset.font;
invalidatePreviews();
render();
});
});
// Realistische Handschrift: Vorschau bei Änderung automatisch neu generieren
bindCheckbox(root, 'sk-realistic', 'sk-realistic-opt', 'realisticHandwriting', () => {
invalidatePreviews();
if (S.step === STEP.FONT) doGenPreview();
});
// Preview generieren
root.querySelector('#sk-gen-preview')?.addEventListener('click', doGenPreview);
// Preview Navigation
bindPreviewNav(root);
// Confirm-Checkboxen
const updateSubmitBtn = () => {
const btn = root.querySelector('#sk-job-submit');
if (btn) btn.disabled = !S.acceptedAgb || !S.acceptedPrivacy || !S.acceptedFinal || S.submitting;
};
bindCheckbox(root, 'sk-agb', 'sk-agb-opt', 'acceptedAgb', updateSubmitBtn);
bindCheckbox(root, 'sk-privacy', 'sk-privacy-opt', 'acceptedPrivacy', updateSubmitBtn);
bindCheckbox(root, 'sk-final', 'sk-final-opt', 'acceptedFinal', updateSubmitBtn);
// Submit
root.querySelector('#sk-job-submit')?.addEventListener('click', doSubmit);
// Signatur-Canvases
if (S.step === STEP.SIGNATURE) bindSignatureCanvases(root);
// Printed-Letter Upload
if (S.step === STEP.PRINTED_UPLOAD) bindPrintedUpload(root);
// Routine: Neuen Unterauftrag starten
root.querySelector('#sk-routine-new')?.addEventListener('click', async (e) => {
const btn = e.currentTarget;
btn.disabled = true;
btn.innerHTML = '<span class="sk-job-spinner"></span> Wird vorbereitet…';
// Bestellnummer JETZT reservieren – dient als eindeutiger Dateispeicher-Schlüssel für
// diesen Unterauftrag, damit Uploads (Adresse/Platzhalter/Brief) den vorherigen
// Unterauftrag nicht überschreiben.
S.subOrderNumber = await genOrderNumber();
S.isSubOrder = true;
S.printedLetterFile = null; S.printedLetterDone = false; S.printedLetterError = null;
S.printedExcelFile = null; S.printedExcelDone = false;
S.signatures = []; S.signatureStrokes = [];
S.briefText = ''; S.rows = []; S.uploadDone = false;
S.customRows = []; S.customUploadDone = false;
S.addressInputMode = 'file'; S.customInputMode = 'file';
invalidatePreviews();
S.step = STEP.OVERVIEW;
render();
});
// Versandfreigabe erteilen
root.querySelector('#sk-approve-shipping')?.addEventListener('click', async () => {
const btn = root.querySelector('#sk-approve-shipping');
if (!btn || !S.job) return;
btn.disabled = true;
btn.innerHTML = '<span class="sk-job-spinner"></span> Freigabe wird erteilt…';
try {
await approveShipping(S.job.id, S.job.shippingToken);
S.shippingApproved = true;
render();
} catch (_) {
btn.disabled = false;
btn.innerHTML = 'Versandfreigabe erteilen →';
}
});
// Navigation
root.querySelector('#sk-job-next')?.addEventListener('click', async () => {
// Direkt eingegebene Adresstabelle zusätzlich als Datei am Server hinterlegen (best-effort)
if (S.step === STEP.ADDRESS && S.addressInputMode === 'table') persistTableAsFile('adresse');
// Auf dem Brieftext-Schritt: erst Vorschau generieren um Overflow zu prüfen
if (S.step === STEP.LETTERTEXT) {
await doLetterTextNext();
} else {
go(1);
}
});
root.querySelector('#sk-job-prev')?.addEventListener('click', () => go(-1));
}
// ─── Haupt-Render ─────────────────────────────────────────────────────────────
function render() {
const inner = document.getElementById('sk-job-inner');
if (!inner) return;
let html = '';
switch (S.step) {
case STEP.VERIFY: html = renderVerify(); break;
case STEP.OVERVIEW: html = renderOverview(); break;
case STEP.ADDRESS: html = renderAddress(); break;
case STEP.LETTERTEXT: html = renderLetterText(); break;
case STEP.PRINTED_UPLOAD: html = renderPrintedUpload(); break;
case STEP.FONT: html = renderFont(); break;
case STEP.SIGNATURE: html = renderSignature(); break;
case STEP.CONFIRM: html = renderConfirm(); break;
case STEP.STATUS: html = renderStatus(); break;
}
inner.innerHTML = html;
bindEvents(inner.closest('[data-skrift-kundenauftrag="1"]'));
}
// ─── Init ─────────────────────────────────────────────────────────────────────
function init() {
const root = document.querySelector('[data-skrift-kundenauftrag="1"]');
if (!root) return;
root.innerHTML = '<div id="sk-job-inner"></div>';
// URL-Parameter: ?nr=… → Eingabefeld vorab befüllen und Auftrag direkt laden
const params = new URLSearchParams(window.location.search);
const urlJobId = (params.get('nr') || '').trim();
if (urlJobId) {
S.jobIdInput = urlJobId;
}
render();
root.classList.add('sk-ready');
// Auftrag automatisch abrufen wenn ID in URL
if (urlJobId) {
setTimeout(() => document.querySelector('#sk-job-verify-btn')?.click(), 80);
}
}
document.addEventListener('DOMContentLoaded', init);