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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user