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 0000000..16293f1 Binary files /dev/null and b/Webseite/skrift-app/public/fonts/WorkSans-VariableFont_wght.ttf differ diff --git a/Webseite/skrift-app/public/logo.svg b/Webseite/skrift-app/public/logo.svg new file mode 100644 index 0000000..752a2e9 --- /dev/null +++ b/Webseite/skrift-app/public/logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + 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}
+
+ )} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +