From 4aa3fd04e6f99cb389f18d02804cc44460bb3d32 Mon Sep 17 00:00:00 2001 From: s4luorth Date: Fri, 11 Sep 2026 14:16:07 +0200 Subject: [PATCH] Statistik-Modul, Kontakt-Bulk, Notiz-Fix, Ausland-Erkennung, Weiteres-Kontakt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../dist/index.js | 18 +- .../dist/index.js | 61 ++++++- .../dist/index.js | 12 +- .../dist/index.js | 96 +++++------ .../dist/index.js | 161 ++++++++++++++++++ .../package.json | 13 ++ 6 files changed, 299 insertions(+), 62 deletions(-) create mode 100644 extensions/directus-extension-skrift-statistik/dist/index.js create mode 100644 extensions/directus-extension-skrift-statistik/package.json diff --git a/extensions/directus-extension-skrift-contacts/dist/index.js b/extensions/directus-extension-skrift-contacts/dist/index.js index fd337be..6be434d 100644 --- a/extensions/directus-extension-skrift-contacts/dist/index.js +++ b/extensions/directus-extension-skrift-contacts/dist/index.js @@ -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`); } + // 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 }) => { - 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 || []) { - try { await saveContactsForOrder(id); } - catch (e) { logger.warn(`[skrift-contacts] speichern ${id}: ${e.message}`); } + try { + if (payload.keep_contacts === true) await saveContactsForOrder(id); + else await deleteContactsForOrder(id); + } catch (e) { logger.warn(`[skrift-contacts] Kontakte ${id}: ${e.message}`); } } }); diff --git a/extensions/directus-extension-skrift-kontakte/dist/index.js b/extensions/directus-extension-skrift-kontakte/dist/index.js index ed98c0f..4d789e1 100644 --- a/extensions/directus-extension-skrift-kontakte/dist/index.js +++ b/extensions/directus-extension-skrift-kontakte/dist/index.js @@ -212,13 +212,46 @@ const Board = defineComponent({ 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 ── function karte(k) { return h('div', { 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)), subdued(adresse(k) || '—', 'font-size:13px;'), 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;' }, [ - 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;' }, [ btn('↻ Aktualisieren', laden), 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 …') : gefiltert.value.length ? h('div', {}, gefiltert.value.map(karte)) : 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;'); - return [kopf, liste, hinweis]; + return [kopf, bulk, liste, hinweis].filter(Boolean); } // ── Detail ── diff --git a/extensions/directus-extension-skrift-orders/dist/index.js b/extensions/directus-extension-skrift-orders/dist/index.js index 3f7afa1..72660e1 100644 --- a/extensions/directus-extension-skrift-orders/dist/index.js +++ b/extensions/directus-extension-skrift-orders/dist/index.js @@ -160,13 +160,21 @@ async function paypalToken() { * Deutschland, wenn: Land = DE/leer, „Deutschland"/„Germany" im Freitext, * „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) { 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 || ''); if (/deutschland|germany/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; } diff --git a/extensions/directus-extension-skrift-produktion/dist/index.js b/extensions/directus-extension-skrift-produktion/dist/index.js index 656a83a..9054aba 100644 --- a/extensions/directus-extension-skrift-produktion/dist/index.js +++ b/extensions/directus-extension-skrift-produktion/dist/index.js @@ -60,7 +60,6 @@ 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', @@ -116,13 +115,19 @@ function notizFeld(wert, onSave, ph) { } // 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. +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) { 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 || ''); if (/deutschland|germany/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; } // 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`); } 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) { const val = String(v || ''); if (val === (chargeNotizen.value[key] || '')) return; @@ -351,7 +372,11 @@ const Board = defineComponent({ refreshTimer = setInterval(() => { // Bei offenem Dialog oder im Hintergrund-Tab nicht aktualisieren. 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); }, 15000); }); @@ -389,25 +414,6 @@ const Board = defineComponent({ 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: [] }; @@ -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);', 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;' }, [ btn(o.keep_contacts ? '📇 In Kontakte gespeichert ✓' : '📇 In Kontakte speichern', () => toggleKeepContacts(o), o.keep_contacts ? 'primary' : 'default'), @@ -1115,30 +1132,6 @@ 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); @@ -1343,13 +1336,10 @@ const Board = defineComponent({ vorschauBox('📄 Schriftstück', empf.pl), vorschauBox('✉ Kuvert', empf.pe), ]) : 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;' }, [ - subdued('Neu generieren erzeugt Schriftstück + Kuvert frisch.'), - btn('🔄 Neu generieren', () => regenerieren(empf.orderObj?.id), 'danger'), - btn('✍ In manuellen Druck übernehmen', () => uebernehmenManuell(), 'primary'), - ]) : null; + // „Neu generieren" / „In manuellen Druck übernehmen" liegen jetzt auf der + // Auftragskarte unter „⚙ Weiteres" – nicht mehr hier im Vorschau-Dialog. 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. function lightboxModal() { diff --git a/extensions/directus-extension-skrift-statistik/dist/index.js b/extensions/directus-extension-skrift-statistik/dist/index.js new file mode 100644 index 0000000..399eb37 --- /dev/null +++ b/extensions/directus-extension-skrift-statistik/dist/index.js @@ -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 }], +}; diff --git a/extensions/directus-extension-skrift-statistik/package.json b/extensions/directus-extension-skrift-statistik/package.json new file mode 100644 index 0000000..8d02703 --- /dev/null +++ b/extensions/directus-extension-skrift-statistik/package.json @@ -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" + } +}