diff --git a/Docker/directus/bootstrap/bootstrap.mjs b/Docker/directus/bootstrap/bootstrap.mjs index 5b1e9e6..82e4c65 100644 --- a/Docker/directus/bootstrap/bootstrap.mjs +++ b/Docker/directus/bootstrap/bootstrap.mjs @@ -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('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).' }), + // 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('status', ['entwurf', 'vorbereitet', 'wartet_auf_zahlung', 'bezahlt', 'material_erwartet', '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).' }), // 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: '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' }), f('needs_envelope', 'boolean', { interface: 'boolean', default: false }), dropdown('envelope_labeling', ['empfaenger', 'freitext', 'keine']), @@ -580,6 +585,45 @@ const collections = [ f('used', 'boolean', { interface: 'boolean', default: false }), { 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 ────────────────────────────────────────────────────────── @@ -899,7 +943,7 @@ async function applySeed() { // ── Rollen / Policies / Rechte (Directus 11) ──────────────────────────────── const BIZ = ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', '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']; // Kunde = Login-Konto: Aufträge hängen direkt am Nutzer. const OWN_ORDER = { customer: { _eq: '$CURRENT_USER' } }; @@ -1028,6 +1072,9 @@ async function applyPermissions() { // Agent meldet Maschinenzustand (Heartbeat). await ensurePermission(svcPol.id, 'machines', 'update', ['status', 'last_seen']); 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', 'statischen Token in .env von Backend/Produktion eintragen.'); @@ -1085,7 +1132,7 @@ async function organizeNav() { }; // 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 }); // Unter „Einstellungen" for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', 'offer_requests', @@ -1126,6 +1173,7 @@ async function applyDisplays() { ['customer_products', 'product', '{{name}}'], ['offer_requests', 'customer', '{{first_name}} {{last_name}}'], ['voucher_redemptions', 'voucher', '{{code}}'], + ['contacts', 'source_order', '{{order_number}}'], ]; for (const [collection, field, template] of displays) { try { diff --git a/Docker/directus/extensions/directus-extension-skrift-api/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-api/dist/index.js index 722701f..916fbd9 100644 --- a/Docker/directus/extensions/directus-extension-skrift-api/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-api/dist/index.js @@ -33,12 +33,17 @@ const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex' const pad3 = (n) => String(n).padStart(3, '0'); const WOCHENTAGE = ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag']; -/** Internal order status → public status (pending | processing | shipped | cancelled). */ -function kundenStatus(status) { - const s = String(status || ''); - if (s === 'versendet' || s === 'abgeschlossen') return 'shipped'; - if (s === 'in_produktion' || s === 'gedruckt') return 'processing'; - if (s === 'storniert') return 'cancelled'; +/** + * Internal order → public status (pending | processing | shipped | cancelled). + * Der Betreiber steuert im Produktions-Board `production_status`; der kaufmännische + * `status` dient als Rückfall. Beide werden berücksichtigt. + */ +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'; } @@ -155,10 +160,10 @@ const DOCS_HTML = `
Returns the current status of one of your orders.
+Returns the current status of one of your orders. status_since is the ISO 8601 timestamp (UTC) since which the order has been in its current status.
curl https://dev.skrift.de/v1/orders/18-08-26-001 -H "X-Api-Key: <KEY>"
-{ "order_number":"18-08-26-001", "status":"processing", "recipients":1 }
+{ "order_number":"18-08-26-001", "status":"shipped", "status_since":"2026-08-20T09:14:00.000Z", "recipients":1 }
Anschrift
${anschrift || '—'}
`); + zeilen.push(''
+ + `E-Mail: ${c.email ? esc(c.email) : '—'}
Telefon: ${c.phone ? esc(c.phone) : '—'}
Notizen
${esc(notes)}
`); + 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 ${esc(name)}:` : `Angebot vorbereiten für ${esc(name)}:`; + const html = `${einleitung}
${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 = `Fälliger Rückruf${wann ? ' (' + esc(wann) + ' Uhr)' : ''} bei ${esc(name)}` + + `${a.contact.phone ? ' · ' + esc(a.contact.phone) + '' : ''}:
${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}`); } + }); +}; diff --git a/Docker/directus/extensions/directus-extension-skrift-contacts/package.json b/Docker/directus/extensions/directus-extension-skrift-contacts/package.json new file mode 100644 index 0000000..9774953 --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-contacts/package.json @@ -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" + } +} diff --git a/Docker/directus/extensions/directus-extension-skrift-kontakte/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-kontakte/dist/index.js new file mode 100644 index 0000000..ed98c0f --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-kontakte/dist/index.js @@ -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 }], +}; diff --git a/Docker/directus/extensions/directus-extension-skrift-kontakte/package.json b/Docker/directus/extensions/directus-extension-skrift-kontakte/package.json new file mode 100644 index 0000000..0e326ed --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-kontakte/package.json @@ -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" + } +} diff --git a/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js index 11a48ac..f6315b8 100644 --- a/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js @@ -127,8 +127,10 @@ export default (register, { services, getSchema, logger }) => { if (!to) return; const settings = await loadSettings(schema); 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; + // API-Aufträge NICHT einzeln melden – dafür gibt es die Tagesübersicht (18:00). + if (order.source === 'api') return; const html = `Neue Bestellung ${order.order_number}${order.product?.name ? ` (${order.product.name})` : ''} – im Produktions-Board unter „Neu".
`; const nachricht = { to, @@ -245,4 +247,45 @@ export default (register, { services, getSchema, logger }) => { 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]) => `Heute über die API eingegangen von ${esc(g.name)}: ${g.orders.length} Auftrag/Aufträge.
` + + `Nach Versandtag:
Im Produktions-Board unter „Neu" als Charge(n) gruppiert.
'; + 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}`); } + }); }; diff --git a/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js index b88740c..80f8a28 100644 --- a/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js @@ -765,6 +765,36 @@ const handler = (router, { services, getSchema, logger }) => { 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) ─────────────── // Legt pro Typ (Schriftstück / Umschlag) einen Job mit aufgelöstem Template // 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.' }); - if (product.type === 'letter' || product.type === 'postcard') { - fetch(`${BACKEND_URL}/api/order/from-directus`, { - method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN }, - body: JSON.stringify({ orderId }), - }).catch((e) => logger.warn(`[skrift-orders] Manuell-Generierung nicht gestartet: ${e.message}`)); - } + // 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`, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN }, + body: JSON.stringify({ orderId }), + }).catch((e) => logger.warn(`[skrift-orders] Manuell-Generierung nicht gestartet: ${e.message}`)); return res.json({ ok: true, order_number: orderNumber, id: orderId }); } catch (err) { diff --git a/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js index 6374119..16acc1f 100644 --- a/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js @@ -31,6 +31,27 @@ const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' }; const ENV_LABEL = { dinlang: 'DIN Lang', c6: 'C6' }; 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 = [ { key: 'neu', label: '🆕 Neu' }, { key: 'warteschlange', label: '⏳ Warteschlange' }, @@ -140,6 +161,8 @@ const Board = defineComponent({ info: '', laden: false, selected: null, pl: '', pe: '', pLoad: false, perr: '', lb: '', lbTitel: '' }); // Massenauswahl (Auftrags-IDs) + Sammel-Druck-Popup (pro Format Template+Maschine). 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: '' }); // Vom Kunden hochgeladene Original-Dokumente (kein generiertes SVG). 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 // 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,' - + '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,' + + 'source,api_client.id,api_client.name,' + 'source_file,' + 'customer.first_name,customer.last_name,customer.email,customer.phone'; async function ladeOrders() { @@ -278,17 +302,87 @@ const Board = defineComponent({ // ── Statuswechsel eines Auftrags ── 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). if (neu === 'versendet' && !order.versandt_am) patch.versandt_am = new Date().toISOString(); try { await api.patch(`/items/orders/${order.id}`, patch); order.production_status = neu; + order.production_status_changed_at = patch.production_status_changed_at; if (patch.versandt_am) order.versandt_am = patch.versandt_am; flash(`${order.order_number} → ${STATUS_LABEL[neu]}`); } 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 ───────────────────────────────────────── const istGewaehlt = (id) => auswahl.value.includes(String(id)); function toggleAuswahl(id) { @@ -324,11 +418,12 @@ const Board = defineComponent({ if (!sel.length || !neuStatus) return; let ok = 0; 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(); try { await api.patch(`/items/orders/${o.id}`, patch); o.production_status = neuStatus; + o.production_status_changed_at = patch.production_status_changed_at; if (patch.versandt_am) o.versandt_am = patch.versandt_am; ok++; } 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 …'; 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 skip = res.data?.uebersprungen || []; flash(`${n} Job(s) eingereiht${skip.length ? ` · ${skip.length} übersprungen` : ''}.`); @@ -572,6 +667,8 @@ const Board = defineComponent({ btn('🖨 Drucken', () => openDruck(order), 'primary'), btn('👥 Empfänger', () => openEmpf(order)), 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)); } function statusSelect(order) { @@ -598,6 +695,7 @@ const Board = defineComponent({ chip(`${o.entries_count || 0} Briefe`), chip(o.needs_envelope ? `${o.entries_count || 0} Kuverts` : 'ohne Kuvert'), 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.format?.name || o.format?.key || 'Format?'), 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]); } + // 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() { 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.'); } 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;` }, [ 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: 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', { 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 = ''; } }, diff --git a/Docker/skrift-backend/package.json b/Docker/skrift-backend/package.json index de9e2bf..d64a944 100644 --- a/Docker/skrift-backend/package.json +++ b/Docker/skrift-backend/package.json @@ -20,6 +20,7 @@ "dotenv": "^16.4.5", "express": "^4.19.0", "multer": "^2.0.2", + "pdf-lib": "^1.17.1", "uuid": "^9.0.1", "xml2js": "^0.6.2" }, diff --git a/Docker/skrift-backend/src/api/controllers/charge-controller.js b/Docker/skrift-backend/src/api/controllers/charge-controller.js new file mode 100644 index 0000000..166851b --- /dev/null +++ b/Docker/skrift-backend/src/api/controllers/charge-controller.js @@ -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 }; diff --git a/Docker/skrift-backend/src/api/routes/charge-routes.js b/Docker/skrift-backend/src/api/routes/charge-routes.js new file mode 100644 index 0000000..9e6d829 --- /dev/null +++ b/Docker/skrift-backend/src/api/routes/charge-routes.js @@ -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; diff --git a/Docker/skrift-backend/src/server.js b/Docker/skrift-backend/src/server.js index 0df1b3f..50a87cb 100644 --- a/Docker/skrift-backend/src/server.js +++ b/Docker/skrift-backend/src/server.js @@ -4,6 +4,7 @@ const config = require('./config'); const previewRoutes = require('./api/routes/preview-routes'); const orderRoutes = require('./api/routes/order-routes'); +const chargeRoutes = require('./api/routes/charge-routes'); const healthRoutes = require('./api/routes/health-routes'); const errorHandler = require('./api/middleware/error-handler'); @@ -46,6 +47,7 @@ app.use(requestLogger); app.use('/health', healthRoutes); app.use('/api/preview', previewRoutes); app.use('/api/order', orderRoutes); +app.use('/api/charge', chargeRoutes); // Format-Cache manuell auffrischen (Token-geschützt) – für „sofort übernehmen". app.post('/api/formats/refresh', async (req, res) => { diff --git a/Docker/skrift-backend/src/services/directus-service.js b/Docker/skrift-backend/src/services/directus-service.js index 5d6569f..5749930 100644 --- a/Docker/skrift-backend/src/services/directus-service.js +++ b/Docker/skrift-backend/src/services/directus-service.js @@ -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. */ async function fetchRenderFormats() { return directusFetch( @@ -86,4 +110,4 @@ async function fetchRenderFormats() { ); } -module.exports = { fetchOrder, updateOrder, addStatusHistory, fetchRenderFormats }; +module.exports = { fetchOrder, updateOrder, addStatusHistory, fetchRenderFormats, fetchOrdersMinimal, fetchAssetBuffer };