Batch-Uebermittlung (Phase 3): Board-Reiter + Buendeln-Knopf + Schreibvorlage

Produktions-Board: neuer Reiter "Uebermittlungen" (getrennt vom
Auftragsboard). Offene batch_submissions je Zugang gruppiert; Knopf
"Jetzt buendeln & hochladen" pro Zugang loest die Backend-Buendelung
(xlsx + Sammel-PDF + FTP) sofort aus. Text-Uebermittlungen bekommen einen
"Schreibvorlage"-Knopf, der Text + 5-Zeilen-Adressblock (Firma / Vorname
Nachname / Zeile1-3 / PLZ Stadt / Land, max 5) in den manuellen Druck
uebernimmt. skrift-orders: Proxy-Route /batch-build -> Backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-09-28 09:23:40 +02:00
parent 131c48485c
commit 8ad087d007
2 changed files with 114 additions and 1 deletions

View File

@@ -1367,6 +1367,27 @@ const handler = (router, { services, getSchema, logger }) => {
}
});
// ── Batch-Bündelung sofort auslösen (Board-Knopf → Backend) ────────────────
// Proxyt an das Backend, das die offenen batch_submissions dieses Zugangs zu
// xlsx + Sammel-PDF bündelt und per FTP hochlädt. client_id = api_clients-ID.
router.post('/batch-build', async (req, res) => {
const acc = req.accountability;
if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
try {
const r = await fetch(`${BACKEND_URL}/api/batch/build`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
body: JSON.stringify({ client_id: req.body?.client_id ?? null }),
});
const txt = await r.text();
let data = null; try { data = txt ? JSON.parse(txt) : null; } catch { /* egal */ }
if (!r.ok) return res.status(502).json({ error: 'Bündelung fehlgeschlagen.', detail: (data && data.message) || String(txt).slice(0, 300) });
return res.json(data || { ok: true });
} catch (e) {
return res.status(500).json({ error: 'Backend nicht erreichbar.', detail: String(e.message || e).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) => {

View File

@@ -60,6 +60,7 @@ const TABS = [
{ key: 'kuvertierung', label: '✉ Kuvertierung' },
{ key: 'versandfertig', label: '📦 Versandfertig' },
{ key: 'versendet', label: '🚚 Versendet' },
{ key: 'batch', label: '📦 Übermittlungen' },
{ key: 'archiv', label: '🗄 Archiv' },
];
const STATUS_LABEL = {
@@ -192,6 +193,10 @@ const Board = defineComponent({
const muster = ref({ text_du: '', text_sie: '' });
// Benannte Mustertexte (Collection muster_texts) – im Muster-Dialog wählbar.
const musterListe = ref([]);
// Alternativer Übermittlungsweg (batch_submissions) – offene Datensätze,
// getrennt vom normalen Auftragsboard im Reiter „Übermittlungen".
const batch = ref([]);
const batchBusy = 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 });
@@ -387,7 +392,7 @@ const Board = defineComponent({
// leise = Hintergrund-Aktualisierung: kein Spinner, kein Fehlerbanner.
if (!leise) { loading.value = true; fehler.value = ''; }
try {
const [o, r, j, m, t, ms, mtx] = await Promise.all([
const [o, r, j, m, t, ms, mtx, bs] = await Promise.all([
ladeOrders(),
api.get('/items/offer_requests', { params: {
limit: -1, sort: '-date_created',
@@ -399,6 +404,11 @@ const Board = defineComponent({
api.get('/items/format_templates', { params: { limit: -1, fields: 'id,key,name,type,width,height,xpos,ypos,scale,rotation,default_format,search_default' } }).catch(() => api.get('/items/format_templates', { params: { limit: -1, fields: 'id,key,name,type,width,height,xpos,ypos,scale,rotation,default_format' } })),
api.get('/items/muster_settings', { params: { fields: 'text_du,text_sie' } }).catch(() => ({ data: { data: { text_du: '', text_sie: '' } } })),
api.get('/items/muster_texts', { params: { limit: -1, filter: { active: { _eq: true } }, sort: 'sort', fields: 'id,label,text' } }).catch(() => ({ data: { data: [] } })),
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',
} }).catch(() => ({ data: { data: [] } })),
]);
orders.value = o.data?.data || [];
requests.value = r.data?.data || [];
@@ -407,6 +417,7 @@ const Board = defineComponent({
templates.value = t.data?.data || [];
muster.value = ms.data?.data || { text_du: '', text_sie: '' };
musterListe.value = mtx.data?.data || [];
batch.value = bs.data?.data || [];
// Empfänger-Index nur beim vollen Laden neu bauen (nicht bei der 15-s-
// Hintergrund-Aktualisierung) – im Hintergrund, blockiert das Board nicht.
if (!leise) { ladeEmpfIndex(); ladeSendIndex(); ladeChargeNotizen(); }
@@ -531,6 +542,7 @@ const Board = defineComponent({
if (key === 'kuvertierung') return ordersByStatus('kuvertieren').length;
if (key === 'versandfertig') return ordersByStatus('versandfertig').length;
if (key === 'versendet') return versendetAktiv().length;
if (key === 'batch') return batch.value.length;
if (key === 'archiv') return archivListe().length;
return 0;
};
@@ -910,6 +922,54 @@ const Board = defineComponent({
} catch (e) { flash('Übernahme fehlgeschlagen.'); }
}
// ── Batch-Übermittlungen (alternativer Weg) ──────────────────────────────
// Offene Datensätze je Zugang gruppieren.
const batchGruppen = () => {
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);
}
return [...m.values()];
};
// 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 = '';
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}` : ''}.`);
await laden();
} catch (e) {
fehler.value = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Bündelung 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) {
const zeilen = [
sub.firma,
[sub.vorname, sub.nachname].filter(Boolean).join(' '),
sub.zeile1, sub.zeile2, sub.zeile3,
[sub.plz, sub.stadt].filter(Boolean).join(' '),
sub.land,
].map((z) => String(z || '').trim()).filter(Boolean).slice(0, 5);
try {
localStorage.setItem('skrift_neuauftrag_uebernahme', JSON.stringify({
text: sub.text || '', adressModus: '5zeilen',
rows: [{ free_text: zeilen.join('\n'), placeholders: {} }],
}));
const base = window.location.pathname.split('/admin/')[0] + '/admin/';
window.location.href = base + 'skrift-neuauftrag';
} catch (e) { flash('Übernahme fehlgeschlagen.'); }
}
// ── Empfänger-Drawer (mit Dokument-Vorschau je Zeile) ──
// Zerlegt einen Dateinamen in Brief-Nummer und Seite:
// letter_001_p2.svg → { nummer:1, seite:2 }, letter_003.svg → { nummer:3, seite:1 }.
@@ -1348,7 +1408,39 @@ const Board = defineComponent({
return rahmen(h('div', { style: 'display:flex;flex-direction:column;gap:2px;' }, treffer.map(zeile)));
}
// ── Reiter „Übermittlungen" (Batch, getrennt vom Auftragsboard) ──────────
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)`),
]),
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)]);
});
}
function tabInhalt() {
if (tab.value === 'batch') return batchInhalt();
if (tab.value === 'suche') return sucheInhalt();
if (tab.value === 'neu') {
const items = [