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:
@@ -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 = `<!doctype html>
|
||||
<h2>Retrieve order status</h2>
|
||||
<div class="card">
|
||||
<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: <KEY>"
|
||||
|
||||
{ "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>
|
||||
|
||||
<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(() => {});
|
||||
|
||||
// Generierung anstoßen (fire-and-forget) – identisch zum manuellen Auftrag.
|
||||
if (['letter', 'postcard', 'envelope'].includes(product.type)) {
|
||||
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-api] Generierung nicht gestartet: ${e.message}`));
|
||||
}
|
||||
// Generierung IMMER anstoßen (fire-and-forget) – für jedes Produkt. Das
|
||||
// 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`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
|
||||
body: JSON.stringify({ orderId }),
|
||||
}).catch((e) => logger.warn(`[skrift-api] Generierung nicht gestartet: ${e.message}`));
|
||||
|
||||
return res.status(201).json({ order_number: orderNumber, status: 'pending' });
|
||||
} catch (err) {
|
||||
@@ -419,13 +425,21 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
const rows = await new ItemsService('orders', { schema, accountability: null }).readByQuery({
|
||||
// Nur eigene Aufträge des Zugangs.
|
||||
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];
|
||||
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({
|
||||
order_number: order.order_number,
|
||||
status: kundenStatus(order.status),
|
||||
status,
|
||||
status_since: statusSince,
|
||||
recipients: order.entries_count ?? null,
|
||||
created_at: order.date_created ?? null,
|
||||
});
|
||||
@@ -514,7 +528,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
'/v1/orders/{order_number}': {
|
||||
get: {
|
||||
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': {
|
||||
|
||||
205
Docker/directus/extensions/directus-extension-skrift-contacts/dist/index.js
vendored
Normal file
205
Docker/directus/extensions/directus-extension-skrift-contacts/dist/index.js
vendored
Normal 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) => ({ '<': '<', '>': '>', '&': '&' }[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(/ /gi, ' ')
|
||||
.replace(/&/gi, '&').replace(/</gi, '<').replace(/>/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}`); }
|
||||
});
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
397
Docker/directus/extensions/directus-extension-skrift-kontakte/dist/index.js
vendored
Normal file
397
Docker/directus/extensions/directus-extension-skrift-kontakte/dist/index.js
vendored
Normal 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 }],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 = `<p>Neue Bestellung <strong>${order.order_number}</strong>${order.product?.name ? ` (${order.product.name})` : ''} – im Produktions-Board unter „Neu".</p>`;
|
||||
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]) => `<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}`); }
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = ''; } },
|
||||
|
||||
Reference in New Issue
Block a user