From 850ba38578c95a597224dd9d94f0c95983621f1b Mon Sep 17 00:00:00 2001 From: s4luorth Date: Thu, 1 Oct 2026 17:39:07 +0200 Subject: [PATCH] Neuauftrag: Empfaenger-Datei-Import (Excel/CSV) nach Spaltennamen - wie app.skrift.de - neuauftrag: Datei-Upload (.xlsx/.xls/.csv) + Vorlage-Download + Einfuegen; Spalten werden per Ueberschrift automatisch zugeordnet (Reihenfolge egal, Zusatzspalten -> Platzhalter); Backend-Entries je nach Modus (Einzelfelder / 5 Zeilen / Ind. Text) in Empfaengerzeilen gewandelt; Rueckmeldung erkannter Spalten + Fehleranzeige - skrift-orders: Proxy POST /skrift-orders/parse-recipients -> Backend /api/order/parse-recipients Braucht Backend >= v1.0.31 (parst XLSX/CSV serverseitig via exceljs). Co-Authored-By: Claude Opus 4.8 --- .../dist/index.js | 134 ++++++++++++++---- .../dist/index.js | 21 +++ 2 files changed, 126 insertions(+), 29 deletions(-) diff --git a/extensions/directus-extension-skrift-neuauftrag/dist/index.js b/extensions/directus-extension-skrift-neuauftrag/dist/index.js index d9b9520..349858a 100644 --- a/extensions/directus-extension-skrift-neuauftrag/dist/index.js +++ b/extensions/directus-extension-skrift-neuauftrag/dist/index.js @@ -90,7 +90,7 @@ const NeuAuftrag = defineComponent({ envFormat: '', // Ohne Kuvert & ohne eigene Platzhalter: einfach N identische Schriftstücke. anzahl: 1, - importOpen: false, importText: '', + importOpen: false, importText: '', importFehler: '', importErkannt: [], importAnzahl: 0, docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '', lbDoc: null, }); // Ist das gewählte Format eine Postkarte (Text links, Adresse rechts, ein SVG)? @@ -270,27 +270,82 @@ const NeuAuftrag = defineComponent({ const identischModus = () => keinKuvert() && customPlaceholders().length === 0; const anzahlNorm = () => Math.max(1, Math.min(1000, Math.round(Number(neu.anzahl) || 1))); - // Aus Excel/CSV eingefügte Zeilen übernehmen. Erst die individuellen - // Platzhalter-Spalten, dann die Adresszeilen (Tab/Semikolon/Komma-getrennt). - function importUebernehmen() { - const ph = customPlaceholders(); - const max = zeilenAnzahl(); - const zeilen = String(neu.importText || '').split(/\r?\n/).map((z) => z.trim()).filter(Boolean); - if (!zeilen.length) { neu.importOpen = false; return; } - const rows = zeilen.map((z) => { - const zellen = z.split(/\t|;|,/).map((c) => saeubere(c.trim())); - const row = leereZeile(); - ph.forEach((name, i) => { row.placeholders = { ...row.placeholders, [name]: zellen[i] || '' }; }); - if (neu.adressModus === 'felder') { - // Spalten nach den Platzhaltern → Vorname, Nachname, Straße, Nr., PLZ, Stadt, Land. - FELDER.forEach(([key], i) => { row[key] = zellen[ph.length + i] || ''; }); + // ── Empfänger-Import: Datei/Einfügen → Backend parst nach SPALTENNAMEN ───── + // (XLSX/CSV, wie in app.skrift.de). Ein Backend-Entry → Empfängerzeile im + // gewählten Modus wandeln. + function entryZuRow(e) { + const row = leereZeile(); + if (e.placeholders && typeof e.placeholders === 'object') { + for (const [k, v] of Object.entries(e.placeholders)) if (String(v || '').trim()) row.placeholders = { ...row.placeholders, [k]: saeubere(String(v)) }; + } + if (neu.adressModus === 'felder') { + FELDER.forEach(([key]) => { if (e[key]) row[key] = saeubere(String(e[key]).replace(/[\r\n]/g, ' ')); }); + } else { + const max = zeilenAnzahl(); + let lines; + if (e.free_text && String(e.free_text).trim()) { + lines = String(e.free_text).split('\n'); } else { - row.free_text = zellen.slice(ph.length, ph.length + max).join('\n'); + const name = [e.salutation, e.first_name, e.last_name].filter(Boolean).join(' '); + const strasse = [e.street, e.house_no].filter(Boolean).join(' '); + const ort = [e.zip, e.city].filter(Boolean).join(' '); + const land = (e.country && !/^(de|deu|d|deutschland|germany)$/i.test(String(e.country).trim())) ? e.country : ''; + lines = [name, strasse, ort, land]; } - return row; - }); - neu.rows = rows; - neu.importText = ''; neu.importOpen = false; neu.previewSession = null; + lines = lines.map((l) => saeubere(String(l || '').replace(/[\r\n]/g, ' ')).trim()).filter(Boolean); + row.free_text = (max === 1) ? lines.join(', ') : lines.slice(0, max).join('\n').replace(/\n+$/, ''); + } + return row; + } + function entriesUebernehmen(data) { + const entries = (data && Array.isArray(data.entries)) ? data.entries : []; + if (!entries.length) { neu.importFehler = 'Keine Empfänger in der Datei gefunden.'; return; } + neu.rows = entries.map(entryZuRow); + neu.importErkannt = data.columns || []; neu.importAnzahl = entries.length; + neu.importFehler = ''; neu.importText = ''; neu.importOpen = false; neu.previewSession = null; + neu.msg = `${entries.length} Empfänger übernommen${neu.importErkannt.length ? ' (Spalten: ' + neu.importErkannt.join(', ') + ')' : ''}.`; + setTimeout(() => { if (neu.msg.startsWith(String(entries.length))) neu.msg = ''; }, 6000); + } + async function parseImBackend(dataB64, filename) { + neu.importFehler = ''; neu.pLaedt = true; + try { + const r = await api.post('/skrift-orders/parse-recipients', { data_base64: dataB64, filename }); + entriesUebernehmen(r.data || {}); + } catch (e) { + neu.importFehler = e?.response?.data?.error || e?.message || 'Datei konnte nicht eingelesen werden.'; + } finally { neu.pLaedt = false; } + } + async function dateiHochladen(ev) { + const file = ev.target && ev.target.files && ev.target.files[0]; + if (!file) return; + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + let bin = ''; for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + await parseImBackend(btoa(bin), file.name || 'empfaenger.xlsx'); + } catch (e) { neu.importFehler = 'Datei konnte nicht gelesen werden.'; } + finally { ev.target.value = ''; } + } + function importUebernehmen() { + const txt = String(neu.importText || ''); + if (!txt.trim()) { neu.importOpen = false; return; } + parseImBackend(btoa(unescape(encodeURIComponent(txt))), 'einfuegen.csv'); + } + // Vorlage (CSV, Excel-kompatibel) passend zu Modus + Platzhaltern herunterladen. + function vorlageHerunterladen() { + const ph = customPlaceholders(); + const basis = neu.adressModus === 'felder' + ? ['Anrede', 'Vorname', 'Nachname', 'Strasse', 'Nr', 'PLZ', 'Ort', 'Land'] + : (neu.adressModus === 'individuell' ? ['Zeile 1'] : ['Zeile 1', 'Zeile 2', 'Zeile 3', 'Zeile 4', 'Zeile 5']); + const beispielBasis = neu.adressModus === 'felder' + ? ['Frau', 'Erika', 'Mustermann', 'Hauptstraße', '1', '10115', 'Berlin', 'DE'] + : (neu.adressModus === 'individuell' ? ['Erika Mustermann, Hauptstraße 1, 10115 Berlin'] : ['Frau Erika Mustermann', 'Hauptstraße 1', '10115 Berlin', '', '']); + const kopf = [...basis, ...ph]; + const beispiel = [...beispielBasis.slice(0, basis.length), ...ph.map(() => 'Beispielwert')]; + const z = (c) => /[;"\n]/.test(String(c)) ? '"' + String(c).replace(/"/g, '""') + '"' : String(c); + const text = '' + [kopf, beispiel].map((r) => r.map(z).join(';')).join('\r\n') + '\r\n'; + const url = URL.createObjectURL(new Blob([text], { type: 'text/csv;charset=utf-8;' })); + const a = document.createElement('a'); a.href = url; a.download = 'skrift-empfaenger-vorlage.csv'; a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1500); } // ── Unterschrift auf dem Brief ([[signatur1]]) ──────────────────────────── @@ -468,7 +523,7 @@ const NeuAuftrag = defineComponent({ sigSvg: '', sigLabel: '', sigX: 0, sigY: 0, sigScale: 100, sigSpacing: '', mehrseitig: false, seitenInfo: '', adressModus: '5zeilen', kuverts: true, absenderAufKuvert: false, envelopeSender: '', envelopeSenderScale: 100, envFormat: '', realistic: true, customer_email: '', text: '', rows: [leereZeile()], anzahl: 1, - importOpen: false, importText: '', + importOpen: false, importText: '', importFehler: '', importErkannt: [], importAnzahl: 0, docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '', }); } @@ -616,8 +671,8 @@ const NeuAuftrag = defineComponent({ h('button', { type: 'button', onClick: () => delZeile(i), style: 'cursor:pointer;border:none;background:transparent;color:#c0392b;font-size:18px;padding:2px 6px;' }, '×'), ]))), h('div', { style: 'margin-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:12px;' }, [ - btn('+ Empfänger', addZeile), - btn(neu.importOpen ? 'Import schließen' : '⇪ Aus Excel einfügen', () => { neu.importOpen = !neu.importOpen; }), + btn('+ Empfänger', addZeile, undefined, 'person_add'), + btn(neu.importOpen ? 'Import schließen' : 'Datei / Excel importieren', () => { neu.importOpen = !neu.importOpen; neu.importFehler = ''; }, undefined, neu.importOpen ? 'close' : 'upload_file'), ]), ]), // Postkarte: Hinweis auf das Kartenlayout; kein separates Kuvert. @@ -653,12 +708,33 @@ const NeuAuftrag = defineComponent({ h('div', { style: 'font-size:11px;color:#566070;margin-top:4px;line-height:1.5;' }, istPostkarte() ? 'Empfänger steht darunter in der Anschriftzone (rechte Hälfte).' : 'Der Empfänger rutscht dann auf dem Kuvert automatisch nach unten rechts.'), ]) : null, - (neu.importOpen && !identischModus()) ? h('div', { style: 'margin-top:12px;' }, [ - subdued('Aus Excel kopieren und einfügen – eine Zeile pro Empfänger, Spalten mit Tab/Semikolon/Komma. ' - + 'Reihenfolge: erst individuelle Platzhalter, dann ' + (neu.adressModus === 'individuell' ? 'die eine Adresszeile.' : 'Zeile 1–5.'), 'margin-bottom:6px;'), - h('textarea', { value: neu.importText, rows: 5, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.importText = e.target.value; } }), - h('div', { style: 'margin-top:6px;' }, [btn('Übernehmen', importUebernehmen, 'primary')]), - ]) : null, + (neu.importOpen && !identischModus()) ? (() => { + const ph = customPlaceholders(); + const erwartet = (neu.adressModus === 'felder' + ? 'Anrede, Vorname, Nachname, Strasse, Nr, PLZ, Ort, Land' + : (neu.adressModus === 'individuell' ? 'Zeile 1' : 'Zeile 1 … Zeile 5')) + + (ph.length ? ', ' + ph.join(', ') : ''); + return h('div', { style: 'margin-top:12px;border:1px solid #d2dae6;border-radius:10px;background:#eef2f8;padding:14px 16px;' }, [ + h('div', { style: 'font-size:13px;color:#1b2430;line-height:1.5;margin-bottom:10px;' }, [ + h('b', {}, 'Datei importieren (Excel .xlsx/.xls oder CSV). '), + 'Die Spalten werden anhand ihrer Überschriften automatisch zugeordnet – die Reihenfolge ist egal, zusätzliche Spalten werden zu [[Platzhaltern]].', + ]), + h('div', { style: 'font-size:12px;color:#566070;margin-bottom:12px;' }, ['Erkannte Spalten: ', h('span', { style: 'font-weight:600;color:#1b2430;' }, erwartet)]), + h('div', { style: 'display:flex;gap:10px;flex-wrap:wrap;align-items:center;' }, [ + h('label', { style: `cursor:pointer;border-radius:8px;padding:9px 16px;font-size:14px;font-weight:500;display:inline-flex;align-items:center;gap:6px;border:1px solid ${ACCENT};background:${ACCENT};color:#fff;` }, [ + icon('upload_file'), neu.pLaedt ? 'Lese Datei …' : 'Datei wählen (Excel/CSV)', + h('input', { type: 'file', accept: '.xlsx,.xls,.csv,.tsv', style: 'display:none;', disabled: neu.pLaedt, onChange: dateiHochladen }), + ]), + btn('Vorlage herunterladen', vorlageHerunterladen, undefined, 'download'), + ]), + neu.importFehler ? h('div', { style: 'margin-top:10px;background:#fbe3e0;border:1px solid #f3c6bf;color:#c0392b;border-radius:8px;padding:9px 12px;font-size:13px;' }, neu.importFehler) : null, + h('div', { style: 'margin-top:14px;border-top:1px solid #d2dae6;padding-top:12px;' }, [ + subdued('Oder Daten aus Excel kopieren und hier einfügen (erste Zeile = Spaltennamen):', 'margin-bottom:6px;'), + h('textarea', { value: neu.importText, rows: 4, placeholder: 'Vorname\tNachname\tPLZ\tOrt\nErika\tMustermann\t10115\tBerlin', style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.importText = e.target.value; } }), + h('div', { style: 'margin-top:6px;' }, [btn('Eingefügte Daten übernehmen', importUebernehmen, 'primary', 'check')]), + ]), + ]); + })() : null, ]); // Bereich 3 – Text (Mustertext-Dropdown direkt darüber) diff --git a/extensions/directus-extension-skrift-orders/dist/index.js b/extensions/directus-extension-skrift-orders/dist/index.js index a5a17e2..dba878a 100644 --- a/extensions/directus-extension-skrift-orders/dist/index.js +++ b/extensions/directus-extension-skrift-orders/dist/index.js @@ -921,6 +921,27 @@ const handler = (router, { services, getSchema, logger }) => { } }); + // Empfängerdatei (XLSX/CSV, Base64) im Backend nach Spaltennamen einlesen. + router.post('/parse-recipients', async (req, res) => { + const acc = req.accountability; + if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' }); + const body = req.body || {}; + if (!body.data_base64) return res.status(400).json({ error: 'Keine Datei.' }); + try { + const r = await fetch(`${BACKEND_URL}/api/order/parse-recipients`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN }, + body: JSON.stringify({ data_base64: body.data_base64, filename: body.filename || '' }), + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) return res.status(r.status === 422 || r.status === 400 ? r.status : 502).json({ error: data.error || 'Einlesen fehlgeschlagen.' }); + return res.json(data); + } catch (err) { + logger.error(`[skrift-orders] parse-recipients: ${err.stack || err.message}`); + return res.status(500).json({ error: 'Einlesen fehlgeschlagen.' }); + } + }); + /** * Öffentlicher Datei-Upload (Motiv/Quelldatei). Gäste bestellen ohne Login und * haben keinen Directus-Token für /files – daher hier serverseitig via FilesService