Kontakt-CRM, Chargen-Sammeldruck, API-Status & Auto-Generierung

Kontakte/CRM (Directus):
- Neue Collections `contacts` (Leads) + `contact_actions` (Rückruf/Angebot/Mail).
- Hook `skrift-contacts`: Kontakte werden nur auf Anforderung angelegt
  (Auftrag "In Kontakte speichern"); Rückruf-Erinnerung (Zeitplan) und
  Angebots-/Mail-an-mich-Mails; Auto-Löschung nur bei Lead-Status
  "kein_interesse" nach 30 Tagen; optionaler DSGVO-Strip (per Env, Dry-Run).
- Modul `skrift-kontakte`: handytaugliches Board (Anruf via groundwire:,
  Notizen mit Autosave, Durchblättern, CSV-Export, Aktionen als Buttons).

API-Chargen & PDF-Sammeldruck:
- Produktions-Board gruppiert API-Aufträge unter "Neu" zu Chargen
  (Kunde + Typ + Versandtag + Eingangstag), aufklappbar, mit "fällig bis"
  (Versandtag der übernächsten Woche).
- Mehrfachauswahl -> Sammel-PDF (Backend `pdf-lib`, /api/charge/merge-pdf,
  Directus-Proxy /skrift-orders/charge/pdf), Kuverts an Plotter und
  Sammel-Status; PDF- und Kuvert-Reihenfolge nach Auftragsnummer (Kollation).
- Mailer: keine Einzelmail je API-Auftrag mehr, stattdessen Tagesübersicht
  (18:00) pro Kunde, nach Versandtag aufgeschlüsselt.

Status-API & Generierung:
- Public-API-Status liest jetzt production_status (Board) statt nur
  kaufmännischem Status; neues Feld `status_since` in der Antwort.
- Auftrag stempelt `production_status_changed_at` bei jedem Statuswechsel.
- Generierung wird IMMER beim Eingang angestoßen (auch Datei-Produkte mit
  handgeschriebenem Kuvert), nicht nur bei letter/postcard.
- Versandtag im Produktionsdashboard sichtbar.

Rechte: Service-Rolle darf directus_files lesen (Backend lädt PDFs für den
Sammeldruck über /assets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-08-21 08:53:08 +02:00
parent c535af5781
commit 57d48409e6
14 changed files with 1035 additions and 33 deletions

View File

@@ -257,6 +257,10 @@ const collections = [
f('signatures_manual', 'json', { interface: 'input-code', note: 'Signatur(en) auf dem Brief bei manuellen Aufträgen: { signatures:{signatur1:svg}, offsets:{signatur1:{x,y,scale}} }.' }), f('signatures_manual', 'json', { interface: 'input-code', note: 'Signatur(en) auf dem Brief bei manuellen Aufträgen: { signatures:{signatur1:svg}, offsets:{signatur1:{x,y,scale}} }.' }),
f('multi_page', 'boolean', { interface: 'boolean', default: false, note: 'Mehrseitiges Schriftstück (Auto-Fluss über mehrere A4-Seiten).' }), f('multi_page', 'boolean', { interface: 'boolean', default: false, note: 'Mehrseitiges Schriftstück (Auto-Fluss über mehrere A4-Seiten).' }),
f('page_count', 'integer', { interface: 'input', note: 'Seiten je Schriftstück (bei mehrseitig; wird bei der Generierung gesetzt).' }), f('page_count', 'integer', { interface: 'input', note: 'Seiten je Schriftstück (bei mehrseitig; wird bei der Generierung gesetzt).' }),
// Kontakt-/Akquise-Steuerung (siehe Collection „contacts").
f('keep_contacts', 'boolean', { interface: 'boolean', default: false, note: 'In Kontakte speichern: legt die Empfänger dieses Auftrags als Kontakte/Leads an (nur auf ausdrückliche Anforderung). Schützt zugleich die Empfängerdaten vor dem DSGVO-Strip.' }),
f('contacts_created', 'boolean', { interface: 'boolean', default: false, hidden: true, note: 'Guard: Kontakte für diesen Auftrag wurden bereits angelegt.' }),
f('entries_stripped', 'boolean', { interface: 'boolean', default: false, hidden: true, note: 'Guard: Empfängerdaten wurden nach Ablauf der Aufbewahrung anonymisiert.' }),
dropdown('source', ['configurator', 'portal', 'operator', 'extern', 'api'], { default: 'configurator', note: '„extern" = andere Plattform (z. B. Etsy); „api" = Public-API-Zugang.' }), dropdown('source', ['configurator', 'portal', 'operator', 'extern', 'api'], { default: 'configurator', note: '„extern" = andere Plattform (z. B. Etsy); „api" = Public-API-Zugang.' }),
dropdown('status', ['entwurf', 'vorbereitet', 'wartet_auf_zahlung', 'bezahlt', 'material_erwartet', dropdown('status', ['entwurf', 'vorbereitet', 'wartet_auf_zahlung', 'bezahlt', 'material_erwartet',
'material_eingegangen', 'in_queue', 'in_produktion', 'gedruckt', 'versendet', 'abgeschlossen', 'storniert'], 'material_eingegangen', 'in_queue', 'in_produktion', 'gedruckt', 'versendet', 'abgeschlossen', 'storniert'],
@@ -268,6 +272,7 @@ const collections = [
f('kuvert_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Umschläge gedruckt (vom Agent gesetzt).' }), f('kuvert_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Umschläge gedruckt (vom Agent gesetzt).' }),
// Zeitpunkt des Versands – Basis fürs Auto-Archiv (nach 14 Tagen). // Zeitpunkt des Versands – Basis fürs Auto-Archiv (nach 14 Tagen).
{ field: 'versandt_am', type: 'timestamp', meta: { interface: 'datetime', note: 'Wird beim Wechsel auf „versendet" gesetzt.' }, schema: { is_nullable: true } }, { field: 'versandt_am', type: 'timestamp', meta: { interface: 'datetime', note: 'Wird beim Wechsel auf „versendet" gesetzt.' }, schema: { is_nullable: true } },
{ field: 'production_status_changed_at', type: 'timestamp', meta: { interface: 'datetime', note: 'Zeitpunkt der letzten Produktionsstatus-Änderung (für „status_since" der Public-API).' }, schema: { is_nullable: true } },
dropdown('shipping_type', ['einzeln', 'sammel'], { default: 'sammel' }), dropdown('shipping_type', ['einzeln', 'sammel'], { default: 'sammel' }),
f('needs_envelope', 'boolean', { interface: 'boolean', default: false }), f('needs_envelope', 'boolean', { interface: 'boolean', default: false }),
dropdown('envelope_labeling', ['empfaenger', 'freitext', 'keine']), dropdown('envelope_labeling', ['empfaenger', 'freitext', 'keine']),
@@ -580,6 +585,45 @@ const collections = [
f('used', 'boolean', { interface: 'boolean', default: false }), f('used', 'boolean', { interface: 'boolean', default: false }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} }, { field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] }, ] },
// Kontakte/Leads – Empfänger gesendeter Schriftstücke. Werden bei Versand
// automatisch angelegt (Hook) und nach 30 Tagen wieder gelöscht, außer der
// Auftrag ist als „Kontakte behalten" (Akquise) markiert. Telefon/E-Mail sind
// manuell zu pflegen (Empfänger liefern sie nicht mit). CSV-Import/-Export
// über die normale Collection-Seite.
{ collection: 'contacts', meta: { icon: 'contacts', note: 'Kontakte/Leads – Empfänger gesendeter Schriftstücke.' }, fields: [
f('salutation', 'string', { interface: 'input' }),
f('first_name', 'string', { interface: 'input' }),
f('last_name', 'string', { interface: 'input' }),
f('company', 'string', { interface: 'input' }),
f('street', 'string', { interface: 'input' }),
f('house_no', 'string', { interface: 'input' }),
f('zip', 'string', { interface: 'input' }),
f('city', 'string', { interface: 'input' }),
f('country', 'string', { interface: 'input' }),
f('email', 'string', { interface: 'input', note: 'Manuell nachtragen.' }),
f('phone', 'string', { interface: 'input', note: 'Manuell nachtragen. Klick im Kontakte-Modul öffnet GroundWire.' }),
f('notes', 'text', { interface: 'input-multiline', note: 'Anruf-/Lead-Notizen.' }),
dropdown('lead_status', ['neu', 'kontaktiert', 'interessiert', 'kein_interesse', 'kunde'], { default: 'neu', note: 'Nur Kontakte mit „kein_interesse" werden nach 30 Tagen automatisch gelöscht; alle anderen bleiben.' }),
m2o('source_order', 'orders', { onDelete: 'SET NULL', note: 'Auftrag, aus dem der Kontakt stammt.' }),
m2o('source_entry', 'order_entries', { onDelete: 'SET NULL', note: 'Empfängerzeile, aus der der Kontakt stammt.' }),
f('sent_at', 'timestamp', { interface: 'datetime', note: 'Versandzeitpunkt – Grundlage für die 30-Tage-Löschung.' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
] },
// Standardaktionen im Kontakte-Modul (Rückruf-Wiedervorlage, Angebot senden).
// „angebot" verschickt sofort die Eckdaten an die interne Adresse; „rueckruf"
// löst am fälligen Tag früh eine Erinnerungs-Mail aus (Hook skrift-contacts).
{ collection: 'contact_actions', meta: { icon: 'task_alt', hidden: true, note: 'Aktionen/Wiedervorlagen je Kontakt (Rückruf, Angebot).' }, fields: [
m2o('contact', 'contacts', { onDelete: 'CASCADE' }),
dropdown('type', ['rueckruf', 'angebot', 'mail'], { default: 'rueckruf' }),
f('due_date', 'timestamp', { interface: 'datetime', note: 'Fälligkeit (nur Rückruf) – Basis der Erinnerungs-Mail.' }),
f('note', 'text', { interface: 'input-multiline', note: 'Notiz zur Aktion (optional).' }),
dropdown('status', ['offen', 'erledigt'], { default: 'offen' }),
f('notified', 'boolean', { interface: 'boolean', default: false, note: 'Erinnerungs-Mail bereits verschickt (verhindert Wiederholung).' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
]; ];
// ── Schema anlegen ────────────────────────────────────────────────────────── // ── Schema anlegen ──────────────────────────────────────────────────────────
@@ -899,7 +943,7 @@ async function applySeed() {
// ── Rollen / Policies / Rechte (Directus 11) ──────────────────────────────── // ── Rollen / Policies / Rechte (Directus 11) ────────────────────────────────
const BIZ = ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', const BIZ = ['products', 'formats', 'motifs', 'price_items', 'pricing_settings',
'vouchers', 'voucher_redemptions', 'orders', 'order_entries', 'order_addons', 'jobs', 'status_history', 'vouchers', 'voucher_redemptions', 'orders', 'order_entries', 'order_addons', 'jobs', 'status_history',
'offer_requests', 'signatures', 'customer_discounts', 'customer_products', 'offer_requests', 'signatures', 'customer_discounts', 'customer_products', 'contacts',
'email_templates', 'email_settings', 'muster_settings', 'muster_texts', 'machines', 'format_templates', 'render_formats', 'test_prints']; 'email_templates', 'email_settings', 'muster_settings', 'muster_texts', 'machines', 'format_templates', 'render_formats', 'test_prints'];
// Kunde = Login-Konto: Aufträge hängen direkt am Nutzer. // Kunde = Login-Konto: Aufträge hängen direkt am Nutzer.
const OWN_ORDER = { customer: { _eq: '$CURRENT_USER' } }; const OWN_ORDER = { customer: { _eq: '$CURRENT_USER' } };
@@ -1028,6 +1072,9 @@ async function applyPermissions() {
// Agent meldet Maschinenzustand (Heartbeat). // Agent meldet Maschinenzustand (Heartbeat).
await ensurePermission(svcPol.id, 'machines', 'update', ['status', 'last_seen']); await ensurePermission(svcPol.id, 'machines', 'update', ['status', 'last_seen']);
await ensurePermission(svcPol.id, 'status_history', 'create'); await ensurePermission(svcPol.id, 'status_history', 'create');
// Hochgeladene Druckdateien lesen (Backend führt PDFs für den Charge-Sammeldruck
// zusammen und lädt sie dafür über /assets).
await ensurePermission(svcPol.id, 'directus_files', 'read');
console.log(' Hinweis: Für Service einen Nutzer in Rolle "Service" anlegen und dessen', console.log(' Hinweis: Für Service einen Nutzer in Rolle "Service" anlegen und dessen',
'statischen Token in .env von Backend/Produktion eintragen.'); 'statischen Token in .env von Backend/Produktion eintragen.');
@@ -1085,7 +1132,7 @@ async function organizeNav() {
}; };
// Hauptebene // Hauptebene
for (const col of ['orders', 'jobs', 'vouchers', 'status_history']) for (const col of ['orders', 'jobs', 'vouchers', 'status_history', 'contacts'])
await patch(col, { group: null, hidden: false }); await patch(col, { group: null, hidden: false });
// Unter „Einstellungen" // Unter „Einstellungen"
for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', 'offer_requests', for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', 'offer_requests',
@@ -1126,6 +1173,7 @@ async function applyDisplays() {
['customer_products', 'product', '{{name}}'], ['customer_products', 'product', '{{name}}'],
['offer_requests', 'customer', '{{first_name}} {{last_name}}'], ['offer_requests', 'customer', '{{first_name}} {{last_name}}'],
['voucher_redemptions', 'voucher', '{{code}}'], ['voucher_redemptions', 'voucher', '{{code}}'],
['contacts', 'source_order', '{{order_number}}'],
]; ];
for (const [collection, field, template] of displays) { for (const [collection, field, template] of displays) {
try { try {

View File

@@ -33,12 +33,17 @@ const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex'
const pad3 = (n) => String(n).padStart(3, '0'); const pad3 = (n) => String(n).padStart(3, '0');
const WOCHENTAGE = ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag']; const WOCHENTAGE = ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'];
/** Internal order status → public status (pending | processing | shipped | cancelled). */ /**
function kundenStatus(status) { * Internal order → public status (pending | processing | shipped | cancelled).
const s = String(status || ''); * Der Betreiber steuert im Produktions-Board `production_status`; der kaufmännische
if (s === 'versendet' || s === 'abgeschlossen') return 'shipped'; * `status` dient als Rückfall. Beide werden berücksichtigt.
if (s === 'in_produktion' || s === 'gedruckt') return 'processing'; */
if (s === 'storniert') return 'cancelled'; function kundenStatus(order) {
const ps = String(order?.production_status || '');
const s = String(order?.status || '');
if (ps === 'storniert' || s === 'storniert') return 'cancelled';
if (ps === 'versendet' || s === 'versendet' || s === 'abgeschlossen') return 'shipped';
if (['im_druck', 'kuvertieren', 'versandfertig'].includes(ps) || ['in_produktion', 'gedruckt'].includes(s)) return 'processing';
return 'pending'; return 'pending';
} }
@@ -155,10 +160,10 @@ const DOCS_HTML = `<!doctype html>
<h2>Retrieve order status</h2> <h2>Retrieve order status</h2>
<div class="card"> <div class="card">
<div class="ep"><span class="m get">GET</span> /v1/orders/{order_number}</div> <div class="ep"><span class="m get">GET</span> /v1/orders/{order_number}</div>
<p>Returns the current status of one of your orders.</p> <p>Returns the current status of one of your orders. <code>status_since</code> is the ISO 8601 timestamp (UTC) since which the order has been in its current status.</p>
<pre>curl https://dev.skrift.de/v1/orders/18-08-26-001 -H "X-Api-Key: &lt;KEY&gt;" <pre>curl https://dev.skrift.de/v1/orders/18-08-26-001 -H "X-Api-Key: &lt;KEY&gt;"
{ "order_number":"18-08-26-001", "status":"processing", "recipients":1 }</pre> { "order_number":"18-08-26-001", "status":"shipped", "status_since":"2026-08-20T09:14:00.000Z", "recipients":1 }</pre>
</div> </div>
<h2>Upload a PDF</h2> <h2>Upload a PDF</h2>
@@ -395,13 +400,14 @@ const handler = (router, { services, getSchema, logger }) => {
await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Über die Public-API angelegt.' }).catch(() => {}); await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Über die Public-API angelegt.' }).catch(() => {});
// Generierung anstoßen (fire-and-forget) – identisch zum manuellen Auftrag. // Generierung IMMER anstoßen (fire-and-forget) – für jedes Produkt. Das
if (['letter', 'postcard', 'envelope'].includes(product.type)) { // Backend entscheidet, was zu erzeugen ist: Schriftstücke bei Handschrift-
// Produkten, Kuverts immer wenn gewünscht; bei Datei-Produkten (z. B.
// Unterschriftenservice) NUR die Kuverts. Ohne zu Erzeugendes = No-Op.
fetch(`${BACKEND_URL}/api/order/from-directus`, { fetch(`${BACKEND_URL}/api/order/from-directus`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN }, method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
body: JSON.stringify({ orderId }), body: JSON.stringify({ orderId }),
}).catch((e) => logger.warn(`[skrift-api] Generierung nicht gestartet: ${e.message}`)); }).catch((e) => logger.warn(`[skrift-api] Generierung nicht gestartet: ${e.message}`));
}
return res.status(201).json({ order_number: orderNumber, status: 'pending' }); return res.status(201).json({ order_number: orderNumber, status: 'pending' });
} catch (err) { } catch (err) {
@@ -419,13 +425,21 @@ const handler = (router, { services, getSchema, logger }) => {
const rows = await new ItemsService('orders', { schema, accountability: null }).readByQuery({ const rows = await new ItemsService('orders', { schema, accountability: null }).readByQuery({
// Nur eigene Aufträge des Zugangs. // Nur eigene Aufträge des Zugangs.
filter: { order_number: { _eq: String(req.params.nummer) }, api_client: { _eq: client.id } }, filter: { order_number: { _eq: String(req.params.nummer) }, api_client: { _eq: client.id } },
limit: 1, fields: ['order_number', 'status', 'entries_count', 'date_created'], limit: 1, fields: ['order_number', 'status', 'production_status', 'production_status_changed_at',
'versandt_am', 'entries_count', 'date_created'],
}); });
const order = rows?.[0]; const order = rows?.[0];
if (!order) return fehler(res, 404, 'Order not found.'); if (!order) return fehler(res, 404, 'Order not found.');
const status = kundenStatus(order);
// Seit wann der Status so ist: Zeitpunkt der letzten Produktionsstatus-
// Änderung; bei „shipped" ersatzweise der Versandzeitpunkt, sonst Anlage.
const statusSince = order.production_status_changed_at
|| (status === 'shipped' ? order.versandt_am : null)
|| order.date_created || null;
return res.json({ return res.json({
order_number: order.order_number, order_number: order.order_number,
status: kundenStatus(order.status), status,
status_since: statusSince,
recipients: order.entries_count ?? null, recipients: order.entries_count ?? null,
created_at: order.date_created ?? null, created_at: order.date_created ?? null,
}); });
@@ -514,7 +528,7 @@ const handler = (router, { services, getSchema, logger }) => {
'/v1/orders/{order_number}': { '/v1/orders/{order_number}': {
get: { get: {
summary: 'Retrieve order status', parameters: [{ name: 'order_number', in: 'path', required: true, schema: { type: 'string' } }], summary: 'Retrieve order status', parameters: [{ name: 'order_number', in: 'path', required: true, schema: { type: 'string' } }],
responses: { 200: { description: 'Order status', content: { 'application/json': { schema: { type: 'object', properties: { order_number: { type: 'string' }, status: { type: 'string', enum: ['pending', 'processing', 'shipped', 'cancelled'] } } } } } } }, responses: { 200: { description: 'Order status', content: { 'application/json': { schema: { type: 'object', properties: { order_number: { type: 'string' }, status: { type: 'string', enum: ['pending', 'processing', 'shipped', 'cancelled'] }, status_since: { type: 'string', format: 'date-time', description: 'ISO 8601 timestamp since which the order has held its current status.' }, recipients: { type: 'integer' } } } } } } },
}, },
}, },
'/v1/files': { '/v1/files': {

View File

@@ -0,0 +1,205 @@
/**
* Skrift – Kontakte-Hook (Anlage auf Anforderung, Aktionen, DSGVO-Purge)
* ---------------------------------------------------------------------------
* Rund um die Collections `contacts` und `contact_actions`:
*
* 1) ANLAGE NUR AUF ANFORDERUNG — Kontakte werden NICHT automatisch bei jedem
* Versand gespeichert. Erst wenn am Auftrag „In Kontakte speichern"
* (orders.keep_contacts) gesetzt wird, entsteht je Empfängerzeile ein
* Kontakt (Guard: orders.contacts_created). Diese Kontakte bleiben dauerhaft.
*
* 2) STANDARDAKTIONEN (contact_actions) —
* • type „angebot" → sofort eine Mail mit den Eckdaten (Name, Adresse,
* E-Mail, Telefon, Notizen) an die interne Adresse (fürs Rechnungsprogramm).
* • type „rueckruf" → am fälligen Tag (Zeitplan) eine Erinnerungs-Mail.
*
* 3) DSGVO-STRIP (optional, per Env) — Empfängerdaten (order_entries) alter,
* versendeter Aufträge OHNE „In Kontakte speichern" werden anonymisiert.
* Standard: nur DRY-RUN (Log). Scharf via SKRIFT_CONTACTS_STRIP_ENTRIES=1.
* Kontakte selbst werden NICHT automatisch gelöscht (nur manuell).
*
* Env:
* SKRIFT_CONTACTS_REMINDER_CRON Cron der Rückruf-Erinnerung (Default „0 7 * * *").
* SKRIFT_CONTACTS_PURGE_CRON Cron des DSGVO-Strip (Default „0 3 * * *").
* SKRIFT_CONTACTS_STRIP_ENTRIES „1"/„true" = Empfängerdaten wirklich löschen.
* SKRIFT_TEAM_EMAIL / EMAIL_FROM Fallback-Empfänger, falls email_settings.team_email leer.
*
* Von Hand als ESM (kein Build-Schritt), analog zu skrift-mailer/skrift-apikey.
*/
const RETENTION_DAYS = 30;
// Alle 15 Minuten prüfen, damit die Erinnerung nahe der gewählten Uhrzeit kommt.
const REMINDER_CRON = process.env.SKRIFT_CONTACTS_REMINDER_CRON || '*/15 * * * *';
const PURGE_CRON = process.env.SKRIFT_CONTACTS_PURGE_CRON || '0 3 * * *';
const STRIP_ENTRIES = /^(1|true|yes|on)$/i.test(String(process.env.SKRIFT_CONTACTS_STRIP_ENTRIES || ''));
const ENTRY_PII = ['salutation', 'first_name', 'last_name', 'street', 'house_no',
'zip', 'city', 'country', 'free_text', 'placeholders', 'text'];
const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
const htmlZuText = (html) => String(html || '')
.replace(/<\s*br\s*\/?>/gi, '\n').replace(/<\/(p|div|tr|h[1-6]|li|table)>/gi, '\n')
.replace(/<[^>]+>/g, '').replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>')
.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
const contactName = (c) => [c?.salutation, c?.first_name, c?.last_name].filter(Boolean).join(' ')
|| c?.company || c?.email || c?.phone || '(ohne Namen)';
// Eckdaten eines Kontakts als HTML-Block (für Angebots-/Erinnerungs-Mails).
function contactHtml(c, extraNote) {
const anschrift = [
c.company ? `<strong>${esc(c.company)}</strong>` : '',
esc([c.salutation, c.first_name, c.last_name].filter(Boolean).join(' ')),
esc([c.street, c.house_no].filter(Boolean).join(' ')),
esc([c.zip, c.city].filter(Boolean).join(' ')),
c.country ? esc(c.country) : '',
].filter(Boolean).join('<br>');
const zeilen = [];
zeilen.push(`<p style="margin:0 0 4px;font-weight:600;">Anschrift</p><p style="margin:0 0 12px;line-height:1.6;">${anschrift || '—'}</p>`);
zeilen.push('<p style="margin:0 0 12px;">'
+ `E-Mail: ${c.email ? esc(c.email) : '—'}<br>Telefon: ${c.phone ? esc(c.phone) : '—'}</p>`);
const notes = [c.notes, extraNote].filter((n) => n && String(n).trim()).join('\n\n');
if (notes) zeilen.push(`<p style="margin:0 0 4px;font-weight:600;">Notizen</p><p style="margin:0;line-height:1.6;white-space:pre-wrap;">${esc(notes)}</p>`);
return zeilen.join('');
}
export default (register, { services, getSchema, logger }) => {
const { ItemsService, MailService } = services;
const svc = (coll, schema) => new ItemsService(coll, { schema, accountability: null });
async function loadSettings(schema) {
try { const rows = await svc('email_settings', schema).readByQuery({ limit: 1 }); return rows?.[0] || {}; }
catch { return {}; }
}
async function sendTeamMail(schema, subject, html) {
const settings = await loadSettings(schema);
const to = settings.team_email || process.env.SKRIFT_TEAM_EMAIL || process.env.EMAIL_FROM;
if (!to) { logger.warn('[skrift-contacts] keine Team-Adresse (email_settings.team_email) – Mail übersprungen'); return; }
const msg = { to, subject, html, text: htmlZuText(html) };
if (settings.sender_email) msg.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
await new MailService({ schema, accountability: null }).send(msg);
logger.info(`[skrift-contacts] Mail „${subject}" an ${to}`);
}
// ── 1) Kontakt-Anlage NUR wenn „In Kontakte speichern" gesetzt wird ─────────
function contactFromEntry(order, e) {
const c = {
salutation: e.salutation || null, first_name: e.first_name || null, last_name: e.last_name || null,
street: e.street || null, house_no: e.house_no || null, zip: e.zip || null, city: e.city || null,
country: e.country || null, lead_status: 'neu',
source_order: order.id, source_entry: e.id, sent_at: order.versandt_am || null,
};
if (!c.first_name && !c.last_name && e.free_text) {
const lines = String(e.free_text).split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
if (lines.length) c.last_name = lines[0];
if (lines.length) c.notes = 'Adresse:\n' + lines.join('\n');
}
return c;
}
async function saveContactsForOrder(orderId) {
const schema = await getSchema();
const orders = svc('orders', schema);
const order = await orders.readOne(orderId, {
fields: ['id', 'order_number', 'versandt_am', 'contacts_created'],
});
if (!order) return;
if (order.contacts_created) return; // schon angelegt → nichts weiter zu tun
const entries = await svc('order_entries', schema).readByQuery({
filter: { order: { _eq: orderId } }, limit: -1,
fields: ['id', 'salutation', 'first_name', 'last_name', 'street', 'house_no', 'zip', 'city', 'country', 'free_text'],
});
if (entries?.length) await svc('contacts', schema).createMany(entries.map((e) => contactFromEntry(order, e)));
await orders.updateOne(orderId, { contacts_created: true });
logger.info(`[skrift-contacts] ${entries?.length || 0} Kontakt(e) aus Auftrag ${order.order_number} gespeichert`);
}
register.action('orders.items.update', async ({ keys, payload }) => {
if (payload?.keep_contacts !== true) return; // nur auf ausdrückliche Anforderung
for (const id of keys || []) {
try { await saveContactsForOrder(id); }
catch (e) { logger.warn(`[skrift-contacts] speichern ${id}: ${e.message}`); }
}
});
// ── 2) Standardaktionen ─────────────────────────────────────────────────────
register.action('contact_actions.items.create', async ({ key, payload }) => {
const typ = payload?.type;
if (typ !== 'angebot' && typ !== 'mail') return; // Rückruf läuft über den Zeitplan
try {
const schema = await getSchema();
const a = await svc('contact_actions', schema).readOne(key, {
fields: ['id', 'note', 'contact.salutation', 'contact.first_name', 'contact.last_name', 'contact.company',
'contact.street', 'contact.house_no', 'contact.zip', 'contact.city', 'contact.country',
'contact.email', 'contact.phone', 'contact.notes'],
});
if (!a?.contact) return;
const name = contactName(a.contact);
const betreff = typ === 'mail' ? `Notiz: ${name}` : `Angebot vorbereiten: ${name}`;
const einleitung = typ === 'mail' ? `Notiz zu <strong>${esc(name)}</strong>:` : `Angebot vorbereiten für <strong>${esc(name)}</strong>:`;
const html = `<p style="margin:0 0 12px;">${einleitung}</p>${contactHtml(a.contact, a.note)}`;
await sendTeamMail(schema, betreff, html);
} catch (e) { logger.warn(`[skrift-contacts] Aktions-Mail (${typ}): ${e.message}`); }
});
register.schedule(REMINDER_CRON, async () => {
try {
const schema = await getSchema();
const nowIso = new Date().toISOString();
const actions = svc('contact_actions', schema);
const due = await actions.readByQuery({
filter: { _and: [{ type: { _eq: 'rueckruf' } }, { status: { _eq: 'offen' } }, { notified: { _eq: false } }, { due_date: { _lte: nowIso } }] },
fields: ['id', 'note', 'due_date', 'contact.salutation', 'contact.first_name', 'contact.last_name', 'contact.company',
'contact.street', 'contact.house_no', 'contact.zip', 'contact.city', 'contact.country', 'contact.email', 'contact.phone', 'contact.notes'],
limit: -1,
});
for (const a of due || []) {
if (!a.contact) { await actions.updateOne(a.id, { notified: true }); continue; }
const name = contactName(a.contact);
const wann = a.due_date ? new Date(a.due_date).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'short', timeStyle: 'short' }) : '';
const html = `<p style="margin:0 0 12px;">Fälliger Rückruf${wann ? ' (' + esc(wann) + ' Uhr)' : ''} bei <strong>${esc(name)}</strong>`
+ `${a.contact.phone ? ' · <strong>' + esc(a.contact.phone) + '</strong>' : ''}:</p>${contactHtml(a.contact, a.note)}`;
try { await sendTeamMail(schema, `Rückruf fällig: ${name}`, html); await actions.updateOne(a.id, { notified: true }); }
catch (e) { logger.warn(`[skrift-contacts] Rückruf-Mail ${a.id}: ${e.message}`); }
}
} catch (e) { logger.warn(`[skrift-contacts] Erinnerungslauf: ${e.message}`); }
});
// ── 3) Täglicher Aufräumlauf ────────────────────────────────────────────────
register.schedule(PURGE_CRON, async () => {
try {
const schema = await getSchema();
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000).toISOString();
// 3a) Kontakte mit „kein Interesse", seit 30 Tagen unverändert → löschen.
// Alle anderen Lead-Status bleiben dauerhaft erhalten.
const contacts = svc('contacts', schema);
const raus = await contacts.readByQuery({
filter: { _and: [{ lead_status: { _eq: 'kein_interesse' } }, { date_updated: { _lt: cutoff } }] },
fields: ['id'], limit: -1,
});
if (raus?.length) { await contacts.deleteMany(raus.map((r) => r.id)); logger.info(`[skrift-contacts] ${raus.length} Kontakt(e) „kein Interesse" (>30 Tage) gelöscht`); }
// 3b) DSGVO-Strip der Empfängerdaten im Auftrag (optional, per Env).
const orders = svc('orders', schema);
const alte = await orders.readByQuery({
filter: { _and: [
{ keep_contacts: { _eq: false } }, { entries_stripped: { _eq: false } },
{ production_status: { _eq: 'versendet' } }, { versandt_am: { _lt: cutoff } },
] },
fields: ['id', 'order_number'], limit: -1,
});
for (const o of alte || []) {
const entries = await svc('order_entries', schema).readByQuery({ filter: { order: { _eq: o.id } }, fields: ['id'], limit: -1 });
if (!STRIP_ENTRIES) {
logger.info(`[skrift-contacts] DSGVO DRY-RUN: würde Empfängerdaten von Auftrag ${o.order_number} (${entries?.length || 0} Zeilen) anonymisieren – scharf via SKRIFT_CONTACTS_STRIP_ENTRIES=1`);
continue;
}
const wipe = Object.fromEntries(ENTRY_PII.map((k) => [k, null]));
if (entries?.length) await svc('order_entries', schema).updateMany(entries.map((r) => r.id), wipe);
await orders.updateOne(o.id, { entries_stripped: true });
logger.info(`[skrift-contacts] DSGVO: Empfängerdaten von Auftrag ${o.order_number} anonymisiert`);
}
} catch (e) { logger.warn(`[skrift-contacts] DSGVO-Lauf: ${e.message}`); }
});
};

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-contacts",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Legt bei Versand automatisch Kontakte je Empfänger an und löscht sie (samt Empfängerdaten im Auftrag) nach 30 Tagen – außer der Auftrag ist als „Kontakte behalten“ markiert.",
"directus:extension": {
"type": "hook",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -0,0 +1,397 @@
/**
* Skrift – Modul „Kontakte" (Mini-CRM)
* ===========================================================================
* Handytaugliches Lead-Board über die Collection `contacts`: alle Empfänger,
* denen ein Schriftstück gesendet wurde. Fokus auf Nachtelefonieren:
*
* • Liste mit Suche + Status-Filter, ein Tipp öffnet den Kontakt.
* • Detail: Name/Adresse, Telefon als GroundWire-Anruf-Button, E-Mail,
* Lead-Status, „Behalten" und ein Notizfeld – alles mit Auto-Speichern.
* • ◀/▶ blättert durch die gefilterte Liste, ohne zurückzuspringen.
* • CSV-Export der (gefilterten) Kontakte. Import über die normale
* Directus-Collection-Seite (Content → Kontakte → Import).
*
* Anruf-Schema fest GroundWire (groundwire://call?number=…). Die Konstante DIAL
* unten ist die einzige Stelle, falls ein anderes Format nötig wird.
*
* Von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
*/
import { defineComponent, ref, reactive, computed, watch, onMounted, h, resolveComponent } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const ACCENT = '#1E50A5';
// GroundWire-Anruf-Link (Acrobits „Call URI"): scheme:NUMMER?dialAction=…
// Die Nummer steht direkt hinter dem Doppelpunkt (kein „//"), ohne Leerzeichen.
// dialAction=voiceCall → sofortiger VoIP-Anruf über GroundWire. Für nur
// Vorbefüllen (Nummer eintippen, selbst tippen) den ?dialAction-Teil weglassen.
const cleanNr = (nr) => String(nr || '').replace(/[^\d+]/g, '');
const DIAL = (nr) => 'groundwire:' + cleanNr(nr) + '?dialAction=voiceCall';
const LEAD = [
{ key: 'neu', label: 'Neu', color: '#6b7280' },
{ key: 'kontaktiert', label: 'Kontaktiert', color: ACCENT },
{ key: 'interessiert', label: 'Interessiert', color: '#e07b00' },
{ key: 'kein_interesse', label: 'Kein Interesse', color: '#c62828' },
{ key: 'kunde', label: 'Kunde', color: '#2e7d32' },
];
const LEAD_LABEL = Object.fromEntries(LEAD.map((l) => [l.key, l.label]));
const LEAD_COLOR = Object.fromEntries(LEAD.map((l) => [l.key, l.color]));
const FIELDS = ['id', 'salutation', 'first_name', 'last_name', 'company', 'street', 'house_no',
'zip', 'city', 'country', 'email', 'phone', 'notes', 'lead_status', 'sent_at',
'source_order.order_number'];
// ── Stil-Helfer ──────────────────────────────────────────────────────────────
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);';
// Klar erkennbares Eingabefeld: kräftigere Umrandung + leicht getönter Grund,
// damit es sich deutlich von der weißen Karte abhebt.
const S_INPUT = 'width:100%;padding:11px 12px;border:1.5px solid var(--border-normal,#c4cdde);border-radius:8px;background:var(--background-subdued,#eef1f7);color:var(--foreground-normal);font:inherit;font-size:15px;box-sizing:border-box;';
const LBL = 'font-size:12px;font-weight:600;color:var(--foreground-subdued);margin:0 0 4px;text-transform:uppercase;letter-spacing:.03em;';
function btn(label, onClick, kind) {
const base = 'cursor:pointer;border-radius:8px;padding:10px 16px;font:inherit;font-size:14px;border:1px solid;';
const style = kind === 'primary'
? base + `border-color:${ACCENT};background:${ACCENT};color:#fff;`
: kind === 'ghost'
? base + 'border-color:transparent;background:transparent;color:var(--foreground-normal);'
: base + 'border-color:var(--border-normal);background:var(--background-subdued);color:var(--foreground-normal);';
return h('button', { type: 'button', onClick, style }, label);
}
function chip(text, farbe) {
return h('span', {
style: 'display:inline-flex;align-items:center;gap:4px;font-size:12px;padding:3px 10px;border-radius:20px;'
+ `background:${farbe || 'var(--background-subdued)'};color:#fff;white-space:nowrap;`,
}, text);
}
function subdued(text, extra) {
return h('div', { style: `color:var(--foreground-subdued);font-size:14px;${extra || ''}` }, text);
}
function feld(label, node) {
return h('div', { style: 'margin-bottom:14px;' }, [h('div', { style: LBL }, label), node]);
}
const anzeigeName = (k) => [k.salutation, k.first_name, k.last_name].filter(Boolean).join(' ')
|| k.company || k.email || k.phone || '(ohne Namen)';
const adresse = (k) => [[k.street, k.house_no].filter(Boolean).join(' '),
[k.zip, k.city].filter(Boolean).join(' '), k.country].filter(Boolean).join(', ');
const Board = defineComponent({
setup() {
const api = useApi();
const kontakte = ref([]);
const loading = ref(false);
const fehler = ref('');
const meldung = ref('');
const suche = ref('');
const statusFilter = ref('');
const aktivId = ref(null);
const savedFlash = ref(null); // Feld-Key, der zuletzt gespeichert wurde
const aktionen = ref([]); // offene/erledigte Aktionen des aktiven Kontakts
const rueckruf = reactive({ open: false, date: '', time: '09:00' });
const mailMich = reactive({ open: false, note: '' });
async function laden() {
loading.value = true; fehler.value = '';
try {
const r = await api.get('/items/contacts', {
params: { limit: -1, sort: ['-sent_at', '-date_created'], fields: FIELDS },
});
kontakte.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 gefiltert = computed(() => {
const q = suche.value.trim().toLowerCase();
return kontakte.value.filter((k) => {
if (statusFilter.value && (k.lead_status || 'neu') !== statusFilter.value) return false;
if (!q) return true;
const hay = [k.salutation, k.first_name, k.last_name, k.company, k.zip, k.city,
k.email, k.phone, k.source_order?.order_number].filter(Boolean).join(' ').toLowerCase();
return hay.includes(q);
});
});
const aktiv = computed(() => kontakte.value.find((k) => k.id === aktivId.value) || null);
const aktivIndex = computed(() => gefiltert.value.findIndex((k) => k.id === aktivId.value));
async function save(id, patch, feldKey) {
try {
await api.patch(`/items/contacts/${id}`, patch);
const k = kontakte.value.find((x) => x.id === id);
if (k) Object.assign(k, patch);
if (feldKey) { savedFlash.value = feldKey; setTimeout(() => { if (savedFlash.value === feldKey) savedFlash.value = null; }, 1500); }
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Speichern fehlgeschlagen.'; }
}
// Speichert nur, wenn sich der Wert wirklich geändert hat (Blur-Handler).
const saveIfChanged = (k, key) => (ev) => {
const val = ev.target.value;
if ((k[key] || '') !== val) save(k.id, { [key]: val || null }, key);
};
// ── Standardaktionen (Rückruf / Angebot) ─────────────────────────────────
async function ladeAktionen(id) {
if (!id) { aktionen.value = []; return; }
try {
const r = await api.get('/items/contact_actions', {
params: { filter: { contact: { _eq: id } }, sort: ['-date_created'], limit: -1,
fields: ['id', 'type', 'due_date', 'note', 'status', 'notified'] },
});
aktionen.value = r.data.data || [];
} catch { aktionen.value = []; }
}
watch(aktivId, (id) => { rueckruf.open = false; rueckruf.date = ''; rueckruf.time = '09:00'; mailMich.open = false; mailMich.note = ''; ladeAktionen(id); });
const isoDatum = (d) => { const x = new Date(); x.setHours(0, 0, 0, 0); x.setDate(x.getDate() + d); return x; };
const ymd = (dt) => `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
async function planeRueckruf(k) {
if (!rueckruf.date) { fehler.value = 'Bitte ein Datum wählen.'; return; }
try {
await api.post('/items/contact_actions', {
contact: k.id, type: 'rueckruf', status: 'offen',
due_date: new Date(rueckruf.date + 'T' + (rueckruf.time || '09:00') + ':00').toISOString(),
});
rueckruf.open = false; rueckruf.date = ''; rueckruf.time = '09:00';
await ladeAktionen(k.id);
meldung.value = 'Rückruf-Erinnerung gesetzt.'; setTimeout(() => { meldung.value = ''; }, 2500);
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Rückruf nicht anlegen.'; }
}
async function sendeAngebot(k) {
try {
await api.post('/items/contact_actions', { contact: k.id, type: 'angebot', status: 'erledigt' });
await ladeAktionen(k.id);
meldung.value = 'Angebot-Eckdaten wurden dir per Mail geschickt.'; setTimeout(() => { meldung.value = ''; }, 3500);
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Angebot nicht senden.'; }
}
// Legt eine Musterbestellung an: erscheint als Musteranfrage in
// Produktion → Neu und wird über den bestehenden „Muster drucken"-Workflow
// gedruckt. Keine Mail an den Kontakt.
async function sendeMuster(k) {
const name = [k.salutation, k.first_name, k.last_name].filter(Boolean).join(' ') || k.company || 'Kontakt';
try {
await api.post('/items/offer_requests', {
type: 'muster', status: 'neu', name, product: 'Muster',
email: k.email || 'muster@skrift.de', phone: k.phone || null,
address: {
company: k.company || '', salutation: k.salutation || '',
first_name: k.first_name || '', last_name: k.last_name || '',
street: [k.street, k.house_no].filter(Boolean).join(' '),
zip: k.zip || '', city: k.city || '', country: k.country || '',
},
});
meldung.value = 'Musterbestellung angelegt – erscheint in Produktion → Neu.'; setTimeout(() => { meldung.value = ''; }, 3500);
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Muster nicht anlegen.'; }
}
// Schickt dir (interne Adresse) die Eckdaten des Kontakts + deine Notiz per Mail.
async function sendeMail(k) {
if (!mailMich.note.trim()) { fehler.value = 'Bitte eine Notiz eingeben.'; return; }
try {
await api.post('/items/contact_actions', { contact: k.id, type: 'mail', status: 'erledigt', note: mailMich.note });
mailMich.open = false; mailMich.note = '';
meldung.value = 'Deine Notiz wurde dir per Mail geschickt.'; setTimeout(() => { meldung.value = ''; }, 3500);
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Mail nicht senden.'; }
}
async function erledige(a, k) {
try { await api.patch(`/items/contact_actions/${a.id}`, { status: 'erledigt' }); await ladeAktionen(k.id); }
catch (e) { fehler.value = e?.message || 'Konnte nicht als erledigt markieren.'; }
}
function exportCsv() {
const rows = gefiltert.value;
const cols = [['salutation', 'Anrede'], ['first_name', 'Vorname'], ['last_name', 'Nachname'],
['company', 'Firma'], ['street', 'Straße'], ['house_no', 'Hausnr'], ['zip', 'PLZ'], ['city', 'Ort'],
['country', 'Land'], ['email', 'E-Mail'], ['phone', 'Telefon'], ['lead_status', 'Status'],
['notes', 'Notizen'], ['sent_at', 'Versendet am']];
const esc = (v) => { const s = v == null ? '' : String(v); return /[",\n;]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; };
const csv = '' + cols.map((c) => c[1]).join(',') + '\n'
+ rows.map((r) => cols.map((c) => esc(r[c[0]])).join(',')).join('\n');
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
const a = document.createElement('a');
a.href = url; a.download = `kontakte-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
}
// ── 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;',
}, [
h('div', { style: 'min-width:0;' }, [
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,
]),
h('div', { style: 'display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex-shrink:0;' }, [
chip(LEAD_LABEL[k.lead_status || 'neu'], LEAD_COLOR[k.lead_status || 'neu']),
]),
]);
}
function listenAnsicht() {
const kopf = h('div', { style: 'display:flex;flex-direction:column;gap:10px;margin-bottom:16px;' }, [
h('input', {
type: 'search', value: suche.value, placeholder: '🔎 Name, Ort, Telefon, E-Mail …',
style: S_INPUT, onInput: (e) => { suche.value = e.target.value; },
}),
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;' }, [
h('select', {
style: S_INPUT + 'width:auto;flex:1;min-width:150px;cursor:pointer;',
onChange: (e) => { statusFilter.value = e.target.value; },
}, [
h('option', { value: '', selected: statusFilter.value === '' }, 'Alle Status'),
...LEAD.map((l) => h('option', { value: l.key, selected: statusFilter.value === l.key }, l.label)),
]),
]),
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;justify-content:space-between;' }, [
subdued(`${gefiltert.value.length} Kontakt(e)`),
h('div', { style: 'display:flex;gap:8px;' }, [
btn('↻ Aktualisieren', laden),
btn('⭳ CSV-Export', exportCsv),
]),
]),
]);
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];
}
// ── Detail ──
function detailAnsicht() {
const k = aktiv.value;
if (!k) { aktivId.value = null; return listenAnsicht(); }
const idx = aktivIndex.value;
const gesamt = gefiltert.value.length;
const geh = (d) => {
const l = gefiltert.value;
let i = aktivIndex.value; if (i < 0) return;
i += d; if (i < 0 || i >= l.length) return;
aktivId.value = l[i].id; fehler.value = '';
};
const flash = (key) => savedFlash.value === key ? h('span', { style: 'color:#2e7d32;font-size:12px;margin-left:8px;' }, '✓ gespeichert') : null;
const navRow = h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:14px;' }, [
btn('◀ Liste', () => { aktivId.value = null; }, 'ghost'),
h('div', { style: 'display:flex;align-items:center;gap:8px;' }, [
h('button', { type: 'button', disabled: idx <= 0, onClick: () => geh(-1),
style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-subdued);border-radius:8px;padding:8px 12px;font-size:16px;' + (idx <= 0 ? 'opacity:.4;cursor:not-allowed;' : '') }, '◀'),
subdued(gesamt ? `${idx + 1} / ${gesamt}` : '–'),
h('button', { type: 'button', disabled: idx >= gesamt - 1, onClick: () => geh(1),
style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-subdued);border-radius:8px;padding:8px 12px;font-size:16px;' + (idx >= gesamt - 1 ? 'opacity:.4;cursor:not-allowed;' : '') }, '▶'),
]),
]);
const kopf = h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:14px;' }, [
h('div', {}, [
h('div', { style: 'font-size:22px;font-weight:700;' }, anzeigeName(k)),
adresse(k) ? subdued(adresse(k), 'margin-top:2px;') : null,
k.source_order?.order_number ? subdued('aus Auftrag ' + k.source_order.order_number, 'font-size:12px;margin-top:2px;') : null,
]),
chip(LEAD_LABEL[k.lead_status || 'neu'], LEAD_COLOR[k.lead_status || 'neu']),
]);
// Anruf-Button (GroundWire) – nur wenn eine Nummer da ist.
const anruf = k.phone
? h('a', { href: DIAL(k.phone),
style: `display:flex;align-items:center;justify-content:center;gap:8px;text-decoration:none;background:#2e7d32;color:#fff;border-radius:10px;padding:14px;font-size:17px;font-weight:600;margin-bottom:14px;` },
[h('span', {}, '📞'), h('span', {}, 'Anrufen · ' + k.phone)])
: subdued('Keine Telefonnummer – unten eintragen, dann erscheint der Anruf-Button.', 'margin-bottom:14px;');
const telFeld = feld(h('span', {}, ['Telefon', flash('phone')]),
h('input', { type: 'tel', value: k.phone || '', placeholder: '+49 …', style: S_INPUT, onBlur: saveIfChanged(k, 'phone') }));
const mailFeld = feld(h('span', {}, ['E-Mail', flash('email')]),
h('div', { style: 'display:flex;gap:8px;align-items:center;' }, [
h('input', { type: 'email', value: k.email || '', placeholder: 'name@…', style: S_INPUT, onBlur: saveIfChanged(k, 'email') }),
k.email ? h('a', { href: 'mailto:' + k.email, style: 'text-decoration:none;font-size:20px;' }, '✉') : null,
]));
const statusFeld = feld('Lead-Status',
h('select', { style: S_INPUT + 'cursor:pointer;', onChange: (e) => save(k.id, { lead_status: e.target.value }, 'lead_status') },
LEAD.map((l) => h('option', { value: l.key, selected: (k.lead_status || 'neu') === l.key }, l.label))));
const notizFeld = feld(h('span', {}, ['Notizen', flash('notes')]),
h('textarea', { value: k.notes || '', rows: 5, placeholder: 'Gesprächsnotizen …',
style: S_INPUT + 'resize:vertical;line-height:1.5;', onBlur: saveIfChanged(k, 'notes') }));
const namensFelder = h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px;' }, [
feld('Vorname', h('input', { value: k.first_name || '', style: S_INPUT, onBlur: saveIfChanged(k, 'first_name') })),
feld('Nachname', h('input', { value: k.last_name || '', style: S_INPUT, onBlur: saveIfChanged(k, 'last_name') })),
feld('Firma', h('input', { value: k.company || '', style: S_INPUT, onBlur: saveIfChanged(k, 'company') })),
feld('Ort', h('input', { value: k.city || '', style: S_INPUT, onBlur: saveIfChanged(k, 'city') })),
]);
// ── Standardaktionen (deutlich als farbige Buttons) ──
const aktBase = 'cursor:pointer;border-radius:8px;padding:11px 15px;font:inherit;font-size:14px;font-weight:600;'
+ 'border:1.5px solid;text-decoration:none;display:inline-flex;align-items:center;gap:6px;';
const aktStil = (rand, grund, text) => aktBase + `border-color:${rand};background:${grund};color:${text};`;
const S_RUECK = aktStil(ACCENT, 'rgba(30,80,165,.10)', ACCENT);
const S_ANGEBOT = aktStil('#e07b00', 'rgba(224,123,0,.12)', '#b35f00');
const S_MUSTER = aktStil('#6b3fa0', 'rgba(107,63,160,.12)', '#5a2f90');
const S_MAIL = aktStil('#0b7285', 'rgba(11,114,133,.12)', '#0b6273');
const aktionsLeiste = h('div', { class: 'sk-akt' }, [
h('button', { type: 'button', style: rueckruf.open ? aktStil(ACCENT, ACCENT, '#fff') : S_RUECK, onClick: () => { rueckruf.open = !rueckruf.open; if (rueckruf.open && !rueckruf.date) rueckruf.date = ymd(isoDatum(1)); } }, '📞 Rückruf planen'),
h('button', { type: 'button', style: S_ANGEBOT, onClick: () => sendeAngebot(k) }, '📧 Angebot senden'),
h('button', { type: 'button', style: S_MUSTER, onClick: () => sendeMuster(k) }, '🧪 Muster senden'),
h('button', { type: 'button', style: mailMich.open ? aktStil('#0b7285', '#0b7285', '#fff') : S_MAIL, onClick: () => { mailMich.open = !mailMich.open; } }, '✉️ Mail an mich'),
]);
const preset = (label, d) => h('button', { type: 'button',
style: 'cursor:pointer;border-radius:20px;padding:6px 12px;font:inherit;font-size:13px;border:1px solid var(--border-normal);'
+ (rueckruf.date === ymd(isoDatum(d)) ? `background:${ACCENT};color:#fff;` : 'background:var(--background-page);color:var(--foreground-normal);'),
onClick: () => { rueckruf.date = ymd(isoDatum(d)); } }, label);
const rueckrufForm = rueckruf.open ? h('div', { style: 'border:1px solid var(--border-normal);border-radius:10px;padding:14px;margin-top:10px;margin-bottom:12px;background:var(--background-subdued);' }, [
h('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;' }, [preset('Morgen', 1), preset('In 3 Tagen', 3), preset('Nächste Woche', 7)]),
h('div', { style: 'display:flex;gap:10px;flex-wrap:wrap;' }, [
h('div', { style: 'flex:2;min-width:150px;' }, feld('Datum', h('input', { type: 'date', value: rueckruf.date, style: S_INPUT, onInput: (e) => { rueckruf.date = e.target.value; } }))),
h('div', { style: 'flex:1;min-width:110px;' }, feld('Uhrzeit', h('input', { type: 'time', value: rueckruf.time, style: S_INPUT, onInput: (e) => { rueckruf.time = e.target.value; } }))),
]),
h('div', { style: 'display:flex;gap:8px;' }, [btn('Erinnerung setzen', () => planeRueckruf(k), 'primary'), btn('Abbrechen', () => { rueckruf.open = false; }, 'ghost')]),
]) : null;
const mailForm = mailMich.open ? h('div', { style: 'border:1px solid var(--border-normal);border-radius:10px;padding:14px;margin-top:10px;margin-bottom:12px;background:var(--background-subdued);' }, [
feld('Notiz an mich', h('textarea', { value: mailMich.note, rows: 3, placeholder: 'Was soll dir per Mail zugehen? (Eckdaten kommen automatisch mit)', style: S_INPUT + 'resize:vertical;', onInput: (e) => { mailMich.note = e.target.value; } })),
h('div', { style: 'display:flex;gap:8px;' }, [btn('Mail an mich senden', () => sendeMail(k), 'primary'), btn('Abbrechen', () => { mailMich.open = false; }, 'ghost')]),
]) : null;
const offene = aktionen.value.filter((a) => a.type === 'rueckruf' && a.status === 'offen');
const offeneListe = offene.length ? h('div', { style: 'margin-bottom:12px;display:flex;flex-direction:column;gap:6px;' }, offene.map((a) => h('div', {
style: 'display:flex;align-items:center;justify-content:space-between;gap:10px;border:1px solid var(--border-normal);border-radius:8px;padding:8px 12px;background:var(--background-page);',
}, [
h('div', { style: 'font-size:13px;' }, [
h('span', { style: 'font-weight:600;' }, '📞 Rückruf ' + (a.due_date ? new Date(a.due_date).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '')),
a.note ? h('div', { style: 'color:var(--foreground-subdued);margin-top:2px;white-space:pre-wrap;' }, a.note) : null,
]),
btn('✓ erledigt', () => erledige(a, k), 'default'),
]))) : null;
const aktionenBlock = h('div', { style: 'margin-bottom:14px;' }, [
h('div', { style: LBL }, 'Aktionen'), aktionsLeiste, rueckrufForm, mailForm, offeneListe,
]);
return [navRow, h('div', { style: CARD }, [kopf, anruf, notizFeld, statusFeld, aktionenBlock, telFeld, mailFeld, namensFelder])];
}
return () => {
const inhalt = h('div', { style: 'max-width:760px;margin:0 auto;padding:20px 16px 60px;' }, [
// Aktions-Buttons: am Desktop nebeneinander (gleich breit), am Handy gestapelt in voller Breite.
h('style', {}, '.sk-akt{display:flex;gap:8px;flex-wrap:wrap;}.sk-akt>*{flex:1 1 auto;justify-content:center;}@media(max-width:600px){.sk-akt{flex-direction:column;}.sk-akt>*{width:100%;flex:none;}}'),
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,
meldung.value ? h('div', { style: 'background:var(--success-25,rgba(46,125,50,.15));color:var(--success,#2e7d32);padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:14px;' }, meldung.value) : null,
aktivId.value ? detailAnsicht() : listenAnsicht(),
]);
return h(resolveComponent('private-view'), { title: 'Kontakte' }, {
default: () => h('div', { style: 'min-height:100%;background:var(--background-subdued,#f1f4f9);' }, [inhalt]),
});
};
},
});
export default {
id: 'skrift-kontakte',
name: 'Kontakte',
icon: 'contacts',
routes: [{ path: '', component: Board }],
};

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-kontakte",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Modul: Kontakte/Leads – handytaugliches Mini-CRM (Anrufen via GroundWire, Notizen, Durchblättern, CSV-Export).",
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -127,8 +127,10 @@ export default (register, { services, getSchema, logger }) => {
if (!to) return; if (!to) return;
const settings = await loadSettings(schema); const settings = await loadSettings(schema);
const order = await new ItemsService('orders', { schema, accountability: null }) const order = await new ItemsService('orders', { schema, accountability: null })
.readOne(orderId, { fields: ['order_number', 'product.name'] }); .readOne(orderId, { fields: ['order_number', 'product.name', 'source'] });
if (!order) return; if (!order) return;
// API-Aufträge NICHT einzeln melden – dafür gibt es die Tagesübersicht (18:00).
if (order.source === 'api') return;
const html = `<p>Neue Bestellung <strong>${order.order_number}</strong>${order.product?.name ? ` (${order.product.name})` : ''} – im Produktions-Board unter „Neu".</p>`; const html = `<p>Neue Bestellung <strong>${order.order_number}</strong>${order.product?.name ? ` (${order.product.name})` : ''} – im Produktions-Board unter „Neu".</p>`;
const nachricht = { const nachricht = {
to, to,
@@ -245,4 +247,45 @@ export default (register, { services, getSchema, logger }) => {
catch (e) { logger.warn(`[skrift-mailer] status ${status}: ${e.message}`); } catch (e) { logger.warn(`[skrift-mailer] status ${status}: ${e.message}`); }
} }
}); });
// ── Tagesübersicht der API-Aufträge (statt Einzelmail je Auftrag) ───────────
// Einmal täglich (Default 18:00): pro API-Kunde eine Zusammenfassung der heute
// eingegangenen Aufträge, aufgeschlüsselt nach Versandtag.
const API_DIGEST_CRON = process.env.SKRIFT_API_DIGEST_CRON || '0 18 * * *';
register.schedule(API_DIGEST_CRON, async () => {
try {
const schema = await getSchema();
const to = await teamEmpfaenger(schema);
if (!to) return;
const settings = await loadSettings(schema);
const start = new Date(); start.setHours(0, 0, 0, 0);
const orders = await new ItemsService('orders', { schema, accountability: null }).readByQuery({
filter: { _and: [{ source: { _eq: 'api' } }, { date_created: { _gte: start.toISOString() } }] },
fields: ['order_number', 'shipping_day', 'api_client.id', 'api_client.name', 'product.name'],
limit: -1, sort: ['order_number'],
});
if (!orders || !orders.length) return;
const byClient = new Map();
for (const o of orders) {
const kid = o.api_client?.id ?? 'x';
let g = byClient.get(kid);
if (!g) { g = { name: o.api_client?.name || 'API-Kunde', orders: [] }; byClient.set(kid, g); }
g.orders.push(o);
}
for (const g of byClient.values()) {
const byDay = {};
for (const o of g.orders) { const d = o.shipping_day || 'ohne Versandtag'; byDay[d] = (byDay[d] || 0) + 1; }
const zeilen = Object.entries(byDay).map(([d, n]) => `<li>${esc(d)}: <strong>${n}</strong></li>`).join('');
const html = `<p>Heute über die API eingegangen von <strong>${esc(g.name)}</strong>: <strong>${g.orders.length}</strong> Auftrag/Aufträge.</p>`
+ `<p>Nach Versandtag:</p><ul>${zeilen}</ul>`
+ '<p>Im Produktions-Board unter „Neu" als Charge(n) gruppiert.</p>';
const nachricht = { to, subject: `API-Tagesübersicht ${g.name} – ${g.orders.length} neue`, html, text: htmlZuText(html), replyTo: REPLY_TO };
if (settings.sender_email) nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
try { await new MailService({ schema, accountability: null }).send(nachricht); }
catch (e) { logger.warn(`[skrift-mailer] API-Tagesmail ${g.name}: ${e.message}`); }
}
logger.info(`[skrift-mailer] API-Tagesübersicht verschickt: ${byClient.size} Kunde(n)`);
} catch (e) { logger.warn(`[skrift-mailer] API-Tageslauf: ${e.message}`); }
});
}; };

View File

@@ -765,6 +765,36 @@ const handler = (router, { services, getSchema, logger }) => {
fs.createReadStream(ziel).pipe(res); fs.createReadStream(ziel).pipe(res);
}); });
// ── Charge-Sammel-PDF ──────────────────────────────────────────────────────
// Führt die Brief-PDFs mehrerer API-Aufträge (Datei-Produkte) im Backend zu
// EINER PDF zusammen und reicht sie ans Admin-Board durch (Bürodrucker).
// Body: { orderIds: (number|string)[] }
router.post('/charge/pdf', async (req, res) => {
const acc = req.accountability;
if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
const orderIds = Array.isArray(req.body?.orderIds) ? req.body.orderIds.filter((v) => v != null) : [];
if (!orderIds.length) return res.status(400).json({ error: 'orderIds (Array) erforderlich.' });
try {
const r = await fetch(`${BACKEND_URL}/api/charge/merge-pdf`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
body: JSON.stringify({ orderIds }),
});
if (!r.ok) {
let msg = 'Sammel-PDF fehlgeschlagen.';
try { msg = (await r.json())?.message || msg; } catch { /* ignore */ }
return res.status(r.status === 400 || r.status === 404 ? r.status : 502).json({ error: msg });
}
const buf = Buffer.from(await r.arrayBuffer());
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="charge.pdf"');
return res.send(buf);
} catch (e) {
logger.warn(`[skrift-orders] charge/pdf: ${e.message}`);
return res.status(502).json({ error: 'Backend nicht erreichbar.' });
}
});
// ── Druckauftrag einreihen (manueller Trigger aus dem Admin) ─────────────── // ── Druckauftrag einreihen (manueller Trigger aus dem Admin) ───────────────
// Legt pro Typ (Schriftstück / Umschlag) einen Job mit aufgelöstem Template // Legt pro Typ (Schriftstück / Umschlag) einen Job mit aufgelöstem Template
// für die gewählte Maschine an. Kein Statuswechsel am Auftrag (bewusst). // für die gewählte Maschine an. Kein Statuswechsel am Auftrag (bewusst).
@@ -1459,12 +1489,13 @@ const handler = (router, { services, getSchema, logger }) => {
await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Manuell im Admin angelegt.' }); await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Manuell im Admin angelegt.' });
if (product.type === 'letter' || product.type === 'postcard') { // Generierung IMMER anstoßen – für jedes Produkt. Das Backend entscheidet,
// was zu erzeugen ist (Schriftstücke bei Handschrift-Produkten, Kuverts immer
// wenn gewünscht; bei Datei-Produkten NUR die Kuverts). Nichts = No-Op.
fetch(`${BACKEND_URL}/api/order/from-directus`, { fetch(`${BACKEND_URL}/api/order/from-directus`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN }, method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
body: JSON.stringify({ orderId }), body: JSON.stringify({ orderId }),
}).catch((e) => logger.warn(`[skrift-orders] Manuell-Generierung nicht gestartet: ${e.message}`)); }).catch((e) => logger.warn(`[skrift-orders] Manuell-Generierung nicht gestartet: ${e.message}`));
}
return res.json({ ok: true, order_number: orderNumber, id: orderId }); return res.json({ ok: true, order_number: orderNumber, id: orderId });
} catch (err) { } catch (err) {

View File

@@ -31,6 +31,27 @@ const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
const ENV_LABEL = { dinlang: 'DIN Lang', c6: 'C6' }; const ENV_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
const INLAND = /^(de|deutschland|germany)$/i; const INLAND = /^(de|deutschland|germany)$/i;
// Versandtag → Wochentag (0=So…6=Sa). Für die Frist-Berechnung.
const WOCHENTAG = { montag: 1, dienstag: 2, mittwoch: 3, donnerstag: 4, freitag: 5, samstag: 6, sonntag: 0 };
// Garantie: kommt ein Auftrag in Woche W rein, geht er spätestens am angegebenen
// Versandtag der ÜBERNÄCHSTEN Woche (W+2) raus. Liefert das Fälligkeitsdatum.
function faelligBis(dateCreated, shippingDay) {
const wd = WOCHENTAG[String(shippingDay || '').toLowerCase()];
if (wd === undefined || !dateCreated) return null;
const d = new Date(dateCreated);
if (isNaN(d)) return null;
d.setHours(0, 0, 0, 0);
// Montag der Eingangswoche (Montag = Wochenstart).
const tag = d.getDay();
const montag = new Date(d);
montag.setDate(d.getDate() + (tag === 0 ? -6 : 1 - tag));
// Montag der übernächsten Woche + Offset bis zum Ziel-Wochentag.
const ziel = new Date(montag);
ziel.setDate(montag.getDate() + 14 + (wd === 0 ? 6 : wd - 1));
return ziel;
}
const dmY = (dt) => dt ? `${String(dt.getDate()).padStart(2, '0')}.${String(dt.getMonth() + 1).padStart(2, '0')}.${dt.getFullYear()}` : '';
const TABS = [ const TABS = [
{ key: 'neu', label: '🆕 Neu' }, { key: 'neu', label: '🆕 Neu' },
{ key: 'warteschlange', label: '⏳ Warteschlange' }, { key: 'warteschlange', label: '⏳ Warteschlange' },
@@ -140,6 +161,8 @@ const Board = defineComponent({
info: '', laden: false, selected: null, pl: '', pe: '', pLoad: false, perr: '', lb: '', lbTitel: '' }); info: '', laden: false, selected: null, pl: '', pe: '', pLoad: false, perr: '', lb: '', lbTitel: '' });
// Massenauswahl (Auftrags-IDs) + Sammel-Druck-Popup (pro Format Template+Maschine). // Massenauswahl (Auftrags-IDs) + Sammel-Druck-Popup (pro Format Template+Maschine).
const auswahl = ref([]); const auswahl = ref([]);
// Aufgeklappte API-Chargen (im „Neu"-Reiter), Schlüssel = Charge-ID.
const expandedCharges = ref(new Set());
const bulk = reactive({ open: false, mode: 'queue', gruppen: [], msg: '', err: '' }); const bulk = reactive({ open: false, mode: 'queue', gruppen: [], msg: '', err: '' });
// Vom Kunden hochgeladene Original-Dokumente (kein generiertes SVG). // Vom Kunden hochgeladene Original-Dokumente (kein generiertes SVG).
const dateien = reactive({ open: false, order: null, items: [], laden: false, err: '' }); const dateien = reactive({ open: false, order: null, items: [], laden: false, err: '' });
@@ -148,8 +171,9 @@ const Board = defineComponent({
// Basis-Felder ohne die evtl. noch nicht gebootstrapten Felder – diese // Basis-Felder ohne die evtl. noch nicht gebootstrapten Felder – diese
// werden separat versucht, damit ein fehlendes Feld nicht das ganze Board sperrt. // werden separat versucht, damit ein fehlendes Feld nicht das ganze Board sperrt.
const ORDER_FELDER = 'id,order_number,status,payment_status,production_status,needs_envelope,envelope_format,' const ORDER_FELDER = 'id,order_number,status,payment_status,production_status,needs_envelope,envelope_format,'
+ 'envelope_labeling,shipping_type,entries_count,count_inland,count_ausland,brief_gedruckt,kuvert_gedruckt,' + 'envelope_labeling,shipping_type,shipping_day,keep_contacts,entries_count,count_inland,count_ausland,brief_gedruckt,kuvert_gedruckt,'
+ 'date_updated,date_created,format.key,format.name,product.name,product.input_mode,' + 'date_updated,date_created,format.key,format.name,product.name,product.input_mode,'
+ 'source,api_client.id,api_client.name,'
+ 'source_file,' + 'source_file,'
+ 'customer.first_name,customer.last_name,customer.email,customer.phone'; + 'customer.first_name,customer.last_name,customer.email,customer.phone';
async function ladeOrders() { async function ladeOrders() {
@@ -278,17 +302,87 @@ const Board = defineComponent({
// ── Statuswechsel eines Auftrags ── // ── Statuswechsel eines Auftrags ──
async function setStatus(order, neu) { async function setStatus(order, neu) {
const patch = { production_status: neu }; const patch = { production_status: neu, production_status_changed_at: new Date().toISOString() };
// Versanddatum festhalten (Basis fürs Auto-Archiv nach 14 Tagen). // Versanddatum festhalten (Basis fürs Auto-Archiv nach 14 Tagen).
if (neu === 'versendet' && !order.versandt_am) patch.versandt_am = new Date().toISOString(); if (neu === 'versendet' && !order.versandt_am) patch.versandt_am = new Date().toISOString();
try { try {
await api.patch(`/items/orders/${order.id}`, patch); await api.patch(`/items/orders/${order.id}`, patch);
order.production_status = neu; order.production_status = neu;
order.production_status_changed_at = patch.production_status_changed_at;
if (patch.versandt_am) order.versandt_am = patch.versandt_am; if (patch.versandt_am) order.versandt_am = patch.versandt_am;
flash(`${order.order_number} → ${STATUS_LABEL[neu]}`); flash(`${order.order_number} → ${STATUS_LABEL[neu]}`);
} catch (e) { fehler.value = e?.message || 'Statuswechsel fehlgeschlagen.'; } } catch (e) { fehler.value = e?.message || 'Statuswechsel fehlgeschlagen.'; }
} }
// Akquise: Empfänger dieses Auftrags ausdrücklich als Kontakte/Leads
// speichern. Der Kontakte-Hook legt sie beim Setzen an (einmalig).
async function toggleKeepContacts(order) {
const neu = !order.keep_contacts;
try {
await api.patch(`/items/orders/${order.id}`, { keep_contacts: neu });
order.keep_contacts = neu;
flash(`${order.order_number}: Empfänger ${neu ? 'werden in Kontakte gespeichert' : 'werden nicht als Kontakte gespeichert'}`);
} catch (e) { fehler.value = e?.message || 'Konnte „In Kontakte speichern" nicht ändern.'; }
}
// ── API-Chargen (Gruppierung im „Neu"-Reiter) ────────────────────────────
// Gruppenschlüssel: Kunde + Produkt/Typ + Versandtag + Eingangstag.
function bildeChargen(orders) {
const map = new Map();
for (const o of orders) {
const kunde = o.api_client?.id ?? 'x';
const produkt = o.product?.name || '?';
const tag = o.shipping_day || '';
const eingang = String(o.date_created || '').slice(0, 10);
const key = `${kunde}|${produkt}|${tag}|${eingang}`;
let c = map.get(key);
if (!c) {
c = { key, kundeName: o.api_client?.name || 'API-Kunde', produkt, versandtag: tag, eingang, orders: [],
faellig: faelligBis(o.date_created, tag) };
map.set(key, c);
}
c.orders.push(o);
}
// Nach Fälligkeit (früheste zuerst), dann Eingang.
return [...map.values()].sort((a, b) => (a.faellig?.getTime() || 9e15) - (b.faellig?.getTime() || 9e15));
}
const chargeGewaehlt = (c) => c.orders.length > 0 && c.orders.every((o) => istGewaehlt(o.id));
function toggleCharge(c) {
const alleDrin = chargeGewaehlt(c);
const ids = c.orders.map((o) => String(o.id));
auswahl.value = alleDrin ? auswahl.value.filter((x) => !ids.includes(x))
: [...new Set([...auswahl.value, ...ids])];
}
const toggleExpand = (key) => {
const s = new Set(expandedCharges.value);
s.has(key) ? s.delete(key) : s.add(key);
expandedCharges.value = s;
};
// Ausgewählte Auftrags-IDs nach Auftragsnummer sortiert – damit PDF-Stapel
// und Kuvert-Reihenfolge im Plotter zueinander passen (Kollation).
function auswahlSortiert() {
const byId = new Map(orders.value.map((o) => [String(o.id), o]));
return auswahl.value.slice().sort((a, b) => String(byId.get(String(a))?.order_number || '')
.localeCompare(String(byId.get(String(b))?.order_number || ''), undefined, { numeric: true }));
}
// Sammel-PDF der aktuell ausgewählten Aufträge (mehrere Chargen kombiniert).
async function chargePdf() {
const ids = auswahlSortiert();
if (!ids.length) { fehler.value = 'Keine Aufträge ausgewählt.'; return; }
meldung.value = 'Sammel-PDF wird erstellt …';
try {
const r = await api.post('/skrift-orders/charge/pdf', { orderIds: ids }, { responseType: 'blob' });
const url = URL.createObjectURL(r.data);
const a = document.createElement('a'); a.href = url; a.download = 'charge.pdf';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
flash(`Sammel-PDF erstellt (${ids.length} Auftrag/Aufträge).`);
} catch (e) {
fehler.value = e?.response?.data?.error || e?.message || 'Sammel-PDF fehlgeschlagen.'; meldung.value = '';
}
}
// ── Massenauswahl + Sammel-Druck ───────────────────────────────────────── // ── Massenauswahl + Sammel-Druck ─────────────────────────────────────────
const istGewaehlt = (id) => auswahl.value.includes(String(id)); const istGewaehlt = (id) => auswahl.value.includes(String(id));
function toggleAuswahl(id) { function toggleAuswahl(id) {
@@ -324,11 +418,12 @@ const Board = defineComponent({
if (!sel.length || !neuStatus) return; if (!sel.length || !neuStatus) return;
let ok = 0; let ok = 0;
for (const o of sel) { for (const o of sel) {
const patch = { production_status: neuStatus }; const patch = { production_status: neuStatus, production_status_changed_at: new Date().toISOString() };
if (neuStatus === 'versendet' && !o.versandt_am) patch.versandt_am = new Date().toISOString(); if (neuStatus === 'versendet' && !o.versandt_am) patch.versandt_am = new Date().toISOString();
try { try {
await api.patch(`/items/orders/${o.id}`, patch); await api.patch(`/items/orders/${o.id}`, patch);
o.production_status = neuStatus; o.production_status = neuStatus;
o.production_status_changed_at = patch.production_status_changed_at;
if (patch.versandt_am) o.versandt_am = patch.versandt_am; if (patch.versandt_am) o.versandt_am = patch.versandt_am;
ok++; ok++;
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Statuswechsel fehlgeschlagen.'; } } catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Statuswechsel fehlgeschlagen.'; }
@@ -369,7 +464,7 @@ const Board = defineComponent({
} }
bulk.err = ''; bulk.msg = 'Wird eingereiht …'; bulk.err = ''; bulk.msg = 'Wird eingereiht …';
try { try {
const res = await api.post('/skrift-orders/enqueue-bulk', { orders: auswahl.value, mode: bulk.mode, byFormat }); const res = await api.post('/skrift-orders/enqueue-bulk', { orders: auswahlSortiert(), mode: bulk.mode, byFormat });
const n = res.data?.jobs?.length || 0; const n = res.data?.jobs?.length || 0;
const skip = res.data?.uebersprungen || []; const skip = res.data?.uebersprungen || [];
flash(`${n} Job(s) eingereiht${skip.length ? ` · ${skip.length} übersprungen` : ''}.`); flash(`${n} Job(s) eingereiht${skip.length ? ` · ${skip.length} übersprungen` : ''}.`);
@@ -572,6 +667,8 @@ const Board = defineComponent({
btn('🖨 Drucken', () => openDruck(order), 'primary'), btn('🖨 Drucken', () => openDruck(order), 'primary'),
btn('👥 Empfänger', () => openEmpf(order)), btn('👥 Empfänger', () => openEmpf(order)),
orderDateiIds(order).length ? btn('📎 Dateien', () => openDateien(order)) : null, orderDateiIds(order).length ? btn('📎 Dateien', () => openDateien(order)) : null,
btn(order.keep_contacts ? '📇 In Kontakte gespeichert ✓' : '📇 In Kontakte speichern',
() => toggleKeepContacts(order), order.keep_contacts ? 'primary' : 'default'),
].filter(Boolean)); ].filter(Boolean));
} }
function statusSelect(order) { function statusSelect(order) {
@@ -598,6 +695,7 @@ const Board = defineComponent({
chip(`${o.entries_count || 0} Briefe`), chip(`${o.entries_count || 0} Briefe`),
chip(o.needs_envelope ? `${o.entries_count || 0} Kuverts` : 'ohne Kuvert'), chip(o.needs_envelope ? `${o.entries_count || 0} Kuverts` : 'ohne Kuvert'),
chip(o.shipping_type === 'einzeln' ? 'Einzelversand' : 'Sammelversand'), chip(o.shipping_type === 'einzeln' ? 'Einzelversand' : 'Sammelversand'),
o.shipping_day ? chip('📅 Versandtag: ' + o.shipping_day.charAt(0).toUpperCase() + o.shipping_day.slice(1), 'var(--primary-25,rgba(30,80,165,.14))') : null,
chip(o.product?.name || 'Produkt?'), chip(o.product?.name || 'Produkt?'),
chip(o.format?.name || o.format?.key || 'Format?'), chip(o.format?.name || o.format?.key || 'Format?'),
chip(o.payment_status === 'bezahlt' ? '✓ bezahlt' : (o.payment_status === 'storniert' ? 'storniert' : 'offen'), chip(o.payment_status === 'bezahlt' ? '✓ bezahlt' : (o.payment_status === 'storniert' ? 'storniert' : 'offen'),
@@ -631,9 +729,39 @@ const Board = defineComponent({
return h('div', { key: 'r' + req.id, style: CARD + `margin-bottom:14px;border-left:4px solid ${istMuster ? FARBE_MUSTER : FARBE_ANGEBOT};` }, [kopf, meta, nachricht, aktionen]); return h('div', { key: 'r' + req.id, style: CARD + `margin-bottom:14px;border-left:4px solid ${istMuster ? FARBE_MUSTER : FARBE_ANGEBOT};` }, [kopf, meta, nachricht, aktionen]);
} }
// Charge-Karte: fasst API-Aufträge (Kunde+Typ+Versandtag+Eingangstag) zusammen.
function chargeCard(c) {
const auf = expandedCharges.value.has(c.key);
let faelligFarbe = 'var(--background-subdued)';
if (c.faellig) {
const tage = Math.ceil((c.faellig.getTime() - Date.now()) / 864e5);
faelligFarbe = tage < 0 ? 'var(--danger-25,rgba(198,40,40,.15))'
: tage <= 7 ? 'var(--warning-25,rgba(230,160,0,.18))' : 'var(--success-25,rgba(46,125,50,.12))';
}
const kopf = h('div', { style: 'display:flex;align-items:center;gap:10px;flex-wrap:wrap;' }, [
h('input', { type: 'checkbox', checked: chargeGewaehlt(c), onChange: () => toggleCharge(c),
title: 'Ganze Charge auswählen', style: 'width:16px;height:16px;cursor:pointer;flex-shrink:0;' }),
h('button', { type: 'button', onClick: () => toggleExpand(c.key),
style: 'cursor:pointer;border:none;background:transparent;font-size:15px;color:var(--foreground-normal);', title: 'Aufklappen' }, auf ? '▾' : '▸'),
h('div', { style: 'font-weight:700;font-size:15px;' }, `📦 ${c.kundeName} · ${c.produkt}`),
chip(`${c.orders.length} Aufträge`),
c.versandtag ? chip('📅 ' + c.versandtag.charAt(0).toUpperCase() + c.versandtag.slice(1)) : null,
c.faellig ? chip('⏰ fällig bis ' + dmY(c.faellig), faelligFarbe) : null,
]);
const body = auf ? h('div', { style: 'margin-top:12px;display:flex;flex-direction:column;gap:2px;' }, c.orders.map((o) => orderCard(o))) : null;
return h('div', { key: 'c' + c.key, style: CARD + `margin-bottom:14px;border-left:4px solid ${ACCENT};` }, [kopf, body]);
}
function tabInhalt() { function tabInhalt() {
if (tab.value === 'neu') { if (tab.value === 'neu') {
const items = [...neueOrders.value.map((o) => orderCard(o)), ...neueRequests.value.map(requestCard)]; // API-Aufträge zu Chargen bündeln; alles andere als Einzelkarten.
const apiOrders = neueOrders.value.filter((o) => o.source === 'api');
const restOrders = neueOrders.value.filter((o) => o.source !== 'api');
const items = [
...bildeChargen(apiOrders).map(chargeCard),
...restOrders.map((o) => orderCard(o)),
...neueRequests.value.map(requestCard),
];
return items.length ? items : subdued('Nichts Neues.'); return items.length ? items : subdued('Nichts Neues.');
} }
if (tab.value === 'warteschlange') { if (tab.value === 'warteschlange') {
@@ -942,6 +1070,7 @@ const Board = defineComponent({
const bulkBar = auswahl.value.length ? h('div', { style: `display:flex;align-items:center;gap:12px;flex-wrap:wrap;background:${ACCENT};color:#fff;padding:10px 16px;border-radius:8px;margin-bottom:12px;` }, [ const bulkBar = auswahl.value.length ? h('div', { style: `display:flex;align-items:center;gap:12px;flex-wrap:wrap;background:${ACCENT};color:#fff;padding:10px 16px;border-radius:8px;margin-bottom:12px;` }, [
h('span', { style: 'font-weight:600;' }, `${auswahl.value.length} Auftrag/Aufträge ausgewählt`), h('span', { style: 'font-weight:600;' }, `${auswahl.value.length} Auftrag/Aufträge ausgewählt`),
h('button', { type: 'button', onClick: openBulk, style: `cursor:pointer;border:none;background:#fff;color:${ACCENT};border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;font-weight:600;` }, '🖨 In Druck geben'), h('button', { type: 'button', onClick: openBulk, style: `cursor:pointer;border:none;background:#fff;color:${ACCENT};border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;font-weight:600;` }, '🖨 In Druck geben'),
h('button', { type: 'button', onClick: chargePdf, style: `cursor:pointer;border:none;background:#fff;color:${ACCENT};border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;font-weight:600;` }, '📄 PDFs drucken'),
h('select', { h('select', {
style: `cursor:pointer;border:none;background:#fff;color:${ACCENT};border-radius:7px;padding:7px 12px;font:inherit;font-size:13px;font-weight:600;`, style: `cursor:pointer;border:none;background:#fff;color:${ACCENT};border-radius:7px;padding:7px 12px;font:inherit;font-size:13px;font-weight:600;`,
value: '', onChange: (e) => { if (e.target.value) { bulkStatus(e.target.value); e.target.value = ''; } }, value: '', onChange: (e) => { if (e.target.value) { bulkStatus(e.target.value); e.target.value = ''; } },

View File

@@ -20,6 +20,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.0", "express": "^4.19.0",
"multer": "^2.0.2", "multer": "^2.0.2",
"pdf-lib": "^1.17.1",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"xml2js": "^0.6.2" "xml2js": "^0.6.2"
}, },

View File

@@ -0,0 +1,69 @@
/**
* Charge-Controller – Sammel-PDF für API-Chargen.
*
* Führt die hochgeladenen Brief-PDFs mehrerer Aufträge (Datei-Produkte, z. B.
* Unterschriften-Service) zu EINER PDF zusammen, die am Bürodrucker in einem
* Rutsch gedruckt werden kann. Reihenfolge = Auftragsnummer (aufsteigend), damit
* sie zur Kuvert-Reihenfolge im Plotter passt.
*/
const { PDFDocument } = require('pdf-lib');
const directus = require('../../services/directus-service');
/**
* POST /api/charge/merge-pdf
* Body: { orderIds: (number|string)[] } → application/pdf (zusammengeführt)
*/
async function mergePdf(req, res, next) {
try {
const ids = Array.isArray(req.body?.orderIds) ? req.body.orderIds.filter((v) => v != null) : [];
if (!ids.length) {
return res.status(400).json({ error: 'Invalid request', message: 'orderIds (array) is required.' });
}
const orders = await directus.fetchOrdersMinimal(ids);
if (!orders || !orders.length) {
return res.status(404).json({ error: 'Not found', message: 'Keine Aufträge gefunden.' });
}
// Datei-IDs in Auftragsnummern-Reihenfolge (orders ist bereits sortiert).
const fileIds = [];
for (const o of orders) {
const sf = Array.isArray(o.source_files) ? o.source_files.filter(Boolean) : [];
if (sf.length) fileIds.push(...sf);
else if (o.source_file) fileIds.push(o.source_file);
}
if (!fileIds.length) {
return res.status(400).json({ error: 'No files', message: 'Keine Druckdateien an den Aufträgen.' });
}
const merged = await PDFDocument.create();
let ok = 0;
const skipped = [];
for (const fid of fileIds) {
try {
const buf = await directus.fetchAssetBuffer(fid);
const src = await PDFDocument.load(buf, { ignoreEncryption: true });
const pages = await merged.copyPages(src, src.getPageIndices());
pages.forEach((p) => merged.addPage(p));
ok += 1;
} catch (e) {
skipped.push(fid);
console.warn(`[Charge] PDF ${fid} übersprungen: ${e.message}`);
}
}
if (!ok) {
return res.status(400).json({ error: 'Merge failed', message: 'Keine gültige PDF-Datei gefunden.' });
}
const bytes = await merged.save();
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="charge.pdf"');
res.setHeader('X-Skrift-Merged', String(ok));
if (skipped.length) res.setHeader('X-Skrift-Skipped', String(skipped.length));
return res.status(200).send(Buffer.from(bytes));
} catch (err) {
return next(err);
}
}
module.exports = { mergePdf };

View File

@@ -0,0 +1,13 @@
const express = require('express');
const router = express.Router();
const chargeController = require('../controllers/charge-controller');
const { authenticateApiToken } = require('../middleware/auth');
/**
* POST /api/charge/merge-pdf
* Führt die Brief-PDFs mehrerer Aufträge zu einer PDF zusammen (Sammeldruck).
* Body: { orderIds: (number|string)[] }
*/
router.post('/merge-pdf', authenticateApiToken, chargeController.mergePdf);
module.exports = router;

View File

@@ -4,6 +4,7 @@ const config = require('./config');
const previewRoutes = require('./api/routes/preview-routes'); const previewRoutes = require('./api/routes/preview-routes');
const orderRoutes = require('./api/routes/order-routes'); const orderRoutes = require('./api/routes/order-routes');
const chargeRoutes = require('./api/routes/charge-routes');
const healthRoutes = require('./api/routes/health-routes'); const healthRoutes = require('./api/routes/health-routes');
const errorHandler = require('./api/middleware/error-handler'); const errorHandler = require('./api/middleware/error-handler');
@@ -46,6 +47,7 @@ app.use(requestLogger);
app.use('/health', healthRoutes); app.use('/health', healthRoutes);
app.use('/api/preview', previewRoutes); app.use('/api/preview', previewRoutes);
app.use('/api/order', orderRoutes); app.use('/api/order', orderRoutes);
app.use('/api/charge', chargeRoutes);
// Format-Cache manuell auffrischen (Token-geschützt) – für „sofort übernehmen". // Format-Cache manuell auffrischen (Token-geschützt) – für „sofort übernehmen".
app.post('/api/formats/refresh', async (req, res) => { app.post('/api/formats/refresh', async (req, res) => {

View File

@@ -78,6 +78,30 @@ async function addStatusHistory(orderId, status, note) {
}); });
} }
/** Lädt mehrere Aufträge (nur die für den Charge-Sammeldruck nötigen Felder),
* sortiert nach Auftragsnummer. */
async function fetchOrdersMinimal(ids) {
const idList = ids.map((v) => encodeURIComponent(v)).join(',');
return directusFetch(
`/items/orders?filter[id][_in]=${idList}&limit=-1&sort=order_number` +
'&fields=id,order_number,source_files,source_file',
);
}
/** Lädt eine Directus-Datei (z. B. hochgeladenes Brief-PDF) als Buffer. */
async function fetchAssetBuffer(fileId) {
requireConfig();
const res = await fetch(config.directus.url.replace(/\/$/, '') + '/assets/' + encodeURIComponent(fileId), {
headers: { Authorization: `Bearer ${config.directus.token}` },
});
if (!res.ok) {
const e = new Error(`Directus asset ${fileId}: ${res.status}`);
e.status = res.status === 404 ? 404 : 502;
throw e;
}
return Buffer.from(await res.arrayBuffer());
}
/** Zentrale Format-Definitionen (Seitengröße, Ränder, Zeilenlimit) aus Directus. */ /** Zentrale Format-Definitionen (Seitengröße, Ränder, Zeilenlimit) aus Directus. */
async function fetchRenderFormats() { async function fetchRenderFormats() {
return directusFetch( return directusFetch(
@@ -86,4 +110,4 @@ async function fetchRenderFormats() {
); );
} }
module.exports = { fetchOrder, updateOrder, addStatusHistory, fetchRenderFormats }; module.exports = { fetchOrder, updateOrder, addStatusHistory, fetchRenderFormats, fetchOrdersMinimal, fetchAssetBuffer };