- 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>
162 lines
8.6 KiB
JavaScript
162 lines
8.6 KiB
JavaScript
/**
|
||
* 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 }],
|
||
};
|