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>
2631 lines
117 KiB
JavaScript
2631 lines
117 KiB
JavaScript
/**
|
||
* 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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
|
||
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} ${S.rows.length === 1 ? 'Datensatz' : 'Datensätze'} geladen – 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> Folgende Zeichen werden nicht unterstützt und wurden entfernt: <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} ${S.rows.length === 1 ? 'Zeile' : 'Zeilen'} geladen – 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> Folgende Zeichen werden nicht unterstützt und wurden entfernt: <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. 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> Datensätze verwendet, oder erhöhen Sie die Auftragsmenge in Schritt „Übersicht".`
|
||
: `Es werden nur <strong>${S.rows.length}</strong> Briefe/Umschläge erzeugt (keine Leerdokumente), oder ergänzen Sie die fehlenden <strong>${Math.abs(diff)}</strong> Datensätze bzw. reduzieren Sie die Auftragsmenge in Schritt „Ü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> 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} ${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} ${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} 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} 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]], …">${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> Folgende Zeichen werden nicht unterstützt und wurden entfernt: <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. 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} ${S.customRows.length === 1 ? 'Zeile' : 'Zeilen'} importiert – 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> Folgende Zeichen werden nicht unterstützt und wurden entfernt: <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 <strong>${f.index + 1}</strong></span>
|
||
<span class="sk-overflow-lines">${f.lineCount} / ${f.lineLimit} Zeilen</span>
|
||
</li>`).join('');
|
||
const restItem = rest > 0
|
||
? `<li class="sk-overflow-item sk-overflow-more">… und ${rest} 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. B. Namen) prüfen, um fortzufahren.
|
||
Die Zeilennummern beziehen sich auf die Empfänger-Tabelle aus Schritt „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} / ${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">×</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} 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);
|