Statistik-Modul, Kontakt-Bulk, Notiz-Fix, Ausland-Erkennung, Weiteres-Kontakt

- Neues Modul „Statistik": versendete Briefe je Kunde/Monat, getrennt Inland/
  Ausland, Zeilen aufklappbar (Auftragsliste), mit Suche. Raus aus dem Board.
- Produktion: Auto-Refresh pausiert, während ein Feld bearbeitet wird (Notizen
  gingen sonst beim 15-s-Reload/Statuswechsel verloren). „Neu generieren“/
  „In manuellen Druck“ aus dem Empfänger-Dialog entfernt (liegen unter Weiteres).
  Unter Weiteres jetzt auch Kontakt/Kunde verknüpfen (per E-Mail) und mit × lösen
  – ohne Kundenmail.
- Ausland-Erkennung (zielInland) mit Wortgrenzen (\b) + unbekannter Ländertext →
  Inland: „Schweizer Straße“, „Neusass“, „Polenz“ werden nicht mehr als Ausland
  geflaggt (orders + produktion).
- Kontakte-Modul: Mehrfachauswahl → Status ändern / löschen.
- contacts-Hook: Abwahl von „In Kontakte speichern“ löscht die Kontakte wieder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-09-11 14:16:07 +02:00
parent 6ccd4f3c53
commit 4aa3fd04e6
6 changed files with 299 additions and 62 deletions

View File

@@ -114,11 +114,23 @@ export default (register, { services, getSchema, logger }) => {
logger.info(`[skrift-contacts] ${entries?.length || 0} Kontakt(e) aus Auftrag ${order.order_number} gespeichert`); logger.info(`[skrift-contacts] ${entries?.length || 0} Kontakt(e) aus Auftrag ${order.order_number} gespeichert`);
} }
// Kontakte eines Auftrags wieder entfernen (Abwahl von „In Kontakte speichern").
async function deleteContactsForOrder(orderId) {
const schema = await getSchema();
const contacts = svc('contacts', schema);
const raus = await contacts.readByQuery({ filter: { source_order: { _eq: orderId } }, limit: -1, fields: ['id'] });
if (raus?.length) await contacts.deleteMany(raus.map((r) => r.id));
await svc('orders', schema).updateOne(orderId, { contacts_created: false });
logger.info(`[skrift-contacts] ${raus?.length || 0} Kontakt(e) aus Auftrag ${orderId} entfernt (Abwahl)`);
}
register.action('orders.items.update', async ({ keys, payload }) => { register.action('orders.items.update', async ({ keys, payload }) => {
if (payload?.keep_contacts !== true) return; // nur auf ausdrückliche Anforderung if (payload?.keep_contacts === undefined) return; // nur wenn der Haken geändert wird
for (const id of keys || []) { for (const id of keys || []) {
try { await saveContactsForOrder(id); } try {
catch (e) { logger.warn(`[skrift-contacts] speichern ${id}: ${e.message}`); } if (payload.keep_contacts === true) await saveContactsForOrder(id);
else await deleteContactsForOrder(id);
} catch (e) { logger.warn(`[skrift-contacts] Kontakte ${id}: ${e.message}`); }
} }
}); });

View File

@@ -212,13 +212,46 @@ const Board = defineComponent({
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
} }
// ── Mehrfachauswahl (Bulk: Status ändern / löschen) ──
const auswahl = ref(new Set());
const istGewaehlt = (id) => auswahl.value.has(id);
const toggleSel = (id) => { const s = new Set(auswahl.value); s.has(id) ? s.delete(id) : s.add(id); auswahl.value = s; };
const selAlle = () => { auswahl.value = new Set(gefiltert.value.map((k) => k.id)); };
const selKeine = () => { auswahl.value = new Set(); };
async function bulkStatus(status) {
const ids = [...auswahl.value]; if (!ids.length || !status) return;
try {
await api.patch('/items/contacts', { keys: ids, data: { lead_status: status } });
for (const k of kontakte.value) if (auswahl.value.has(k.id)) k.lead_status = status;
meldung.value = `Status für ${ids.length} Kontakt(e) geändert.`; setTimeout(() => { meldung.value = ''; }, 2500);
selKeine();
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Status-Änderung fehlgeschlagen.'; }
}
async function bulkLoeschen() {
const ids = [...auswahl.value]; if (!ids.length) return;
if (typeof window !== 'undefined' && !window.confirm(`${ids.length} Kontakt(e) endgültig löschen?`)) return;
try {
await api.delete('/items/contacts', { data: ids });
const raus = auswahl.value;
kontakte.value = kontakte.value.filter((k) => !raus.has(k.id));
selKeine();
meldung.value = `${ids.length} Kontakt(e) gelöscht.`; setTimeout(() => { meldung.value = ''; }, 2500);
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Löschen fehlgeschlagen.'; }
}
// ── Liste ── // ── Liste ──
function karte(k) { function karte(k) {
return h('div', { return h('div', {
onClick: () => { aktivId.value = k.id; fehler.value = ''; }, onClick: () => { aktivId.value = k.id; fehler.value = ''; },
style: CARD + 'cursor:pointer;margin-bottom:10px;display:flex;justify-content:space-between;align-items:flex-start;gap:12px;', style: CARD + 'cursor:pointer;margin-bottom:10px;display:flex;justify-content:space-between;align-items:flex-start;gap:12px;'
+ (istGewaehlt(k.id) ? 'outline:2px solid var(--primary,#1E50A5);' : ''),
}, [ }, [
h('div', { style: 'min-width:0;' }, [ h('input', {
type: 'checkbox', checked: istGewaehlt(k.id),
onClick: (e) => { e.stopPropagation(); toggleSel(k.id); },
title: 'Auswählen', style: 'width:18px;height:18px;margin-top:3px;cursor:pointer;flex-shrink:0;',
}),
h('div', { style: 'min-width:0;flex:1;' }, [
h('div', { style: 'font-size:16px;font-weight:600;margin-bottom:2px;' }, anzeigeName(k)), h('div', { style: 'font-size:16px;font-weight:600;margin-bottom:2px;' }, anzeigeName(k)),
subdued(adresse(k) || '—', 'font-size:13px;'), subdued(adresse(k) || '—', 'font-size:13px;'),
k.phone ? subdued('📞 ' + k.phone, 'font-size:13px;margin-top:2px;') : null, k.phone ? subdued('📞 ' + k.phone, 'font-size:13px;margin-top:2px;') : null,
@@ -245,18 +278,38 @@ const Board = defineComponent({
]), ]),
]), ]),
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;justify-content:space-between;' }, [ h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;justify-content:space-between;' }, [
subdued(`${gefiltert.value.length} Kontakt(e)`), h('label', { style: 'display:flex;align-items:center;gap:6px;font-size:13px;color:var(--foreground-subdued);cursor:pointer;' }, [
h('input', { type: 'checkbox',
checked: gefiltert.value.length > 0 && auswahl.value.size >= gefiltert.value.length,
onChange: (e) => { e.target.checked ? selAlle() : selKeine(); }, style: 'width:16px;height:16px;cursor:pointer;' }),
`${gefiltert.value.length} Kontakt(e)`,
]),
h('div', { style: 'display:flex;gap:8px;' }, [ h('div', { style: 'display:flex;gap:8px;' }, [
btn('↻ Aktualisieren', laden), btn('↻ Aktualisieren', laden),
btn('⭳ CSV-Export', exportCsv), btn('⭳ CSV-Export', exportCsv),
]), ]),
]), ]),
]); ]);
// Bulk-Leiste, sobald mindestens ein Kontakt ausgewählt ist.
const bulk = auswahl.value.size ? h('div', {
style: 'position:sticky;top:8px;z-index:5;background:var(--primary,#1E50A5);color:#fff;border-radius:10px;padding:10px 14px;margin-bottom:12px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;',
}, [
h('span', { style: 'font-weight:600;' }, `${auswahl.value.size} ausgewählt`),
h('span', {}, 'Status:'),
h('select', {
style: 'border-radius:6px;padding:5px 8px;font:inherit;font-size:13px;border:none;cursor:pointer;', value: '',
onChange: (e) => { if (e.target.value) { bulkStatus(e.target.value); e.target.value = ''; } },
}, [h('option', { value: '' }, 'ändern auf …'), ...LEAD.map((l) => h('option', { value: l.key }, l.label))]),
h('button', { type: 'button', onClick: bulkLoeschen,
style: 'margin-left:auto;cursor:pointer;border:1px solid #fff;background:transparent;color:#fff;border-radius:6px;padding:5px 12px;font:inherit;font-size:13px;font-weight:600;' }, '🗑 Löschen'),
h('button', { type: 'button', onClick: selKeine,
style: 'cursor:pointer;border:none;background:rgba(255,255,255,.2);color:#fff;border-radius:6px;padding:5px 12px;font:inherit;font-size:13px;' }, 'Aufheben'),
]) : null;
const liste = loading.value ? subdued('Lädt …') const liste = loading.value ? subdued('Lädt …')
: gefiltert.value.length ? h('div', {}, gefiltert.value.map(karte)) : gefiltert.value.length ? h('div', {}, gefiltert.value.map(karte))
: subdued('Keine Kontakte. Sobald ein Auftrag versendet wird, erscheinen die Empfänger hier automatisch.'); : subdued('Keine Kontakte. Sobald ein Auftrag versendet wird, erscheinen die Empfänger hier automatisch.');
const hinweis = subdued('CSV-Import: Content → Kontakte → Import (oben rechts).', 'margin-top:16px;font-size:12px;'); const hinweis = subdued('CSV-Import: Content → Kontakte → Import (oben rechts).', 'margin-top:16px;font-size:12px;');
return [kopf, liste, hinweis]; return [kopf, bulk, liste, hinweis].filter(Boolean);
} }
// ── Detail ── // ── Detail ──

View File

@@ -160,13 +160,21 @@ async function paypalToken() {
* Deutschland, wenn: Land = DE/leer, „Deutschland"/„Germany" im Freitext, * Deutschland, wenn: Land = DE/leer, „Deutschland"/„Germany" im Freitext,
* „D-" vor der PLZ, oder nur eine PLZ ohne Auslandshinweis. Sonst → weltweit (INT). * „D-" vor der PLZ, oder nur eine PLZ ohne Auslandshinweis. Sonst → weltweit (INT).
*/ */
// Auslandsländer als GANZE Wörter (\b) – sonst matchte „schweiz" in „Schweizer
// Straße", „usa" in „Neusass", „polen" in „Polenz" usw. und flaggte fälschlich Ausland.
const AUSLAND_RE = /\b(österreich|schweiz|austria|switzerland|frankreich|france|italien|italy|niederlande|netherlands|belgien|belgium|luxemburg|luxembourg|spanien|spain|portugal|usa|vereinigte staaten|united states|kanada|canada|england|großbritannien|grossbritannien|united kingdom|dänemark|denmark|schweden|sweden|norwegen|norway|finnland|finland|polen|poland|tschechien|czech|ungarn|hungary|griechenland|greece|irland|ireland|australien|australia|liechtenstein)\b/i;
function zielInland(e) { function zielInland(e) {
const land = String(e?.country || '').trim(); const land = String(e?.country || '').trim();
if (land) return /^(de|deu|d|deutschland|germany)$/i.test(land); if (land) {
if (/^(de|deu|ger|d|brd|deutschland|germany)$/i.test(land)) return true;
if (/deutschland|germany/i.test(land)) return true;
if (AUSLAND_RE.test(land)) return false;
return true; // unbekannter/leerer Ländertext → NICHT automatisch Ausland
}
const txt = String(e?.free_text || ''); const txt = String(e?.free_text || '');
if (/deutschland|germany/i.test(txt)) return true; if (/deutschland|germany/i.test(txt)) return true;
if (/\bd-\s?\d{4,5}\b/i.test(txt)) return true; if (/\bd-\s?\d{4,5}\b/i.test(txt)) return true;
if (/(österreich|schweiz|austria|switzerland|frankreich|france|italien|italy|niederlande|netherlands|belgien|belgium|luxemburg|luxembourg|spanien|spain|portugal|usa|vereinigte staaten|united states|kanada|canada|england|großbritannien|grossbritannien|united kingdom|dänemark|denmark|schweden|sweden|norwegen|norway|finnland|finland|polen|poland|tschechien|czech|ungarn|hungary|griechenland|greece|irland|ireland|australien|australia|liechtenstein)/i.test(txt)) return false; if (AUSLAND_RE.test(txt)) return false;
return true; return true;
} }

View File

@@ -60,7 +60,6 @@ const TABS = [
{ key: 'versandfertig', label: '📦 Versandfertig' }, { key: 'versandfertig', label: '📦 Versandfertig' },
{ key: 'versendet', label: '🚚 Versendet' }, { key: 'versendet', label: '🚚 Versendet' },
{ key: 'archiv', label: '🗄 Archiv' }, { key: 'archiv', label: '🗄 Archiv' },
{ key: 'statistik', label: '📊 Statistik' },
]; ];
const STATUS_LABEL = { const STATUS_LABEL = {
eingegangen: 'Eingegangen', im_druck: 'Im Druck', kuvertieren: 'Kuvertierung', eingegangen: 'Eingegangen', im_druck: 'Im Druck', kuvertieren: 'Kuvertierung',
@@ -116,13 +115,19 @@ function notizFeld(wert, onSave, ph) {
} }
// Zielland-Erkennung je Empfänger (wie im Backend): Deutschland, wenn Land DE/leer, // Zielland-Erkennung je Empfänger (wie im Backend): Deutschland, wenn Land DE/leer,
// „Deutschland" im Freitext, „D-" vor der PLZ oder nur PLZ ohne Auslandshinweis. // „Deutschland" im Freitext, „D-" vor der PLZ oder nur PLZ ohne Auslandshinweis.
const AUSLAND_RE = /\b(österreich|schweiz|austria|switzerland|frankreich|france|italien|italy|niederlande|netherlands|belgien|belgium|luxemburg|luxembourg|spanien|spain|portugal|usa|vereinigte staaten|united states|kanada|canada|england|großbritannien|grossbritannien|united kingdom|dänemark|denmark|schweden|sweden|norwegen|norway|finnland|finland|polen|poland|tschechien|czech|ungarn|hungary|griechenland|greece|irland|ireland|australien|australia|liechtenstein)\b/i;
function zielInland(e) { function zielInland(e) {
const land = String(e?.country || '').trim(); const land = String(e?.country || '').trim();
if (land) return /^(de|deu|d|deutschland|germany)$/i.test(land); if (land) {
if (/^(de|deu|ger|d|brd|deutschland|germany)$/i.test(land)) return true;
if (/deutschland|germany/i.test(land)) return true;
if (AUSLAND_RE.test(land)) return false;
return true; // unbekannter/leerer Ländertext → NICHT automatisch Ausland
}
const txt = String(e?.free_text || ''); const txt = String(e?.free_text || '');
if (/deutschland|germany/i.test(txt)) return true; if (/deutschland|germany/i.test(txt)) return true;
if (/\bd-\s?\d{4,5}\b/i.test(txt)) return true; if (/\bd-\s?\d{4,5}\b/i.test(txt)) return true;
if (/(österreich|schweiz|austria|switzerland|frankreich|france|italien|italy|niederlande|netherlands|belgien|belgium|luxemburg|luxembourg|spanien|spain|portugal|usa|vereinigte staaten|united states|kanada|canada|england|großbritannien|grossbritannien|united kingdom|dänemark|denmark|schweden|sweden|norwegen|norway|finnland|finland|polen|poland|tschechien|czech|ungarn|hungary|griechenland|greece|irland|ireland|australien|australia|liechtenstein)/i.test(txt)) return false; if (AUSLAND_RE.test(txt)) return false;
return true; return true;
} }
// Segmentierter Umschalter mit sichtbarem Rahmen: Optionen teilen sich die Breite. // Segmentierter Umschalter mit sichtbarem Rahmen: Optionen teilen sich die Breite.
@@ -302,6 +307,22 @@ const Board = defineComponent({
flash(`${order.order_number} ↔ ${ziel.order_number} verknüpft`); flash(`${order.order_number} ↔ ${ziel.order_number} verknüpft`);
} catch { fehler.value = 'Verknüpfung konnte nicht gespeichert werden.'; } } catch { fehler.value = 'Verknüpfung konnte nicht gespeichert werden.'; }
} }
// Kontakt/Kunde am Auftrag verknüpfen (per E-Mail) oder lösen (x). Löst KEINE
// Kundenmail aus (nur „status"-Änderungen tun das, nicht das customer-Feld).
async function setOrderCustomer(order, email) {
const mail = String(email || '').trim().toLowerCase();
try {
if (!mail) {
await api.patch('/items/orders/' + order.id, { customer: null });
order.customer = null; flash(`${order.order_number}: Kontakt gelöst`); return;
}
const u = (await api.get('/users', { params: { filter: { email: { _eq: mail } }, limit: 1, fields: 'id,first_name,last_name,email' } })).data?.data?.[0];
if (!u) { fehler.value = `Kein Kunde mit E-Mail ${mail} gefunden.`; return; }
await api.patch('/items/orders/' + order.id, { customer: u.id, customer_email: u.email });
order.customer = { first_name: u.first_name, last_name: u.last_name, email: u.email };
flash(`${order.order_number} ↔ ${u.email} verknüpft`);
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Kontakt konnte nicht verknüpft werden.'; }
}
async function setChargeNotiz(key, v) { async function setChargeNotiz(key, v) {
const val = String(v || ''); const val = String(v || '');
if (val === (chargeNotizen.value[key] || '')) return; if (val === (chargeNotizen.value[key] || '')) return;
@@ -351,7 +372,11 @@ const Board = defineComponent({
refreshTimer = setInterval(() => { refreshTimer = setInterval(() => {
// Bei offenem Dialog oder im Hintergrund-Tab nicht aktualisieren. // Bei offenem Dialog oder im Hintergrund-Tab nicht aktualisieren.
const dialogOffen = bulk.open || druck.open || manuell.open || empf.open || dateien.open; const dialogOffen = bulk.open || druck.open || manuell.open || empf.open || dateien.open;
if (loading.value || dialogOffen || (typeof document !== 'undefined' && document.hidden)) return; // Nicht neu laden, während ein Feld bearbeitet wird (sonst geht die getippte
// Notiz verloren, weil laden() die Auftragsobjekte ersetzt).
const ae = (typeof document !== 'undefined') ? document.activeElement : null;
const tippt = ae && (ae.tagName === 'TEXTAREA' || ae.tagName === 'INPUT');
if (loading.value || dialogOffen || tippt || (typeof document !== 'undefined' && document.hidden)) return;
laden(true); laden(true);
}, 15000); }, 15000);
}); });
@@ -389,25 +414,6 @@ const Board = defineComponent({
const versendetAktiv = () => orders.value.filter((o) => o.production_status === 'versendet' && !istArchiviert(o) && trifftSuche(orderHay(o))); 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))); 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 queuedByMachine = computed(() => {
const groups = {}; const groups = {};
for (const m of aktiveMaschinen.value) groups[m.id] = { machine: m, jobs: [] }; for (const m of aktiveMaschinen.value) groups[m.id] = { machine: m, jobs: [] };
@@ -935,6 +941,17 @@ const Board = defineComponent({
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);', 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) }), onChange: (e) => setOrderLink(o, e.target.value) }),
]), ]),
// Kontakt/Kunde verknüpfen (per E-Mail) oder mit „×" lösen. Keine Kundenmail.
h('div', { style: 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;' }, [
subdued('👤 Kontakt:', ''),
o.customer ? h('span', { style: 'font-size:12px;' }, `${kundeName(o)}${o.customer.email ? ' · ' + o.customer.email : ''}`) : null,
o.customer
? h('button', { type: 'button', title: 'Kontakt lösen', onClick: () => setOrderCustomer(o, ''),
style: 'cursor:pointer;border:1px solid var(--border-subdued);background:var(--background-page);border-radius:6px;padding:3px 9px;font:inherit;font-size:13px;color:var(--danger,#c62828);' }, '×')
: h('input', { placeholder: 'E-Mail verknüpfen …',
style: 'flex:1;min-width:160px;max-width:240px;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) => setOrderCustomer(o, e.target.value) }),
]),
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [ h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
btn(o.keep_contacts ? '📇 In Kontakte gespeichert ✓' : '📇 In Kontakte speichern', btn(o.keep_contacts ? '📇 In Kontakte gespeichert ✓' : '📇 In Kontakte speichern',
() => toggleKeepContacts(o), o.keep_contacts ? 'primary' : 'default'), () => toggleKeepContacts(o), o.keep_contacts ? 'primary' : 'default'),
@@ -1115,30 +1132,6 @@ const Board = defineComponent({
const list = archivListe(); const list = archivListe();
return list.length ? renderGruppiert(list) : subdued('Archiv leer.'); 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 map = { kuvertierung: 'kuvertieren', versandfertig: 'versandfertig' };
const st = map[tab.value]; const st = map[tab.value];
const list = ordersByStatus(st); const list = ordersByStatus(st);
@@ -1343,13 +1336,10 @@ const Board = defineComponent({
vorschauBox('📄 Schriftstück', empf.pl), vorschauBox('📄 Schriftstück', empf.pl),
vorschauBox('✉ Kuvert', empf.pe), vorschauBox('✉ Kuvert', empf.pe),
]) : null; ]) : null;
const uebernahme = istOrder ? h('div', { style: 'padding:12px 20px;border-bottom:1px solid var(--border-subdued);display:flex;justify-content:flex-end;align-items:center;gap:10px;flex-wrap:wrap;' }, [ // „Neu generieren" / „In manuellen Druck übernehmen" liegen jetzt auf der
subdued('Neu generieren erzeugt Schriftstück + Kuvert frisch.'), // Auftragskarte unter „⚙ Weiteres" – nicht mehr hier im Vorschau-Dialog.
btn('🔄 Neu generieren', () => regenerieren(empf.orderObj?.id), 'danger'),
btn('✍ In manuellen Druck übernehmen', () => uebernehmenManuell(), 'primary'),
]) : null;
const body = h('div', { style: 'padding:16px 20px;display:flex;gap:20px;flex-wrap:wrap;' }, [liste, vorschau]); const body = h('div', { style: 'padding:16px 20px;display:flex;gap:20px;flex-wrap:wrap;' }, [liste, vorschau]);
return overlay([modalKopf(empf.titel, () => { empf.open = false; }), uebernahme, body].filter(Boolean), () => { empf.open = false; }, istOrder ? '980px' : '620px'); return overlay([modalKopf(empf.titel, () => { empf.open = false; }), body].filter(Boolean), () => { empf.open = false; }, istOrder ? '980px' : '620px');
} }
// Große Einzelvorschau (Lightbox) über dem Empfänger-Dialog. // Große Einzelvorschau (Lightbox) über dem Empfänger-Dialog.
function lightboxModal() { function lightboxModal() {

View File

@@ -0,0 +1,161 @@
/**
* Skrift – Modul „Statistik"
* ===========================================================================
* Versendete Briefe je Kunde und Monat, getrennt nach Inland (Deutschland) und
* Ausland (weltweit). Jede Kundenzeile ist aufklappbar und zeigt die einzelnen
* Aufträge. Oben Monatswahl + Suche (Kunde, E-Mail, Auftragsnummer).
*
* Zählt nur Aufträge mit production_status = 'versendet'. Inland/Ausland kommen
* aus count_inland / count_ausland des Auftrags (bei Anlage bestimmt).
*
* Von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
*/
import { defineComponent, ref, computed, onMounted, h, resolveComponent } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const ACCENT = '#1E50A5';
const CARD = 'border:1px solid var(--border-subdued);border-radius:10px;padding:16px 18px;background:var(--background-page,#fff);box-shadow:0 1px 3px rgba(0,0,0,.08);';
const S_INPUT = 'border:1px solid var(--border-normal,#c9d3e0);border-radius:8px;padding:9px 12px;font:inherit;font-size:14px;background:var(--background-page,#fff);color:var(--foreground-normal);outline:none;width:100%;';
const subdued = (t, extra) => h('div', { style: `color:var(--foreground-subdued);font-size:13px;${extra || ''}` }, t);
function chip(text, farbe) {
return h('span', { style: `display:inline-block;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600;background:${farbe || 'var(--background-subdued,#eef2f7)'};color:var(--foreground-normal);` }, text);
}
const kundeName = (c) => [c?.first_name, c?.last_name].filter(Boolean).join(' ') || c?.email || '—';
const Board = defineComponent({
setup() {
const api = useApi();
const orders = ref([]);
const loading = ref(false);
const fehler = ref('');
const monat = ref(new Date().toISOString().slice(0, 7)); // YYYY-MM
const suche = ref('');
const offen = ref(new Set()); // aufgeklappte Kundenzeilen
async function laden() {
loading.value = true; fehler.value = '';
try {
const r = await api.get('/items/orders', {
params: {
limit: -1, sort: ['-versandt_am'],
filter: { production_status: { _eq: 'versendet' } },
fields: ['id', 'order_number', 'entries_count', 'count_inland', 'count_ausland',
'versandt_am', 'date_updated', 'date_created', 'product.name',
'customer.first_name', 'customer.last_name', 'customer.email'],
},
});
orders.value = r.data?.data || [];
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Fehler beim Laden.'; }
finally { loading.value = false; }
}
onMounted(laden);
const versandMonat = (o) => String(o.versandt_am || o.date_updated || o.date_created || '').slice(0, 7);
const inlandVon = (o) => Number(o.count_inland) || 0;
const auslandVon = (o) => Number(o.count_ausland) || 0;
const briefeVon = (o) => (inlandVon(o) + auslandVon(o)) || (Number(o.entries_count) || 0);
const statistik = computed(() => {
const q = suche.value.trim().toLowerCase();
const map = new Map();
let inland = 0, ausland = 0, gesamt = 0;
for (const o of orders.value) {
if (versandMonat(o) !== monat.value) continue;
const email = o.customer?.email || '';
const name = kundeName(o.customer);
const key = email || name || '—';
if (q) {
const hay = [name, email, o.order_number].filter(Boolean).join(' ').toLowerCase();
if (!hay.includes(q)) continue;
}
const cur = map.get(key) || { kunde: name, email, inland: 0, ausland: 0, briefe: 0, orders: [] };
cur.inland += inlandVon(o);
cur.ausland += auslandVon(o);
cur.briefe += briefeVon(o);
cur.orders.push(o);
map.set(key, cur);
inland += inlandVon(o); ausland += auslandVon(o); gesamt += briefeVon(o);
}
const zeilen = [...map.entries()].map(([key, v]) => ({ key, ...v }))
.sort((a, b) => b.briefe - a.briefe);
return { zeilen, inland, ausland, gesamt };
});
const toggle = (key) => { const s = new Set(offen.value); s.has(key) ? s.delete(key) : s.add(key); offen.value = s; };
const th = (t, extra) => h('th', { style: `text-align:${extra || 'left'};padding:9px 12px;border-bottom:2px solid var(--border-subdued);font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);white-space:nowrap;` }, t);
const td = (t, extra) => h('td', { style: `padding:9px 12px;border-bottom:1px solid var(--border-subdued);${extra || ''}` }, t);
function auftragsListe(z) {
const rows = z.orders.slice().sort((a, b) => String(b.versandt_am || '').localeCompare(String(a.versandt_am || '')));
return h('tr', {}, h('td', { colspan: 5, style: 'padding:0 12px 12px;background:var(--background-subdued,#f7f9fc);' }, [
h('table', { style: 'width:100%;border-collapse:collapse;font-size:13px;' }, [
h('tbody', {}, rows.map((o) => h('tr', {}, [
td(`#${o.order_number}`, 'font-weight:600;'),
td(String(o.versandt_am || o.date_updated || '').slice(0, 10) || '—', 'color:var(--foreground-subdued);'),
td(o.product?.name || '—', 'color:var(--foreground-subdued);'),
td(`🇩🇪 ${inlandVon(o)}`, 'text-align:right;'),
td(`🌍 ${auslandVon(o)}`, 'text-align:right;'),
]))),
]),
]));
}
return () => {
const s = statistik.value;
const kopf = 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: monat.value, style: S_INPUT + 'width:auto;',
onInput: (e) => { monat.value = e.target.value || monat.value; } }),
h('input', { type: 'search', value: suche.value, placeholder: '🔎 Kunde, E-Mail, Auftragsnr. …',
style: S_INPUT + 'width:auto;flex:1;min-width:200px;', onInput: (e) => { suche.value = e.target.value; } }),
h('button', { type: 'button', onClick: laden,
style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-subdued);border-radius:7px;padding:8px 14px;font:inherit;font-size:13px;' }, '↻'),
]);
const summen = h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px;' }, [
chip(`🇩🇪 Inland: ${s.inland}`, 'var(--primary-25,rgba(30,80,165,.14))'),
chip(`🌍 Ausland: ${s.ausland}`, 'var(--warning-25,rgba(230,160,0,.15))'),
chip(`Σ Gesamt: ${s.gesamt} Briefe`, 'var(--success-25,rgba(46,125,50,.15))'),
]);
const inhalt = loading.value ? subdued('Lädt …')
: !s.zeilen.length ? subdued('Keine versendeten Briefe in diesem Monat.')
: h('div', { style: CARD + 'overflow-x:auto;padding:0;' }, [
h('table', { style: 'width:100%;border-collapse:collapse;font-size:14px;' }, [
h('thead', {}, h('tr', {}, [th(''), th('Kunde'), th('Inland', 'right'), th('Ausland', 'right'), th('Gesamt', 'right')])),
h('tbody', {}, s.zeilen.flatMap((z) => {
const auf = offen.value.has(z.key);
const zeile = h('tr', {
onClick: () => toggle(z.key),
style: 'cursor:pointer;',
}, [
td(auf ? '▾' : '▸', 'color:var(--foreground-subdued);width:24px;'),
td(h('div', {}, [
h('div', { style: 'font-weight:600;' }, z.kunde),
z.email ? subdued(z.email, 'font-size:12px;') : null,
])),
td(String(z.inland), 'text-align:right;'),
td(String(z.ausland), 'text-align:right;'),
td(String(z.briefe), 'text-align:right;font-weight:700;color:' + ACCENT + ';'),
]);
return auf ? [zeile, auftragsListe(z)] : [zeile];
})),
]),
]);
const seite = h('div', { style: 'max-width:960px;margin:0 auto;padding:24px 20px 60px;' }, [
fehler.value ? h('div', { style: 'background:var(--danger-25,rgba(198,40,40,.12));color:var(--danger,#c62828);padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:14px;' }, fehler.value) : null,
kopf, summen, inhalt,
]);
return h(resolveComponent('private-view'), { title: 'Statistik' }, {
default: () => h('div', { style: 'min-height:100%;background:var(--background-subdued,#f1f4f9);' }, [seite]),
});
};
},
});
export default {
id: 'skrift-statistik',
name: 'Statistik',
icon: 'bar_chart',
routes: [{ path: '', component: Board }],
};

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-statistik",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Modul: Statistik – versendete Briefe je Kunde/Monat, getrennt nach Inland/Ausland, aufklickbar mit Auftragsliste und Suche.",
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}