Produktions-Modul, überarbeitete Preislogik und WordPress-Preisrechner

Directus
- Neues Modul „Produktion": Board mit Neu/Warteschlange/Im Druck/
  Kuvertierung/Versandfertig/Versendet/Archiv, zentraler Suche, Druck-Popup
  (Maschine, Template, Maße, Nummernbereich, Direktdruck vs. Warteschlange)
  und Empfängerliste mit Vorschau von Schriftstück und Kuvert.
- Interface-Plugins: Auftragsdateien, Drucken, Testdruck, Empfängersuche.
- Mailer-Hook für Auftrags- und Statusmails inkl. Signatur und AGB-Anhang.
- Auftrag: Empfängerzeilen inline, Produktionsstatus (inkl. storniert),
  Versanddatum, Brief-/Kuvert-Indikatoren, Insights-Dashboard.
- Absender aller Mails ist Skrift (Projektname + email_settings); die
  Angebotsanfrage nennt jetzt das angefragte Produkt.

Preislogik
- Eine gemeinsame Multiplikator-Formel; Normalpreis-Menge je Produkt
  (products.norm_qty). Alte B2B/B2C-Fallback-Felder entfernt.
- Mengenrabatt-Staffel (ab X Stück Y %) in den Preis-Einstellungen.
- Preis pro Stück ohne einmalige Kosten (unit_net_total).
- Einzelversand rechnet Kuvert und Beschriftung nicht mehr doppelt.
- Fehlerhafte Grundpreis-Formel legt die Preisauskunft nicht mehr lahm.
- Briefnummern 1-basiert: Dateiname entspricht der Briefnummer.

app.skrift.de
- Direktlinks für Muster, Unterschriftenservice und Follow-ups; Muster als
  eigene Seite, Kontaktprodukte direkt im Anfrageformular.
- Mehrere Adressen in den Stammdaten plus Adressauswahl im Konfigurator.
- Verbotene Zeichen werden bei der Eingabe entfernt und gemeldet.
- Zeilenüberlauf als Fehlerkasten vor „Weiter"; Mengenfeld leerbar,
  Standardmenge 100; Checkout-Checkboxen im Kartenstil.

Produktion
- skrift-agent: Poll-Modell je Maschine, Maß-Übersteuerung, Nummernfilter
  und automatischer Übergang in die Kuvertierung.

WordPress
- Neues Plugin „Skrift Preisrechner": Shortcodes inkl./exkl. MwSt,
  stündlicher Abgleich der Preisdaten in WordPress und ein sicherer
  Formel-Interpreter, damit Formeländerungen automatisch greifen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-07-23 13:24:12 +02:00
parent 2dbbc4611a
commit 06c4078290
62 changed files with 11384 additions and 154 deletions

View File

@@ -94,27 +94,54 @@ const m2o = (field, related, extra = {}) =>
meta: { interface: 'select-dropdown-m2o', note: extra.note, special: null },
schema: { is_nullable: extra.nullable !== false }, __related: related, __onDelete: extra.onDelete || 'SET NULL' });
// Kunden sind Directus-Login-Nutzer (directus_users). Kein eigener „customers"-
// Datensatz mehr – das Profil hängt direkt am Konto (Felder siehe USER_FIELDS).
// Unterfelder der Adressliste – ohne diese zeigt die „list"-Oberfläche ein
// leeres Formular („no visible fields").
const ADDRESS_SUBFIELDS = [
{ field: 'label', name: 'Bezeichnung', type: 'string', meta: { interface: 'input', width: 'full', options: { placeholder: 'z. B. Rechnung, Lieferung' } } },
{ field: 'street', name: 'Straße & Hausnummer', type: 'string', meta: { interface: 'input', width: 'full' } },
{ field: 'zip', name: 'PLZ', type: 'string', meta: { interface: 'input', width: 'half' } },
{ field: 'city', name: 'Ort', type: 'string', meta: { interface: 'input', width: 'half' } },
{ field: 'country', name: 'Land', type: 'string', meta: { interface: 'input', width: 'full', options: { placeholder: 'Deutschland' } } },
];
// Nur die Felder, die Skrift wirklich braucht. Alias-Felder (signatures,
// discounts) zeigen die zugehörigen o2m-Collections direkt am Kundenkonto.
const USER_FIELDS = [
dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat', note: 'Einstufung – nur Betreiber ändert sie.' }),
f('company', 'string', { interface: 'input', note: 'Firmenname (bei Unternehmen).' }),
f('phone', 'string', { interface: 'input' }),
f('addresses', 'json', { interface: 'list', note: 'Adressen des Kunden', options: { fields: ADDRESS_SUBFIELDS } }),
f('notes', 'text', { interface: 'input-multiline', note: 'Interne Notizen (für den Kunden nicht sichtbar).' }),
{ field: 'signatures', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Hinterlegte Unterschriften des Kunden.' }, schema: null },
{ field: 'discounts', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Kundenspezifische Rabatte.' }, schema: null },
{ field: 'unlocked_products', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Individuell freigeschaltete Produkte.' }, schema: null },
];
// directus_users-Felder, die Skrift nicht braucht – werden ausgeblendet.
const HIDE_USER_FIELDS = ['location', 'title', 'description', 'tags', 'avatar', 'language',
'appearance', 'theme_light', 'theme_dark', 'theme_light_overrides', 'theme_dark_overrides',
'text_direction', 'email_notifications'];
// ── Collections ─────────────────────────────────────────────────────────────
const collections = [
{ collection: 'customers', meta: { icon: 'person', note: 'Kunde – existiert auch ohne Login (Zuordnung per E-Mail).' }, fields: [
f('email', 'string', { interface: 'input', unique: true, required: true }),
dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat' }),
f('first_name', 'string', { interface: 'input' }),
f('last_name', 'string', { interface: 'input' }),
f('company', 'string', { interface: 'input' }),
f('phone', 'string', { interface: 'input' }),
f('addresses', 'json', { interface: 'list', note: 'Adressen des Kunden' }),
m2o('linked_user', 'directus_users', { note: 'Verknüpftes Login-Konto (falls aktiviert).' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
{ collection: 'products', meta: { icon: 'inventory_2', note: 'Produkte inkl. Kontakt-Produkte.' }, fields: [
f('key', 'string', { interface: 'input', unique: true, required: true }),
f('name', 'string', { interface: 'input', required: true }),
f('description', 'text', { interface: 'input-multiline' }),
dropdown('type', ['letter', 'postcard', 'sample', 'contact'], { required: true }),
dropdown('visibility', ['alle', 'privat', 'unternehmen'], { default: 'alle', note: 'Für wen im Konfigurator sichtbar.' }),
dropdown('pricing_mode', ['formel', 'auf_anfrage'], { default: 'formel' }),
f('base_price', 'float', { interface: 'input', note: 'Basispreis (netto). Leer bei auf_anfrage.' }),
// Ab dieser Menge gilt der Normalpreis (Multiplikator 1) – je Produkt.
// Leer = Rückfall auf die globale Norm-Menge in den Preis-Einstellungen.
f('norm_qty', 'float', { interface: 'input', note: 'Menge, ab der der Normalpreis gilt (Multiplikator 1). Leer = globaler Wert.' }),
// Eingabeart im Konfigurator: freier Text oder Datei-Upload (z. B. ein zu
// unterschreibendes PDF beim Unterschriftenservice).
dropdown('input_mode', ['text', 'datei'], { default: 'text', note: 'Text-Eingabe oder Datei-Upload.' }),
f('active', 'boolean', { interface: 'boolean', default: true }),
f('sort', 'integer', { interface: 'input' }),
] },
@@ -129,6 +156,7 @@ const collections = [
{ collection: 'motifs', meta: { icon: 'image', note: 'Motiv-Katalog für Postkarten.' }, fields: [
f('name', 'string', { interface: 'input', required: true }),
m2o('image', 'directus_files'),
dropdown('orientation', ['quer', 'hochkant'], { default: 'quer', note: 'Passend zum Postkartenformat.' }),
f('active', 'boolean', { interface: 'boolean', default: true }),
f('sort', 'integer', { interface: 'input' }),
] },
@@ -143,11 +171,33 @@ const collections = [
] },
{ collection: 'pricing_settings', meta: { icon: 'calculate', singleton: true, note: 'Formel + globale Werte.' }, fields: [
f('formula', 'text', { interface: 'input-multiline', note: 'Preisformel (editierbar).' }),
f('formula', 'text', { interface: 'input-multiline', note: 'Grundpreis-Formel. Variablen: base_price, menge.' }),
// Mengen-Multiplikator (Kleinmengen-Aufschlag), je Segment editierbar.
// Variablen: qty, norm_b, norm_c, mind_b, mind_c, base_price. Funktionen:
// sqrt/abs/min/max/round. Vergleiche + Ternär (cond ? a : b) erlaubt.
// Beispiel: (qty >= norm_b) ? 1 : (2 - sqrt(qty / norm_b))
// Eine gemeinsame Formel für Privat und Unternehmen. `norm` ist die
// Normalpreis-Menge des jeweiligen Produkts (products.norm_qty).
f('multiplier_formula', 'text', { interface: 'input-multiline', note: 'Mengen-Multiplikator (gilt für Privat und Unternehmen). Variablen: qty, norm, base_price. Funktionen: sqrt, pow, min, max, round …' }),
f('mwst_percent', 'float', { interface: 'input', default: 19 }),
// Mengenrabatt-Staffel: ab min_qty Stück gilt percent % Rabatt auf den
// Grundpreis. Es greift die höchste passende Stufe. Frei in Directus pflegbar.
{ field: 'quantity_discounts', type: 'json',
meta: { interface: 'list', note: 'Mengenrabatt-Staffel (ab X Stück Y %). Höchste passende Stufe greift.',
options: { fields: [
{ field: 'min_qty', name: 'ab Menge', type: 'integer', meta: { interface: 'input', width: 'half' } },
{ field: 'percent', name: 'Rabatt %', type: 'float', meta: { interface: 'input', width: 'half' } },
] } },
schema: {} },
f('paypal_limit_net', 'float', { interface: 'input', default: 200, note: 'Ab diesem Netto-Wert nur Rechnung.' }),
f('char_whitelist', 'text', { interface: 'input-multiline', note: 'Erlaubte Zeichen (Regex-Klasse, 1:1 aus job-manager).' }),
f('currency', 'string', { interface: 'input', default: 'EUR' }),
// PayPal: NUR die Client-ID (öffentlich). Das Secret gehört nicht hierher –
// diese Collection ist für Gäste lesbar, und clientseitig wird es nicht gebraucht.
f('paypal_enabled', 'boolean', { interface: 'boolean', default: false }),
dropdown('paypal_mode', ['sandbox', 'live'], { default: 'sandbox' }),
f('paypal_client_id_sandbox', 'string', { interface: 'input', note: 'Öffentliche Client-ID (Sandbox).' }),
f('paypal_client_id_live', 'string', { interface: 'input', note: 'Öffentliche Client-ID (Live).' }),
] },
{ collection: 'vouchers', meta: { icon: 'confirmation_number', note: 'Gutscheine.' }, fields: [
@@ -162,7 +212,7 @@ const collections = [
{ collection: 'orders', meta: { icon: 'receipt_long', note: 'Auftrag.' }, fields: [
f('order_number', 'string', { interface: 'input', unique: true, required: true }),
m2o('customer', 'customers'),
m2o('customer', 'directus_users', { note: 'Kunde = Login-Konto (Zuordnung per E-Mail).' }),
f('customer_email', 'string', { interface: 'input', required: true, note: 'Zuordnungsschlüssel.' }),
m2o('product', 'products'),
m2o('format', 'formats'),
@@ -172,6 +222,13 @@ const collections = [
dropdown('status', ['entwurf', 'vorbereitet', 'wartet_auf_zahlung', 'bezahlt', 'material_erwartet',
'material_eingegangen', 'in_queue', 'in_produktion', 'gedruckt', 'versendet', 'abgeschlossen', 'storniert'],
{ default: 'entwurf' }),
// Produktionsstatus (fürs Kanban) – getrennt vom kaufmännischen Status.
dropdown('production_status', ['eingegangen', 'im_druck', 'kuvertieren', 'versandfertig', 'versendet', 'storniert'],
{ default: 'eingegangen', note: 'Produktions-/Kanban-Status.' }),
f('brief_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Schriftstücke gedruckt (vom Agent gesetzt).' }),
f('kuvert_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Umschläge gedruckt (vom Agent gesetzt).' }),
// Zeitpunkt des Versands – Basis fürs Auto-Archiv (nach 14 Tagen).
{ field: 'versandt_am', type: 'timestamp', meta: { interface: 'datetime', note: 'Wird beim Wechsel auf „versendet" gesetzt.' }, 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']),
@@ -182,7 +239,17 @@ const collections = [
f('own_cards', 'boolean', { interface: 'boolean', default: false, note: 'Eigene Karten (≤ 240 g/m²).' }),
f('text_briefing', 'text', { interface: 'input-multiline', note: 'Nur bei Schreibservice.' }),
f('text_template', 'text', { interface: 'input-multiline', note: 'Text mit [[Platzhaltern]].' }),
f('preview_session', 'string', { interface: 'input', note: 'Vorschau-Session – gezeigte Dokumente werden 1:1 übernommen.' }),
// Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF/Word).
{ field: 'source_file', type: 'uuid',
meta: { interface: 'file', special: ['file'], note: 'Hochgeladenes Dokument (Datei-Produkte).' },
schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' },
// Rechnungs-/Lieferadresse als Snapshot (für Bestätigung, Rechnung, Versand).
f('billing', 'json', { interface: 'input-code', note: 'Rechnungsadresse (Snapshot bei Bestellung).' }),
f('shipping_address', 'json', { interface: 'input-code', note: 'Abweichende Lieferadresse (falls vorhanden).' }),
f('entries_count', 'integer', { interface: 'input', default: 0 }),
f('count_inland', 'integer', { interface: 'input', default: 0, note: 'Empfängerzeilen Deutschland.' }),
f('count_ausland', 'integer', { interface: 'input', default: 0, note: 'Empfängerzeilen Ausland.' }),
f('net_total', 'float', { interface: 'input' }),
f('vat_amount', 'float', { interface: 'input' }),
f('gross_total', 'float', { interface: 'input' }),
@@ -193,8 +260,30 @@ const collections = [
f('artifact_path', 'string', { interface: 'input', note: 'Verweis auf Backend-Output.' }),
{ 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: {} },
// Produktionsansicht: durchsuchbare Empfängertabelle (Briefnummer + volle
// Anschrift, ganze Zeile) – wie im alten Job-Manager. Nur lesend/suchend.
{ field: 'entries_view', type: 'alias',
meta: { interface: 'skrift-order-entries', special: ['alias', 'no-data'],
note: 'Alle Empfänger durchsuchbar (Briefnummer + volle Anschrift).' },
schema: null },
// Empfängerzeilen direkt im Auftrag (Adresse bzw. alle Freitext-Zeilen
// bearbeitbar) – kein Wechsel auf eine eigene Seite nötig.
{ field: 'entries', type: 'alias',
meta: { interface: 'list-o2m', special: ['o2m'],
options: { template: '#{{letter_number}} · {{first_name}} {{last_name}} · {{zip}} {{city}}' },
note: 'Empfängerzeilen bearbeiten (Adresse korrigieren).' },
schema: null },
// m2m-Alias zu price_items über order_addons:
{ field: 'addons', type: 'alias', meta: { interface: 'list-m2m', special: ['m2m'] }, schema: null },
// Präsentationsfeld: zeigt die generierten Dateien per Interface-Plugin
// (Direktzugriff auf den Ausgabe-Ordner, keine Kopie).
{ field: 'files_view', type: 'alias',
meta: { interface: 'skrift-order-files', special: ['alias', 'no-data'], note: 'Generierte Dateien (Direktzugriff).' },
schema: null },
// Präsentationsfeld: „Drucken"-Button (reiht Druck-Jobs für eine Maschine ein).
{ field: 'print_action', type: 'alias',
meta: { interface: 'skrift-print-button', special: ['alias', 'no-data'], note: 'In die Druck-Warteschlange einreihen.' },
schema: null },
] },
{ collection: 'order_entries', meta: { icon: 'table_rows', note: 'Empfängerzeilen (Tabelle/Excel).' }, fields: [
@@ -217,13 +306,67 @@ const collections = [
m2o('price_item', 'price_items', { onDelete: 'CASCADE' }),
] },
{ collection: 'jobs', meta: { icon: 'print', note: 'Produktions-Job (Queue – wird später verfeinert).' }, fields: [
m2o('order', 'orders', { onDelete: 'CASCADE' }),
dropdown('status', ['queued', 'printing', 'printed', 'failed', 'shipped'], { default: 'queued' }),
f('priority', 'integer', { interface: 'input', default: 0 }),
// Plotter/Schreibmaschinen – im Admin pflegbar, Basis für die Warteschlange.
{ collection: 'machines', meta: { icon: 'print', note: 'Produktionsmaschinen (Plotter).' }, fields: [
f('name', 'string', { interface: 'input', required: true }),
f('base_url', 'string', { interface: 'input', required: true, note: 'z. B. http://192.168.2.32:90' }),
f('mid', 'string', { interface: 'input', note: 'Maschinen-ID an der Steuerung (optional).' }),
f('username', 'string', { interface: 'input', note: 'Login an der Steuerung (falls nötig).' }),
f('password', 'string', { interface: 'input', options: { masked: true }, note: 'Login-Passwort (falls nötig).' }),
f('poll_interval_seconds', 'integer', { interface: 'input', default: 5 }),
f('active', 'boolean', { interface: 'boolean', default: true }),
dropdown('status', ['unbekannt', 'bereit', 'druckt', 'fehler', 'offline'], { default: 'unbekannt', note: 'Vom Agent gemeldet.' }),
{ field: 'last_seen', type: 'timestamp', meta: { interface: 'datetime', note: 'Letzte Rückmeldung des Agents.' }, schema: {} },
f('notes', 'text', { interface: 'input-multiline' }),
] },
// Testdruck: beliebiges Template + Daten schnell generieren und drucken.
{ collection: 'test_prints', meta: { icon: 'science', note: 'Testdrucke zum Verproben von Templates & Maschinen.' }, fields: [
f('label', 'string', { interface: 'input', note: 'Bezeichnung (optional).' }),
m2o('template', 'format_templates', { note: 'Druck-Template (Platzierung an der Maschine).' }),
dropdown('format', ['a4', 'a6_hoch', 'a6_quer'], { default: 'a4', note: 'Layout für die Textgenerierung.' }),
dropdown('font', ['tilda', 'alva', 'ellie'], { default: 'tilda' }),
m2o('machine', 'machines'),
f('text', 'text', { interface: 'input-multiline', note: 'Testtext (Platzhalter werden nicht ersetzt).' }),
dropdown('status', ['neu', 'generiert', 'fehler'], { default: 'neu' }),
f('output_ref', 'string', { interface: 'input', readonly: true, note: 'Ausgabe-Ordner des Testdrucks.' }),
{ field: 'run_action', type: 'alias',
meta: { interface: 'skrift-testprint-button', special: ['alias', 'no-data'], note: 'Generieren & in die Druck-Warteschlange legen.' },
schema: null },
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// Druck-Templates (Maße/Position/Rotation in Zoll) – aus config.json.
{ collection: 'format_templates', meta: { icon: 'straighten', note: 'Druck-Templates (Zoll).' }, fields: [
f('key', 'string', { interface: 'input', unique: true, required: true }),
f('name', 'string', { interface: 'input', required: true }),
dropdown('type', ['brief', 'umschlag', 'signatur'], { default: 'brief' }),
f('width', 'float', { interface: 'input', note: 'Zoll' }),
f('height', 'float', { interface: 'input', note: 'Zoll' }),
f('xpos', 'float', { interface: 'input', default: 0 }),
f('ypos', 'float', { interface: 'input', default: 0 }),
f('scale', 'float', { interface: 'input', default: 1 }),
f('rotation', 'integer', { interface: 'input', default: 0 }),
] },
{ collection: 'jobs', meta: { icon: 'print', note: 'Produktions-Job (Druck-Warteschlange je Maschine).' }, fields: [
m2o('order', 'orders', { onDelete: 'CASCADE' }),
f('output_ref', 'string', { interface: 'input', note: 'Ausgabe-Ordner ohne Auftrag (z. B. Testdruck).' }),
m2o('machine', 'machines'),
dropdown('type', ['brief', 'umschlag', 'signatur'], { default: 'brief', note: 'Was gedruckt wird.' }),
m2o('template', 'format_templates', { note: 'Aufgelöstes Druck-Template.' }),
dropdown('status', ['queued', 'printing', 'printed', 'failed', 'shipped'], { default: 'queued' }),
f('priority', 'integer', { interface: 'input', default: 0, note: 'Höher = zuerst. Direktdruck bekommt hohe Priorität.' }),
// Pro-Druck-Übersteuerung der Template-Maße (Breite/Höhe/X/Y/Scale/Rot).
// Vom Druck-Popup gesetzt; leer = Template-Werte verwenden.
f('params', 'json', { interface: 'input-code', note: 'Maß-Übersteuerung {width,height,xpos,ypos,scale,rotation} (leer = Template).' }),
// Welche Dokumentnummern gedruckt werden (1-basiert, z. B. „1-20" oder „1,3,5-7"). Leer = alle.
f('numbers', 'string', { interface: 'input', note: 'Zu druckende Briefnummern (1-basiert). Leer = alle.' }),
f('error', 'text', { interface: 'input-multiline', note: 'Fehlermeldung vom Agent (falls fehlgeschlagen).' }),
{ 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: {} },
] },
{ collection: 'status_history', meta: { icon: 'history', note: 'Status-Timeline fürs Portal.' }, fields: [
m2o('order', 'orders', { onDelete: 'CASCADE' }),
f('status', 'string', { interface: 'input' }),
@@ -232,6 +375,88 @@ const collections = [
{ field: 'timestamp', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// Unterschriften je Kunde (SVG-Datei). Wird am Kundenkonto als o2m gezeigt.
{ collection: 'signatures', meta: { icon: 'draw', note: 'Unterschriften je Kunde.' }, fields: [
m2o('customer', 'directus_users', { onDelete: 'CASCADE' }),
f('label', 'string', { interface: 'input', note: 'z. B. „Standard", „mit Titel".' }),
// Datei-Upload (SVG) statt Textfeld.
{ field: 'file', type: 'uuid',
meta: { interface: 'file', special: ['file'], note: 'Signatur als SVG-Datei (Upload).' },
schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' },
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// Kundenspezifische Rabatte. Wird am Kundenkonto als o2m gezeigt.
{ collection: 'customer_discounts', meta: { icon: 'percent', note: 'Kundenspezifische Rabatte.' }, fields: [
m2o('customer', 'directus_users', { onDelete: 'CASCADE' }),
f('label', 'string', { interface: 'input', note: 'z. B. „Einführungsrabatt".' }),
dropdown('type', ['prozent', 'fest'], { default: 'prozent' }),
f('value', 'float', { interface: 'input', note: 'Prozent oder €-Betrag.' }),
dropdown('scope', ['alle', 'erste_n_stueck'], { default: 'alle', note: 'Gilt für alle oder nur die ersten N Stück.' }),
f('limit_stueck', 'integer', { interface: 'input', note: 'N bei „erste_n_stueck".' }),
f('active', 'boolean', { interface: 'boolean', default: true }),
f('valid_until', 'timestamp', { interface: 'datetime', note: 'Optionales Ablaufdatum.' }),
] },
// Für einzelne Kunden freigeschaltete Produkte (Override der Sichtbarkeit).
{ collection: 'customer_products', meta: { icon: 'lock_open', note: 'Freigeschaltete Produkte je Kunde.', hidden: true }, fields: [
m2o('customer', 'directus_users', { onDelete: 'CASCADE' }),
m2o('product', 'products', { onDelete: 'CASCADE' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// E-Mail-Vorlagen (Betreff + HTML-Text, WYSIWYG). Ein Datensatz je Ereignis.
{ collection: 'email_templates', meta: { icon: 'mail', note: 'E-Mail-Vorlagen (Betreff + Text).' }, fields: [
f('key', 'string', { interface: 'input', unique: true, required: true, note: 'Ereignis-Schlüssel (login_code, order_created, versendet …).' }),
f('label', 'string', { interface: 'input', note: 'Bezeichnung im Admin.' }),
f('subject', 'string', { interface: 'input', required: true, note: 'Betreff. Platzhalter: {{order_id}}, {{name}}, {{code}}, {{link}} …' }),
f('body', 'text', { interface: 'input-rich-text-html', note: 'Text (WYSIWYG/HTML). Platzhalter in {{...}}.' }),
f('append_signature', 'boolean', { interface: 'boolean', default: true, note: 'Signatur automatisch anhängen (bei Mails mit eigenem Footer aus).' }),
f('attach_agb', 'boolean', { interface: 'boolean', default: false, note: 'AGB-PDF an diese Mail anhängen.' }),
f('enabled', 'boolean', { interface: 'boolean', default: true }),
] },
// Gemeinsame E-Mail-Einstellungen (Signatur, Portal-Link, AGB, Footer).
{ collection: 'email_settings', meta: { icon: 'alternate_email', singleton: true, note: 'Absender, Signatur, Portal-Link, AGB + Footer für alle Mails.' }, fields: [
f('sender_name', 'string', { interface: 'input', note: 'Absendername (erscheint als Absender, z. B. „Skrift").' }),
f('sender_email', 'string', { interface: 'input', note: 'Absenderadresse (muss vom SMTP-Server erlaubt sein).' }),
f('signature', 'text', { interface: 'input-rich-text-html', note: 'Signatur unter Mails ohne eigenen Footer (WYSIWYG/HTML).' }),
f('portal_url', 'string', { interface: 'input', note: 'Basis-URL des Kundenportals (Platzhalter {{link}}).' }),
f('company_footer', 'text', { interface: 'input-rich-text-html', note: 'Footer-Adresszeile (Platzhalter {{footer}}).' }),
{ field: 'agb_file', type: 'uuid',
meta: { interface: 'file', special: ['file'], note: 'AGB als PDF – wird an Mails mit „AGB anhängen" gehängt. Zum Ändern einfach neue Datei hochladen.' },
schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' },
] },
// Standard-Mustertexte für den manuellen Muster-Druck (Du-/Sie-Form).
// Platzhalter [[abschnitt]] füllt der Bediener im Druck-Dialog kurz aus.
{ collection: 'muster_settings', meta: { icon: 'science', singleton: true, note: 'Standard-Mustertexte (Du/Sie) für den Muster-Druck.' }, fields: [
f('text_du', 'text', { interface: 'input-multiline', note: 'Standard-Mustertext (Du-Form). Platzhalter [[abschnitt]] wird im Druck-Dialog gefüllt.' }),
f('text_sie', 'text', { interface: 'input-multiline', note: 'Standard-Mustertext (Sie-Form). Platzhalter [[abschnitt]] wird im Druck-Dialog gefüllt.' }),
] },
// Angebots- und Musteranfragen aus der App.
{ collection: 'offer_requests', meta: { icon: 'request_quote', note: 'Angebots- und Musteranfragen aus der App.' }, fields: [
dropdown('type', ['angebot', 'muster'], { default: 'angebot' }),
f('name', 'string', { interface: 'input', required: true }),
f('email', 'string', { interface: 'input', required: true }),
f('phone', 'string', { interface: 'input' }),
f('quantity', 'integer', { interface: 'input' }),
f('product', 'string', { interface: 'input', note: 'Bei Muster: gewünschtes Produkt.' }),
f('message', 'text', { interface: 'input-multiline' }),
f('address', 'json', { interface: 'input-code', note: 'Bei Muster: Lieferadresse.' }),
dropdown('status', ['neu', 'in_bearbeitung', 'beantwortet', 'abgelehnt'], { default: 'neu' }),
m2o('customer', 'directus_users', { note: 'Falls die Anfrage von einem bestehenden Konto kommt.' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// Vorschau-Kontingent (serverseitig – der Client darf das nicht bestimmen).
{ collection: 'preview_usage', meta: { icon: 'speed', hidden: true, note: 'Vorschau-Verbrauch je Kennung und Tag.' }, fields: [
f('key', 'string', { interface: 'input', required: true, note: 'E-Mail oder anonyme Kennung.' }),
f('day', 'string', { interface: 'input', required: true }),
f('count', 'integer', { interface: 'input', default: 0 }),
] },
// Einmalcodes für den passwortlosen Login (nur die Extension greift darauf zu).
{ collection: 'login_codes', meta: { icon: 'key', hidden: true, note: 'Einmalcodes für den passwortlosen Login (nur Hashes).' }, fields: [
f('email', 'string', { interface: 'input', required: true }),
@@ -268,6 +493,29 @@ async function applySchema() {
}
}
// Profilfelder direkt am Login-Konto (Kunde = directus_users).
console.log('› Kundenfelder an directus_users');
for (const fld of USER_FIELDS) {
if (!existingFields.has(`directus_users.${fld.field}`)) {
await api('POST', '/fields/directus_users', fld);
log('field +', `directus_users.${fld.field}`);
} else {
// Vorhandenes Feld: Oberfläche/Optionen aktualisieren (z. B. Adressliste).
try { await api('PATCH', `/fields/directus_users/${fld.field}`, { meta: fld.meta }); } catch { /* egal */ }
}
}
// Ungenutzte Standardfelder ausblenden. Direkt patchen (System-Felder existieren
// immer); ein Fehler wird geloggt statt still verschluckt – so sieht man, falls
// ein Feldname in dieser Directus-Version anders heißt.
for (const field of HIDE_USER_FIELDS) {
try {
await api('PATCH', `/fields/directus_users/${field}`, { meta: { hidden: true } });
log('feld versteckt', `directus_users.${field}`);
} catch (e) {
console.warn(` Feld directus_users.${field} nicht ausgeblendet: ${e.message}`);
}
}
console.log('› Beziehungen (m2o)');
for (const r of relationsToCreate) {
if (existingRels.has(`${r.collection}.${r.field}`)) { continue; }
@@ -285,6 +533,16 @@ async function applySchema() {
await api('PATCH', '/relations/order_addons/price_item', { meta: { junction_field: 'order' } });
log('m2m verdrahtet');
} catch (e) { console.warn(' m2m-Verdrahtung übersprungen (bitte melden):', e.message); }
// o2m am Kundenkonto: Unterschriften + Rabatte direkt im User anzeigen.
console.log('› o2m-Verdrahtung');
try {
await api('PATCH', '/relations/signatures/customer', { meta: { one_field: 'signatures' } });
await api('PATCH', '/relations/customer_discounts/customer', { meta: { one_field: 'discounts' } });
await api('PATCH', '/relations/customer_products/customer', { meta: { one_field: 'unlocked_products' } });
await api('PATCH', '/relations/order_entries/order', { meta: { one_field: 'entries' } });
log('o2m verdrahtet');
} catch (e) { console.warn(' o2m-Verdrahtung übersprungen (bitte melden):', e.message); }
}
// ── Seed-Daten ──────────────────────────────────────────────────────────────
@@ -299,11 +557,14 @@ async function seedItem(collection, uniqueField, item) {
async function applySeed() {
console.log('› Seed');
const products = [
{ key: 'briefe', name: 'Briefe', type: 'letter', pricing_mode: 'formel', base_price: 4.0, active: true, sort: 1 },
{ key: 'postkarten', name: 'Postkarten', type: 'postcard', pricing_mode: 'formel', base_price: 1.3, active: true, sort: 2 },
{ key: 'muster', name: 'Muster', type: 'sample', pricing_mode: 'formel', base_price: 0, active: true, sort: 3 },
{ key: 'unterschriftenservice', name: 'Unterschriftenservice', type: 'contact', pricing_mode: 'auf_anfrage', active: true, sort: 4 },
{ key: 'follow_ups', name: 'Automatisierte Follow-ups', type: 'contact', pricing_mode: 'auf_anfrage', active: true, sort: 5 },
{ key: 'briefe', name: 'Briefe', type: 'letter', visibility: 'alle', pricing_mode: 'formel', base_price: 4.0, norm_qty: 100, active: true, sort: 1 },
{ key: 'postkarten', name: 'Postkarten', type: 'postcard', visibility: 'alle', pricing_mode: 'formel', base_price: 1.3, norm_qty: 100, active: true, sort: 2 },
{ key: 'muster', name: 'Muster', type: 'sample', visibility: 'alle', pricing_mode: 'formel', base_price: 0, active: true, sort: 3 },
// Datei-Produkt: pro Kunde freischaltbar, Kunde lädt ein PDF/Word hoch.
// pricing_mode „formel" + base_price, damit es für freigeschaltete Kunden
// bestellbar ist; für andere bleibt es (type contact) ein Anfrage-Produkt.
{ key: 'unterschriftenservice', name: 'Unterschriftenservice', type: 'contact', visibility: 'unternehmen', pricing_mode: 'formel', base_price: 5.0, input_mode: 'datei', active: true, sort: 4 },
{ key: 'follow_ups', name: 'Automatisierte Follow-ups', type: 'contact', visibility: 'unternehmen', pricing_mode: 'auf_anfrage', active: true, sort: 5 },
];
const productByKey = {};
for (const p of products) productByKey[p.key] = await seedItem('products', 'key', p);
@@ -334,18 +595,119 @@ async function applySeed() {
await api('PATCH', '/items/pricing_settings', {
formula: 'base_price * menge', mwst_percent: 19, paypal_limit_net: 200,
currency: 'EUR', char_whitelist: CHAR_WHITELIST,
// Beispiel-Staffel (relativ hoch angesetzt) – in Directus anpassbar.
quantity_discounts: [
{ min_qty: 250, percent: 5 },
{ min_qty: 500, percent: 10 },
{ min_qty: 1000, percent: 15 },
],
});
log('seed singleton pricing_settings');
}
// Gemeinsame Mengen-Multiplikator-Formel: nur setzen, wenn leer (Edits bleiben
// erhalten). `norm` kommt je Produkt aus products.norm_qty.
if (!ps || !ps.multiplier_formula) {
await api('PATCH', '/items/pricing_settings', {
multiplier_formula: '(qty >= norm) ? 1 : (1 + 2 * pow(1 - qty / norm, 3))',
});
log('seed multiplier-formel');
}
// E-Mail-Vorlagen (nur anlegen, wenn noch nicht vorhanden – Edits bleiben erhalten).
const G = 'Guten Tag {{name}},'; // gemeinsame Anrede
// Reiche Bestellbestätigung (Design an das bisherige n8n-Template angelehnt,
// aber mit flachen {{platzhaltern}}, die der Mailer-Hook befüllt).
const orderCreatedBody = `
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#f5f5f5;font-family:Arial,Helvetica,sans-serif;color:#333;">
<tr><td style="padding:20px 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" align="center" style="background:#fff;border-radius:8px;overflow:hidden;">
<tr><td style="background:#1a365d;padding:30px 40px;text-align:center;"><h1 style="margin:0;color:#fff;font-size:26px;">Bestellbestätigung</h1></td></tr>
<tr><td style="padding:36px 40px 8px;"><p style="margin:0 0 16px;font-size:18px;">Guten Tag {{name}},</p><p style="margin:0 0 16px;">vielen Dank für Ihre Bestellung bei Skrift. Wir haben sie erhalten und bearbeiten sie schnellstmöglich.</p></td></tr>
<tr><td style="padding:0 40px 24px;"><table role="presentation" width="100%" style="background:#f0f7ff;border-radius:8px;border-left:4px solid #1a365d;"><tr><td style="padding:18px;"><p style="margin:0;font-size:14px;color:#666;">Ihre Bestellnummer:</p><p style="margin:4px 0 0;font-size:22px;font-weight:700;color:#1a365d;">{{order_id}}</p></td></tr></table></td></tr>
<tr><td style="padding:8px 40px 4px;"><h2 style="margin:0 0 12px;font-size:18px;color:#1a365d;">Ihre Bestellung</h2>
<table role="presentation" width="100%">
<tr><td width="40%" style="padding:6px 0;color:#666;">Produkt:</td><td style="padding:6px 0;font-weight:600;">{{product}}</td></tr>
<tr><td style="padding:6px 0;color:#666;">Menge:</td><td style="padding:6px 0;font-weight:600;">{{menge}} Stück</td></tr>
<tr><td style="padding:6px 0;color:#666;">Format:</td><td style="padding:6px 0;font-weight:600;">{{format}}</td></tr>
<tr><td style="padding:6px 0;color:#666;">Versand:</td><td style="padding:6px 0;font-weight:600;">{{versand}}</td></tr>
</table>{{kuvert_block}}</td></tr>
<tr><td style="padding:16px 40px 4px;"><h2 style="margin:0 0 12px;font-size:18px;color:#1a365d;">Preisübersicht</h2>
<table role="presentation" width="100%">
<tr><td style="padding:6px 0;color:#666;">Zwischensumme (netto):</td><td style="padding:6px 0;text-align:right;">{{netto}} {{waehrung}}</td></tr>
<tr><td style="padding:6px 0;color:#666;">MwSt. ({{mwst_satz}} %):</td><td style="padding:6px 0;text-align:right;">{{mwst_betrag}} {{waehrung}}</td></tr>
<tr><td colspan="2" style="padding:8px 0;"><hr style="border:none;border-top:2px solid #1a365d;margin:0;"></td></tr>
<tr><td style="padding:6px 0;font-size:17px;font-weight:700;color:#1a365d;">Gesamt (brutto):</td><td style="padding:6px 0;text-align:right;font-size:17px;font-weight:700;color:#1a365d;">{{brutto}} {{waehrung}}</td></tr>
</table></td></tr>
<tr><td style="padding:16px 40px 4px;"><h2 style="margin:0 0 12px;font-size:18px;color:#1a365d;">Rechnungsadresse</h2><p style="margin:0;line-height:1.6;">{{rechnungsadresse}}</p></td></tr>
{{lieferadresse_block}}
<tr><td style="padding:16px 40px;"><h2 style="margin:0 0 10px;font-size:18px;color:#1a365d;">Wie geht es weiter?</h2><ol style="margin:0;padding-left:20px;line-height:1.7;"><li>Wir prüfen Ihre Bestellung und bereiten die Produktion vor.</li><li>Nach Fertigstellung erhalten Sie eine Versandbestätigung.</li></ol></td></tr>
<tr><td style="background:#f5f5f5;padding:24px 40px;text-align:center;border-top:1px solid #e0e0e0;"><p style="margin:0 0 8px;font-size:14px;color:#666;">Mit freundlichen Grüßen<br><strong>Ihr Skrift-Team</strong></p><p style="margin:12px 0 0;font-size:12px;color:#999;">{{footer}}</p></td></tr>
</table></td></tr></table>`.trim();
const templates = [
{ key: 'login_code', label: 'Anmeldecode', subject: 'Ihr Anmeldecode: {{code}}', append_signature: true, attach_agb: false,
body: `<p>Guten Tag,</p><p>Ihr Anmeldecode für Skrift lautet:</p>` +
`<p style="font-size:28px;font-weight:700;letter-spacing:4px">{{code}}</p>` +
`<p>Der Code ist 10 Minuten gültig. Falls Sie keine Anmeldung angefordert haben, ignorieren Sie diese E-Mail.</p>` },
{ key: 'order_created', label: 'Bestellbestätigung', subject: 'Ihre Bestellung {{order_id}} ist eingegangen',
append_signature: false, attach_agb: true, body: orderCreatedBody },
{ key: 'bezahlt', label: 'Status: bezahlt', subject: 'Zahlung erhalten – {{order_id}}', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>Ihre Zahlung zu Auftrag <strong>{{order_id}}</strong> ist bei uns eingegangen. Wir starten die Produktion.</p>` },
{ key: 'in_produktion', label: 'Status: in Produktion', subject: '{{order_id}} ist in Produktion', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>Ihr Auftrag <strong>{{order_id}}</strong> ist jetzt in Produktion.</p>` },
{ key: 'versendet', label: 'Status: versendet', subject: '{{order_id}} wurde versendet', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>Ihre Schriftstücke zu Auftrag <strong>{{order_id}}</strong> wurden versendet bzw. an Sie übergeben. Vielen Dank für Ihr Vertrauen!</p>` },
{ key: 'abgeschlossen', label: 'Status: abgeschlossen', subject: '{{order_id}} abgeschlossen', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>Ihr Auftrag <strong>{{order_id}}</strong> ist abgeschlossen. Wir freuen uns auf Ihre nächste Bestellung.</p>` },
{ key: 'storniert', label: 'Status: storniert', subject: '{{order_id}} wurde storniert', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>Ihr Auftrag <strong>{{order_id}}</strong> wurde storniert. Bei Fragen sind wir gerne für Sie da.</p>{{reason}}` },
];
for (const t of templates) await seedItem('email_templates', 'key', { enabled: true, ...t });
// Druck-Templates (aus der bisherigen config.json der Produktions-App).
const formatTemplates = [
{ key: 'a4_single', name: 'A4 Brief – Einzeln', type: 'brief', width: 8.27, height: 11.69, xpos: 0.0, ypos: -0.3, scale: 1.0, rotation: 0 },
{ key: 'a6_v1', name: 'A6 Brief – Hochformat', type: 'brief', width: 4.1, height: 5.8, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 },
{ key: 'a6_v2', name: 'A6 Brief – Querformat', type: 'brief', width: 4.1, height: 5.8, xpos: 4.0, ypos: -0.3, scale: 1.0, rotation: 90 },
{ key: 'kuvert_a_v1', name: 'Kuvert DIN Lang', type: 'umschlag', width: 4.49, height: 6.38, xpos: 4.2, ypos: -0.2, scale: 1.0, rotation: 90 },
{ key: 'kuvert_b_v1', name: 'Kuvert C6', type: 'umschlag', width: 3.9, height: 8.27, xpos: 4.49, ypos: -0.2, scale: 1.0, rotation: 90 },
];
for (const t of formatTemplates) await seedItem('format_templates', 'key', t);
// email_settings (Singleton) – Signatur nur setzen, wenn leer.
const es = await api('GET', '/items/email_settings');
if (!es || !es.signature) {
await api('PATCH', '/items/email_settings', {
sender_name: 'Skrift',
sender_email: (process.env.EMAIL_FROM || 'noreply@skrift.de').trim(),
signature: '<p>Mit freundlichen Grüßen<br>Ihr Skrift-Team</p>',
portal_url: (process.env.CORS_ORIGIN || 'https://app.skrift.de').split(',')[0].trim(),
company_footer: 'Skrift | Hundscheiderweg 4 | 66679 Losheim am See | www.skrift.de',
});
log('seed singleton email_settings');
}
// muster_settings (Singleton) – Standardtexte nur setzen, wenn leer.
const ms = await api('GET', '/items/muster_settings');
if (!ms || (!ms.text_du && !ms.text_sie)) {
await api('PATCH', '/items/muster_settings', {
text_du: 'Hallo [[Vorname]],\n\nschön, dass Du Skrift ausprobierst! Dieser Brief wurde von einem Roboter mit einem echten Stift geschrieben.\n\n[[abschnitt]]\n\nHerzliche Grüße\nDein Skrift-Team',
text_sie: 'Sehr geehrte/r [[Anrede]] [[Nachname]],\n\nschön, dass Sie Skrift ausprobieren! Dieser Brief wurde von einem Roboter mit einem echten Stift geschrieben.\n\n[[abschnitt]]\n\nMit freundlichen Grüßen\nIhr Skrift-Team',
});
log('seed singleton muster_settings');
}
}
// ── Rollen / Policies / Rechte (Directus 11) ────────────────────────────────
const BIZ = ['customers', 'products', 'formats', 'motifs', 'price_items', 'pricing_settings',
'vouchers', 'voucher_redemptions', 'orders', 'order_entries', 'order_addons', 'jobs', 'status_history'];
const OWN_ORDER = { customer: { linked_user: { _eq: '$CURRENT_USER' } } };
const OWN_VIA_ORDER = { order: { customer: { linked_user: { _eq: '$CURRENT_USER' } } } };
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',
'email_templates', 'email_settings', 'muster_settings', 'machines', 'format_templates', 'test_prints'];
// Kunde = Login-Konto: Aufträge hängen direkt am Nutzer.
const OWN_ORDER = { customer: { _eq: '$CURRENT_USER' } };
const OWN_VIA_ORDER = { order: { customer: { _eq: '$CURRENT_USER' } } };
const CUSTOMER_ROLE_NAME = 'Kunde (Login)';
const CUSTOMER_ROLE_NAME = 'Kunde';
/** Benennt eine bestehende Rolle um (idempotent – tut nichts, wenn schon umbenannt). */
async function renameRole(oldName, newName) {
@@ -400,17 +762,28 @@ async function applyPermissions() {
await ensurePermission(staffPol.id, col, act);
// Customer: nur Eigenes + Lesen der Kataloge
// (Alt-Name "Customer" umbenennen, damit er sich von der Collection "Kunden" unterscheidet.)
// Alt-Namen auf den finalen "Kunde" ziehen (idempotent).
await renameRole('Customer', CUSTOMER_ROLE_NAME);
await renameRole('Kunde (Login)', CUSTOMER_ROLE_NAME);
const custRole = await ensureRole(CUSTOMER_ROLE_NAME, 'person');
const custPol = await ensurePolicy('Customer', { app: false });
await ensureAccess(custRole.id, custPol.id);
await ensurePermission(custPol.id, 'customers', 'read', ['*'], { linked_user: { _eq: '$CURRENT_USER' } });
await ensurePermission(custPol.id, 'customers', 'update', ['first_name', 'last_name', 'company', 'phone', 'addresses'], { linked_user: { _eq: '$CURRENT_USER' } });
// Kunde = eigenes Login-Konto: eigenes Profil lesen …
await ensurePermission(custPol.id, 'directus_users', 'read',
['id', 'email', 'first_name', 'last_name', 'company', 'phone', 'person_type', 'addresses'],
{ id: { _eq: '$CURRENT_USER' } });
// … und begrenzt selbst pflegen. person_type bewusst NICHT enthalten – die
// Einstufung privat/Unternehmen legt der Betreiber fest, nicht der Kunde.
await ensurePermission(custPol.id, 'directus_users', 'update',
['first_name', 'last_name', 'company', 'phone', 'addresses'],
{ id: { _eq: '$CURRENT_USER' } });
for (const act of ['create', 'read', 'update']) await ensurePermission(custPol.id, 'orders', act, ['*'], OWN_ORDER);
for (const col of ['order_entries', 'order_addons']) for (const act of ['create', 'read', 'update']) await ensurePermission(custPol.id, col, act, ['*'], OWN_VIA_ORDER);
await ensurePermission(custPol.id, 'status_history', 'read', ['*'], OWN_VIA_ORDER);
await ensurePermission(custPol.id, 'jobs', 'read', ['*'], OWN_VIA_ORDER);
// Eigene Produkt-Freischaltungen lesen (bestimmt, welche Produkte der Kunde
// im Konfigurator konfigurieren darf).
await ensurePermission(custPol.id, 'customer_products', 'read', ['*'], { customer: { _eq: '$CURRENT_USER' } });
for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings'])
await ensurePermission(custPol.id, col, 'read');
@@ -425,11 +798,16 @@ async function applyPermissions() {
const svcRole = await ensureRole('Service', 'smart_toy');
const svcPol = await ensurePolicy('Service', { app: false });
await ensureAccess(svcRole.id, svcPol.id);
for (const col of ['orders', 'order_entries', 'order_addons', 'jobs', 'customers', 'products', 'formats', 'motifs', 'price_items'])
for (const col of ['orders', 'order_entries', 'order_addons', 'jobs', 'products', 'formats',
'motifs', 'price_items', 'machines', 'format_templates'])
await ensurePermission(svcPol.id, col, 'read');
await ensurePermission(svcPol.id, 'orders', 'update', ['status', 'artifact_path', 'payment_status', 'payment_ref']);
await ensurePermission(svcPol.id, 'orders', 'update',
['status', 'artifact_path', 'payment_status', 'payment_ref', 'production_status', 'brief_gedruckt', 'kuvert_gedruckt']);
await ensurePermission(svcPol.id, 'jobs', 'create');
await ensurePermission(svcPol.id, 'jobs', 'update', ['status', 'priority']);
// Agent meldet Druckfortschritt zurück.
await ensurePermission(svcPol.id, 'jobs', 'update', ['status', 'priority', 'error']);
// Agent meldet Maschinenzustand (Heartbeat).
await ensurePermission(svcPol.id, 'machines', 'update', ['status', 'last_seen']);
await ensurePermission(svcPol.id, 'status_history', 'create');
console.log(' Hinweis: Für Service einen Nutzer in Rolle "Service" anlegen und dessen',
@@ -440,7 +818,6 @@ async function applyPermissions() {
async function applyLabels() {
console.log('› Anzeigenamen');
const labels = {
customers: { de: ['Kunde', 'Kunden'], en: ['Customer record', 'Customer records'] },
orders: { de: ['Auftrag', 'Aufträge'], en: ['Order', 'Orders'] },
order_entries: { de: ['Empfängerzeile', 'Empfängerzeilen'], en: ['Entry', 'Entries'] },
price_items: { de: ['Preisposition', 'Preise'], en: ['Price item', 'Price items'] },
@@ -460,6 +837,173 @@ async function applyLabels() {
}
}
// ── Navigation gruppieren ────────────────────────────────────────────────────
// Hauptansicht bewusst schlank: nur Aufträge, Jobs, Gutscheine, Verlauf.
// Alles Konfigurations-artige (Produkte, Preise, Motive …) wandert in einen
// Ordner „Einstellungen"; reine Detail-/Technik-Collections werden versteckt.
async function organizeNav() {
console.log('› Navigation gruppieren');
const FOLDER = 'einstellungen';
// Ordner (Collection ohne Tabelle) anlegen, falls noch nicht vorhanden.
let folderDa = true;
try { await api('GET', `/collections/${FOLDER}`); } catch { folderDa = false; }
if (!folderDa) {
await api('POST', '/collections', {
collection: FOLDER, schema: null,
meta: { icon: 'settings', note: 'Konfiguration: Produkte, Preise, Motive, Gutschein-Einlösungen …',
translations: [
{ language: 'de-DE', translation: 'Einstellungen', singular: 'Einstellung', plural: 'Einstellungen' },
{ language: 'en-US', translation: 'Settings', singular: 'Setting', plural: 'Settings' },
] },
});
log('folder +', FOLDER);
}
const patch = async (col, meta) => {
try { await api('PATCH', `/collections/${col}`, { meta }); }
catch (e) { console.warn(` Nav ${col} übersprungen: ${e.message}`); }
};
// Hauptebene
for (const col of ['orders', 'jobs', 'vouchers', 'status_history'])
await patch(col, { group: null, hidden: false });
// Unter „Einstellungen"
for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', 'offer_requests',
'email_templates', 'email_settings', 'muster_settings', 'machines', 'format_templates', 'test_prints'])
await patch(col, { group: FOLDER, hidden: false });
// Reine Detail-/Technik-Collections aus der Navigation nehmen (Signaturen und
// Rabatte werden direkt am Kundenkonto gepflegt, nicht als eigener Menüpunkt).
// Empfängerzeilen werden direkt im Auftrag gepflegt (o2m) → keine eigene Seite.
for (const col of ['order_entries', 'order_addons', 'voucher_redemptions', 'preview_usage',
'login_codes', 'signatures', 'customer_discounts', 'customer_products'])
await patch(col, { hidden: true });
}
// ── Anzeige-Vorlagen für Verknüpfungen ──────────────────────────────────────
// Ohne diese zeigt Directus bei m2o-Feldern nur die ID (z. B. Produkt „1").
// Mit „related-values" + Template erscheint der aussagekräftige Wert.
async function applyDisplays() {
console.log('› Anzeige-Vorlagen (m2o)');
const displays = [
['orders', 'product', '{{name}}'],
['orders', 'format', '{{name}}'],
['orders', 'motif', '{{name}}'],
['orders', 'customer', '{{first_name}} {{last_name}}'],
['orders', 'voucher', '{{code}}'],
['formats', 'product', '{{name}}'],
['jobs', 'order', '{{order_number}}'],
['jobs', 'machine', '{{name}}'],
['jobs', 'template', '{{name}}'],
['test_prints', 'template', '{{name}}'],
['test_prints', 'machine', '{{name}}'],
['status_history', 'order', '{{order_number}}'],
['order_entries', 'order', '{{order_number}}'],
['order_addons', 'order', '{{order_number}}'],
['order_addons', 'price_item', '{{label}}'],
['signatures', 'customer', '{{first_name}} {{last_name}}'],
['customer_discounts', 'customer', '{{first_name}} {{last_name}}'],
['customer_products', 'customer', '{{first_name}} {{last_name}}'],
['customer_products', 'product', '{{name}}'],
['offer_requests', 'customer', '{{first_name}} {{last_name}}'],
['voucher_redemptions', 'voucher', '{{code}}'],
];
for (const [collection, field, template] of displays) {
try {
await api('PATCH', `/fields/${collection}/${field}`, {
meta: { display: 'related-values', display_options: { template } },
});
} catch (e) { console.warn(` Display ${collection}.${field} übersprungen: ${e.message}`); }
}
}
// ── Altlasten entfernen ─────────────────────────────────────────────────────
// Ersetzt durch: eine gemeinsame `multiplier_formula` + `products.norm_qty`.
async function removeDeprecatedFields() {
console.log('› Alte Felder entfernen');
const weg = [
['pricing_settings', 'multiplier_formula_business'],
['pricing_settings', 'multiplier_formula_privat'],
['pricing_settings', 'norm_qty_business'],
['pricing_settings', 'norm_qty_privat'],
['pricing_settings', 'min_qty_business'],
['pricing_settings', 'min_qty_privat'],
];
for (const [col, fld] of weg) {
try { await api('DELETE', `/fields/${col}/${fld}`); log('field -', `${col}.${fld}`); }
catch { /* bereits entfernt */ }
}
}
// ── Projektname ─────────────────────────────────────────────────────────────
// Directus nutzt den Projektnamen als ABSENDERNAMEN aller Mails. Steht hier
// „Directus", kommen die Mails auch so beim Kunden an.
async function applyProjectName() {
console.log('› Projektname');
try {
await api('PATCH', '/settings', { project_name: 'Skrift' });
log('settings', 'project_name = Skrift');
} catch (e) { console.warn(` Projektname übersprungen: ${e.message}`); }
}
// ── Auftrags-Übersicht (Produktion) ─────────────────────────────────────────
// Sammel-Anzeigevorlage + globaler Tabellen-Preset: auf einen Blick sieht man
// je Auftrag Nummer, Empfängeranzahl, Kuvert-Bedarf, Versandart und Bezahlt.
async function applyOrdersLayout() {
console.log('› Auftrags-Übersicht (Liste)');
try {
await api('PATCH', '/collections/orders', {
meta: { display_template: '{{order_number}} · {{entries_count}}× · {{shipping_type}} · {{payment_status}}' },
});
} catch (e) { console.warn(` Sammel-Template orders übersprungen: ${e.message}`); }
const spalten = ['order_number', 'customer', 'entries_count', 'needs_envelope',
'shipping_type', 'payment_status', 'production_status', 'date_created'];
try {
const vorhanden = await api('GET',
'/presets?filter[collection][_eq]=orders&filter[role][_null]=true&filter[user][_null]=true&filter[bucket][_null]=true&limit=1');
const preset = {
collection: 'orders', bucket: null, role: null, user: null,
layout: 'tabular',
layout_query: { tabular: { fields: spalten, sort: ['-date_created'], page: 1 } },
layout_options: { tabular: { widths: {} } },
};
if (vorhanden && vorhanden[0]) await api('PATCH', `/presets/${vorhanden[0].id}`, preset);
else { await api('POST', '/presets', preset); log('preset +', 'orders (Übersicht)'); }
} catch (e) { console.warn(` Listen-Preset orders übersprungen: ${e.message}`); }
}
// ── Insights-Dashboard „Übersicht" ──────────────────────────────────────────
// Zählt neue Aufträge, Anfragen und Musterbestellungen auf einen Blick.
async function applyDashboard() {
console.log('› Dashboard (Insights)');
try {
let dash = (await api('GET', `/dashboards?filter[name][_eq]=${encodeURIComponent('Übersicht')}&limit=1`))?.[0];
if (!dash) {
dash = await api('POST', '/dashboards', {
name: 'Übersicht', icon: 'dashboard', note: 'Neue Aufträge, Anfragen & Muster auf einen Blick.',
});
log('dashboard +', 'Übersicht');
}
const vorhanden = new Set(((await api('GET', `/panels?filter[dashboard][_eq]=${dash.id}&limit=-1&fields=name`)) || []).map((p) => p.name));
const metric = (name, icon, collection, filter, x) => ({
dashboard: dash.id, name, icon, type: 'metric', show_header: true,
position_x: x, position_y: 1, width: 6, height: 6,
options: { collection, field: 'id', function: 'count', filter },
});
const panels = [
metric('Neue Aufträge', 'receipt_long', 'orders', { production_status: { _eq: 'eingegangen' } }, 1),
metric('Neue Anfragen', 'request_quote', 'offer_requests', { _and: [{ type: { _eq: 'angebot' } }, { status: { _eq: 'neu' } }] }, 7),
metric('Neue Muster', 'science', 'offer_requests', { _and: [{ type: { _eq: 'muster' } }, { status: { _eq: 'neu' } }] }, 13),
];
for (const p of panels) {
if (vorhanden.has(p.name)) continue;
try { await api('POST', '/panels', p); log('panel +', p.name); }
catch (e) { console.warn(` Panel ${p.name} übersprungen: ${e.message}`); }
}
} catch (e) { console.warn(' Dashboard übersprungen (bitte melden):', e.message); }
}
// ── Main ────────────────────────────────────────────────────────────────────
(async () => {
await login();
@@ -467,6 +1011,12 @@ async function applyLabels() {
await applySchema();
await applySeed();
await applyLabels();
await organizeNav();
await applyDisplays();
await removeDeprecatedFields();
await applyProjectName();
await applyOrdersLayout();
await applyDashboard();
if (SKIP_PERMISSIONS) console.log('› Rechte übersprungen (SKIP_PERMISSIONS=1)');
else await applyPermissions();
console.log('✓ Fertig.');