Batch-Reiter: FTP-Upload (primär) zurück, Tag+Kunde-Gruppen aufklappbar

- "Per FTP hochladen" als Primär-Aktion (wirkt auf Auswahl, ids an
  /skrift-orders/batch-build -> Backend), "In Produktion übernehmen"
  sekundär; dazu Versendet/Nicht versendet/Löschen.
- Gruppierung Tag → Kunde, beide aufklappbar, Gruppen-Kopf mit Haken
  (wählt alle in der Gruppe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-09-28 10:24:10 +02:00
parent 208d398034
commit c6c682d48f
2 changed files with 90 additions and 18 deletions

View File

@@ -1377,7 +1377,7 @@ const handler = (router, { services, getSchema, logger }) => {
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 }),
body: JSON.stringify({ client_id: req.body?.client_id ?? null, ids: Array.isArray(req.body?.ids) ? req.body.ids : null }),
});
const txt = await r.text();
let data = null; try { data = txt ? JSON.parse(txt) : null; } catch { /* egal */ }

View File

@@ -198,6 +198,7 @@ const Board = defineComponent({
const batch = ref([]);
const batchBusy = ref('');
const batchAuswahl = ref([]);
const batchOffen = ref({}); // Gruppen-Aufklappstatus (Default: aufgeklappt)
// ── 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 });
@@ -985,6 +986,53 @@ const Board = defineComponent({
fehler.value = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Übernahme fehlgeschlagen.';
} finally { batchBusy.value = ''; }
}
// Ausgewählte per FTP hochladen (xlsx + Sammel-Druck-PDF). Nur PDF-Übermittlungen.
async function batchFtp() {
const ids = [...batchAuswahl.value];
if (!ids.length || batchBusy.value) return;
batchBusy.value = 'ftp'; fehler.value = '';
try {
const res = await api.post('/skrift-orders/batch-build', { ids });
const bundles = res.data?.bundles || [];
const errs = bundles.filter((b) => b.error);
const total = bundles.reduce((s, b) => s + (b.count || 0), 0);
const files = bundles.filter((b) => b.file).map((b) => b.file).join(', ');
if (errs.length) fehler.value = `FTP: ${errs.map((e) => e.error).join('; ')}`;
else { flash(`✓ ${total} Übermittlung(en) per FTP hochgeladen${files ? ` → ${files}.{xlsx,pdf}` : ''}.`); batchAuswahlLeeren(); }
await laden();
} catch (e) {
fehler.value = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'FTP-Upload fehlgeschlagen.';
} finally { batchBusy.value = ''; }
}
// Gruppen (Tag/Kunde): Aufklappen + Kopf-Auswahl.
const istOffen = (k) => batchOffen.value[k] !== false;
const toggleOffen = (k) => { batchOffen.value = { ...batchOffen.value, [k]: !istOffen(k) }; };
const batchGruppeGewaehlt = (subs) => subs.length > 0 && subs.every((s) => batchGewaehlt(s.id));
function batchGruppeToggle(subs) {
const alle = batchGruppeGewaehlt(subs);
const set = new Set(batchAuswahl.value);
subs.forEach((s) => (alle ? set.delete(String(s.id)) : set.add(String(s.id))));
batchAuswahl.value = [...set];
}
// Baum: Tag → Kunde → Übermittlungen (neueste Tage zuerst).
const batchBaum = () => {
const tage = new Map();
for (const s of batch.value) {
const tag = String(s.date_created || '').slice(0, 10) || '—';
const cid = (s.api_client && s.api_client.id != null) ? s.api_client.id : (s.api_client ?? '—');
const cname = (s.api_client && s.api_client.name) || `Zugang ${cid}`;
if (!tage.has(tag)) tage.set(tag, new Map());
const kunden = tage.get(tag);
const ck = String(cid);
if (!kunden.has(ck)) kunden.set(ck, { id: cid, name: cname, subs: [] });
kunden.get(ck).subs.push(s);
}
return [...tage.entries()].sort((a, b) => String(b[0]).localeCompare(String(a[0])))
.map(([tag, kunden]) => {
const ks = [...kunden.values()];
return { tag, subsAll: ks.flatMap((k) => k.subs), kunden: ks };
});
};
// Ausgewählte als versendet / nicht versendet markieren.
async function batchVersendet(val) {
const ids = [...batchAuswahl.value];
@@ -1469,46 +1517,70 @@ const Board = defineComponent({
return rahmen(h('div', { style: 'display:flex;flex-direction:column;gap:2px;' }, treffer.map(zeile)));
}
// ── Reiter „Übermittlungen" (Batch, nach Tag gruppiert, mit Auswahl) ──────
// ── Reiter „Übermittlungen" (Batch, Tag → Kunde, aufklappbar, Auswahl) ────
function batchInhalt() {
const gruppen = batchGruppenTag();
const baum = batchBaum();
const auswahlN = batchAuswahl.value.length;
// Aktionsleiste (nur bei Auswahl) – Übernehmen / Versendet / Löschen.
// Aktionsleiste (nur bei Auswahl): FTP primär, Übernehmen sekundär.
const wbtn = (label, on, primary) => h('button', { type: 'button', onClick: on, disabled: !!batchBusy.value,
style: 'cursor:pointer;border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;font-weight:600;'
+ (primary ? `border:none;background:#fff;color:${ACCENT};` : 'border:1px solid rgba(255,255,255,.6);background:transparent;color:#fff;') }, label);
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;');
wbtn(batchBusy.value === 'ftp' ? 'Lädt hoch …' : '📤 Per FTP hochladen', batchFtp, true),
wbtn(batchBusy.value === 'uebernehmen' ? 'Übernimmt …' : '➡ In Produktion übernehmen', batchUebernehmen, false),
wbtn('✓ Versendet', () => batchVersendet(true), false),
wbtn('✗ Nicht versendet', () => batchVersendet(false), false),
wbtn('🗑 Löschen', batchLoeschen, false),
h('button', { type: 'button', onClick: batchAuswahlLeeren, style: 'cursor:pointer;border:none;background:transparent;color:#fff;font:inherit;font-size:13px;text-decoration:underline;' }, 'Auswahl aufheben'),
]) : subdued('Übermittlungen auswählen (Strg/Shift für mehrere, oder Gruppen-Haken), dann per FTP hochladen / übernehmen / markieren / löschen.', 'margin-bottom:12px;');
if (!gruppen.length) return [leiste, subdued('Keine offenen Übermittlungen.')];
if (!baum.length) return [leiste, subdued('Keine offenen Übermittlungen.')];
const pfeil = (offen) => h('span', { style: 'display:inline-block;width:14px;font-size:12px;color:var(--foreground-subdued);' }, offen ? '▾' : '▸');
const gruppenHaken = (subs) => h('input', { type: 'checkbox', checked: batchGruppeGewaehlt(subs), onClick: (e) => { e.stopPropagation(); batchGruppeToggle(subs); }, style: 'width:16px;height:16px;flex-shrink:0;' });
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};` : '') }, [
return h('div', { key: 'bs' + s.id, style: CARD + 'margin:6px 0 6px 26px;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,
]),
]);
};
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)),
]));
const bloecke = baum.map((tg) => {
const tagKey = 'd:' + tg.tag;
const tagOffen = istOffen(tagKey);
const tagKopf = h('div', { style: 'display:flex;align-items:center;gap:10px;margin-bottom:8px;' }, [
gruppenHaken(tg.subsAll),
h('div', { style: 'display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:700;font-size:15px;color:' + ACCENT + ';', onClick: () => toggleOffen(tagKey) }, [
pfeil(tagOffen), h('span', {}, `📅 ${tg.tag}`), subdued(`· ${tg.subsAll.length} Übermittlung(en)`),
]),
]);
const kundenBloecke = tagOffen ? tg.kunden.map((k) => {
const kKey = `c:${tg.tag}:${k.id}`;
const kOffen = istOffen(kKey);
const kKopf = h('div', { style: 'display:flex;align-items:center;gap:10px;margin:10px 0 4px 20px;' }, [
gruppenHaken(k.subs),
h('div', { style: 'display:flex;align-items:center;gap:6px;cursor:pointer;font-weight:600;', onClick: () => toggleOffen(kKey) }, [
pfeil(kOffen), h('span', {}, `👤 ${k.name}`), subdued(`· ${k.subs.length}`),
]),
]);
return h('div', {}, [kKopf, kOffen ? h('div', {}, k.subs.map(zeile)) : null]);
}) : null;
return h('div', { style: SECTION }, [tagKopf, kundenBloecke]);
});
return [leiste, ...bloecke];
}