From 06c40782908e263f9fe0499b3080f3d32e6ba7ae Mon Sep 17 00:00:00 2001 From: Lucas Orth Date: Thu, 23 Jul 2026 13:24:12 +0200 Subject: [PATCH] =?UTF-8?q?Produktions-Modul,=20=C3=BCberarbeitete=20Preis?= =?UTF-8?q?logik=20und=20WordPress-Preisrechner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Docker/directus/.env.example | 18 + Docker/directus/bootstrap/bootstrap.mjs | 618 +++++- Docker/directus/docker-compose.yml | 16 + .../dist/index.js | 105 +- .../dist/index.js | 158 ++ .../package.json | 13 + .../dist/index.js | 149 ++ .../package.json | 13 + .../dist/index.js | 105 + .../package.json | 13 + .../dist/index.js | 969 ++++++++- .../dist/index.js | 83 + .../package.json | 13 + .../dist/index.js | 701 ++++++ .../package.json | 13 + .../dist/index.js | 59 + .../package.json | 13 + .../api/controllers/directus-controller.js | 11 +- .../src/api/controllers/order-controller.js | 57 +- .../src/services/directus-service.js | 4 +- .../src/services/placeholder-service.js | 21 +- Produktion/skrift-agent/README.md | 44 + Produktion/skrift-agent/agent.py | 294 +++ Produktion/skrift-agent/config.example.json | 6 + Produktion/skrift-agent/requirements.txt | 1 + Webseite/skrift-app/.dockerignore | 9 + Webseite/skrift-app/.env.example | 4 + Webseite/skrift-app/Dockerfile | 30 + Webseite/skrift-app/docker-compose.yml | 29 + Webseite/skrift-app/index.html | 23 + Webseite/skrift-app/nginx.conf | 42 + Webseite/skrift-app/package-lock.json | 1884 +++++++++++++++++ Webseite/skrift-app/package.json | 25 + Webseite/skrift-app/public/README.md | 31 + Webseite/skrift-app/public/favicon.svg | 46 + Webseite/skrift-app/public/fonts/README.md | 41 + .../fonts/WorkSans-VariableFont_wght.ttf | Bin 0 -> 359628 bytes Webseite/skrift-app/public/logo.svg | 11 + Webseite/skrift-app/src/App.tsx | 58 + .../skrift-app/src/components/AppShell.tsx | 497 +++++ Webseite/skrift-app/src/components/Icons.tsx | 111 + .../skrift-app/src/components/OfferForm.tsx | 135 ++ .../src/components/PreviewPanel.tsx | 164 ++ Webseite/skrift-app/src/lib/api.ts | 548 +++++ Webseite/skrift-app/src/lib/dialogs.tsx | 37 + Webseite/skrift-app/src/lib/excel.ts | 109 + Webseite/skrift-app/src/lib/paypal.ts | 86 + Webseite/skrift-app/src/lib/status.ts | 18 + Webseite/skrift-app/src/lib/wizard.tsx | 46 + Webseite/skrift-app/src/main.tsx | 14 + Webseite/skrift-app/src/routes/Auftraege.tsx | 123 ++ .../skrift-app/src/routes/Configurator.tsx | 1830 ++++++++++++++++ Webseite/skrift-app/src/routes/Login.tsx | 121 ++ Webseite/skrift-app/src/routes/Muster.tsx | 8 + Webseite/skrift-app/src/routes/Stammdaten.tsx | 129 ++ Webseite/skrift-app/src/styles/global.css | 1137 ++++++++++ Webseite/skrift-app/src/styles/tokens.css | 86 + Webseite/skrift-app/src/vite-env.d.ts | 10 + Webseite/skrift-app/tsconfig.json | 20 + Webseite/skrift-app/vite.config.ts | 8 + Webseite/skrift-preisrechner/formel.php | 155 ++ .../skrift-preisrechner.php | 416 ++++ 62 files changed, 11384 insertions(+), 154 deletions(-) create mode 100644 Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js create mode 100644 Docker/directus/extensions/directus-extension-skrift-mailer/package.json create mode 100644 Docker/directus/extensions/directus-extension-skrift-orderentries/dist/index.js create mode 100644 Docker/directus/extensions/directus-extension-skrift-orderentries/package.json create mode 100644 Docker/directus/extensions/directus-extension-skrift-orderfiles/dist/index.js create mode 100644 Docker/directus/extensions/directus-extension-skrift-orderfiles/package.json create mode 100644 Docker/directus/extensions/directus-extension-skrift-printbutton/dist/index.js create mode 100644 Docker/directus/extensions/directus-extension-skrift-printbutton/package.json create mode 100644 Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js create mode 100644 Docker/directus/extensions/directus-extension-skrift-produktion/package.json create mode 100644 Docker/directus/extensions/directus-extension-skrift-testprint/dist/index.js create mode 100644 Docker/directus/extensions/directus-extension-skrift-testprint/package.json create mode 100644 Produktion/skrift-agent/README.md create mode 100644 Produktion/skrift-agent/agent.py create mode 100644 Produktion/skrift-agent/config.example.json create mode 100644 Produktion/skrift-agent/requirements.txt create mode 100644 Webseite/skrift-app/.dockerignore create mode 100644 Webseite/skrift-app/.env.example create mode 100644 Webseite/skrift-app/Dockerfile create mode 100644 Webseite/skrift-app/docker-compose.yml create mode 100644 Webseite/skrift-app/index.html create mode 100644 Webseite/skrift-app/nginx.conf create mode 100644 Webseite/skrift-app/package-lock.json create mode 100644 Webseite/skrift-app/package.json create mode 100644 Webseite/skrift-app/public/README.md create mode 100644 Webseite/skrift-app/public/favicon.svg create mode 100644 Webseite/skrift-app/public/fonts/README.md create mode 100644 Webseite/skrift-app/public/fonts/WorkSans-VariableFont_wght.ttf create mode 100644 Webseite/skrift-app/public/logo.svg create mode 100644 Webseite/skrift-app/src/App.tsx create mode 100644 Webseite/skrift-app/src/components/AppShell.tsx create mode 100644 Webseite/skrift-app/src/components/Icons.tsx create mode 100644 Webseite/skrift-app/src/components/OfferForm.tsx create mode 100644 Webseite/skrift-app/src/components/PreviewPanel.tsx create mode 100644 Webseite/skrift-app/src/lib/api.ts create mode 100644 Webseite/skrift-app/src/lib/dialogs.tsx create mode 100644 Webseite/skrift-app/src/lib/excel.ts create mode 100644 Webseite/skrift-app/src/lib/paypal.ts create mode 100644 Webseite/skrift-app/src/lib/status.ts create mode 100644 Webseite/skrift-app/src/lib/wizard.tsx create mode 100644 Webseite/skrift-app/src/main.tsx create mode 100644 Webseite/skrift-app/src/routes/Auftraege.tsx create mode 100644 Webseite/skrift-app/src/routes/Configurator.tsx create mode 100644 Webseite/skrift-app/src/routes/Login.tsx create mode 100644 Webseite/skrift-app/src/routes/Muster.tsx create mode 100644 Webseite/skrift-app/src/routes/Stammdaten.tsx create mode 100644 Webseite/skrift-app/src/styles/global.css create mode 100644 Webseite/skrift-app/src/styles/tokens.css create mode 100644 Webseite/skrift-app/src/vite-env.d.ts create mode 100644 Webseite/skrift-app/tsconfig.json create mode 100644 Webseite/skrift-app/vite.config.ts create mode 100644 Webseite/skrift-preisrechner/formel.php create mode 100644 Webseite/skrift-preisrechner/skrift-preisrechner.php diff --git a/Docker/directus/.env.example b/Docker/directus/.env.example index d99bc46..67025ad 100644 --- a/Docker/directus/.env.example +++ b/Docker/directus/.env.example @@ -31,3 +31,21 @@ EMAIL_SMTP_SECURE=false # Nur falls der Mailserver ein selbstsigniertes Zertifikat nutzt: # EMAIL_SMTP_TLS_REJECT_UNAUTHORIZED=false + +# ── Skrift-Backend (Vorschau + finaler Auftrag) ───────────── +# Intern über das gemeinsame npm-Netz erreichbar (container_name). +SKRIFT_BACKEND_URL=http://skrift-backend:4000 +# MUSS exakt dem API_TOKEN aus Docker/skrift-backend/.env entsprechen, +# sonst antwortet das Backend mit 401 Unauthorized. +SKRIFT_BACKEND_TOKEN=CHANGE-ME-gleich-wie-backend-API_TOKEN +# Vorschauen pro Kennung und Tag +SKRIFT_PREVIEW_LIMIT=10 +# Zieladresse für Angebots-/Musteranfragen +SKRIFT_TEAM_EMAIL=hello@skrift.de + +# ── PayPal (server-seitige Zahlungsprüfung) ───────────────── +# Client-ID ist öffentlich (auch im Frontend), das Secret bleibt NUR hier. +PAYPAL_CLIENT_ID= +PAYPAL_CLIENT_SECRET= +# sandbox oder live +PAYPAL_ENV=sandbox diff --git a/Docker/directus/bootstrap/bootstrap.mjs b/Docker/directus/bootstrap/bootstrap.mjs index 3618152..1379e27 100644 --- a/Docker/directus/bootstrap/bootstrap.mjs +++ b/Docker/directus/bootstrap/bootstrap.mjs @@ -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 = ` + +
+ + + + + + + +{{lieferadresse_block}} + + +

Bestellbestätigung

Guten Tag {{name}},

vielen Dank für Ihre Bestellung bei Skrift. Wir haben sie erhalten und bearbeiten sie schnellstmöglich.

Ihre Bestellnummer:

{{order_id}}

Ihre Bestellung

+ + + + + +
Produkt:{{product}}
Menge:{{menge}} Stück
Format:{{format}}
Versand:{{versand}}
{{kuvert_block}}

Preisübersicht

+ + + + + +
Zwischensumme (netto):{{netto}} {{waehrung}}
MwSt. ({{mwst_satz}} %):{{mwst_betrag}} {{waehrung}}

Gesamt (brutto):{{brutto}} {{waehrung}}

Rechnungsadresse

{{rechnungsadresse}}

Wie geht es weiter?

  1. Wir prüfen Ihre Bestellung und bereiten die Produktion vor.
  2. Nach Fertigstellung erhalten Sie eine Versandbestätigung.

Mit freundlichen Grüßen
Ihr Skrift-Team

{{footer}}

`.trim(); + + const templates = [ + { key: 'login_code', label: 'Anmeldecode', subject: 'Ihr Anmeldecode: {{code}}', append_signature: true, attach_agb: false, + body: `

Guten Tag,

Ihr Anmeldecode für Skrift lautet:

` + + `

{{code}}

` + + `

Der Code ist 10 Minuten gültig. Falls Sie keine Anmeldung angefordert haben, ignorieren Sie diese E-Mail.

` }, + { 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: `

${G}

Ihre Zahlung zu Auftrag {{order_id}} ist bei uns eingegangen. Wir starten die Produktion.

` }, + { key: 'in_produktion', label: 'Status: in Produktion', subject: '{{order_id}} ist in Produktion', append_signature: true, attach_agb: false, + body: `

${G}

Ihr Auftrag {{order_id}} ist jetzt in Produktion.

` }, + { key: 'versendet', label: 'Status: versendet', subject: '{{order_id}} wurde versendet', append_signature: true, attach_agb: false, + body: `

${G}

Ihre Schriftstücke zu Auftrag {{order_id}} wurden versendet bzw. an Sie übergeben. Vielen Dank für Ihr Vertrauen!

` }, + { key: 'abgeschlossen', label: 'Status: abgeschlossen', subject: '{{order_id}} abgeschlossen', append_signature: true, attach_agb: false, + body: `

${G}

Ihr Auftrag {{order_id}} ist abgeschlossen. Wir freuen uns auf Ihre nächste Bestellung.

` }, + { key: 'storniert', label: 'Status: storniert', subject: '{{order_id}} wurde storniert', append_signature: true, attach_agb: false, + body: `

${G}

Ihr Auftrag {{order_id}} wurde storniert. Bei Fragen sind wir gerne für Sie da.

{{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: '

Mit freundlichen Grüßen
Ihr Skrift-Team

', + 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.'); diff --git a/Docker/directus/docker-compose.yml b/Docker/directus/docker-compose.yml index f8e2a4b..1839a3a 100644 --- a/Docker/directus/docker-compose.yml +++ b/Docker/directus/docker-compose.yml @@ -40,6 +40,9 @@ services: - directus-uploads:/directus/uploads # Bind-Mount: Extensions kommen aus dem Repo-Ordner ./extensions - ./extensions:/directus/extensions + # Generierte Auftragsdateien read-only einblenden (gleicher Ordner wie + # das Backend beschreibt / SFTP-Ziel) – für Direktzugriff statt Kopie. + - /var/skrift-output:/var/skrift-output:ro environment: SECRET: ${DIRECTUS_SECRET} @@ -68,6 +71,19 @@ services: EMAIL_SMTP_USER: ${EMAIL_SMTP_USER} EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD} EMAIL_SMTP_SECURE: ${EMAIL_SMTP_SECURE} + + # Skrift-Backend (SVG-Generierung: Vorschau + finaler Auftrag). + # SKRIFT_BACKEND_TOKEN MUSS dem API_TOKEN des Backends entsprechen, + # sonst antwortet das Backend mit 401. + SKRIFT_BACKEND_URL: ${SKRIFT_BACKEND_URL:-http://skrift-backend:4000} + SKRIFT_BACKEND_TOKEN: ${SKRIFT_BACKEND_TOKEN} + SKRIFT_PREVIEW_LIMIT: ${SKRIFT_PREVIEW_LIMIT:-10} + SKRIFT_TEAM_EMAIL: ${SKRIFT_TEAM_EMAIL:-hello@skrift.de} + + # PayPal – server-seitige Zahlungsprüfung (Secret liegt NUR hier, nie im Frontend) + PAYPAL_CLIENT_ID: ${PAYPAL_CLIENT_ID} + PAYPAL_CLIENT_SECRET: ${PAYPAL_CLIENT_SECRET} + PAYPAL_ENV: ${PAYPAL_ENV:-sandbox} expose: - "8055" networks: diff --git a/Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js index 056832d..b5930b4 100644 --- a/Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js @@ -24,7 +24,7 @@ import crypto from 'node:crypto'; const CODE_TTL_MIN = 10; const MAX_ATTEMPTS = 5; const RESEND_COOLDOWN_SEC = 60; -const CUSTOMER_ROLE_NAME = 'Kunde (Login)'; +const CUSTOMER_ROLE_NAME = 'Kunde'; const handler = (router, { services, getSchema, env, logger }) => { const { ItemsService, MailService, UsersService, AuthenticationService, RolesService } = services; @@ -35,6 +35,12 @@ const handler = (router, { services, getSchema, env, logger }) => { crypto.createHash('sha256').update(`${code}:${email}:${env.SECRET}`).digest('hex'); const genCode = () => String(crypto.randomInt(0, 1_000_000)).padStart(6, '0'); + // Temporäres Passwort, das jede übliche Passwort-Richtlinie erfüllt + // (Groß-, Kleinbuchstabe, Ziffer, Sonderzeichen). Wird nie angezeigt und + // rotiert bei jedem Login. + const tempPasswort = () => + `Aa1!${crypto.randomBytes(24).toString('base64url').replace(/[^A-Za-z0-9]/g, '')}`; + const ctx = async () => ({ schema: await getSchema(), accountability: null }); async function findUserByEmail(email, schema) { @@ -48,18 +54,41 @@ const handler = (router, { services, getSchema, env, logger }) => { } async function sendCodeMail(email, code) { - const mail = new MailService({ schema: await getSchema(), accountability: null }); - await mail.send({ - to: email, - subject: `Ihr Anmeldecode: ${code}`, - html: ` -

Guten Tag,

-

Ihr Anmeldecode für Skrift lautet:

-

${code}

-

Der Code ist ${CODE_TTL_MIN} Minuten gültig. Falls Sie keine Anmeldung - angefordert haben, können Sie diese E-Mail ignorieren.

- `, - }); + const schema = await getSchema(); + + // Standardinhalt (Fallback, falls keine Vorlage hinterlegt ist). + let subject = `Ihr Anmeldecode: ${code}`; + let body = + `

Guten Tag,

Ihr Anmeldecode für Skrift lautet:

` + + `

${code}

` + + `

Der Code ist ${CODE_TTL_MIN} Minuten gültig. Falls Sie keine Anmeldung ` + + `angefordert haben, können Sie diese E-Mail ignorieren.

`; + let signature = ''; + let absender = ''; + + // Editierbare Vorlage „login_code" + gemeinsame Signatur laden (falls vorhanden). + try { + const rows = await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({ + filter: { key: { _eq: 'login_code' }, enabled: { _eq: true } }, limit: 1, fields: ['subject', 'body'], + }); + const tpl = rows?.[0]; + const fuelle = (s) => String(s || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (k === 'code' ? code : '')); + if (tpl) { + if (tpl.subject) subject = fuelle(tpl.subject); + if (tpl.body) body = fuelle(tpl.body); + } + const es = await new ItemsService('email_settings', { schema, accountability: null }) + .readSingleton({ fields: ['signature', 'sender_name', 'sender_email'] }); + if (es?.signature) signature = es.signature; + if (es?.sender_email) { + absender = es.sender_name ? `${es.sender_name} <${es.sender_email}>` : es.sender_email; + } + } catch { /* Vorlage optional – Fallback oben greift */ } + + const mail = new MailService({ schema, accountability: null }); + const nachricht = { to: email, subject, html: body + signature }; + if (absender) nachricht.from = absender; + await mail.send(nachricht); } // ── Code anfordern ──────────────────────────────────────────────────────── @@ -146,28 +175,38 @@ const handler = (router, { services, getSchema, env, logger }) => { // Offizieller Login-Pfad mit rotierendem Zufallspasswort (s. Kopfkommentar). const users = new UsersService({ schema, accountability: null }); - const tempPassword = crypto.randomBytes(32).toString('base64url'); + const tempPassword = tempPasswort(); await users.updateOne(user.id, { password: tempPassword }); + // Die Signatur von AuthenticationService.login variiert zwischen + // Directus-Versionen. Der Aufruf mit ('default', payload) hat bei dir per + // curl funktioniert – daher zuerst diese Variante, dann Fallbacks. const auth = new AuthenticationService({ schema, accountability: null }); - const result = await auth.login('default', { email, password: tempPassword }); - - // Kundendatensatz mit dem Login-Konto verknüpfen, falls noch offen. - const customers = new ItemsService('customers', { schema, accountability: null }); - const cust = await customers.readByQuery({ - filter: { email: { _eq: email } }, limit: 1, fields: ['id', 'linked_user'], - }); - if (cust?.[0] && !cust[0].linked_user) { - await customers.updateOne(cust[0].id, { linked_user: user.id }); + const versuche = [ + () => auth.login('default', { email, password: tempPassword }), + () => auth.login({ email, password: tempPassword }), + () => auth.login({ email, password: tempPassword }, {}), + ]; + let result, letzterFehler; + for (const versuch of versuche) { + try { result = await versuch(); break; } + catch (e) { letzterFehler = e; } + } + if (!result) { + logger.error(`[skrift-auth] login fehlgeschlagen: ${letzterFehler?.message}`); + const e = new Error(`Login-Service: ${letzterFehler?.message || 'unbekannt'}`); + e.detail = letzterFehler?.message; + throw e; } + // Kunde = Login-Konto selbst – keine separate Verknüpfung mehr nötig. return res.json({ access_token: result.accessToken ?? result.access_token, refresh_token: result.refreshToken ?? result.refresh_token, expires: result.expires, }); } catch (err) { - logger.error(`[skrift-auth] verify-code: ${err.message}`); + logger.error(`[skrift-auth] verify-code: ${err.stack || err.message}`); return res.status(500).json({ error: 'Anmeldung fehlgeschlagen.' }); } }); @@ -193,28 +232,18 @@ const handler = (router, { services, getSchema, env, logger }) => { return res.status(500).json({ error: 'Konto konnte nicht angelegt werden.' }); } + // Kunde = Login-Konto: Profil hängt direkt am Nutzer. const users = new UsersService({ schema, accountability: null }); - const userId = await users.createOne({ + await users.createOne({ email, password: crypto.randomBytes(32).toString('base64url'), role: role.id, status: 'active', first_name: req.body?.first_name ?? null, last_name: req.body?.last_name ?? null, - }); - - // Kundendatensatz anlegen oder verknüpfen (Zuordnung immer per E-Mail). - const customers = new ItemsService('customers', { schema, accountability: null }); - const cust = (await customers.readByQuery({ - filter: { email: { _eq: email } }, limit: 1, fields: ['id'], - }))?.[0]; - if (cust) await customers.updateOne(cust.id, { linked_user: userId }); - else await customers.createOne({ - email, linked_user: userId, - first_name: req.body?.first_name ?? null, - last_name: req.body?.last_name ?? null, - person_type: req.body?.person_type === 'unternehmen' ? 'unternehmen' : 'privat', company: req.body?.company ?? null, + phone: req.body?.phone ?? null, + person_type: req.body?.person_type === 'unternehmen' ? 'unternehmen' : 'privat', }); return res.json({ ok: true, existed: false }); diff --git a/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js new file mode 100644 index 0000000..0e90621 --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js @@ -0,0 +1,158 @@ +/** + * Skrift – Mail-Hook + * --------------------------------------------------------------------------- + * Versendet Kunden-E-Mails auf Basis editierbarer Vorlagen (`email_templates`) + * mit gemeinsamer Signatur/Footer (`email_settings`) und optionalem AGB-Anhang. + * + * orders.items.create → Vorlage „order_created" (reiche Bestellbestätigung) + * orders.items.update → Vorlage mit dem Schlüssel des neuen Status + * (z. B. „bezahlt", „versendet"), falls vorhanden+aktiv + * + * Der Login-Code wird bewusst in skrift-auth versendet (dort entsteht er). + */ +import fs from 'node:fs'; +import path from 'node:path'; + +const STATUS_LABEL = { + wartet_auf_zahlung: 'Wartet auf Zahlung', bezahlt: 'Bezahlt', + material_erwartet: 'Material erwartet', material_eingegangen: 'Material eingegangen', + in_queue: 'In Warteschlange', in_produktion: 'In Produktion', gedruckt: 'Gedruckt', + versendet: 'Versendet', abgeschlossen: 'Abgeschlossen', storniert: 'Storniert', +}; +const ENVELOPE_LABEL = { dinlang: 'DIN Lang', c6: 'C6' }; +const LABELING_LABEL = { empfaenger: 'Empfängeradresse', freitext: 'Individueller Text', keine: 'Keine' }; +const UPLOAD_ROOT = process.env.STORAGE_LOCAL_ROOT || '/directus/uploads'; + +const render = (str, vars) => + String(str || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : '')); +const eur = (n) => (Number(n) || 0).toFixed(2).replace('.', ','); +const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c])); + +/** Formatiert einen Adress-Snapshot (billing/shipping) als HTML. */ +function adresseHtml(a) { + if (!a || typeof a !== 'object') return ''; + const zeilen = []; + if (a.company) zeilen.push(`${esc(a.company)}`); + zeilen.push(esc([a.first_name, a.last_name].filter(Boolean).join(' '))); + if (a.street) zeilen.push(esc(a.street)); + zeilen.push(esc([a.zip, a.city].filter(Boolean).join(' '))); + if (a.country) zeilen.push(esc(a.country)); + let html = zeilen.filter(Boolean).join('
'); + if (a.email) html += `

E-Mail: ${esc(a.email)}`; + if (a.phone) html += `
Telefon: ${esc(a.phone)}`; + return html; +} + +export default (register, { services, getSchema, logger }) => { + const { ItemsService, MailService } = services; + + async function loadTemplate(key, schema) { + const rows = await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({ + filter: { key: { _eq: key }, enabled: { _eq: true } }, + limit: 1, fields: ['subject', 'body', 'append_signature', 'attach_agb'], + }); + return rows?.[0] || null; + } + + async function loadSettings(schema) { + try { + return await new ItemsService('email_settings', { schema, accountability: null }) + .readSingleton({ fields: ['signature', 'portal_url', 'company_footer', 'agb_file', 'sender_name', 'sender_email'] }); + } catch { return {}; } + } + + /** AGB-PDF (aus email_settings.agb_file) als Nodemailer-Anhang, direkt von Platte. */ + async function agbAnhang(schema, settings) { + if (!settings?.agb_file) return null; + try { + const file = await new ItemsService('directus_files', { schema, accountability: null }) + .readOne(settings.agb_file, { fields: ['filename_disk', 'filename_download', 'type'] }); + if (!file?.filename_disk) return null; + const buf = fs.readFileSync(path.join(UPLOAD_ROOT, file.filename_disk)); + return { filename: file.filename_download || 'AGB.pdf', content: buf, contentType: file.type || 'application/pdf' }; + } catch (e) { + logger.warn(`[skrift-mailer] AGB-Anhang übersprungen: ${e.message}`); + return null; + } + } + + async function sendForOrder(orderId, key, extraVars = {}) { + const schema = await getSchema(); + const tpl = await loadTemplate(key, schema); + if (!tpl) return; // keine (aktive) Vorlage → keine Mail + + const order = await new ItemsService('orders', { schema, accountability: null }).readOne(orderId, { + fields: [ + 'order_number', 'customer_email', 'entries_count', 'shipping_type', + 'needs_envelope', 'envelope_labeling', 'envelope_format', + 'net_total', 'vat_amount', 'gross_total', 'billing', 'shipping_address', + 'customer.first_name', 'customer.last_name', 'customer.company', + 'product.name', 'format.name', + ], + }); + if (!order?.customer_email) return; + + const settings = await loadSettings(schema); + const name = [order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(' ') + || order.customer?.company || ''; + + const net = Number(order.net_total) || 0; + const vat = Number(order.vat_amount) || 0; + const mwstSatz = net > 0 ? Math.round((vat / net) * 100) : 19; + + const kuvertBlock = (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine') + ? `
+

Umschlag

+

Format: ${ENVELOPE_LABEL[order.envelope_format] || '—'} · Beschriftung: ${LABELING_LABEL[order.envelope_labeling] || '—'}

+
` + : ''; + + const liefer = adresseHtml(order.shipping_address); + const lieferBlock = liefer + ? `

Lieferadresse

${liefer}

` + : ''; + + const vars = { + order_id: order.order_number, name, link: settings.portal_url || '', + product: esc(order.product?.name || ''), menge: order.entries_count ?? '', + format: esc(order.format?.name || ''), + versand: order.shipping_type === 'einzeln' ? 'Einzelversand durch Skrift' : 'Sammellieferung', + kuvert_block: kuvertBlock, + netto: eur(net), mwst_satz: mwstSatz, mwst_betrag: eur(vat), brutto: eur(order.gross_total), waehrung: 'EUR', + rechnungsadresse: adresseHtml(order.billing), + lieferadresse_block: lieferBlock, + footer: settings.company_footer || '', + ...extraVars, + }; + + let html = render(tpl.body, vars); + if (tpl.append_signature !== false && settings.signature) html += render(settings.signature, vars); + + const nachricht = { to: order.customer_email, subject: render(tpl.subject, vars), html }; + // Absender aus den Einstellungen (z. B. „Skrift "). + if (settings.sender_email) { + nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email; + } + if (tpl.attach_agb) { + const anhang = await agbAnhang(schema, settings); + if (anhang) nachricht.attachments = [anhang]; + } + await new MailService({ schema, accountability: null }).send(nachricht); + logger.info(`[skrift-mailer] „${key}" an ${order.customer_email} (${order.order_number}).`); + } + + register.action('orders.items.create', async ({ key, payload }) => { + if (payload?.status === 'entwurf') return; + try { await sendForOrder(key, 'order_created'); } + catch (e) { logger.warn(`[skrift-mailer] order_created: ${e.message}`); } + }); + + register.action('orders.items.update', async ({ keys, payload }) => { + if (!payload || payload.status === undefined) return; + const status = payload.status; + for (const id of keys || []) { + try { await sendForOrder(id, status, { status: STATUS_LABEL[status] || status }); } + catch (e) { logger.warn(`[skrift-mailer] status ${status}: ${e.message}`); } + } + }); +}; diff --git a/Docker/directus/extensions/directus-extension-skrift-mailer/package.json b/Docker/directus/extensions/directus-extension-skrift-mailer/package.json new file mode 100644 index 0000000..5a074e1 --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-mailer/package.json @@ -0,0 +1,13 @@ +{ + "name": "directus-extension-skrift-mailer", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Versendet E-Mails (Bestellbestätigung + Statusänderungen) anhand editierbarer Vorlagen (email_templates + email_settings).", + "directus:extension": { + "type": "hook", + "path": "dist/index.js", + "source": "dist/index.js", + "host": "^11.0.0" + } +} diff --git a/Docker/directus/extensions/directus-extension-skrift-orderentries/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-orderentries/dist/index.js new file mode 100644 index 0000000..a1108e0 --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-orderentries/dist/index.js @@ -0,0 +1,149 @@ +/** + * Skrift – Interface „Empfänger (durchsuchbar)" + * --------------------------------------------------------------------------- + * Zeigt im Auftrag alle Empfängerzeilen als kompakte, durchsuchbare Tabelle – + * die Produktionsansicht aus dem alten Job-Manager: pro Zeile die Briefnummer + * und die VOLLE Anschrift bzw. der Freitext (nicht nur der Name). Das Suchfeld + * filtert über die ganze Zeile (Name, Straße, Ort, Land, Platzhalter …). + * + * Rein lesend/suchend – bearbeitet wird eine Zeile weiterhin über das darunter + * liegende o2m-Feld „Empfänger (bearbeiten)". + * + * Bewusst von Hand als ESM geschrieben (kein Build-Schritt): Vue und der + * Extensions-SDK werden von Directus zur Laufzeit bereitgestellt. + */ +import { defineComponent, ref, computed, inject, watch, onMounted, h } from 'vue'; +import { useApi } from '@directus/extensions-sdk'; + +const INLAND = /^(de|deutschland|germany)$/i; + +/** Baut die volle Anzeigezeile einer Empfängerzeile. */ +function volleZeile(e) { + if (e.free_text && String(e.free_text).trim()) { + return String(e.free_text).trim().split(/\r?\n/).map((s) => s.trim()).filter(Boolean).join(' · '); + } + const name = [e.salutation, e.first_name, e.last_name].filter(Boolean).join(' '); + const strasse = [e.street, e.house_no].filter(Boolean).join(' '); + const ort = [e.zip, e.city].filter(Boolean).join(' '); + const land = e.country && !INLAND.test(String(e.country).trim()) ? e.country : null; + return [name, strasse, ort, land].filter(Boolean).join(' · '); +} + +/** Zusätzliche freie Platzhalter als „Key: Wert"-Liste. */ +function extras(e) { + if (!e.placeholders || typeof e.placeholders !== 'object') return ''; + return Object.entries(e.placeholders) + .filter(([, v]) => v !== null && v !== undefined && String(v).trim() !== '') + .map(([k, v]) => `${k}: ${v}`).join(' · '); +} + +const EmpfaengerSuche = defineComponent({ + setup() { + const api = useApi(); + const values = inject('values', ref({})); + const rows = ref([]); + const loading = ref(false); + const error = ref(''); + const geladen = ref(false); + const suche = ref(''); + + const orderId = () => (values && values.value ? values.value.id : null); + + async function laden() { + const id = orderId(); + if (!id) { rows.value = []; geladen.value = true; return; } + loading.value = true; error.value = ''; + try { + const res = await api.get('/items/order_entries', { + params: { + 'filter[order][_eq]': id, + sort: 'letter_number', + limit: -1, + fields: '*', + }, + }); + const data = (res.data && res.data.data) || []; + rows.value = data.map((e) => ({ + nr: e.letter_number, + zeile: volleZeile(e), + extra: extras(e), + })); + } catch (e) { + error.value = (e && e.response && e.response.data && e.response.data.errors && + e.response.data.errors[0] && e.response.data.errors[0].message) || + (e && e.message) || 'Empfänger konnten nicht geladen werden.'; + } finally { + loading.value = false; geladen.value = true; + } + } + + onMounted(laden); + watch(orderId, laden); + + const treffer = computed(() => { + const q = suche.value.trim().toLowerCase(); + if (!q) return rows.value; + return rows.value.filter((r) => + (`#${r.nr} ${r.zeile} ${r.extra}`).toLowerCase().includes(q)); + }); + + const hinweis = (txt) => h('div', { style: 'color:var(--foreground-subdued);font-size:14px;padding:8px 0;' }, txt); + + return () => { + if (loading.value) return hinweis('Empfänger werden geladen …'); + if (error.value) return hinweis(error.value); + if (!geladen.value) return hinweis(''); + if (!rows.value.length) return hinweis('Noch keine Empfängerzeilen.'); + + const suchfeld = h('div', { style: 'display:flex;align-items:center;gap:10px;margin-bottom:10px;' }, [ + h('input', { + type: 'search', + value: suche.value, + placeholder: 'Empfänger durchsuchen (Name, Straße, Ort, Platzhalter …)', + onInput: (ev) => { suche.value = ev.target.value; }, + style: 'flex:1;padding:8px 12px;border:1px solid var(--border-normal);border-radius:6px;' + + 'background:var(--background-page);color:var(--foreground-normal);font:inherit;font-size:14px;', + }), + h('span', { style: 'font-size:12px;color:var(--foreground-subdued);white-space:nowrap;' }, + `${treffer.value.length} von ${rows.value.length}`), + ]); + + const zeilen = treffer.value.map((r) => + h('div', { + key: r.nr, + style: 'display:flex;align-items:baseline;gap:12px;padding:7px 12px;' + + 'border:1px solid var(--border-subdued);border-radius:6px;', + }, [ + h('span', { + style: 'min-width:44px;font-family:var(--family-monospace, monospace);font-size:13px;' + + 'font-weight:600;color:var(--primary);', + }, `#${r.nr}`), + h('div', { style: 'flex:1;font-size:14px;line-height:1.4;word-break:break-word;' }, [ + h('span', {}, r.zeile || '—'), + r.extra + ? h('span', { style: 'display:block;font-size:12px;color:var(--foreground-subdued);margin-top:2px;' }, r.extra) + : null, + ]), + ]), + ); + + const body = treffer.value.length + ? h('div', { style: 'display:flex;flex-direction:column;gap:5px;' }, zeilen) + : hinweis('Keine Treffer.'); + + return h('div', {}, [suchfeld, body]); + }; + }, +}); + +export default { + id: 'skrift-order-entries', + name: 'Empfänger (durchsuchbar)', + icon: 'search', + description: 'Durchsuchbare Empfängertabelle: Briefnummer + volle Anschrift (Produktionsansicht).', + component: EmpfaengerSuche, + types: ['alias'], + localTypes: ['presentation'], + group: 'presentation', + options: null, +}; diff --git a/Docker/directus/extensions/directus-extension-skrift-orderentries/package.json b/Docker/directus/extensions/directus-extension-skrift-orderentries/package.json new file mode 100644 index 0000000..6d702c9 --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-orderentries/package.json @@ -0,0 +1,13 @@ +{ + "name": "directus-extension-skrift-orderentries", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Interface: durchsuchbare Empfängertabelle eines Auftrags (Briefnummer + volle Anschrift) – Produktionsansicht wie im alten Job-Manager.", + "directus:extension": { + "type": "interface", + "path": "dist/index.js", + "source": "dist/index.js", + "host": "^11.0.0" + } +} diff --git a/Docker/directus/extensions/directus-extension-skrift-orderfiles/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-orderfiles/dist/index.js new file mode 100644 index 0000000..9a9fd3a --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-orderfiles/dist/index.js @@ -0,0 +1,105 @@ +/** + * Skrift – Interface „Auftragsdateien" + * --------------------------------------------------------------------------- + * Zeigt im Auftrag die generierten Dateien (Schriftstücke, Umschläge, + * Platzhalter-CSV) direkt aus dem gemounteten Ausgabe-Ordner an – ohne Kopie + * in den Directus-Speicher. Die Liste und der Download laufen über den + * Endpoint /skrift-orders/files/* und die angemeldete Directus-Session + * (kein Token in der URL nötig). + * + * Bewusst von Hand als ESM geschrieben (kein Build-Schritt): Vue und der + * Extensions-SDK werden von Directus zur Laufzeit bereitgestellt. + */ +import { defineComponent, ref, inject, watch, onMounted, h } from 'vue'; +import { useApi } from '@directus/extensions-sdk'; + +const KIND_LABEL = { + schriftstueck: 'Schriftstück', + umschlag: 'Umschlag', + platzhalter: 'Platzhalter-CSV', + sonstiges: 'Datei', +}; + +const AuftragsDateien = defineComponent({ + setup() { + const api = useApi(); + const values = inject('values', ref({})); + const files = ref([]); + const loading = ref(false); + const error = ref(''); + const geladen = ref(false); + + function nummer() { + return values && values.value ? values.value.order_number : null; + } + + async function laden() { + const nr = nummer(); + if (!nr) { files.value = []; geladen.value = true; return; } + loading.value = true; error.value = ''; + try { + const res = await api.get('/skrift-orders/files/' + encodeURIComponent(nr)); + files.value = (res.data && res.data.files) || []; + } catch (e) { + error.value = (e && e.response && e.response.data && e.response.data.error) || (e && e.message) || 'Fehler beim Laden.'; + } finally { + loading.value = false; geladen.value = true; + } + } + + async function download(f) { + const nr = nummer(); + if (!nr) return; + try { + const res = await api.get('/skrift-orders/files/' + encodeURIComponent(nr) + '/download', { + params: { rel: f.rel }, responseType: 'blob', + }); + const url = URL.createObjectURL(res.data); + const a = document.createElement('a'); + a.href = url; a.download = f.name; + document.body.appendChild(a); a.click(); a.remove(); + URL.revokeObjectURL(url); + } catch { + error.value = 'Download fehlgeschlagen.'; + } + } + + onMounted(laden); + watch(nummer, laden); + + const hinweis = (txt) => h('div', { style: 'color:var(--foreground-subdued);font-size:14px;padding:8px 0;' }, txt); + + return () => { + if (loading.value) return hinweis('Dateien werden geladen …'); + if (error.value) return hinweis(error.value); + if (!files.value.length) return hinweis(geladen.value ? 'Noch keine generierten Dateien.' : ''); + return h('div', { style: 'display:flex;flex-direction:column;gap:6px;' }, files.value.map((f) => + h('div', { + key: f.rel, + style: 'display:flex;align-items:center;gap:12px;padding:8px 12px;border:1px solid var(--border-normal);border-radius:6px;', + }, [ + h('span', { style: 'font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);min-width:110px;' }, + KIND_LABEL[f.kind] || 'Datei'), + h('span', { style: 'flex:1;font-family:var(--family-monospace, monospace);font-size:13px;word-break:break-all;' }, f.name), + h('button', { + type: 'button', + onClick: () => download(f), + style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-subdued);border-radius:6px;padding:4px 12px;font:inherit;font-size:13px;', + }, 'Herunterladen'), + ]), + )); + }; + }, +}); + +export default { + id: 'skrift-order-files', + name: 'Auftragsdateien', + icon: 'folder_open', + description: 'Zeigt die generierten Dateien des Auftrags (Direktzugriff auf den Server-Ordner).', + component: AuftragsDateien, + types: ['alias'], + localTypes: ['presentation'], + group: 'presentation', + options: null, +}; diff --git a/Docker/directus/extensions/directus-extension-skrift-orderfiles/package.json b/Docker/directus/extensions/directus-extension-skrift-orderfiles/package.json new file mode 100644 index 0000000..b2df74f --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-orderfiles/package.json @@ -0,0 +1,13 @@ +{ + "name": "directus-extension-skrift-orderfiles", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Interface: zeigt die generierten Auftragsdateien per Direktzugriff auf den Ausgabe-Ordner (kein Download in Directus, keine Kopie).", + "directus:extension": { + "type": "interface", + "path": "dist/index.js", + "source": "dist/index.js", + "host": "^11.0.0" + } +} diff --git a/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js index 7e910ca..9d70cd8 100644 --- a/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js @@ -15,62 +15,149 @@ * - Zahlung → ab pricing_settings.paypal_limit_net (netto) nur noch Rechnung. */ -// ── Sicherer Formel-Interpreter ───────────────────────────────────────────── -// Erlaubt: Zahlen, Variablen, + - * / ( ) und unäres Minus. Sonst nichts. +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +/** Basisordner der generierten Auftragsdateien (read-only in Directus gemountet). */ +const OUTPUT_DIR = process.env.SKRIFT_OUTPUT_DIR || '/var/skrift-output'; + +// ── Sicherer Formel-Interpreter (kein eval) ───────────────────────────────── +// Erlaubt: Zahlen, Variablen, + - * / ( ), unäres Minus, Vergleiche +// (>= <= > < == !=), Ternär (cond ? a : b) und Funktionen sqrt/abs/min/max/ +// round/floor/ceil. Schreibweisen %var% und Math.fn werden vorab normalisiert. +const FORMULA_FUNCS = { + sqrt: Math.sqrt, abs: Math.abs, min: Math.min, max: Math.max, + round: Math.round, floor: Math.floor, ceil: Math.ceil, pow: Math.pow, +}; function evalFormula(expr, vars) { - const tokens = String(expr).match(/\d+(?:\.\d+)?|[A-Za-z_][A-Za-z0-9_]*|[+\-*/()]/g); + const src = String(expr).replace(/%(\w+)%/g, '$1').replace(/Math\./g, ''); + const tokens = src.match(/\d+(?:\.\d+)?|[A-Za-z_]\w*|>=|<=|==|!=|[+\-*/()?:<>,]/g); if (!tokens) throw new Error('Formel ist leer oder ungültig.'); let pos = 0; const peek = () => tokens[pos]; const next = () => tokens[pos++]; + const expect = (t) => { if (next() !== t) throw new Error(`Erwartet "${t}" in der Formel.`); }; - // Ausdruck := Term (('+'|'-') Term)* - function parseExpr() { - let left = parseTerm(); + function parseTernary() { + const cond = parseCompare(); + if (peek() === '?') { + next(); + const a = parseTernary(); + expect(':'); + const b = parseTernary(); + return cond ? a : b; + } + return cond; + } + function parseCompare() { + const left = parseAdd(); + const op = peek(); + if (['>=', '<=', '>', '<', '==', '!='].includes(op)) { + next(); + const right = parseAdd(); + if (op === '>=') return left >= right ? 1 : 0; + if (op === '<=') return left <= right ? 1 : 0; + if (op === '>') return left > right ? 1 : 0; + if (op === '<') return left < right ? 1 : 0; + if (op === '==') return left === right ? 1 : 0; + return left !== right ? 1 : 0; + } + return left; + } + function parseAdd() { + let left = parseMul(); while (peek() === '+' || peek() === '-') { const op = next(); - const right = parseTerm(); + const right = parseMul(); left = op === '+' ? left + right : left - right; } return left; } - // Term := Faktor (('*'|'/') Faktor)* - function parseTerm() { - let left = parseFactor(); + function parseMul() { + let left = parseUnary(); while (peek() === '*' || peek() === '/') { const op = next(); - const right = parseFactor(); - if (op === '/' && right === 0) throw new Error('Division durch 0 in der Preisformel.'); + const right = parseUnary(); + if (op === '/' && right === 0) throw new Error('Division durch 0 in der Formel.'); left = op === '*' ? left * right : left / right; } return left; } - // Faktor := '-' Faktor | '(' Ausdruck ')' | Zahl | Variable - function parseFactor() { + function parseUnary() { + if (peek() === '-') { next(); return -parseUnary(); } + return parsePrimary(); + } + function parsePrimary() { const t = next(); if (t === undefined) throw new Error('Formel unvollständig.'); - if (t === '-') return -parseFactor(); - if (t === '(') { - const v = parseExpr(); - if (next() !== ')') throw new Error('Fehlende schließende Klammer.'); - return v; - } + if (t === '(') { const v = parseTernary(); expect(')'); return v; } if (/^\d/.test(t)) return parseFloat(t); - if (Object.prototype.hasOwnProperty.call(vars, t)) return Number(vars[t]) || 0; - throw new Error(`Unbekannte Variable in der Preisformel: "${t}"`); + if (/^[A-Za-z_]/.test(t)) { + if (peek() === '(') { // Funktionsaufruf + next(); + const args = []; + if (peek() !== ')') { + args.push(parseTernary()); + while (peek() === ',') { next(); args.push(parseTernary()); } + } + expect(')'); + const fn = FORMULA_FUNCS[t.toLowerCase()]; + if (!fn) throw new Error(`Unbekannte Funktion: "${t}"`); + return fn(...args); + } + if (Object.prototype.hasOwnProperty.call(vars, t)) return Number(vars[t]) || 0; + throw new Error(`Unbekannte Variable in der Formel: "${t}"`); + } + throw new Error(`Unerwartetes Token: "${t}"`); } - const result = parseExpr(); + const result = parseTernary(); if (pos !== tokens.length) throw new Error('Formel konnte nicht vollständig gelesen werden.'); - if (!Number.isFinite(result)) throw new Error('Formel ergibt keinen gültigen Betrag.'); + if (!Number.isFinite(result)) throw new Error('Formel ergibt keinen gültigen Wert.'); return result; } const round2 = (n) => Math.round((Number(n) + Number.EPSILON) * 100) / 100; +/** Adresse, an die Angebotsanfragen gemeldet werden. */ +const TEAM_EMAIL = process.env.SKRIFT_TEAM_EMAIL || 'hello@skrift.de'; + +/** Backend, das die SVGs erzeugt. */ +const BACKEND_URL = (process.env.SKRIFT_BACKEND_URL || 'http://skrift-backend:4000').replace(/\/$/, ''); +const BACKEND_TOKEN = process.env.SKRIFT_BACKEND_TOKEN || ''; + +/** Vorschauen pro Kennung und Tag. */ +const PREVIEW_LIMIT = Number(process.env.SKRIFT_PREVIEW_LIMIT || 10); +/** Wie viele Dokumente eine Vorschau maximal zurückgibt. */ +const PREVIEW_MAX_DOCS = 3; + +/** PayPal – Server-Zugangsdaten (Secret liegt NUR hier, nie im Frontend). */ +const PAYPAL_CLIENT_ID = process.env.PAYPAL_CLIENT_ID || ''; +const PAYPAL_SECRET = process.env.PAYPAL_CLIENT_SECRET || ''; +const PAYPAL_API = (process.env.PAYPAL_ENV || 'sandbox') === 'live' + ? 'https://api-m.paypal.com' + : 'https://api-m.sandbox.paypal.com'; + +/** OAuth-Token für die PayPal-REST-API holen. */ +async function paypalToken() { + const auth = Buffer.from(`${PAYPAL_CLIENT_ID}:${PAYPAL_SECRET}`).toString('base64'); + const r = await fetch(`${PAYPAL_API}/v1/oauth2/token`, { + method: 'POST', + headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'grant_type=client_credentials', + }); + if (!r.ok) throw new Error(`PayPal-Token: ${r.status}`); + return (await r.json()).access_token; +} + const handler = (router, { services, getSchema, logger }) => { - const { ItemsService } = services; + const { ItemsService, MailService, UsersService, RolesService } = services; + + // Policy-konformes Zufallspasswort (Kunde meldet sich ohnehin passwortlos an). + const zufallsPasswort = () => + `Aa1!${crypto.randomBytes(24).toString('base64url').replace(/[^A-Za-z0-9]/g, '')}`; const norm = (e) => String(e || '').trim().toLowerCase(); const isEmail = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); @@ -102,7 +189,7 @@ const handler = (router, { services, getSchema, logger }) => { const product = (await svc('products').readByQuery({ filter: { key: { _eq: input.product } }, limit: 1, - fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'active'], + fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'norm_qty', 'active'], }))?.[0]; if (!product || !product.active) throw new Error('Unbekanntes oder inaktives Produkt.'); if (product.pricing_mode === 'auf_anfrage') @@ -112,40 +199,122 @@ const handler = (router, { services, getSchema, logger }) => { if (menge < 1) throw new Error('Menge muss mindestens 1 sein.'); const lines = []; - const add = (label, amount) => { if (amount) lines.push({ label, amount: round2(amount) }); }; + // perUnit=true kennzeichnet Positionen, die pro Stück anfallen (fließen in den + // „Preis pro Stück"); einmalige Kosten (Sammelversand, Gutschein …) nicht. + const add = (label, amount, perUnit = false) => { if (amount) lines.push({ label, amount: round2(amount), per_unit: perUnit }); }; const priceOf = (key) => Number(priceItems[key]?.price) || 0; const unitOf = (key) => priceItems[key]?.unit || 'einmalig'; const applyUnit = (key) => (unitOf(key) === 'pro_stueck' ? priceOf(key) * menge : priceOf(key)); - // 1) Grundpreis über die editierbare Formel + // 1) Grundpreis über die editierbare Formel × Mengen-Multiplikator. + // Der Multiplikator bildet den Kleinmengen-Aufschlag ab und ist je Segment + // (B2B/B2C) editierbar. Variablen: qty, norm_b/norm_c, mind_b/mind_c, base_price. + // Eine gemeinsame Formel; die Normalpreis-Menge kommt vom Produkt. + const multFormula = settings.multiplier_formula || '1'; + const norm = Number(product.norm_qty) || 0; + const multVars = { + qty: menge, menge, norm, + base_price: Number(product.base_price) || 0, + }; + // Ohne Normalpreis-Menge gibt es keinen Kleinmengen-Aufschlag (Multiplikator 1). + let multiplier = 1; + if (norm > 0) { + try { multiplier = evalFormula(multFormula, multVars); } catch { multiplier = 1; } + } + if (!Number.isFinite(multiplier) || multiplier <= 0) multiplier = 1; + + // Grundpreis-Formel. Variablen sind bewusst nur base_price und menge. + // Ist die Formel fehlerhaft (z. B. versehentlich die Multiplikator-Formel + // eingetragen), fällt der Preis auf die Standardformel zurück, statt die + // gesamte Preisauskunft scheitern zu lassen. const formula = settings.formula || 'base_price * menge'; - const grund = evalFormula(formula, { base_price: Number(product.base_price) || 0, menge }); - add(`${product.name} (${menge} Stk.)`, grund); + const grundVars = { base_price: Number(product.base_price) || 0, menge }; + let basis; + try { + basis = evalFormula(formula, grundVars); + } catch (e) { + logger.warn(`[skrift-orders] Grundpreis-Formel ungültig ("${formula}"): ${e.message} – nutze base_price * menge.`); + basis = evalFormula('base_price * menge', grundVars); + } + if (!Number.isFinite(basis)) basis = grundVars.base_price * menge; + const grund = basis * multiplier; + add(`${product.name} (${menge} Stk.)`, grund, true); + + // 1b) Mengenrabatt-Staffel: höchste passende Stufe (auf den Grundpreis). + const tiers = Array.isArray(settings.quantity_discounts) ? settings.quantity_discounts : []; + const stufe = tiers + .filter((t) => menge >= (Number(t.min_qty) || 0) && Number(t.percent) > 0) + .sort((a, b) => (Number(b.min_qty) || 0) - (Number(a.min_qty) || 0) + || (Number(b.percent) || 0) - (Number(a.percent) || 0))[0]; + if (stufe) add(`Mengenrabatt (${stufe.percent} %)`, -(grund * (Number(stufe.percent) / 100)), true); // 2) Versand – Kuvert ist bei Einzelversand Pflicht if (input.shipping_type === 'einzeln') { const auslandCount = (input.entries || []) .filter((e) => e.country && !/^(de|deutschland|germany)$/i.test(String(e.country).trim())).length; const inlandCount = menge - auslandCount; - add('Porto Inland', priceOf('porto_inland') * inlandCount); - add('Porto Ausland', priceOf('porto_ausland') * auslandCount); + add('Porto Inland', priceOf('porto_inland') * inlandCount, true); + add('Porto Ausland', priceOf('porto_ausland') * auslandCount, true); } else { - add('Sammelversand', priceOf('sammelversand')); + add('Sammelversand', priceOf('sammelversand'), false); } - // 3) Kuvert + Beschriftung - if (input.needs_envelope) { - add('Kuvert', applyUnit('kuvert')); + // 3) Kuvert + Beschriftung – NUR bei Sammelversand. + // Bei Einzelversand an die Empfänger ist Kuvert + Beschriftung bereits im + // Einzelversand-Wert (Porto) enthalten und wird NICHT separat berechnet. + if (input.needs_envelope && input.shipping_type !== 'einzeln') { + add('Kuvert', applyUnit('kuvert'), unitOf('kuvert') === 'pro_stueck'); if (input.envelope_labeling && input.envelope_labeling !== 'keine') - add('Kuvert-Beschriftung', applyUnit('beschriftung')); + add('Kuvert-Beschriftung', applyUnit('beschriftung'), unitOf('beschriftung') === 'pro_stueck'); } // 4) Motiv-Upload + Zusatzleistungen (Schreib-/Gestaltungsservice usw.) - if (input.motif_mode === 'upload') add('Motiv-Upload', applyUnit('motiv_upload')); + if (input.motif_mode === 'upload') add('Motiv-Upload', applyUnit('motiv_upload'), unitOf('motiv_upload') === 'pro_stueck'); for (const key of input.addons || []) { const item = priceItems[key]; if (!item || item.category !== 'zusatzleistung') continue; - add(item.key, applyUnit(key)); + add(item.key, applyUnit(key), unitOf(key) === 'pro_stueck'); + } + + // 4b) Kundenrabatt – nur bei bekanntem Konto (E-Mail). Rabatt gilt auf den + // Schriftstück-Grundpreis. „erste_n_stueck" zählt KUMULATIV über alle + // bisherigen (nicht stornierten) Aufträge des Kunden. + if (input.email && isEmail(norm(input.email))) { + try { + const users = new UsersService({ schema, accountability: null }); + const kunde = (await users.readByQuery({ + filter: { email: { _eq: norm(input.email) } }, limit: 1, fields: ['id'], + }))?.[0]; + if (kunde) { + const jetzt = new Date().toISOString(); + const rabatt = (await svc('customer_discounts').readByQuery({ + filter: { + customer: { _eq: kunde.id }, active: { _eq: true }, + _or: [{ valid_until: { _null: true } }, { valid_until: { _gte: jetzt } }], + }, + limit: 1, sort: ['-value'], + fields: ['label', 'type', 'value', 'scope', 'limit_stueck'], + }))?.[0]; + if (rabatt && Number(rabatt.value) > 0) { + const proStueck = menge > 0 ? grund / menge : 0; + let stueck = menge; // scope 'alle' + if (rabatt.scope === 'erste_n_stueck') { + const frueher = await svc('orders').readByQuery({ + filter: { customer: { _eq: kunde.id }, status: { _neq: 'storniert' } }, + limit: -1, fields: ['entries_count'], + }); + const genutzt = (frueher || []).reduce((s, o) => s + (Number(o.entries_count) || 0), 0); + stueck = Math.min(menge, Math.max(0, (Number(rabatt.limit_stueck) || 0) - genutzt)); + } + if (stueck > 0) { + const betrag = rabatt.type === 'prozent' + ? proStueck * stueck * (Number(rabatt.value) / 100) + : Number(rabatt.value) * stueck; + add(`Kundenrabatt${rabatt.label ? ` (${rabatt.label})` : ''}`, -Math.min(betrag, grund), true); + } + } + } + } catch (e) { logger.warn(`[skrift-orders] Kundenrabatt übersprungen: ${e.message}`); } } let net = lines.reduce((s, l) => s + l.amount, 0); @@ -169,6 +338,8 @@ const handler = (router, { services, getSchema, logger }) => { } net = round2(Math.max(0, net)); + // Netto-Summe NUR der pro-Stück-Positionen (für „Preis pro Stück" ohne Einmalkosten). + const unitNet = round2(Math.max(0, lines.filter((l) => l.per_unit).reduce((s, l) => s + l.amount, 0))); const mwst = Number(settings.mwst_percent ?? 19); const vat = round2(net * (mwst / 100)); const gross = round2(net + vat); @@ -177,7 +348,7 @@ const handler = (router, { services, getSchema, logger }) => { const limit = Number(settings.paypal_limit_net ?? 200); const paypalAllowed = net < limit; - return { product, lines, net_total: net, vat_amount: vat, gross_total: gross, + return { product, lines, net_total: net, unit_net_total: unitNet, vat_amount: vat, gross_total: gross, mwst_percent: mwst, paypal_allowed: paypalAllowed, paypal_limit_net: limit, voucher }; } @@ -202,7 +373,8 @@ const handler = (router, { services, getSchema, logger }) => { ...body, quantity: body.quantity ?? (body.entries || []).length, }); return res.json({ - lines: quote.lines, net_total: quote.net_total, vat_amount: quote.vat_amount, + lines: quote.lines, net_total: quote.net_total, unit_net_total: quote.unit_net_total, + vat_amount: quote.vat_amount, gross_total: quote.gross_total, mwst_percent: quote.mwst_percent, paypal_allowed: quote.paypal_allowed, }); @@ -211,6 +383,638 @@ const handler = (router, { services, getSchema, logger }) => { } }); + // ── Vorschau ───────────────────────────────────────────────────────────── + /** + * Erzeugt bis zu PREVIEW_MAX_DOCS Vorschau-SVGs über das Backend. + * Das Kontingent wird serverseitig geführt – jede Generierung kostet einen + * Scriptalizer-Aufruf, deshalb darf der Client das nicht bestimmen. + */ + router.post('/preview', async (req, res) => { + const body = req.body || {}; + // Reine Zeilen-/Überlauf-Prüfung (beim „Weiter") verbraucht KEIN Kontingent + // und liefert keine SVGs zurück – nur has_overflow/overflow. + const nurPruefen = body.validate === true; + const kennung = norm(body.email) || String(body.client_id || '').slice(0, 64); + if (!kennung && !nurPruefen) return res.status(400).json({ error: 'E-Mail oder Kennung erforderlich.' }); + if (!body.text || !String(body.text).trim()) + return res.status(400).json({ error: 'Kein Text für die Vorschau.' }); + + try { + const schema = await getSchema(); + const svc = (c) => new ItemsService(c, { schema, accountability: null }); + const heute = new Date().toISOString().slice(0, 10); + + const vorhanden = nurPruefen ? null : (await svc('preview_usage').readByQuery({ + filter: { key: { _eq: kennung }, day: { _eq: heute } }, limit: 1, fields: ['id', 'count'], + }))?.[0]; + const verbraucht = vorhanden?.count ?? 0; + if (!nurPruefen && verbraucht >= PREVIEW_LIMIT) { + return res.status(429).json({ + error: `Vorschau-Kontingent für heute aufgebraucht (${PREVIEW_LIMIT}).`, + remaining: 0, limit: PREVIEW_LIMIT, + }); + } + + const { settings } = await loadPricingContext(schema); + const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E'); + + const entries = (Array.isArray(body.entries) ? body.entries : []).slice(0, PREVIEW_MAX_DOCS); + const font = ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda'; + const realistisch = body.realistic !== false; + // App-Formatschlüssel → Backend-Formate (wie im directus-controller). + // Ohne diese Abbildung rendert das Backend unbekannte Keys als A4. + const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' }; + const briefFormat = LETTER_FORMAT[body.format] || 'a4'; + // A4 → DIN Lang, A6 → C6 + const umschlagFormat = briefFormat === 'a4' ? 'din_lang' : 'c6'; + + const platzhalterVon = (e, i) => ({ + Anrede: clean(e.salutation || ''), + Vorname: clean(e.first_name || ''), + Nachname: clean(e.last_name || ''), + Strasse: clean([e.street, e.house_no].filter(Boolean).join(' ')), + PLZ: clean(e.zip || ''), + Ort: clean(e.city || ''), + Land: clean(e.country || ''), + Briefnummer: String(i + 1), + ...(e.placeholders && typeof e.placeholders === 'object' + ? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))])) + : {}), + }); + + const adressBlock = (e) => { + if (e.free_text) return clean(String(e.free_text)); + const name = [e.first_name, e.last_name].filter(Boolean).join(' '); + const strasse = [e.street, e.house_no].filter(Boolean).join(' '); + const ort = [e.zip, e.city].filter(Boolean).join(' '); + return clean([name, strasse, ort].filter(Boolean).join('\n')); + }; + + const basis = entries.length ? entries : [{}]; + const letters = []; + basis.forEach((e, i) => { + // Dateinummer 1-basiert = Briefnummer (letter_001.svg ↔ Brief 1). + const nr = i + 1; + letters.push({ + index: nr, type: 'letter', text: clean(String(body.text)), + format: briefFormat, font, realisticHandwriting: realistisch, + placeholders: platzhalterVon(e, i), + }); + + // Umschlag nur, wenn er auch beschriftet wird. + if (body.envelope_labeling && body.envelope_labeling !== 'keine') { + const istFreitext = body.envelope_labeling === 'freitext'; + letters.push({ + index: nr, type: 'envelope', + envelopeType: istFreitext ? 'custom' : 'recipient', + text: istFreitext ? clean(String(body.envelope_text || '')) : adressBlock(e), + format: umschlagFormat, font, realisticHandwriting: realistisch, + placeholders: platzhalterVon(e, i), + }); + } + }); + + const sessionId = `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const kopf = { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN }; + + // Das Backend liest `letters` direkt aus dem Body (nicht aus `config`). + // Umschläge stecken als Einträge mit type:'envelope' bereits in `letters`. + const antwort = await fetch(`${BACKEND_URL}/api/preview/batch`, { + method: 'POST', + headers: kopf, + body: JSON.stringify({ sessionId, letters }), + }); + if (!antwort.ok) { + const txt = await antwort.text(); + logger.error(`[skrift-orders] Vorschau-Backend: ${antwort.status} ${txt}`); + // Grund für die Diagnose mitgeben (Status + gekürzte Backend-Meldung). + return res.status(502).json({ + error: 'Vorschau konnte nicht erzeugt werden.', + detail: `Backend ${antwort.status}: ${String(txt).slice(0, 200)}`, + }); + } + const daten = await antwort.json(); + + // Zeilen-Überlauf (Schriftstück zu lang für das Format) durchreichen. + const overflow = (daten.overflowFiles || []) + .map((f) => ({ index: f.index, lineCount: f.lineCount, lineLimit: f.lineLimit })); + + // Reine Prüfung (beim „Weiter"): kein Download, kein Kontingent-Verbrauch. + if (nurPruefen) { + return res.json({ has_overflow: !!daten.hasOverflow, overflow }); + } + + // SVGs direkt einsammeln – so braucht der Browser keinen Backend-Zugang. + // Schriftstücke und Umschläge kommen gemeinsam zurück. + // Umschlag vs. Schriftstück steckt im Dateinamen-Präfix (envelope_ / letter_) – + // die Datei-Objekte des Backends tragen kein eigenes type-Feld. + const docs = []; + for (const datei of (daten.files || []).slice(0, PREVIEW_MAX_DOCS * 2)) { + const r = await fetch(`${BACKEND_URL}${datei.url}`, { headers: { 'X-API-Token': BACKEND_TOKEN } }); + if (r.ok) { + const istUmschlag = /(^|\/)envelope/.test(datei.filename || datei.url || ''); + docs.push({ art: istUmschlag ? 'umschlag' : 'brief', svg: await r.text() }); + } + } + + // Verbrauch erst nach Erfolg hochzählen. + if (vorhanden) await svc('preview_usage').updateOne(vorhanden.id, { count: verbraucht + 1 }); + else await svc('preview_usage').createOne({ key: kennung, day: heute, count: 1 }); + + return res.json({ + docs, remaining: PREVIEW_LIMIT - (verbraucht + 1), limit: PREVIEW_LIMIT, + has_overflow: !!daten.hasOverflow, overflow, + // Session zurückgeben, damit die gezeigten Dokumente bei der Bestellung + // 1:1 übernommen werden können. + session: sessionId, + }); + } catch (err) { + logger.error(`[skrift-orders] preview: ${err.stack || err.message}`); + return res.status(500).json({ error: 'Vorschau fehlgeschlagen.' }); + } + }); + + /** Aktuelles Kontingent abfragen, ohne etwas zu verbrauchen. */ + router.get('/preview/quota', async (req, res) => { + try { + const kennung = norm(req.query.email) || String(req.query.client_id || '').slice(0, 64); + if (!kennung) return res.json({ remaining: PREVIEW_LIMIT, limit: PREVIEW_LIMIT }); + const schema = await getSchema(); + const heute = new Date().toISOString().slice(0, 10); + const row = (await new ItemsService('preview_usage', { schema, accountability: null }).readByQuery({ + filter: { key: { _eq: kennung }, day: { _eq: heute } }, limit: 1, fields: ['count'], + }))?.[0]; + return res.json({ remaining: Math.max(0, PREVIEW_LIMIT - (row?.count ?? 0)), limit: PREVIEW_LIMIT }); + } catch { + return res.json({ remaining: PREVIEW_LIMIT, limit: PREVIEW_LIMIT }); + } + }); + + // ── Auftragsdateien (Direktzugriff auf den Ausgabe-Ordner, keine Kopie) ──── + // Zugriff: App-Nutzer (Staff/Admin) ODER der Produktions-Agent (Rolle Service). + let _serviceRoleId; + async function serviceRoleId() { + if (_serviceRoleId !== undefined) return _serviceRoleId; + try { + const rows = await new ItemsService('directus_roles', { schema: await getSchema(), accountability: null }) + .readByQuery({ filter: { name: { _eq: 'Service' } }, limit: 1, fields: ['id'] }); + _serviceRoleId = rows?.[0]?.id ?? null; + } catch { _serviceRoleId = null; } + return _serviceRoleId; + } + async function darfDateien(req) { + const acc = req.accountability; + if (!acc || !acc.user) return false; + if (acc.admin || acc.app) return true; // Operator im Admin + const svc = await serviceRoleId(); + return !!(svc && acc.role === svc); // Produktions-Agent + } + const safeOrder = (s) => (/^[A-Za-z0-9_-]+$/.test(String(s || '')) ? String(s) : null); + + /** Listet die Dateien eines Auftrags aus dem gemounteten Ausgabe-Ordner. */ + function listeDateien(orderNummer) { + const basis = path.join(OUTPUT_DIR, orderNummer); + let root; + try { root = fs.readdirSync(basis); } catch { return null; } // Ordner (noch) nicht da + const out = []; + const add = (rel, kind) => { + try { + const st = fs.statSync(path.join(basis, rel)); + if (st.isFile()) out.push({ name: path.basename(rel), rel, kind, size: st.size }); + } catch { /* ignore */ } + }; + for (const f of root) { + if (/\.csv$/i.test(f)) add(f, 'platzhalter'); + else if (/\.svg$/i.test(f)) add(f, 'schriftstueck'); + } + try { + for (const f of fs.readdirSync(path.join(basis, 'umschlaege'))) { + if (/\.svg$/i.test(f)) add(path.join('umschlaege', f), 'umschlag'); + } + } catch { /* keine Umschläge */ } + return out; + } + + router.get('/files/:order', async (req, res) => { + if (!(await darfDateien(req))) return res.status(403).json({ error: 'Kein Zugriff.' }); + const nummer = safeOrder(req.params.order); + if (!nummer) return res.status(400).json({ error: 'Ungültige Auftragsnummer.' }); + const files = listeDateien(nummer); + if (files === null) return res.json({ files: [], exists: false }); + return res.json({ files, exists: true }); + }); + + router.get('/files/:order/download', async (req, res) => { + if (!(await darfDateien(req))) return res.status(403).json({ error: 'Kein Zugriff.' }); + const nummer = safeOrder(req.params.order); + const rel = String(req.query.rel || ''); + if (!nummer || rel.includes('..') || rel.startsWith('/') || rel.startsWith('\\')) { + return res.status(400).json({ error: 'Ungültiger Pfad.' }); + } + const basis = path.resolve(OUTPUT_DIR, nummer); + const ziel = path.resolve(basis, rel); + // Pfad muss innerhalb des Auftragsordners liegen (kein Ausbruch). + if (ziel !== basis && !ziel.startsWith(basis + path.sep)) { + return res.status(400).json({ error: 'Ungültiger Pfad.' }); + } + if (!fs.existsSync(ziel) || !fs.statSync(ziel).isFile()) { + return res.status(404).json({ error: 'Datei nicht gefunden.' }); + } + const typ = ziel.endsWith('.svg') ? 'image/svg+xml' + : ziel.endsWith('.csv') ? 'text/csv; charset=utf-8' : 'application/octet-stream'; + res.setHeader('Content-Type', typ); + res.setHeader('Content-Disposition', `attachment; filename="${path.basename(ziel)}"`); + fs.createReadStream(ziel).pipe(res); + }); + + // ── 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). + const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6_v2' }; + const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' }; + + router.post('/enqueue', async (req, res) => { + const acc = req.accountability; + if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' }); + const body = req.body || {}; + if (!body.order || !body.machine) return res.status(400).json({ error: 'order und machine erforderlich.' }); + try { + const schema = await getSchema(); + const svc = (c) => new ItemsService(c, { schema, accountability: null }); + const order = await svc('orders').readOne(body.order, { + fields: ['id', 'order_number', 'format.key', 'needs_envelope', 'envelope_labeling', 'envelope_format'], + }); + if (!order) return res.status(404).json({ error: 'Auftrag nicht gefunden.' }); + + const tplByKey = async (key) => (await svc('format_templates').readByQuery({ + filter: { key: { _eq: key } }, limit: 1, fields: ['id'], + }))?.[0]?.id ?? null; + + // Läuft auf dieser Maschine gerade ein Druck? → nur Warteschlange möglich. + const laufend = await svc('jobs').readByQuery({ + filter: { machine: { _eq: body.machine }, status: { _eq: 'printing' } }, limit: 1, fields: ['id'], + }); + const busy = !!(laufend && laufend.length); + const modus = (body.mode === 'direct' && !busy) ? 'direct' : 'queue'; + const prio = modus === 'direct' ? 100 : 0; + + // Reinigt eine Maß-Übersteuerung auf erlaubte Zahlenfelder (oder null). + const clampParams = (p) => { + if (!p || typeof p !== 'object') return null; + const out = {}; + for (const k of ['width', 'height', 'xpos', 'ypos', 'scale', 'rotation']) { + if (p[k] !== undefined && p[k] !== null && p[k] !== '' && !isNaN(Number(p[k]))) out[k] = Number(p[k]); + } + return Object.keys(out).length ? out : null; + }; + + const jobs = []; + // Neue, ausführliche Form: explizite Abschnitte (Brief/Umschlag) aus dem Popup. + const s = body.sections; + if (s && typeof s === 'object') { + for (const typ of ['brief', 'umschlag']) { + const sec = s[typ]; + if (!sec || sec.enabled === false) continue; + if (!sec.template) return res.status(400).json({ error: `Template für ${typ} fehlt.` }); + jobs.push(await svc('jobs').createOne({ + order: order.id, machine: body.machine, type: typ, template: sec.template, + params: clampParams(sec.params), numbers: (typeof sec.numbers === 'string' && sec.numbers.trim()) ? sec.numbers.trim() : null, + status: 'queued', priority: prio, + })); + } + if (!jobs.length) return res.status(400).json({ error: 'Kein Abschnitt zum Drucken gewählt.' }); + } else { + // Alte, einfache Form (Auftrags-Button): Brief + ggf. Umschlag automatisch. + const briefTpl = await tplByKey(LETTER_TPL[order.format?.key] || 'a4_single'); + jobs.push(await svc('jobs').createOne({ + order: order.id, machine: body.machine, type: 'brief', template: briefTpl, status: 'queued', priority: prio, + })); + if (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine') { + const envTpl = await tplByKey(ENVELOPE_TPL[order.envelope_format] || 'kuvert_a_v1'); + jobs.push(await svc('jobs').createOne({ + order: order.id, machine: body.machine, type: 'umschlag', template: envTpl, status: 'queued', priority: prio, + })); + } + } + return res.json({ ok: true, jobs, mode: modus, busy }); + } catch (err) { + logger.error(`[skrift-orders] enqueue: ${err.stack || err.message}`); + return res.status(500).json({ error: 'Einreihen fehlgeschlagen.' }); + } + }); + + // ── Testdruck: beliebiges Template + Text generieren und einreihen ───────── + const TEST_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' }; + router.post('/test-print', async (req, res) => { + const acc = req.accountability; + if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' }); + const { id } = req.body || {}; + if (!id) return res.status(400).json({ error: 'id erforderlich.' }); + try { + const schema = await getSchema(); + const svc = (c) => new ItemsService(c, { schema, accountability: null }); + const tp = await svc('test_prints').readOne(id, { + fields: ['id', 'text', 'font', 'format', 'machine', 'template'], + }); + if (!tp) return res.status(404).json({ error: 'Testdruck nicht gefunden.' }); + if (!tp.template || !tp.machine) return res.status(400).json({ error: 'Bitte Template und Maschine wählen.' }); + + const ref = `TEST-${id}-${Date.now().toString(36)}`; + const font = ['tilda', 'alva', 'ellie'].includes(tp.font) ? tp.font : 'tilda'; + const format = TEST_FORMAT[tp.format] || 'a4'; + + // 1) Test-SVG über das bestehende Backend erzeugen (schreibt in den Ausgabeordner). + const gen = await fetch(`${BACKEND_URL}/api/order/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN }, + body: JSON.stringify({ + orderNumber: ref, + letters: [{ index: 0, type: 'letter', text: tp.text || 'Skrift Testdruck', format, font, realisticHandwriting: false }], + metadata: {}, + }), + }); + if (!gen.ok) { + const txt = await gen.text(); + await svc('test_prints').updateOne(id, { status: 'fehler' }); + return res.status(502).json({ error: 'Generierung fehlgeschlagen.', detail: `Backend ${gen.status}: ${String(txt).slice(0, 200)}` }); + } + + // 2) Druck-Job (ohne Auftrag, über output_ref) für die gewählte Maschine. + const job = await svc('jobs').createOne({ + machine: tp.machine, template: tp.template, type: 'brief', status: 'queued', output_ref: ref, + }); + await svc('test_prints').updateOne(id, { status: 'generiert', output_ref: ref }); + return res.json({ ok: true, ref, job }); + } catch (err) { + logger.error(`[skrift-orders] test-print: ${err.stack || err.message}`); + return res.status(500).json({ error: 'Testdruck fehlgeschlagen.' }); + } + }); + + // ── Manueller Druck (Muster) ─────────────────────────────────────────────── + // Generiert ein einzelnes Schriftstück aus freiem Text (Adresse/Standardtext + // wurden im Dialog bereits eingesetzt) und reiht es als Job ein. Optional wird + // die zugehörige Musteranfrage auf „in_bearbeitung" gesetzt. + router.post('/manual-print', async (req, res) => { + const acc = req.accountability; + if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' }); + const body = req.body || {}; + if (!body.text || !String(body.text).trim()) return res.status(400).json({ error: 'Text fehlt.' }); + if (!body.template || !body.machine) return res.status(400).json({ error: 'Template und Maschine erforderlich.' }); + try { + const schema = await getSchema(); + const svc = (c) => new ItemsService(c, { schema, accountability: null }); + const { settings } = await loadPricingContext(schema); + const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E'); + + const ref = `MUSTER-${(body.offer_request || 'x')}-${Date.now().toString(36)}`; + const font = ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda'; + const format = TEST_FORMAT[body.format] || 'a4'; + + const gen = await fetch(`${BACKEND_URL}/api/order/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN }, + body: JSON.stringify({ + orderNumber: ref, + letters: [{ index: 1, type: 'letter', text: clean(String(body.text)), format, font, realisticHandwriting: body.realistic !== false }], + metadata: { rawText: String(body.text) }, + }), + }); + if (!gen.ok) { + const txt = await gen.text(); + return res.status(502).json({ error: 'Generierung fehlgeschlagen.', detail: `Backend ${gen.status}: ${String(txt).slice(0, 200)}` }); + } + + const busyList = await svc('jobs').readByQuery({ + filter: { machine: { _eq: body.machine }, status: { _eq: 'printing' } }, limit: 1, fields: ['id'], + }); + const busy = !!(busyList && busyList.length); + const prio = (body.mode === 'direct' && !busy) ? 100 : 0; + + const job = await svc('jobs').createOne({ + machine: body.machine, template: body.template, type: 'brief', status: 'queued', output_ref: ref, priority: prio, + }); + if (body.offer_request) { + try { await svc('offer_requests').updateOne(body.offer_request, { status: 'in_bearbeitung' }); } catch { /* nicht kritisch */ } + } + return res.json({ ok: true, ref, job, mode: prio ? 'direct' : 'queue', busy }); + } catch (err) { + logger.error(`[skrift-orders] manual-print: ${err.stack || err.message}`); + return res.status(500).json({ error: 'Manueller Druck fehlgeschlagen.' }); + } + }); + + // ── Zahlung bestätigen (serverseitig verifiziert) ──────────────────────── + /** + * Verifiziert die PayPal-Zahlung gegen die PayPal-API: + * - Capture existiert und ist COMPLETED + * - gezahlter Betrag == serverseitig gespeicherter Auftragsbetrag (gross_total) + * Erst dann wird der Auftrag als bezahlt markiert. Dem Client wird nicht + * vertraut – weder Betrag noch Status. + */ + router.post('/payment', async (req, res) => { + const { order_number, payment_ref } = req.body || {}; + if (!order_number || !payment_ref) + return res.status(400).json({ error: 'order_number und payment_ref erforderlich.' }); + if (!PAYPAL_CLIENT_ID || !PAYPAL_SECRET) { + logger.error('[skrift-orders] PayPal-Server-Zugangsdaten fehlen.'); + return res.status(500).json({ error: 'Zahlungsprüfung nicht konfiguriert.' }); + } + + try { + const schema = await getSchema(); + const svc = (c) => new ItemsService(c, { schema, accountability: null }); + + const order = (await svc('orders').readByQuery({ + filter: { order_number: { _eq: String(order_number) } }, + limit: 1, fields: ['id', 'payment_status', 'gross_total'], + }))?.[0]; + if (!order) return res.status(404).json({ error: 'Auftrag nicht gefunden.' }); + if (order.payment_status === 'bezahlt') return res.json({ ok: true, bereits: true }); + + // Capture bei PayPal abfragen und prüfen. + const token = await paypalToken(); + const r = await fetch(`${PAYPAL_API}/v2/payments/captures/${encodeURIComponent(String(payment_ref))}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!r.ok) { + logger.error(`[skrift-orders] PayPal-Capture ${payment_ref}: ${r.status}`); + return res.status(400).json({ error: 'Zahlung konnte nicht verifiziert werden.' }); + } + const capture = await r.json(); + + const bezahlt = Number(capture?.amount?.value); + const soll = Number(order.gross_total); + const betragOk = Number.isFinite(bezahlt) && Math.abs(bezahlt - soll) < 0.01; + + if (capture?.status !== 'COMPLETED' || capture?.amount?.currency_code !== 'EUR' || !betragOk) { + logger.warn(`[skrift-orders] Zahlung ungültig: status=${capture?.status} ` + + `betrag=${capture?.amount?.value} soll=${soll}`); + return res.status(400).json({ error: 'Zahlung stimmt nicht mit dem Auftrag überein.' }); + } + + await svc('orders').updateOne(order.id, { + payment_status: 'bezahlt', + payment_ref: String(payment_ref).slice(0, 128), + status: 'bezahlt', + }); + await svc('status_history').createOne({ + order: order.id, status: 'bezahlt', + note: `Zahlung über PayPal verifiziert (${bezahlt.toFixed(2)} €).`, + }); + + return res.json({ ok: true }); + } catch (err) { + logger.error(`[skrift-orders] payment: ${err.stack || err.message}`); + return res.status(500).json({ error: 'Zahlung konnte nicht verifiziert werden.' }); + } + }); + + /** + * Absender aus email_settings („Skrift "). Ohne das greift der + * Directus-Standardabsender – dann steht „Directus" im Postfach. + */ + async function absender(schema) { + try { + const s = await new ItemsService('email_settings', { schema, accountability: null }) + .readSingleton({ fields: ['sender_name', 'sender_email'] }); + if (s && s.sender_email) { + return s.sender_name ? `${s.sender_name} <${s.sender_email}>` : s.sender_email; + } + } catch { /* Standard verwenden */ } + return null; + } + + // ── Angebotsanfrage („Individuelles Angebot") ──────────────────────────── + router.post('/offer-request', async (req, res) => { + const body = req.body || {}; + const name = String(body.name || '').trim(); + const email = norm(body.email); + const message = String(body.message || '').trim(); + + const errors = []; + if (!name) errors.push('Name fehlt.'); + if (!isEmail(email)) errors.push('Gültige E-Mail-Adresse fehlt.'); + if (!message && !body.product) errors.push('Bitte beschreiben Sie Ihr Vorhaben.'); + if (message.length > 5000) errors.push('Beschreibung ist zu lang.'); + if (errors.length) return res.status(400).json({ errors }); + + try { + const schema = await getSchema(); + const svc = (c) => new ItemsService(c, { schema, accountability: null }); + + const { settings } = await loadPricingContext(schema); + const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E'); + + // Falls es zu der E-Mail schon ein Konto gibt, hängen wir die Anfrage an. + const customer = (await new UsersService({ schema, accountability: null }).readByQuery({ + filter: { email: { _eq: email } }, limit: 1, fields: ['id'], + }))?.[0]; + + const quantity = Number.parseInt(body.quantity, 10); + + await svc('offer_requests').createOne({ + type: 'angebot', + name: clean(name), + email, + phone: clean(String(body.phone || '').trim()) || null, + quantity: Number.isFinite(quantity) && quantity > 0 ? quantity : null, + product: body.product ? clean(String(body.product)) : null, + message: clean(message), + status: 'neu', + customer: customer?.id ?? null, + }); + + // Benachrichtigung – darf den Erfolg der Anfrage nicht gefährden. + try { + const mail = new MailService({ schema, accountability: null }); + const von = await absender(schema); + // Angefragtes Produkt (z. B. Unterschriftenservice, Follow-ups) mit ausweisen. + const produkt = body.product ? clean(String(body.product)) : null; + await mail.send({ + ...(von ? { from: von } : {}), + to: TEAM_EMAIL, + subject: produkt ? `Angebotsanfrage: ${produkt} – ${name}` : `Angebotsanfrage von ${name}`, + html: + `

${name} (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})

` + + `

Anfrage betrifft: ${produkt || 'Individuelles Angebot'}

` + + `

Menge: ${Number.isFinite(quantity) ? quantity : 'k. A.'}

` + + `

${clean(message)}

`, + }); + } catch (mailErr) { + logger.warn(`[skrift-orders] Angebots-Mail nicht versendet: ${mailErr.message}`); + } + + return res.json({ ok: true }); + } catch (err) { + logger.error(`[skrift-orders] offer-request: ${err.stack || err.message}`); + return res.status(500).json({ error: 'Anfrage konnte nicht gespeichert werden.' }); + } + }); + + // ── Musterbestellung (kostenlos) ───────────────────────────────────────── + router.post('/sample-request', async (req, res) => { + const body = req.body || {}; + const email = norm(body.email); + const errors = []; + if (!body.product) errors.push('Produkt fehlt.'); + if (!String(body.name || '').trim()) errors.push('Name fehlt.'); + if (!isEmail(email)) errors.push('Gültige E-Mail-Adresse fehlt.'); + if (!String(body.street || '').trim() || !String(body.zip || '').trim() || !String(body.city || '').trim()) + errors.push('Lieferadresse unvollständig.'); + if (errors.length) return res.status(400).json({ errors }); + + try { + const schema = await getSchema(); + const svc = (c) => new ItemsService(c, { schema, accountability: null }); + const { settings } = await loadPricingContext(schema); + const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E'); + + const customer = (await new UsersService({ schema, accountability: null }).readByQuery({ + filter: { email: { _eq: email } }, limit: 1, fields: ['id'], + }))?.[0]; + + await svc('offer_requests').createOne({ + type: 'muster', + name: clean(String(body.name).trim()), + email, + phone: clean(String(body.phone || '').trim()) || null, + product: clean(String(body.product)), + message: null, + address: { + street: clean(String(body.street || '')), + zip: clean(String(body.zip || '')), + city: clean(String(body.city || '')), + country: clean(String(body.country || '')), + }, + status: 'neu', + customer: customer?.id ?? null, + }); + + try { + const mail = new MailService({ schema, accountability: null }); + const von = await absender(schema); + await mail.send({ + ...(von ? { from: von } : {}), + to: TEAM_EMAIL, + subject: `Musterbestellung: ${clean(String(body.product))} – ${body.name}`, + html: + `

${clean(String(body.name))} (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})

` + + `

Produkt: ${clean(String(body.product))}

` + + `

${clean(String(body.street))}, ${clean(String(body.zip))} ${clean(String(body.city))}, ` + + `${clean(String(body.country || ''))}

`, + }); + } catch (mailErr) { + logger.warn(`[skrift-orders] Muster-Mail nicht versendet: ${mailErr.message}`); + } + + return res.json({ ok: true }); + } catch (err) { + logger.error(`[skrift-orders] sample-request: ${err.stack || err.message}`); + return res.status(500).json({ error: 'Musterbestellung konnte nicht gespeichert werden.' }); + } + }); + // ── Auftrag anlegen ────────────────────────────────────────────────────── router.post('/', async (req, res) => { const body = req.body || {}; @@ -232,14 +1036,21 @@ const handler = (router, { services, getSchema, logger }) => { let paymentMethod = body.payment_method === 'paypal' ? 'paypal' : 'rechnung'; if (paymentMethod === 'paypal' && !quote.paypal_allowed) paymentMethod = 'rechnung'; - // Kunde finden oder anlegen – Zuordnung immer per E-Mail - const customers = svc('customers'); - let customer = (await customers.readByQuery({ + // Kunde = Login-Konto (directus_users). Finden oder anlegen – per E-Mail. + const users = new UsersService({ schema, accountability: null }); + let customer = (await users.readByQuery({ filter: { email: { _eq: email } }, limit: 1, fields: ['id'], }))?.[0]; if (!customer) { - const id = await customers.createOne({ + const rolesSvc = new RolesService({ schema, accountability: null }); + const role = (await rolesSvc.readByQuery({ + filter: { name: { _eq: 'Kunde' } }, limit: 1, fields: ['id'], + }))?.[0]; + const id = await users.createOne({ email, + password: zufallsPasswort(), + role: role?.id ?? null, + status: 'active', person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat', first_name: clean(body.first_name) ?? null, last_name: clean(body.last_name) ?? null, @@ -249,6 +1060,18 @@ const handler = (router, { services, getSchema, logger }) => { customer = { id }; } + // Spezial-/Kontaktprodukte (z. B. Unterschriftenservice) sind nur + // bestellbar, wenn sie für dieses Konto freigeschaltet wurden. + if (quote.product.type === 'contact') { + const frei = await svc('customer_products').readByQuery({ + filter: { customer: { _eq: customer.id }, product: { _eq: quote.product.id } }, + limit: 1, fields: ['id'], + }); + if (!frei?.length) { + return res.status(403).json({ errors: ['Dieses Produkt ist für Ihr Konto nicht freigeschaltet.'] }); + } + } + // Format auflösen (optional) let formatId = null; if (body.format) { @@ -262,11 +1085,24 @@ const handler = (router, { services, getSchema, logger }) => { ? (String(body.format || '').startsWith('a4') ? 'dinlang' : 'c6') : null; - const orderNumber = `SK-${new Date().toISOString().slice(0, 10)}-${ - String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`; + // Empfänger nach Inland/Ausland aufteilen (für die Auftragsübersicht). + const istInland = (e) => !e.country || /^(de|deutschland|germany)$/i.test(String(e.country).trim()); + const countAusland = entries.filter((e) => !istInland(e)).length; + const countInland = entries.length - countAusland; - const orderId = await svc('orders').createOne({ - order_number: orderNumber, + // Fortlaufende Bestellnummer je Tag: dd-mm-yy-NNN (NNN ab 001). + const jt = new Date(); + const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`; + const naechsteNummer = async () => { + const heutige = await svc('orders').readByQuery({ + filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'], + }); + return `${praefix}-${String((heutige?.length ?? 0) + 1).padStart(3, '0')}`; + }; + + // Anlegen mit Wiederholung, falls zwei Bestellungen gleichzeitig dieselbe + // Nummer träfen (order_number ist eindeutig). + const orderData = { customer: customer.id, customer_email: email, product: quote.product.id, @@ -280,17 +1116,37 @@ const handler = (router, { services, getSchema, logger }) => { envelope_labeling: body.envelope_labeling ?? null, envelope_format: envelopeFormat, motif_mode: body.motif_mode ?? null, + motif: Number.isFinite(Number(body.motif)) && body.motif ? Number(body.motif) : null, + motif_upload: body.motif_upload || null, + source_file: body.source_file || null, + billing: body.billing && typeof body.billing === 'object' ? body.billing : null, + shipping_address: body.shipping_address && typeof body.shipping_address === 'object' ? body.shipping_address : null, own_cards: body.motif_mode === 'eigene_karten', text_template: clean(body.text_template) ?? null, text_briefing: clean(body.text_briefing) ?? null, + preview_session: typeof body.preview_session === 'string' ? body.preview_session : null, entries_count: entries.length, + count_inland: countInland, + count_ausland: countAusland, net_total: quote.net_total, vat_amount: quote.vat_amount, gross_total: quote.gross_total, voucher: quote.voucher?.id ?? null, payment_method: paymentMethod, payment_status: 'offen', - }); + }; + + let orderId, orderNumber; + for (let versuch = 0; versuch < 6; versuch += 1) { + orderNumber = await naechsteNummer(); + try { + orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); + break; + } catch (e) { + const dup = /unique|duplicate|bereits|exists/i.test(String(e?.message || '')); + if (versuch === 5 || !dup) throw e; + } + } // Empfängerzeilen – Briefnummer wird IMMER vom System vergeben const entriesSvc = svc('order_entries'); @@ -334,6 +1190,19 @@ const handler = (router, { services, getSchema, logger }) => { order: orderId, status: 'wartet_auf_zahlung', note: 'Auftrag über den Konfigurator angelegt.', }); + // SVGs automatisch erzeugen, sobald der Kunde den Auftrag abschließt. + // Handschrift-Produkte werden gerendert (gezeigte Vorschau-Dokumente 1:1); + // Datei-Produkte (Upload) nicht. Feuern & vergessen – blockiert die Antwort + // an den Kunden nicht (das Backend generiert im Hintergrund und schreibt + // Status/artifact_path selbst zurück). + if (quote.product.type === 'letter' || quote.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] Auto-Generierung nicht gestartet: ${e.message}`)); + } + return res.json({ order_number: orderNumber, net_total: quote.net_total, vat_amount: quote.vat_amount, gross_total: quote.gross_total, diff --git a/Docker/directus/extensions/directus-extension-skrift-printbutton/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-printbutton/dist/index.js new file mode 100644 index 0000000..562ea19 --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-printbutton/dist/index.js @@ -0,0 +1,83 @@ +/** + * Skrift – Interface „Drucken" + * --------------------------------------------------------------------------- + * Zeigt im Auftrag eine Maschinen-Auswahl + „Drucken"-Button. Der Button reiht + * den Auftrag über POST /skrift-orders/enqueue als Druck-Job(s) ein (Brief + + * ggf. Umschlag, mit aufgelöstem Template). Läuft über die angemeldete Session. + * + * Bewusst von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit. + */ +import { defineComponent, ref, inject, onMounted, h } from 'vue'; +import { useApi } from '@directus/extensions-sdk'; + +const PrintButton = defineComponent({ + setup() { + const api = useApi(); + const values = inject('values', ref({})); + const machines = ref([]); + const selected = ref(null); + const busy = ref(false); + const msg = ref(''); + const err = ref(''); + + async function ladeMaschinen() { + try { + const res = await api.get('/items/machines', { params: { fields: 'id,name,active', limit: -1 } }); + machines.value = (res.data?.data || []).filter((m) => m.active !== false); + if (machines.value.length && selected.value == null) selected.value = machines.value[0].id; + } catch { + err.value = 'Maschinen konnten nicht geladen werden.'; + } + } + onMounted(ladeMaschinen); + + async function drucken() { + const orderId = values && values.value ? values.value.id : null; + if (!orderId) { err.value = 'Auftrag zuerst speichern.'; return; } + if (selected.value == null) { err.value = 'Bitte eine Maschine wählen.'; return; } + busy.value = true; err.value = ''; msg.value = ''; + try { + const res = await api.post('/skrift-orders/enqueue', { order: orderId, machine: selected.value }); + const n = (res.data?.jobs || []).length; + msg.value = `${n} Druck-Job(s) eingereiht.`; + } catch (e) { + err.value = (e && e.response && e.response.data && e.response.data.error) || (e && e.message) || 'Fehler beim Einreihen.'; + } finally { + busy.value = false; + } + } + + return () => { + if (!machines.value.length) { + return h('div', { style: 'color:var(--foreground-subdued);font-size:13px;' }, + err.value || 'Keine aktive Maschine – bitte unter Einstellungen → Maschinen anlegen.'); + } + return h('div', { style: 'display:flex;flex-direction:column;gap:8px;' }, [ + h('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;' }, [ + h('select', { + style: 'padding:6px 10px;border:1px solid var(--border-normal);border-radius:6px;background:var(--background-page);font:inherit;', + onChange: (e) => { selected.value = e.target.value; }, + }, machines.value.map((m) => h('option', { value: m.id, selected: String(m.id) === String(selected.value) }, m.name))), + h('button', { + type: 'button', disabled: busy.value, onClick: drucken, + style: 'cursor:pointer;border:1px solid var(--primary);background:var(--primary);color:#fff;border-radius:6px;padding:6px 16px;font:inherit;', + }, busy.value ? 'Wird eingereiht …' : 'Drucken'), + ]), + msg.value ? h('div', { style: 'color:var(--success, #2e7d32);font-size:13px;' }, msg.value) : null, + err.value ? h('div', { style: 'color:var(--danger, #c62828);font-size:13px;' }, err.value) : null, + ]); + }; + }, +}); + +export default { + id: 'skrift-print-button', + name: 'Drucken', + icon: 'print', + description: 'Reiht den Auftrag als Druck-Job für eine Maschine ein.', + component: PrintButton, + types: ['alias'], + localTypes: ['presentation'], + group: 'presentation', + options: null, +}; diff --git a/Docker/directus/extensions/directus-extension-skrift-printbutton/package.json b/Docker/directus/extensions/directus-extension-skrift-printbutton/package.json new file mode 100644 index 0000000..e3a98d6 --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-printbutton/package.json @@ -0,0 +1,13 @@ +{ + "name": "directus-extension-skrift-printbutton", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Interface: reiht einen Auftrag als Druck-Job für eine Maschine ein (POST /skrift-orders/enqueue).", + "directus:extension": { + "type": "interface", + "path": "dist/index.js", + "source": "dist/index.js", + "host": "^11.0.0" + } +} diff --git a/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js new file mode 100644 index 0000000..51157fb --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js @@ -0,0 +1,701 @@ +/** + * Skrift – Modul „Produktion" + * =========================================================================== + * Die Produktions-Kommandozentrale: ein Board mit sechs Status-Umschaltern, + * zentraler Suche und pro Auftrag den drei Aktionen Drucken / Empfänger / + * Dateien – man muss die Ansicht für nichts verlassen. + * + * 1 Neu – neue Aufträge + Muster- und Angebotsanfragen + * 2 Warteschlange – eingereihte Druck-Jobs, nach Maschine unterteilt + * 3 Im Druck – laufende Aufträge (Brief/Kuvert getrennt gekennzeichnet) + * 4 Kuvertierung – fertig gedruckt, wird eingetütet + * 5 Versandfertig – bereit zum Versand + * 6 Versendet – abgeschlossen + * + * Druck-Popup wie im alten Job-Manager: Maschine + je Typ Template mit + * vorgeschlagenem Format, editierbaren Maßen und Nummern-Bereich, dazu + * Warteschlange vs. Direktdruck (Direkt gesperrt, wenn die Maschine druckt). + * Muster: manueller Druck mit Adresse aus der Anfrage + Du/Sie-Standardtext. + * + * Bewusst von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit. + */ +import { defineComponent, ref, reactive, computed, onMounted, h, Teleport, resolveComponent } from 'vue'; +import { useApi } from '@directus/extensions-sdk'; + +// Auftragsformat → Standard-Template-Schlüssel (wie im Backend /enqueue). +const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6_v2' }; +const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' }; +const INLAND = /^(de|deutschland|germany)$/i; + +const TABS = [ + { key: 'neu', label: '🆕 Neu' }, + { key: 'warteschlange', label: '⏳ Warteschlange' }, + { key: 'druck', label: '🖨 Im Druck' }, + { key: 'kuvertierung', label: '✉ Kuvertierung' }, + { key: 'versandfertig', label: '📦 Versandfertig' }, + { key: 'versendet', label: '🚚 Versendet' }, + { key: 'archiv', label: '🗄 Archiv' }, +]; +const STATUS_LABEL = { + eingegangen: 'Eingegangen', im_druck: 'Im Druck', kuvertieren: 'Kuvertierung', + versandfertig: 'Versandfertig', versendet: 'Versendet', storniert: 'Storniert', +}; +// Tage, nach denen ein versendeter Auftrag automatisch ins Archiv wandert. +const ARCHIV_TAGE = 14; +const PARAM_FELDER = [ + ['width', 'Breite'], ['height', 'Höhe'], ['xpos', 'X'], ['ypos', 'Y'], ['scale', 'Scale'], ['rotation', 'Rot°'], +]; + +// ── kleine Stil-/Render-Helfer ────────────────────────────────────────────── +// Weiße Karte, die sich klar von der (leicht getönten) Inhaltsfläche abhebt. +const CARD = 'border:1px solid var(--border-subdued);border-radius:8px;padding:16px 18px;background:var(--background-page,#fff);box-shadow:0 1px 3px rgba(0,0,0,.08);'; +// Getönte Sektion (Briefe/Kuverts) mit Rand + Radius; Abstand über margin-bottom. +const SECTION = 'background:var(--background-subdued,#f1f4f9);border:1px solid var(--border-normal);border-radius:8px;padding:16px 18px;margin-bottom:16px;'; +// Deutlich sichtbares Eingabefeld (weiß auf getönter Sektion). +const S_FELD = 'border:1px solid var(--border-normal);border-radius:7px;padding:6px 10px;background:var(--background-page,#fff);'; +const S_INPUT = 'padding:7px 10px;border:1px solid var(--border-normal);border-radius:7px;background:var(--background-page);color:var(--foreground-normal);font:inherit;font-size:13px;'; +const S_SELECT = S_INPUT + 'cursor:pointer;'; + +// Directus-Primärfarbe (Marke) + Kartenfarben je Vorgangstyp. +const ACCENT = '#1E50A5'; // Aufträge / Druck → blau +const FARBE_MUSTER = '#d13438'; // Musteranfrage → rot +const FARBE_ANGEBOT = '#e07b00'; // Angebotsanfrage → orange + +function btn(label, onClick, kind) { + const base = 'cursor:pointer;border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;border:1px solid;'; + const style = kind === 'primary' + ? base + `border-color:${ACCENT};background:${ACCENT};color:#fff;` + : kind === 'danger' + ? base + 'border-color:var(--danger,#c62828);background:transparent;color:var(--danger,#c62828);' + : 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:11px;padding:2px 8px;border-radius:20px;` + + `background:${farbe || 'var(--background-subdued)'};color:var(--foreground-subdued);white-space:nowrap;`, + }, text); +} +function subdued(text, extra) { + return h('div', { style: `color:var(--foreground-subdued);font-size:13px;${extra || ''}` }, text); +} +// Segmentierter Umschalter mit sichtbarem Rahmen: Optionen teilen sich die Breite. +function segToggle(value, opts, onSel) { + return h('div', { style: 'display:flex;width:100%;border:1px solid var(--border-normal);border-radius:8px;overflow:hidden;background:var(--background-subdued,#f1f4f9);' }, + opts.map((o, i) => h('button', { + type: 'button', disabled: o.disabled, onClick: () => { if (!o.disabled) onSel(o.key); }, + style: `flex:1;padding:12px 8px;font:inherit;font-size:13px;text-align:center;cursor:${o.disabled ? 'not-allowed' : 'pointer'};border:none;` + + (i > 0 ? 'border-left:1px solid var(--border-normal);' : '') + + (value === o.key ? `background:${ACCENT};color:#fff;font-weight:700;` : 'background:transparent;color:var(--foreground-normal);') + + (o.disabled ? 'opacity:.45;' : ''), + }, o.label))); +} + +// ── Datenhelfer ───────────────────────────────────────────────────────────── +function volleZeile(e) { + if (e.free_text && String(e.free_text).trim()) { + return String(e.free_text).trim().split(/\r?\n/).map((s) => s.trim()).filter(Boolean).join(' · '); + } + const name = [e.salutation, e.first_name, e.last_name].filter(Boolean).join(' '); + const strasse = [e.street, e.house_no].filter(Boolean).join(' '); + const ort = [e.zip, e.city].filter(Boolean).join(' '); + const land = e.country && !INLAND.test(String(e.country).trim()) ? e.country : null; + return [name, strasse, ort, land].filter(Boolean).join(' · '); +} +function extras(e) { + if (!e.placeholders || typeof e.placeholders !== 'object') return ''; + return Object.entries(e.placeholders) + .filter(([, v]) => v !== null && v !== undefined && String(v).trim() !== '') + .map(([k, v]) => `${k}: ${v}`).join(' · '); +} +function kundeName(o) { + const c = o.customer || {}; + return [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email || '—'; +} + +const Board = defineComponent({ + setup() { + const api = useApi(); + const tab = ref('neu'); + const suche = ref(''); + const loading = ref(false); + const fehler = ref(''); + const meldung = ref(''); + + const orders = ref([]); + const requests = ref([]); + const jobs = ref([]); + const machines = ref([]); + const templates = ref([]); + const muster = ref({ text_du: '', text_sie: '' }); + + // ── Modale ── + const druck = reactive({ open: false, order: null, machine: null, mode: 'queue', busy: false, brief: null, umschlag: null, msg: '', err: '' }); + const manuell = reactive({ open: false, req: null, form: 'sie', font: 'tilda', format: 'a4', template: null, machine: null, mode: 'queue', text: '', msg: '', err: '' }); + const empf = reactive({ open: false, mode: 'order', titel: '', order: null, suche: '', rows: [], files: [], + info: '', laden: false, selected: null, pl: '', pe: '', pLoad: false, perr: '', lb: '', lbTitel: '' }); + + // ── Laden ── + // 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,' + + 'date_updated,date_created,format.key,format.name,product.name,product.input_mode,' + + 'customer.first_name,customer.last_name,customer.email,customer.phone'; + async function ladeOrders() { + try { + return await api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER + ',versandt_am' } }); + } catch { + // Feld „versandt_am" fehlt noch (Bootstrap nicht gelaufen) → ohne laden, + // Archiv nutzt ersatzweise date_updated. + return api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER } }); + } + } + async function laden() { + loading.value = true; fehler.value = ''; + try { + const [o, r, j, m, t, ms] = await Promise.all([ + ladeOrders(), + api.get('/items/offer_requests', { params: { + limit: -1, sort: '-date_created', + filter: { status: { _in: ['neu', 'in_bearbeitung'] } }, + fields: 'id,type,name,email,phone,quantity,product,message,address,status,date_created', + } }), + api.get('/items/jobs', { params: { + limit: -1, sort: '-priority,date_created', + filter: { status: { _in: ['queued', 'printing', 'failed'] } }, + fields: 'id,type,status,priority,numbers,output_ref,error,date_created,machine.id,machine.name,order.id,order.order_number', + } }), + api.get('/items/machines', { params: { limit: -1, fields: 'id,name,active,status,last_seen' } }), + api.get('/items/format_templates', { params: { limit: -1, fields: 'id,key,name,type,width,height,xpos,ypos,scale,rotation' } }), + api.get('/items/muster_settings', { params: { fields: 'text_du,text_sie' } }).catch(() => ({ data: { data: { text_du: '', text_sie: '' } } })), + ]); + orders.value = o.data?.data || []; + requests.value = r.data?.data || []; + jobs.value = j.data?.data || []; + machines.value = (m.data?.data || []); + templates.value = t.data?.data || []; + muster.value = ms.data?.data || { text_du: '', text_sie: '' }; + } catch (e) { + fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.'; + } finally { loading.value = false; } + } + onMounted(laden); + + const aktiveMaschinen = computed(() => machines.value.filter((m) => m.active !== false)); + const maschineBusy = (id) => jobs.value.some((j) => j.status === 'printing' && String(j.machine?.id) === String(id)); + + // Aufträge mit offenen/laufenden Jobs → gehören in „Warteschlange"/„Im Druck", nicht mehr in „Neu". + const orderIdsInArbeit = computed(() => { + const s = new Set(); + for (const j of jobs.value) if (j.order?.id && (j.status === 'queued' || j.status === 'printing')) s.add(String(j.order.id)); + return s; + }); + + function trifftSuche(hay) { + const q = suche.value.trim().toLowerCase(); + if (!q) return true; + return hay.toLowerCase().includes(q); + } + const orderHay = (o) => [o.order_number, kundeName(o), o.customer?.email, o.customer?.phone, + o.product?.name, o.format?.name, o.shipping_type, o.production_status, o.payment_status].filter(Boolean).join(' '); + + // Tab-Listen + const neueOrders = computed(() => orders.value.filter((o) => + (o.production_status || 'eingegangen') === 'eingegangen' && !orderIdsInArbeit.value.has(String(o.id)) && trifftSuche(orderHay(o)))); + const neueRequests = computed(() => requests.value.filter((r) => + trifftSuche([r.name, r.email, r.phone, r.product, r.message, r.type].filter(Boolean).join(' ')))); + const ordersByStatus = (st) => orders.value.filter((o) => (o.production_status || 'eingegangen') === st && trifftSuche(orderHay(o))); + // Archiv: storniert ODER seit mehr als 14 Tagen versendet. + const alterTage = (ts) => (ts ? (Date.now() - new Date(ts).getTime()) / 86400000 : Infinity); + const istArchiviert = (o) => o.production_status === 'storniert' + || (o.production_status === 'versendet' && alterTage(o.versandt_am || o.date_updated) > ARCHIV_TAGE); + const versendetAktiv = () => orders.value.filter((o) => o.production_status === 'versendet' && !istArchiviert(o) && trifftSuche(orderHay(o))); + const archivListe = () => orders.value.filter((o) => istArchiviert(o) && trifftSuche(orderHay(o))); + const queuedByMachine = computed(() => { + const groups = {}; + for (const m of aktiveMaschinen.value) groups[m.id] = { machine: m, jobs: [] }; + groups.__ohne = { machine: { name: 'Ohne Maschine' }, jobs: [] }; + for (const j of jobs.value) { + if (j.status !== 'queued') continue; + if (!trifftSuche([j.order?.order_number, j.output_ref, j.type].filter(Boolean).join(' '))) continue; + (groups[j.machine?.id] || groups.__ohne).jobs.push(j); + } + return Object.values(groups).filter((g) => g.jobs.length); + }); + const tabCount = (key) => { + if (key === 'neu') return neueOrders.value.length + neueRequests.value.length; + if (key === 'warteschlange') return jobs.value.filter((j) => j.status === 'queued').length; + if (key === 'druck') return ordersByStatus('im_druck').length; + if (key === 'kuvertierung') return ordersByStatus('kuvertieren').length; + if (key === 'versandfertig') return ordersByStatus('versandfertig').length; + if (key === 'versendet') return versendetAktiv().length; + if (key === 'archiv') return archivListe().length; + return 0; + }; + + function flash(msg) { meldung.value = msg; setTimeout(() => { if (meldung.value === msg) meldung.value = ''; }, 4000); } + + // ── Statuswechsel eines Auftrags ── + async function setStatus(order, neu) { + const patch = { production_status: neu }; + // 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; + 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.'; } + } + + // ── Druck-Popup ── + const tplByKey = (key, typ) => templates.value.find((t) => t.key === key && t.type === typ) || templates.value.find((t) => t.type === typ) || null; + const tplToFelder = (t) => ({ tplId: t?.id ?? null, width: t?.width ?? 0, height: t?.height ?? 0, xpos: t?.xpos ?? 0, ypos: t?.ypos ?? 0, scale: t?.scale ?? 1, rotation: t?.rotation ?? 0 }); + + function openDruck(order) { + const bt = tplByKey(LETTER_TPL[order.format?.key] || 'a4_single', 'brief'); + const wantsEnv = order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine'; + const et = wantsEnv ? tplByKey(ENVELOPE_TPL[order.envelope_format] || 'kuvert_a_v1', 'umschlag') : null; + const n = Math.max(1, order.entries_count || 1); + druck.order = order; + druck.machine = aktiveMaschinen.value[0]?.id ?? null; + druck.mode = 'queue'; druck.msg = ''; druck.err = ''; + druck.brief = { enabled: true, numbers: `1-${n}`, done: !!order.brief_gedruckt, ...tplToFelder(bt) }; + druck.umschlag = wantsEnv ? { enabled: true, numbers: `1-${n}`, done: !!order.kuvert_gedruckt, ...tplToFelder(et) } : null; + druck.open = true; + } + function druckTplWechsel(sec, tplId) { + const t = templates.value.find((x) => String(x.id) === String(tplId)); + if (!t) return; + Object.assign(druck[sec], tplToFelder(t)); + } + async function submitDruck() { + if (!druck.machine) { druck.err = 'Bitte Maschine wählen.'; return; } + const sections = {}; + const bau = (sec) => ({ enabled: true, template: sec.tplId, numbers: sec.numbers || '', + params: { width: sec.width, height: sec.height, xpos: sec.xpos, ypos: sec.ypos, scale: sec.scale, rotation: sec.rotation } }); + if (druck.brief?.enabled) sections.brief = bau(druck.brief); + if (druck.umschlag?.enabled) sections.umschlag = bau(druck.umschlag); + if (!Object.keys(sections).length) { druck.err = 'Kein Abschnitt gewählt.'; return; } + druck.err = ''; druck.msg = 'Wird eingereiht …'; + try { + const res = await api.post('/skrift-orders/enqueue', { order: druck.order.id, machine: druck.machine, mode: druck.mode, sections }); + const n = res.data?.jobs?.length || 0; + const modus = res.data?.mode === 'direct' ? 'Direktdruck' : 'Warteschlange'; + flash(`${n} Job(s) eingereiht (${modus}).`); + druck.open = false; + await laden(); + } catch (e) { druck.err = e?.response?.data?.error || e?.message || 'Einreihen fehlgeschlagen.'; druck.msg = ''; } + } + + // ── Muster: manueller Druck ── + function musterAufloesen() { + const roh = manuell.form === 'du' ? muster.value.text_du : muster.value.text_sie; + const a = manuell.req?.address && typeof manuell.req.address === 'object' ? manuell.req.address : {}; + const name = manuell.req?.name || ''; + const teile = name.split(' '); + const vor = teile[0] || ''; const nach = teile.slice(1).join(' '); + manuell.text = String(roh || '') + .replace(/\[\[Vorname\]\]/gi, a.first_name || a.vorname || vor) + .replace(/\[\[Nachname\]\]/gi, a.last_name || a.nachname || nach) + .replace(/\[\[Anrede\]\]/gi, a.salutation || a.anrede || '') + .replace(/\[\[Name\]\]/gi, name); + } + function openMuster(req) { + manuell.req = req; manuell.form = 'sie'; manuell.font = 'tilda'; manuell.format = 'a4'; + manuell.template = tplByKey('a4_single', 'brief')?.id ?? templates.value.find((t) => t.type === 'brief')?.id ?? null; + manuell.machine = aktiveMaschinen.value[0]?.id ?? null; manuell.mode = 'queue'; + manuell.msg = ''; manuell.err = ''; + musterAufloesen(); + manuell.open = true; + } + async function submitMuster() { + if (!manuell.text.trim()) { manuell.err = 'Text fehlt.'; return; } + if (!manuell.template || !manuell.machine) { manuell.err = 'Template und Maschine wählen.'; return; } + manuell.err = ''; manuell.msg = 'Wird erzeugt …'; + try { + const res = await api.post('/skrift-orders/manual-print', { + text: manuell.text, font: manuell.font, format: manuell.format, + template: manuell.template, machine: manuell.machine, mode: manuell.mode, + offer_request: manuell.req?.id || null, + }); + const modus = res.data?.mode === 'direct' ? 'Direktdruck' : 'Warteschlange'; + flash(`Muster erzeugt & eingereiht (${modus}).`); + manuell.open = false; + await laden(); + } catch (e) { manuell.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Fehlgeschlagen.'; manuell.msg = ''; } + } + + // ── Empfänger-Drawer (mit Dokument-Vorschau je Zeile) ── + const dateiNummer = (name) => { + const z = String(name || '').replace(/\.[^.]*$/, '').replace(/\D/g, ''); + return z ? parseInt(z, 10) : null; + }; + async function openEmpf(order) { + empf.open = true; empf.mode = 'order'; empf.titel = `Empfänger · ${order.order_number}`; + empf.order = order.order_number; empf.suche = ''; empf.rows = []; empf.files = []; empf.info = ''; + empf.selected = null; empf.pl = ''; empf.pe = ''; empf.perr = ''; empf.lb = ''; empf.laden = true; + try { + const [ent, fl] = await Promise.all([ + api.get('/items/order_entries', { params: { 'filter[order][_eq]': order.id, sort: 'letter_number', limit: -1, fields: '*' } }), + api.get('/skrift-orders/files/' + encodeURIComponent(order.order_number)).catch(() => ({ data: { files: [] } })), + ]); + empf.rows = (ent.data?.data || []).map((e) => ({ nr: e.letter_number, zeile: volleZeile(e), extra: extras(e) })); + empf.files = fl.data?.files || []; + } catch (e) { empf.info = e?.message || 'Empfänger konnten nicht geladen werden.'; } finally { empf.laden = false; } + } + function openReqInfo(req) { + empf.open = true; empf.mode = 'request'; empf.titel = `Anfrage · ${req.name}`; empf.suche = ''; empf.info = ''; + empf.files = []; empf.selected = null; empf.pl = ''; empf.pe = ''; empf.lb = ''; + const a = req.address && typeof req.address === 'object' ? Object.entries(req.address).map(([k, v]) => `${k}: ${v}`).join(' · ') : (req.address || ''); + empf.rows = [ + { nr: '✉', zeile: `${req.name} · ${req.email}${req.phone ? ' · ' + req.phone : ''}`, extra: '' }, + { nr: '📦', zeile: a || 'Keine Adresse angegeben.', extra: '' }, + { nr: '💬', zeile: req.message || '—', extra: `Produkt: ${req.product || '—'} · Menge: ${req.quantity ?? '—'}` }, + ]; + empf.laden = false; + } + async function empfBlob(f) { + const res = await api.get('/skrift-orders/files/' + encodeURIComponent(empf.order) + '/download', { params: { rel: f.rel }, responseType: 'blob' }); + return URL.createObjectURL(res.data); + } + // Positionsbasierter Abgleich: der i-te Empfänger → die i-te Datei. Robust + // gegen alte (0-basierte) und neue (1-basierte) Dateinamen. + const nachNummer = (kind) => empf.files + .filter((f) => f.kind === kind) + .sort((a, b) => (dateiNummer(a.name) ?? 0) - (dateiNummer(b.name) ?? 0)); + async function previewEntry(nr, rang) { + empf.selected = nr; empf.perr = ''; empf.pLoad = true; + if (empf.pl) URL.revokeObjectURL(empf.pl); if (empf.pe) URL.revokeObjectURL(empf.pe); + empf.pl = ''; empf.pe = ''; + try { + const brief = nachNummer('schriftstueck')[rang] || empf.files.find((f) => f.kind === 'schriftstueck' && dateiNummer(f.name) === nr); + const kuvert = nachNummer('umschlag')[rang] || empf.files.find((f) => f.kind === 'umschlag' && dateiNummer(f.name) === nr); + if (brief) empf.pl = await empfBlob(brief); + if (kuvert) empf.pe = await empfBlob(kuvert); + if (!brief && !kuvert) { + empf.perr = empf.files.length + ? 'Für diese Nummer sind noch keine Dateien generiert.' + : 'Für diesen Auftrag wurden noch keine Dateien generiert (erst nach Abschluss/Generierung).'; + } + } catch (e) { empf.perr = 'Vorschau fehlgeschlagen.'; } finally { empf.pLoad = false; } + } + const empfTreffer = computed(() => { + const q = empf.suche.trim().toLowerCase(); + if (!q) return empf.rows; + return empf.rows.filter((r) => `#${r.nr} ${r.zeile} ${r.extra}`.toLowerCase().includes(q)); + }); + + // ── Job abbrechen ── + async function cancelJob(job) { + try { await api.delete(`/items/jobs/${job.id}`); flash('Job entfernt.'); await laden(); } + catch (e) { fehler.value = e?.message || 'Job konnte nicht entfernt werden.'; } + } + async function setRequestStatus(req, st) { + try { await api.patch(`/items/offer_requests/${req.id}`, { status: st }); flash('Anfrage aktualisiert.'); await laden(); } + catch (e) { fehler.value = e?.message || 'Fehlgeschlagen.'; } + } + + // ── Render-Bausteine ────────────────────────────────────────────────── + function actionButtons(order) { + // Drucken + Empfänger (Empfänger enthält die Vorschau je Dokument). + return h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [ + btn('🖨 Drucken', () => openDruck(order), 'primary'), + btn('👥 Empfänger', () => openEmpf(order)), + ]); + } + function statusSelect(order) { + return h('select', { + style: S_SELECT + 'font-size:12px;padding:4px 8px;', + value: order.production_status || 'eingegangen', + onChange: (e) => setStatus(order, e.target.value), + }, Object.entries(STATUS_LABEL).map(([k, v]) => h('option', { value: k, selected: (order.production_status || 'eingegangen') === k }, v))); + } + function orderCard(order, opts) { + const o = order; const zeigeBadges = opts?.badges; + const kopf = h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;' }, [ + h('div', {}, [ + h('div', { style: 'font-weight:700;font-size:15px;' }, `#${o.order_number}`), + subdued(`${kundeName(o)}${o.customer?.email ? ' · ' + o.customer.email : ''}${o.customer?.phone ? ' · ' + o.customer.phone : ''}`, 'margin-top:2px;'), + ]), + statusSelect(o), + ]); + const meta = h('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;margin:10px 0;' }, [ + chip(`${o.entries_count || 0} Briefe`), + chip(o.needs_envelope ? `${o.entries_count || 0} Kuverts` : 'ohne Kuvert'), + chip(o.shipping_type === 'einzeln' ? 'Einzelversand' : 'Sammelversand'), + 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'), + o.payment_status === 'bezahlt' ? 'var(--success-25,rgba(46,125,50,.15))' : 'var(--warning-25,rgba(230,160,0,.15))'), + ]); + const badges = zeigeBadges ? h('div', { style: 'display:flex;gap:8px;margin-bottom:10px;' }, [ + chip(o.brief_gedruckt ? '✓ Brief gedruckt' : '… Brief offen', o.brief_gedruckt ? 'var(--success-25,rgba(46,125,50,.15))' : 'var(--background-subdued)'), + o.needs_envelope ? chip(o.kuvert_gedruckt ? '✓ Kuvert gedruckt' : '… Kuvert offen', o.kuvert_gedruckt ? 'var(--success-25,rgba(46,125,50,.15))' : 'var(--background-subdued)') : null, + ]) : null; + return h('div', { key: 'o' + o.id, style: CARD + `margin-bottom:14px;border-left:4px solid ${ACCENT};` }, [kopf, meta, badges, actionButtons(o)]); + } + function requestCard(req) { + const istMuster = req.type === 'muster'; + const kopf = h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;' }, [ + h('div', {}, [ + h('div', { style: 'font-weight:700;font-size:15px;' }, `${istMuster ? '🧪 Muster' : '📝 Angebot'} · ${req.name}`), + subdued(`${req.email}${req.phone ? ' · ' + req.phone : ''}`, 'margin-top:2px;'), + ]), + h('select', { style: S_SELECT + 'font-size:12px;padding:4px 8px;', value: req.status, onChange: (e) => setRequestStatus(req, e.target.value) }, + ['neu', 'in_bearbeitung', 'beantwortet', 'abgelehnt'].map((s) => h('option', { value: s, selected: req.status === s }, s))), + ]); + const meta = h('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;margin:10px 0;' }, [ + chip(req.product || 'Produkt?'), req.quantity ? chip(`Menge ${req.quantity}`) : null, + ]); + const nachricht = req.message ? subdued(req.message, 'margin:0 0 10px;white-space:pre-wrap;') : null; + const aktionen = h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [ + // Muster (Druckprodukt) → manueller Druck; individuelles Angebot → kein Druck. + istMuster ? btn('🖨 Muster drucken', () => openMuster(req), 'primary') : null, + btn('👤 Details', () => openReqInfo(req)), + ]); + 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]); + } + + function tabInhalt() { + if (tab.value === 'neu') { + const items = [...neueOrders.value.map((o) => orderCard(o)), ...neueRequests.value.map(requestCard)]; + return items.length ? items : subdued('Nichts Neues.'); + } + if (tab.value === 'warteschlange') { + const gruppen = queuedByMachine.value; + if (!gruppen.length) return subdued('Warteschlange leer.'); + return gruppen.map((g) => h('div', { style: 'margin-bottom:18px;' }, [ + h('div', { style: 'font-weight:700;margin-bottom:8px;' }, `🖨 ${g.machine.name}${g.machine.status ? ' · ' + g.machine.status : ''}`), + ...g.jobs.map((j) => h('div', { key: 'j' + j.id, style: CARD + 'margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;' }, [ + h('div', {}, [ + h('div', { style: 'font-weight:600;' }, `#${j.order?.order_number || j.output_ref || j.id} · ${j.type}`), + subdued(`${j.numbers ? 'Nr. ' + j.numbers : 'alle'}${j.priority ? ' · Direkt' : ''}`), + ]), + btn('Entfernen', () => cancelJob(j), 'danger'), + ])), + ])); + } + if (tab.value === 'druck') { + const list = ordersByStatus('im_druck'); + return list.length ? list.map((o) => orderCard(o, { badges: true })) : subdued('Nichts im Druck.'); + } + if (tab.value === 'versendet') { + const list = versendetAktiv(); + return list.length ? list.map((o) => orderCard(o)) : subdued('Nichts (kürzlich) versendet.'); + } + if (tab.value === 'archiv') { + const list = archivListe(); + return list.length ? list.map((o) => orderCard(o)) : subdued('Archiv leer.'); + } + const map = { kuvertierung: 'kuvertieren', versandfertig: 'versandfertig' }; + const st = map[tab.value]; + const list = ordersByStatus(st); + return list.length ? list.map((o) => orderCard(o, { badges: st === 'kuvertieren' })) : subdued('Leer.'); + } + + // ── Modale rendern ────────────────────────────────────────────────────── + function overlay(inhalt, close, breite) { + return h('div', { + onClick: (e) => { if (e.target === e.currentTarget) close(); }, + style: 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:flex-start;justify-content:center;padding:5vh 16px;overflow:auto;', + }, [h('div', { style: `background:var(--background-page,#fff);border:1px solid var(--border-subdued);border-radius:12px;width:${breite || '620px'};max-width:100%;box-shadow:0 20px 60px rgba(0,0,0,.35);` }, inhalt)]); + } + function modalKopf(titel, close) { + return h('div', { style: 'display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border-subdued);' }, [ + h('div', { style: 'font-weight:700;font-size:16px;' }, titel), + btn('✕', close), + ]); + } + function feldGrid(sec, secKey) { + return h('div', { style: 'display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:12px 0;' }, PARAM_FELDER.map(([k, lbl]) => + h('div', { style: S_FELD + 'display:flex;flex-direction:column;' }, [ + h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--foreground-subdued);margin-bottom:2px;' }, lbl), + h('input', { type: 'number', step: '0.01', value: sec[k], + style: 'border:none;background:transparent;color:var(--foreground-normal);font:inherit;font-size:15px;font-weight:600;width:100%;padding:0;outline:none;', + onInput: (e) => { druck[secKey][k] = parseFloat(e.target.value) || 0; } }), + ]))); + } + function druckSektion(secKey, titel, ikon, typ) { + const sec = druck[secKey]; if (!sec) return null; + const opts = templates.value.filter((t) => t.type === typ); + return h('div', { style: SECTION }, [ + h('div', { style: 'display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;' }, [ + h('label', { style: 'display:flex;align-items:center;gap:8px;font-weight:700;font-size:15px;' }, [ + h('input', { type: 'checkbox', checked: sec.enabled, onChange: (e) => { druck[secKey].enabled = e.target.checked; }, style: 'width:16px;height:16px;' }), + `${ikon} ${titel}`, + ]), + sec.done ? chip('✓ bereits gedruckt', 'var(--success-25,rgba(46,125,50,.15))') : null, + ]), + h('select', { style: S_SELECT + 'width:100%;margin-bottom:6px;', value: sec.tplId, onChange: (e) => druckTplWechsel(secKey, e.target.value) }, + opts.map((t) => h('option', { value: t.id, selected: String(sec.tplId) === String(t.id) }, t.name))), + feldGrid(sec, secKey), + h('div', { style: S_FELD + 'display:flex;flex-direction:column;gap:4px;' }, [ + h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--foreground-subdued);' }, 'Zu druckende Nummern'), + h('input', { value: sec.numbers, placeholder: 'z. B. 1,4,8 oder 1-9 oder 1-3,5,8', + style: 'border:none;background:transparent;color:var(--foreground-normal);font:inherit;font-size:14px;width:100%;padding:0;outline:none;', + onInput: (e) => { druck[secKey].numbers = e.target.value; } }), + ]), + ]); + } + function druckModal() { + if (!druck.open) return null; + const busy = druck.machine != null && maschineBusy(druck.machine); + const body = h('div', { style: 'padding:18px 20px;' }, [ + h('div', { style: 'display:flex;flex-direction:column;gap:4px;margin-bottom:14px;' }, [ + h('label', { style: 'font-size:12px;color:var(--foreground-subdued);' }, 'Maschine'), + aktiveMaschinen.value.length + ? h('select', { style: S_SELECT + 'width:100%;', value: druck.machine, onChange: (e) => { druck.machine = e.target.value; if (maschineBusy(e.target.value)) druck.mode = 'queue'; } }, + aktiveMaschinen.value.map((m) => h('option', { value: m.id, selected: String(druck.machine) === String(m.id) }, `${m.name}${maschineBusy(m.id) ? ' (druckt)' : ''}`))) + : subdued('Keine aktive Maschine – bitte unter Einstellungen → Maschinen anlegen.'), + ]), + druckSektion('brief', 'Briefe', '📄', 'brief'), + druckSektion('umschlag', 'Kuverts', '✉', 'umschlag'), + h('div', { style: 'margin:12px 0 4px;' }, [ + segToggle(busy ? 'queue' : druck.mode, [ + { key: 'direct', label: '⚡ Direktdruck', disabled: busy }, + { key: 'queue', label: '⏳ Warteschlange' }, + ], (k) => { druck.mode = k; }), + busy ? subdued('Maschine druckt gerade → nur Warteschlange.', 'margin-top:6px;') : null, + ]), + druck.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;margin-top:8px;' }, druck.err) : null, + druck.msg ? subdued(druck.msg, 'margin-top:8px;') : null, + ]); + const foot = h('div', { style: 'display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border-subdued);' }, [ + btn('Abbrechen', () => { druck.open = false; }), + btn('In Produktion geben', submitDruck, 'primary'), + ]); + return overlay([modalKopf(`Drucken · #${druck.order.order_number}`, () => { druck.open = false; }), body, foot], () => { druck.open = false; }, '640px'); + } + function musterModal() { + if (!manuell.open) return null; + const briefTpls = templates.value.filter((t) => t.type === 'brief'); + const body = h('div', { style: 'padding:18px 20px;display:flex;flex-direction:column;gap:12px;' }, [ + h('div', { style: 'display:flex;gap:16px;align-items:center;' }, [ + h('label', { style: 'display:flex;align-items:center;gap:6px;' }, [h('input', { type: 'radio', name: 'anrede', checked: manuell.form === 'sie', onChange: () => { manuell.form = 'sie'; musterAufloesen(); } }), 'Sie-Form']), + h('label', { style: 'display:flex;align-items:center;gap:6px;' }, [h('input', { type: 'radio', name: 'anrede', checked: manuell.form === 'du', onChange: () => { manuell.form = 'du'; musterAufloesen(); } }), 'Du-Form']), + subdued('Adresse aus der Anfrage übernommen · [[abschnitt]] bitte füllen'), + ]), + h('textarea', { style: S_INPUT + 'width:100%;min-height:200px;font-family:var(--family-monospace,monospace);', value: manuell.text, onInput: (e) => { manuell.text = e.target.value; } }), + h('div', { style: 'display:grid;grid-template-columns:repeat(4,1fr);gap:8px;' }, [ + h('div', {}, [h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Schrift'), + h('select', { style: S_SELECT + 'width:100%;', value: manuell.font, onChange: (e) => { manuell.font = e.target.value; } }, ['tilda', 'alva', 'ellie'].map((f) => h('option', { value: f, selected: manuell.font === f }, f)))]), + h('div', {}, [h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Format'), + h('select', { style: S_SELECT + 'width:100%;', value: manuell.format, onChange: (e) => { manuell.format = e.target.value; } }, + [['a4', 'A4'], ['a6_hoch', 'A6 hoch'], ['a6_quer', 'A6 quer']].map(([v, l]) => h('option', { value: v, selected: manuell.format === v }, l)))]), + h('div', {}, [h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Template'), + h('select', { style: S_SELECT + 'width:100%;', value: manuell.template, onChange: (e) => { manuell.template = e.target.value; } }, briefTpls.map((t) => h('option', { value: t.id, selected: String(manuell.template) === String(t.id) }, t.name)))]), + h('div', {}, [h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Maschine'), + h('select', { style: S_SELECT + 'width:100%;', value: manuell.machine, onChange: (e) => { manuell.machine = e.target.value; } }, aktiveMaschinen.value.map((m) => h('option', { value: m.id, selected: String(manuell.machine) === String(m.id) }, m.name)))]), + ]), + segToggle(manuell.mode, [ + { key: 'direct', label: '⚡ Direktdruck' }, + { key: 'queue', label: '⏳ Warteschlange' }, + ], (k) => { manuell.mode = k; }), + manuell.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;' }, manuell.err) : null, + manuell.msg ? subdued(manuell.msg) : null, + ]); + const foot = h('div', { style: 'display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border-subdued);' }, [ + btn('Abbrechen', () => { manuell.open = false; }), + btn('Erzeugen & drucken', submitMuster, 'primary'), + ]); + return overlay([modalKopf(`Muster drucken · ${manuell.req?.name || ''}`, () => { manuell.open = false; }), body, foot], () => { manuell.open = false; }, '680px'); + } + function empfModal() { + if (!empf.open) return null; + const istOrder = empf.mode === 'order'; + // Linke Spalte (~70 %): Suche + klickbare Empfängerliste. + const liste = h('div', { style: `flex:1 1 66%;min-width:260px;` }, [ + h('input', { type: 'search', value: empf.suche, placeholder: 'Durchsuchen (Name, Straße, Ort, Platzhalter …)', style: S_INPUT + 'width:100%;margin-bottom:12px;', onInput: (e) => { empf.suche = e.target.value; } }), + empf.laden ? subdued('Wird geladen …') : empf.info ? subdued(empf.info) + : h('div', { style: 'display:flex;flex-direction:column;gap:6px;max-height:62vh;overflow:auto;' }, empfTreffer.value.length + ? empfTreffer.value.map((r) => h('div', { + onClick: istOrder ? () => previewEntry(r.nr, empf.rows.findIndex((x) => x.nr === r.nr)) : undefined, + style: 'display:flex;gap:12px;align-items:baseline;border:1px solid var(--border-subdued);border-radius:8px;padding:12px 14px;' + + `${istOrder ? 'cursor:pointer;' : ''}` + + (istOrder && empf.selected === r.nr ? `border-color:${ACCENT};background:rgba(30,80,165,.07);` : 'background:var(--background-page);'), + }, [ + h('span', { style: `min-width:40px;font-weight:700;color:${ACCENT};font-family:var(--family-monospace,monospace);` }, `#${r.nr}`), + h('div', { style: 'flex:1;' }, [h('div', {}, r.zeile || '—'), r.extra ? subdued(r.extra, 'margin-top:2px;') : null]), + ])) + : subdued('Keine Treffer.')), + ]); + // Rechte Spalte (~30 %): Vorschau; Klick aufs Bild → große Lightbox. + const vorschauBox = (titel, url) => h('div', { style: 'display:flex;flex-direction:column;gap:6px;' }, [ + h('div', { style: 'font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--foreground-subdued);' }, titel), + h('div', { + onClick: url ? () => { empf.lb = url; empf.lbTitel = titel; } : undefined, + style: 'border:1px solid var(--border-subdued);border-radius:8px;background:#fff;display:flex;align-items:center;justify-content:center;min-height:150px;padding:8px;' + + (url ? 'cursor:zoom-in;' : ''), + }, url ? h('img', { src: url, style: 'max-width:100%;max-height:30vh;object-fit:contain;' }) : subdued('—')), + ]); + const vorschau = istOrder ? h('div', { style: 'flex:0 0 30%;min-width:220px;display:flex;flex-direction:column;gap:14px;' }, + empf.selected == null ? [subdued('Empfänger anklicken → Vorschau (Klick aufs Bild = groß).')] + : empf.pLoad ? [subdued('Vorschau wird geladen …')] + : [ + empf.perr ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;' }, empf.perr) : null, + vorschauBox('📄 Schriftstück', empf.pl), + vorschauBox('✉ Kuvert', empf.pe), + ]) : null; + const body = h('div', { style: 'padding:16px 20px;display:flex;gap:20px;flex-wrap:wrap;' }, [liste, vorschau]); + return overlay([modalKopf(empf.titel, () => { empf.open = false; }), body], () => { empf.open = false; }, istOrder ? '980px' : '620px'); + } + // Große Einzelvorschau (Lightbox) über dem Empfänger-Dialog. + function lightboxModal() { + if (!empf.lb) return null; + return h('div', { + onClick: () => { empf.lb = ''; }, + style: 'position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:1100;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:4vh 4vw;gap:14px;cursor:zoom-out;', + }, [ + h('div', { style: 'color:#fff;font-size:13px;text-transform:uppercase;letter-spacing:.05em;' }, empf.lbTitel), + h('img', { src: empf.lb, style: 'max-width:100%;max-height:82vh;object-fit:contain;background:#fff;border-radius:8px;' }), + h('div', { style: 'color:rgba(255,255,255,.7);font-size:12px;' }, 'Klicken zum Schließen'), + ]); + } + + // ── Gesamt-Render ───────────────────────────────────────────────────── + // In gehängt → normaler Directus-Content-Bereich + Kopf. + // Die sechs Umschalter liegen in der nativen Navigationsspalte links. + return () => { + const navi = h('div', { style: 'padding:12px 10px;' }, TABS.map((t) => + h('button', { + type: 'button', onClick: () => { tab.value = t.key; }, + style: 'display:flex;justify-content:space-between;align-items:center;gap:8px;width:100%;text-align:left;' + + 'cursor:pointer;border:none;border-radius:6px;padding:10px 12px;margin-bottom:3px;font:inherit;font-size:14px;' + + (tab.value === t.key ? `background:${ACCENT};color:#fff;font-weight:600;` : 'background:transparent;color:var(--foreground-normal);'), + }, [ + h('span', {}, t.label), + h('span', { style: 'font-size:12px;opacity:.75;' }, String(tabCount(t.key))), + ]))); + + const kopfzeile = h('div', { style: 'display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:18px;' }, [ + h('input', { type: 'search', value: suche.value, placeholder: '🔎 Auftrag, Kunde, E-Mail, Produkt …', + style: S_INPUT + 'flex:1;min-width:240px;', onInput: (e) => { suche.value = e.target.value; } }), + btn('↻ Aktualisieren', laden), + ]); + + // Leicht getönte Inhaltsfläche → weiße Karten heben sich klar ab (wie im Referenz-Dashboard). + const inhalt = h('div', { style: 'padding:24px 28px;min-height:100%;background:var(--background-subdued,#f1f4f9);' }, [ + kopfzeile, + meldung.value ? h('div', { style: 'background:var(--success-25,rgba(46,125,50,.15));color:var(--success,#2e7d32);padding:9px 14px;border-radius:8px;margin-bottom:12px;font-size:13px;' }, meldung.value) : null, + fehler.value ? h('div', { style: 'background:var(--danger-25,rgba(198,40,40,.12));color:var(--danger,#c62828);padding:9px 14px;border-radius:8px;margin-bottom:12px;font-size:13px;' }, fehler.value) : null, + loading.value ? subdued('Lädt …') : h('div', { style: 'display:flex;flex-direction:column;gap:2px;' }, tabInhalt()), + ]); + + const modale = [druckModal(), musterModal(), empfModal(), lightboxModal()].filter(Boolean); + + return h(resolveComponent('private-view'), { title: 'Produktion' }, { + navigation: () => navi, + default: () => [inhalt, modale.length ? h(Teleport, { to: 'body' }, modale) : null], + }); + }; + }, +}); + +export default { + id: 'skrift-produktion', + name: 'Produktion', + icon: 'precision_manufacturing', + routes: [{ path: '', component: Board }], +}; diff --git a/Docker/directus/extensions/directus-extension-skrift-produktion/package.json b/Docker/directus/extensions/directus-extension-skrift-produktion/package.json new file mode 100644 index 0000000..47440f9 --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-produktion/package.json @@ -0,0 +1,13 @@ +{ + "name": "directus-extension-skrift-produktion", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Modul: Produktions-Kommandozentrale (Status-Board, Druck-Popup, Empfänger/Dateien, Muster-Druck).", + "directus:extension": { + "type": "module", + "path": "dist/index.js", + "source": "dist/index.js", + "host": "^11.0.0" + } +} diff --git a/Docker/directus/extensions/directus-extension-skrift-testprint/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-testprint/dist/index.js new file mode 100644 index 0000000..4f3d6fe --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-testprint/dist/index.js @@ -0,0 +1,59 @@ +/** + * Skrift – Interface „Testdruck generieren & drucken" + * --------------------------------------------------------------------------- + * Button im test_prints-Datensatz: erzeugt aus Template + Text ein SVG und + * reiht es als Druck-Job für die gewählte Maschine ein + * (POST /skrift-orders/test-print). Datensatz vorher speichern. + * + * Von Hand als ESM (kein Build) – Vue + SDK stellt Directus zur Laufzeit bereit. + */ +import { defineComponent, ref, inject, h } from 'vue'; +import { useApi } from '@directus/extensions-sdk'; + +const TestPrintButton = defineComponent({ + setup() { + const api = useApi(); + const values = inject('values', ref({})); + const busy = ref(false); + const msg = ref(''); + const err = ref(''); + + async function ausloesen() { + const id = values && values.value ? values.value.id : null; + if (!id) { err.value = 'Bitte den Datensatz zuerst speichern.'; return; } + busy.value = true; err.value = ''; msg.value = ''; + try { + const res = await api.post('/skrift-orders/test-print', { id }); + msg.value = `Generiert & eingereiht (Job ${res.data?.job}, Ordner ${res.data?.ref}).`; + } catch (e) { + const d = e && e.response && e.response.data; + err.value = (d && (d.error + (d.detail ? ' – ' + d.detail : ''))) || (e && e.message) || 'Fehler.'; + } finally { + busy.value = false; + } + } + + return () => h('div', { style: 'display:flex;flex-direction:column;gap:8px;' }, [ + h('button', { + type: 'button', disabled: busy.value, onClick: ausloesen, + style: 'align-self:flex-start;cursor:pointer;border:1px solid var(--primary);background:var(--primary);color:#fff;border-radius:6px;padding:8px 18px;font:inherit;', + }, busy.value ? 'Wird generiert …' : 'Generieren & drucken'), + msg.value ? h('div', { style: 'color:var(--success, #2e7d32);font-size:13px;' }, msg.value) : null, + err.value ? h('div', { style: 'color:var(--danger, #c62828);font-size:13px;' }, err.value) : null, + h('div', { style: 'color:var(--foreground-subdued);font-size:12px;' }, + 'Hinweis: Datensatz mit Template, Format, Maschine und Text speichern, dann klicken.'), + ]); + }, +}); + +export default { + id: 'skrift-testprint-button', + name: 'Testdruck', + icon: 'science', + description: 'Generiert einen Testdruck und reiht ihn zum Drucken ein.', + component: TestPrintButton, + types: ['alias'], + localTypes: ['presentation'], + group: 'presentation', + options: null, +}; diff --git a/Docker/directus/extensions/directus-extension-skrift-testprint/package.json b/Docker/directus/extensions/directus-extension-skrift-testprint/package.json new file mode 100644 index 0000000..a817d9b --- /dev/null +++ b/Docker/directus/extensions/directus-extension-skrift-testprint/package.json @@ -0,0 +1,13 @@ +{ + "name": "directus-extension-skrift-testprint", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Interface: generiert einen Testdruck aus einem Template + Text und reiht ihn zum Drucken ein (POST /skrift-orders/test-print).", + "directus:extension": { + "type": "interface", + "path": "dist/index.js", + "source": "dist/index.js", + "host": "^11.0.0" + } +} diff --git a/Docker/skrift-backend/src/api/controllers/directus-controller.js b/Docker/skrift-backend/src/api/controllers/directus-controller.js index f61ac94..ff9c26c 100644 --- a/Docker/skrift-backend/src/api/controllers/directus-controller.js +++ b/Docker/skrift-backend/src/api/controllers/directus-controller.js @@ -54,9 +54,12 @@ function buildDocuments(order, entries) { entries.forEach((entry, i) => { const placeholders = placeholdersOf(entry); + // Dateinummer = Briefnummer (1-basiert), damit letter_001.svg zu + // Briefnummer 1 gehört. Vorschau nummeriert identisch (siehe skrift-orders). + const nr = Number(entry.letter_number) || (i + 1); documents.push({ - index: i, + index: nr, type: 'letter', text: order.text_template || '', format: letterFormat, @@ -66,7 +69,7 @@ function buildDocuments(order, entries) { if (wantsEnvelope) { documents.push({ - index: i, + index: nr, type: 'envelope', envelopeType, text: envelopeType === 'recipient' ? addressText(entry) : (order.text_template || ''), @@ -123,6 +126,10 @@ async function generateFromDirectus(req, res, next) { envelopeBeschriftung: wantsEnvelope ? order.envelope_labeling : null, directusOrderId: orderId, customerEmail: order.customer_email, + // Vorschau-Session → gezeigte Dokumente 1:1 übernehmen. + previewSession: order.preview_session || null, + // Eingegebener Brieftext → als brieftext.txt im Ausgabeordner ablegen. + rawText: order.text_template || null, }, }); diff --git a/Docker/skrift-backend/src/api/controllers/order-controller.js b/Docker/skrift-backend/src/api/controllers/order-controller.js index b28b518..483d54d 100644 --- a/Docker/skrift-backend/src/api/controllers/order-controller.js +++ b/Docker/skrift-backend/src/api/controllers/order-controller.js @@ -261,26 +261,43 @@ async function runGeneration({ orderNumber, letters, metadata }) { // Step 4: Generate SVG files - separate letters and envelopes const generatedLetters = []; const generatedEnvelopes = []; + // Vorschau-Session (falls vorhanden): bereits gezeigte Dokumente werden 1:1 + // aus dem Vorschau-Cache übernommen, damit der Kunde exakt das gedruckte + // Ergebnis erhält, das er gesehen hat (gleiche Zufalls-Handschrift). + const previewSession = (metadata || {}).previewSession; + const previewsRoot = config.paths.previews; for (let i = 0; i < processedLetters.length; i++) { const letter = processedLetters[i]; - // Verwende loopIndex für scriptalizedMap Zuordnung + const absoluteIndex = letter.index !== undefined ? letter.index : i; + const isEnv = letter.type === 'envelope'; + const filename = `${isEnv ? 'envelope' : 'letter'}_${String(absoluteIndex).padStart(3, '0')}.svg`; + const filepath = isEnv ? path.join(envelopesDir, filename) : path.join(outputDir, filename); + + // 1:1 aus der Vorschau übernehmen, wenn für dieses Dokument eine + // Vorschau-Datei existiert. + if (previewSession) { + const cached = path.join(previewsRoot, previewSession, filename); + try { + await fs.access(cached); + await fs.copyFile(cached, filepath); + (isEnv ? generatedEnvelopes : generatedLetters).push(filename); + console.log(`[Order] ${filename} 1:1 aus der Vorschau übernommen.`); + continue; + } catch { /* keine Vorschau-Datei → normal rendern */ } + } + const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText; - let svgResult; - try { - if (letter.type === 'envelope') { + if (isEnv) { svgResult = generateEnvelopeSVG( - scriptalizedText, - letter.format, - letter.envelopeType, + scriptalizedText, letter.format, letter.envelopeType, { font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false } ); } else { svgResult = generateLetterSVG( - scriptalizedText, - letter.format, + scriptalizedText, letter.format, { font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, signatures: letter.signatures || {} } ); } @@ -292,20 +309,8 @@ async function runGeneration({ orderNumber, letters, metadata }) { throw e; } - // Save SVG file - unterscheide zwischen Briefen und Umschlägen - const absoluteIndex = letter.index !== undefined ? letter.index : i; - - if (letter.type === 'envelope') { - const filename = `envelope_${String(absoluteIndex).padStart(3, '0')}.svg`; - const filepath = path.join(envelopesDir, filename); - await fs.writeFile(filepath, svgResult.svg, 'utf8'); - generatedEnvelopes.push(filename); - } else { - const filename = `letter_${String(absoluteIndex).padStart(3, '0')}.svg`; - const filepath = path.join(outputDir, filename); - await fs.writeFile(filepath, svgResult.svg, 'utf8'); - generatedLetters.push(filename); - } + await fs.writeFile(filepath, svgResult.svg, 'utf8'); + (isEnv ? generatedEnvelopes : generatedLetters).push(filename); } console.log(`[Order] Generated ${generatedLetters.length} letters, ${generatedEnvelopes.length} envelopes`); @@ -367,6 +372,12 @@ async function runGeneration({ orderNumber, letters, metadata }) { const orderMetadataPath = path.join(outputDir, 'order-metadata.json'); await fs.writeFile(orderMetadataPath, JSON.stringify(orderMetadata, null, 2), 'utf8'); + // Eingegebenen Text als reine Textdatei mitablegen (falls übergeben). + if (meta.rawText) { + try { await fs.writeFile(path.join(outputDir, 'brieftext.txt'), String(meta.rawText), 'utf8'); } + catch (e) { console.warn('[Order] brieftext.txt nicht geschrieben:', e.message); } + } + console.log(`[Order] Order ${orderNumber} generated successfully`); return { diff --git a/Docker/skrift-backend/src/services/directus-service.js b/Docker/skrift-backend/src/services/directus-service.js index 984fecc..bd80b31 100644 --- a/Docker/skrift-backend/src/services/directus-service.js +++ b/Docker/skrift-backend/src/services/directus-service.js @@ -42,8 +42,8 @@ async function fetchOrder(orderId) { const fields = [ 'id', 'order_number', 'customer_email', 'font', 'status', 'needs_envelope', 'envelope_labeling', 'envelope_format', - 'text_template', 'motif_mode', 'own_cards', 'entries_count', - 'product.key', 'product.type', 'format.key', + 'text_template', 'motif_mode', 'own_cards', 'entries_count', 'preview_session', + 'source_file', 'product.key', 'product.type', 'product.input_mode', 'format.key', ].join(','); const order = await directusFetch(`/items/orders/${encodeURIComponent(orderId)}?fields=${fields}`); diff --git a/Docker/skrift-backend/src/services/placeholder-service.js b/Docker/skrift-backend/src/services/placeholder-service.js index b01adac..0ddd604 100644 --- a/Docker/skrift-backend/src/services/placeholder-service.js +++ b/Docker/skrift-backend/src/services/placeholder-service.js @@ -48,19 +48,30 @@ function generatePlaceholderCSV(letters) { return ''; } - // Sammle alle unique Keys - const allKeys = new Set(); + // Keys in Auftrittsreihenfolge sammeln (NICHT alphabetisch sortieren – sonst + // stimmt die Spaltenreihenfolge nicht mit der Tabelle auf der Website überein). + const auftritt = []; letterItems.forEach(letter => { if (letter.placeholders) { - Object.keys(letter.placeholders).forEach(key => allKeys.add(key)); + Object.keys(letter.placeholders).forEach(key => { + if (!auftritt.includes(key)) auftritt.push(key); + }); } }); - if (allKeys.size === 0) { + if (auftritt.length === 0) { return ''; } - const keys = ['BriefNr', ...Array.from(allKeys).sort()]; + // Strukturierte Adressfelder zuerst (wie im Konfigurator), eigene Platzhalter + // danach in Auftrittsreihenfolge. Briefnummer steckt bereits in „BriefNr". + const BEVORZUGT = ['Anrede', 'Vorname', 'Nachname', 'Strasse', 'PLZ', 'Ort', 'Land']; + const vorhanden = new Set(auftritt); + const geordnet = [ + ...BEVORZUGT.filter(k => vorhanden.has(k)), + ...auftritt.filter(k => !BEVORZUGT.includes(k) && k !== 'Briefnummer'), + ]; + const keys = ['BriefNr', ...geordnet]; // Header const header = keys.join(','); diff --git a/Produktion/skrift-agent/README.md b/Produktion/skrift-agent/README.md new file mode 100644 index 0000000..e0d67ad --- /dev/null +++ b/Produktion/skrift-agent/README.md @@ -0,0 +1,44 @@ +# Skrift Produktions-Agent + +Schlanker lokaler Agent (Windows) neben dem Plotter. Steuerung läuft über +Directus – der Agent macht nur: **Heartbeat → Job holen → drucken → melden**. + +## Einrichtung + +1. In Directus einen Nutzer der Rolle **Service** anlegen und einen **statischen + Token** erzeugen. +2. Die Maschine in Directus unter **Einstellungen → Maschinen** anlegen + (`base_url`, ggf. `mid`/Login). Die numerische ID der Maschine ist die + `machine_id` für die Agent-Config. +3. `config.example.json` → `config.json` kopieren und ausfüllen + (`directus_url`, `directus_token`, `machine_id`). +4. Abhängigkeiten installieren und starten: + +```bat +python -m venv .venv +.venv\Scripts\pip install -r requirements.txt +.venv\Scripts\python agent.py +``` + +## Ablauf + +- Der Agent meldet zyklisch `machines.status` + `last_seen` (Heartbeat). +- Er zieht den nächsten `jobs`-Eintrag seiner Maschine mit `status=queued` + (sortiert nach `priority`, dann Alter). +- Er lädt die SVGs des Auftrags passend zum Job-Typ (Schriftstück/Umschlag) + über den Directus-Datei-Endpoint (`/skrift-orders/files/...`) und schickt sie + mit den Template-Werten (Maße/Position/Rotation) an den Plotter. +- Status zurück: `printing → printed` bzw. `failed` (+ Fehlermeldung). +- Bei Maschinenfehler (z. B. Papierende) ruft er `recover_write` auf. + +## Jobs entstehen + +Über den Admin-Trigger `POST /skrift-orders/enqueue { order, machine }` +(bzw. später ein Button in der Webapp). Pro Auftrag entsteht ein Brief-Job und +– falls Kuvert beschriftet – ein Umschlag-Job, jeweils mit aufgelöstem Template. + +## Signaturen / AutoRecover + +Der Signatur-Service bleibt vorerst unberührt. Das win32-`AutoRecover` +(klickt das Java-Fenster) kann weiter separat laufen; der Agent nutzt zusätzlich +den API-`recover_write` der Maschine. diff --git a/Produktion/skrift-agent/agent.py b/Produktion/skrift-agent/agent.py new file mode 100644 index 0000000..e2bfde8 --- /dev/null +++ b/Produktion/skrift-agent/agent.py @@ -0,0 +1,294 @@ +""" +Skrift – schlanker Produktions-Agent +==================================== +Läuft lokal (Windows) neben dem Plotter. Er macht bewusst nur drei Dinge: + + 1. Heartbeat – meldet den Maschinenzustand an Directus (machines.status/last_seen) + 2. Poll – holt den nächsten Druck-Job seiner Maschine (jobs, status=queued) + 3. Drucken – lädt die SVGs vom Directus-Datei-Endpoint, schickt sie an den + Plotter und meldet den Status zurück (printing → printed/failed) + +Alles andere (Aufträge, Warteschlange, Templates, Maschinenpflege) steuerst du in +Directus / der Webapp. Der Agent kennt nur seine eine `machine`-ID. + +Konfiguration: config.json (siehe config.example.json). +Start: python agent.py +""" + +import io +import json +import os +import sys +import time +import tempfile +import datetime as dt + +import requests + +HIER = os.path.dirname(os.path.abspath(__file__)) + + +def lade_config(): + pfad = os.path.join(HIER, "config.json") + if not os.path.exists(pfad): + print("config.json fehlt – bitte config.example.json kopieren und ausfüllen.") + sys.exit(1) + with open(pfad, "r", encoding="utf-8") as f: + return json.load(f) + + +class Directus: + """Minimaler Directus-Client (Service-Token).""" + + def __init__(self, base_url, token): + self.base = base_url.rstrip("/") + self.s = requests.Session() + self.s.headers["Authorization"] = f"Bearer {token}" + + def get(self, path, **params): + r = self.s.get(self.base + path, params=params, timeout=30) + r.raise_for_status() + return r.json().get("data") + + def patch(self, path, body): + r = self.s.patch(self.base + path, json=body, timeout=30) + r.raise_for_status() + return r.json().get("data") + + def download(self, path, params): + r = self.s.get(self.base + path, params=params, timeout=120) + r.raise_for_status() + return r.content + + +class Plotter: + """Sendet SVGs an die Maschinen-API (Auszug aus der bisherigen App).""" + + def __init__(self, machine): + self.base = str(machine.get("base_url", "")).rstrip("/") + self.mid = machine.get("mid") or "" + self.user = machine.get("username") or "" + self.passwd = machine.get("password") or "" + self.s = requests.Session() + + def login(self): + if not self.user: + return True + r = self.s.get(f"{self.base}/login", params={"uin": self.user, "passwd": self.passwd}, timeout=10) + return r.json().get("error", -1) == 0 + + def status(self): + params = {"mids": self.mid} if self.mid else {} + try: + r = self.s.get(f"{self.base}/machine_status", params=params, timeout=10) + data = r.json() + return data[0] if isinstance(data, list) and data else None + except Exception: + return None + + def recover(self): + params = {"mid": self.mid} if self.mid else {} + try: + r = self.s.get(f"{self.base}/recover_write", params=params, timeout=10) + return r.json().get("code", -1) == 0 + except Exception: + return False + + @staticmethod + def _fmt(v): + v = float(v or 0) + return str(int(v)) if v == int(v) else str(v) + + def write(self, dateien, tpl): + """dateien: [(name, bytes)]; tpl: Template-Dict mit width/height/... (Zoll).""" + basis = { + "width": f"{self._fmt(tpl.get('width'))}in", + "height": f"{self._fmt(tpl.get('height'))}in", + "xpos": f"{self._fmt(tpl.get('xpos'))}in", + "ypos": f"{self._fmt(tpl.get('ypos'))}in", + "rotation": self._fmt(tpl.get("rotation")), + "scale": self._fmt(tpl.get("scale") if tpl.get("scale") is not None else 1), + "repeat": "1", + } + if self.mid: + basis["mid"] = self.mid + letzte = len(dateien) - 1 + antwort = {} + for i, (name, inhalt) in enumerate(dateien): + data = {**basis, "clear": "1" if i == 0 else "0", "start": "1" if i == letzte else "0"} + files = [("file", (name, io.BytesIO(inhalt), "image/svg+xml"))] + r = self.s.post(f"{self.base}/write_svg", data=data, files=files, timeout=60) + antwort = r.json() + if antwort.get("error", -1) != 0: + raise RuntimeError(f"Maschine meldet Fehler {antwort.get('error')} bei {name}") + return antwort + + +def jetzt(): + return dt.datetime.now(dt.timezone.utc).isoformat() + + +def parse_nummern(spec): + """„1-3,5,7-8" → {1,2,3,5,7,8}. Leer/None → None (= alle drucken).""" + if not spec or not str(spec).strip(): + return None + treffer = set() + for teil in str(spec).split(","): + teil = teil.strip() + if not teil: + continue + if "-" in teil: + try: + a, b = teil.split("-", 1) + a, b = int(a), int(b) + treffer.update(range(min(a, b), max(a, b) + 1)) + except ValueError: + continue + else: + try: + treffer.add(int(teil)) + except ValueError: + continue + return treffer or None + + +def datei_nummer(name): + """Zahl aus einem Dateinamen ziehen (letter_003.svg → 3).""" + ziffern = "".join(ch for ch in os.path.splitext(os.path.basename(name))[0] if ch.isdigit()) + return int(ziffern) if ziffern else None + + +def main(): + cfg = lade_config() + dx = Directus(cfg["directus_url"], cfg["directus_token"]) + machine_id = cfg["machine_id"] + poll = int(cfg.get("poll_interval_seconds", 5)) + + print(f"[Agent] gestartet für Maschine {machine_id} gegen {cfg['directus_url']}") + + KIND = {"brief": "schriftstueck", "umschlag": "umschlag", "signatur": "signatur"} + + while True: + try: + maschine = dx.get(f"/items/machines/{machine_id}", + fields="base_url,mid,username,password,active") + if not maschine or not maschine.get("active", True): + _heartbeat(dx, machine_id, "offline") + time.sleep(poll) + continue + + plotter = Plotter(maschine) + mstatus = plotter.status() + # Bei Fehler (z. B. Papierende) automatisch fortsetzen. + if mstatus and str(mstatus.get("status", "")).lower() in ("error", "fehler"): + plotter.recover() + _heartbeat(dx, machine_id, "bereit") + + job = _naechster_job(dx, machine_id) + if not job: + time.sleep(poll) + continue + + _druck(dx, plotter, job, KIND) + + except requests.HTTPError as e: + print(f"[Agent] HTTP-Fehler: {e}") + time.sleep(poll) + except Exception as e: + print(f"[Agent] Fehler: {e}") + time.sleep(poll) + + +def _heartbeat(dx, machine_id, status): + try: + dx.patch(f"/items/machines/{machine_id}", {"status": status, "last_seen": jetzt()}) + except Exception: + pass + + +def _naechster_job(dx, machine_id): + jobs = dx.get( + "/items/jobs", + **{ + "filter[machine][_eq]": machine_id, + "filter[status][_eq]": "queued", + "sort": "-priority,date_created", + "limit": 1, + "fields": "id,type,output_ref,params,numbers," + "order.id,order.order_number,order.needs_envelope," + "template.width,template.height," + "template.xpos,template.ypos,template.scale,template.rotation", + }, + ) + return jobs[0] if jobs else None + + +def _druck(dx, plotter, job, KIND): + job_id = job["id"] + order = job.get("order") or {} + order_id = order.get("id") + # Ordner: entweder Auftragsnummer oder ein output_ref (z. B. Testdruck). + order_nr = job.get("output_ref") or order.get("order_number") + tpl = dict(job.get("template") or {}) + # Pro-Druck-Übersteuerung der Maße (aus dem Druck-Popup) hat Vorrang. + params = job.get("params") + if isinstance(params, dict): + for k in ("width", "height", "xpos", "ypos", "scale", "rotation"): + if params.get(k) is not None: + tpl[k] = params[k] + nummern = parse_nummern(job.get("numbers")) + typ = job.get("type") or "brief" + print(f"[Agent] Job {job_id}: {typ} für {order_nr}") + + dx.patch(f"/items/jobs/{job_id}", {"status": "printing", "error": None}) + # Produktionsstatus fürs Kanban: sobald gedruckt wird → „im_druck". + if order_id: + try: dx.patch(f"/items/orders/{order_id}", {"production_status": "im_druck"}) + except Exception: pass + try: + if not order_nr: + raise RuntimeError("Auftragsnummer fehlt am Job.") + liste = dx.get(f"/skrift-orders/files/{order_nr}") or {} + gewuenscht = KIND.get(typ, "schriftstueck") + passende = sorted( + [f for f in (liste.get("files") or []) if f.get("kind") == gewuenscht], + key=lambda f: f.get("name", ""), + ) + # Nur bestimmte Briefnummern drucken, falls im Job angegeben. + if nummern is not None: + passende = [f for f in passende if datei_nummer(f.get("name")) in nummern] + if not passende: + raise RuntimeError(f"Keine Dateien vom Typ '{gewuenscht}' für {order_nr} gefunden.") + + dateien = [] + for f in passende: + inhalt = dx.download(f"/skrift-orders/files/{order_nr}/download", {"rel": f["rel"]}) + dateien.append((f["name"], inhalt)) + + plotter.login() + plotter.write(dateien, tpl) + + dx.patch(f"/items/jobs/{job_id}", {"status": "printed"}) + # Indikator am Auftrag setzen (Schriftstück bzw. Umschlag gedruckt) und + # bei Vollständigkeit automatisch in die Kuvertierung schieben. + if order_id: + feld = "kuvert_gedruckt" if typ == "umschlag" else "brief_gedruckt" + try: + dx.patch(f"/items/orders/{order_id}", {feld: True}) + stand = dx.get(f"/items/orders/{order_id}", + fields="brief_gedruckt,kuvert_gedruckt,needs_envelope,production_status") or {} + braucht_kuvert = bool(stand.get("needs_envelope")) + fertig = stand.get("brief_gedruckt") and (stand.get("kuvert_gedruckt") or not braucht_kuvert) + if fertig and stand.get("production_status") == "im_druck": + dx.patch(f"/items/orders/{order_id}", {"production_status": "kuvertieren"}) + print(f"[Agent] Auftrag {order_nr} vollständig gedruckt → Kuvertierung.") + except Exception: + pass + print(f"[Agent] Job {job_id} gedruckt ({len(dateien)} Dateien).") + except Exception as e: + dx.patch(f"/items/jobs/{job_id}", {"status": "failed", "error": str(e)[:1000]}) + print(f"[Agent] Job {job_id} fehlgeschlagen: {e}") + + +if __name__ == "__main__": + main() diff --git a/Produktion/skrift-agent/config.example.json b/Produktion/skrift-agent/config.example.json new file mode 100644 index 0000000..e812591 --- /dev/null +++ b/Produktion/skrift-agent/config.example.json @@ -0,0 +1,6 @@ +{ + "directus_url": "https://admin.skrift.de", + "directus_token": "SERVICE-TOKEN-HIER-EINTRAGEN", + "machine_id": 1, + "poll_interval_seconds": 5 +} diff --git a/Produktion/skrift-agent/requirements.txt b/Produktion/skrift-agent/requirements.txt new file mode 100644 index 0000000..535409c --- /dev/null +++ b/Produktion/skrift-agent/requirements.txt @@ -0,0 +1 @@ +requests>=2.31 diff --git a/Webseite/skrift-app/.dockerignore b/Webseite/skrift-app/.dockerignore new file mode 100644 index 0000000..8ca1061 --- /dev/null +++ b/Webseite/skrift-app/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist +.env +.env.* +!.env.example +.git +.gitignore +*.log +README.md diff --git a/Webseite/skrift-app/.env.example b/Webseite/skrift-app/.env.example new file mode 100644 index 0000000..7fa7c58 --- /dev/null +++ b/Webseite/skrift-app/.env.example @@ -0,0 +1,4 @@ +# Kopieren zu .env.local (wird nicht committet) + +# Directus-Instanz (Daten, Auth, Bestellungen) +VITE_DIRECTUS_URL=https://admin.skrift.de diff --git a/Webseite/skrift-app/Dockerfile b/Webseite/skrift-app/Dockerfile new file mode 100644 index 0000000..48e69d3 --- /dev/null +++ b/Webseite/skrift-app/Dockerfile @@ -0,0 +1,30 @@ +# Mehrstufig: Node baut die App, nginx liefert nur noch statische Dateien aus. +# Im Endimage ist kein Node und kein node_modules mehr enthalten. + +# ── Build ─────────────────────────────────────────────────── +FROM node:20-alpine AS build +WORKDIR /app + +# Die Directus-URL wird zur BUILD-Zeit ins Bundle geschrieben (Vite-Prinzip), +# nicht zur Laufzeit. Daher als Build-Argument. +ARG VITE_DIRECTUS_URL +ENV VITE_DIRECTUS_URL=$VITE_DIRECTUS_URL + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# ── Auslieferung ──────────────────────────────────────────── +FROM nginx:1.27-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +# BusyBox-wget (in nginx:alpine) kennt --spider/--tries nicht zuverlässig – +# stattdessen die Startseite nach /dev/null laden. Schlägt nur fehl, wenn +# nginx wirklich nicht antwortet. +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1/ || exit 1 diff --git a/Webseite/skrift-app/docker-compose.yml b/Webseite/skrift-app/docker-compose.yml new file mode 100644 index 0000000..4e33bba --- /dev/null +++ b/Webseite/skrift-app/docker-compose.yml @@ -0,0 +1,29 @@ +# Skrift-App (Konfigurator + Portal) hinter dem Nginx Proxy Manager. +# +# Im NPM als Proxy Host anlegen: +# Domain Names : app.skrift.de +# Scheme : http +# Forward Hostname : skrift-app +# Forward Port : 80 +# SSL : Let's-Encrypt-Zertifikat aktivieren +# +# WICHTIG: VITE_DIRECTUS_URL wird beim BUILD ins Bundle geschrieben. +# Nach einer Änderung der URL muss neu gebaut werden (nicht nur neu gestartet). + +services: + skrift-app: + build: + context: . + args: + VITE_DIRECTUS_URL: ${VITE_DIRECTUS_URL:-https://admin.skrift.de} + container_name: skrift-app + restart: unless-stopped + expose: + - "80" + networks: + - npm + +networks: + npm: + name: nginx-proxy-manager_default + external: true diff --git a/Webseite/skrift-app/index.html b/Webseite/skrift-app/index.html new file mode 100644 index 0000000..8d6583d --- /dev/null +++ b/Webseite/skrift-app/index.html @@ -0,0 +1,23 @@ + + + + + + + + Skrift + + + + + + + + + + + +
+ + + diff --git a/Webseite/skrift-app/nginx.conf b/Webseite/skrift-app/nginx.conf new file mode 100644 index 0000000..beb13ab --- /dev/null +++ b/Webseite/skrift-app/nginx.conf @@ -0,0 +1,42 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + gzip_min_length 1024; + + # Sicherheits-Header (TLS terminiert der Nginx Proxy Manager davor) + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Gehashte Assets dürfen dauerhaft gecacht werden. + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Schriften: lokal ausgeliefert, lange cachebar. + location /fonts/ { + expires 1y; + add_header Cache-Control "public, immutable"; + types { + font/woff2 woff2; + font/ttf ttf; + } + } + + # index.html nie cachen – sonst sehen Nutzer nach einem Deploy die alte App. + location = /index.html { + add_header Cache-Control "no-store, must-revalidate"; + } + + # SPA-Fallback: /portal, /login usw. existieren nicht als Dateien. + # Ohne diese Zeile führt ein Reload auf einer Unterseite zu 404. + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/Webseite/skrift-app/package-lock.json b/Webseite/skrift-app/package-lock.json new file mode 100644 index 0000000..134f56e --- /dev/null +++ b/Webseite/skrift-app/package-lock.json @@ -0,0 +1,1884 @@ +{ + "name": "skrift-app", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "skrift-app", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.44", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.44.tgz", + "integrity": "sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.393", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", + "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/Webseite/skrift-app/package.json b/Webseite/skrift-app/package.json new file mode 100644 index 0000000..2171a1e --- /dev/null +++ b/Webseite/skrift-app/package.json @@ -0,0 +1,25 @@ +{ + "name": "skrift-app", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Skrift – Konfigurator und Kundenportal (eine App).", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.2" + } +} diff --git a/Webseite/skrift-app/public/README.md b/Webseite/skrift-app/public/README.md new file mode 100644 index 0000000..f94bb25 --- /dev/null +++ b/Webseite/skrift-app/public/README.md @@ -0,0 +1,31 @@ +# public/ – statische Dateien + +Alles hier wird **unverändert** unter dem Wurzelpfad ausgeliefert: +`public/favicon.svg` → `https://app.skrift.de/favicon.svg`. + +Das ist der richtige Ort für Dateien mit **festem Namen** (Icons, robots.txt). +Grafiken, die in Komponenten importiert werden, gehören dagegen nach +`src/assets/` – die bekommen beim Build einen Hash und sind damit cache-sicher. + +## Hier ablegen + +| Datei | Zweck | Empfehlung | +|---|---|---| +| `logo.svg` | Logo in der Kopfzeile | SVG, einfarbig in `#1E3A6A`, Höhe ~28 px optisch | +| `favicon.svg` | Browser-Tab (moderne Browser) | quadratisch, schlichte Bildmarke – Schriftzug wird bei 16 px unlesbar | +| `favicon.ico` | Rückfall für ältere Browser | 32×32 | +| `apple-touch-icon.png` | iOS-Startbildschirm | 180×180, ohne Transparenz | +| `fonts/` | Schriften (siehe `fonts/README.md`) | – | + +## Verhalten, solange Dateien fehlen + +- **Logo:** Die Kopfzeile zeigt automatisch den Schriftzug „Skrift" als Text. + Sobald `logo.svg` existiert, wird stattdessen das Bild angezeigt – kein + Codeänderung nötig. +- **Favicon:** Der Browser zeigt sein Standard-Icon. Kein Fehler. + +## Icons erzeugen + +Aus einem quadratischen SVG lassen sich die übrigen Formate schnell ableiten, +z. B. mit – dabei nur die oben genannten +Dateien behalten, den Rest braucht es nicht. diff --git a/Webseite/skrift-app/public/favicon.svg b/Webseite/skrift-app/public/favicon.svg new file mode 100644 index 0000000..fb678c1 --- /dev/null +++ b/Webseite/skrift-app/public/favicon.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Webseite/skrift-app/public/fonts/README.md b/Webseite/skrift-app/public/fonts/README.md new file mode 100644 index 0000000..d9e28ac --- /dev/null +++ b/Webseite/skrift-app/public/fonts/README.md @@ -0,0 +1,41 @@ +# Schriften (lokal, DSGVO-konform) + +Work Sans wird **lokal** ausgeliefert. Es darf **keine** Anfrage an +`fonts.googleapis.com` oder `fonts.gstatic.com` gehen – sonst fließt die +IP-Adresse des Besuchers ohne Einwilligung an Google. + +## Benötigte Datei + +Wir nutzen die **Variable Font** – eine Datei deckt alle Gewichte (100–900) ab. +Abgelegt wird sie in diesem Ordner unter einem dieser Namen: + +``` +work-sans-variable.woff2 ← bevorzugt (klein) +WorkSans-VariableFont_wght.ttf ← Rückfall (funktioniert, aber groß) +``` + +Beide sind in `src/styles/global.css` als `@font-face` hinterlegt; der Browser +nimmt automatisch die erste verfügbare. + +## Empfohlen: TTF nach woff2 umwandeln + +Die Original-TTF von Google ist mehrere hundert KB, als woff2 bleiben davon +meist unter 100 KB übrig – bei gleicher Qualität. Einmalig: + +```bash +pip install fonttools brotli +fonttools ttLib.woff2 compress WorkSans-VariableFont_wght.ttf +# Ergebnis umbenennen zu: work-sans-variable.woff2 +``` + +Wer kein Python möchte, kann die woff2-Variante auch direkt beziehen: + (Charsets `latin` + `latin-ext`). + +Work Sans steht unter der SIL Open Font License 1.1 – freie Nutzung, auch +kommerziell. + +## Prüfen + +Nach dem Start in den DevTools unter *Netzwerk* filtern: Es darf **kein** +Request an eine Google-Domain auftauchen. Die Schrift kommt von +`/fonts/work-sans-*`. diff --git a/Webseite/skrift-app/public/fonts/WorkSans-VariableFont_wght.ttf b/Webseite/skrift-app/public/fonts/WorkSans-VariableFont_wght.ttf new file mode 100644 index 0000000000000000000000000000000000000000..16293f14056e593cc317ec25c1945cfb02d68e7d GIT binary patch literal 359628 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

11NiHw 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 + + + + + + + + + + diff --git a/Webseite/skrift-app/src/App.tsx b/Webseite/skrift-app/src/App.tsx new file mode 100644 index 0000000..738e7cf --- /dev/null +++ b/Webseite/skrift-app/src/App.tsx @@ -0,0 +1,58 @@ +import { Navigate, Route, Routes } from 'react-router-dom'; +import AppShell from './components/AppShell'; +import { WizardProvider } from './lib/wizard'; +import { DialogsProvider } from './lib/dialogs'; +import Login from './routes/Login'; +import Auftraege from './routes/Auftraege'; +import Stammdaten from './routes/Stammdaten'; +import Configurator from './routes/Configurator'; +import MusterSeite from './routes/Muster'; +import { loadSession } from './lib/api'; + +/** + * Produkt-Direktlinks (Slug → Produktschlüssel): wählen das Produkt vor, der + * Konfigurator startet auf Schritt 1. Mehrere freundliche Slugs erlaubt + * (z. B. /followups zeigt auf den Schlüssel follow_ups). + */ +const PRODUKT_PFADE: Record = { + briefe: 'briefe', + postkarten: 'postkarten', + unterschriftenservice: 'unterschriftenservice', + follow_ups: 'follow_ups', + followups: 'follow_ups', + 'follow-ups': 'follow_ups', +}; + +function Geschuetzt({ children }: { children: React.ReactNode }) { + return loadSession() ? <>{children} : ; +} + +export default function App() { + return ( + + + + + } /> + } /> + {/* Muster als eigene Seite (kein Popup) – Direktlink /muster. */} + } /> + } + /> + } + /> + {/* Produkt-Direktlinks – Produktwahl entfällt, Personentyp bleibt. */} + {Object.entries(PRODUKT_PFADE).map(([slug, key]) => ( + } /> + ))} + } /> + + + + + ); +} diff --git a/Webseite/skrift-app/src/components/AppShell.tsx b/Webseite/skrift-app/src/components/AppShell.tsx new file mode 100644 index 0000000..0dd0499 --- /dev/null +++ b/Webseite/skrift-app/src/components/AppShell.tsx @@ -0,0 +1,497 @@ +import { useEffect, useState } from 'react'; +import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom'; +import { + ApiError, + fetchMyCustomer, + fetchProducts, + fetchStatusEvents, + getSeenAt, + loadSession, + logout, + markNotificationsSeen, + sendSampleRequest, + type Product, + type StatusEvent, +} from '../lib/api'; +import { STATUS_LABEL } from '../lib/status'; +import { useDialogs } from '../lib/dialogs'; +import { useWizard } from '../lib/wizard'; +import OfferForm from './OfferForm'; +import { + IconBell, + IconCheck, + IconChevronDown, + IconClose, + IconHelp, + IconList, + IconMail, + IconMenu, + IconOffer, + IconPen, + IconPhone, + IconUser, +} from './Icons'; + +const KONTAKT = { mail: 'hello@skrift.de', tel: '+49 6872 2153' }; +/** Routen, unter denen der Konfigurator läuft (Start + Direktlinks). */ +const KONFIGURATOR_PFADE = ['/', '/briefe', '/postkarten', '/unterschriftenservice', '/follow_ups', '/followups', '/follow-ups']; +const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien', 'Niederlande', 'Frankreich']; +const istEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.trim()); + +function Wordmark() { + const [logoFehlt, setLogoFehlt] = useState(false); + return ( + + {logoFehlt ? 'Skrift' : Skrift setLogoFehlt(true)} />} + + ); +} + +/** Konto-Dropdown in der Kopfleiste: Name/Rolle + Abmelden (bzw. Anmelden). */ +function UserMenu() { + const navigate = useNavigate(); + const angemeldet = !!loadSession(); + const [offen, setOffen] = useState(false); + const [kunde, setKunde] = useState<{ name: string; email: string; rolle: string } | null>(null); + + useEffect(() => { + if (!angemeldet) return; + fetchMyCustomer() + .then((c) => { + if (!c) return; + setKunde({ + name: [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email, + email: c.email, + rolle: c.person_type === 'unternehmen' ? 'Unternehmen' : 'Privatkunde', + }); + }) + .catch(() => { /* Anzeige ist unkritisch */ }); + }, [angemeldet]); + + if (!angemeldet) { + return ( + + Anmelden + + ); + } + + return ( +

+ + + {offen && ( + <> +
setOffen(false)} /> +
+
+
{kunde?.name}
+
{kunde?.email}
+
{kunde?.rolle}
+
+ +
+ + )} +
+ ); +} + +/** Schlichtes Overlay für Hilfe und Angebotsanfrage. */ +function Dialog({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) { + return ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+ {children} +
+
+ ); +} + +function Hilfe({ onClose }: { onClose: () => void }) { + return ( + +

Sie erreichen uns direkt – wir melden uns werktags am selben Tag.

+ + + +
+ ); +} + +function Angebot({ onClose, vorbelegung }: { onClose: () => void; vorbelegung?: string }) { + // vorbelegung = gewähltes Kontakt-Produkt; wird separat übermittelt, nicht im Text. + return ( + + + + ); +} + +/** Muster bestellen – kostenlos, mit Produktwahl und Lieferadresse. + * variant='page' rendert als eigene Seite (Direktlink /muster) statt als Popup. */ +export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: () => void; variant?: 'dialog' | 'page' }) { + const [produkte, setProdukte] = useState([]); + const [form, setForm] = useState({ + product: '', name: '', email: '', phone: '', + street: '', zip: '', city: '', country: 'Deutschland', + }); + const [datenschutz, setDatenschutz] = useState(false); + const [status, setStatus] = useState<'formular' | 'sendet' | 'fertig'>('formular'); + const [fehler, setFehler] = useState([]); + + useEffect(() => { + fetchProducts() + .then((ps) => setProdukte(ps.filter((p) => p.type === 'letter' || p.type === 'postcard'))) + .catch(() => setProdukte([])); + if (loadSession()) { + fetchMyCustomer() + .then((c) => c && setForm((f) => ({ + ...f, + name: [c.first_name, c.last_name].filter(Boolean).join(' ') || f.name, + email: c.email || f.email, + phone: c.phone || f.phone, + }))) + .catch(() => { /* optional */ }); + } + }, []); + + const set = (k: keyof typeof form) => (e: React.ChangeEvent) => { + const roh = e.target.value; + setForm({ ...form, [k]: k === 'phone' ? roh.replace(/[^0-9+ ]/g, '') : k === 'zip' ? roh.replace(/[^0-9]/g, '') : roh }); + }; + + async function absenden(e: React.FormEvent) { + e.preventDefault(); + if (!istEmail(form.email)) { setFehler(['Bitte geben Sie eine gültige E-Mail-Adresse an.']); return; } + setFehler([]); + setStatus('sendet'); + try { + await sendSampleRequest({ + product: form.product, + name: form.name.trim(), + email: form.email.trim(), + phone: form.phone.trim() || undefined, + street: form.street.trim(), + zip: form.zip.trim(), + city: form.city.trim(), + country: form.country, + }); + setStatus('fertig'); + } catch (err) { + setFehler(err instanceof ApiError ? (err.details.length ? err.details : [err.message]) : ['Anfrage fehlgeschlagen.']); + setStatus('formular'); + } + } + + // Rahmen je nach Modus: Popup (Dialog) oder eigene Seite (sk-view). + const wrap = (titel: string, inhalt: React.ReactNode) => + variant === 'page' + ?

{titel}

{inhalt}
+ : {inhalt}; + + if (status === 'fertig') { + return wrap('Muster ist unterwegs', ( + <> +

Vielen Dank – Ihr kostenloses Muster wird vorbereitet und verschickt.

+ + + )); + } + + const feld = (k: keyof typeof form, label: string, opt = false, type = 'text') => ( +
+ + +
+ ); + + const gueltig = form.product && form.name.trim() && istEmail(form.email) && form.phone.trim() + && form.street.trim() && form.zip.trim() && form.city.trim() && datenschutz; + + return wrap('Muster bestellen', ( + <> +

Wir schicken Ihnen kostenlos ein Handschrift-Muster zum Anfassen.

+
+
+ + +
+ {feld('name', 'Name')} + {feld('email', 'E-Mail-Adresse', false, 'email')} + {feld('phone', 'Telefon', false, 'tel')} + {feld('street', 'Straße und Hausnummer')} + {feld('zip', 'PLZ')} + {feld('city', 'Stadt')} +
+ + +
+ + + + {fehler.length > 0 && ( +
    + {fehler.map((f) =>
  • {f}
  • )} +
+ )} + + +
+ + )); +} + +function Sidebar({ onNavigate }: { onNavigate?: () => void }) { + const { steps, current, goto } = useWizard(); + const location = useLocation(); + const { open } = useDialogs(); + const angemeldet = !!loadSession(); + + // Ablauf nur im Konfigurator zeigen (Start + Direktlinks /briefe, /postkarten) – + // nicht in Aufträgen/Stammdaten. + const zeigeAblauf = KONFIGURATOR_PFADE.includes(location.pathname) && steps.length > 0; + + const item = ({ isActive }: { isActive: boolean }) => `sk-nav-item${isActive ? ' sk-nav-item--active' : ''}`; + + return ( + + ); +} + +/** + * Benachrichtigungen speisen sich aus dem Statusverlauf der eigenen Aufträge – + * dafür braucht es keine eigene Collection. Als „gelesen" gilt alles, was älter + * ist als der zuletzt geöffnete Zeitpunkt (lokal gespeichert). + */ +function Notifications() { + const [events, setEvents] = useState([]); + const [offen, setOffen] = useState(false); + const [seenAt, setSeenAt] = useState(getSeenAt); + + useEffect(() => { + fetchStatusEvents() + .then(setEvents) + .catch(() => { + /* Benachrichtigungen sind nicht kritisch */ + }); + }, []); + + const ungelesen = events.filter((e) => new Date(e.timestamp).getTime() > seenAt).length; + + function umschalten() { + const next = !offen; + setOffen(next); + if (next && ungelesen > 0) { + markNotificationsSeen(); + setSeenAt(Date.now()); + } + } + + return ( + <> + + + {offen && ( +
+ {events.length === 0 ? ( +

Keine Benachrichtigungen.

+ ) : ( +
    + {events.slice(0, 5).map((e) => ( +
  • +
    + {e.order?.order_number ?? 'Auftrag'} · {STATUS_LABEL[e.status] ?? e.status} +
    + {e.note &&
    {e.note}
    } +
    + {new Intl.DateTimeFormat('de-DE', { dateStyle: 'short', timeStyle: 'short' }).format( + new Date(e.timestamp), + )} +
    +
  • + ))} +
+ )} +
+ )} + + ); +} + +function Topbar({ onMenu }: { onMenu: () => void }) { + const angemeldet = !!loadSession(); + return ( +
+
+ {/* Menü-Umschalter nur auf schmalen Geräten sichtbar (CSS). */} + + +
+ +
+ {angemeldet && } + +
+
+ ); +} + +export default function AppShell({ children }: { children: React.ReactNode }) { + const [drawerOffen, setDrawerOffen] = useState(false); + const location = useLocation(); + const { dialog, vorbelegung, close } = useDialogs(); + + // Beim Seitenwechsel das mobile Menü schließen. + useEffect(() => { setDrawerOffen(false); }, [location.pathname]); + + return ( +
+ setDrawerOffen(true)} /> + +
+ setDrawerOffen(false)} /> +
+ {drawerOffen &&
setDrawerOffen(false)} />} + +
{children}
+ + {/* Dialoge zentral – von Seitenleiste und Konfigurator aus geöffnet. */} + {dialog === 'hilfe' && } + {dialog === 'angebot' && } +
+ ); +} diff --git a/Webseite/skrift-app/src/components/Icons.tsx b/Webseite/skrift-app/src/components/Icons.tsx new file mode 100644 index 0000000..4c8f4f2 --- /dev/null +++ b/Webseite/skrift-app/src/components/Icons.tsx @@ -0,0 +1,111 @@ +/** + * Single-Line-Icons als Inline-SVG. + * + * Bewusst ohne Icon-Bibliothek: keine externe Anfrage (DSGVO), kein zusätzliches + * Bundle, und eine einheitliche Strichstärke über alle Symbole. + */ + +type Props = { size?: number; className?: string }; + +const base = (size: number) => ({ + width: size, + height: size, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 1.5, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + 'aria-hidden': true, + focusable: false, +}); + +export const IconPen = ({ size = 18, className }: Props) => ( + + + + +); + +export const IconList = ({ size = 18, className }: Props) => ( + + + + +); + +export const IconUser = ({ size = 18, className }: Props) => ( + + + + +); + +export const IconBell = ({ size = 18, className }: Props) => ( + + + + +); + +export const IconHelp = ({ size = 18, className }: Props) => ( + + + + + +); + +export const IconOffer = ({ size = 18, className }: Props) => ( + + + + + +); + +export const IconCheck = ({ size = 18, className }: Props) => ( + + + +); + +export const IconMail = ({ size = 18, className }: Props) => ( + + + + +); + +export const IconPhone = ({ size = 18, className }: Props) => ( + + + +); + +export const IconClose = ({ size = 18, className }: Props) => ( + + + +); + +export const IconBuilding = ({ size = 18, className }: Props) => ( + + + + + + +); + +export const IconMenu = ({ size = 18, className }: Props) => ( + + + +); + +export const IconChevronDown = ({ size = 18, className }: Props) => ( + + + +); diff --git a/Webseite/skrift-app/src/components/OfferForm.tsx b/Webseite/skrift-app/src/components/OfferForm.tsx new file mode 100644 index 0000000..b0460ed --- /dev/null +++ b/Webseite/skrift-app/src/components/OfferForm.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState } from 'react'; +import { ApiError, fetchMyCustomer, loadSession, sendOfferRequest } from '../lib/api'; + +const istEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.trim()); + +/** + * Anfrageformular „Individuelles Angebot". + * + * Wird an zwei Stellen genutzt: im Konfigurator als eigene Seite (nach der Wahl + * eines Anfrageprodukts) und in der Seitenleiste als Dialog. Deshalb rendert es + * nur den Inhalt – die Umrahmung (Karte bzw. Overlay) kommt von außen. + * + * `onClose` blendet im Dialog den Schließen-Button ein; inline (im Konfigurator) + * bleibt er weg. + */ +export default function OfferForm({ produkt, onClose }: { produkt?: string; onClose?: () => void }) { + const gewaehltesProdukt = produkt?.trim() || ''; + const [form, setForm] = useState({ name: '', email: '', phone: '', quantity: '', message: '' }); + const [datenschutz, setDatenschutz] = useState(false); + const [status, setStatus] = useState<'formular' | 'sendet' | 'fertig'>('formular'); + const [fehler, setFehler] = useState([]); + + // Bei angemeldeten Kunden Name und E-Mail vorausfüllen. + useEffect(() => { + if (!loadSession()) return; + fetchMyCustomer() + .then((c) => { + if (!c) return; + setForm((f) => ({ + ...f, + name: [c.first_name, c.last_name].filter(Boolean).join(' ') || f.name, + email: c.email || f.email, + phone: c.phone || f.phone, + })); + }) + .catch(() => { /* Vorausfüllen ist optional */ }); + }, []); + + const set = (k: keyof typeof form) => (e: React.ChangeEvent) => { + const roh = e.target.value; + setForm({ ...form, [k]: k === 'phone' ? roh.replace(/[^0-9+ ]/g, '') : roh }); + }; + + async function absenden(e: React.FormEvent) { + e.preventDefault(); + if (!istEmail(form.email)) { setFehler(['Bitte geben Sie eine gültige E-Mail-Adresse an.']); return; } + if (!form.phone.trim()) { setFehler(['Bitte geben Sie eine Telefonnummer an.']); return; } + setFehler([]); + setStatus('sendet'); + try { + await sendOfferRequest({ + name: form.name.trim(), + email: form.email.trim(), + phone: form.phone.trim() || undefined, + quantity: form.quantity ? Number(form.quantity) : null, + message: form.message.trim(), + product: gewaehltesProdukt || undefined, + }); + setStatus('fertig'); + } catch (err) { + setFehler(err instanceof ApiError ? (err.details.length ? err.details : [err.message]) : ['Anfrage konnte nicht gesendet werden.']); + setStatus('formular'); + } + } + + if (status === 'fertig') { + return ( + <> +

Vielen Dank – wir melden uns werktags innerhalb eines Tages mit einem Angebot bei Ihnen.

+ {onClose && ( + + )} + + ); + } + + return ( + <> +

+ Größere Mengen, Sonderformate, Unterschriftenservice oder automatisierte Follow-ups – schildern Sie + kurz Ihr Vorhaben. +

+ {gewaehltesProdukt && ( +
+
Ihre Anfrage betrifft
+
{gewaehltesProdukt}
+
+ )} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +