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:
@@ -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}`); }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 ──
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
161
extensions/directus-extension-skrift-statistik/dist/index.js
vendored
Normal file
161
extensions/directus-extension-skrift-statistik/dist/index.js
vendored
Normal 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 }],
|
||||||
|
};
|
||||||
13
extensions/directus-extension-skrift-statistik/package.json
Normal file
13
extensions/directus-extension-skrift-statistik/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user