From 208d398034196a8661589232b61cd3f7635a0fcf Mon Sep 17 00:00:00 2001 From: s4luorth Date: Mon, 28 Sep 2026 10:15:18 +0200 Subject: [PATCH] Batch-Reiter Umbau: Uebernahme in Produktion, Tag-Gruppen, Auswahl, Versendet, Loeschen Uebermittlungen-Reiter neu: nach Tag gruppiert, Mehrfachauswahl (Strg/ Shift, board-weit auch fuer Auftragskarten). Aktionen auf Auswahl: - "In Produktion uebernehmen" -> neuer /skrift-orders/batch-uebernehmen: PDF -> Datei-Auftrag (PDF = Druckdatei), Text -> Handschrift-Brief (Text + 5-Zeilen-Adresse), Kuvert mit Empfaenger; Status uebernommen; Generierung angestossen. Danach in "Neu" (dort wie bisher buendeln). - "Versendet" / "Nicht versendet" (neues Feld batch_submissions.versendet). - "Loeschen" (mit Rueckfrage). FTP-"Jetzt buendeln"-Knopf entfernt (FTP-Weg entfaellt). Bootstrap: Status 'uebernommen' + Feld versendet. Co-Authored-By: Claude Opus 4.8 --- bootstrap/bootstrap.mjs | 5 +- .../dist/index.js | 74 ++++++++ .../dist/index.js | 169 +++++++++++++----- 3 files changed, 198 insertions(+), 50 deletions(-) diff --git a/bootstrap/bootstrap.mjs b/bootstrap/bootstrap.mjs index d40eb0a..203df01 100644 --- a/bootstrap/bootstrap.mjs +++ b/bootstrap/bootstrap.mjs @@ -710,8 +710,9 @@ const collections = [ f('land', 'string', { interface: 'input' }), f('text', 'text', { interface: 'input-multiline', note: 'Schriftstücktext (Handschrift), falls keine Druck-PDF.' }), m2o('pdf_file', 'directus_files', { note: 'Fertige Druck-PDF (wird 1:1 übernommen), falls kein Text.' }), - dropdown('status', ['pending', 'bundled'], { default: 'pending', note: 'pending = noch nicht gebündelt.' }), - f('bundle_name', 'string', { interface: 'input', readonly: true, note: 'Name der Bündel-Datei, in die dieser Datensatz ging.' }), + dropdown('status', ['pending', 'uebernommen', 'bundled'], { default: 'pending', note: 'pending = offen; uebernommen = als Auftrag in die Produktion übernommen.' }), + f('versendet', 'boolean', { interface: 'boolean', default: false, note: 'Im Board manuell als versendet markiert.' }), + f('bundle_name', 'string', { interface: 'input', readonly: true, note: 'Name der Bündel-Datei / Referenz.' }), f('bundled_at', 'timestamp', { interface: 'datetime', readonly: true }), { field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} }, ] }, diff --git a/extensions/directus-extension-skrift-orders/dist/index.js b/extensions/directus-extension-skrift-orders/dist/index.js index 911152b..23bd008 100644 --- a/extensions/directus-extension-skrift-orders/dist/index.js +++ b/extensions/directus-extension-skrift-orders/dist/index.js @@ -1388,6 +1388,80 @@ const handler = (router, { services, getSchema, logger }) => { } }); + // ── Batch-Übermittlungen in die Produktion übernehmen (Reiter „Neu") ─────── + // Ausgewählte batch_submissions werden Aufträge: PDF → Datei-Auftrag (die PDF + // ist die Druckdatei, source_file), Text → Handschrift-Brief (Text + 5-Zeilen- + // Adresse). Kuvert mit Empfängeradresse. Danach Generierung + Status uebernommen. + router.post('/batch-uebernehmen', async (req, res) => { + const acc = req.accountability; + if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' }); + const ids = Array.isArray(req.body?.ids) ? req.body.ids : []; + if (!ids.length) return res.status(400).json({ error: 'Keine Übermittlungen gewählt.' }); + try { + const schema = await getSchema(); + const svc = (c) => new ItemsService(c, { schema, accountability: null }); + const { settings } = await loadPricingContext(schema); + const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E'); + + const dateiProd = (await svc('products').readByQuery({ filter: { input_mode: { _eq: 'datei' }, active: { _eq: true } }, limit: 1, fields: ['id'] }))?.[0]; + let briefProd = (await svc('products').readByQuery({ filter: { type: { _eq: 'letter' }, active: { _eq: true } }, limit: 1, sort: ['sort'], fields: ['id'] }))?.[0]; + if (!briefProd) briefProd = (await svc('products').readByQuery({ filter: { input_mode: { _neq: 'datei' }, active: { _eq: true } }, limit: 1, sort: ['sort'], fields: ['id'] }))?.[0]; + const fmtA4 = (await svc('formats').readByQuery({ filter: { key: { _eq: 'a4' } }, limit: 1, fields: ['id'] }))?.[0]?.id ?? null; + + const subs = await svc('batch_submissions').readMany(ids, { + fields: ['id', 'firma', 'vorname', 'nachname', 'zeile1', 'zeile2', 'zeile3', 'plz', 'stadt', 'land', 'text', 'pdf_file', 'status', 'customer'], + }); + + const naechsteNummer = async () => { + const jt = new Date(); + const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`; + const heutige = await svc('orders').readByQuery({ filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'] }); + return `${praefix}-${String((heutige?.length ?? 0) + 1).padStart(3, '0')}`; + }; + + const angelegt = []; + const uebersprungen = []; + for (const s of subs || []) { + if (!s || s.status !== 'pending') { uebersprungen.push({ id: s?.id, grund: 'nicht offen' }); continue; } + const istPdf = !!s.pdf_file; + const product = istPdf ? dateiProd : briefProd; + if (!product) { uebersprungen.push({ id: s.id, grund: istPdf ? 'kein Datei-Produkt' : 'kein Brief-Produkt' }); continue; } + const adr = [s.firma, [s.vorname, s.nachname].filter(Boolean).join(' '), s.zeile1, s.zeile2, s.zeile3, [s.plz, s.stadt].filter(Boolean).join(' '), s.land] + .map((x) => String(x || '').trim()).filter(Boolean).slice(0, 5); + const orderData = { + customer: s.customer || null, + product: product.id, format: fmtA4, + person_type: 'unternehmen', font: 'tilda', + source: 'operator', status: 'in_queue', production_status: 'eingegangen', + payment_status: 'bezahlt', payment_method: 'rechnung', shipping_type: 'sammel', + needs_envelope: true, envelope_labeling: 'empfaenger', envelope_format: 'dinlang', + text_template: istPdf ? null : (clean(s.text) || null), + source_file: istPdf ? s.pdf_file : null, + source_files: istPdf ? [s.pdf_file] : null, + entries_count: 1, count_inland: 1, count_ausland: 0, + multi_page: !istPdf, + }; + let orderId, orderNumber; + for (let v = 0; v < 6; v += 1) { + orderNumber = await naechsteNummer(); + try { orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); break; } + catch (e) { if (v === 5 || !/unique|duplicate|bereits|exists/i.test(String(e?.message || ''))) throw e; } + } + await svc('order_entries').createOne({ order: orderId, letter_number: 1, free_text: adr.join('\n') || null, country: String(s.land || '').trim() || null }); + await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Aus Batch-Übermittlung übernommen.' }).catch(() => {}); + await svc('batch_submissions').updateOne(s.id, { status: 'uebernommen', bundled_at: new Date().toISOString(), bundle_name: orderNumber }); + fetch(`${BACKEND_URL}/api/order/from-directus`, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN }, body: JSON.stringify({ orderId }), + }).catch((e) => logger.warn(`[skrift-orders] batch-uebernehmen Generierung: ${e.message}`)); + angelegt.push({ id: s.id, order_number: orderNumber }); + } + return res.json({ ok: true, created: angelegt.length, orders: angelegt, skipped: uebersprungen }); + } catch (err) { + logger.error(`[skrift-orders] batch-uebernehmen: ${err.stack || err.message}`); + return res.status(500).json({ error: 'Übernahme fehlgeschlagen.', detail: String(err.message || err).slice(0, 300) }); + } + }); + // ── Testdruck: beliebiges Template + Text generieren und einreihen ───────── const TEST_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' }; router.post('/test-print', async (req, res) => { diff --git a/extensions/directus-extension-skrift-produktion/dist/index.js b/extensions/directus-extension-skrift-produktion/dist/index.js index 9178792..b763b60 100644 --- a/extensions/directus-extension-skrift-produktion/dist/index.js +++ b/extensions/directus-extension-skrift-produktion/dist/index.js @@ -197,6 +197,7 @@ const Board = defineComponent({ // getrennt vom normalen Auftragsboard im Reiter „Übermittlungen". const batch = ref([]); const batchBusy = ref(''); + const batchAuswahl = ref([]); // ── Modale ── const druck = reactive({ open: false, order: null, machine: null, mode: 'queue', busy: false, brief: null, umschlag: null, spaltenweise: false, msg: '', err: '', deNums: '', intNums: '', deN: 0, intN: 0 }); @@ -407,7 +408,7 @@ const Board = defineComponent({ api.get('/items/batch_submissions', { params: { limit: -1, sort: 'date_created', filter: { status: { _eq: 'pending' } }, - fields: 'id,api_client.id,api_client.name,firma,vorname,nachname,zeile1,zeile2,zeile3,plz,stadt,land,text,pdf_file,status,date_created', + fields: 'id,api_client.id,api_client.name,firma,vorname,nachname,zeile1,zeile2,zeile3,plz,stadt,land,text,pdf_file,status,versendet,date_created', } }).catch(() => ({ data: { data: [] } })), ]); orders.value = o.data?.data || []; @@ -639,9 +640,24 @@ const Board = defineComponent({ // ── Massenauswahl + Sammel-Druck ───────────────────────────────────────── const istGewaehlt = (id) => auswahl.value.includes(String(id)); - function toggleAuswahl(id) { + // Zuletzt angeklickte Karte – für Shift-Bereichsauswahl im ganzen Board. + let letzteAuswahlId = null; + function toggleAuswahl(id, ev) { id = String(id); + if (ev && (ev.shiftKey || ev.ctrlKey || ev.metaKey) && letzteAuswahlId && letzteAuswahlId !== id) { + const ids = sichtbareOrders().map((o) => String(o.id)); + const a = ids.indexOf(letzteAuswahlId), b = ids.indexOf(id); + if (a !== -1 && b !== -1) { + const [lo, hi] = a < b ? [a, b] : [b, a]; + const set = new Set(auswahl.value); + ids.slice(lo, hi + 1).forEach((x) => set.add(x)); + auswahl.value = [...set]; + letzteAuswahlId = id; + return; + } + } auswahl.value = istGewaehlt(id) ? auswahl.value.filter((x) => x !== id) : [...auswahl.value, id]; + letzteAuswahlId = id; } const auswahlLeeren = () => { auswahl.value = []; }; // Aufträge, die im aktuellen Tab als Karten sichtbar sind (für „Alle auswählen"). @@ -922,34 +938,79 @@ const Board = defineComponent({ } catch (e) { flash('Übernahme fehlgeschlagen.'); } } - // ── Batch-Übermittlungen (alternativer Weg) ────────────────────────────── - // Offene Datensätze je Zugang gruppieren. - const batchGruppen = () => { + // ── Batch-Übermittlungen ───────────────────────────────────────────────── + // Nach Tag gruppieren (neueste zuerst); innerhalb je Datensatz eine Zeile. + const batchGruppenTag = () => { const m = new Map(); for (const s of batch.value) { - const cid = (s.api_client && s.api_client.id != null) ? s.api_client.id : (s.api_client ?? '—'); - const key = String(cid); - if (!m.has(key)) m.set(key, { id: cid, name: (s.api_client && s.api_client.name) || `Zugang ${cid}`, subs: [] }); - m.get(key).subs.push(s); + const tag = String(s.date_created || '').slice(0, 10) || '—'; + if (!m.has(tag)) m.set(tag, []); + m.get(tag).push(s); } - return [...m.values()]; + return [...m.entries()].sort((a, b) => String(b[0]).localeCompare(String(a[0]))); }; - // Sofort bündeln (xlsx + Sammel-PDF + FTP) für EINEN Zugang. - async function batchBuild(clientId, clientName) { - if (batchBusy.value) return; - batchBusy.value = String(clientId); - fehler.value = ''; + // Mehrfachauswahl der Übermittlungen (mit Shift-Bereich). + let letzteBatchId = null; + const batchGewaehlt = (id) => batchAuswahl.value.includes(String(id)); + function batchToggle(id, ev) { + id = String(id); + const alleIds = batch.value.map((s) => String(s.id)); + if (ev && (ev.shiftKey || ev.ctrlKey || ev.metaKey) && letzteBatchId && letzteBatchId !== id) { + const a = alleIds.indexOf(letzteBatchId), b = alleIds.indexOf(id); + if (a !== -1 && b !== -1) { + const [lo, hi] = a < b ? [a, b] : [b, a]; + const set = new Set(batchAuswahl.value); + alleIds.slice(lo, hi + 1).forEach((x) => set.add(x)); + batchAuswahl.value = [...set]; letzteBatchId = id; return; + } + } + batchAuswahl.value = batchGewaehlt(id) ? batchAuswahl.value.filter((x) => x !== id) : [...batchAuswahl.value, id]; + letzteBatchId = id; + } + const batchAuswahlLeeren = () => { batchAuswahl.value = []; }; + + // Ausgewählte in die Produktion übernehmen (PDF→Datei-Auftrag, Text→Brief). + async function batchUebernehmen() { + const ids = [...batchAuswahl.value]; + if (!ids.length || batchBusy.value) return; + batchBusy.value = 'uebernehmen'; fehler.value = ''; try { - const res = await api.post('/skrift-orders/batch-build', { client_id: clientId }); - const bundles = res.data?.bundles || []; - const meins = bundles.find((x) => String(x.client_id) === String(clientId)) || bundles[0] || {}; - if (meins.error) fehler.value = `Bündelung: ${meins.error}`; - else flash(`✓ ${clientName || clientId}: ${meins.count || 0} Übermittlung(en) gebündelt${meins.file ? ` → ${meins.file}.{xlsx,pdf}` : ''}.`); + const res = await api.post('/skrift-orders/batch-uebernehmen', { ids }); + const n = res.data?.created || 0; + const skip = (res.data?.skipped || []).length; + flash(`✓ ${n} Auftrag/Aufträge unter „Neu" angelegt${skip ? ` · ${skip} übersprungen` : ''}.`); + batchAuswahlLeeren(); await laden(); } catch (e) { - fehler.value = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Bündelung fehlgeschlagen.'; + fehler.value = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Übernahme fehlgeschlagen.'; } finally { batchBusy.value = ''; } } + // Ausgewählte als versendet / nicht versendet markieren. + async function batchVersendet(val) { + const ids = [...batchAuswahl.value]; + if (!ids.length || batchBusy.value) return; + batchBusy.value = 'versendet'; fehler.value = ''; + try { + await api.patch('/items/batch_submissions', { keys: ids, data: { versendet: !!val } }); + flash(`${ids.length} als ${val ? 'versendet' : 'nicht versendet'} markiert.`); + await laden(); + } catch (e) { fehler.value = e?.message || 'Konnte nicht gespeichert werden.'; } + finally { batchBusy.value = ''; } + } + // Ausgewählte löschen (mit Rückfrage). + async function batchLoeschen() { + const ids = [...batchAuswahl.value]; + if (!ids.length || batchBusy.value) return; + if (!window.confirm(`${ids.length} Übermittlung(en) endgültig löschen?`)) return; + batchBusy.value = 'loeschen'; fehler.value = ''; + try { + await api.delete('/items/batch_submissions', { data: ids }); + flash(`${ids.length} Übermittlung(en) gelöscht.`); + batchAuswahlLeeren(); + await laden(); + } catch (e) { fehler.value = e?.message || 'Löschen fehlgeschlagen.'; } + finally { batchBusy.value = ''; } + } // Text-Übermittlung als Schreibvorlage in den manuellen Druck übernehmen. // 5-Zeilen-Adressblock: Firma / Vorname Nachname / Zeile1-3 / PLZ Stadt / Land (leere weg, max 5). function batchSchreibvorlage(sub) { @@ -1243,7 +1304,7 @@ const Board = defineComponent({ const o = order; const kopf = h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;' }, [ h('div', { style: 'display:flex;gap:10px;align-items:flex-start;' }, [ - h('input', { type: 'checkbox', checked: istGewaehlt(o.id), onChange: () => toggleAuswahl(o.id), + h('input', { type: 'checkbox', checked: istGewaehlt(o.id), onClick: (e) => toggleAuswahl(o.id, e), title: 'Für Sammel-Druck auswählen', style: 'width:16px;height:16px;margin-top:3px;cursor:pointer;' }), h('div', {}, [ h('div', { style: 'font-weight:700;font-size:15px;' }, `#${o.order_number}`), @@ -1408,35 +1469,47 @@ const Board = defineComponent({ return rahmen(h('div', { style: 'display:flex;flex-direction:column;gap:2px;' }, treffer.map(zeile))); } - // ── Reiter „Übermittlungen" (Batch, getrennt vom Auftragsboard) ────────── + // ── Reiter „Übermittlungen" (Batch, nach Tag gruppiert, mit Auswahl) ────── function batchInhalt() { - const gruppen = batchGruppen(); - if (!gruppen.length) return subdued('Keine offenen Übermittlungen.'); - return gruppen.map((g) => { - const kopf = h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px;' }, [ - h('div', {}, [ - h('div', { style: 'font-weight:700;font-size:15px;' }, g.name), - subdued(`Zugang #${g.id} · ${g.subs.length} offene Übermittlung(en)`), + const gruppen = batchGruppenTag(); + const auswahlN = batchAuswahl.value.length; + // Aktionsleiste (nur bei Auswahl) – Übernehmen / Versendet / Löschen. + const leiste = auswahlN ? h('div', { style: `display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:${ACCENT};color:#fff;padding:10px 16px;border-radius:8px;margin-bottom:14px;` }, [ + h('span', { style: 'font-weight:600;' }, `${auswahlN} ausgewählt`), + h('button', { type: 'button', onClick: batchUebernehmen, disabled: !!batchBusy.value, style: `cursor:pointer;border:none;background:#fff;color:${ACCENT};border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;font-weight:600;` }, batchBusy.value === 'uebernehmen' ? 'Übernimmt …' : '➡ In Produktion übernehmen'), + h('button', { type: 'button', onClick: () => batchVersendet(true), disabled: !!batchBusy.value, style: 'cursor:pointer;border:1px solid rgba(255,255,255,.6);background:transparent;color:#fff;border-radius:7px;padding:6px 12px;font:inherit;font-size:13px;' }, '✓ Versendet'), + h('button', { type: 'button', onClick: () => batchVersendet(false), disabled: !!batchBusy.value, style: 'cursor:pointer;border:1px solid rgba(255,255,255,.6);background:transparent;color:#fff;border-radius:7px;padding:6px 12px;font:inherit;font-size:13px;' }, '✗ Nicht versendet'), + h('button', { type: 'button', onClick: batchLoeschen, disabled: !!batchBusy.value, style: 'cursor:pointer;border:1px solid rgba(255,255,255,.6);background:transparent;color:#fff;border-radius:7px;padding:6px 12px;font:inherit;font-size:13px;' }, '🗑 Löschen'), + h('button', { type: 'button', onClick: batchAuswahlLeeren, style: 'cursor:pointer;border:none;background:transparent;color:#fff;border-radius:7px;padding:6px 8px;font:inherit;font-size:13px;text-decoration:underline;' }, 'Auswahl aufheben'), + ]) : subdued('Übermittlungen auswählen (Strg/Shift für mehrere), dann übernehmen/markieren/löschen.', 'margin-bottom:12px;'); + + if (!gruppen.length) return [leiste, subdued('Keine offenen Übermittlungen.')]; + + const zeile = (s) => { + const person = [s.firma, [s.vorname, s.nachname].filter(Boolean).join(' ')].filter(Boolean).join(' · ') || '—'; + const ort = [s.plz, s.stadt].filter(Boolean).join(' '); + const istText = !s.pdf_file; + const gew = batchGewaehlt(s.id); + const zugang = (s.api_client && s.api_client.name) || (s.api_client ? `Zugang ${s.api_client.id ?? s.api_client}` : ''); + return h('div', { key: 'bs' + s.id, style: CARD + 'margin-bottom:8px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;' + (gew ? `border-color:${ACCENT};` : '') }, [ + h('input', { type: 'checkbox', checked: gew, onClick: (e) => batchToggle(s.id, e), style: 'width:16px;height:16px;flex-shrink:0;' }), + h('div', { style: 'min-width:0;flex:1;' }, [ + h('div', { style: 'font-weight:600;' }, person), + subdued([ort, s.land].filter(Boolean).join(' · ') || '—'), + ]), + h('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;' }, [ + zugang ? subdued(zugang) : null, + chip(istText ? '✍ Text' : '📄 Druck-PDF'), + s.versendet ? chip('✓ versendet', 'var(--success-25,rgba(46,125,50,.15))') : null, + istText ? btn('✍ Schreibvorlage', () => batchSchreibvorlage(s)) : null, ]), - btn(batchBusy.value === String(g.id) ? 'Bündelt …' : '📤 Jetzt bündeln & hochladen', () => batchBuild(g.id, g.name), 'primary'), ]); - const zeilen = g.subs.map((s) => { - const person = [s.firma, [s.vorname, s.nachname].filter(Boolean).join(' ')].filter(Boolean).join(' · ') || '—'; - const ort = [s.plz, s.stadt].filter(Boolean).join(' '); - const istText = !s.pdf_file; - return h('div', { key: 'bs' + s.id, style: CARD + 'margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;' }, [ - h('div', { style: 'min-width:0;' }, [ - h('div', { style: 'font-weight:600;' }, person), - subdued([ort, s.land].filter(Boolean).join(' · ') || '—'), - ]), - h('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;' }, [ - chip(istText ? '✍ Text' : '📄 Druck-PDF'), - istText ? btn('✍ Schreibvorlage', () => batchSchreibvorlage(s)) : null, - ]), - ]); - }); - return h('div', { style: SECTION }, [kopf, h('div', { style: 'display:flex;flex-direction:column;gap:2px;' }, zeilen)]); - }); + }; + const bloecke = gruppen.map(([tag, subs]) => h('div', { style: SECTION }, [ + h('div', { style: 'font-weight:700;font-size:14px;color:' + ACCENT + ';margin-bottom:10px;' }, `📅 ${tag} · ${subs.length} Übermittlung(en)`), + h('div', { style: 'display:flex;flex-direction:column;gap:2px;' }, subs.map(zeile)), + ])); + return [leiste, ...bloecke]; } function tabInhalt() {