Statistik-Tab, Unterschriftenservice-Kuverts, Auftragsverknüpfung & Notizen

- Produktion: Statistik-Tab (versendete Briefe je Kunde/Monat), verknüpfter
  Auftrag je Karte anzeigen/ändern/entfernen, Datei-Aufträge werden beim
  Übernehmen wieder in den Signatur-Tab überführt.
- Unterschriftenservice (neuauftrag): optionale Kuverts (je Empfänger ein
  Umschlag, Absender möglich) und Verknüpfung mit vorhandenem Auftrag.
- orders: signature-order erzeugt Kuverts über die Standard-Pipeline
  (Datei-Produkt -> nur Umschläge, Seiten bleiben erhalten) und speichert
  linked_order; nachträgliches Verknüpfen im Board.
- bootstrap: orders.linked_order (Self-Relation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-09-07 08:27:44 +02:00
parent 2b0c0028ed
commit ae7ef2422e
4 changed files with 188 additions and 12 deletions

View File

@@ -311,6 +311,7 @@ const collections = [
f('payment_ref', 'string', { interface: 'input' }),
f('artifact_path', 'string', { interface: 'input', note: 'Verweis auf Backend-Output.' }),
f('internal_note', 'text', { interface: 'input-multiline', note: 'Interne Notiz zum Auftrag (Produktion).' }),
m2o('linked_order', 'orders', { onDelete: 'SET NULL', note: 'Verknüpfter Auftrag (z. B. Kuverts zum Unterschriftenservice). Nachträglich änderbar.' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
// Produktionsansicht: durchsuchbare Empfängertabelle (Briefnummer + volle

View File

@@ -59,6 +59,11 @@ const NeuAuftrag = defineComponent({
modus: 'auftrag', // 'auftrag' (aus Auftrag laden) oder 'manuell' (direkt hochladen)
orderNr: '', order: null, docs: [], docId: null, docName: '', signaturen: [],
customerEmail: '', laden: false, pdf: '', msg: '', err: '',
// Kuverts zum Unterschriftenservice (wie manueller Druck): je Empfänger ein Umschlag.
kuverts: false, kuvertFormat: 'dinlang', empfBlocks: '',
absenderAuf: false, absender: '', absenderScale: 100,
// Verknüpfung mit vorhandenem Auftrag (dessen Kuverts genutzt werden).
linkNr: '',
});
// Datei nach Directus hochladen (liefert die Datei-ID).
async function sigUpload(file) {
@@ -118,7 +123,13 @@ const NeuAuftrag = defineComponent({
if (raw) {
localStorage.removeItem('skrift_neuauftrag_uebernahme');
const d = JSON.parse(raw);
if (d && typeof d === 'object') {
if (d && d.zielSignatur) {
// Datei-/Unterschriftenservice-Auftrag → Signatur-Tab, per Nr. laden.
ansicht.value = 'signatur';
sig.modus = 'auftrag';
sig.orderNr = String(d.orderNr || '');
if (sig.orderNr) { sigLaden().catch(() => {}); }
} else if (d && typeof d === 'object') {
if (d.product) neu.product = d.product;
if (d.format) neu.format = d.format;
if (d.font) neu.font = d.font;
@@ -757,10 +768,20 @@ const NeuAuftrag = defineComponent({
async function sigAuftrag() {
if (!sig.docId) { sig.err = 'Bitte ein Dokument wählen/hochladen.'; return; }
if (!sigAktive().length) { sig.err = 'Mindestens eine Unterschrift aktivieren.'; return; }
if (sig.kuverts && !sig.empfBlocks.trim()) { sig.err = 'Kuverts gewählt, aber keine Empfänger angegeben.'; return; }
sig.err = ''; sig.msg = 'Auftrag wird angelegt …';
try {
const res = await api.post('/skrift-orders/signature-order', { ...sigPayload(), customer_email: sig.customerEmail || undefined });
sig.msg = `✔ Auftrag ${res.data.order_number} angelegt (${res.data.pages} Seite/n). Im Produktions-Board unter „Neu" → Drucken.`;
const res = await api.post('/skrift-orders/signature-order', {
...sigPayload(), customer_email: sig.customerEmail || undefined,
kuverts: sig.kuverts,
recipients_text: sig.kuverts ? sig.empfBlocks : undefined,
envelope_format: sig.kuverts ? sig.kuvertFormat : undefined,
envelope_sender: sig.kuverts && sig.absenderAuf ? sig.absender.trim() : undefined,
envelope_sender_scale: (Number(sig.absenderScale) || 100) / 100,
link_order_number: sig.linkNr.trim() || undefined,
});
const kt = res.data.kuverts ? `, ${res.data.kuverts} Kuvert/s` : '';
sig.msg = `✔ Auftrag ${res.data.order_number} angelegt (${res.data.pages} Seite/n${kt}). Im Produktions-Board unter „Neu" → Drucken.`;
} catch (e) { sig.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Auftrag konnte nicht angelegt werden.'; sig.msg = ''; }
}
@@ -835,11 +856,44 @@ const NeuAuftrag = defineComponent({
btn('⤓ A4-Signaturen (ZIP)', sigDownload),
]),
]);
// ── Kuverts & Verknüpfung ──────────────────────────────────────────────
const b3 = h('div', { style: BEREICH }, [
bereichKopf('✉ Kuverts & Verknüpfung'),
subdued('Optional: adressierte Kuverts zum Unterschriftenservice erzeugen (wie beim manuellen Druck) – oder den Auftrag mit einem vorhandenen Auftrag verknüpfen, dessen Kuverts genutzt werden.', 'margin-bottom:10px;'),
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;margin-bottom:8px;' }, [
h('input', { type: 'checkbox', checked: sig.kuverts, onChange: (e) => { sig.kuverts = e.target.checked; }, style: 'width:16px;height:16px;' }),
'Kuverts mitdrucken (je Empfänger ein Umschlag)',
]),
sig.kuverts ? h('div', { style: 'padding-left:24px;display:flex;flex-direction:column;gap:12px;margin-bottom:12px;' }, [
h('div', { style: 'max-width:240px;' }, [label('Kuvertformat'), sel(sig.kuvertFormat, [{ v: 'dinlang', t: 'DIN Lang' }, { v: 'c6', t: 'C6' }], (e) => { sig.kuvertFormat = e.target.value; })]),
h('div', {}, [
label('Empfänger (ein Block je Empfänger, durch Leerzeile getrennt)'),
h('textarea', { value: sig.empfBlocks, rows: 6, placeholder: 'Max Mustermann\nMusterstr. 1\n12345 Musterstadt\n\nErika Beispiel\nBeispielweg 2\n54321 Beispielort',
style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { sig.empfBlocks = e.target.value; } }),
]),
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;' }, [
h('input', { type: 'checkbox', checked: sig.absenderAuf, onChange: (e) => { sig.absenderAuf = e.target.checked; }, style: 'width:16px;height:16px;' }),
'Absender aufs Kuvert',
]),
sig.absenderAuf ? h('div', { style: 'display:flex;gap:12px;flex-wrap:wrap;' }, [
h('div', { style: 'flex:1;min-width:200px;' }, [label('Absender (max. 5 Zeilen)'),
h('textarea', { value: sig.absender, rows: 5, style: WFELD + 'width:100%;',
onInput: (e) => { sig.absender = e.target.value.split('\n').slice(0, 5).join('\n'); } })]),
h('div', { style: 'width:120px;' }, [label('Größe (%)'),
h('input', { type: 'number', step: 5, min: 50, max: 200, value: sig.absenderScale, style: WFELD + 'width:100%;', onInput: (e) => { sig.absenderScale = e.target.value; } })]),
]) : null,
]) : null,
h('div', { style: 'max-width:360px;' }, [
label('Mit vorhandenem Auftrag verknüpfen (Nr., optional)'),
h('input', { value: sig.linkNr, placeholder: 'z. B. 07-09-26-003', style: WFELD + 'width:100%;', onInput: (e) => { sig.linkNr = e.target.value; } }),
subdued('Dessen Kuverts werden für diesen Auftrag genutzt. Änderbar im Produktions-Board.', 'margin-top:4px;'),
]),
]);
const meldung = h('div', {}, [
sig.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;margin-top:8px;' }, sig.err) : null,
sig.msg ? subdued(sig.msg, 'margin-top:8px;') : null,
]);
const links = h('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:16px;' }, [b1, b2, meldung]);
const links = h('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:16px;' }, [b1, b2, b3, meldung]);
const rechts = h('div', { style: BEREICH + 'flex:0 0 420px;position:sticky;top:20px;align-self:flex-start;min-height:200px;' }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:8px;' }, [
bereichKopf('👁 Vorschau'),

View File

@@ -310,6 +310,20 @@ const handler = (router, { services, getSchema, logger }) => {
const pages = (await r.json())?.pages || [];
if (!pages.length) return res.status(422).json({ error: 'Keine Seiten erzeugt.' });
// Optionale Kuverts (wie manueller Druck): je Empfänger ein Umschlag. Empfänger
// als Blöcke (durch Leerzeile getrennt) im Freitext. Absender optional.
const kuvertsAn = !!body.kuverts;
const empf = kuvertsAn
? String(body.recipients_text || '')
.split(/\n\s*\n/).map((s) => s.trim()).filter(Boolean)
.map((free_text) => ({ free_text }))
: [];
if (kuvertsAn && !empf.length) return res.status(400).json({ error: 'Kuverts gewählt, aber keine Empfänger angegeben.' });
const envFormat = ['c6', 'dinlang'].includes(body.envelope_format) ? body.envelope_format : 'dinlang';
const absender = String(body.envelope_sender || '').trim();
const absenderScale = Math.max(0.5, Math.min(2, Number(body.envelope_sender_scale) || 1));
const countAusland = empf.filter((e) => !zielInland(e)).length;
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 naechsteNummer = async () => {
@@ -320,9 +334,14 @@ const handler = (router, { services, getSchema, logger }) => {
customer: customerId, customer_email: customerEmail, product: product.id,
person_type: 'privat', source: 'operator', status: 'in_queue', production_status: 'eingegangen',
payment_status: 'bezahlt', payment_method: 'rechnung', shipping_type: 'sammel',
needs_envelope: false, envelope_labeling: 'keine',
needs_envelope: kuvertsAn, envelope_labeling: kuvertsAn ? 'empfaenger' : 'keine',
envelope_format: kuvertsAn ? envFormat : null,
envelope_sender: kuvertsAn && absender ? absender : null,
envelope_sender_scale: absenderScale,
source_file: pdfId, source_files: [pdfId],
entries_count: pages.length, count_inland: pages.length, count_ausland: 0,
entries_count: kuvertsAn ? empf.length : pages.length,
count_inland: kuvertsAn ? (empf.length - countAusland) : pages.length,
count_ausland: kuvertsAn ? countAusland : 0,
};
let orderId, orderNumber;
for (let v = 0; v < 6; v += 1) {
@@ -332,11 +351,28 @@ const handler = (router, { services, getSchema, logger }) => {
}
const entriesSvc = svc('order_entries');
for (let i = 0; i < pages.length; i += 1) {
await entriesSvc.createOne({ order: orderId, letter_number: i + 1, free_text: `Seite ${i + 1}` });
if (kuvertsAn) {
// Empfänger als Zeilen – daraus erzeugt die Pipeline je einen Umschlag.
for (let i = 0; i < empf.length; i += 1) {
await entriesSvc.createOne({ order: orderId, letter_number: i + 1, free_text: empf[i].free_text });
}
} else {
for (let i = 0; i < pages.length; i += 1) {
await entriesSvc.createOne({ order: orderId, letter_number: i + 1, free_text: `Seite ${i + 1}` });
}
}
await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Unterschriftenservice – manuell angelegt.' }).catch(() => {});
// Verknüpfung mit vorhandenem Auftrag (dessen Kuverts genutzt werden) – optional.
if (body.link_order_number) {
try {
const linked = (await svc('orders').readByQuery({
filter: { order_number: { _eq: String(body.link_order_number).trim() } }, limit: 1, fields: ['id'],
}))?.[0];
if (linked) await svc('orders').updateOne(orderId, { linked_order: linked.id });
} catch (e) { logger.warn(`[skrift-orders] signature-order Verknüpfung: ${e.message}`); }
}
// Seiten-SVGs als Schriftstück in den Auftragsordner schreiben.
const ordner = path.join(OUTPUT_DIR, orderNumber);
fs.mkdirSync(ordner, { recursive: true });
@@ -344,7 +380,16 @@ const handler = (router, { services, getSchema, logger }) => {
fs.writeFileSync(path.join(ordner, `letter_${String(i + 1).padStart(3, '0')}.svg`), p.svg, 'utf8');
});
return res.json({ ok: true, order_number: orderNumber, id: orderId, pages: pages.length });
// Kuverts über die Standard-Pipeline erzeugen (Datei-Produkt → NUR Umschläge;
// die Seiten-SVGs bleiben erhalten, da runGeneration den Ordner nicht leert).
if (kuvertsAn) {
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] signature-order Kuverts nicht gestartet: ${e.message}`));
}
return res.json({ ok: true, order_number: orderNumber, id: orderId, pages: pages.length, kuverts: empf.length });
} catch (err) {
logger.error(`[skrift-orders] signature-order: ${err.stack || err.message}`);
return res.status(500).json({ error: 'Auftrag konnte nicht angelegt werden.', detail: String(err.message || err).slice(0, 400) });

View File

@@ -60,6 +60,7 @@ const TABS = [
{ key: 'versandfertig', label: '📦 Versandfertig' },
{ key: 'versendet', label: '🚚 Versendet' },
{ key: 'archiv', label: '🗄 Archiv' },
{ key: 'statistik', label: '📊 Statistik' },
];
const STATUS_LABEL = {
eingegangen: 'Eingegangen', im_druck: 'Im Druck', kuvertieren: 'Kuvertierung',
@@ -196,7 +197,7 @@ const Board = defineComponent({
+ 'envelope_labeling,shipping_type,shipping_day,keep_contacts,entries_count,count_inland,count_ausland,brief_gedruckt,kuvert_gedruckt,'
+ 'date_updated,date_created,format.key,format.name,product.name,product.input_mode,'
+ 'source,api_client.id,api_client.name,'
+ 'source_file,internal_note,'
+ 'source_file,internal_note,linked_order.id,linked_order.order_number,'
+ 'customer.first_name,customer.last_name,customer.email,customer.phone';
async function ladeOrders() {
try {
@@ -285,6 +286,19 @@ const Board = defineComponent({
try { await api.patch('/items/orders/' + order.id, { internal_note: val }); order.internal_note = val; }
catch { fehler.value = 'Notiz konnte nicht gespeichert werden.'; }
}
// Verknüpften Auftrag (Kuverts) setzen/ändern/entfernen – per Auftragsnummer.
async function setOrderLink(order, nr) {
const num = String(nr || '').trim();
if (num === (order.linked_order?.order_number || '')) return;
try {
if (!num) { await api.patch('/items/orders/' + order.id, { linked_order: null }); order.linked_order = null; flash(`${order.order_number}: Verknüpfung entfernt`); return; }
const ziel = (await api.get('/items/orders', { params: { filter: { order_number: { _eq: num } }, limit: 1, fields: 'id,order_number' } })).data?.data?.[0];
if (!ziel) { fehler.value = `Auftrag ${num} nicht gefunden.`; return; }
await api.patch('/items/orders/' + order.id, { linked_order: ziel.id });
order.linked_order = { id: ziel.id, order_number: ziel.order_number };
flash(`${order.order_number} ↔ ${ziel.order_number} verknüpft`);
} catch { fehler.value = 'Verknüpfung konnte nicht gespeichert werden.'; }
}
async function setChargeNotiz(key, v) {
const val = String(v || '');
if (val === (chargeNotizen.value[key] || '')) return;
@@ -371,6 +385,26 @@ const Board = defineComponent({
|| (o.production_status === 'versendet' && alterTage(o.versandt_am || o.date_updated) > ARCHIV_TAGE);
const versendetAktiv = () => orders.value.filter((o) => o.production_status === 'versendet' && !istArchiviert(o) && trifftSuche(orderHay(o)));
const archivListe = () => orders.value.filter((o) => istArchiviert(o) && trifftSuche(orderHay(o)));
// ── Statistik: versendete Briefe je Kunde und Monat ──────────────────────
const statMonat = ref(new Date().toISOString().slice(0, 7)); // YYYY-MM
const versandMonat = (o) => String(o.versandt_am || o.date_updated || o.date_created || '').slice(0, 7);
const statistik = computed(() => {
const map = new Map();
let gesamt = 0;
for (const o of orders.value) {
if (o.production_status !== 'versendet') continue;
if (versandMonat(o) !== statMonat.value) continue;
const email = o.customer?.email || '';
const key = email || kundeName(o) || '—';
const cur = map.get(key) || { kunde: kundeName(o) || email || '—', email, briefe: 0, auftraege: 0 };
cur.briefe += Number(o.entries_count) || 0;
cur.auftraege += 1;
map.set(key, cur);
gesamt += Number(o.entries_count) || 0;
}
return { zeilen: [...map.values()].sort((a, b) => b.briefe - a.briefe), gesamt };
});
const queuedByMachine = computed(() => {
const groups = {};
for (const m of aktiveMaschinen.value) groups[m.id] = { machine: m, jobs: [] };
@@ -775,8 +809,16 @@ const Board = defineComponent({
if (!o) return;
try {
const full = (await api.get('/items/orders/' + o.id, { params: {
fields: 'text_template,font,multi_page,realistic,font_scale_letter,font_scale_envelope,needs_envelope,envelope_sender,envelope_sender_scale,product.key,format.key',
fields: 'order_number,text_template,font,multi_page,realistic,font_scale_letter,font_scale_envelope,needs_envelope,envelope_sender,envelope_sender_scale,product.key,product.input_mode,format.key',
} })).data?.data || {};
// Datei-Produkt (Unterschriftenservice) → zurück in den Signatur-Tab,
// dort per Auftragsnummer erneut laden (statt normaler manueller Druck).
if (full.product?.input_mode === 'datei') {
localStorage.setItem('skrift_neuauftrag_uebernahme', JSON.stringify({ zielSignatur: true, orderNr: full.order_number || o.order_number || '' }));
const base = window.location.pathname.split('/admin/')[0] + '/admin/';
window.location.href = base + 'skrift-neuauftrag';
return;
}
const ent = (await api.get('/items/order_entries', { params: {
'filter[order][_eq]': o.id, sort: 'letter_number', limit: -1, fields: 'free_text,placeholders,letter_number',
} })).data?.data || [];
@@ -914,6 +956,16 @@ const Board = defineComponent({
chip(o.format?.name || o.format?.key || 'Format?'),
chip(o.payment_status === 'bezahlt' ? '✓ bezahlt' : (o.payment_status === 'storniert' ? 'storniert' : 'offen'),
o.payment_status === 'bezahlt' ? 'var(--success-25,rgba(46,125,50,.15))' : 'var(--warning-25,rgba(230,160,0,.15))'),
o.linked_order?.order_number ? chip('🔗 Kuverts: #' + o.linked_order.order_number, 'var(--primary-25,rgba(30,80,165,.14))') : null,
]);
// Verknüpfter Auftrag (Kuverts zum Unterschriftenservice) – setzen/ändern/entfernen.
const linkFeld = h('div', { style: 'display:flex;align-items:center;gap:8px;margin:2px 0 6px;flex-wrap:wrap;' }, [
subdued('🔗 Verknüpfter Auftrag:', ''),
h('input', {
placeholder: 'Auftragsnr. (leer = keine)', value: o.linked_order?.order_number || '',
style: 'flex:1;min-width:140px;max-width:220px;border:1px solid var(--border-subdued);border-radius:6px;padding:5px 8px;font:inherit;font-size:12px;background:var(--background-page);color:var(--foreground-normal);',
onChange: (e) => setOrderLink(o, e.target.value),
}),
]);
// „Auf einen Blick": welche Nummern wurden schon in den Druck gesendet
// (aus den Jobs, inkl. bereits gedruckter). Kein Status, nur die Eingabe.
@@ -923,7 +975,7 @@ const Board = defineComponent({
send.brief.length ? chip('🖨 Brief gesendet: ' + send.brief.join(' · '), 'var(--primary-25,rgba(30,80,165,.14))') : null,
send.umschlag.length ? chip('✉ Kuvert gesendet: ' + send.umschlag.join(' · '), 'var(--primary-25,rgba(30,80,165,.14))') : null,
]) : null;
return h('div', { key: 'o' + o.id, style: CARD + `margin-bottom:14px;border-left:4px solid ${ACCENT};` }, [kopf, meta, badges, notizFeld(o.internal_note, (v) => setOrderNotiz(o, v), 'Notiz zum Auftrag …'), actionButtons(o)]);
return h('div', { key: 'o' + o.id, style: CARD + `margin-bottom:14px;border-left:4px solid ${ACCENT};` }, [kopf, meta, badges, linkFeld, notizFeld(o.internal_note, (v) => setOrderNotiz(o, v), 'Notiz zum Auftrag …'), actionButtons(o)]);
}
function requestCard(req) {
const istMuster = req.type === 'muster';
@@ -1047,6 +1099,30 @@ const Board = defineComponent({
const list = archivListe();
return list.length ? renderGruppiert(list) : subdued('Archiv leer.');
}
if (tab.value === 'statistik') {
const s = statistik.value;
const kopfzeile = h('div', { style: 'display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:16px;' }, [
h('label', { style: 'font-weight:600;' }, 'Monat:'),
h('input', { type: 'month', value: statMonat.value, style: S_INPUT + 'width:auto;',
onInput: (e) => { statMonat.value = e.target.value || statMonat.value; } }),
chip(`${s.gesamt} versendete Briefe gesamt`, 'var(--primary-25,rgba(30,80,165,.14))'),
]);
if (!s.zeilen.length) return h('div', {}, [kopfzeile, subdued('Keine versendeten Briefe in diesem Monat.')]);
const th = (t) => h('th', { style: 'text-align:left;padding:8px 12px;border-bottom:2px solid var(--border-subdued);font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);' }, t);
const td = (t, extra) => h('td', { style: `padding:8px 12px;border-bottom:1px solid var(--border-subdued);${extra || ''}` }, t);
return h('div', {}, [
kopfzeile,
h('div', { style: CARD + 'overflow-x:auto;' }, [
h('table', { style: 'width:100%;border-collapse:collapse;font-size:14px;' }, [
h('thead', {}, h('tr', {}, [th('Kunde'), th('E-Mail'), th('Aufträge'), th('Briefe versendet')])),
h('tbody', {}, s.zeilen.map((z) => h('tr', {}, [
td(z.kunde, 'font-weight:600;'), td(z.email || '—', 'color:var(--foreground-subdued);'),
td(String(z.auftraege)), td(String(z.briefe), 'font-weight:700;color:' + ACCENT + ';'),
]))),
]),
]),
]);
}
const map = { kuvertierung: 'kuvertieren', versandfertig: 'versandfertig' };
const st = map[tab.value];
const list = ordersByStatus(st);