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 = `
+
+
+
+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}} |
+{{lieferadresse_block}}
+Wie geht es weiter?- Wir prüfen Ihre Bestellung und bereiten die Produktion vor.
- 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' :
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 (
+
+ );
+}
+
+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}
+ : ;
+
+ 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.
+
+ >
+ ));
+}
+
+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 (
+
+ );
+}
+
+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}
+
+ )}
+
+ >
+ );
+}
diff --git a/Webseite/skrift-app/src/components/PreviewPanel.tsx b/Webseite/skrift-app/src/components/PreviewPanel.tsx
new file mode 100644
index 0000000..d1e398a
--- /dev/null
+++ b/Webseite/skrift-app/src/components/PreviewPanel.tsx
@@ -0,0 +1,164 @@
+import { useEffect, useMemo, useState } from 'react';
+
+/**
+ * Live-Vorschau neben dem Formular.
+ *
+ * Die Vorschau ist absichtlich kontingentiert: Jede Generierung kostet einen
+ * Aufruf der Scriptalizer-API (Tageslimit). Der Zähler ist daher Teil der
+ * Bedienung und kein Beiwerk.
+ *
+ * Schriftstück und zugehöriger Umschlag gehören zusammen und werden gemeinsam
+ * gezeigt (Schriftstück oben, Umschlag unten). Die Pfeile blättern je Empfänger.
+ */
+
+export type PreviewDoc = { art: 'brief' | 'umschlag'; svg: string };
+
+/** Ein Empfänger-Dokument: Schriftstück + optional zugehöriger Umschlag. */
+type Gruppe = { brief?: PreviewDoc; umschlag?: PreviewDoc };
+
+type Props = {
+ titel: string;
+ format: string;
+ docs: PreviewDoc[];
+ gesamtDokumente: number;
+ verbleibend: number;
+ maximal: number;
+ laedt: boolean;
+ fehler: string | null;
+ onGenerate: () => void;
+ bereit: boolean;
+};
+
+/** Flache [brief, umschlag, brief, …]-Liste zu Empfänger-Gruppen bündeln. */
+function gruppiere(docs: PreviewDoc[]): Gruppe[] {
+ const gruppen: Gruppe[] = [];
+ for (const d of docs) {
+ if (d.art === 'umschlag') {
+ const letzte = gruppen[gruppen.length - 1];
+ if (letzte && letzte.brief && !letzte.umschlag) letzte.umschlag = d;
+ else gruppen.push({ umschlag: d });
+ } else {
+ gruppen.push({ brief: d });
+ }
+ }
+ return gruppen;
+}
+
+export default function PreviewPanel({
+ titel, format, docs, gesamtDokumente, verbleibend, maximal, laedt, fehler, onGenerate, bereit,
+}: Props) {
+ const [index, setIndex] = useState(0);
+ const [zoomSvg, setZoomSvg] = useState(null);
+ // Die Zoom-Fläche wird auf den Inhaltsbereich begrenzt (unterhalb der
+ // Preiszeile, rechts der Seitenleiste), statt das ganze Fenster zu belegen.
+ const [zoomBox, setZoomBox] = useState({});
+
+ const gruppen = useMemo(() => gruppiere(docs), [docs]);
+
+ function messeBox(): React.CSSProperties {
+ if (typeof window === 'undefined' || window.innerWidth <= 900) return {};
+ const content = document.querySelector('.sk-content');
+ if (!content) return {};
+ const c = content.getBoundingClientRect();
+ const bar = document.querySelector('.sk-pricebar');
+ const top = Math.max(c.top, bar ? bar.getBoundingClientRect().bottom : c.top);
+ return {
+ top: `${top}px`, left: `${c.left}px`, width: `${c.width}px`,
+ height: `${Math.max(0, c.bottom - top)}px`, right: 'auto', bottom: 'auto',
+ };
+ }
+ function openZoom(svg: string) { setZoomBox(messeBox()); setZoomSvg(svg); }
+
+ // Nach einer neuen Generierung wieder vorn beginnen.
+ useEffect(() => { setIndex(0); }, [docs]);
+
+ // Vergrößerte Ansicht: Escape schließt, Resize misst die Fläche neu.
+ useEffect(() => {
+ if (!zoomSvg) return;
+ const zu = (e: KeyboardEvent) => e.key === 'Escape' && setZoomSvg(null);
+ const neu = () => setZoomBox(messeBox());
+ window.addEventListener('keydown', zu);
+ window.addEventListener('resize', neu);
+ return () => { window.removeEventListener('keydown', zu); window.removeEventListener('resize', neu); };
+ }, [zoomSvg]);
+
+ const aktuell = gruppen[index] ?? null;
+ const anzahl = gruppen.length || gesamtDokumente;
+
+ /** Ein einzelnes Dokument (Schriftstück oder Umschlag) mit Beschriftung. */
+ const DocBox = ({ label, svg }: { label: string; svg: string }) => (
+ openZoom(svg)}
+ role="button"
+ tabIndex={0}
+ title="Zum Vergrößern klicken"
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') openZoom(svg); }}
+ >
+ {label}
+ {/* Das SVG stammt aus unserer eigenen Generierung, nicht aus Nutzereingaben. */}
+
+
+ );
+
+ return (
+