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:
@@ -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()));
|
||||
// ── 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();
|
||||
ph.forEach((name, i) => { row.placeholders = { ...row.placeholders, [name]: zellen[i] || '' }; });
|
||||
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') {
|
||||
// Spalten nach den Platzhaltern → Vorname, Nachname, Straße, Nr., PLZ, Stadt, Land.
|
||||
FELDER.forEach(([key], i) => { row[key] = zellen[ph.length + i] || ''; });
|
||||
FELDER.forEach(([key]) => { if (e[key]) row[key] = saeubere(String(e[key]).replace(/[\r\n]/g, ' ')); });
|
||||
} else {
|
||||
row.free_text = zellen.slice(ph.length, ph.length + max).join('\n');
|
||||
const max = zeilenAnzahl();
|
||||
let lines;
|
||||
if (e.free_text && String(e.free_text).trim()) {
|
||||
lines = String(e.free_text).split('\n');
|
||||
} else {
|
||||
const name = [e.salutation, e.first_name, e.last_name].filter(Boolean).join(' ');
|
||||
const strasse = [e.street, e.house_no].filter(Boolean).join(' ');
|
||||
const ort = [e.zip, e.city].filter(Boolean).join(' ');
|
||||
const land = (e.country && !/^(de|deu|d|deutschland|germany)$/i.test(String(e.country).trim())) ? e.country : '';
|
||||
lines = [name, strasse, ort, land];
|
||||
}
|
||||
lines = lines.map((l) => saeubere(String(l || '').replace(/[\r\n]/g, ' ')).trim()).filter(Boolean);
|
||||
row.free_text = (max === 1) ? lines.join(', ') : lines.slice(0, max).join('\n').replace(/\n+$/, '');
|
||||
}
|
||||
return row;
|
||||
});
|
||||
neu.rows = rows;
|
||||
neu.importText = ''; neu.importOpen = false; neu.previewSession = null;
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user