Neuauftrag: Empfaenger-Datei-Import (Excel/CSV) nach Spaltennamen - wie app.skrift.de

- neuauftrag: Datei-Upload (.xlsx/.xls/.csv) + Vorlage-Download + Einfuegen; Spalten
  werden per Ueberschrift automatisch zugeordnet (Reihenfolge egal, Zusatzspalten ->
  Platzhalter); Backend-Entries je nach Modus (Einzelfelder / 5 Zeilen / Ind. Text)
  in Empfaengerzeilen gewandelt; Rueckmeldung erkannter Spalten + Fehleranzeige
- skrift-orders: Proxy POST /skrift-orders/parse-recipients -> Backend /api/order/parse-recipients

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-10-01 17:39:07 +02:00
parent 4b314d65ba
commit 850ba38578
2 changed files with 126 additions and 29 deletions

View File

@@ -90,7 +90,7 @@ const NeuAuftrag = defineComponent({
envFormat: '', envFormat: '',
// Ohne Kuvert & ohne eigene Platzhalter: einfach N identische Schriftstücke. // Ohne Kuvert & ohne eigene Platzhalter: einfach N identische Schriftstücke.
anzahl: 1, anzahl: 1,
importOpen: false, importText: '', importOpen: false, importText: '', importFehler: '', importErkannt: [], importAnzahl: 0,
docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '', lbDoc: null, docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '', lbDoc: null,
}); });
// Ist das gewählte Format eine Postkarte (Text links, Adresse rechts, ein SVG)? // 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 identischModus = () => keinKuvert() && customPlaceholders().length === 0;
const anzahlNorm = () => Math.max(1, Math.min(1000, Math.round(Number(neu.anzahl) || 1))); const anzahlNorm = () => Math.max(1, Math.min(1000, Math.round(Number(neu.anzahl) || 1)));
// Aus Excel/CSV eingefügte Zeilen übernehmen. Erst die individuellen // ── Empfänger-Import: Datei/Einfügen → Backend parst nach SPALTENNAMEN ─────
// Platzhalter-Spalten, dann die Adresszeilen (Tab/Semikolon/Komma-getrennt). // (XLSX/CSV, wie in app.skrift.de). Ein Backend-Entry → Empfängerzeile im
function importUebernehmen() { // gewählten Modus wandeln.
const ph = customPlaceholders(); function entryZuRow(e) {
const max = zeilenAnzahl(); const row = leereZeile();
const zeilen = String(neu.importText || '').split(/\r?\n/).map((z) => z.trim()).filter(Boolean); if (e.placeholders && typeof e.placeholders === 'object') {
if (!zeilen.length) { neu.importOpen = false; return; } for (const [k, v] of Object.entries(e.placeholders)) if (String(v || '').trim()) row.placeholders = { ...row.placeholders, [k]: saeubere(String(v)) };
const rows = zeilen.map((z) => { }
const zellen = z.split(/\t|;|,/).map((c) => saeubere(c.trim())); if (neu.adressModus === 'felder') {
const row = leereZeile(); FELDER.forEach(([key]) => { if (e[key]) row[key] = saeubere(String(e[key]).replace(/[\r\n]/g, ' ')); });
ph.forEach((name, i) => { row.placeholders = { ...row.placeholders, [name]: zellen[i] || '' }; }); } else {
if (neu.adressModus === 'felder') { const max = zeilenAnzahl();
// Spalten nach den Platzhaltern → Vorname, Nachname, Straße, Nr., PLZ, Stadt, Land. let lines;
FELDER.forEach(([key], i) => { row[key] = zellen[ph.length + i] || ''; }); if (e.free_text && String(e.free_text).trim()) {
lines = String(e.free_text).split('\n');
} else { } 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; 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+$/, '');
neu.rows = rows; }
neu.importText = ''; neu.importOpen = false; neu.previewSession = null; 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]]) ──────────────────────────── // ── Unterschrift auf dem Brief ([[signatur1]]) ────────────────────────────
@@ -468,7 +523,7 @@ const NeuAuftrag = defineComponent({
sigSvg: '', sigLabel: '', sigX: 0, sigY: 0, sigScale: 100, sigSpacing: '', mehrseitig: false, seitenInfo: '', 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()], adressModus: '5zeilen', kuverts: true, absenderAufKuvert: false, envelopeSender: '', envelopeSenderScale: 100, envFormat: '', realistic: true, customer_email: '', text: '', rows: [leereZeile()],
anzahl: 1, anzahl: 1,
importOpen: false, importText: '', importOpen: false, importText: '', importFehler: '', importErkannt: [], importAnzahl: 0,
docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '', 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('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;' }, [ h('div', { style: 'margin-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:12px;' }, [
btn('+ Empfänger', addZeile), btn('+ Empfänger', addZeile, undefined, 'person_add'),
btn(neu.importOpen ? 'Import schließen' : '⇪ Aus Excel einfügen', () => { neu.importOpen = !neu.importOpen; }), 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. // 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;' }, 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.'), istPostkarte() ? 'Empfänger steht darunter in der Anschriftzone (rechte Hälfte).' : 'Der Empfänger rutscht dann auf dem Kuvert automatisch nach unten rechts.'),
]) : null, ]) : null,
(neu.importOpen && !identischModus()) ? h('div', { style: 'margin-top:12px;' }, [ (neu.importOpen && !identischModus()) ? (() => {
subdued('Aus Excel kopieren und einfügen – eine Zeile pro Empfänger, Spalten mit Tab/Semikolon/Komma. ' const ph = customPlaceholders();
+ 'Reihenfolge: erst individuelle Platzhalter, dann ' + (neu.adressModus === 'individuell' ? 'die eine Adresszeile.' : 'Zeile 1–5.'), 'margin-bottom:6px;'), const erwartet = (neu.adressModus === 'felder'
h('textarea', { value: neu.importText, rows: 5, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.importText = e.target.value; } }), ? 'Anrede, Vorname, Nachname, Strasse, Nr, PLZ, Ort, Land'
h('div', { style: 'margin-top:6px;' }, [btn('Übernehmen', importUebernehmen, 'primary')]), : (neu.adressModus === 'individuell' ? 'Zeile 1' : 'Zeile 1 … Zeile 5'))
]) : null, + (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) // Bereich 3 – Text (Mustertext-Dropdown direkt darüber)

View File

@@ -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 * Öffentlicher Datei-Upload (Motiv/Quelldatei). Gäste bestellen ohne Login und
* haben keinen Directus-Token für /files – daher hier serverseitig via FilesService * haben keinen Directus-Token für /files – daher hier serverseitig via FilesService