Compare commits

10 Commits

Author SHA1 Message Date
Lucas Orth
57d48409e6 Kontakt-CRM, Chargen-Sammeldruck, API-Status & Auto-Generierung
Kontakte/CRM (Directus):
- Neue Collections `contacts` (Leads) + `contact_actions` (Rückruf/Angebot/Mail).
- Hook `skrift-contacts`: Kontakte werden nur auf Anforderung angelegt
  (Auftrag "In Kontakte speichern"); Rückruf-Erinnerung (Zeitplan) und
  Angebots-/Mail-an-mich-Mails; Auto-Löschung nur bei Lead-Status
  "kein_interesse" nach 30 Tagen; optionaler DSGVO-Strip (per Env, Dry-Run).
- Modul `skrift-kontakte`: handytaugliches Board (Anruf via groundwire:,
  Notizen mit Autosave, Durchblättern, CSV-Export, Aktionen als Buttons).

API-Chargen & PDF-Sammeldruck:
- Produktions-Board gruppiert API-Aufträge unter "Neu" zu Chargen
  (Kunde + Typ + Versandtag + Eingangstag), aufklappbar, mit "fällig bis"
  (Versandtag der übernächsten Woche).
- Mehrfachauswahl -> Sammel-PDF (Backend `pdf-lib`, /api/charge/merge-pdf,
  Directus-Proxy /skrift-orders/charge/pdf), Kuverts an Plotter und
  Sammel-Status; PDF- und Kuvert-Reihenfolge nach Auftragsnummer (Kollation).
- Mailer: keine Einzelmail je API-Auftrag mehr, stattdessen Tagesübersicht
  (18:00) pro Kunde, nach Versandtag aufgeschlüsselt.

Status-API & Generierung:
- Public-API-Status liest jetzt production_status (Board) statt nur
  kaufmännischem Status; neues Feld `status_since` in der Antwort.
- Auftrag stempelt `production_status_changed_at` bei jedem Statuswechsel.
- Generierung wird IMMER beim Eingang angestoßen (auch Datei-Produkte mit
  handgeschriebenem Kuvert), nicht nur bei letter/postcard.
- Versandtag im Produktionsdashboard sichtbar.

Rechte: Service-Rolle darf directus_files lesen (Backend lädt PDFs für den
Sammeldruck über /assets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 08:53:08 +02:00
Lucas Orth
c535af5781 API: mehrere Produkte je Zugang (m2m) + Produktwahl im Order-Request
- api_clients bekommt ein m2m-Feld `products` (Mehrfachauswahl) über die neue
  Junction `api_client_products`; das Einzelfeld `product` bleibt als Fallback.
- POST /v1/orders: neues Pflichtfeld `product` (Produkt-Key) wählt aus den
  erlaubten Produkten; bei genau einem optional. Ungültig/fehlend → 400 mit
  Liste der erlaubten Keys.
- POST /v1/clients akzeptiert `products: [id, …]`.
- Doku (Swagger UI) + OpenAPI um das product-Feld ergänzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 11:25:46 +02:00
Lucas Orth
1d5c1a14d4 Public-API, mehrseitige Schriftstücke, manuelle Aufträge & Mailer
Public-API (neu, directus-extension-skrift-api):
- Versionierter /v1-Namespace: POST /v1/orders, GET /v1/orders/:nr (Status
  pending/processing/shipped/cancelled), POST /v1/files (nur PDF, ≤80 MB).
- Auth über X-Api-Key (Authorization ist von Directus reserviert), SHA-256-Hash.
- Produkt-gebunden; Kuvert-only-Produkttyp; Kuvertformat automatisch (A4→DIN Lang, sonst C6);
  kein Platzhalter, kein Kunden-multipage/font_scale; Versandart englisch (single/bulk).
- Selbst gehostete Swagger-UI-Doku (/docs) + OpenAPI (/v1/openapi.json), englisch/technisch.
- Key-Hook (directus-extension-skrift-apikey): erzeugt Key automatisch, zeigt ihn
  einmalig in key_plain.

Mehrseitige Schriftstücke:
- generateLetterPages (Auto-Fluss, [[seitenumbruch]], Leerseiten-Filter).
- Preview/Order-Generierung schreiben letter_NNN_pM.svg; Seiten-Übersicht im Neuauftrag.
- Agent (skrift-agent): Seiten je Brief absteigend an den Plotter (Stapel-Reihenfolge).
- countLetterPages + /api/order/pagecount für die Seiten-Vorschau.

Manuelle Aufträge:
- Schriftgröße (Brief/Kuvert), Signatur auf dem Brief mit X/Y/Größe-Feinjustierung,
  realistische Handschrift bis in den finalen Druck durchgereicht.
- Spalten-Einfügen in der Empfängertabelle (Excel-Spalte je Zelle).

Mailer: Text-Alternative + Reply-To (Zustellbarkeit).

App: Freitext in Musteranfrage, Konfigurator-Reset, Lightbox-Seitenverhältnis +
Bild-Download, Sticky-Vorschau-Fix, Tab-Eingabe + Rechtsbündigkeit, Leerzeilen-Trim.

Bootstrap: neue Felder (font_scale_*, signatures_manual, multi_page, page_count,
realistic, shipping_day, api_client) + Collections api_clients, Produkttyp envelope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 10:01:53 +02:00
Lucas Orth
db419bf8eb Tabulator-Support in Brieftexten + ausstehende Batches
Tabs (Eingabe + Rendering):
- Tab-Taste fügt echten Tabulator ein (App-Konfigurator + Directus
  manueller Druck); \t in beiden Zeichen-Whitelists erlaubt.
- Renderer: Zeilen mit führenden Tabs werden nicht mehr nach Wortbreite
  umgebrochen; Tab-Spruenge an den rechten Rand geclampt; bei Ueberlauf
  rechtsbuendig (Datum ganz rechts, ohne Absturz nach links). Tab-loser
  Text bleibt bit-identisch.

Mailer:
- loadSettings bricht nicht mehr ab, wenn team_email fehlt (Team-
  Empfaenger separat gelesen).
- Eingebaute Fallback-Vorlagen fuer order_created + Status-Mails, damit
  Bestaetigung/Status auch ohne DB-Vorlage zugestellt werden.

Weitere ausstehende Arbeit:
- Abweichender Text je Schriftstueck (#5) in Generierung/Konfigurator.
- Produktions-Board: Format-Sammelgruppen (Bulk) + Bootstrap-Feld.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 16:32:11 +02:00
Lucas Orth
0b2f2ba50a Postkarten-Maße-Fix, Sammelversand-Staffel, Extern-Format, Mustertexte
- render_formats: Upsert statt nur-anlegen → korrekte A6-Maße werden beim
  Bootstrap erzwungen (behebt „Postkarten passen nicht, weder quer noch hoch")
- Sammelversand-Staffel: bis 5 Schriftstücke 1,80 € (neues Preisitem
  sammelversand_klein), ab 6 der reguläre Satz – Backend + App-Option
- Extern-Modus: Postkarten-Format (hoch/quer) wieder wählbar; optionaler
  ?format=-Parameter; kein Auto-Vorbelegen bei mehreren Formaten
- Unbegrenzte Mustertexte: neue Collection muster_texts (Dropdown im
  „Manueller Druck", Du/Sie als Fallback + Seeds)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 07:49:22 +02:00
Lucas Orth
26040bd68f gitignore: Website-/DB-Backups ausschließen
Ein versehentlich im Repo-Root gelandeter Voll-Export (skrift.de__…zip, 436 MB
mit sqldump.sql) soll nicht versioniert werden – enthält PII/DB-Dump/Secrets.
Regel deckt Root-Zips und sqldump-Dateien ab; getrackte Zips in Unterordnern
(z. B. Webseite/skrift-preisrechner.zip) bleiben unberührt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 15:02:34 +02:00
Lucas Orth
3cb93d078e Unterschriftenservice, Extern-Modus, Adressmodell, Alt-Mail, Team-Mails
skrift-app (Konfigurator):
- Datei-Produkte (Unterschriftenservice): Mehrfach-Upload (PDF/Word/Excel),
  komplett preisfrei (keine Preisleiste/Aufpreise/Kostenübersicht/Zahlung)
- Extern-/Etsy-Modus (?extern=1&versand&kuvert&beschriftung&menge): vom Betreiber
  vorkonfiguriert, Kunde füllt nur Empfänger/Text/Rechnungsdaten/Übersicht,
  keine Preise/Zahlung; Menge greift immer (auch >25 / ohne Empfänger); eigener
  Danke-Text
- Adressmodell: zwei feste Adressen (Rechnung/Lieferung) ohne Namen statt
  Dropdown; Auto-Vorbefüllung; Lieferadresse nur bei Sammelversand; abweichende
  Lieferadresse per Default aus; Pflichtfelder erzwungen
- Platzhalter-Tabelle immer im Text-Schritt (auch Sammelversand ohne Kuvert) +
  Excel; Tabellen-Eingabe nur bis 25, „Ab 26"-Hinweis entfernt
- Stammdaten: zwei Adressblöcke + Erfolgsmeldung

Directus/Backend:
- Signatur-Endpunkte /signature-preview + /signature-render (Proxy zum
  signature-service, Marker + Konto-Offsets), Modul-Reiter „Unterschriften"
  (aus Auftrag oder manuell), Tabelle über volle Breite
- Generierung läuft bei jeder Bestellung; Kuverts immer (Datei-Produkt: nur
  Kuvert, DIN Lang als Default); source_files am Auftrag
- Produktions-Board: „Dateien"-Button (hochgeladene Originale, keine SVGs)
- Alt-Mail-Login: Zweit-Mails melden im selben Konto an (skrift-auth)
- Team-Mails schlank + konfigurierbar (email_settings.team_email) für
  Bestellung/Muster/Anfrage; keine Bestellbestätigung bei Datei-Produkten,
  separate Vorlagen unterschriften_eingang/extern_eingang
- Rechnungsadresse wird ins Konto übernommen, wenn dort noch keine steht
- Kunden-Policy: directus_files create/read (Upload); Bootstrap: users
  billing_address/shipping_address/alt_emails, orders.source_files/source=extern,
  signatures marker/offsets, unterschriftenservice input_mode=datei/pricing_mode=formel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 14:52:09 +02:00
Lucas Orth
6232cecdb1 Produktion: Format-Warteschlangen, Sammeldruck, Webhook, Preis-a/e
Produktions-Board:
- Massenauswahl (+ "Alle auswählen") mit Sammel-Druck und Sammel-Status
- Warteschlange je Maschine nach Format gebündelt; Formatabfrage
  (machines.current_format) statt automatischem Anschluss
- resiliente Ladeabfragen (Board bleibt ohne neue Felder nutzbar)

Agent (skrift-agent):
- druckt strikt ein Dokument nach dem anderen (Idle-Gate) -> behebt
  Verlust von Folgejobs während eines laufenden Drucks
- druckt nur Jobs des eingelegten Formats (current_format), kein Auto-Anschluss
- Heartbeat gedrosselt (nur bei Statuswechsel / alle 60 s)

Neues Modul "Manueller Druck" (skrift-neuauftrag):
- interner Auftrag ohne Zahlung, Vorschau, Platzhalter/Excel-Import,
  Felder bleiben nach dem Absenden stehen

Backend/Endpunkte (skrift-orders):
- jobs tragen format/format_label; /enqueue-bulk für Sammeldruck
- Webhook /inbound/:kind (JetFormBuilder) mit Token + tolerantem Body-Parser
- Kleinmengen-Aufschlag je Produkt über Faktoren a (Deckel) und e (Exponent)
- Fix: norm()-Shadowing in calculatePrice (norm is not a function)

Bootstrap:
- jobs.format/format_label, machines.current_format
- products.price_factor_a/e; Multiplikator-Formel auf a/e umgestellt inkl.
  verhaltensneutraler Migration bestehender Formel/Produkte

skrift-app (Konfigurator):
- Zeilenumbrüche im Textfeld erlaubt; Umschlag-Individualtext als Textfeld
- Platzhalter-Tabelle im Text-Schritt immer verfügbar (auch Sammelversand
  ohne Kuvert) inkl. Excel-Upload nur für Platzhalterwerte
- Danke-Seite: Button zurück zu skrift.de
- z-index-Fix: Platzhalter-Tabelle überlagert die Vorschau nicht mehr

WordPress-Preisrechner: a/e-Faktoren in PHP- und JS-Rechnung
Directus: SKRIFT_WEBHOOK_TOKEN in compose/.env.example

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 11:51:24 +02:00
Lucas Orth
f77ddb6a4c Formate zentral aus Directus + Kuvert-/Agent-/Login-Fixes
Formate zentralisiert (Phase 1)
- Neue Collection render_formats (Seitengröße, Ränder, Zeilenhöhe/-limit +
  Plotter-Template) als einzige Quelle. Backend liest sie gecacht (Start +
  stündlich, synchroner Zugriff → keine Latenz) mit eingebauten Defaults als
  Fallback; manueller Trigger via POST /api/formats/refresh.
- Ermöglicht frei definierbare Formate ohne Codeänderung.

Kuvert-Templates
- Werte 1:1 aus dem alten Job-Manager übernommen (falsche/vertauschte Maße
  korrigiert, per Upsert) und je Format die zweite Variante ergänzt
  (individuelle Beschriftung). Druck-Popup schlägt bei freiem Text Variante 2 vor.

Produktions-Agent
- Datei-Endpunkt liefert {files} ohne data-Hülle → eigener get_raw statt des
  data-auspackenden get; Dokumentauswahl positionsbasiert (robust gegen 0-/1-
  basierte Dateinamen).

Bootstrap
- Ungültiger DIRECTUS_TOKEN wird erkannt und fällt auf ADMIN_EMAIL/PASSWORD
  zurück, statt mit einem 401 abzubrechen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 09:05:09 +02:00
Lucas Orth
445ebdec5a Bootstrap: bestehende Berechtigungen angleichen statt überspringen
ensurePermission brach ab, sobald für eine Collection bereits ein
Berechtigungseintrag existierte. Dadurch blieben einmal angelegte, zu enge
Feldlisten dauerhaft bestehen und spätere Erweiterungen kamen nie an --
der Produktions-Agent lief deshalb in 403-Fehler beim Zurückschreiben von
jobs.status und orders.production_status.

Vorhandene Einträge werden jetzt per PATCH auf die im Bootstrap definierten
Felder und Filter nachgezogen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:44:58 +02:00
43 changed files with 4771 additions and 534 deletions

5
.gitignore vendored
View File

@@ -34,6 +34,11 @@ dist/
# ihr dist/ IST der Quellcode und muss ins Repo.
!Docker/directus/extensions/*/dist/
# ── Website-/DB-Backups (NIE ins Repo – enthalten PII, DB-Dump, Secrets) ──
/*.zip
skrift.de__*.zip
*sqldump*.sql
# ── OS / Editor ─────────────────────────────────────────────
.DS_Store
Thumbs.db

View File

@@ -42,6 +42,12 @@ SKRIFT_BACKEND_TOKEN=CHANGE-ME-gleich-wie-backend-API_TOKEN
SKRIFT_PREVIEW_LIMIT=10
# Zieladresse für Angebots-/Musteranfragen
SKRIFT_TEAM_EMAIL=hello@skrift.de
# Shared Secret für den WordPress-Webhook (JetFormBuilder → /skrift-orders/inbound/*).
# Frei wählbarer langer Zufallswert; muss identisch als ?token=... in der
# Webhook-URL stehen. Ohne diesen Wert lehnt der Webhook alle Anfragen ab.
SKRIFT_WEBHOOK_TOKEN=CHANGE-ME-langer-zufallswert
# Signatur-Plotter-Service (intern über das npm-Netz). Default passt zum container_name.
SKRIFT_SIGN_URL=http://skrift-signature-service:8000
# ── PayPal (server-seitige Zahlungsprüfung) ─────────────────
# Client-ID ist öffentlich (auch im Frontend), das Secret bleibt NUR hier.

View File

@@ -56,10 +56,27 @@ async function api(method, path, body) {
return data?.data ?? data;
}
/** Prüft, ob der gesetzte Token wirklich gültig ist. */
async function tokenGueltig() {
try {
const res = await fetch(DIRECTUS_URL + '/users/me?fields=id', {
headers: { Authorization: `Bearer ${token}` },
});
return res.ok;
} catch { return false; }
}
async function login() {
if (token) return;
// Ein mitgegebener DIRECTUS_TOKEN (z. B. versehentlich aus der .env
// übernommen) wird geprüft – sonst schlägt erst der nächste Aufruf mit einem
// wenig hilfreichen 401 fehl.
if (token) {
if (await tokenGueltig()) return;
console.warn(' DIRECTUS_TOKEN ist ungültig – versuche Login mit ADMIN_EMAIL/ADMIN_PASSWORD.');
token = '';
}
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
console.error('Weder DIRECTUS_TOKEN noch ADMIN_EMAIL/ADMIN_PASSWORD gesetzt.');
console.error('DIRECTUS_TOKEN ungültig bzw. nicht gesetzt und ADMIN_EMAIL/ADMIN_PASSWORD fehlen.');
process.exit(1);
}
const res = await fetch(DIRECTUS_URL + '/auth/login', {
@@ -113,8 +130,16 @@ 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 } }),
// Genau zwei feste Adressen je Konto (ohne Namen/Bezeichnung): Rechnung + Lieferung.
// Werden automatisch an der passenden Stelle im Bestellvorgang eingesetzt.
f('billing_address', 'json', { interface: 'input-code', note: 'Rechnungsadresse {street,zip,city,country}.' }),
f('shipping_address', 'json', { interface: 'input-code', note: 'Lieferadresse {street,zip,city,country}.' }),
// Veraltet (frühere Mehrfach-Adressliste) – bleibt für Alt-Daten erhalten.
f('addresses', 'json', { interface: 'list', hidden: true, note: 'Veraltet – ersetzt durch billing_address/shipping_address.', options: { fields: ADDRESS_SUBFIELDS } }),
f('notes', 'text', { interface: 'input-multiline', note: 'Interne Notizen (für den Kunden nicht sichtbar).' }),
// Zusätzliche Login-Mailadressen: Wer sich mit einer dieser Mails per Code
// anmeldet, landet im SELBEN Konto (voller Zugriff). Liste von E-Mails.
f('alt_emails', 'json', { interface: 'tags', note: 'Weitere Login-Mailadressen (gleiches Konto).' }),
{ 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 },
@@ -132,13 +157,19 @@ const collections = [
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('type', ['letter', 'postcard', 'envelope', '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.' }),
// Kleinmengen-Aufschlag je Produkt (fließt als a/e in die Multiplikator-Formel):
// a = Deckel (bei Menge→0 ist der Multiplikator 1 + a), e = Abklingform
// (großes e = Aufschlag fällt schnell; kleines e = bleibt über mittlere Mengen hoch).
// Leer → a = 1, e = 3 (bisheriges Verhalten). Postkarten z. B. a = 1.5, e = 2.
f('price_factor_a', 'float', { interface: 'input', default: 1, note: 'Kleinmengen-Aufschlag: Deckel a (Multiplikator bei Menge→0 = 1 + a). Leer = 1.' }),
f('price_factor_e', 'float', { interface: 'input', default: 3, note: 'Kleinmengen-Aufschlag: Exponent e (größer = fällt schneller ab). Leer = 3.' }),
// 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.' }),
@@ -178,7 +209,7 @@ const collections = [
// 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('multiplier_formula', 'text', { interface: 'input-multiline', note: 'Mengen-Multiplikator (gilt für Privat und Unternehmen). Variablen: qty, norm, base_price, a, e (a/e je Produkt). 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.
@@ -218,7 +249,19 @@ const collections = [
m2o('format', 'formats'),
dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat' }),
dropdown('font', ['tilda', 'alva', 'ellie']),
dropdown('source', ['configurator', 'portal', 'operator'], { default: 'configurator' }),
f('realistic', 'boolean', { interface: 'boolean', default: true, note: 'Natürliche Handschrift-Variation (Jitter/Neigung).' }),
m2o('api_client', 'api_clients', { onDelete: 'SET NULL', note: 'Gesetzt bei Aufträgen über die Public-API.' }),
dropdown('shipping_day', ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'], { note: 'Vom Kunden gewünschter Versandtag (optional, v. a. Public-API).' }),
f('font_scale_letter', 'float', { interface: 'input', default: 1, note: 'Relative Schriftgröße Brief (0.8–1.5, 1 = Standard).' }),
f('font_scale_envelope', 'float', { interface: 'input', default: 1, note: 'Relative Schriftgröße Kuvert (0.8–1.5, 1 = Standard).' }),
f('signatures_manual', 'json', { interface: 'input-code', note: 'Signatur(en) auf dem Brief bei manuellen Aufträgen: { signatures:{signatur1:svg}, offsets:{signatur1:{x,y,scale}} }.' }),
f('multi_page', 'boolean', { interface: 'boolean', default: false, note: 'Mehrseitiges Schriftstück (Auto-Fluss über mehrere A4-Seiten).' }),
f('page_count', 'integer', { interface: 'input', note: 'Seiten je Schriftstück (bei mehrseitig; wird bei der Generierung gesetzt).' }),
// Kontakt-/Akquise-Steuerung (siehe Collection „contacts").
f('keep_contacts', 'boolean', { interface: 'boolean', default: false, note: 'In Kontakte speichern: legt die Empfänger dieses Auftrags als Kontakte/Leads an (nur auf ausdrückliche Anforderung). Schützt zugleich die Empfängerdaten vor dem DSGVO-Strip.' }),
f('contacts_created', 'boolean', { interface: 'boolean', default: false, hidden: true, note: 'Guard: Kontakte für diesen Auftrag wurden bereits angelegt.' }),
f('entries_stripped', 'boolean', { interface: 'boolean', default: false, hidden: true, note: 'Guard: Empfängerdaten wurden nach Ablauf der Aufbewahrung anonymisiert.' }),
dropdown('source', ['configurator', 'portal', 'operator', 'extern', 'api'], { default: 'configurator', note: '„extern" = andere Plattform (z. B. Etsy); „api" = Public-API-Zugang.' }),
dropdown('status', ['entwurf', 'vorbereitet', 'wartet_auf_zahlung', 'bezahlt', 'material_erwartet',
'material_eingegangen', 'in_queue', 'in_produktion', 'gedruckt', 'versendet', 'abgeschlossen', 'storniert'],
{ default: 'entwurf' }),
@@ -229,9 +272,11 @@ const collections = [
f('kuvert_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Umschläge gedruckt (vom Agent gesetzt).' }),
// Zeitpunkt des Versands – Basis fürs Auto-Archiv (nach 14 Tagen).
{ field: 'versandt_am', type: 'timestamp', meta: { interface: 'datetime', note: 'Wird beim Wechsel auf „versendet" gesetzt.' }, schema: { is_nullable: true } },
{ field: 'production_status_changed_at', type: 'timestamp', meta: { interface: 'datetime', note: 'Zeitpunkt der letzten Produktionsstatus-Änderung (für „status_since" der Public-API).' }, schema: { is_nullable: true } },
dropdown('shipping_type', ['einzeln', 'sammel'], { default: 'sammel' }),
f('needs_envelope', 'boolean', { interface: 'boolean', default: false }),
dropdown('envelope_labeling', ['empfaenger', 'freitext', 'keine']),
f('envelope_text', 'text', { interface: 'input-multiline', note: 'Individueller Kuverttext (bei envelope_labeling = freitext).' }),
dropdown('envelope_format', ['c6', 'dinlang'], { note: 'Automatisch aus Format.' }),
dropdown('motif_mode', ['katalog', 'upload', 'ohne', 'eigene_karten']),
m2o('motif', 'motifs'),
@@ -242,8 +287,10 @@ const collections = [
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).' },
meta: { interface: 'file', special: ['file'], note: 'Hochgeladenes Dokument (Datei-Produkte) – erstes/primäres.' },
schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' },
// Alle hochgeladenen Dokumente (mehrere PDF/Word/Excel möglich) als Datei-ID-Liste.
f('source_files', 'json', { interface: 'tags', note: 'Alle hochgeladenen Dokumente (Datei-IDs).' }),
// 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).' }),
@@ -299,6 +346,7 @@ const collections = [
f('country', 'string', { interface: 'input' }),
f('free_text', 'text', { interface: 'input-multiline', note: 'Alternativ zur strukturierten Adresse.' }),
f('placeholders', 'json', { interface: 'list', note: 'Zusätzliche Platzhalterwerte.' }),
f('text', 'text', { interface: 'input-multiline', note: 'Abweichender Brieftext NUR für dieses Schriftstück (leer = Standardtext des Auftrags).' }),
] },
{ collection: 'order_addons', meta: { icon: 'add_shopping_cart', note: 'Junction Auftrag ↔ Zusatzleistung.', hidden: true }, fields: [
@@ -317,6 +365,10 @@ const collections = [
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: {} },
// Aktuell eingelegtes Format: Der Agent druckt NUR Jobs mit passendem
// format-Schlüssel und springt nie selbstständig auf ein anderes Format.
// Leer = Maschine wartet, bis im Board ein Format als eingelegt gewählt wird.
f('current_format', 'string', { interface: 'input', note: 'Aktuell eingelegtes Format (Agent druckt nur passende Jobs). Leer = wartet auf Formatwahl.' }),
f('notes', 'text', { interface: 'input-multiline' }),
] },
@@ -349,12 +401,36 @@ const collections = [
f('rotation', 'integer', { interface: 'input', default: 0 }),
] },
// Zentrale Rendering-Formate: Seitengröße, Ränder, Zeilenhöhe/-limit. Das
// Backend liest diese (gecacht) statt hartkodierter Werte – so lassen sich
// eigene Formate anlegen, ohne Code zu ändern (eine Quelle für App + Backend).
{ collection: 'render_formats', meta: { icon: 'crop_free', note: 'Rendering-Formate (Seitengröße, Ränder, Zeilenlimit) – zentral fürs Backend.' }, fields: [
f('key', 'string', { interface: 'input', unique: true, required: true, note: 'Technischer Schlüssel, z. B. a4, a6p, din_lang, oder ein eigener.' }),
f('name', 'string', { interface: 'input', required: true }),
dropdown('kind', ['brief', 'umschlag'], { default: 'brief' }),
f('page_width_mm', 'float', { interface: 'input', required: true, note: 'Seitenbreite in mm.' }),
f('page_height_mm', 'float', { interface: 'input', required: true, note: 'Seitenhöhe in mm.' }),
f('margin_top_mm', 'float', { interface: 'input', default: 25 }),
f('margin_left_mm', 'float', { interface: 'input', default: 20 }),
f('margin_right_mm', 'float', { interface: 'input', note: 'Optional (v. a. Querformat).' }),
f('line_height_factor', 'float', { interface: 'input', default: 1.35, note: 'Zeilenhöhe = Schriftgröße × Faktor.' }),
f('line_limit', 'integer', { interface: 'input', note: 'Max. Zeilen. Leer = automatisch aus Höhe.' }),
// Zugehöriges Plotter-Druck-Template (Platzierung an der Maschine).
m2o('print_template', 'format_templates'),
f('active', 'boolean', { interface: 'boolean', default: true }),
] },
{ 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.' }),
// Format-Schlüssel des Jobs (Brief: a4/a6…, Kuvert: dinlang/c6). Grundlage
// für die Format-Warteschlange: der Agent druckt nur Jobs, deren Format dem
// an der Maschine eingelegten Format entspricht.
f('format', 'string', { interface: 'input', note: 'Format-Schlüssel (Brief: a4/a6…, Kuvert: dinlang/c6). Grundlage der Format-Warteschlange.' }),
f('format_label', 'string', { interface: 'input', note: 'Lesbare Formatbezeichnung für die Warteschlangen-Ansicht.' }),
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).
@@ -375,6 +451,33 @@ const collections = [
{ field: 'timestamp', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// API-Zugänge für Kunden (Public-API). Keys werden nur als SHA-256-Hash gespeichert.
{ collection: 'api_clients', meta: { icon: 'vpn_key', note: 'API-Zugänge für Kunden (Public-API).' }, fields: [
f('name', 'string', { interface: 'input', required: true, note: 'Bezeichnung, z. B. Firmenname.' }),
f('key_plain', 'string', { interface: 'input', note: 'Wird beim Anlegen automatisch erzeugt und EINMALIG angezeigt – jetzt kopieren! Verschwindet bei der nächsten Änderung. Nur der Hash bleibt gespeichert.' }),
f('key_prefix', 'string', { interface: 'input', note: 'Erkennungspräfix des Keys (nur Anzeige).' }),
f('key_hash', 'string', { interface: 'input', note: 'SHA-256 des API-Keys – automatisch gesetzt. Nicht manuell ändern.' }),
m2o('customer', 'directus_users', { note: 'Verknüpftes Kundenkonto (optional).' }),
// Mehrere erlaubte Produkte (m2m). Die Order gibt per „product" an, welches genutzt wird.
{ field: 'products', type: 'alias', meta: { interface: 'list-m2m', special: ['m2m'], note: 'Erlaubte Produkte für diesen Zugang (Mehrfachauswahl).' }, schema: null },
// Einzelprodukt (optional, Fallback/alt) – wird nur genutzt, wenn keine m2m-Produkte gesetzt sind.
m2o('product', 'products', { note: 'Einzelprodukt (Fallback). Für mehrere Produkte „products" nutzen.' }),
f('active', 'boolean', { interface: 'boolean', default: true }),
f('webhook_url', 'string', { interface: 'input', note: 'Optionaler Webhook für Statusänderungen (derzeit ungenutzt – Kunde pollt).' }),
f('config', 'json', { interface: 'input-code', note: 'Kundenspezifische Konfiguration (optional).' }),
f('last_used', 'timestamp', { interface: 'datetime' }),
// AGB/Vertrag – einmalige Zustimmung beim Onboarding festhalten (Nachweis).
f('terms_version', 'string', { interface: 'input', note: 'Akzeptierte AGB-Version, z. B. „2026-01".' }),
f('terms_accepted_at', 'timestamp', { interface: 'datetime', note: 'Zeitpunkt der AGB-/Vertragszustimmung.' }),
f('contract_ref', 'string', { interface: 'input', note: 'Referenz zum unterschriebenen Vertrag (Aktenzeichen/Link).' }),
] },
// Junction API-Zugang ↔ Produkt (m2m für api_clients.products).
{ collection: 'api_client_products', meta: { icon: 'link', note: 'Junction API-Zugang ↔ Produkt.', hidden: true }, fields: [
m2o('api_client', 'api_clients', { onDelete: 'CASCADE' }),
m2o('product', 'products', { onDelete: 'CASCADE' }),
] },
// 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' }),
@@ -383,6 +486,12 @@ const collections = [
{ 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' },
// Voreingestellte Platzierung relativ zum Marker im PDF (§§SIG1§§ …).
// Wird im Signatur-Modul als Vorgabe geladen, dort manuell überschreibbar.
f('marker_key', 'string', { interface: 'input', default: 'SIG1', note: 'Marker-Token im PDF (ohne §§), z. B. SIG1.' }),
f('offset_x_mm', 'float', { interface: 'input', default: 0, note: 'Versatz X zum Marker (mm).' }),
f('offset_y_mm', 'float', { interface: 'input', default: 0, note: 'Versatz Y zum Marker (mm).' }),
f('width_mm', 'float', { interface: 'input', default: 40, note: 'Breite der Signatur (mm).' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
@@ -420,6 +529,7 @@ const collections = [
{ 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('team_email', 'string', { interface: 'input', note: 'Interne Benachrichtigungen (neue Bestellung/Muster/Anfrage). Leer = keine Team-Mails.' }),
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}}).' }),
@@ -435,6 +545,15 @@ const collections = [
f('text_sie', 'text', { interface: 'input-multiline', note: 'Standard-Mustertext (Sie-Form). Platzhalter [[abschnitt]] wird im Druck-Dialog gefüllt.' }),
] },
// Frei anlegbare Mustertexte (beliebig viele) – erscheinen im „Manueller Druck"-
// Dropdown. Ersetzt langfristig die feste Du/Sie-Vorgabe (die als Fallback bleibt).
{ collection: 'muster_texts', meta: { icon: 'science', note: 'Frei anlegbare Mustertexte für den manuellen Druck.' }, fields: [
f('label', 'string', { interface: 'input', required: true, note: 'Bezeichnung im Dropdown.' }),
f('text', 'text', { interface: 'input-multiline', note: 'Mustertext. Platzhalter [[…]] erlaubt.' }),
f('sort', 'integer', { interface: 'input' }),
f('active', 'boolean', { interface: 'boolean', default: true }),
] },
// 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' }),
@@ -466,6 +585,45 @@ const collections = [
f('used', 'boolean', { interface: 'boolean', default: false }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
// Kontakte/Leads – Empfänger gesendeter Schriftstücke. Werden bei Versand
// automatisch angelegt (Hook) und nach 30 Tagen wieder gelöscht, außer der
// Auftrag ist als „Kontakte behalten" (Akquise) markiert. Telefon/E-Mail sind
// manuell zu pflegen (Empfänger liefern sie nicht mit). CSV-Import/-Export
// über die normale Collection-Seite.
{ collection: 'contacts', meta: { icon: 'contacts', note: 'Kontakte/Leads – Empfänger gesendeter Schriftstücke.' }, fields: [
f('salutation', 'string', { interface: 'input' }),
f('first_name', 'string', { interface: 'input' }),
f('last_name', 'string', { interface: 'input' }),
f('company', 'string', { interface: 'input' }),
f('street', 'string', { interface: 'input' }),
f('house_no', 'string', { interface: 'input' }),
f('zip', 'string', { interface: 'input' }),
f('city', 'string', { interface: 'input' }),
f('country', 'string', { interface: 'input' }),
f('email', 'string', { interface: 'input', note: 'Manuell nachtragen.' }),
f('phone', 'string', { interface: 'input', note: 'Manuell nachtragen. Klick im Kontakte-Modul öffnet GroundWire.' }),
f('notes', 'text', { interface: 'input-multiline', note: 'Anruf-/Lead-Notizen.' }),
dropdown('lead_status', ['neu', 'kontaktiert', 'interessiert', 'kein_interesse', 'kunde'], { default: 'neu', note: 'Nur Kontakte mit „kein_interesse" werden nach 30 Tagen automatisch gelöscht; alle anderen bleiben.' }),
m2o('source_order', 'orders', { onDelete: 'SET NULL', note: 'Auftrag, aus dem der Kontakt stammt.' }),
m2o('source_entry', 'order_entries', { onDelete: 'SET NULL', note: 'Empfängerzeile, aus der der Kontakt stammt.' }),
f('sent_at', 'timestamp', { interface: 'datetime', note: 'Versandzeitpunkt – Grundlage für die 30-Tage-Löschung.' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
] },
// Standardaktionen im Kontakte-Modul (Rückruf-Wiedervorlage, Angebot senden).
// „angebot" verschickt sofort die Eckdaten an die interne Adresse; „rueckruf"
// löst am fälligen Tag früh eine Erinnerungs-Mail aus (Hook skrift-contacts).
{ collection: 'contact_actions', meta: { icon: 'task_alt', hidden: true, note: 'Aktionen/Wiedervorlagen je Kontakt (Rückruf, Angebot).' }, fields: [
m2o('contact', 'contacts', { onDelete: 'CASCADE' }),
dropdown('type', ['rueckruf', 'angebot', 'mail'], { default: 'rueckruf' }),
f('due_date', 'timestamp', { interface: 'datetime', note: 'Fälligkeit (nur Rückruf) – Basis der Erinnerungs-Mail.' }),
f('note', 'text', { interface: 'input-multiline', note: 'Notiz zur Aktion (optional).' }),
dropdown('status', ['offen', 'erledigt'], { default: 'offen' }),
f('notified', 'boolean', { interface: 'boolean', default: false, note: 'Erinnerungs-Mail bereits verschickt (verhindert Wiederholung).' }),
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
] },
];
// ── Schema anlegen ──────────────────────────────────────────────────────────
@@ -531,6 +689,9 @@ async function applySchema() {
try {
await api('PATCH', '/relations/order_addons/order', { meta: { one_field: 'addons', junction_field: 'price_item' } });
await api('PATCH', '/relations/order_addons/price_item', { meta: { junction_field: 'order' } });
// m2m api_clients.products ↔ products über api_client_products
await api('PATCH', '/relations/api_client_products/api_client', { meta: { one_field: 'products', junction_field: 'product' } });
await api('PATCH', '/relations/api_client_products/product', { meta: { junction_field: 'api_client' } });
log('m2m verdrahtet');
} catch (e) { console.warn(' m2m-Verdrahtung übersprungen (bitte melden):', e.message); }
@@ -557,8 +718,10 @@ async function seedItem(collection, uniqueField, item) {
async function applySeed() {
console.log('› Seed');
const products = [
{ 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: 'briefe', name: 'Briefe', type: 'letter', visibility: 'alle', pricing_mode: 'formel', base_price: 4.0, norm_qty: 100, price_factor_a: 1, price_factor_e: 3, active: true, sort: 1 },
// Postkarten: höherer Deckel + sanfterer Exponent, damit kleine/mittlere
// Mengen nicht zu günstig werden (Fixaufwand relativ größer als bei Briefen).
{ key: 'postkarten', name: 'Postkarten', type: 'postcard', visibility: 'alle', pricing_mode: 'formel', base_price: 1.3, norm_qty: 100, price_factor_a: 1.5, price_factor_e: 2, 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
@@ -568,6 +731,21 @@ async function applySeed() {
];
const productByKey = {};
for (const p of products) productByKey[p.key] = await seedItem('products', 'key', p);
// Unterschriftenservice muss im Datei-Modus laufen (Text-Schritt = Upload) UND
// bepreisbar sein (formel), damit freigeschaltete Kunden ihn regulär bestellen
// können. Auch für bestehende Datensätze erzwingen (seedItem aktualisiert nicht).
try {
const us = productByKey.unterschriftenservice;
if (us?.id) {
const patch = {};
if (us.input_mode !== 'datei') patch.input_mode = 'datei';
if (us.pricing_mode !== 'formel') patch.pricing_mode = 'formel';
if (Object.keys(patch).length) {
await api('PATCH', `/items/products/${us.id}`, patch);
log('unterschriftenservice →', JSON.stringify(patch));
}
}
} catch { /* nicht kritisch */ }
const formats = [
{ product: productByKey.briefe.id, key: 'a4', name: 'A4', active: true },
@@ -581,6 +759,7 @@ async function applySeed() {
{ key: 'porto_inland', label: 'Porto Inland', category: 'versand', price: 2.2, unit: 'pro_stueck', active: true },
{ key: 'porto_ausland', label: 'Porto Ausland', category: 'versand', price: 2.5, unit: 'pro_stueck', active: true },
{ key: 'sammelversand', label: 'Sammelversand', category: 'versand', price: 4.95, unit: 'einmalig', active: true },
{ key: 'sammelversand_klein', label: 'Sammelversand (bis 5 Stück)', category: 'versand', price: 1.80, unit: 'einmalig', active: true },
{ key: 'kuvert', label: 'Kuvert', category: 'kuvert', price: 0.5, unit: 'pro_stueck', active: true },
{ key: 'beschriftung', label: 'Kuvert-Beschriftung', category: 'beschriftung', price: 0.5, unit: 'pro_stueck', active: true },
{ key: 'schreibservice', label: 'Schreibservice', category: 'zusatzleistung', price: 0, unit: 'einmalig', active: true },
@@ -605,12 +784,30 @@ async function applySeed() {
log('seed singleton pricing_settings');
}
// Gemeinsame Mengen-Multiplikator-Formel: nur setzen, wenn leer (Edits bleiben
// erhalten). `norm` kommt je Produkt aus products.norm_qty.
// erhalten). `norm` kommt je Produkt aus products.norm_qty, a/e je Produkt.
const CANON_MULT = '(qty >= norm) ? 1 : (1 + a * pow(1 - qty / norm, e))';
if (!ps || !ps.multiplier_formula) {
await api('PATCH', '/items/pricing_settings', {
multiplier_formula: '(qty >= norm) ? 1 : (1 + 2 * pow(1 - qty / norm, 3))',
});
await api('PATCH', '/items/pricing_settings', { multiplier_formula: CANON_MULT });
log('seed multiplier-formel');
} else {
// Migration: eine bestehende Formel mit fest verdrahtetem Koeffizient/Exponent
// (1 + N * pow(1 - qty / norm, M)) auf die a/e-Variante umstellen und die
// Altwerte in die Produkte übernehmen – Verhalten bleibt exakt erhalten.
const m = String(ps.multiplier_formula).match(
/^\(\s*qty\s*>=\s*norm\s*\)\s*\?\s*1\s*:\s*\(\s*1\s*\+\s*([\d.]+)\s*\*\s*pow\(\s*1\s*-\s*qty\s*\/\s*norm\s*,\s*([\d.]+)\s*\)\s*\)$/,
);
if (m) {
const aOld = Number(m[1]); const eOld = Number(m[2]);
await api('PATCH', '/items/pricing_settings', { multiplier_formula: CANON_MULT });
const prods = await api('GET', '/items/products?fields=id,price_factor_a,price_factor_e&limit=-1');
for (const p of (prods || [])) {
const patch = {};
if (p.price_factor_a == null) patch.price_factor_a = aOld;
if (p.price_factor_e == null) patch.price_factor_e = eOld;
if (Object.keys(patch).length) await api('PATCH', `/items/products/${p.id}`, patch);
}
log(`migriere multiplier-formel → a/e (Altwerte a=${aOld}, e=${eOld} in Produkte übernommen)`);
}
}
// E-Mail-Vorlagen (nur anlegen, wenn noch nicht vorhanden – Edits bleiben erhalten).
@@ -661,18 +858,52 @@ async function applySeed() {
body: `<p>${G}</p><p>Ihr Auftrag <strong>{{order_id}}</strong> ist abgeschlossen. Wir freuen uns auf Ihre nächste Bestellung.</p>` },
{ key: 'storniert', label: 'Status: storniert', subject: '{{order_id}} wurde storniert', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>Ihr Auftrag <strong>{{order_id}}</strong> wurde storniert. Bei Fragen sind wir gerne für Sie da.</p>{{reason}}` },
// Separate, frei editierbare Mails für Unterschriftenservice- und Extern-Bestellungen.
{ key: 'unterschriften_eingang', label: 'Unterschriften: Eingang', subject: 'Ihr Unterschriften-Auftrag {{order_number}} ist eingegangen', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>vielen Dank – Ihr Auftrag <strong>{{order_number}}</strong> für den Unterschriftenservice ist bei uns eingegangen. Wir prüfen Ihr Dokument und melden uns mit den nächsten Schritten.</p>` },
{ key: 'extern_eingang', label: 'Externe Bestellung: Eingang', subject: 'Ihre Bestellung {{order_number}} ist eingegangen', append_signature: true, attach_agb: false,
body: `<p>${G}</p><p>vielen Dank – Ihre Bestellung <strong>{{order_number}}</strong> ist bei uns eingegangen und geht direkt in die Produktion.</p>` },
];
for (const t of templates) await seedItem('email_templates', 'key', { enabled: true, ...t });
// Druck-Templates (aus der bisherigen config.json der Produktions-App).
// Druck-Templates – Werte 1:1 aus der config.json des alten Job-Managers.
// Je Format gibt es zwei Varianten (v1/v2); die zweite ist die zusätzliche
// Platzierung (u. a. für individuelle Kuvert-Beschriftung). Werte werden per
// Upsert gesetzt, damit falsche Altwerte beim Bootstrap korrigiert werden.
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 },
{ key: 'a4_single', name: 'A4 Brief', type: 'brief', width: 8.27, height: 11.69, xpos: 0.0, ypos: -0.3, scale: 1.0, rotation: 0 },
{ key: 'a6_v1', name: 'A6 Hochformat – Variante 1', type: 'brief', width: 4.1, height: 5.8, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 },
{ key: 'a6_v2', name: 'A6 Hochformat – Variante 2', type: 'brief', width: 4.1, height: 5.8, xpos: 4.0, ypos: -0.3, scale: 1.0, rotation: 90 },
{ key: 'a6l_v1', name: 'A6 Querformat – Variante 1', type: 'brief', width: 5.8, height: 4.1, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 },
{ key: 'a6l_v2', name: 'A6 Querformat – Variante 2', type: 'brief', width: 5.8, height: 4.1, xpos: 4.0, ypos: -0.3, scale: 1.0, rotation: 90 },
// DIN Lang – v1 = Empfängeradresse, v2 = individuelle Beschriftung.
{ key: 'kuvert_a_v1', name: 'Kuvert DIN Lang – Variante 1', type: 'umschlag', width: 4.33, height: 8.27, xpos: 4.0, ypos: -0.2, scale: 1.0, rotation: 90 },
{ key: 'kuvert_a_v2', name: 'Kuvert DIN Lang – Variante 2', type: 'umschlag', width: 3.9, height: 8.27, xpos: 4.49, ypos: -0.2, scale: 1.0, rotation: 90 },
// C6 – v1 = Empfängeradresse, v2 = individuelle Beschriftung.
{ key: 'kuvert_b_v1', name: 'Kuvert C6 – Variante 1', type: 'umschlag', width: 4.49, height: 6.38, xpos: 4.2, ypos: -0.2, scale: 1.0, rotation: 90 },
{ key: 'kuvert_b_v2', name: 'Kuvert C6 – Variante 2', type: 'umschlag', width: 4.33, height: 6.38, xpos: 4.0, ypos: -0.2, scale: 1.0, rotation: 90 },
];
for (const t of formatTemplates) await seedItem('format_templates', 'key', t);
for (const t of formatTemplates) {
const vorhanden = await api('GET', `/items/format_templates?filter[key][_eq]=${encodeURIComponent(t.key)}&limit=1&fields=id`);
if (vorhanden && vorhanden.length) await api('PATCH', `/items/format_templates/${vorhanden[0].id}`, t);
else { await api('POST', '/items/format_templates', t); log('seed + format_templates.' + t.key); }
}
// Rendering-Formate (zentral fürs Backend). Werte = bisherige Defaults.
const renderFormats = [
{ key: 'a4', name: 'A4', kind: 'brief', page_width_mm: 210, page_height_mm: 297, margin_top_mm: 25, margin_left_mm: 20, line_height_factor: 1.35, line_limit: 27, active: true },
{ key: 'a6p', name: 'A6 Hochformat', kind: 'brief', page_width_mm: 105, page_height_mm: 148, margin_top_mm: 8, margin_left_mm: 8, line_height_factor: 1.3, line_limit: 14, active: true },
{ key: 'a6l', name: 'A6 Querformat', kind: 'brief', page_width_mm: 148, page_height_mm: 105, margin_top_mm: 8, margin_left_mm: 8, margin_right_mm: 14, line_height_factor: 1.2, line_limit: 11, active: true },
{ key: 'din_lang', name: 'DIN Lang Kuvert', kind: 'umschlag', page_width_mm: 220, page_height_mm: 110, margin_top_mm: 50, margin_left_mm: 10, line_height_factor: 1.2, active: true },
{ key: 'c6', name: 'C6 Kuvert', kind: 'umschlag', page_width_mm: 162, page_height_mm: 114, margin_top_mm: 60, margin_left_mm: 10, line_height_factor: 1.2, active: true },
];
// Upsert (nicht nur anlegen), damit falsche Alt-Maße korrigiert werden – z. B.
// Postkarten (a6p/a6l), die sonst nicht aufs Kartenformat passen.
for (const rf of renderFormats) {
const vorhanden = await api('GET', `/items/render_formats?filter[key][_eq]=${encodeURIComponent(rf.key)}&limit=1&fields=id`);
if (vorhanden && vorhanden.length) await api('PATCH', `/items/render_formats/${vorhanden[0].id}`, rf);
else { await api('POST', '/items/render_formats', rf); log('seed + render_formats.' + rf.key); }
}
// email_settings (Singleton) – Signatur nur setzen, wenn leer.
const es = await api('GET', '/items/email_settings');
@@ -680,6 +911,7 @@ async function applySeed() {
await api('PATCH', '/items/email_settings', {
sender_name: 'Skrift',
sender_email: (process.env.EMAIL_FROM || 'noreply@skrift.de').trim(),
team_email: (process.env.SKRIFT_TEAM_EMAIL || 'hello@skrift.de').trim(),
signature: '<p>Mit freundlichen Grüßen<br>Ihr Skrift-Team</p>',
portal_url: (process.env.CORS_ORIGIN || 'https://app.skrift.de').split(',')[0].trim(),
company_footer: 'Skrift | Hundscheiderweg 4 | 66679 Losheim am See | www.skrift.de',
@@ -696,13 +928,23 @@ async function applySeed() {
});
log('seed singleton muster_settings');
}
// muster_texts – Starteinträge (Du/Sie) nur anlegen, wenn noch keine da sind.
const mtx = await api('GET', '/items/muster_texts?limit=1');
if (!mtx || !mtx.length) {
await api('POST', '/items/muster_texts', { label: 'Sie-Form', sort: 1, active: true,
text: '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' });
await api('POST', '/items/muster_texts', { label: 'Du-Form', sort: 2, active: true,
text: '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' });
log('seed muster_texts');
}
}
// ── Rollen / Policies / Rechte (Directus 11) ────────────────────────────────
const BIZ = ['products', 'formats', 'motifs', 'price_items', 'pricing_settings',
'vouchers', 'voucher_redemptions', 'orders', 'order_entries', 'order_addons', 'jobs', 'status_history',
'offer_requests', 'signatures', 'customer_discounts', 'customer_products',
'email_templates', 'email_settings', 'muster_settings', 'machines', 'format_templates', 'test_prints'];
'offer_requests', 'signatures', 'customer_discounts', 'customer_products', 'contacts',
'email_templates', 'email_settings', 'muster_settings', 'muster_texts', 'machines', 'format_templates', 'render_formats', 'test_prints'];
// Kunde = Login-Konto: Aufträge hängen direkt am Nutzer.
const OWN_ORDER = { customer: { _eq: '$CURRENT_USER' } };
const OWN_VIA_ORDER = { order: { customer: { _eq: '$CURRENT_USER' } } };
@@ -743,10 +985,28 @@ async function ensureAccess(roleId, policyId) {
await api('POST', '/access', { role: roleId, policy: policyId });
log('access +', `${roleId || 'public'} ↔ policy`);
}
/**
* Legt eine Berechtigung an ODER gleicht eine bestehende an.
*
* Wichtig: Früher wurde ein vorhandener Eintrag unangetastet gelassen. Dadurch
* blieben einmal angelegte, zu enge Feldlisten für immer bestehen – spätere
* Erweiterungen (z. B. production_status für den Produktions-Agent) kamen nie
* an und führten zu 403-Fehlern. Der Bootstrap ist die Quelle der Wahrheit,
* deshalb werden Felder und Filter jetzt aktiv nachgezogen.
*/
async function ensurePermission(policyId, collection, action, fields = ['*'], permissions = {}) {
const existing = await api('GET',
`/permissions?filter[policy][_eq]=${policyId}&filter[collection][_eq]=${collection}&filter[action][_eq]=${action}&limit=1`);
if (existing && existing.length) return;
`/permissions?filter[policy][_eq]=${policyId}&filter[collection][_eq]=${collection}&filter[action][_eq]=${action}&limit=1&fields=id,fields,permissions`);
if (existing && existing.length) {
const cur = existing[0];
const gleich = JSON.stringify(cur.fields ?? null) === JSON.stringify(fields)
&& JSON.stringify(cur.permissions ?? {}) === JSON.stringify(permissions);
if (!gleich) {
await api('PATCH', `/permissions/${cur.id}`, { fields, permissions });
log('perm ~', `${collection}.${action}`);
}
return;
}
await api('POST', '/permissions', { policy: policyId, collection, action, fields, permissions, validation: {} });
}
@@ -770,12 +1030,12 @@ async function applyPermissions() {
await ensureAccess(custRole.id, custPol.id);
// 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', 'email', 'first_name', 'last_name', 'company', 'phone', 'person_type', 'addresses', 'billing_address', 'shipping_address', 'alt_emails'],
{ 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'],
['first_name', 'last_name', 'company', 'phone', 'addresses', 'billing_address', 'shipping_address'],
{ 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);
@@ -786,6 +1046,9 @@ async function applyPermissions() {
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');
// Datei-Upload: Motiv-Bilder und hochzuladende Dokumente (Unterschriftenservice).
await ensurePermission(custPol.id, 'directus_files', 'create');
await ensurePermission(custPol.id, 'directus_files', 'read', ['*'], { uploaded_by: { _eq: '$CURRENT_USER' } });
// Public (Gast): nur aktive Kataloge lesen
const pubPol = await ensurePolicy('Public (Skrift)', { app: false });
@@ -799,7 +1062,7 @@ async function applyPermissions() {
const svcPol = await ensurePolicy('Service', { app: false });
await ensureAccess(svcRole.id, svcPol.id);
for (const col of ['orders', 'order_entries', 'order_addons', 'jobs', 'products', 'formats',
'motifs', 'price_items', 'machines', 'format_templates'])
'motifs', 'price_items', 'machines', 'format_templates', 'render_formats'])
await ensurePermission(svcPol.id, col, 'read');
await ensurePermission(svcPol.id, 'orders', 'update',
['status', 'artifact_path', 'payment_status', 'payment_ref', 'production_status', 'brief_gedruckt', 'kuvert_gedruckt']);
@@ -809,6 +1072,9 @@ async function applyPermissions() {
// Agent meldet Maschinenzustand (Heartbeat).
await ensurePermission(svcPol.id, 'machines', 'update', ['status', 'last_seen']);
await ensurePermission(svcPol.id, 'status_history', 'create');
// Hochgeladene Druckdateien lesen (Backend führt PDFs für den Charge-Sammeldruck
// zusammen und lädt sie dafür über /assets).
await ensurePermission(svcPol.id, 'directus_files', 'read');
console.log(' Hinweis: Für Service einen Nutzer in Rolle "Service" anlegen und dessen',
'statischen Token in .env von Backend/Produktion eintragen.');
@@ -866,11 +1132,11 @@ async function organizeNav() {
};
// Hauptebene
for (const col of ['orders', 'jobs', 'vouchers', 'status_history'])
for (const col of ['orders', 'jobs', 'vouchers', 'status_history', 'contacts'])
await patch(col, { group: null, hidden: false });
// Unter „Einstellungen"
for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', 'offer_requests',
'email_templates', 'email_settings', 'muster_settings', 'machines', 'format_templates', 'test_prints'])
'email_templates', 'email_settings', 'muster_settings', 'muster_texts', 'machines', 'format_templates', 'render_formats', '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).
@@ -907,6 +1173,7 @@ async function applyDisplays() {
['customer_products', 'product', '{{name}}'],
['offer_requests', 'customer', '{{first_name}} {{last_name}}'],
['voucher_redemptions', 'voucher', '{{code}}'],
['contacts', 'source_order', '{{order_number}}'],
];
for (const [collection, field, template] of displays) {
try {

View File

@@ -80,6 +80,13 @@ services:
SKRIFT_PREVIEW_LIMIT: ${SKRIFT_PREVIEW_LIMIT:-10}
SKRIFT_TEAM_EMAIL: ${SKRIFT_TEAM_EMAIL:-hello@skrift.de}
# Shared Secret für den WordPress-Webhook (JetFormBuilder → /skrift-orders/inbound/*).
# Muss identisch als ?token=... in der Webhook-URL stehen.
SKRIFT_WEBHOOK_TOKEN: ${SKRIFT_WEBHOOK_TOKEN}
# Signatur-Plotter-Service (Vorschau/Render im Unterschriftenservice-Modul).
SKRIFT_SIGN_URL: ${SKRIFT_SIGN_URL:-http://skrift-signature-service:8000}
# PayPal – server-seitige Zahlungsprüfung (Secret liegt NUR hier, nie im Frontend)
PAYPAL_CLIENT_ID: ${PAYPAL_CLIENT_ID}
PAYPAL_CLIENT_SECRET: ${PAYPAL_CLIENT_SECRET}

View File

@@ -0,0 +1,572 @@
/**
* Skrift – Public-API (v1) für Kunden
* ===========================================================================
* Eigener, versionierter API-Namespace, gedacht hinter einer eigenen Domain
* (z. B. api.skrift.de → NPM proxyt auf /skrift-api/*). Der Kunde sieht NIE das
* Directus-Dashboard.
*
* POST /v1/orders – Auftrag anlegen (Brief/Postkarte: Text ODER Datei)
* GET /v1/orders/:nummer – Status abfragen (wartend | in_bearbeitung | versendet)
* POST /v1/files – Druckdatei hochladen (Base64) → file_id
* GET /v1/openapi.json – OpenAPI-Spezifikation
* GET /docs – interaktive Doku (Scalar)
* POST /v1/clients – (Admin) neuen API-Zugang + Key erzeugen
*
* Auth: Header `X-Api-Key: <key>` (NICHT Authorization – den reserviert Directus).
* Gespeichert wird nur der SHA-256-Hash (api_clients). Jeder Zugang ist an GENAU
* EIN Produkt gebunden.
*/
import crypto from 'node:crypto';
import { Readable } from 'node:stream';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
// Verzeichnis dieser Extension (für die selbst gehosteten Swagger-UI-Assets).
const EXT_DIR = path.dirname(fileURLToPath(import.meta.url));
const BACKEND_URL = (process.env.SKRIFT_BACKEND_URL || 'http://skrift-backend:4000').replace(/\/$/, '');
const BACKEND_TOKEN = process.env.SKRIFT_BACKEND_TOKEN || '';
const STORAGE = (process.env.STORAGE_LOCATIONS || 'local').split(',')[0].trim() || 'local';
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
const pad3 = (n) => String(n).padStart(3, '0');
const WOCHENTAGE = ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'];
/**
* Internal order → public status (pending | processing | shipped | cancelled).
* Der Betreiber steuert im Produktions-Board `production_status`; der kaufmännische
* `status` dient als Rückfall. Beide werden berücksichtigt.
*/
function kundenStatus(order) {
const ps = String(order?.production_status || '');
const s = String(order?.status || '');
if (ps === 'storniert' || s === 'storniert') return 'cancelled';
if (ps === 'versendet' || s === 'versendet' || s === 'abgeschlossen') return 'shipped';
if (['im_druck', 'kuvertieren', 'versandfertig'].includes(ps) || ['in_produktion', 'gedruckt'].includes(s)) return 'processing';
return 'pending';
}
// Selbst ausgelieferte HTML-Doku (kein externes Script → CSP-sicher). Inline-CSS.
const DOCS_HTML = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Skrift API Reference</title>
<style>
*{box-sizing:border-box}
body{margin:0;font:16px/1.6 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;background:#f6f8fb}
.wrap{max-width:900px;margin:0 auto;padding:32px 20px 80px}
h1{font-size:30px;margin:0 0 4px}
h2{font-size:22px;margin:40px 0 12px;padding-top:14px;border-top:1px solid #e2e8f0}
h3{font-size:15px;margin:18px 0 6px}
code{background:#eef2f7;padding:2px 6px;border-radius:5px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:14px}
pre{background:#0f172a;color:#e6edf3;padding:16px;border-radius:10px;overflow:auto;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:1.5}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:18px 20px;margin:14px 0}
.ep{display:flex;align-items:center;gap:10px;font-family:ui-monospace,monospace;font-size:15px;margin-bottom:6px}
.m{font-weight:700;color:#fff;border-radius:6px;padding:2px 8px;font-size:12px}
.post{background:#16a34a}.get{background:#2563eb}
table{border-collapse:collapse;width:100%;margin:8px 0;font-size:14px}
th,td{text-align:left;border-bottom:1px solid #e2e8f0;padding:7px 8px;vertical-align:top}
th{color:#64748b;font-weight:600}
.muted{color:#64748b}
.tag{display:inline-block;background:#eef2f7;border-radius:6px;padding:1px 8px;font-size:13px;margin:0 6px 4px 0}
a{color:#1E50A5}
</style></head>
<body><div class="wrap">
<h1>Skrift API Reference</h1>
<p class="muted">Handwritten mail as a service · API v1 · Base URL <code>https://dev.skrift.de</code></p>
<h2>Overview</h2>
<div class="card">
<p>REST API over HTTPS. Request and response bodies are JSON (<code>Content-Type: application/json</code>, UTF-8). An order yields one document and/or one envelope per recipient; the <b>quantity is derived from the number of recipients</b>. One or more products are bound to your API key; each order selects one via the <code>product</code> field.</p>
</div>
<h2>Authentication</h2>
<div class="card">
<p>Every request must include your API key in the <code>X-Api-Key</code> header:</p>
<pre>X-Api-Key: &lt;API_KEY&gt;</pre>
<p class="muted">Each key is scoped to exactly one product. Keys are secrets — never expose them in client-side code.</p>
</div>
<h2>Errors</h2>
<div class="card">
<p>Errors use standard HTTP status codes and a uniform body:</p>
<pre>{ "error": "human-readable message" }</pre>
<table>
<tr><th>Status</th><th>Meaning</th></tr>
<tr><td>200 / 201</td><td>Success / order created</td></tr>
<tr><td>400</td><td>Invalid request (missing or invalid fields)</td></tr>
<tr><td>401</td><td>Missing or invalid API key</td></tr>
<tr><td>404</td><td>Resource not found</td></tr>
<tr><td>413</td><td>Payload too large (file &gt; 80 MB)</td></tr>
<tr><td>415</td><td>Unsupported media type (only PDF is accepted)</td></tr>
<tr><td>500</td><td>Internal error</td></tr>
</table>
</div>
<h2>Order status</h2>
<div class="card">
<p>The <code>status</code> field returns one of:</p>
<span class="tag">pending</span> received, not yet in production
<span class="tag">processing</span> being written / printed
<span class="tag">shipped</span> dispatched
<span class="tag">cancelled</span> cancelled
</div>
<h2>Create an order</h2>
<div class="card">
<div class="ep"><span class="m post">POST</span> /v1/orders</div>
<p>Creates an order and returns its order number.</p>
<h3>Request body</h3>
<table>
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
<tr><td>product</td><td>string</td><td>Product key to use. Required if your key allows multiple products; optional (auto) if exactly one.</td></tr>
<tr><td>recipients</td><td>array</td><td><b>Required.</b> One entry per recipient; count = quantity. Address as <code>free_text</code> (up to 5 lines, separated by <code>\\n</code>).</td></tr>
<tr><td>document_format</td><td>string</td><td>Document format: <code>a4</code>, <code>a6h</code> or <code>a6l</code>.</td></tr>
<tr><td>font</td><td>string</td><td><code>tilda</code>, <code>alva</code> or <code>ellie</code>.</td></tr>
<tr><td>realistic</td><td>boolean</td><td>Natural handwriting variation. Default <code>true</code>.</td></tr>
<tr><td>shipping_type</td><td>string</td><td><code>bulk</code> (consolidated shipment to you; default) or <code>single</code> (direct to each recipient).</td></tr>
<tr><td>shipping_day</td><td>string</td><td>Preferred dispatch weekday, e.g. <code>montag</code> (optional).</td></tr>
<tr><td>letter.text</td><td>string</td><td>Letter body, <b>fully written out — no placeholders</b>. Long text flows across multiple pages automatically; <code>[[seitenumbruch]]</code> forces a page break.</td></tr>
<tr><td>letter.files</td><td>array</td><td><code>file_id</code>(s) from <code>POST /v1/files</code> (file / PDF products).</td></tr>
<tr><td>envelope.mode</td><td>string</td><td><code>none</code> (no envelope) or <code>recipient</code> (recipient address on the envelope). Envelope size is selected automatically: A4 → DIN Lang, otherwise C6.</td></tr>
</table>
<h3>Example — text letter</h3>
<pre>curl -X POST https://dev.skrift.de/v1/orders \\
-H "X-Api-Key: &lt;KEY&gt;" -H "Content-Type: application/json" \\
-d '{
"product":"briefe","document_format":"a4","font":"tilda","realistic":true,
"shipping_type":"bulk","shipping_day":"montag",
"letter":{"text":"Dear Anna,\\n\\nthank you for ..."},
"envelope":{"mode":"recipient"},
"recipients":[
{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}
]
}'</pre>
<h3>Example — print uploaded PDFs (file product)</h3>
<pre>curl -X POST https://dev.skrift.de/v1/orders \\
-H "X-Api-Key: &lt;KEY&gt;" -H "Content-Type: application/json" \\
-d '{"document_format":"a4","letter":{"files":["&lt;file_id&gt;"]},
"envelope":{"mode":"recipient"},
"recipients":[{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}]}'</pre>
<h3>Example — address envelopes only</h3>
<pre>curl -X POST https://dev.skrift.de/v1/orders \\
-H "X-Api-Key: &lt;KEY&gt;" -H "Content-Type: application/json" \\
-d '{"envelope":{"mode":"recipient"},
"recipients":[{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}]}'</pre>
<h3>Response <span class="muted">· 201</span></h3>
<pre>{ "order_number": "18-08-26-001", "status": "pending" }</pre>
</div>
<h2>Retrieve order status</h2>
<div class="card">
<div class="ep"><span class="m get">GET</span> /v1/orders/{order_number}</div>
<p>Returns the current status of one of your orders. <code>status_since</code> is the ISO 8601 timestamp (UTC) since which the order has been in its current status.</p>
<pre>curl https://dev.skrift.de/v1/orders/18-08-26-001 -H "X-Api-Key: &lt;KEY&gt;"
{ "order_number":"18-08-26-001", "status":"shipped", "status_since":"2026-08-20T09:14:00.000Z", "recipients":1 }</pre>
</div>
<h2>Upload a PDF</h2>
<div class="card">
<div class="ep"><span class="m post">POST</span> /v1/files</div>
<p>Uploads a base64-encoded print file. <b>PDF only</b>, max. 80 MB. Returns a <code>file_id</code> to reference in <code>letter.files</code>.</p>
<pre>curl -X POST https://dev.skrift.de/v1/files \\
-H "X-Api-Key: &lt;KEY&gt;" -H "Content-Type: application/json" \\
-d '{"filename":"letter.pdf","content_base64":"&lt;BASE64&gt;"}'
{ "file_id": "..." }</pre>
</div>
<p class="muted" style="margin-top:30px">Machine-readable specification: <a href="v1/openapi.json">OpenAPI (JSON)</a> — import into Postman, Insomnia, or any OpenAPI client.</p>
</div></body></html>`;
// Swagger UI (selbst gehostet). Assets liegen als swagger-ui.css + swagger-ui-bundle.js
// im Extension-Ordner; init.js wird generiert. Kein CDN → CSP-/Brave-sicher.
const SWAGGER_HTML = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Skrift API Reference</title>
<link rel="stylesheet" href="docs-assets/swagger-ui.css">
<style>body{margin:0;background:#fafafa}.swagger-ui .topbar{display:none}</style>
</head><body>
<div id="swagger-ui"></div>
<script src="docs-assets/swagger-ui-bundle.js"></script>
<script src="docs-assets/init.js"></script>
</body></html>`;
const INIT_JS = "window.addEventListener('DOMContentLoaded',function(){window.ui=SwaggerUIBundle({url:'v1/openapi.json',dom_id:'#swagger-ui',deepLinking:true,presets:[SwaggerUIBundle.presets.apis],layout:'BaseLayout'});});";
const handler = (router, { services, getSchema, logger }) => {
const { ItemsService, FilesService } = services;
const svcFactory = (schema) => (c) => new ItemsService(c, { schema, accountability: null });
/** Zeichen-Whitelist (wie im übrigen System) als Sanitizer laden. */
async function ladeClean(schema) {
try {
const ps = await new ItemsService('pricing_settings', { schema, accountability: null })
.readSingleton({ fields: ['char_whitelist'] });
const wl = ps?.char_whitelist;
const re = wl ? new RegExp(`[^${wl}\\n\\r\\t]`, 'g') : null;
return (v) => (typeof v === 'string' && re ? v.replace(re, '') : v);
} catch { return (v) => v; }
}
/** API-Key aus dem X-Api-Key-Header lesen und den aktiven Zugang finden.
* (NICHT Authorization: Bearer – den reserviert Directus für eigene Tokens
* und lehnt fremde Werte global mit INVALID_CREDENTIALS ab.) */
async function authClient(req) {
const raw = String(req.headers['x-api-key'] || '').trim();
if (!raw) return null;
const schema = await getSchema();
const rows = await new ItemsService('api_clients', { schema, accountability: null }).readByQuery({
filter: { key_hash: { _eq: sha256(raw) }, active: { _eq: true } },
limit: 1,
fields: ['id', 'name', 'customer', 'webhook_url', 'config',
'product.id', 'product.key', 'product.type', 'product.input_mode',
'products.product.id', 'products.product.key', 'products.product.type', 'products.product.input_mode'],
});
const client = rows?.[0] || null;
if (client) {
// Letzte Nutzung vermerken (best effort, nicht blockierend).
new ItemsService('api_clients', { schema, accountability: null })
.updateOne(client.id, { last_used: new Date().toISOString() }).catch(() => {});
}
return client;
}
const fehler = (res, code, msg) => res.status(code).json({ error: msg });
// ── POST /v1/files ─────────────────────────────────────────────────────────
// Body: { filename, content_base64 } → { file_id }. NUR PDF (Sicherheit).
router.post('/v1/files', async (req, res) => {
const client = await authClient(req);
if (!client) return fehler(res, 401, 'Missing or invalid API key.');
const { filename, content_base64 } = req.body || {};
if (!filename || !content_base64) return fehler(res, 400, 'filename and content_base64 are required.');
if (!/\.pdf$/i.test(String(filename))) return fehler(res, 415, 'Only PDF files are accepted (filename must end in .pdf).');
let buf;
try { buf = Buffer.from(String(content_base64), 'base64'); } catch { return fehler(res, 400, 'content_base64 is not valid base64.'); }
if (!buf.length) return fehler(res, 400, 'File is empty.');
if (buf.length > 80 * 1024 * 1024) return fehler(res, 413, 'File too large (max. 80 MB).');
// Inhalts-Prüfung: echte PDF-Signatur, nicht nur die Endung.
if (buf.slice(0, 5).toString('latin1') !== '%PDF-') return fehler(res, 415, 'File is not a valid PDF.');
try {
const schema = await getSchema();
const filesSvc = new FilesService({ schema, accountability: null });
const id = await filesSvc.uploadOne(Readable.from(buf), {
storage: STORAGE,
filename_download: String(filename),
title: String(filename),
type: 'application/pdf',
});
return res.json({ file_id: id });
} catch (err) {
logger.error(`[skrift-api] files: ${err.stack || err.message}`);
return fehler(res, 500, 'File upload failed.');
}
});
// ── POST /v1/orders ─────────────────────────────────────────────────────────
router.post('/v1/orders', async (req, res) => {
const client = await authClient(req);
if (!client) return fehler(res, 401, 'Missing or invalid API key.');
const body = req.body || {};
// Erlaubte Produkte des Keys: m2m-Liste + (Fallback) Einzelprodukt, dedupliziert.
const erlaubt = {};
if (Array.isArray(client.products)) for (const j of client.products) if (j && j.product && j.product.id != null) erlaubt[j.product.id] = j.product;
if (client.product && client.product.id != null) erlaubt[client.product.id] = client.product;
const erlaubteListe = Object.values(erlaubt);
if (!erlaubteListe.length) return fehler(res, 409, 'No product is assigned to this API key.');
// Produktwahl: body.product (Key) muss erlaubt sein; bei genau einem Produkt optional.
const gewuenscht = String(body.product || '').trim();
let product;
if (gewuenscht) {
product = erlaubteListe.find((p) => String(p.key) === gewuenscht);
if (!product) return fehler(res, 400, `product "${gewuenscht}" is not allowed for this key. Allowed: ${erlaubteListe.map((p) => p.key).join(', ')}.`);
} else if (erlaubteListe.length === 1) {
product = erlaubteListe[0];
} else {
return fehler(res, 400, `Field "product" is required. Allowed: ${erlaubteListe.map((p) => p.key).join(', ')}.`);
}
const recipients = Array.isArray(body.recipients) ? body.recipients : [];
if (!recipients.length) return fehler(res, 400, 'At least one recipient is required.');
if (recipients.length > 5000) return fehler(res, 400, 'Too many recipients (max. 5000).');
const istKuvertOnly = product.type === 'envelope';
const istDatei = product.input_mode === 'datei';
const letter = body.letter || {};
const files = Array.isArray(letter.files) ? letter.files.filter(Boolean) : [];
// Kuvert-only: kein Brief nötig (nur adressierte Kuverts); PDF-Dateien optional.
if (!istKuvertOnly) {
if (istDatei && !files.length) return fehler(res, 400, 'This product requires uploaded print files (letter.files).');
if (!istDatei && !String(letter.text || '').trim()) return fehler(res, 400, 'letter.text is required.');
}
try {
const schema = await getSchema();
const svc = svcFactory(schema);
const clean = await ladeClean(schema);
// Schriftstückformat: nur A4 / A6H / A6L (freundliche Werte → interne Keys).
const FORMAT_MAP = { a4: 'a4', a6h: 'a6_hoch', a6l: 'a6_quer', a6_hoch: 'a6_hoch', a6_quer: 'a6_quer' };
const fmtKey = FORMAT_MAP[String(body.document_format || '').trim().toLowerCase()] || null;
if (!fmtKey && !istKuvertOnly) return fehler(res, 400, 'document_format must be a4, a6h or a6l.');
let formatId = null;
if (fmtKey) {
const fr = await svc('formats').readByQuery({ filter: { key: { _eq: fmtKey } }, limit: 1, fields: ['id'] });
formatId = fr?.[0]?.id ?? null;
if (!formatId) return fehler(res, 400, `Format ${fmtKey} is not active.`);
}
// Kuvert: NUR "recipient" (Empfängeradresse) oder "none" (gar kein Kuvert).
const envMode = String((body.envelope || {}).mode || '').toLowerCase() === 'recipient'
? 'recipient' : (istKuvertOnly ? 'recipient' : 'none');
// Kuvertformat automatisch aus dem Dokumentformat: A4 → DIN Lang, sonst C6.
const envFormat = envMode === 'none' ? null : (fmtKey === 'a4' ? 'dinlang' : 'c6');
const hatDateien = files.length > 0;
const font = ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda';
// Versandart nur Englisch: "single" (direkt an Empfänger) / "bulk" (Sammelversand an dich).
const shippingType = String(body.shipping_type || '').toLowerCase() === 'single' ? 'einzeln' : 'sammel';
// Gewünschter Versandtag (optional), z. B. "montag".
const shippingDay = WOCHENTAGE.includes(String(body.shipping_day || '').toLowerCase())
? String(body.shipping_day).toLowerCase() : null;
const orderData = {
api_client: client.id,
customer: client.customer || null,
customer_email: (client.config && client.config.email) || null,
product: product.id,
format: formatId,
person_type: 'unternehmen',
font,
realistic: body.realistic !== false,
source: 'api',
status: 'in_queue',
production_status: 'eingegangen',
payment_status: 'offen',
payment_method: 'rechnung',
shipping_type: shippingType,
needs_envelope: envMode === 'recipient',
envelope_labeling: envMode === 'recipient' ? 'empfaenger' : 'keine',
envelope_text: null,
envelope_format: envFormat,
// Kein Brieftext bei Datei- oder Kuvert-only-Produkten.
text_template: (istDatei || istKuvertOnly) ? null : clean(String(letter.text || '')),
// Hochgeladene PDF-Briefe (Datei-Produkt ODER optional bei Kuvert-only).
source_files: (istDatei || (istKuvertOnly && hatDateien)) ? files : null,
source_file: (istDatei || (istKuvertOnly && hatDateien)) ? (files[0] || null) : null,
entries_count: recipients.length,
shipping_day: shippingDay,
// Mehrseitigkeit läuft selbstständig (Auto-Fluss); nicht vom Kunden wählbar.
multi_page: true,
// Schriftgröße nicht vom Kunden wählbar.
font_scale_letter: 1,
font_scale_envelope: 1,
};
// Auftragsnummer (Tages-Präfix) mit Kollisions-Wiederholung.
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}-${pad3((heutige?.length ?? 0) + 1)}`;
};
let orderId, orderNumber;
for (let v = 0; v < 6; v += 1) {
orderNumber = await naechsteNummer();
try { orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); break; }
catch (e) { if (v === 5 || !/unique|duplicate|bereits|exists/i.test(String(e?.message || ''))) throw e; }
}
// Empfängerzeilen – Briefnummer wird vom System vergeben.
const entriesSvc = svc('order_entries');
let n = 0;
for (const e of recipients) {
n += 1;
await entriesSvc.createOne({
order: orderId, letter_number: n,
salutation: clean(e.salutation ?? null), first_name: clean(e.first_name ?? null), last_name: clean(e.last_name ?? null),
street: clean(e.street ?? null), house_no: clean(e.house_no ?? null), zip: clean(e.zip ?? null),
city: clean(e.city ?? null), country: clean(e.country ?? null), free_text: clean(e.free_text ?? null),
// Keine Platzhalter über die API – der Brieftext wird 1:1 verwendet.
placeholders: null,
});
}
await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Über die Public-API angelegt.' }).catch(() => {});
// Generierung IMMER anstoßen (fire-and-forget) – für jedes Produkt. Das
// Backend entscheidet, was zu erzeugen ist: Schriftstücke bei Handschrift-
// Produkten, Kuverts immer wenn gewünscht; bei Datei-Produkten (z. B.
// Unterschriftenservice) NUR die Kuverts. Ohne zu Erzeugendes = No-Op.
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-api] Generierung nicht gestartet: ${e.message}`));
return res.status(201).json({ order_number: orderNumber, status: 'pending' });
} catch (err) {
logger.error(`[skrift-api] orders: ${err.stack || err.message}`);
return fehler(res, 500, 'Order could not be created.');
}
});
// ── GET /v1/orders/:nummer ──────────────────────────────────────────────────
router.get('/v1/orders/:nummer', async (req, res) => {
const client = await authClient(req);
if (!client) return fehler(res, 401, 'Missing or invalid API key.');
try {
const schema = await getSchema();
const rows = await new ItemsService('orders', { schema, accountability: null }).readByQuery({
// Nur eigene Aufträge des Zugangs.
filter: { order_number: { _eq: String(req.params.nummer) }, api_client: { _eq: client.id } },
limit: 1, fields: ['order_number', 'status', 'production_status', 'production_status_changed_at',
'versandt_am', 'entries_count', 'date_created'],
});
const order = rows?.[0];
if (!order) return fehler(res, 404, 'Order not found.');
const status = kundenStatus(order);
// Seit wann der Status so ist: Zeitpunkt der letzten Produktionsstatus-
// Änderung; bei „shipped" ersatzweise der Versandzeitpunkt, sonst Anlage.
const statusSince = order.production_status_changed_at
|| (status === 'shipped' ? order.versandt_am : null)
|| order.date_created || null;
return res.json({
order_number: order.order_number,
status,
status_since: statusSince,
recipients: order.entries_count ?? null,
created_at: order.date_created ?? null,
});
} catch (err) {
logger.error(`[skrift-api] status: ${err.stack || err.message}`);
return fehler(res, 500, 'Failed to retrieve order status.');
}
});
// ── POST /v1/clients (Admin) – neuen Zugang + Key erzeugen ───────────────────
// Nur mit Directus-Admin-Token/Session. Der Klartext-Key wird EINMALIG geliefert.
router.post('/v1/clients', async (req, res) => {
if (!req.accountability || req.accountability.admin !== true) return fehler(res, 403, 'Administrators only.');
const { name, product, products, customer, webhook_url } = req.body || {};
// Mehrere Produkte (Array von IDs) bevorzugt; „product" (einzeln) als Kurzform.
const prodIds = Array.isArray(products) ? products.filter((x) => x != null) : (product != null ? [product] : []);
if (!name || !prodIds.length) return fehler(res, 400, 'name and products (array of product IDs) are required.');
try {
const schema = await getSchema();
const key = `sk_live_${crypto.randomBytes(24).toString('base64url')}`;
const id = await new ItemsService('api_clients', { schema, accountability: null }).createOne({
name: String(name), products: prodIds.map((pid) => ({ product: pid })),
customer: customer || null, webhook_url: webhook_url || null,
key_hash: sha256(key), key_prefix: key.slice(0, 14), active: true,
});
// Klartext-Key nur JETZT – wird nirgends gespeichert.
return res.status(201).json({ id, name, api_key: key, note: 'Store this key securely — it will not be shown again.' });
} catch (err) {
logger.error(`[skrift-api] clients: ${err.stack || err.message}`);
return fehler(res, 500, 'API client could not be created.');
}
});
// ── OpenAPI + Doku ──────────────────────────────────────────────────────────
const OPENAPI = {
openapi: '3.0.3',
info: { title: 'Skrift API', version: '1.0.0', description: 'Handwritten mail as a service. Authentication: API key in the X-Api-Key header. Bodies are JSON (UTF-8).' },
servers: [{ url: 'https://dev.skrift.de' }],
components: {
securitySchemes: { ApiKey: { type: 'apiKey', in: 'header', name: 'X-Api-Key' } },
schemas: {
Recipient: {
type: 'object',
properties: {
free_text: { type: 'string', description: 'Recipient address as free text, up to 5 lines separated by \\n.' },
salutation: { type: 'string' }, first_name: { type: 'string' }, last_name: { type: 'string' },
street: { type: 'string' }, house_no: { type: 'string' }, zip: { type: 'string' }, city: { type: 'string' }, country: { type: 'string' },
},
},
OrderRequest: {
type: 'object', required: ['recipients'],
properties: {
product: { type: 'string', description: 'Product key to use. Required if the API key is bound to more than one product; optional (auto) if exactly one.' },
document_format: { type: 'string', enum: ['a4', 'a6h', 'a6l'], description: 'Document format.' },
font: { type: 'string', enum: ['tilda', 'alva', 'ellie'] },
realistic: { type: 'boolean', default: true, description: 'Natural handwriting variation.' },
shipping_type: { type: 'string', enum: ['single', 'bulk'], default: 'bulk', description: 'single = direct to each recipient; bulk = consolidated shipment to you.' },
shipping_day: { type: 'string', enum: ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'], description: 'Preferred dispatch weekday (optional).' },
letter: {
type: 'object',
properties: {
text: { type: 'string', description: 'Letter body, fully written out (no placeholders). Long text flows across multiple pages automatically; [[seitenumbruch]] forces a page break.' },
files: { type: 'array', items: { type: 'string' }, description: 'file_id(s) from POST /v1/files (file / PDF products).' },
},
},
envelope: {
type: 'object',
properties: {
mode: { type: 'string', enum: ['none', 'recipient'], default: 'none', description: 'none = no envelope; recipient = recipient address on the envelope. Envelope size is selected automatically (A4 → DIN Lang, otherwise C6).' },
},
},
recipients: { type: 'array', items: { $ref: '#/components/schemas/Recipient' } },
},
},
},
},
security: [{ ApiKey: [] }],
paths: {
'/v1/orders': {
post: {
summary: 'Create an order', description: 'Quantity equals the number of recipients. Returns the order number.',
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/OrderRequest' } } } },
responses: { 201: { description: 'Order created', content: { 'application/json': { schema: { type: 'object', properties: { order_number: { type: 'string' }, status: { type: 'string' } } } } } } },
},
},
'/v1/orders/{order_number}': {
get: {
summary: 'Retrieve order status', parameters: [{ name: 'order_number', in: 'path', required: true, schema: { type: 'string' } }],
responses: { 200: { description: 'Order status', content: { 'application/json': { schema: { type: 'object', properties: { order_number: { type: 'string' }, status: { type: 'string', enum: ['pending', 'processing', 'shipped', 'cancelled'] }, status_since: { type: 'string', format: 'date-time', description: 'ISO 8601 timestamp since which the order has held its current status.' }, recipients: { type: 'integer' } } } } } } },
},
},
'/v1/files': {
post: {
summary: 'Upload a PDF (PDF only, max. 80 MB)',
requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['filename', 'content_base64'], properties: { filename: { type: 'string', description: 'Must end in .pdf.' }, content_base64: { type: 'string', description: 'PDF as base64.' } } } } } },
responses: { 200: { description: 'Uploaded', content: { 'application/json': { schema: { type: 'object', properties: { file_id: { type: 'string' } } } } } } },
},
},
},
};
router.get('/v1/openapi.json', (_req, res) => res.json(OPENAPI));
router.get('/docs', (_req, res) => {
// Swagger UI, wenn die Assets im Extension-Ordner liegen; sonst die statische
// HTML-Doku als Fallback (nie leer). Beides CSP-/Brave-sicher (kein CDN).
let hasSwagger = false;
try { hasSwagger = fs.existsSync(path.join(EXT_DIR, 'swagger-ui-bundle.js')); } catch { /* ignore */ }
res.type('html').send(hasSwagger ? SWAGGER_HTML : DOCS_HTML);
});
// Statische Doku-Assets (Swagger UI) – von der eigenen Domain, whitelisted.
router.get('/docs-assets/:file', (req, res) => {
const name = String(req.params.file || '');
if (name === 'init.js') { res.type('application/javascript').send(INIT_JS); return; }
const TYPES = { 'swagger-ui.css': 'text/css', 'swagger-ui-bundle.js': 'application/javascript' };
if (!TYPES[name]) return res.status(404).json({ error: 'Not found.' });
try {
const buf = fs.readFileSync(path.join(EXT_DIR, name));
res.setHeader('Content-Type', TYPES[name]);
res.setHeader('Cache-Control', 'public, max-age=86400');
return res.send(buf);
} catch {
return res.status(404).json({ error: 'Docs asset missing – place swagger-ui.css and swagger-ui-bundle.js in the extension folder.' });
}
});
};
// Objekt-Form mit fester id → Mount unter /skrift-api (wie skrift-orders).
export default { id: 'skrift-api', handler };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-api",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Public-API für Kunden (v1): Aufträge anlegen, Status abfragen, Datei-Upload. Key-Auth über api_clients (SHA-256).",
"directus:extension": {
"type": "endpoint",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -0,0 +1,37 @@
/**
* Skrift – API-Key-Hook
* ---------------------------------------------------------------------------
* Beim Anlegen eines api_clients-Eintrags wird automatisch ein API-Key erzeugt:
* - key_hash = SHA-256 des Keys (dauerhaft; nur damit authentifiziert die API)
* - key_prefix = Erkennungspräfix (Anzeige)
* - key_plain = Klartext-Key, EINMALIG sichtbar (jetzt kopieren)
*
* Bei der nächsten Änderung des Eintrags wird key_plain wieder geleert
* („reveal once"). Der Klartext wird also nie dauerhaft gespeichert.
*/
import crypto from 'node:crypto';
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
export default (register) => {
// Anlegen: Key erzeugen, sofern noch keiner gesetzt ist.
register.filter('api_clients.items.create', (payload) => {
if (payload && typeof payload === 'object' && !payload.key_hash) {
const key = `sk_live_${crypto.randomBytes(24).toString('base64url')}`;
payload.key_hash = sha256(key);
payload.key_prefix = key.slice(0, 14);
payload.key_plain = key; // einmalige Anzeige
if (payload.active === undefined) payload.active = true;
}
return payload;
});
// Jede spätere Änderung entfernt den Klartext (nur direkt nach Anlage sichtbar),
// außer er wird gerade explizit gesetzt.
register.filter('api_clients.items.update', (payload) => {
if (payload && typeof payload === 'object' && payload.key_plain === undefined) {
payload.key_plain = null;
}
return payload;
});
};

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-apikey",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Erzeugt beim Anlegen eines api_clients automatisch einen API-Key (SHA-256 gespeichert, Klartext einmalig in key_plain).",
"directus:extension": {
"type": "hook",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -43,14 +43,26 @@ const handler = (router, { services, getSchema, env, logger }) => {
const ctx = async () => ({ schema: await getSchema(), accountability: null });
// Findet das Konto per primärer E-Mail ODER per zusätzlicher Login-Mail
// (alt_emails). So melden sich Zweit-Mails im SELBEN Konto an.
async function findUserByEmail(email, schema) {
const users = new UsersService({ schema, accountability: null });
const found = await users.readByQuery({
filter: { email: { _eq: email } },
fields: ['id', 'email', 'status'],
limit: 1,
const felder = ['id', 'email', 'status', 'alt_emails'];
const primaer = await users.readByQuery({ filter: { email: { _eq: email } }, fields: felder, limit: 1 });
if (primaer?.[0]) return primaer[0];
// Zusätzliche Login-Mails: JSON-Feld ist DB-abhängig schwer filterbar, daher
// die (wenigen) Konten mit alt_emails laden und exakt in JS prüfen.
try {
const cand = await users.readByQuery({ filter: { alt_emails: { _nnull: true } }, fields: felder, limit: -1 });
const treffer = (cand || []).find((u) => {
const liste = Array.isArray(u.alt_emails)
? u.alt_emails
: (typeof u.alt_emails === 'string' ? (() => { try { return JSON.parse(u.alt_emails); } catch { return []; } })() : []);
return Array.isArray(liste) && liste.map((x) => norm(x)).includes(email);
});
return found?.[0] || null;
if (treffer) return treffer;
} catch { /* alt_emails evtl. noch nicht vorhanden → ignorieren */ }
return null;
}
async function sendCodeMail(email, code) {
@@ -173,6 +185,11 @@ const handler = (router, { services, getSchema, env, logger }) => {
if (!user || user.status !== 'active')
return res.status(401).json({ error: 'Kein aktives Konto zu dieser Adresse.' });
// WICHTIG: Directus kennt nur die PRIMÄRE E-Mail als Login. Wurde der Code
// an eine Zweit-Mail geschickt, melden wir dennoch mit der primären an –
// dadurch landet die Zweit-Mail im selben Konto (voller Zugriff).
const loginEmail = norm(user.email);
// Offizieller Login-Pfad mit rotierendem Zufallspasswort (s. Kopfkommentar).
const users = new UsersService({ schema, accountability: null });
const tempPassword = tempPasswort();
@@ -183,9 +200,9 @@ const handler = (router, { services, getSchema, env, logger }) => {
// curl funktioniert – daher zuerst diese Variante, dann Fallbacks.
const auth = new AuthenticationService({ schema, accountability: null });
const versuche = [
() => auth.login('default', { email, password: tempPassword }),
() => auth.login({ email, password: tempPassword }),
() => auth.login({ email, password: tempPassword }, {}),
() => auth.login('default', { email: loginEmail, password: tempPassword }),
() => auth.login({ email: loginEmail, password: tempPassword }),
() => auth.login({ email: loginEmail, password: tempPassword }, {}),
];
let result, letzterFehler;
for (const versuch of versuche) {

View File

@@ -0,0 +1,205 @@
/**
* Skrift – Kontakte-Hook (Anlage auf Anforderung, Aktionen, DSGVO-Purge)
* ---------------------------------------------------------------------------
* Rund um die Collections `contacts` und `contact_actions`:
*
* 1) ANLAGE NUR AUF ANFORDERUNG — Kontakte werden NICHT automatisch bei jedem
* Versand gespeichert. Erst wenn am Auftrag „In Kontakte speichern"
* (orders.keep_contacts) gesetzt wird, entsteht je Empfängerzeile ein
* Kontakt (Guard: orders.contacts_created). Diese Kontakte bleiben dauerhaft.
*
* 2) STANDARDAKTIONEN (contact_actions) —
* • type „angebot" → sofort eine Mail mit den Eckdaten (Name, Adresse,
* E-Mail, Telefon, Notizen) an die interne Adresse (fürs Rechnungsprogramm).
* • type „rueckruf" → am fälligen Tag (Zeitplan) eine Erinnerungs-Mail.
*
* 3) DSGVO-STRIP (optional, per Env) — Empfängerdaten (order_entries) alter,
* versendeter Aufträge OHNE „In Kontakte speichern" werden anonymisiert.
* Standard: nur DRY-RUN (Log). Scharf via SKRIFT_CONTACTS_STRIP_ENTRIES=1.
* Kontakte selbst werden NICHT automatisch gelöscht (nur manuell).
*
* Env:
* SKRIFT_CONTACTS_REMINDER_CRON Cron der Rückruf-Erinnerung (Default „0 7 * * *").
* SKRIFT_CONTACTS_PURGE_CRON Cron des DSGVO-Strip (Default „0 3 * * *").
* SKRIFT_CONTACTS_STRIP_ENTRIES „1"/„true" = Empfängerdaten wirklich löschen.
* SKRIFT_TEAM_EMAIL / EMAIL_FROM Fallback-Empfänger, falls email_settings.team_email leer.
*
* Von Hand als ESM (kein Build-Schritt), analog zu skrift-mailer/skrift-apikey.
*/
const RETENTION_DAYS = 30;
// Alle 15 Minuten prüfen, damit die Erinnerung nahe der gewählten Uhrzeit kommt.
const REMINDER_CRON = process.env.SKRIFT_CONTACTS_REMINDER_CRON || '*/15 * * * *';
const PURGE_CRON = process.env.SKRIFT_CONTACTS_PURGE_CRON || '0 3 * * *';
const STRIP_ENTRIES = /^(1|true|yes|on)$/i.test(String(process.env.SKRIFT_CONTACTS_STRIP_ENTRIES || ''));
const ENTRY_PII = ['salutation', 'first_name', 'last_name', 'street', 'house_no',
'zip', 'city', 'country', 'free_text', 'placeholders', 'text'];
const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
const htmlZuText = (html) => String(html || '')
.replace(/<\s*br\s*\/?>/gi, '\n').replace(/<\/(p|div|tr|h[1-6]|li|table)>/gi, '\n')
.replace(/<[^>]+>/g, '').replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>')
.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
const contactName = (c) => [c?.salutation, c?.first_name, c?.last_name].filter(Boolean).join(' ')
|| c?.company || c?.email || c?.phone || '(ohne Namen)';
// Eckdaten eines Kontakts als HTML-Block (für Angebots-/Erinnerungs-Mails).
function contactHtml(c, extraNote) {
const anschrift = [
c.company ? `<strong>${esc(c.company)}</strong>` : '',
esc([c.salutation, c.first_name, c.last_name].filter(Boolean).join(' ')),
esc([c.street, c.house_no].filter(Boolean).join(' ')),
esc([c.zip, c.city].filter(Boolean).join(' ')),
c.country ? esc(c.country) : '',
].filter(Boolean).join('<br>');
const zeilen = [];
zeilen.push(`<p style="margin:0 0 4px;font-weight:600;">Anschrift</p><p style="margin:0 0 12px;line-height:1.6;">${anschrift || '—'}</p>`);
zeilen.push('<p style="margin:0 0 12px;">'
+ `E-Mail: ${c.email ? esc(c.email) : '—'}<br>Telefon: ${c.phone ? esc(c.phone) : '—'}</p>`);
const notes = [c.notes, extraNote].filter((n) => n && String(n).trim()).join('\n\n');
if (notes) zeilen.push(`<p style="margin:0 0 4px;font-weight:600;">Notizen</p><p style="margin:0;line-height:1.6;white-space:pre-wrap;">${esc(notes)}</p>`);
return zeilen.join('');
}
export default (register, { services, getSchema, logger }) => {
const { ItemsService, MailService } = services;
const svc = (coll, schema) => new ItemsService(coll, { schema, accountability: null });
async function loadSettings(schema) {
try { const rows = await svc('email_settings', schema).readByQuery({ limit: 1 }); return rows?.[0] || {}; }
catch { return {}; }
}
async function sendTeamMail(schema, subject, html) {
const settings = await loadSettings(schema);
const to = settings.team_email || process.env.SKRIFT_TEAM_EMAIL || process.env.EMAIL_FROM;
if (!to) { logger.warn('[skrift-contacts] keine Team-Adresse (email_settings.team_email) – Mail übersprungen'); return; }
const msg = { to, subject, html, text: htmlZuText(html) };
if (settings.sender_email) msg.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
await new MailService({ schema, accountability: null }).send(msg);
logger.info(`[skrift-contacts] Mail „${subject}" an ${to}`);
}
// ── 1) Kontakt-Anlage NUR wenn „In Kontakte speichern" gesetzt wird ─────────
function contactFromEntry(order, e) {
const c = {
salutation: e.salutation || null, first_name: e.first_name || null, last_name: e.last_name || null,
street: e.street || null, house_no: e.house_no || null, zip: e.zip || null, city: e.city || null,
country: e.country || null, lead_status: 'neu',
source_order: order.id, source_entry: e.id, sent_at: order.versandt_am || null,
};
if (!c.first_name && !c.last_name && e.free_text) {
const lines = String(e.free_text).split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
if (lines.length) c.last_name = lines[0];
if (lines.length) c.notes = 'Adresse:\n' + lines.join('\n');
}
return c;
}
async function saveContactsForOrder(orderId) {
const schema = await getSchema();
const orders = svc('orders', schema);
const order = await orders.readOne(orderId, {
fields: ['id', 'order_number', 'versandt_am', 'contacts_created'],
});
if (!order) return;
if (order.contacts_created) return; // schon angelegt → nichts weiter zu tun
const entries = await svc('order_entries', schema).readByQuery({
filter: { order: { _eq: orderId } }, limit: -1,
fields: ['id', 'salutation', 'first_name', 'last_name', 'street', 'house_no', 'zip', 'city', 'country', 'free_text'],
});
if (entries?.length) await svc('contacts', schema).createMany(entries.map((e) => contactFromEntry(order, e)));
await orders.updateOne(orderId, { contacts_created: true });
logger.info(`[skrift-contacts] ${entries?.length || 0} Kontakt(e) aus Auftrag ${order.order_number} gespeichert`);
}
register.action('orders.items.update', async ({ keys, payload }) => {
if (payload?.keep_contacts !== true) return; // nur auf ausdrückliche Anforderung
for (const id of keys || []) {
try { await saveContactsForOrder(id); }
catch (e) { logger.warn(`[skrift-contacts] speichern ${id}: ${e.message}`); }
}
});
// ── 2) Standardaktionen ─────────────────────────────────────────────────────
register.action('contact_actions.items.create', async ({ key, payload }) => {
const typ = payload?.type;
if (typ !== 'angebot' && typ !== 'mail') return; // Rückruf läuft über den Zeitplan
try {
const schema = await getSchema();
const a = await svc('contact_actions', schema).readOne(key, {
fields: ['id', 'note', 'contact.salutation', 'contact.first_name', 'contact.last_name', 'contact.company',
'contact.street', 'contact.house_no', 'contact.zip', 'contact.city', 'contact.country',
'contact.email', 'contact.phone', 'contact.notes'],
});
if (!a?.contact) return;
const name = contactName(a.contact);
const betreff = typ === 'mail' ? `Notiz: ${name}` : `Angebot vorbereiten: ${name}`;
const einleitung = typ === 'mail' ? `Notiz zu <strong>${esc(name)}</strong>:` : `Angebot vorbereiten für <strong>${esc(name)}</strong>:`;
const html = `<p style="margin:0 0 12px;">${einleitung}</p>${contactHtml(a.contact, a.note)}`;
await sendTeamMail(schema, betreff, html);
} catch (e) { logger.warn(`[skrift-contacts] Aktions-Mail (${typ}): ${e.message}`); }
});
register.schedule(REMINDER_CRON, async () => {
try {
const schema = await getSchema();
const nowIso = new Date().toISOString();
const actions = svc('contact_actions', schema);
const due = await actions.readByQuery({
filter: { _and: [{ type: { _eq: 'rueckruf' } }, { status: { _eq: 'offen' } }, { notified: { _eq: false } }, { due_date: { _lte: nowIso } }] },
fields: ['id', 'note', 'due_date', 'contact.salutation', 'contact.first_name', 'contact.last_name', 'contact.company',
'contact.street', 'contact.house_no', 'contact.zip', 'contact.city', 'contact.country', 'contact.email', 'contact.phone', 'contact.notes'],
limit: -1,
});
for (const a of due || []) {
if (!a.contact) { await actions.updateOne(a.id, { notified: true }); continue; }
const name = contactName(a.contact);
const wann = a.due_date ? new Date(a.due_date).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'short', timeStyle: 'short' }) : '';
const html = `<p style="margin:0 0 12px;">Fälliger Rückruf${wann ? ' (' + esc(wann) + ' Uhr)' : ''} bei <strong>${esc(name)}</strong>`
+ `${a.contact.phone ? ' · <strong>' + esc(a.contact.phone) + '</strong>' : ''}:</p>${contactHtml(a.contact, a.note)}`;
try { await sendTeamMail(schema, `Rückruf fällig: ${name}`, html); await actions.updateOne(a.id, { notified: true }); }
catch (e) { logger.warn(`[skrift-contacts] Rückruf-Mail ${a.id}: ${e.message}`); }
}
} catch (e) { logger.warn(`[skrift-contacts] Erinnerungslauf: ${e.message}`); }
});
// ── 3) Täglicher Aufräumlauf ────────────────────────────────────────────────
register.schedule(PURGE_CRON, async () => {
try {
const schema = await getSchema();
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000).toISOString();
// 3a) Kontakte mit „kein Interesse", seit 30 Tagen unverändert → löschen.
// Alle anderen Lead-Status bleiben dauerhaft erhalten.
const contacts = svc('contacts', schema);
const raus = await contacts.readByQuery({
filter: { _and: [{ lead_status: { _eq: 'kein_interesse' } }, { date_updated: { _lt: cutoff } }] },
fields: ['id'], limit: -1,
});
if (raus?.length) { await contacts.deleteMany(raus.map((r) => r.id)); logger.info(`[skrift-contacts] ${raus.length} Kontakt(e) „kein Interesse" (>30 Tage) gelöscht`); }
// 3b) DSGVO-Strip der Empfängerdaten im Auftrag (optional, per Env).
const orders = svc('orders', schema);
const alte = await orders.readByQuery({
filter: { _and: [
{ keep_contacts: { _eq: false } }, { entries_stripped: { _eq: false } },
{ production_status: { _eq: 'versendet' } }, { versandt_am: { _lt: cutoff } },
] },
fields: ['id', 'order_number'], limit: -1,
});
for (const o of alte || []) {
const entries = await svc('order_entries', schema).readByQuery({ filter: { order: { _eq: o.id } }, fields: ['id'], limit: -1 });
if (!STRIP_ENTRIES) {
logger.info(`[skrift-contacts] DSGVO DRY-RUN: würde Empfängerdaten von Auftrag ${o.order_number} (${entries?.length || 0} Zeilen) anonymisieren – scharf via SKRIFT_CONTACTS_STRIP_ENTRIES=1`);
continue;
}
const wipe = Object.fromEntries(ENTRY_PII.map((k) => [k, null]));
if (entries?.length) await svc('order_entries', schema).updateMany(entries.map((r) => r.id), wipe);
await orders.updateOne(o.id, { entries_stripped: true });
logger.info(`[skrift-contacts] DSGVO: Empfängerdaten von Auftrag ${o.order_number} anonymisiert`);
}
} catch (e) { logger.warn(`[skrift-contacts] DSGVO-Lauf: ${e.message}`); }
});
};

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-contacts",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Legt bei Versand automatisch Kontakte je Empfänger an und löscht sie (samt Empfängerdaten im Auftrag) nach 30 Tagen – außer der Auftrag ist als „Kontakte behalten“ markiert.",
"directus:extension": {
"type": "hook",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -0,0 +1,397 @@
/**
* Skrift – Modul „Kontakte" (Mini-CRM)
* ===========================================================================
* Handytaugliches Lead-Board über die Collection `contacts`: alle Empfänger,
* denen ein Schriftstück gesendet wurde. Fokus auf Nachtelefonieren:
*
* • Liste mit Suche + Status-Filter, ein Tipp öffnet den Kontakt.
* • Detail: Name/Adresse, Telefon als GroundWire-Anruf-Button, E-Mail,
* Lead-Status, „Behalten" und ein Notizfeld – alles mit Auto-Speichern.
* • ◀/▶ blättert durch die gefilterte Liste, ohne zurückzuspringen.
* • CSV-Export der (gefilterten) Kontakte. Import über die normale
* Directus-Collection-Seite (Content → Kontakte → Import).
*
* Anruf-Schema fest GroundWire (groundwire://call?number=…). Die Konstante DIAL
* unten ist die einzige Stelle, falls ein anderes Format nötig wird.
*
* Von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
*/
import { defineComponent, ref, reactive, computed, watch, onMounted, h, resolveComponent } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const ACCENT = '#1E50A5';
// GroundWire-Anruf-Link (Acrobits „Call URI"): scheme:NUMMER?dialAction=…
// Die Nummer steht direkt hinter dem Doppelpunkt (kein „//"), ohne Leerzeichen.
// dialAction=voiceCall → sofortiger VoIP-Anruf über GroundWire. Für nur
// Vorbefüllen (Nummer eintippen, selbst tippen) den ?dialAction-Teil weglassen.
const cleanNr = (nr) => String(nr || '').replace(/[^\d+]/g, '');
const DIAL = (nr) => 'groundwire:' + cleanNr(nr) + '?dialAction=voiceCall';
const LEAD = [
{ key: 'neu', label: 'Neu', color: '#6b7280' },
{ key: 'kontaktiert', label: 'Kontaktiert', color: ACCENT },
{ key: 'interessiert', label: 'Interessiert', color: '#e07b00' },
{ key: 'kein_interesse', label: 'Kein Interesse', color: '#c62828' },
{ key: 'kunde', label: 'Kunde', color: '#2e7d32' },
];
const LEAD_LABEL = Object.fromEntries(LEAD.map((l) => [l.key, l.label]));
const LEAD_COLOR = Object.fromEntries(LEAD.map((l) => [l.key, l.color]));
const FIELDS = ['id', 'salutation', 'first_name', 'last_name', 'company', 'street', 'house_no',
'zip', 'city', 'country', 'email', 'phone', 'notes', 'lead_status', 'sent_at',
'source_order.order_number'];
// ── Stil-Helfer ──────────────────────────────────────────────────────────────
const CARD = 'border:1px solid var(--border-subdued);border-radius:10px;padding:16px 18px;background:var(--background-page,#fff);box-shadow:0 1px 3px rgba(0,0,0,.08);';
// Klar erkennbares Eingabefeld: kräftigere Umrandung + leicht getönter Grund,
// damit es sich deutlich von der weißen Karte abhebt.
const S_INPUT = 'width:100%;padding:11px 12px;border:1.5px solid var(--border-normal,#c4cdde);border-radius:8px;background:var(--background-subdued,#eef1f7);color:var(--foreground-normal);font:inherit;font-size:15px;box-sizing:border-box;';
const LBL = 'font-size:12px;font-weight:600;color:var(--foreground-subdued);margin:0 0 4px;text-transform:uppercase;letter-spacing:.03em;';
function btn(label, onClick, kind) {
const base = 'cursor:pointer;border-radius:8px;padding:10px 16px;font:inherit;font-size:14px;border:1px solid;';
const style = kind === 'primary'
? base + `border-color:${ACCENT};background:${ACCENT};color:#fff;`
: kind === 'ghost'
? base + 'border-color:transparent;background:transparent;color:var(--foreground-normal);'
: 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:12px;padding:3px 10px;border-radius:20px;'
+ `background:${farbe || 'var(--background-subdued)'};color:#fff;white-space:nowrap;`,
}, text);
}
function subdued(text, extra) {
return h('div', { style: `color:var(--foreground-subdued);font-size:14px;${extra || ''}` }, text);
}
function feld(label, node) {
return h('div', { style: 'margin-bottom:14px;' }, [h('div', { style: LBL }, label), node]);
}
const anzeigeName = (k) => [k.salutation, k.first_name, k.last_name].filter(Boolean).join(' ')
|| k.company || k.email || k.phone || '(ohne Namen)';
const adresse = (k) => [[k.street, k.house_no].filter(Boolean).join(' '),
[k.zip, k.city].filter(Boolean).join(' '), k.country].filter(Boolean).join(', ');
const Board = defineComponent({
setup() {
const api = useApi();
const kontakte = ref([]);
const loading = ref(false);
const fehler = ref('');
const meldung = ref('');
const suche = ref('');
const statusFilter = ref('');
const aktivId = ref(null);
const savedFlash = ref(null); // Feld-Key, der zuletzt gespeichert wurde
const aktionen = ref([]); // offene/erledigte Aktionen des aktiven Kontakts
const rueckruf = reactive({ open: false, date: '', time: '09:00' });
const mailMich = reactive({ open: false, note: '' });
async function laden() {
loading.value = true; fehler.value = '';
try {
const r = await api.get('/items/contacts', {
params: { limit: -1, sort: ['-sent_at', '-date_created'], fields: FIELDS },
});
kontakte.value = r.data.data || [];
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Fehler beim Laden.'; }
finally { loading.value = false; }
}
onMounted(laden);
const gefiltert = computed(() => {
const q = suche.value.trim().toLowerCase();
return kontakte.value.filter((k) => {
if (statusFilter.value && (k.lead_status || 'neu') !== statusFilter.value) return false;
if (!q) return true;
const hay = [k.salutation, k.first_name, k.last_name, k.company, k.zip, k.city,
k.email, k.phone, k.source_order?.order_number].filter(Boolean).join(' ').toLowerCase();
return hay.includes(q);
});
});
const aktiv = computed(() => kontakte.value.find((k) => k.id === aktivId.value) || null);
const aktivIndex = computed(() => gefiltert.value.findIndex((k) => k.id === aktivId.value));
async function save(id, patch, feldKey) {
try {
await api.patch(`/items/contacts/${id}`, patch);
const k = kontakte.value.find((x) => x.id === id);
if (k) Object.assign(k, patch);
if (feldKey) { savedFlash.value = feldKey; setTimeout(() => { if (savedFlash.value === feldKey) savedFlash.value = null; }, 1500); }
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Speichern fehlgeschlagen.'; }
}
// Speichert nur, wenn sich der Wert wirklich geändert hat (Blur-Handler).
const saveIfChanged = (k, key) => (ev) => {
const val = ev.target.value;
if ((k[key] || '') !== val) save(k.id, { [key]: val || null }, key);
};
// ── Standardaktionen (Rückruf / Angebot) ─────────────────────────────────
async function ladeAktionen(id) {
if (!id) { aktionen.value = []; return; }
try {
const r = await api.get('/items/contact_actions', {
params: { filter: { contact: { _eq: id } }, sort: ['-date_created'], limit: -1,
fields: ['id', 'type', 'due_date', 'note', 'status', 'notified'] },
});
aktionen.value = r.data.data || [];
} catch { aktionen.value = []; }
}
watch(aktivId, (id) => { rueckruf.open = false; rueckruf.date = ''; rueckruf.time = '09:00'; mailMich.open = false; mailMich.note = ''; ladeAktionen(id); });
const isoDatum = (d) => { const x = new Date(); x.setHours(0, 0, 0, 0); x.setDate(x.getDate() + d); return x; };
const ymd = (dt) => `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
async function planeRueckruf(k) {
if (!rueckruf.date) { fehler.value = 'Bitte ein Datum wählen.'; return; }
try {
await api.post('/items/contact_actions', {
contact: k.id, type: 'rueckruf', status: 'offen',
due_date: new Date(rueckruf.date + 'T' + (rueckruf.time || '09:00') + ':00').toISOString(),
});
rueckruf.open = false; rueckruf.date = ''; rueckruf.time = '09:00';
await ladeAktionen(k.id);
meldung.value = 'Rückruf-Erinnerung gesetzt.'; setTimeout(() => { meldung.value = ''; }, 2500);
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Rückruf nicht anlegen.'; }
}
async function sendeAngebot(k) {
try {
await api.post('/items/contact_actions', { contact: k.id, type: 'angebot', status: 'erledigt' });
await ladeAktionen(k.id);
meldung.value = 'Angebot-Eckdaten wurden dir per Mail geschickt.'; setTimeout(() => { meldung.value = ''; }, 3500);
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Angebot nicht senden.'; }
}
// Legt eine Musterbestellung an: erscheint als Musteranfrage in
// Produktion → Neu und wird über den bestehenden „Muster drucken"-Workflow
// gedruckt. Keine Mail an den Kontakt.
async function sendeMuster(k) {
const name = [k.salutation, k.first_name, k.last_name].filter(Boolean).join(' ') || k.company || 'Kontakt';
try {
await api.post('/items/offer_requests', {
type: 'muster', status: 'neu', name, product: 'Muster',
email: k.email || 'muster@skrift.de', phone: k.phone || null,
address: {
company: k.company || '', salutation: k.salutation || '',
first_name: k.first_name || '', last_name: k.last_name || '',
street: [k.street, k.house_no].filter(Boolean).join(' '),
zip: k.zip || '', city: k.city || '', country: k.country || '',
},
});
meldung.value = 'Musterbestellung angelegt – erscheint in Produktion → Neu.'; setTimeout(() => { meldung.value = ''; }, 3500);
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Muster nicht anlegen.'; }
}
// Schickt dir (interne Adresse) die Eckdaten des Kontakts + deine Notiz per Mail.
async function sendeMail(k) {
if (!mailMich.note.trim()) { fehler.value = 'Bitte eine Notiz eingeben.'; return; }
try {
await api.post('/items/contact_actions', { contact: k.id, type: 'mail', status: 'erledigt', note: mailMich.note });
mailMich.open = false; mailMich.note = '';
meldung.value = 'Deine Notiz wurde dir per Mail geschickt.'; setTimeout(() => { meldung.value = ''; }, 3500);
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Mail nicht senden.'; }
}
async function erledige(a, k) {
try { await api.patch(`/items/contact_actions/${a.id}`, { status: 'erledigt' }); await ladeAktionen(k.id); }
catch (e) { fehler.value = e?.message || 'Konnte nicht als erledigt markieren.'; }
}
function exportCsv() {
const rows = gefiltert.value;
const cols = [['salutation', 'Anrede'], ['first_name', 'Vorname'], ['last_name', 'Nachname'],
['company', 'Firma'], ['street', 'Straße'], ['house_no', 'Hausnr'], ['zip', 'PLZ'], ['city', 'Ort'],
['country', 'Land'], ['email', 'E-Mail'], ['phone', 'Telefon'], ['lead_status', 'Status'],
['notes', 'Notizen'], ['sent_at', 'Versendet am']];
const esc = (v) => { const s = v == null ? '' : String(v); return /[",\n;]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; };
const csv = '' + cols.map((c) => c[1]).join(',') + '\n'
+ rows.map((r) => cols.map((c) => esc(r[c[0]])).join(',')).join('\n');
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
const a = document.createElement('a');
a.href = url; a.download = `kontakte-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
}
// ── Liste ──
function karte(k) {
return h('div', {
onClick: () => { aktivId.value = k.id; fehler.value = ''; },
style: CARD + 'cursor:pointer;margin-bottom:10px;display:flex;justify-content:space-between;align-items:flex-start;gap:12px;',
}, [
h('div', { style: 'min-width:0;' }, [
h('div', { style: 'font-size:16px;font-weight:600;margin-bottom:2px;' }, anzeigeName(k)),
subdued(adresse(k) || '—', 'font-size:13px;'),
k.phone ? subdued('📞 ' + k.phone, 'font-size:13px;margin-top:2px;') : null,
]),
h('div', { style: 'display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex-shrink:0;' }, [
chip(LEAD_LABEL[k.lead_status || 'neu'], LEAD_COLOR[k.lead_status || 'neu']),
]),
]);
}
function listenAnsicht() {
const kopf = h('div', { style: 'display:flex;flex-direction:column;gap:10px;margin-bottom:16px;' }, [
h('input', {
type: 'search', value: suche.value, placeholder: '🔎 Name, Ort, Telefon, E-Mail …',
style: S_INPUT, onInput: (e) => { suche.value = e.target.value; },
}),
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;' }, [
h('select', {
style: S_INPUT + 'width:auto;flex:1;min-width:150px;cursor:pointer;',
onChange: (e) => { statusFilter.value = e.target.value; },
}, [
h('option', { value: '', selected: statusFilter.value === '' }, 'Alle Status'),
...LEAD.map((l) => h('option', { value: l.key, selected: statusFilter.value === l.key }, l.label)),
]),
]),
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;justify-content:space-between;' }, [
subdued(`${gefiltert.value.length} Kontakt(e)`),
h('div', { style: 'display:flex;gap:8px;' }, [
btn('↻ Aktualisieren', laden),
btn('⭳ CSV-Export', exportCsv),
]),
]),
]);
const liste = loading.value ? subdued('Lädt …')
: gefiltert.value.length ? h('div', {}, gefiltert.value.map(karte))
: subdued('Keine Kontakte. Sobald ein Auftrag versendet wird, erscheinen die Empfänger hier automatisch.');
const hinweis = subdued('CSV-Import: Content → Kontakte → Import (oben rechts).', 'margin-top:16px;font-size:12px;');
return [kopf, liste, hinweis];
}
// ── Detail ──
function detailAnsicht() {
const k = aktiv.value;
if (!k) { aktivId.value = null; return listenAnsicht(); }
const idx = aktivIndex.value;
const gesamt = gefiltert.value.length;
const geh = (d) => {
const l = gefiltert.value;
let i = aktivIndex.value; if (i < 0) return;
i += d; if (i < 0 || i >= l.length) return;
aktivId.value = l[i].id; fehler.value = '';
};
const flash = (key) => savedFlash.value === key ? h('span', { style: 'color:#2e7d32;font-size:12px;margin-left:8px;' }, '✓ gespeichert') : null;
const navRow = h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:14px;' }, [
btn('◀ Liste', () => { aktivId.value = null; }, 'ghost'),
h('div', { style: 'display:flex;align-items:center;gap:8px;' }, [
h('button', { type: 'button', disabled: idx <= 0, onClick: () => geh(-1),
style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-subdued);border-radius:8px;padding:8px 12px;font-size:16px;' + (idx <= 0 ? 'opacity:.4;cursor:not-allowed;' : '') }, '◀'),
subdued(gesamt ? `${idx + 1} / ${gesamt}` : '–'),
h('button', { type: 'button', disabled: idx >= gesamt - 1, onClick: () => geh(1),
style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-subdued);border-radius:8px;padding:8px 12px;font-size:16px;' + (idx >= gesamt - 1 ? 'opacity:.4;cursor:not-allowed;' : '') }, '▶'),
]),
]);
const kopf = h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:14px;' }, [
h('div', {}, [
h('div', { style: 'font-size:22px;font-weight:700;' }, anzeigeName(k)),
adresse(k) ? subdued(adresse(k), 'margin-top:2px;') : null,
k.source_order?.order_number ? subdued('aus Auftrag ' + k.source_order.order_number, 'font-size:12px;margin-top:2px;') : null,
]),
chip(LEAD_LABEL[k.lead_status || 'neu'], LEAD_COLOR[k.lead_status || 'neu']),
]);
// Anruf-Button (GroundWire) – nur wenn eine Nummer da ist.
const anruf = k.phone
? h('a', { href: DIAL(k.phone),
style: `display:flex;align-items:center;justify-content:center;gap:8px;text-decoration:none;background:#2e7d32;color:#fff;border-radius:10px;padding:14px;font-size:17px;font-weight:600;margin-bottom:14px;` },
[h('span', {}, '📞'), h('span', {}, 'Anrufen · ' + k.phone)])
: subdued('Keine Telefonnummer – unten eintragen, dann erscheint der Anruf-Button.', 'margin-bottom:14px;');
const telFeld = feld(h('span', {}, ['Telefon', flash('phone')]),
h('input', { type: 'tel', value: k.phone || '', placeholder: '+49 …', style: S_INPUT, onBlur: saveIfChanged(k, 'phone') }));
const mailFeld = feld(h('span', {}, ['E-Mail', flash('email')]),
h('div', { style: 'display:flex;gap:8px;align-items:center;' }, [
h('input', { type: 'email', value: k.email || '', placeholder: 'name@…', style: S_INPUT, onBlur: saveIfChanged(k, 'email') }),
k.email ? h('a', { href: 'mailto:' + k.email, style: 'text-decoration:none;font-size:20px;' }, '✉') : null,
]));
const statusFeld = feld('Lead-Status',
h('select', { style: S_INPUT + 'cursor:pointer;', onChange: (e) => save(k.id, { lead_status: e.target.value }, 'lead_status') },
LEAD.map((l) => h('option', { value: l.key, selected: (k.lead_status || 'neu') === l.key }, l.label))));
const notizFeld = feld(h('span', {}, ['Notizen', flash('notes')]),
h('textarea', { value: k.notes || '', rows: 5, placeholder: 'Gesprächsnotizen …',
style: S_INPUT + 'resize:vertical;line-height:1.5;', onBlur: saveIfChanged(k, 'notes') }));
const namensFelder = h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px;' }, [
feld('Vorname', h('input', { value: k.first_name || '', style: S_INPUT, onBlur: saveIfChanged(k, 'first_name') })),
feld('Nachname', h('input', { value: k.last_name || '', style: S_INPUT, onBlur: saveIfChanged(k, 'last_name') })),
feld('Firma', h('input', { value: k.company || '', style: S_INPUT, onBlur: saveIfChanged(k, 'company') })),
feld('Ort', h('input', { value: k.city || '', style: S_INPUT, onBlur: saveIfChanged(k, 'city') })),
]);
// ── Standardaktionen (deutlich als farbige Buttons) ──
const aktBase = 'cursor:pointer;border-radius:8px;padding:11px 15px;font:inherit;font-size:14px;font-weight:600;'
+ 'border:1.5px solid;text-decoration:none;display:inline-flex;align-items:center;gap:6px;';
const aktStil = (rand, grund, text) => aktBase + `border-color:${rand};background:${grund};color:${text};`;
const S_RUECK = aktStil(ACCENT, 'rgba(30,80,165,.10)', ACCENT);
const S_ANGEBOT = aktStil('#e07b00', 'rgba(224,123,0,.12)', '#b35f00');
const S_MUSTER = aktStil('#6b3fa0', 'rgba(107,63,160,.12)', '#5a2f90');
const S_MAIL = aktStil('#0b7285', 'rgba(11,114,133,.12)', '#0b6273');
const aktionsLeiste = h('div', { class: 'sk-akt' }, [
h('button', { type: 'button', style: rueckruf.open ? aktStil(ACCENT, ACCENT, '#fff') : S_RUECK, onClick: () => { rueckruf.open = !rueckruf.open; if (rueckruf.open && !rueckruf.date) rueckruf.date = ymd(isoDatum(1)); } }, '📞 Rückruf planen'),
h('button', { type: 'button', style: S_ANGEBOT, onClick: () => sendeAngebot(k) }, '📧 Angebot senden'),
h('button', { type: 'button', style: S_MUSTER, onClick: () => sendeMuster(k) }, '🧪 Muster senden'),
h('button', { type: 'button', style: mailMich.open ? aktStil('#0b7285', '#0b7285', '#fff') : S_MAIL, onClick: () => { mailMich.open = !mailMich.open; } }, '✉️ Mail an mich'),
]);
const preset = (label, d) => h('button', { type: 'button',
style: 'cursor:pointer;border-radius:20px;padding:6px 12px;font:inherit;font-size:13px;border:1px solid var(--border-normal);'
+ (rueckruf.date === ymd(isoDatum(d)) ? `background:${ACCENT};color:#fff;` : 'background:var(--background-page);color:var(--foreground-normal);'),
onClick: () => { rueckruf.date = ymd(isoDatum(d)); } }, label);
const rueckrufForm = rueckruf.open ? h('div', { style: 'border:1px solid var(--border-normal);border-radius:10px;padding:14px;margin-top:10px;margin-bottom:12px;background:var(--background-subdued);' }, [
h('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;' }, [preset('Morgen', 1), preset('In 3 Tagen', 3), preset('Nächste Woche', 7)]),
h('div', { style: 'display:flex;gap:10px;flex-wrap:wrap;' }, [
h('div', { style: 'flex:2;min-width:150px;' }, feld('Datum', h('input', { type: 'date', value: rueckruf.date, style: S_INPUT, onInput: (e) => { rueckruf.date = e.target.value; } }))),
h('div', { style: 'flex:1;min-width:110px;' }, feld('Uhrzeit', h('input', { type: 'time', value: rueckruf.time, style: S_INPUT, onInput: (e) => { rueckruf.time = e.target.value; } }))),
]),
h('div', { style: 'display:flex;gap:8px;' }, [btn('Erinnerung setzen', () => planeRueckruf(k), 'primary'), btn('Abbrechen', () => { rueckruf.open = false; }, 'ghost')]),
]) : null;
const mailForm = mailMich.open ? h('div', { style: 'border:1px solid var(--border-normal);border-radius:10px;padding:14px;margin-top:10px;margin-bottom:12px;background:var(--background-subdued);' }, [
feld('Notiz an mich', h('textarea', { value: mailMich.note, rows: 3, placeholder: 'Was soll dir per Mail zugehen? (Eckdaten kommen automatisch mit)', style: S_INPUT + 'resize:vertical;', onInput: (e) => { mailMich.note = e.target.value; } })),
h('div', { style: 'display:flex;gap:8px;' }, [btn('Mail an mich senden', () => sendeMail(k), 'primary'), btn('Abbrechen', () => { mailMich.open = false; }, 'ghost')]),
]) : null;
const offene = aktionen.value.filter((a) => a.type === 'rueckruf' && a.status === 'offen');
const offeneListe = offene.length ? h('div', { style: 'margin-bottom:12px;display:flex;flex-direction:column;gap:6px;' }, offene.map((a) => h('div', {
style: 'display:flex;align-items:center;justify-content:space-between;gap:10px;border:1px solid var(--border-normal);border-radius:8px;padding:8px 12px;background:var(--background-page);',
}, [
h('div', { style: 'font-size:13px;' }, [
h('span', { style: 'font-weight:600;' }, '📞 Rückruf ' + (a.due_date ? new Date(a.due_date).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '')),
a.note ? h('div', { style: 'color:var(--foreground-subdued);margin-top:2px;white-space:pre-wrap;' }, a.note) : null,
]),
btn('✓ erledigt', () => erledige(a, k), 'default'),
]))) : null;
const aktionenBlock = h('div', { style: 'margin-bottom:14px;' }, [
h('div', { style: LBL }, 'Aktionen'), aktionsLeiste, rueckrufForm, mailForm, offeneListe,
]);
return [navRow, h('div', { style: CARD }, [kopf, anruf, notizFeld, statusFeld, aktionenBlock, telFeld, mailFeld, namensFelder])];
}
return () => {
const inhalt = h('div', { style: 'max-width:760px;margin:0 auto;padding:20px 16px 60px;' }, [
// Aktions-Buttons: am Desktop nebeneinander (gleich breit), am Handy gestapelt in voller Breite.
h('style', {}, '.sk-akt{display:flex;gap:8px;flex-wrap:wrap;}.sk-akt>*{flex:1 1 auto;justify-content:center;}@media(max-width:600px){.sk-akt{flex-direction:column;}.sk-akt>*{width:100%;flex:none;}}'),
fehler.value ? h('div', { style: 'background:var(--danger-25,rgba(198,40,40,.12));color:var(--danger,#c62828);padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:14px;' }, fehler.value) : null,
meldung.value ? h('div', { style: 'background:var(--success-25,rgba(46,125,50,.15));color:var(--success,#2e7d32);padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:14px;' }, meldung.value) : null,
aktivId.value ? detailAnsicht() : listenAnsicht(),
]);
return h(resolveComponent('private-view'), { title: 'Kontakte' }, {
default: () => h('div', { style: 'min-height:100%;background:var(--background-subdued,#f1f4f9);' }, [inhalt]),
});
};
},
});
export default {
id: 'skrift-kontakte',
name: 'Kontakte',
icon: 'contacts',
routes: [{ path: '', component: Board }],
};

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-kontakte",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Modul: Kontakte/Leads – handytaugliches Mini-CRM (Anrufen via GroundWire, Notizen, Durchblättern, CSV-Export).",
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -23,11 +23,59 @@ 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';
// Eingebaute Fallback-Vorlagen: greifen NUR, wenn in `email_templates` keine
// aktive Vorlage mit passendem Schlüssel existiert. So gehen Bestätigung und
// die kundenrelevanten Status-Mails auch ohne konfigurierte Vorlage sicher raus
// (analog zum Login-Fallback in skrift-auth). Interne Status (in_queue, gedruckt …)
// haben bewusst KEINEN Fallback → für die braucht es eine aktive Vorlage.
const FALLBACK_TEMPLATES = {
order_created: {
subject: 'Bestellbestätigung {{order_id}}',
body: `<p>Hallo {{name}},</p>
<p>vielen Dank für Ihre Bestellung <strong>{{order_id}}</strong>. Wir haben Ihren Auftrag erhalten und melden uns mit den nächsten Schritten.</p>`,
},
bezahlt: {
subject: 'Zahlung erhalten – Bestellung {{order_id}}',
body: `<p>Hallo {{name}},</p>
<p>Ihre Zahlung zur Bestellung <strong>{{order_id}}</strong> ist bei uns eingegangen. Wir starten nun mit der Bearbeitung.</p>`,
},
versendet: {
subject: 'Ihre Bestellung {{order_id}} ist unterwegs',
body: `<p>Hallo {{name}},</p>
<p>Ihre Bestellung <strong>{{order_id}}</strong> wurde versendet.</p>`,
},
abgeschlossen: {
subject: 'Bestellung {{order_id}} abgeschlossen',
body: `<p>Hallo {{name}},</p>
<p>Ihre Bestellung <strong>{{order_id}}</strong> ist abgeschlossen. Vielen Dank für Ihr Vertrauen!</p>`,
},
storniert: {
subject: 'Bestellung {{order_id}} storniert',
body: `<p>Hallo {{name}},</p>
<p>Ihre Bestellung <strong>{{order_id}}</strong> wurde storniert. Bei Fragen antworten Sie einfach auf diese E-Mail.</p>`,
},
};
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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
// Antwortadresse für Kundenmails – echte, überwachte Adresse statt „noreply".
const REPLY_TO = 'hello@skrift.de';
/** Grobe Text-Alternative aus dem HTML. Reine HTML-Mails ohne Text-Teil kosten
* bei Spamfiltern (SpamAssassin) sichere Punkte – der Text-Part senkt die Wertung. */
const htmlZuText = (html) => String(html || '')
.replace(/<\s*br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|tr|h[1-6]|li|table)>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
/** Formatiert einen Adress-Snapshot (billing/shipping) als HTML. */
function adresseHtml(a) {
if (!a || typeof a !== 'object') return '';
@@ -61,6 +109,45 @@ export default (register, { services, getSchema, logger }) => {
} catch { return {}; }
}
/** Konfigurierter Team-Empfänger (separat gelesen, damit ein evtl. noch nicht
* gebootstraptes Feld die Kundenmails nicht beeinträchtigt). */
async function teamEmpfaenger(schema) {
try {
const es = await new ItemsService('email_settings', { schema, accountability: null })
.readSingleton({ fields: ['team_email'] });
return (es?.team_email || '').trim();
} catch { return ''; }
}
/** Schlanke interne Benachrichtigung „Neue Bestellung …" (Empfänger konfigurierbar). */
async function teamNotify(orderId) {
try {
const schema = await getSchema();
const to = await teamEmpfaenger(schema);
if (!to) return;
const settings = await loadSettings(schema);
const order = await new ItemsService('orders', { schema, accountability: null })
.readOne(orderId, { fields: ['order_number', 'product.name', 'source'] });
if (!order) return;
// API-Aufträge NICHT einzeln melden – dafür gibt es die Tagesübersicht (18:00).
if (order.source === 'api') return;
const html = `<p>Neue Bestellung <strong>${order.order_number}</strong>${order.product?.name ? ` (${order.product.name})` : ''} – im Produktions-Board unter „Neu".</p>`;
const nachricht = {
to,
subject: `Neue Bestellung ${order.order_number}`,
html,
text: htmlZuText(html),
replyTo: REPLY_TO,
};
if (settings.sender_email) {
nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
}
await new MailService({ schema, accountability: null }).send(nachricht);
} catch (e) {
logger.warn(`[skrift-mailer] Team-Benachrichtigung: ${e.message}`);
}
}
/** AGB-PDF (aus email_settings.agb_file) als Nodemailer-Anhang, direkt von Platte. */
async function agbAnhang(schema, settings) {
if (!settings?.agb_file) return null;
@@ -78,8 +165,8 @@ export default (register, { services, getSchema, logger }) => {
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 tpl = await loadTemplate(key, schema) || FALLBACK_TEMPLATES[key];
if (!tpl) return; // weder aktive Vorlage noch Fallback → keine Mail
const order = await new ItemsService('orders', { schema, accountability: null }).readOne(orderId, {
fields: [
@@ -87,10 +174,13 @@ export default (register, { services, getSchema, logger }) => {
'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',
'product.name', 'product.input_mode', 'format.name',
],
});
if (!order?.customer_email) return;
// Datei-Produkte (Unterschriftenservice) erhalten KEINE Standard-Bestell-
// bestätigung – dafür geht eine eigene Vorlage aus dem Bestell-Endpunkt raus.
if (key === 'order_created' && order.product?.input_mode === 'datei') return;
const settings = await loadSettings(schema);
const name = [order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(' ')
@@ -128,7 +218,7 @@ export default (register, { services, getSchema, logger }) => {
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 };
const nachricht = { to: order.customer_email, subject: render(tpl.subject, vars), html, text: htmlZuText(html), replyTo: REPLY_TO };
// Absender aus den Einstellungen (z. B. „Skrift <noreply@skrift.de>").
if (settings.sender_email) {
nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
@@ -145,6 +235,8 @@ export default (register, { services, getSchema, logger }) => {
if (payload?.status === 'entwurf') return;
try { await sendForOrder(key, 'order_created'); }
catch (e) { logger.warn(`[skrift-mailer] order_created: ${e.message}`); }
// Schlanke interne Benachrichtigung „Neue Bestellung …" an das Team.
await teamNotify(key);
});
register.action('orders.items.update', async ({ keys, payload }) => {
@@ -155,4 +247,45 @@ export default (register, { services, getSchema, logger }) => {
catch (e) { logger.warn(`[skrift-mailer] status ${status}: ${e.message}`); }
}
});
// ── Tagesübersicht der API-Aufträge (statt Einzelmail je Auftrag) ───────────
// Einmal täglich (Default 18:00): pro API-Kunde eine Zusammenfassung der heute
// eingegangenen Aufträge, aufgeschlüsselt nach Versandtag.
const API_DIGEST_CRON = process.env.SKRIFT_API_DIGEST_CRON || '0 18 * * *';
register.schedule(API_DIGEST_CRON, async () => {
try {
const schema = await getSchema();
const to = await teamEmpfaenger(schema);
if (!to) return;
const settings = await loadSettings(schema);
const start = new Date(); start.setHours(0, 0, 0, 0);
const orders = await new ItemsService('orders', { schema, accountability: null }).readByQuery({
filter: { _and: [{ source: { _eq: 'api' } }, { date_created: { _gte: start.toISOString() } }] },
fields: ['order_number', 'shipping_day', 'api_client.id', 'api_client.name', 'product.name'],
limit: -1, sort: ['order_number'],
});
if (!orders || !orders.length) return;
const byClient = new Map();
for (const o of orders) {
const kid = o.api_client?.id ?? 'x';
let g = byClient.get(kid);
if (!g) { g = { name: o.api_client?.name || 'API-Kunde', orders: [] }; byClient.set(kid, g); }
g.orders.push(o);
}
for (const g of byClient.values()) {
const byDay = {};
for (const o of g.orders) { const d = o.shipping_day || 'ohne Versandtag'; byDay[d] = (byDay[d] || 0) + 1; }
const zeilen = Object.entries(byDay).map(([d, n]) => `<li>${esc(d)}: <strong>${n}</strong></li>`).join('');
const html = `<p>Heute über die API eingegangen von <strong>${esc(g.name)}</strong>: <strong>${g.orders.length}</strong> Auftrag/Aufträge.</p>`
+ `<p>Nach Versandtag:</p><ul>${zeilen}</ul>`
+ '<p>Im Produktions-Board unter „Neu" als Charge(n) gruppiert.</p>';
const nachricht = { to, subject: `API-Tagesübersicht ${g.name} – ${g.orders.length} neue`, html, text: htmlZuText(html), replyTo: REPLY_TO };
if (settings.sender_email) nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
try { await new MailService({ schema, accountability: null }).send(nachricht); }
catch (e) { logger.warn(`[skrift-mailer] API-Tagesmail ${g.name}: ${e.message}`); }
}
logger.info(`[skrift-mailer] API-Tagesübersicht verschickt: ${byClient.size} Kunde(n)`);
} catch (e) { logger.warn(`[skrift-mailer] API-Tageslauf: ${e.message}`); }
});
};

View File

@@ -0,0 +1,684 @@
/**
* Skrift – Modul „Neuer Auftrag"
* ===========================================================================
* Eigenständige, manuelle Auftragsanlage im Admin (eigenes Modul, kein Teil des
* Produktions-Boards). Nutzt ausschließlich die zentralen Backend-Endpunkte:
* POST /skrift-orders/preview – Vorschau (Admin ohne Kontingent)
* POST /skrift-orders/manual-order – Auftrag intern anlegen (keine Zahlung)
* Der Auftrag erscheint danach als „eingegangen" im Produktions-Board.
*
* Von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
*/
import { defineComponent, ref, reactive, onMounted, h, resolveComponent, Teleport } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const ACCENT = '#1E50A5';
// Weißes, randloses Eingabefeld – hebt sich durch Kontrast + leichten Schatten
// vom hellblauen Bereich ab (kein Rahmen).
const WFELD = 'background:#fff;border:none;border-radius:8px;padding:9px 12px;font:inherit;font-size:14px;color:#1A1A1A;outline:none;box-shadow:0 1px 2px rgba(0,0,0,.08);';
const WSELECT = WFELD + 'cursor:pointer;';
// Hellblauer Bereich (kein Rahmen).
const BEREICH = 'background:rgba(30,80,165,.06);border-radius:12px;padding:18px 20px;';
const STANDARD_PLATZHALTER = ['Anrede', 'Vorname', 'Nachname', 'Strasse', 'PLZ', 'Ort', 'Land', 'Briefnummer'];
function btn(label, onClick, kind) {
const base = 'cursor:pointer;border-radius:8px;padding:8px 16px;font:inherit;font-size:14px;border:none;';
const style = kind === 'primary' ? base + `background:${ACCENT};color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.15);`
: base + `background:#fff;color:${ACCENT};box-shadow:0 1px 2px rgba(0,0,0,.1);`;
return h('button', { type: 'button', onClick, style }, label);
}
function subdued(t, extra) { return h('div', { style: `color:var(--foreground-subdued);font-size:13px;${extra || ''}` }, t); }
function segToggle(value, opts, onSel) {
return h('div', { style: 'display:flex;width:100%;border-radius:8px;overflow:hidden;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.08);' },
opts.map((o) => h('button', {
type: 'button', onClick: () => onSel(o.key),
style: `flex:1;padding:10px 8px;font:inherit;font-size:13px;text-align:center;cursor:pointer;border:none;`
+ (value === o.key ? `background:${ACCENT};color:#fff;font-weight:700;` : `background:transparent;color:${ACCENT};`),
}, o.label)));
}
function bereichKopf(txt) { return h('div', { style: `font-size:15px;font-weight:800;color:${ACCENT};margin-bottom:14px;` }, txt); }
function label(txt) { return h('label', { style: 'display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);margin-bottom:4px;' }, txt); }
const NeuAuftrag = defineComponent({
setup() {
const api = useApi();
const produkte = ref([]);
const formate = ref([]);
const verbotenRe = ref(null);
const musterTexte = ref({ text_du: '', text_sie: '' });
// Frei anlegbare Mustertexte (Collection muster_texts). Fallback: Du/Sie aus muster_settings.
const musterListe = ref([]);
// Vorhandene Kundensignaturen (für „Unterschrift auf den Brief setzen").
const signaturListe = ref([]);
const geladen = ref(false);
// Aktives Unterwerkzeug: 'neu' (manueller Druck) oder 'signatur' (Unterschriftenservice).
const ansicht = ref('neu');
// Unterschriftenservice: Auftrag laden → Dokument + Kundensignaturen wählen →
// Vorschau (Marker + Offsets) → A4-SVGs herunterladen.
const sig = reactive({
modus: 'auftrag', // 'auftrag' (aus Auftrag laden) oder 'manuell' (direkt hochladen)
orderNr: '', order: null, docs: [], docId: null, docName: '', signaturen: [],
laden: false, pdf: '', msg: '', err: '',
});
// Datei nach Directus hochladen (liefert die Datei-ID).
async function sigUpload(file) {
const fd = new FormData();
fd.append('file', file);
const res = await api.post('/files', fd);
return res.data?.data?.id;
}
const leereZeile = () => ({ free_text: '', placeholders: {} });
const neu = reactive({
product: '', format: '', font: 'tilda',
// Relative Schriftgröße in % (getrennt Brief/Kuvert); 100 = Standard.
fontSizeLetter: 100, fontSizeEnvelope: 100,
// Unterschrift auf dem Brief ([[signatur1]]): SVG-Inhalt + Feinjustierung.
sigSvg: '', sigLabel: '', sigX: 0, sigY: 0, sigScale: 100,
// Mehrseitiges Schriftstück (Auto-Fluss über mehrere A4-Seiten).
mehrseitig: false, seitenInfo: '',
// Kuvert-Adresse je Empfänger: '5zeilen' (5 Zeilen) oder 'individuell' (einzeilig).
adressModus: '5zeilen', kuverts: false, realistic: true, customer_email: '', text: '', rows: [leereZeile()],
importOpen: false, importText: '',
docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '',
});
// Kuverts nur generieren, wenn angehakt (Beschriftung aus der Empfängeradresse).
const kuvertPayload = () => (neu.kuverts
? { needs_envelope: true, envelope_labeling: 'empfaenger', envelope_text: '' }
: { needs_envelope: false, envelope_labeling: 'keine', envelope_text: '' });
async function laden() {
try {
const [p, f, ps, mt, mtx, sg] = await Promise.all([
api.get('/items/products', { params: { limit: -1, filter: { active: { _eq: true }, type: { _in: ['letter', 'postcard'] } }, fields: 'key,name,type' } }),
api.get('/items/formats', { params: { limit: -1, filter: { active: { _eq: true } }, fields: 'key,name,product.key' } }),
api.get('/items/pricing_settings', { params: { fields: 'char_whitelist' } }).catch(() => ({ data: { data: {} } })),
api.get('/items/muster_settings', { params: { fields: 'text_du,text_sie' } }).catch(() => ({ data: { data: {} } })),
api.get('/items/muster_texts', { params: { limit: -1, filter: { active: { _eq: true } }, sort: 'sort', fields: 'id,label,text' } }).catch(() => ({ data: { data: [] } })),
api.get('/items/signatures', { params: { limit: -1, sort: '-date_created', fields: 'id,label,file,customer.first_name,customer.last_name' } }).catch(() => ({ data: { data: [] } })),
]);
produkte.value = p.data?.data || [];
formate.value = f.data?.data || [];
musterTexte.value = mt.data?.data || { text_du: '', text_sie: '' };
musterListe.value = mtx.data?.data || [];
signaturListe.value = sg.data?.data || [];
const wl = ps.data?.data?.char_whitelist;
// Zeilenumbrüche immer erlauben (Brieftext ist mehrzeilig). Einzeilige
// Felder entfernen \n/\r ohnehin separat, daher hier gefahrlos. Tabs (\t)
// sind erlaubt, damit man z.B. das Datum an den rechten Rand rücken kann.
try { verbotenRe.value = wl ? new RegExp(`[^${wl}\\n\\r\\t]`, 'g') : null; } catch { verbotenRe.value = null; }
neu.product = produkte.value[0]?.key || 'briefe';
neu.format = neuFormate()[0]?.key || '';
} catch (e) {
neu.err = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.';
} finally { geladen.value = true; }
}
onMounted(laden);
const saeubere = (v) => {
if (!verbotenRe.value) return v;
const treffer = String(v).match(verbotenRe.value);
if (!treffer || !treffer.length) return v;
const einmalig = [...new Set(treffer)].map((c) => (c === ' ' ? '␣' : c)).join(' ');
neu.hinweis = `Zeichen ${einmalig} ist nicht erlaubt und wurde entfernt.`;
setTimeout(() => { if (neu.hinweis.includes(einmalig)) neu.hinweis = ''; }, 4000);
return String(v).replace(verbotenRe.value, '');
};
// Alle aktiven Formate zur Auswahl (nicht aufs Produkt eingeschränkt) –
// so lassen sich auch freie/eigene Formate direkt wählen.
const neuFormate = () => formate.value;
// Wie viele Adresszeilen je Empfänger: 5 („5 Zeilen") bzw. 1 („Individueller Text").
const zeilenAnzahl = () => (neu.adressModus === 'individuell' ? 1 : 5);
const freitext = (e, z) => (String(e.free_text || '').split('\n')[z] ?? '');
const setFreitext = (i, z, v) => {
const max = zeilenAnzahl();
const zeilen = String(neu.rows[i].free_text || '').split('\n');
while (zeilen.length < max) zeilen.push('');
zeilen[z] = saeubere(String(v).replace(/[\r\n]/g, ''));
neu.rows[i].free_text = zeilen.slice(0, max).join('\n').replace(/\n+$/, '');
neu.previewSession = null;
};
// Individuelle Platzhalter je Empfänger (z. B. [[Gutschein]]).
const setPlatzhalter = (i, name, v) => {
neu.rows[i].placeholders = { ...(neu.rows[i].placeholders || {}), [name]: saeubere(v) };
neu.previewSession = null;
};
// Spalten-Einfügen: eine ganze Excel-Spalte (Strg+V) in EINE Zelle einfügen.
// Zeilenumbrüche trennen die Empfänger; Kommata bleiben Teil des Werts (kein
// Trennzeichen). Legt automatisch so viele Empfänger an wie eingefügte Zeilen
// und füllt die Spalte ab der aktuellen Zeile nach unten.
// Rückgabe true = mehrzeilig eingefügt (Default-Paste unterdrücken).
const spalteEinfuegen = (startI, apply, text) => {
const werte = String(text || '').replace(/\r\n?/g, '\n').split('\n');
while (werte.length > 1 && werte[werte.length - 1].trim() === '') werte.pop();
if (werte.length <= 1) return false; // Einzelwert → normales Einfügen zulassen
while (neu.rows.length < startI + werte.length) neu.rows.push(leereZeile());
werte.forEach((w, k) => apply(startI + k, w));
return true;
};
// Platzhalter aus dem Brieftext ziehen (ohne den automatischen [[Briefnummer]]).
const customPlaceholders = () => {
const set = new Set();
for (const m of String(neu.text || '').matchAll(/\[\[([^\]]+)\]\]/g)) {
const name = m[1].trim();
if (name && name.toLowerCase() !== 'briefnummer') set.add(name);
}
return [...set];
};
const addZeile = () => neu.rows.push(leereZeile());
const delZeile = (i) => { neu.rows.splice(i, 1); if (!neu.rows.length) neu.rows.push(leereZeile()); };
// Empfänger zählt, wenn eine Adresszeile ODER ein Platzhalterwert gefüllt ist.
const entries = () => neu.rows.filter((e) => String(e.free_text || '').trim()
|| (e.placeholders && Object.values(e.placeholders).some((v) => String(v || '').trim())));
// Aus Excel/CSV eingefügte Zeilen übernehmen. Erst die individuellen
// Platzhalter-Spalten, dann die Adresszeilen (Tab/Semikolon/Komma-getrennt).
function importUebernehmen() {
const ph = customPlaceholders();
const max = zeilenAnzahl();
const zeilen = String(neu.importText || '').split(/\r?\n/).map((z) => z.trim()).filter(Boolean);
if (!zeilen.length) { neu.importOpen = false; return; }
const rows = zeilen.map((z) => {
const zellen = z.split(/\t|;|,/).map((c) => saeubere(c.trim()));
const row = leereZeile();
ph.forEach((name, i) => { row.placeholders = { ...row.placeholders, [name]: zellen[i] || '' }; });
row.free_text = zellen.slice(ph.length, ph.length + max).join('\n');
return row;
});
neu.rows = rows;
neu.importText = ''; neu.importOpen = false; neu.previewSession = null;
}
// ── Unterschrift auf dem Brief ([[signatur1]]) ────────────────────────────
// Sorgt dafür, dass der Platzhalter im Text steht (sonst rendert keine Signatur).
function sigEinfuegen() {
if (!neu.text.includes('[[signatur1]]')) neu.text = `${neu.text.trimEnd()}\n[[signatur1]]`;
neu.previewSession = null;
}
async function sigWaehlen(id) {
neu.sigSvg = ''; neu.sigLabel = '';
if (!id) { neu.previewSession = null; return; }
const s = signaturListe.value.find((x) => String(x.id) === String(id));
if (!s?.file) { neu.err = 'Diese Signatur hat keine SVG-Datei.'; return; }
try {
const res = await api.get(`/assets/${s.file}`, { responseType: 'text' });
neu.sigSvg = typeof res.data === 'string' ? res.data : String(res.data || '');
neu.sigLabel = s.label || 'Unterschrift';
sigEinfuegen();
} catch { neu.err = 'Signatur konnte nicht geladen werden.'; }
}
function sigDatei(e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
const r = new FileReader();
r.onload = () => { neu.sigSvg = String(r.result || ''); neu.sigLabel = file.name; sigEinfuegen(); };
r.readAsText(file);
}
function sigEntfernen() {
neu.sigSvg = ''; neu.sigLabel = '';
neu.text = neu.text.replace(/\n?\[\[signatur1\]\]/g, '');
neu.previewSession = null;
}
// Signatur-Anteil der Payloads (Vorschau + Anlage). „brief"-Präfix, weil es
// bereits ein sigPayload() für den Unterschriften-Service (PDF) gibt.
const briefSigPayload = () => (neu.sigSvg
? { signatures: { signatur1: neu.sigSvg },
signature_offsets: { signatur1: { x: Number(neu.sigX) || 0, y: Number(neu.sigY) || 0, scale: (Number(neu.sigScale) || 100) / 100 } } }
: {});
async function preview() {
// Vorschau ohne Empfänger erlaubt – das Backend nutzt dann eine Blankozeile.
if (!neu.text.trim()) { neu.err = 'Bitte zuerst einen Text eingeben.'; return; }
neu.pLaedt = true; neu.err = ''; neu.msg = '';
try {
const kv = kuvertPayload();
const res = await api.post('/skrift-orders/preview', {
text: neu.text, font: neu.font, format: neu.format, realistic: neu.realistic,
envelope_labeling: kv.envelope_labeling, envelope_text: kv.envelope_text,
font_scale_letter: neu.fontSizeLetter / 100, font_scale_envelope: neu.fontSizeEnvelope / 100,
multi_page: neu.mehrseitig,
...briefSigPayload(),
entries: entries().slice(0, 3),
});
const d = res.data || {};
if (d.has_overflow) {
// Zeigen, wie viele Zeilen zu lang (aus dem ersten überlaufenden Schriftstück).
const of = (d.overflow || [])[0];
const zuViel = of ? Math.max(1, (Number(of.lineCount) || 0) - (Number(of.lineLimit) || 0)) : 0;
neu.err = of
? `Text ist ${zuViel} Zeile${zuViel === 1 ? '' : 'n'} zu lang – ${of.lineCount} statt max. ${of.lineLimit} Zeilen. Bitte kürzen.`
: 'Text ist zu lang für das Format – bitte kürzen.';
neu.docs = []; neu.previewSession = null;
} else { neu.docs = d.docs || d.previews || []; neu.previewSession = d.session || d.sessionId || null; }
} catch (e) { neu.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Vorschau fehlgeschlagen.'; }
finally { neu.pLaedt = false; }
}
// Seiten je Schriftstück berechnen (mehrseitig) und nach Seitenzahl gruppieren:
// „2 Seiten: 1, 3, 4" · „3 Seiten: 2, 6, 7" …
async function seitenBerechnen() {
if (!neu.text.trim()) { neu.err = 'Bitte zuerst einen Text eingeben.'; return; }
neu.seitenInfo = 'Berechne …';
try {
const res = await api.post('/skrift-orders/pagecount', {
text: neu.text, format: neu.format,
font_scale_letter: neu.fontSizeLetter / 100,
entries: entries(),
});
const counts = res.data?.counts || [];
const gruppen = new Map();
counts.forEach((c) => { const g = gruppen.get(c.pages) || []; g.push(c.index); gruppen.set(c.pages, g); });
const zeilen = [...gruppen.keys()].sort((a, b) => a - b)
.map((p) => `${p} Seite${p === 1 ? '' : 'n'}: ${gruppen.get(p).join(', ')}`);
neu.seitenInfo = zeilen.join('\n') || 'Keine Schriftstücke.';
} catch (e) {
neu.seitenInfo = '';
neu.err = e?.response?.data?.error || e?.message || 'Seitenzählung fehlgeschlagen.';
}
}
async function absenden() {
// Empfänger sind optional – ohne Empfänger genau ein Schriftstück (Blankozeile).
const ent = entries();
const payloadEntries = ent.length ? ent : [leereZeile()];
neu.err = ''; neu.msg = 'Wird angelegt …';
try {
const kv = kuvertPayload();
const res = await api.post('/skrift-orders/manual-order', {
product: neu.product, format: neu.format, font: neu.font,
realistic: neu.realistic,
font_scale_letter: neu.fontSizeLetter / 100, font_scale_envelope: neu.fontSizeEnvelope / 100,
multi_page: neu.mehrseitig,
...briefSigPayload(),
needs_envelope: kv.needs_envelope, envelope_labeling: kv.envelope_labeling, envelope_text: kv.envelope_text,
shipping_type: 'sammel',
customer_email: neu.customer_email.trim() || undefined,
text_template: neu.text, preview_session: neu.previewSession || undefined,
entries: payloadEntries,
});
const nr = res.data?.order_number || '';
// Felder bewusst NICHT zurücksetzen – der letzte Auftrag bleibt stehen,
// damit er sich als Vorlage für den nächsten leicht anpassen lässt.
neu.msg = `✓ Auftrag ${nr} angelegt – liegt im Produktions-Board unter „Neu". Felder bleiben stehen; mit „Formular leeren" zurücksetzen.`;
} catch (e) { neu.err = e?.response?.data?.error || e?.message || 'Anlegen fehlgeschlagen.'; neu.msg = ''; }
}
function zuruecksetzen() {
Object.assign(neu, {
product: produkte.value[0]?.key || 'briefe', format: neuFormate()[0]?.key || '', font: 'tilda',
fontSizeLetter: 100, fontSizeEnvelope: 100,
sigSvg: '', sigLabel: '', sigX: 0, sigY: 0, sigScale: 100, mehrseitig: false, seitenInfo: '',
adressModus: '5zeilen', kuverts: false, realistic: true, customer_email: '', text: '', rows: [leereZeile()],
importOpen: false, importText: '',
docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '',
});
}
const musterEinfuegen = (wert) => {
// Freie Mustertexte (per ID) bevorzugt; sonst Fallback Du/Sie.
let t = '';
if (wert === 'du' || wert === 'sie') t = wert === 'du' ? musterTexte.value.text_du : musterTexte.value.text_sie;
else t = (musterListe.value.find((m) => String(m.id) === String(wert)) || {}).text || '';
if (!t) return;
neu.text = neu.text.trim() ? `${neu.text}\n\n${t}` : t;
neu.previewSession = null;
};
// Weißes Dropdown / Eingabefeld.
const sel = (val, opts, on, extra) => h('select', { style: WSELECT + 'width:100%;' + (extra || ''), value: val, onChange: on },
opts.map((o) => h('option', { value: o.v, selected: String(val) === String(o.v) }, o.t)));
const svgUri = (svg) => { try { return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); } catch { return null; } };
const docBild = (d) => {
const svg = d && typeof d.svg === 'string' ? d.svg : null;
const src = svg ? svgUri(svg) : (typeof d === 'string' ? d : (d?.dataUrl || d?.url || d?.image || null));
const seite = (d?.page && d?.pageCount) ? ` · Seite ${d.page}/${d.pageCount}` : '';
const txt = (d?.art === 'umschlag' ? '✉ Kuvert' : '📄 Schriftstück') + seite;
return h('div', { style: 'display:flex;flex-direction:column;align-items:center;gap:4px;' }, [
src ? h('img', { src, onClick: () => { neu.lb = src; }, style: 'width:100%;max-height:260px;object-fit:contain;border-radius:6px;background:#fff;cursor:zoom-in;box-shadow:0 1px 3px rgba(0,0,0,.12);' })
: h('div', { style: 'width:100%;height:200px;border-radius:6px;display:flex;align-items:center;justify-content:center;background:#fff;color:var(--foreground-subdued);' }, 'Dok.'),
h('span', { style: 'font-size:11px;color:var(--foreground-subdued);' }, txt),
]);
};
// Weißes, einzeiliges Zellenfeld (feste Breite → Zeile scrollt bei Bedarf).
const feld = (lbl, val, on, ph, onPaste) => h('div', { style: 'display:flex;flex-direction:column;gap:3px;flex:1 1 0;min-width:90px;' }, [
h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);' }, lbl),
h('input', { value: val, placeholder: ph || '', style: WFELD + 'width:100%;padding:7px 9px;font-size:13px;', onInput: on, onPaste }),
]);
function formular() {
// Empfängerzeile: erst individuelle Platzhalter, dann die Adresszeilen.
const zeile = (e, i) => [
...customPlaceholders().map((name) => feld(name, e.placeholders?.[name] || '', (ev) => setPlatzhalter(i, name, ev.target.value), '',
(ev) => { if (spalteEinfuegen(i, (ri, v) => setPlatzhalter(ri, name, v), ev.clipboardData && ev.clipboardData.getData('text'))) ev.preventDefault(); })),
...Array.from({ length: zeilenAnzahl() }, (_, z) => feld('Zeile ' + (z + 1), freitext(e, z), (ev) => setFreitext(i, z, ev.target.value), '',
(ev) => { if (spalteEinfuegen(i, (ri, v) => setFreitext(ri, z, v), ev.clipboardData && ev.clipboardData.getData('text'))) ev.preventDefault(); })),
];
// Bereich 1 – Produkt
const b1 = h('div', { style: BEREICH }, [
bereichKopf('📄 Produkt'),
h('div', { style: 'display:grid;grid-template-columns:repeat(3,1fr);gap:12px;' }, [
h('div', {}, [label('Produkt'), sel(neu.product, produkte.value.map((p) => ({ v: p.key, t: p.name })), (e) => { neu.product = e.target.value; neu.format = neuFormate()[0]?.key || ''; neu.previewSession = null; })]),
h('div', {}, [label('Format'), sel(neu.format, neuFormate().map((f) => ({ v: f.key, t: f.name })), (e) => { neu.format = e.target.value; neu.previewSession = null; })]),
h('div', {}, [label('Schrift'), sel(neu.font, [['tilda', 'Tilda'], ['alva', 'Alva'], ['ellie', 'Ellie']].map(([v, t]) => ({ v, t })), (e) => { neu.font = e.target.value; neu.previewSession = null; })]),
]),
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px;' }, [
h('div', {}, [label('Schriftgröße Brief (%)'),
// Frei tippbar; erst beim Verlassen des Feldes auf 80–150 begrenzen.
h('input', { type: 'number', min: 80, max: 150, step: 5, value: neu.fontSizeLetter, style: WFELD + 'width:100%;',
onInput: (e) => { neu.fontSizeLetter = e.target.value; neu.previewSession = null; },
onBlur: (e) => { const n = Math.round(Number(e.target.value)); neu.fontSizeLetter = (Number.isFinite(n) && n) ? Math.max(80, Math.min(150, n)) : 100; } })]),
h('div', {}, [label('Schriftgröße Kuvert (%)'),
h('input', { type: 'number', min: 80, max: 150, step: 5, value: neu.fontSizeEnvelope, style: WFELD + 'width:100%;',
onInput: (e) => { neu.fontSizeEnvelope = e.target.value; neu.previewSession = null; },
onBlur: (e) => { const n = Math.round(Number(e.target.value)); neu.fontSizeEnvelope = (Number.isFinite(n) && n) ? Math.max(80, Math.min(150, n)) : 100; } })]),
]),
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;margin-top:12px;cursor:pointer;' }, [
h('input', { type: 'checkbox', checked: neu.realistic, onChange: (e) => { neu.realistic = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
'Natürliche Handschrift (leichte Variation)',
]),
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;margin-top:8px;cursor:pointer;' }, [
h('input', { type: 'checkbox', checked: neu.mehrseitig, onChange: (e) => { neu.mehrseitig = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
'Mehrseitiges Schriftstück (Text fließt automatisch über mehrere A4-Seiten)',
]),
]);
// Bereich 2 – Kuvert (Empfängertabelle)
const b2 = h('div', { style: BEREICH }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;' }, [
bereichKopf('✉ Kuvert'),
h('div', { style: 'width:300px;' }, [segToggle(neu.adressModus, [{ key: '5zeilen', label: '5 Zeilen' }, { key: 'individuell', label: 'Individueller Text' }], (k) => { neu.adressModus = k; neu.previewSession = null; })]),
]),
h('div', { style: 'display:flex;flex-direction:column;gap:8px;max-height:34vh;overflow:auto;' },
neu.rows.map((e, i) => h('div', { style: 'display:flex;gap:8px;align-items:center;' }, [
h('span', { style: `min-width:22px;font-weight:700;color:${ACCENT};` }, `#${i + 1}`),
h('div', { style: 'flex:1;min-width:0;display:flex;gap:6px;flex-wrap:nowrap;overflow-x:auto;padding-bottom:2px;' }, zeile(e, i)),
h('button', { type: 'button', onClick: () => delZeile(i), style: 'cursor:pointer;border:none;background:transparent;color:var(--danger,#c62828);font-size:18px;padding:2px 6px;' }, '×'),
]))),
h('div', { style: 'margin-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:12px;' }, [
btn('+ Empfänger', addZeile),
btn(neu.importOpen ? 'Import schließen' : '⇪ Aus Excel einfügen', () => { neu.importOpen = !neu.importOpen; }),
]),
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;margin-top:12px;' }, [
h('input', { type: 'checkbox', checked: neu.kuverts, onChange: (e) => { neu.kuverts = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
'Kuverts generieren',
]),
neu.importOpen ? h('div', { style: 'margin-top:12px;' }, [
subdued('Aus Excel kopieren und einfügen – eine Zeile pro Empfänger, Spalten mit Tab/Semikolon/Komma. '
+ 'Reihenfolge: erst individuelle Platzhalter, dann ' + (neu.adressModus === 'individuell' ? 'die eine Adresszeile.' : 'Zeile 1–5.'), 'margin-bottom:6px;'),
h('textarea', { value: neu.importText, rows: 5, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.importText = e.target.value; } }),
h('div', { style: 'margin-top:6px;' }, [btn('Übernehmen', importUebernehmen, 'primary')]),
]) : null,
]);
// Bereich 3 – Text (Mustertext-Dropdown direkt darüber)
const b3 = h('div', { style: BEREICH }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;' }, [
bereichKopf('✍ Text'),
h('select', { style: WSELECT + 'font-size:13px;padding:7px 10px;width:auto;', onChange: (e) => { if (e.target.value) { musterEinfuegen(e.target.value); e.target.value = ''; } } },
[h('option', { value: '' }, 'Mustertext einfügen …')].concat(
musterListe.value.length
? musterListe.value.map((m) => h('option', { value: m.id }, m.label || `Text ${m.id}`))
: [h('option', { value: 'sie' }, 'Sie-Form'), h('option', { value: 'du' }, 'Du-Form')],
)),
]),
label(`Brieftext · ${neu.text.length} Zeichen · [[Platzhalter]] erlaubt`),
h('textarea', { value: neu.text, rows: 8, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.text = saeubere(e.target.value); neu.previewSession = null; }, onKeydown: (e) => {
// Tab-Taste fügt einen echten Tabulator ein (statt den Fokus zu verschieben).
if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
e.preventDefault();
const ta = e.target, s = ta.selectionStart, en = ta.selectionEnd;
neu.text = saeubere(neu.text.slice(0, s) + '\t' + neu.text.slice(en));
neu.previewSession = null;
requestAnimationFrame(() => { try { ta.selectionStart = ta.selectionEnd = s + 1; } catch {} });
} }),
// ── Unterschrift auf den Brief ─────────────────────────────────────
h('div', { style: 'margin-top:16px;border-top:1px solid rgba(30,80,165,.15);padding-top:14px;' }, [
label('Unterschrift auf den Brief (optional) · Platzhalter [[signatur1]]'),
h('div', { style: 'display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;' }, [
h('select', { style: WSELECT + 'width:100%;', onChange: (e) => sigWaehlen(e.target.value) },
[h('option', { value: '' }, 'Vorhandene Unterschrift wählen …')].concat(
signaturListe.value.map((s) => {
const kunde = [s.customer?.first_name, s.customer?.last_name].filter(Boolean).join(' ');
return h('option', { value: s.id }, (s.label || `Signatur ${s.id}`) + (kunde ? ` · ${kunde}` : ''));
}))),
h('label', { style: 'cursor:pointer;border-radius:8px;padding:8px 16px;font:inherit;font-size:14px;border:none;background:#fff;color:' + ACCENT + ';box-shadow:0 1px 2px rgba(0,0,0,.1);white-space:nowrap;' }, [
'⇪ SVG hochladen',
h('input', { type: 'file', accept: '.svg,image/svg+xml', style: 'display:none;', onChange: sigDatei }),
]),
]),
neu.sigSvg ? h('div', { style: 'margin-top:12px;' }, [
h('div', { style: 'display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;' }, [
h('span', { style: `color:${ACCENT};font-size:13px;font-weight:600;` }, `✓ ${neu.sigLabel} – als [[signatur1]] im Text`),
btn('Entfernen', sigEntfernen),
]),
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;' }, [
h('div', {}, [label('Versatz X (mm)'), h('input', { type: 'number', step: 1, value: neu.sigX, style: WFELD + 'width:100%;', onInput: (e) => { neu.sigX = e.target.value; neu.previewSession = null; } })]),
h('div', {}, [label('Versatz Y (mm)'), h('input', { type: 'number', step: 1, value: neu.sigY, style: WFELD + 'width:100%;', onInput: (e) => { neu.sigY = e.target.value; neu.previewSession = null; } })]),
h('div', {}, [label('Größe (%)'), h('input', { type: 'number', step: 5, value: neu.sigScale, style: WFELD + 'width:100%;', onInput: (e) => { neu.sigScale = e.target.value; neu.previewSession = null; } })]),
]),
subdued('Positiv X = nach rechts, positiv Y = nach unten. Nach dem Anpassen „Aktualisieren" in der Vorschau klicken.', 'margin-top:8px;'),
]) : null,
]),
]);
// Bereich 4 – Kunde
const b4 = h('div', { style: BEREICH }, [
bereichKopf('👤 Kunde'),
h('div', { style: 'max-width:420px;' }, [
label('Kunde verknüpfen (E-Mail, optional)'),
h('input', { type: 'email', value: neu.customer_email, placeholder: 'kunde@example.de', style: WFELD + 'width:100%;', onInput: (e) => { neu.customer_email = e.target.value; } }),
]),
]);
const aktionen = h('div', { class: 'sk-btnrow', style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
neu.hinweis ? h('div', { style: `color:var(--danger,#c62828);font-size:13px;width:100%;` }, neu.hinweis) : null,
neu.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;width:100%;' }, neu.err) : null,
neu.msg ? h('div', { style: 'width:100%;' }, subdued(neu.msg)) : null,
btn(neu.pLaedt ? 'Vorschau …' : '👁 Vorschau', preview),
btn('In Druck geben', absenden, 'primary'),
btn('Formular leeren', zuruecksetzen),
]);
const links = h('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:16px;' }, [b1, b2, b3, b4, aktionen]);
// Vorschau rechts daneben (eigener hellblauer Bereich).
const rechts = h('div', { style: BEREICH + 'flex:0 0 340px;position:sticky;top:20px;align-self:flex-start;' }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;' }, [
bereichKopf('👁 Vorschau'),
btn(neu.pLaedt ? '…' : 'Aktualisieren', preview),
]),
neu.pLaedt ? subdued('Wird erzeugt …')
: neu.docs.length ? h('div', { style: 'display:flex;flex-direction:column;gap:12px;' }, neu.docs.slice(0, 12).map(docBild))
: subdued('Noch keine Vorschau. „Aktualisieren" klicken (Klick aufs Bild = groß).'),
neu.mehrseitig ? h('div', { style: 'margin-top:14px;border-top:1px solid rgba(30,80,165,.15);padding-top:12px;' }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px;' }, [
h('div', { style: `font-size:13px;font-weight:700;color:${ACCENT};` }, 'Seiten je Schriftstück'),
btn('Berechnen', seitenBerechnen),
]),
neu.seitenInfo
? h('pre', { style: 'margin:0;white-space:pre-wrap;font:inherit;font-size:13px;color:var(--foreground);' }, neu.seitenInfo)
: subdued('„Berechnen" klicken – zeigt, wie viele Seiten je Empfänger entstehen.'),
]) : null,
]);
return h('div', { style: 'display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap;' }, [links, rechts]);
}
// ── Unterschriftenservice ────────────────────────────────────────────────
async function sigLaden() {
sig.err = ''; sig.msg = ''; sig.pdf = ''; sig.order = null; sig.signaturen = []; sig.docs = []; sig.docId = null;
const nr = sig.orderNr.trim();
if (!nr) { sig.err = 'Bitte Auftragsnummer eingeben.'; return; }
sig.laden = true;
try {
const o = (await api.get('/items/orders', { params: {
filter: { order_number: { _eq: nr } }, limit: 1,
fields: 'id,order_number,source_file,source_files,customer.id',
} })).data?.data?.[0];
if (!o) { sig.err = `Auftrag ${nr} nicht gefunden.`; sig.laden = false; return; }
sig.order = o;
const docs = Array.isArray(o.source_files) && o.source_files.length
? o.source_files : (o.source_file ? [o.source_file] : []);
sig.docs = docs;
sig.docId = docs[0] || null;
if (o.customer?.id) {
const rows = (await api.get('/items/signatures', { params: {
filter: { customer: { _eq: o.customer.id } }, limit: -1,
fields: 'id,label,file,marker_key,offset_x_mm,offset_y_mm,width_mm',
} })).data?.data || [];
sig.signaturen = rows.filter((r) => r.file).map((r) => ({
file_id: r.file, label: r.label || 'Signatur', aktiv: true,
marker_key: r.marker_key || 'SIG1', position: 'genau',
width_mm: r.width_mm ?? 40, offset_x_mm: r.offset_x_mm ?? 0, offset_y_mm: r.offset_y_mm ?? 0,
}));
}
if (!sig.docs.length) sig.err = 'An diesem Auftrag ist kein Dokument hochgeladen.';
else if (!sig.signaturen.length) sig.err = 'Für diesen Kunden ist keine Unterschrift (mit Datei) hinterlegt.';
} catch (e) { sig.err = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.'; }
finally { sig.laden = false; }
}
const sigAktive = () => sig.signaturen.filter((s) => s.aktiv);
const sigPayload = () => ({ order: sig.order?.id || undefined, document_id: sig.docId || undefined, signatures: sigAktive() });
// PDF direkt hochladen (manueller Modus).
async function sigPdfUpload(file) {
if (!file) return;
sig.err = ''; sig.msg = 'Dokument wird hochgeladen …'; sig.pdf = '';
try { sig.docId = await sigUpload(file); sig.docName = file.name; sig.msg = ''; }
catch (e) { sig.err = 'Dokument-Upload fehlgeschlagen.'; sig.msg = ''; }
}
// Signatur-SVG hochladen und als Zeile hinzufügen (manuell oder ergänzend).
async function sigSvgUpload(file) {
if (!file) return;
sig.err = '';
try {
const id = await sigUpload(file);
sig.signaturen.push({ file_id: id, label: file.name, aktiv: true, marker_key: 'SIG' + (sig.signaturen.length + 1), position: 'genau', width_mm: 40, offset_x_mm: 0, offset_y_mm: 0 });
sig.pdf = '';
} catch (e) { sig.err = 'Signatur-Upload fehlgeschlagen.'; }
}
async function sigVorschau() {
if (!sig.docId) { sig.err = 'Bitte ein Dokument wählen/hochladen.'; return; }
if (!sigAktive().length) { sig.err = 'Mindestens eine Unterschrift aktivieren.'; return; }
sig.err = ''; sig.msg = 'Vorschau wird erzeugt …'; sig.pdf = '';
try {
const res = await api.post('/skrift-orders/signature-preview', sigPayload());
sig.pdf = 'data:application/pdf;base64,' + res.data.pdf_base64;
sig.msg = '';
} catch (e) { sig.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Vorschau fehlgeschlagen.'; sig.msg = ''; }
}
async function sigDownload() {
if (!sig.docId || !sigAktive().length) { sig.err = 'Dokument und Signatur wählen.'; return; }
sig.err = ''; sig.msg = 'A4-Signaturen werden erzeugt …';
try {
const res = await api.post('/skrift-orders/signature-render', sigPayload());
const bin = atob(res.data.zip_base64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
const url = URL.createObjectURL(new Blob([arr], { type: 'application/zip' }));
const a = document.createElement('a');
a.href = url; a.download = `signaturen-${res.data.order_number || sig.order.order_number}.zip`;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
sig.msg = 'ZIP heruntergeladen – A4-Signaturen an den Plotter geben.';
} catch (e) { sig.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Erzeugung fehlgeschlagen.'; sig.msg = ''; }
}
function signaturPanel() {
const sigFeld = (s, k, lbl, step) => h('div', { style: 'display:flex;flex-direction:column;gap:3px;flex:1;min-width:90px;' }, [
h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);' }, lbl),
h('input', { value: s[k], type: k === 'marker_key' ? 'text' : 'number', step: step || 'any',
style: WFELD + 'width:100%;padding:7px 9px;font-size:13px;',
onInput: (e) => { s[k] = k === 'marker_key' ? e.target.value : (parseFloat(e.target.value) || 0); sig.pdf = ''; } }),
]);
const b1 = h('div', { style: BEREICH }, [
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;' }, [
bereichKopf('✍ Unterschriften'),
h('div', { style: 'width:280px;' }, [segToggle(sig.modus, [{ key: 'auftrag', label: 'Aus Auftrag' }, { key: 'manuell', label: 'Manuell' }], (k) => { sig.modus = k; sig.pdf = ''; })]),
]),
sig.modus === 'auftrag'
? h('div', {}, [
h('div', { style: 'display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;' }, [
h('div', { style: 'flex:1;min-width:200px;' }, [label('Auftragsnummer'), h('input', { value: sig.orderNr, placeholder: 'SK-2026-…', style: WFELD + 'width:100%;', onInput: (e) => { sig.orderNr = e.target.value; } })]),
btn(sig.laden ? 'Lädt …' : 'Auftrag laden', sigLaden, 'primary'),
]),
sig.docs.length > 1 ? h('div', { style: 'margin-top:12px;' }, [label('Dokument'), sel(sig.docId, sig.docs.map((d, i) => ({ v: d, t: `Dokument ${i + 1}` })), (e) => { sig.docId = e.target.value; sig.pdf = ''; })]) : null,
])
: h('div', {}, [
label('Dokument (PDF mit Marker §§SIG…§§)'),
h('input', { type: 'file', accept: '.pdf', onChange: (e) => sigPdfUpload(e.target.files && e.target.files[0]), style: 'font-size:13px;' }),
sig.docName ? subdued(`${sig.docName} hochgeladen.`, 'margin-top:6px;') : null,
]),
]);
const b2 = h('div', { style: BEREICH }, [
bereichKopf('Unterschriften & Position'),
subdued(sig.modus === 'auftrag'
? 'Aus dem Kundenkonto vorbelegt (Marker + Feinjustierung), hier überschreibbar. Weitere Unterschrift-SVGs sind ergänzbar.'
: 'Unterschrift(en) als SVG hochladen und Marker/Position setzen.', 'margin-bottom:10px;'),
h('div', { style: 'margin-bottom:10px;' }, [
h('label', { style: `display:inline-block;cursor:pointer;color:${ACCENT};font-size:13px;` }, [
'+ Unterschrift (SVG) hinzufügen',
h('input', { type: 'file', accept: '.svg', style: 'display:none;', onChange: (e) => sigSvgUpload(e.target.files && e.target.files[0]) }),
]),
]),
...sig.signaturen.map((s) => h('div', { style: 'background:#fff;border-radius:8px;padding:10px 12px;margin-bottom:8px;box-shadow:0 1px 2px rgba(0,0,0,.08);' }, [
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;margin-bottom:8px;cursor:pointer;' }, [
h('input', { type: 'checkbox', checked: s.aktiv, onChange: (e) => { s.aktiv = e.target.checked; sig.pdf = ''; }, style: 'width:16px;height:16px;' }),
s.label,
]),
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
sigFeld(s, 'marker_key', 'Marker'),
sigFeld(s, 'width_mm', 'Breite mm'),
sigFeld(s, 'offset_x_mm', 'X mm'),
sigFeld(s, 'offset_y_mm', 'Y mm'),
]),
])),
h('div', { class: 'sk-btnrow', style: 'display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;' }, [
btn(sig.msg ? '…' : '👁 Vorschau', sigVorschau, 'primary'),
btn('⤓ A4-Signaturen (ZIP)', sigDownload),
]),
]);
const meldung = h('div', {}, [
sig.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;margin-top:8px;' }, sig.err) : null,
sig.msg ? subdued(sig.msg, 'margin-top:8px;') : null,
]);
const links = h('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:16px;' }, [b1, b2, meldung]);
const rechts = h('div', { style: BEREICH + 'flex:0 0 420px;position:sticky;top:20px;align-self:flex-start;min-height:200px;' }, [
bereichKopf('👁 Vorschau'),
sig.pdf
? h('iframe', { src: sig.pdf, style: 'width:100%;height:70vh;border:none;border-radius:8px;background:#fff;' })
: subdued('Noch keine Vorschau. Auftrag laden, Signatur wählen, „Vorschau" klicken.'),
]);
return h('div', { style: 'display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap;' }, [links, rechts]);
}
// Linke Navigationsspalte: Werkzeuge dieses Moduls.
const navItem = (label, aktiv, onClick) => h('div', {
onClick,
style: 'display:flex;align-items:center;gap:8px;border-radius:6px;padding:10px 12px;margin-bottom:3px;font-size:14px;cursor:pointer;'
+ (aktiv ? `background:${ACCENT};color:#fff;font-weight:600;` : 'color:var(--foreground-normal);'),
}, label);
return () => h(resolveComponent('private-view'), { title: ansicht.value === 'signatur' ? 'Unterschriften' : 'Neuer Auftrag' }, {
navigation: () => h('div', { style: 'padding:12px 10px;' }, [
navItem('📝 Neuer Auftrag', ansicht.value === 'neu', () => { ansicht.value = 'neu'; }),
navItem('✍ Unterschriften', ansicht.value === 'signatur', () => { ansicht.value = 'signatur'; }),
]),
default: () => [
// Mobil: Button-Reihen bleiben in einer Zeile und füllen die Breite.
h('style', {}, '@media(max-width:640px){.sk-btnrow>button{flex:1 1 auto!important;}}'),
h('div', { style: 'padding:24px 28px;background:var(--background-subdued,#f1f4f9);min-height:100%;' },
ansicht.value === 'signatur' ? signaturPanel() : (!geladen.value ? subdued('Lädt …') : formular())),
neu.lb ? h(Teleport, { to: 'body' }, [
h('div', {
onClick: () => { neu.lb = ''; },
style: 'position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:1100;display:flex;align-items:center;justify-content:center;padding:4vh 4vw;cursor:zoom-out;',
}, [h('img', { src: neu.lb, style: 'max-width:100%;max-height:90vh;object-fit:contain;background:#fff;border-radius:8px;' })]),
]) : null,
],
});
},
});
export default {
id: 'skrift-neuauftrag',
name: 'Manueller Druck',
icon: 'print',
routes: [{ path: '', component: NeuAuftrag }],
};

View File

@@ -0,0 +1,13 @@
{
"name": "directus-extension-skrift-neuauftrag",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Modul: Neuer Auftrag – manuelle Auftragsanlage (Format/Kuvert/Schrift, Struktur- oder 5-Zeilen-Adressen, Vorschau) über die zentralen Backend-Endpunkte.",
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "dist/index.js",
"host": "^11.0.0"
}
}

View File

@@ -153,7 +153,87 @@ async function paypalToken() {
}
const handler = (router, { services, getSchema, logger }) => {
const { ItemsService, MailService, UsersService, RolesService } = services;
const { ItemsService, MailService, UsersService, RolesService, AssetsService } = services;
// Signatur-Plotter-Service (intern über das npm-Netz per container_name erreichbar).
const SIGN_URL = (process.env.SKRIFT_SIGN_URL || 'http://skrift-signature-service:8000').replace(/\/$/, '');
/** Liest eine Directus-Datei (Upload) serverseitig als Buffer. */
async function dateiBytes(schema, id) {
const assets = new AssetsService({ schema, accountability: null });
const { stream } = await assets.getAsset(id, {});
const chunks = [];
for await (const c of stream) chunks.push(c);
return Buffer.concat(chunks);
}
/** Baut die Multipart-Anfrage an den Signatur-Service (Vorschau/Render). */
async function signatureForm(schema, pdfId, sigList) {
const pdfBytes = await dateiBytes(schema, pdfId);
const form = new FormData();
form.append('pdf', new Blob([pdfBytes], { type: 'application/pdf' }), 'dokument.pdf');
const jobs = [];
for (let i = 0; i < sigList.length; i += 1) {
const s = sigList[i] || {};
if (!s.file_id) continue;
const svgBytes = await dateiBytes(schema, s.file_id);
const svgName = `sig${i + 1}.svg`;
form.append('svgs', new Blob([svgBytes], { type: 'image/svg+xml' }), svgName);
const marker = String(s.marker_key || `SIG${i + 1}`).replace(/§/g, '').trim();
jobs.push({
token: `§§${marker}§§`,
svg_id: svgName,
position: ['genau', 'ueber', 'unter'].includes(s.position) ? s.position : 'genau',
breite_mm: Number(s.width_mm) > 0 ? Number(s.width_mm) : 45,
versatz_x_mm: Number(s.offset_x_mm) || 0,
versatz_y_mm: Number(s.offset_y_mm) || 0,
});
}
form.append('job', JSON.stringify(jobs));
return { form, jobCount: jobs.length };
}
/** Gemeinsame Vorbereitung für /signature-preview und /signature-render. */
async function signatureAufruf(req, res, pfad, contentType, key) {
const acc = req.accountability;
if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
const body = req.body || {};
// Entweder aus einem Auftrag (order) ODER manuell mit direkt hochgeladenem
// Dokument (document_id) – eins von beiden muss vorhanden sein.
if (!body.order && !body.document_id) return res.status(400).json({ error: 'order oder document_id erforderlich.' });
try {
const schema = await getSchema();
const svc = (c) => new ItemsService(c, { schema, accountability: null });
let ord = null;
if (body.order) {
ord = await svc('orders').readOne(body.order, { fields: ['id', 'order_number', 'source_file', 'source_files'] });
if (!ord) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
}
const pdfId = body.document_id || (ord ? (ord.source_file || (Array.isArray(ord.source_files) ? ord.source_files[0] : null)) : null);
if (!pdfId) return res.status(400).json({ error: 'Kein Dokument (Auftrag ohne Upload bzw. keine document_id).' });
const sigList = Array.isArray(body.signatures) ? body.signatures : [];
if (!sigList.length) return res.status(400).json({ error: 'Mindestens eine Signatur wählen.' });
const { form, jobCount } = await signatureForm(schema, pdfId, sigList);
if (!jobCount) return res.status(400).json({ error: 'Keine gültige Signatur (SVG-Datei fehlt).' });
const r = await fetch(`${SIGN_URL}${pfad}`, { method: 'POST', body: form });
if (!r.ok) {
const t = await r.text();
return res.status(502).json({ error: 'Signatur-Service-Fehler.', detail: `${r.status}: ${String(t).slice(0, 400)}` });
}
const buf = Buffer.from(await r.arrayBuffer());
return res.json({ [key]: buf.toString('base64'), content_type: contentType, order_number: ord?.order_number || 'manuell' });
} catch (err) {
logger.error(`[skrift-orders] ${pfad}: ${err.stack || err.message}`);
return res.status(500).json({ error: 'Signatur-Verarbeitung fehlgeschlagen.' });
}
}
// Vorschau-PDF (Dokument mit eingefügten Unterschriften).
router.post('/signature-preview', (req, res) => signatureAufruf(req, res, '/preview', 'application/pdf', 'pdf_base64'));
// A4-Signatur-SVGs als ZIP (zum Plotten in der lokalen Produktion).
router.post('/signature-render', (req, res) => signatureAufruf(req, res, '/render', 'application/zip', 'zip_base64'));
// Policy-konformes Zufallspasswort (Kunde meldet sich ohnehin passwortlos an).
const zufallsPasswort = () =>
@@ -189,7 +269,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', 'norm_qty', 'active'],
fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'norm_qty', 'price_factor_a', 'price_factor_e', 'input_mode', 'active'],
}))?.[0];
if (!product || !product.active) throw new Error('Unbekanntes oder inaktives Produkt.');
if (product.pricing_mode === 'auf_anfrage')
@@ -211,14 +291,22 @@ const handler = (router, { services, getSchema, logger }) => {
// (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;
// ACHTUNG: nicht `norm` nennen – das würde die Sanitizer-Funktion norm()
// überschatten, die weiter unten für norm(input.email) gebraucht wird.
const normMenge = Number(product.norm_qty) || 0;
// Kleinmengen-Aufschlag je Produkt: a = Deckel, e = Abkling-Exponent.
// Leer/ungültig → 1 bzw. 3 (bisheriges Verhalten). 0 bleibt gültig (kein Aufschlag).
const zahlOder = (v, d) => (v === null || v === undefined || v === '' || !Number.isFinite(Number(v))) ? d : Number(v);
const faktorA = zahlOder(product.price_factor_a, 1);
const faktorE = zahlOder(product.price_factor_e, 3);
const multVars = {
qty: menge, menge, norm,
qty: menge, menge, norm: normMenge,
a: faktorA, e: faktorE,
base_price: Number(product.base_price) || 0,
};
// Ohne Normalpreis-Menge gibt es keinen Kleinmengen-Aufschlag (Multiplikator 1).
let multiplier = 1;
if (norm > 0) {
if (normMenge > 0) {
try { multiplier = evalFormula(multFormula, multVars); } catch { multiplier = 1; }
}
if (!Number.isFinite(multiplier) || multiplier <= 0) multiplier = 1;
@@ -256,7 +344,10 @@ const handler = (router, { services, getSchema, logger }) => {
add('Porto Inland', priceOf('porto_inland') * inlandCount, true);
add('Porto Ausland', priceOf('porto_ausland') * auslandCount, true);
} else {
add('Sammelversand', priceOf('sammelversand'), false);
// Sammelversand-Staffel: bis 5 Schriftstücke günstiger (sammelversand_klein),
// ab 6 der reguläre Satz. Fallback 1,80 €, falls der kleine Satz fehlt.
const sammelKlein = priceItems['sammelversand_klein'] ? priceOf('sammelversand_klein') : 1.80;
add('Sammelversand', menge <= 5 ? sammelKlein : priceOf('sammelversand'), false);
}
// 3) Kuvert + Beschriftung – NUR bei Sammelversand.
@@ -394,8 +485,10 @@ const handler = (router, { services, getSchema, logger }) => {
// 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;
// Angemeldete Admin/App-Nutzer (Produktions-Modul) umgehen Kennung + Kontingent.
const istIntern = !!(req.accountability && (req.accountability.admin || req.accountability.app));
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 (!kennung && !nurPruefen && !istIntern) 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.' });
@@ -404,11 +497,11 @@ const handler = (router, { services, getSchema, logger }) => {
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({
const vorhanden = (nurPruefen || istIntern) ? 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) {
if (!nurPruefen && !istIntern && verbraucht >= PREVIEW_LIMIT) {
return res.status(429).json({
error: `Vorschau-Kontingent für heute aufgebraucht (${PREVIEW_LIMIT}).`,
remaining: 0, limit: PREVIEW_LIMIT,
@@ -423,8 +516,10 @@ const handler = (router, { services, getSchema, logger }) => {
const realistisch = body.realistic !== false;
// App-Formatschlüssel → Backend-Formate (wie im directus-controller).
// Ohne diese Abbildung rendert das Backend unbekannte Keys als A4.
// Bekannte App-Schlüssel übersetzen; eigene Format-Schlüssel (freie
// Formate) unverändert durchreichen – das Backend kennt sie aus render_formats.
const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
const briefFormat = LETTER_FORMAT[body.format] || 'a4';
const briefFormat = LETTER_FORMAT[body.format] || body.format || 'a4';
// A4 → DIN Lang, A6 → C6
const umschlagFormat = briefFormat === 'a4' ? 'din_lang' : 'c6';
@@ -450,15 +545,27 @@ const handler = (router, { services, getSchema, logger }) => {
return clean([name, strasse, ort].filter(Boolean).join('\n'));
};
// Relative Schriftgröße (manuelle Aufträge), getrennt für Brief/Kuvert. 1 = Standard.
const skala = (v) => { const n = Number(v); return Number.isFinite(n) ? Math.max(0.8, Math.min(1.5, n)) : 1; };
const fsLetter = skala(body.font_scale_letter);
const fsEnv = skala(body.font_scale_envelope);
// Signaturen (SVG je [[signaturN]]) + Feinjustierung – nur Brief, nicht Kuvert.
const sigMap = (body.signatures && typeof body.signatures === 'object') ? body.signatures : {};
const sigOff = (body.signature_offsets && typeof body.signature_offsets === 'object') ? body.signature_offsets : {};
// Mehrseitiges Schriftstück: Text fließt über mehrere Seiten (kein Überlauf-Fehler).
const mehrseitig = body.multi_page === true;
const basis = entries.length ? entries : [{}];
const letters = [];
basis.forEach((e, i) => {
// Dateinummer 1-basiert = Briefnummer (letter_001.svg ↔ Brief 1).
const nr = i + 1;
// Abweichender Text je Schriftstück (#5) hat Vorrang; sonst der Standardtext.
const briefText = (e.text && String(e.text).trim()) ? String(e.text) : String(body.text);
letters.push({
index: nr, type: 'letter', text: clean(String(body.text)),
index: nr, type: 'letter', text: clean(briefText),
format: briefFormat, font, realisticHandwriting: realistisch,
placeholders: platzhalterVon(e, i),
placeholders: platzhalterVon(e, i), fontScale: fsLetter,
signatures: sigMap, signatureOffsets: sigOff, multiPage: mehrseitig,
});
// Umschlag nur, wenn er auch beschriftet wird.
@@ -469,7 +576,7 @@ const handler = (router, { services, getSchema, logger }) => {
envelopeType: istFreitext ? 'custom' : 'recipient',
text: istFreitext ? clean(String(body.envelope_text || '')) : adressBlock(e),
format: umschlagFormat, font, realisticHandwriting: realistisch,
placeholders: platzhalterVon(e, i),
placeholders: platzhalterVon(e, i), fontScale: fsEnv,
});
}
});
@@ -513,13 +620,15 @@ const handler = (router, { services, getSchema, logger }) => {
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() });
docs.push({ art: istUmschlag ? 'umschlag' : 'brief', svg: await r.text(), page: datei.page, pageCount: datei.pageCount });
}
}
// Verbrauch erst nach Erfolg hochzählen.
// Verbrauch erst nach Erfolg hochzählen – intern (Admin) zählt nicht.
if (!istIntern && !nurPruefen && kennung) {
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,
@@ -534,6 +643,35 @@ const handler = (router, { services, getSchema, logger }) => {
}
});
/** Seitenzahl je Schriftstück (mehrseitig) – schlank, ohne Scriptalizer/SVG. */
router.post('/pagecount', async (req, res) => {
const body = req.body || {};
if (!body.text || !String(body.text).trim()) return res.status(400).json({ error: 'Text fehlt.' });
try {
const schema = await getSchema();
const { settings } = await loadPricingContext(schema);
const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
const briefFormat = LETTER_FORMAT[body.format] || body.format || 'a4';
const scale = Math.max(0.8, Math.min(1.5, Number(body.font_scale_letter) || 1));
const entries = (Array.isArray(body.entries) ? body.entries : []).map((e) => ({
placeholders: (e && typeof e.placeholders === 'object')
? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))]))
: {},
}));
const r = await fetch(`${BACKEND_URL}/api/order/pagecount`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
body: JSON.stringify({ text: clean(String(body.text)), format: briefFormat, fontScale: scale, entries }),
});
if (!r.ok) return res.status(502).json({ error: 'Seitenzählung fehlgeschlagen.' });
return res.json(await r.json());
} catch (err) {
logger.error(`[skrift-orders] pagecount: ${err.stack || err.message}`);
return res.status(500).json({ error: 'Seitenzählung fehlgeschlagen.' });
}
});
/** Aktuelles Kontingent abfragen, ohne etwas zu verbrauchen. */
router.get('/preview/quota', async (req, res) => {
try {
@@ -627,11 +765,47 @@ const handler = (router, { services, getSchema, logger }) => {
fs.createReadStream(ziel).pipe(res);
});
// ── Charge-Sammel-PDF ──────────────────────────────────────────────────────
// Führt die Brief-PDFs mehrerer API-Aufträge (Datei-Produkte) im Backend zu
// EINER PDF zusammen und reicht sie ans Admin-Board durch (Bürodrucker).
// Body: { orderIds: (number|string)[] }
router.post('/charge/pdf', async (req, res) => {
const acc = req.accountability;
if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
const orderIds = Array.isArray(req.body?.orderIds) ? req.body.orderIds.filter((v) => v != null) : [];
if (!orderIds.length) return res.status(400).json({ error: 'orderIds (Array) erforderlich.' });
try {
const r = await fetch(`${BACKEND_URL}/api/charge/merge-pdf`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
body: JSON.stringify({ orderIds }),
});
if (!r.ok) {
let msg = 'Sammel-PDF fehlgeschlagen.';
try { msg = (await r.json())?.message || msg; } catch { /* ignore */ }
return res.status(r.status === 400 || r.status === 404 ? r.status : 502).json({ error: msg });
}
const buf = Buffer.from(await r.arrayBuffer());
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="charge.pdf"');
return res.send(buf);
} catch (e) {
logger.warn(`[skrift-orders] charge/pdf: ${e.message}`);
return res.status(502).json({ error: 'Backend nicht erreichbar.' });
}
});
// ── 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 LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6l_v1' };
const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
// Lesbare Labels + Format-Schlüssel je Job-Typ. Der Schlüssel steuert die
// Format-Warteschlange (Agent druckt nur Jobs mit passendem machines.current_format).
const ENV_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
const LETTER_LABEL = { a4: 'A4', a6_hoch: 'A6 hoch', a6_quer: 'A6 quer' };
const briefFormatOf = (order) => ({ key: order.format?.key || 'a4', label: order.format?.name || LETTER_LABEL[order.format?.key] || order.format?.key || 'A4' });
const umschlagFormatOf = (order) => ({ key: order.envelope_format || 'c6', label: ENV_LABEL[order.envelope_format] || order.envelope_format || 'C6' });
router.post('/enqueue', async (req, res) => {
const acc = req.accountability;
@@ -642,7 +816,7 @@ const handler = (router, { services, getSchema, logger }) => {
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'],
fields: ['id', 'order_number', 'format.key', 'format.name', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
});
if (!order) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
@@ -676,8 +850,10 @@ const handler = (router, { services, getSchema, logger }) => {
const sec = s[typ];
if (!sec || sec.enabled === false) continue;
if (!sec.template) return res.status(400).json({ error: `Template für ${typ} fehlt.` });
const fmt = typ === 'umschlag' ? umschlagFormatOf(order) : briefFormatOf(order);
jobs.push(await svc('jobs').createOne({
order: order.id, machine: body.machine, type: typ, template: sec.template,
format: fmt.key, format_label: fmt.label,
params: clampParams(sec.params), numbers: (typeof sec.numbers === 'string' && sec.numbers.trim()) ? sec.numbers.trim() : null,
status: 'queued', priority: prio,
}));
@@ -685,14 +861,18 @@ const handler = (router, { services, getSchema, logger }) => {
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 bf = briefFormatOf(order);
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,
order: order.id, machine: body.machine, type: 'brief', template: briefTpl,
format: bf.key, format_label: bf.label, status: 'queued', priority: prio,
}));
if (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine') {
const ef = umschlagFormatOf(order);
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,
order: order.id, machine: body.machine, type: 'umschlag', template: envTpl,
format: ef.key, format_label: ef.label, status: 'queued', priority: prio,
}));
}
}
@@ -703,6 +883,57 @@ const handler = (router, { services, getSchema, logger }) => {
}
});
// ── Sammel-Druck: mehrere Aufträge auf einmal einreihen ────────────────────
// Body: { orders:[id…], mode:'queue'|'direct',
// byFormat:{ '<formatKey>': { template, machine, label } } }
// Pro Format wird einmal Template + Maschine gewählt; hier werden daraus die
// Jobs für alle markierten Aufträge erzeugt (Brief + ggf. Umschlag je Format).
router.post('/enqueue-bulk', 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 || {};
const orderIds = Array.isArray(body.orders) ? body.orders : [];
const byFormat = (body.byFormat && typeof body.byFormat === 'object') ? body.byFormat : {};
if (!orderIds.length) return res.status(400).json({ error: 'Keine Aufträge gewählt.' });
try {
const schema = await getSchema();
const svc = (c) => new ItemsService(c, { schema, accountability: null });
const orders = await svc('orders').readMany(orderIds, {
fields: ['id', 'order_number', 'format.key', 'format.name', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
});
// Direktdruck bekommt hohe Priorität, außer die Zielmaschine druckt schon.
const printing = await svc('jobs').readByQuery({ filter: { status: { _eq: 'printing' } }, limit: -1, fields: ['machine'] });
const busySet = new Set((printing || []).map((j) => String(j.machine)));
const mode = body.mode === 'direct' ? 'direct' : 'queue';
const prioFor = (m) => (mode === 'direct' && !busySet.has(String(m))) ? 100 : 0;
const jobs = [];
const uebersprungen = [];
const mkJob = async (order, typ, fmt) => {
const cfg = byFormat[fmt.key];
if (!cfg || !cfg.template || !cfg.machine) {
uebersprungen.push({ order: order.order_number, grund: `Kein Template/Maschine für ${typ === 'umschlag' ? 'Kuvert' : 'Format'} ${fmt.label}` });
return;
}
jobs.push(await svc('jobs').createOne({
order: order.id, machine: cfg.machine, type: typ, template: cfg.template,
format: fmt.key, format_label: cfg.label || fmt.label, status: 'queued', priority: prioFor(cfg.machine),
}));
};
for (const order of orders) {
await mkJob(order, 'brief', briefFormatOf(order));
if (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine') {
await mkJob(order, 'umschlag', umschlagFormatOf(order));
}
}
return res.json({ ok: true, jobs, mode, uebersprungen });
} catch (err) {
logger.error(`[skrift-orders] enqueue-bulk: ${err.stack || err.message}`);
return res.status(500).json({ error: 'Sammel-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) => {
@@ -742,6 +973,7 @@ const handler = (router, { services, getSchema, logger }) => {
// 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,
format: tp.format || 'a4', format_label: LETTER_LABEL[tp.format] || tp.format || 'A4',
});
await svc('test_prints').updateOne(id, { status: 'generiert', output_ref: ref });
return res.json({ ok: true, ref, job });
@@ -793,6 +1025,7 @@ const handler = (router, { services, getSchema, logger }) => {
const job = await svc('jobs').createOne({
machine: body.machine, template: body.template, type: 'brief', status: 'queued', output_ref: ref, priority: prio,
format: body.format || 'a4', format_label: LETTER_LABEL[body.format] || body.format || 'A4',
});
if (body.offer_request) {
try { await svc('offer_requests').updateOne(body.offer_request, { status: 'in_bearbeitung' }); } catch { /* nicht kritisch */ }
@@ -885,6 +1118,52 @@ const handler = (router, { services, getSchema, logger }) => {
return null;
}
/**
* Schlanke interne Benachrichtigung (neue Bestellung/Muster/Anfrage). Empfänger
* ist konfigurierbar (email_settings.team_email, Fallback SKRIFT_TEAM_EMAIL).
* Leer → keine Team-Mail.
*/
async function teamMail(schema, subject, html) {
try {
let to = TEAM_EMAIL;
try {
const es = await new ItemsService('email_settings', { schema, accountability: null }).readSingleton({ fields: ['team_email'] });
if (es && es.team_email !== undefined && es.team_email !== null) to = String(es.team_email).trim();
} catch { /* Feld evtl. noch nicht gebootstrapt → Fallback */ }
if (!to) return; // bewusst leer = keine Team-Mails
const mail = new MailService({ schema, accountability: null });
const von = await absender(schema);
await mail.send({ ...(von ? { from: von } : {}), to, subject, html });
} catch (e) {
logger.warn(`[skrift-orders] Team-Mail nicht versendet: ${e.message}`);
}
}
/**
* Sendet eine im Admin editierbare E-Mail-Vorlage (email_templates) an den
* Kunden. Platzhalter {{feld}} werden aus `vars` gefüllt. Fehlt/deaktiviert die
* Vorlage, passiert nichts (kein Fehler).
*/
async function sendeVorlage(schema, key, to, vars) {
try {
const tpl = (await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({
filter: { key: { _eq: key }, enabled: { _eq: true } }, limit: 1, fields: ['subject', 'body'],
}))?.[0];
if (!tpl) return;
const fuelle = (t) => String(t || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] ?? ''));
let signature = '';
try {
const es = await new ItemsService('email_settings', { schema, accountability: null }).readSingleton({ fields: ['signature'] });
if (es?.signature) signature = es.signature;
} catch { /* optional */ }
const mail = new MailService({ schema, accountability: null });
const von = await absender(schema);
await mail.send({ ...(von ? { from: von } : {}), to, subject: fuelle(tpl.subject), html: fuelle(tpl.body) + signature });
} catch (e) {
logger.warn(`[skrift-orders] Vorlagen-Mail „${key}" nicht versendet: ${e.message}`);
}
}
// ── Angebotsanfrage („Individuelles Angebot") ────────────────────────────
router.post('/offer-request', async (req, res) => {
const body = req.body || {};
@@ -925,25 +1204,11 @@ const handler = (router, { services, getSchema, logger }) => {
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.
// Schlanke interne Benachrichtigung.
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:
`<p><strong>${name}</strong> (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})</p>` +
`<p>Anfrage betrifft: <strong>${produkt || 'Individuelles Angebot'}</strong></p>` +
`<p>Menge: ${Number.isFinite(quantity) ? quantity : 'k. A.'}</p>` +
`<p style="white-space:pre-wrap">${clean(message)}</p>`,
});
} catch (mailErr) {
logger.warn(`[skrift-orders] Angebots-Mail nicht versendet: ${mailErr.message}`);
}
await teamMail(schema, `Neue Angebotsanfrage: ${name}`,
`<p>Neue Angebotsanfrage von <strong>${name}</strong> (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})`
+ `${produkt ? ` · ${produkt}` : ''} – im Board unter „Neu".</p>`);
return res.json({ ok: true });
} catch (err) {
@@ -980,7 +1245,7 @@ const handler = (router, { services, getSchema, logger }) => {
email,
phone: clean(String(body.phone || '').trim()) || null,
product: clean(String(body.product)),
message: null,
message: clean(String(body.message || '').trim().slice(0, 1000)) || null,
address: {
street: clean(String(body.street || '')),
zip: clean(String(body.zip || '')),
@@ -991,22 +1256,11 @@ const handler = (router, { services, getSchema, logger }) => {
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:
`<p><strong>${clean(String(body.name))}</strong> (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})</p>` +
`<p>Produkt: <strong>${clean(String(body.product))}</strong></p>` +
`<p>${clean(String(body.street))}, ${clean(String(body.zip))} ${clean(String(body.city))}, ` +
`${clean(String(body.country || ''))}</p>`,
});
} catch (mailErr) {
logger.warn(`[skrift-orders] Muster-Mail nicht versendet: ${mailErr.message}`);
}
const musterNachricht = clean(String(body.message || '').trim().slice(0, 1000));
await teamMail(schema, `Neue Musteranfrage: ${clean(String(body.name))}`,
`<p>Neue Musteranfrage von <strong>${clean(String(body.name))}</strong> (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})`
+ ` · Produkt: ${clean(String(body.product))} – im Board unter „Neu".</p>`
+ (musterNachricht ? `<p><strong>Nachricht:</strong><br>${musterNachricht.replace(/\n/g, '<br>')}</p>` : ''));
return res.json({ ok: true });
} catch (err) {
@@ -1015,6 +1269,241 @@ const handler = (router, { services, getSchema, logger }) => {
}
});
// ── Webhook: Anfragen direkt aus WordPress (JetFormBuilder) ────────────────
// POST /skrift-orders/inbound/:kind (kind = 'muster' | 'anfrage')
// Öffentlich erreichbar, daher mit Shared-Secret gesichert (SKRIFT_WEBHOOK_TOKEN,
// per ?token= oder Header X-Webhook-Token). Feldnamen = JetFormBuilder-Felder.
// JetFormBuilder sendet meist application/x-www-form-urlencoded – das parst
// Directus für Custom-Endpunkte NICHT in req.body. Daher lesen wir den
// Rohbody hier selbst und interpretieren ihn als urlencoded oder JSON.
router.use('/inbound', (req, res, next) => {
if (req.method !== 'POST') return next();
if (req.body && typeof req.body === 'object' && Object.keys(req.body).length) return next();
let raw = '';
req.setEncoding('utf8');
req.on('data', (c) => { raw += c; if (raw.length > 1000000) req.destroy(); });
req.on('end', () => {
const s = raw.trim();
if (s) {
try { req.body = JSON.parse(s); }
catch { req.body = Object.fromEntries(new URLSearchParams(s)); }
}
next();
});
req.on('error', () => next());
});
router.post('/inbound/:kind', async (req, res) => {
// Body ist durch die /inbound-Middleware bereits geparst (JSON/urlencoded).
let body = req.body || {};
if (typeof body === 'string') {
try { body = JSON.parse(body); }
catch { body = Object.fromEntries(new URLSearchParams(body)); }
}
const kind = String(req.params.kind || '').toLowerCase();
// Immer protokollieren, damit im Directus-Log sichtbar ist, was ankommt.
logger.info(`[skrift-orders] inbound/${kind}: content-type=${req.get('content-type') || '—'}, `
+ `felder=[${Object.keys(body).join(',')}], token=${req.query.token ? 'query' : (req.get('x-webhook-token') ? 'header' : (body.token ? 'body' : 'keiner'))}`);
const secret = process.env.SKRIFT_WEBHOOK_TOKEN || '';
const provided = req.query.token || req.get('x-webhook-token') || body.token || '';
if (!secret) {
logger.warn('[skrift-orders] inbound: SKRIFT_WEBHOOK_TOKEN ist nicht gesetzt – Webhook abgelehnt.');
return res.status(401).json({ error: 'Webhook nicht konfiguriert (SKRIFT_WEBHOOK_TOKEN fehlt serverseitig).' });
}
if (String(provided) !== secret) {
return res.status(401).json({ error: 'Ungültiger oder fehlender Webhook-Token.' });
}
if (!['muster', 'anfrage'].includes(kind)) return res.status(404).json({ error: 'Unbekannter Hook (muster|anfrage).' });
const name = String(body.name || '').trim();
const email = norm(body.email);
if (!name) return res.status(400).json({ error: 'Name fehlt.', felder: Object.keys(body) });
if (!isEmail(email)) return res.status(400).json({ error: 'Gültige E-Mail-Adresse fehlt.', felder: Object.keys(body) });
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];
const phone = clean(String(body.telefon || body.phone || '').trim()) || null;
let record; let subject; let mailHtml;
if (kind === 'muster') {
const produkt = body.produkt_interesse ? clean(String(body.produkt_interesse)) : null;
record = {
type: 'muster', name: clean(name), email, phone, product: produkt, message: null,
address: {
street: clean(String(body.street || '')),
zip: clean(String(body.zipcode || body.zip || '')),
city: clean(String(body.city || '')),
country: clean(String(body.country || '')),
},
status: 'neu', customer: customer?.id ?? null,
};
subject = `Neue Musteranfrage: ${name}`;
mailHtml =
`<p><strong>${clean(name)}</strong> (${email}${phone ? `, ${phone}` : ''})</p>` +
`<p>Interesse: <strong>${produkt || 'k. A.'}</strong></p>` +
`<p>${clean(String(body.street || ''))}, ${clean(String(body.zipcode || body.zip || ''))} ${clean(String(body.city || ''))}, ${clean(String(body.country || ''))}</p>`;
} else {
const unternehmen = clean(String(body.unternehmen || '').trim());
const nachricht = clean(String(body.nachricht || body.message || '').trim());
// offer_requests hat kein Firmenfeld → Unternehmen der Nachricht voranstellen.
const message = [unternehmen ? `Unternehmen: ${unternehmen}` : '', nachricht].filter(Boolean).join('\n\n') || null;
record = {
type: 'angebot', name: clean(name), email, phone, product: null, message,
status: 'neu', customer: customer?.id ?? null,
};
subject = `Neue Angebotsanfrage: ${name}`;
mailHtml =
`<p><strong>${clean(name)}</strong>${unternehmen ? ` · ${unternehmen}` : ''} (${email}${phone ? `, ${phone}` : ''})</p>` +
`<p style="white-space:pre-wrap">${nachricht || '—'}</p>`;
}
await svc('offer_requests').createOne(record);
logger.info(`[skrift-orders] inbound/${kind}: Anfrage von ${email} gespeichert.`);
// Schlanke interne Benachrichtigung (Kunden-Autoresponder macht JetFormBuilder).
await teamMail(schema, subject, mailHtml);
return res.json({ ok: true });
} catch (err) {
logger.error(`[skrift-orders] inbound/${kind}: ${err.stack || err.message}`);
return res.status(500).json({ error: 'Anfrage konnte nicht gespeichert werden.' });
}
});
// ── Manueller Auftrag (Admin) ────────────────────────────────────────────
// Legt intern einen Auftrag an (keine Zahlung, keine Datei-Eingabe), erzeugt
// die SVGs (Vorschau 1:1) und stellt ihn als „eingegangen" ins Board. Nutzt
// dieselben Bausteine wie die öffentliche Anlage – keine Doppellogik.
router.post('/manual-order', 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 || {};
const entries = Array.isArray(body.entries) ? body.entries : [];
if (!body.product) return res.status(400).json({ error: 'Produkt fehlt.' });
if (!entries.length) return res.status(400).json({ error: 'Mindestens eine Empfängerzeile 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 product = (await svc('products').readByQuery({
filter: { key: { _eq: body.product } }, limit: 1, fields: ['id', 'key', 'name', 'type'],
}))?.[0];
if (!product) return res.status(400).json({ error: 'Unbekanntes Produkt.' });
// Preis nur zur Aufzeichnung (kein Gate).
let quote = null;
try { quote = await calculatePrice(schema, { ...body, quantity: entries.length }); } catch { /* egal */ }
// Kunde optional verknüpfen (per E-Mail oder ID).
let customerId = null, customerEmail = null;
if (body.customer_email && isEmail(norm(body.customer_email))) {
const u = (await new UsersService({ schema, accountability: null }).readByQuery({
filter: { email: { _eq: norm(body.customer_email) } }, limit: 1, fields: ['id', 'email'],
}))?.[0];
if (u) { customerId = u.id; customerEmail = u.email; }
else customerEmail = norm(body.customer_email);
}
let formatId = null;
if (body.format) {
formatId = (await svc('formats').readByQuery({ filter: { key: { _eq: body.format } }, limit: 1, fields: ['id'] }))?.[0]?.id ?? null;
}
// Kuvertformat: explizite Angabe gewinnt; Datei-Produkte (Unterschriften)
// immer DIN Lang; A6/Postkarten → C6; sonst Standard DIN Lang.
const envelopeFormat = body.needs_envelope
? (body.envelope_format
|| (quote.product?.input_mode === 'datei' ? 'dinlang'
: (String(body.format || '').startsWith('a6') ? 'c6' : 'dinlang')))
: null;
const istInland = (e) => !e.country || /^(de|deutschland|germany)$/i.test(String(e.country).trim());
const countAusland = entries.filter((e) => !istInland(e)).length;
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')}`;
};
const orderData = {
customer: customerId, customer_email: customerEmail,
product: product.id, format: formatId,
person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
font: ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda',
realistic: body.realistic !== false,
// Relative Schriftgröße (getrennt Brief/Kuvert), 1 = Standard.
font_scale_letter: Math.max(0.8, Math.min(1.5, Number(body.font_scale_letter) || 1)),
font_scale_envelope: Math.max(0.8, Math.min(1.5, Number(body.font_scale_envelope) || 1)),
// Signatur(en) auf dem Brief (SVG je [[signaturN]]) + Feinjustierung, für die finale Generierung.
signatures_manual: (body.signatures && typeof body.signatures === 'object' && Object.keys(body.signatures).length)
? { signatures: body.signatures, offsets: (body.signature_offsets && typeof body.signature_offsets === 'object') ? body.signature_offsets : {} }
: null,
// Mehrseitiges Schriftstück (Auto-Fluss). Seiten werden bei der Generierung erzeugt.
multi_page: body.multi_page === true,
source: 'operator', status: 'in_queue', production_status: 'eingegangen',
payment_status: 'bezahlt', payment_method: 'rechnung',
shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel',
needs_envelope: !!body.needs_envelope,
envelope_labeling: body.envelope_labeling ?? null,
envelope_text: clean(body.envelope_text) ?? null,
envelope_format: envelopeFormat,
text_template: clean(body.text_template) ?? null,
preview_session: typeof body.preview_session === 'string' ? body.preview_session : null,
entries_count: entries.length,
count_inland: entries.length - countAusland, count_ausland: countAusland,
net_total: quote?.net_total ?? null, vat_amount: quote?.vat_amount ?? null, gross_total: quote?.gross_total ?? null,
};
let orderId, orderNumber;
for (let v = 0; v < 6; v += 1) {
orderNumber = await naechsteNummer();
try { orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); break; }
catch (e) { if (v === 5 || !/unique|duplicate|bereits|exists/i.test(String(e?.message || ''))) throw e; }
}
const entriesSvc = svc('order_entries');
let n = 0;
for (const e of entries) {
n += 1;
await entriesSvc.createOne({
order: orderId, letter_number: n,
salutation: clean(e.salutation) ?? null, first_name: clean(e.first_name) ?? null, last_name: clean(e.last_name) ?? null,
street: clean(e.street) ?? null, house_no: clean(e.house_no) ?? null, zip: clean(e.zip) ?? null,
city: clean(e.city) ?? null, country: clean(e.country) ?? null, free_text: clean(e.free_text) ?? null,
placeholders: e.placeholders && typeof e.placeholders === 'object'
? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))])) : null,
});
}
await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Manuell im Admin angelegt.' });
// Generierung IMMER anstoßen – für jedes Produkt. Das Backend entscheidet,
// was zu erzeugen ist (Schriftstücke bei Handschrift-Produkten, Kuverts immer
// wenn gewünscht; bei Datei-Produkten NUR die Kuverts). Nichts = No-Op.
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] Manuell-Generierung nicht gestartet: ${e.message}`));
return res.json({ ok: true, order_number: orderNumber, id: orderId });
} catch (err) {
logger.error(`[skrift-orders] manual-order: ${err.stack || err.message}`);
return res.status(400).json({ error: err.message });
}
});
// ── Auftrag anlegen ──────────────────────────────────────────────────────
router.post('/', async (req, res) => {
const body = req.body || {};
@@ -1039,7 +1528,7 @@ const handler = (router, { services, getSchema, logger }) => {
// 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'],
filter: { email: { _eq: email } }, limit: 1, fields: ['id', 'billing_address'],
}))?.[0];
if (!customer) {
const rolesSvc = new RolesService({ schema, accountability: null });
@@ -1057,9 +1546,25 @@ const handler = (router, { services, getSchema, logger }) => {
company: clean(body.company) ?? null,
phone: clean(body.phone) ?? null,
});
customer = { id };
customer = { id, billing_address: null };
}
// Rechnungsadresse automatisch ins Konto übernehmen, wenn dort noch KEINE
// hinterlegt ist (bestehende Adresse wird nicht überschrieben).
try {
const b = body.billing && typeof body.billing === 'object' ? body.billing : null;
const hatAdresse = customer.billing_address
&& (customer.billing_address.street || customer.billing_address.city);
if (b && (b.street || b.city) && !hatAdresse) {
await users.updateOne(customer.id, {
billing_address: {
street: clean(b.street) || '', zip: clean(b.zip) || '',
city: clean(b.city) || '', country: clean(b.country) || '',
},
});
}
} catch (e) { logger.warn(`[skrift-orders] Konto-Adresse nicht übernommen: ${e.message}`); }
// Spezial-/Kontaktprodukte (z. B. Unterschriftenservice) sind nur
// bestellbar, wenn sie für dieses Konto freigeschaltet wurden.
if (quote.product.type === 'contact') {
@@ -1080,9 +1585,9 @@ const handler = (router, { services, getSchema, logger }) => {
}))?.[0]?.id ?? null;
}
// Umschlagformat automatisch: A4 → DIN Lang, A6 → C6
// Umschlagformat automatisch: A6/Postkarte → C6, sonst Standard DIN Lang.
const envelopeFormat = body.needs_envelope
? (String(body.format || '').startsWith('a4') ? 'dinlang' : 'c6')
? (String(body.format || '').startsWith('a6') ? 'c6' : 'dinlang')
: null;
// Empfänger nach Inland/Ausland aufteilen (für die Auftragsübersicht).
@@ -1109,16 +1614,24 @@ const handler = (router, { services, getSchema, logger }) => {
format: formatId,
person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
font: ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda',
source: body.source === 'operator' ? 'operator' : (body.source === 'portal' ? 'portal' : 'configurator'),
status: 'wartet_auf_zahlung',
// Relative Schriftgröße (v.a. manuelle Aufträge), getrennt Brief/Kuvert. 1 = Standard.
font_scale_letter: Math.max(0.8, Math.min(1.5, Number(body.font_scale_letter) || 1)),
font_scale_envelope: Math.max(0.8, Math.min(1.5, Number(body.font_scale_envelope) || 1)),
source: ['operator', 'portal', 'extern'].includes(body.source) ? body.source : 'configurator',
// Extern (z. B. Etsy) wird über die Plattform bezahlt → nicht auf Zahlung warten.
status: body.source === 'extern' ? 'bezahlt' : 'wartet_auf_zahlung',
shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel',
needs_envelope: !!body.needs_envelope,
envelope_labeling: body.envelope_labeling ?? null,
envelope_text: clean(body.envelope_text) ?? 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,
source_files: Array.isArray(body.source_files) && body.source_files.length
? body.source_files
: (body.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',
@@ -1133,7 +1646,7 @@ const handler = (router, { services, getSchema, logger }) => {
gross_total: quote.gross_total,
voucher: quote.voucher?.id ?? null,
payment_method: paymentMethod,
payment_status: 'offen',
payment_status: body.source === 'extern' ? 'bezahlt' : 'offen',
};
let orderId, orderNumber;
@@ -1168,6 +1681,8 @@ const handler = (router, { services, getSchema, logger }) => {
placeholders: e.placeholders && typeof e.placeholders === 'object'
? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))]))
: null,
// Abweichender Text nur für dieses Schriftstück (#5).
text: (e.text && String(e.text).trim()) ? clean(String(e.text)) : null,
});
}
@@ -1190,18 +1705,24 @@ 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') {
// Separate, im Admin editierbare Kunden-Mail für Sonderfälle.
const kundeEmail = norm(body.email);
if (isEmail(kundeEmail)) {
const vars = { order_number: orderNumber, name: [body.first_name, body.last_name].filter(Boolean).join(' ') };
if (quote.product?.input_mode === 'datei') sendeVorlage(schema, 'unterschriften_eingang', kundeEmail, vars);
else if (body.source === 'extern') sendeVorlage(schema, 'extern_eingang', kundeEmail, vars);
}
// SVGs automatisch erzeugen, sobald der Kunde den Auftrag abschließt – IMMER
// und für jedes Produkt (das Backend entscheidet, was zu erzeugen ist:
// Schriftstücke bei Handschrift-Produkten, Kuverts immer wenn gewünscht;
// bei Datei-Produkten NUR die Kuverts, nicht das hochgeladene Dokument).
// Feuern & vergessen – blockiert die Antwort an den Kunden nicht.
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,

View File

@@ -19,14 +19,39 @@
*
* 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 { defineComponent, ref, reactive, computed, onMounted, onUnmounted, 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 LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6l_v1' };
// v1 = Empfängeradresse (Standardvorschlag). Für individuelle Beschriftung
// wählt der Operator im Popup die Variante 2 (kuvert_a_v2 / kuvert_b_v2).
const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
// Lesbare Kuvert-Formatlabels (für Sammel-Druck + Format-Warteschlange).
const ENV_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
const INLAND = /^(de|deutschland|germany)$/i;
// Versandtag → Wochentag (0=So…6=Sa). Für die Frist-Berechnung.
const WOCHENTAG = { montag: 1, dienstag: 2, mittwoch: 3, donnerstag: 4, freitag: 5, samstag: 6, sonntag: 0 };
// Garantie: kommt ein Auftrag in Woche W rein, geht er spätestens am angegebenen
// Versandtag der ÜBERNÄCHSTEN Woche (W+2) raus. Liefert das Fälligkeitsdatum.
function faelligBis(dateCreated, shippingDay) {
const wd = WOCHENTAG[String(shippingDay || '').toLowerCase()];
if (wd === undefined || !dateCreated) return null;
const d = new Date(dateCreated);
if (isNaN(d)) return null;
d.setHours(0, 0, 0, 0);
// Montag der Eingangswoche (Montag = Wochenstart).
const tag = d.getDay();
const montag = new Date(d);
montag.setDate(d.getDate() + (tag === 0 ? -6 : 1 - tag));
// Montag der übernächsten Woche + Offset bis zum Ziel-Wochentag.
const ziel = new Date(montag);
ziel.setDate(montag.getDate() + 14 + (wd === 0 ? 6 : wd - 1));
return ziel;
}
const dmY = (dt) => dt ? `${String(dt.getDate()).padStart(2, '0')}.${String(dt.getMonth() + 1).padStart(2, '0')}.${dt.getFullYear()}` : '';
const TABS = [
{ key: 'neu', label: '🆕 Neu' },
{ key: 'warteschlange', label: '⏳ Warteschlange' },
@@ -134,25 +159,54 @@ const Board = defineComponent({
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: '' });
// Massenauswahl (Auftrags-IDs) + Sammel-Druck-Popup (pro Format Template+Maschine).
const auswahl = ref([]);
// Aufgeklappte API-Chargen (im „Neu"-Reiter), Schlüssel = Charge-ID.
const expandedCharges = ref(new Set());
const bulk = reactive({ open: false, mode: 'queue', gruppen: [], msg: '', err: '' });
// Vom Kunden hochgeladene Original-Dokumente (kein generiertes SVG).
const dateien = reactive({ open: false, order: null, items: [], laden: false, err: '' });
// ── 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,'
+ 'envelope_labeling,shipping_type,shipping_day,keep_contacts,entries_count,count_inland,count_ausland,brief_gedruckt,kuvert_gedruckt,'
+ 'date_updated,date_created,format.key,format.name,product.name,product.input_mode,'
+ 'source,api_client.id,api_client.name,'
+ 'source_file,'
+ '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' } });
// source_files/versandt_am sind ggf. noch nicht gebootstrapt → separater Versuch.
return await api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER + ',versandt_am,source_files' } });
} catch {
// Feld „versandt_am" fehlt noch (Bootstrap nicht gelaufen) → ohne laden,
// Archiv nutzt ersatzweise date_updated.
// Neues Feld fehlt noch → ohne laden (Archiv nutzt ersatzweise date_updated,
// Dateien-Button fällt auf source_file zurück).
return api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER } });
}
}
async function laden() {
loading.value = true; fehler.value = '';
// Jobs mit Format-Feldern; fällt ohne sie zurück, falls der Bootstrap für die
// Format-Warteschlange noch nicht gelaufen ist (dann degradiert die Ansicht).
const JOB_FELDER = 'id,type,status,priority,numbers,output_ref,error,date_created,machine.id,machine.name,order.id,order.order_number';
async function ladeJobs() {
const filter = { status: { _in: ['queued', 'printing', 'failed'] } };
try {
return await api.get('/items/jobs', { params: { limit: -1, sort: '-priority,date_created', filter, fields: JOB_FELDER + ',format,format_label' } });
} catch {
return api.get('/items/jobs', { params: { limit: -1, sort: '-priority,date_created', filter, fields: JOB_FELDER } });
}
}
async function ladeMachines() {
try {
return await api.get('/items/machines', { params: { limit: -1, fields: 'id,name,active,status,last_seen,current_format' } });
} catch {
return api.get('/items/machines', { params: { limit: -1, fields: 'id,name,active,status,last_seen' } });
}
}
async function laden(leise = false) {
// leise = Hintergrund-Aktualisierung: kein Spinner, kein Fehlerbanner.
if (!leise) { loading.value = true; fehler.value = ''; }
try {
const [o, r, j, m, t, ms] = await Promise.all([
ladeOrders(),
@@ -161,12 +215,8 @@ const Board = defineComponent({
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' } }),
ladeJobs(),
ladeMachines(),
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: '' } } })),
]);
@@ -177,11 +227,25 @@ const Board = defineComponent({
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; }
if (!leise) fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.';
} finally { if (!leise) loading.value = false; }
}
onMounted(laden);
// Auto-Refresh: hält das Board aktuell, ohne dass man die Seite neu laden muss.
// Pausiert bei offenem Sammel-Dialog, im Hintergrund-Tab und während bereits
// geladen wird – damit laufende Bedienung nicht gestört wird.
let refreshTimer = null;
onMounted(() => {
refreshTimer = setInterval(() => {
// Bei offenem Dialog oder im Hintergrund-Tab nicht aktualisieren.
const dialogOffen = bulk.open || druck.open || manuell.open || empf.open || dateien.open;
if (loading.value || dialogOffen || (typeof document !== 'undefined' && document.hidden)) return;
laden(true);
}, 15000);
});
onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer); });
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));
@@ -238,17 +302,185 @@ const Board = defineComponent({
// ── Statuswechsel eines Auftrags ──
async function setStatus(order, neu) {
const patch = { production_status: neu };
const patch = { production_status: neu, production_status_changed_at: new Date().toISOString() };
// 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;
order.production_status_changed_at = patch.production_status_changed_at;
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.'; }
}
// Akquise: Empfänger dieses Auftrags ausdrücklich als Kontakte/Leads
// speichern. Der Kontakte-Hook legt sie beim Setzen an (einmalig).
async function toggleKeepContacts(order) {
const neu = !order.keep_contacts;
try {
await api.patch(`/items/orders/${order.id}`, { keep_contacts: neu });
order.keep_contacts = neu;
flash(`${order.order_number}: Empfänger ${neu ? 'werden in Kontakte gespeichert' : 'werden nicht als Kontakte gespeichert'}`);
} catch (e) { fehler.value = e?.message || 'Konnte „In Kontakte speichern" nicht ändern.'; }
}
// ── API-Chargen (Gruppierung im „Neu"-Reiter) ────────────────────────────
// Gruppenschlüssel: Kunde + Produkt/Typ + Versandtag + Eingangstag.
function bildeChargen(orders) {
const map = new Map();
for (const o of orders) {
const kunde = o.api_client?.id ?? 'x';
const produkt = o.product?.name || '?';
const tag = o.shipping_day || '';
const eingang = String(o.date_created || '').slice(0, 10);
const key = `${kunde}|${produkt}|${tag}|${eingang}`;
let c = map.get(key);
if (!c) {
c = { key, kundeName: o.api_client?.name || 'API-Kunde', produkt, versandtag: tag, eingang, orders: [],
faellig: faelligBis(o.date_created, tag) };
map.set(key, c);
}
c.orders.push(o);
}
// Nach Fälligkeit (früheste zuerst), dann Eingang.
return [...map.values()].sort((a, b) => (a.faellig?.getTime() || 9e15) - (b.faellig?.getTime() || 9e15));
}
const chargeGewaehlt = (c) => c.orders.length > 0 && c.orders.every((o) => istGewaehlt(o.id));
function toggleCharge(c) {
const alleDrin = chargeGewaehlt(c);
const ids = c.orders.map((o) => String(o.id));
auswahl.value = alleDrin ? auswahl.value.filter((x) => !ids.includes(x))
: [...new Set([...auswahl.value, ...ids])];
}
const toggleExpand = (key) => {
const s = new Set(expandedCharges.value);
s.has(key) ? s.delete(key) : s.add(key);
expandedCharges.value = s;
};
// Ausgewählte Auftrags-IDs nach Auftragsnummer sortiert – damit PDF-Stapel
// und Kuvert-Reihenfolge im Plotter zueinander passen (Kollation).
function auswahlSortiert() {
const byId = new Map(orders.value.map((o) => [String(o.id), o]));
return auswahl.value.slice().sort((a, b) => String(byId.get(String(a))?.order_number || '')
.localeCompare(String(byId.get(String(b))?.order_number || ''), undefined, { numeric: true }));
}
// Sammel-PDF der aktuell ausgewählten Aufträge (mehrere Chargen kombiniert).
async function chargePdf() {
const ids = auswahlSortiert();
if (!ids.length) { fehler.value = 'Keine Aufträge ausgewählt.'; return; }
meldung.value = 'Sammel-PDF wird erstellt …';
try {
const r = await api.post('/skrift-orders/charge/pdf', { orderIds: ids }, { responseType: 'blob' });
const url = URL.createObjectURL(r.data);
const a = document.createElement('a'); a.href = url; a.download = 'charge.pdf';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
flash(`Sammel-PDF erstellt (${ids.length} Auftrag/Aufträge).`);
} catch (e) {
fehler.value = e?.response?.data?.error || e?.message || 'Sammel-PDF fehlgeschlagen.'; meldung.value = '';
}
}
// ── Massenauswahl + Sammel-Druck ─────────────────────────────────────────
const istGewaehlt = (id) => auswahl.value.includes(String(id));
function toggleAuswahl(id) {
id = String(id);
auswahl.value = istGewaehlt(id) ? auswahl.value.filter((x) => x !== id) : [...auswahl.value, id];
}
const auswahlLeeren = () => { auswahl.value = []; };
// Aufträge, die im aktuellen Tab als Karten sichtbar sind (für „Alle auswählen").
function sichtbareOrders() {
switch (tab.value) {
case 'neu': return neueOrders.value;
case 'druck': return ordersByStatus('im_druck');
case 'kuvertierung': return ordersByStatus('kuvertieren');
case 'versandfertig': return ordersByStatus('versandfertig');
case 'versendet': return versendetAktiv();
case 'archiv': return archivListe();
default: return [];
}
}
const alleSichtbarGewaehlt = () => {
const ids = sichtbareOrders().map((o) => String(o.id));
return ids.length > 0 && ids.every((id) => auswahl.value.includes(id));
};
function alleAuswaehlen() {
const ids = sichtbareOrders().map((o) => String(o.id));
auswahl.value = alleSichtbarGewaehlt()
? auswahl.value.filter((id) => !ids.includes(id))
: [...new Set([...auswahl.value, ...ids])];
}
// Status für alle ausgewählten Aufträge auf einmal setzen.
async function bulkStatus(neuStatus) {
const sel = orders.value.filter((o) => istGewaehlt(o.id));
if (!sel.length || !neuStatus) return;
let ok = 0;
for (const o of sel) {
const patch = { production_status: neuStatus, production_status_changed_at: new Date().toISOString() };
if (neuStatus === 'versendet' && !o.versandt_am) patch.versandt_am = new Date().toISOString();
try {
await api.patch(`/items/orders/${o.id}`, patch);
o.production_status = neuStatus;
o.production_status_changed_at = patch.production_status_changed_at;
if (patch.versandt_am) o.versandt_am = patch.versandt_am;
ok++;
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Statuswechsel fehlgeschlagen.'; }
}
flash(`${ok} Auftrag/Aufträge → ${STATUS_LABEL[neuStatus] || neuStatus}`);
auswahlLeeren();
}
// Standard-Template je Format vorbelegen (Operator kann im Popup ändern).
function stdTpl(formatKey, typ) {
const key = typ === 'umschlag' ? (ENVELOPE_TPL[formatKey] || 'kuvert_a_v1') : (LETTER_TPL[formatKey] || 'a4_single');
const t = templates.value.find((x) => x.key === key && x.type === typ) || templates.value.find((x) => x.type === typ);
return t?.id ?? null;
}
function openBulk() {
const sel = orders.value.filter((o) => istGewaehlt(o.id));
if (!sel.length) return;
const map = {};
const add = (key, label, typ) => {
if (!map[key]) map[key] = { key, label, type: typ, aktiv: true, template: stdTpl(key, typ), machine: aktiveMaschinen.value[0]?.id ?? null };
};
for (const o of sel) {
add(o.format?.key || 'a4', o.format?.name || o.format?.key || 'A4', 'brief');
if (o.needs_envelope && o.envelope_labeling && o.envelope_labeling !== 'keine') {
const ek = o.envelope_format || 'c6';
add(ek, ENV_LABEL[ek] || ek, 'umschlag');
}
}
bulk.gruppen = Object.values(map);
bulk.mode = 'queue'; bulk.msg = ''; bulk.err = ''; bulk.open = true;
}
async function submitBulk() {
const byFormat = {};
const aktive = bulk.gruppen.filter((g) => g.aktiv);
if (!aktive.length) { bulk.err = 'Mindestens ein Format auswählen.'; return; }
for (const g of aktive) {
if (!g.template || !g.machine) { bulk.err = `Für „${g.label}" bitte Template und Maschine wählen.`; return; }
byFormat[g.key] = { template: g.template, machine: g.machine, label: g.label };
}
bulk.err = ''; bulk.msg = 'Wird eingereiht …';
try {
const res = await api.post('/skrift-orders/enqueue-bulk', { orders: auswahlSortiert(), mode: bulk.mode, byFormat });
const n = res.data?.jobs?.length || 0;
const skip = res.data?.uebersprungen || [];
flash(`${n} Job(s) eingereiht${skip.length ? ` · ${skip.length} übersprungen` : ''}.`);
bulk.open = false; auswahlLeeren(); await laden();
} catch (e) { bulk.err = e?.response?.data?.error || e?.message || 'Sammel-Einreihen fehlgeschlagen.'; bulk.msg = ''; }
}
// ── Format-Warteschlange: welches Format liegt an der Maschine ein? ───────
async function setMachineFormat(machine, key) {
try {
await api.patch(`/items/machines/${machine.id}`, { current_format: key || null });
machine.current_format = key || null;
flash(key ? `„${key}" als eingelegtes Format an ${machine.name} gesetzt.` : `Format an ${machine.name} geleert – Maschine wartet.`);
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Format konnte nicht gesetzt werden.'; }
}
// ── 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 });
@@ -256,7 +488,10 @@ const Board = defineComponent({
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;
// Bei individuellem Text die Variante 2 des Kuverts vorschlagen (v1 = Empfängeradresse).
let envKey = ENVELOPE_TPL[order.envelope_format] || 'kuvert_a_v1';
if (wantsEnv && order.envelope_labeling === 'freitext') envKey = envKey.replace('_v1', '_v2');
const et = wantsEnv ? (tplByKey(envKey, 'umschlag') || tplByKey(ENVELOPE_TPL[order.envelope_format], 'umschlag')) : null;
const n = Math.max(1, order.entries_count || 1);
druck.order = order;
druck.machine = aktiveMaschinen.value[0]?.id ?? null;
@@ -397,13 +632,44 @@ const Board = defineComponent({
catch (e) { fehler.value = e?.message || 'Fehlgeschlagen.'; }
}
// ── Vom Kunden hochgeladene Dokumente (Original, keine SVGs) ──
const orderDateiIds = (o) => (Array.isArray(o.source_files) && o.source_files.length)
? o.source_files : (o.source_file ? [o.source_file] : []);
async function openDateien(order) {
dateien.open = true; dateien.order = order; dateien.items = []; dateien.err = ''; dateien.laden = true;
try {
const items = [];
for (const id of orderDateiIds(order)) {
try {
const f = (await api.get('/files/' + id, { params: { fields: 'id,filename_download,type,filesize' } })).data?.data;
items.push({ id, name: f?.filename_download || id, type: f?.type || '' });
} catch { items.push({ id, name: id, type: '' }); }
}
dateien.items = items;
if (!items.length) dateien.err = 'Keine hochgeladenen Dateien an diesem Auftrag.';
} catch (e) { dateien.err = e?.message || 'Dateien konnten nicht geladen werden.'; }
finally { dateien.laden = false; }
}
async function dlDatei(item) {
try {
const res = await api.get('/assets/' + item.id, { responseType: 'blob' });
const url = URL.createObjectURL(res.data);
const a = document.createElement('a'); a.href = url; a.download = item.name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
} catch (e) { dateien.err = 'Download 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)),
]);
orderDateiIds(order).length ? btn('📎 Dateien', () => openDateien(order)) : null,
btn(order.keep_contacts ? '📇 In Kontakte gespeichert ✓' : '📇 In Kontakte speichern',
() => toggleKeepContacts(order), order.keep_contacts ? 'primary' : 'default'),
].filter(Boolean));
}
function statusSelect(order) {
return h('select', {
@@ -415,16 +681,21 @@ const Board = defineComponent({
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', { style: 'display:flex;gap:10px;align-items:flex-start;' }, [
h('input', { type: 'checkbox', checked: istGewaehlt(o.id), onChange: () => toggleAuswahl(o.id),
title: 'Für Sammel-Druck auswählen', style: 'width:16px;height:16px;margin-top:3px;cursor:pointer;' }),
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'),
o.shipping_day ? chip('📅 Versandtag: ' + o.shipping_day.charAt(0).toUpperCase() + o.shipping_day.slice(1), 'var(--primary-25,rgba(30,80,165,.14))') : null,
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'),
@@ -458,24 +729,83 @@ const Board = defineComponent({
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]);
}
// Charge-Karte: fasst API-Aufträge (Kunde+Typ+Versandtag+Eingangstag) zusammen.
function chargeCard(c) {
const auf = expandedCharges.value.has(c.key);
let faelligFarbe = 'var(--background-subdued)';
if (c.faellig) {
const tage = Math.ceil((c.faellig.getTime() - Date.now()) / 864e5);
faelligFarbe = tage < 0 ? 'var(--danger-25,rgba(198,40,40,.15))'
: tage <= 7 ? 'var(--warning-25,rgba(230,160,0,.18))' : 'var(--success-25,rgba(46,125,50,.12))';
}
const kopf = h('div', { style: 'display:flex;align-items:center;gap:10px;flex-wrap:wrap;' }, [
h('input', { type: 'checkbox', checked: chargeGewaehlt(c), onChange: () => toggleCharge(c),
title: 'Ganze Charge auswählen', style: 'width:16px;height:16px;cursor:pointer;flex-shrink:0;' }),
h('button', { type: 'button', onClick: () => toggleExpand(c.key),
style: 'cursor:pointer;border:none;background:transparent;font-size:15px;color:var(--foreground-normal);', title: 'Aufklappen' }, auf ? '▾' : '▸'),
h('div', { style: 'font-weight:700;font-size:15px;' }, `📦 ${c.kundeName} · ${c.produkt}`),
chip(`${c.orders.length} Aufträge`),
c.versandtag ? chip('📅 ' + c.versandtag.charAt(0).toUpperCase() + c.versandtag.slice(1)) : null,
c.faellig ? chip('⏰ fällig bis ' + dmY(c.faellig), faelligFarbe) : null,
]);
const body = auf ? h('div', { style: 'margin-top:12px;display:flex;flex-direction:column;gap:2px;' }, c.orders.map((o) => orderCard(o))) : null;
return h('div', { key: 'c' + c.key, style: CARD + `margin-bottom:14px;border-left:4px solid ${ACCENT};` }, [kopf, body]);
}
function tabInhalt() {
if (tab.value === 'neu') {
const items = [...neueOrders.value.map((o) => orderCard(o)), ...neueRequests.value.map(requestCard)];
// API-Aufträge zu Chargen bündeln; alles andere als Einzelkarten.
const apiOrders = neueOrders.value.filter((o) => o.source === 'api');
const restOrders = neueOrders.value.filter((o) => o.source !== 'api');
const items = [
...bildeChargen(apiOrders).map(chargeCard),
...restOrders.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;' }, [
return gruppen.map((g) => {
const m = g.machine;
const aktiv = (m.current_format || '').trim();
// Jobs je Maschine nach Format bündeln (jede Format-Queue eigenständig).
const fmt = {};
for (const j of g.jobs) {
const key = j.format || '';
(fmt[key] || (fmt[key] = { key, label: j.format_label || j.format || 'Ohne Format', jobs: [] })).jobs.push(j);
}
const formatBlock = (fg) => {
const laeuft = !!aktiv && fg.key === aktiv;
const kopf = h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:8px;' }, [
h('div', { style: 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;' }, [
h('span', { style: `font-weight:700;font-size:14px;color:${ACCENT};` }, fg.label),
chip(`${fg.jobs.length} Job(s)`),
laeuft ? chip('▶ liegt ein – wird gedruckt', 'var(--success-25,rgba(46,125,50,.15))')
: (aktiv ? chip('⏸ wartet (anderes Format liegt ein)') : null),
]),
laeuft
? btn('⏹ Stapel stoppen', () => setMachineFormat(m, null), 'danger')
: btn('▶ Dieses Format liegt ein → starten', () => setMachineFormat(m, fg.key), 'primary'),
]);
const cards = fg.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}`),
h('div', { style: 'font-weight:600;' }, `#${j.order?.order_number || j.output_ref || j.id} · ${j.type === 'umschlag' ? '✉ Kuvert' : '📄 Brief'}`),
subdued(`${j.numbers ? 'Nr. ' + j.numbers : 'alle'}${j.priority ? ' · Direkt' : ''}`),
]),
btn('Entfernen', () => cancelJob(j), 'danger'),
])),
]));
return h('div', { style: SECTION }, [kopf, ...cards]);
};
return h('div', { style: 'margin-bottom:22px;' }, [
h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:8px;' }, [
h('div', { style: 'font-weight:700;' }, `🖨 ${m.name}${m.status ? ' · ' + m.status : ''}`),
aktiv ? subdued(`Eingelegtes Format: ${aktiv}`) : subdued('Kein Format eingelegt – bitte einen Stapel starten.'),
]),
...Object.values(fmt).map(formatBlock),
]);
});
}
if (tab.value === 'druck') {
const list = ordersByStatus('im_druck');
@@ -568,6 +898,49 @@ const Board = defineComponent({
]);
return overlay([modalKopf(`Drucken · #${druck.order.order_number}`, () => { druck.open = false; }), body, foot], () => { druck.open = false; }, '640px');
}
function bulkModal() {
if (!bulk.open) return null;
const gruppeRow = (g) => {
const tplOpts = templates.value.filter((t) => t.type === g.type);
return h('div', { style: SECTION + (g.aktiv ? '' : 'opacity:.5;') }, [
// Format-Auswahl: abgehakt = kommt in den Sammeldruck (z. B. nur Schriftstücke,
// Kuverts abwählen). Abgewählte Formate werden komplett übersprungen.
h('label', { style: 'display:flex;align-items:center;gap:8px;font-weight:700;margin-bottom:10px;cursor:pointer;' }, [
h('input', { type: 'checkbox', checked: g.aktiv, onChange: (e) => { g.aktiv = e.target.checked; }, style: 'width:16px;height:16px;' }),
`${g.type === 'umschlag' ? '✉' : '📄'} ${g.label}`,
]),
g.aktiv ? h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px;' }, [
h('div', {}, [
h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Template'),
h('select', { style: S_SELECT + 'width:100%;', value: g.template, onChange: (e) => { g.template = e.target.value; } },
tplOpts.length ? tplOpts.map((t) => h('option', { value: t.id, selected: String(g.template) === String(t.id) }, t.name)) : [h('option', { value: '' }, 'kein Template')]),
]),
h('div', {}, [
h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Maschine'),
h('select', { style: S_SELECT + 'width:100%;', value: g.machine, onChange: (e) => { g.machine = e.target.value; } },
aktiveMaschinen.value.length ? aktiveMaschinen.value.map((mm) => h('option', { value: mm.id, selected: String(g.machine) === String(mm.id) }, mm.name)) : [h('option', { value: '' }, 'keine Maschine')]),
]),
]) : null,
]);
};
const body = h('div', { style: 'padding:18px 20px;' }, [
subdued(`${auswahl.value.length} Auftrag/Aufträge · pro Format einmal Template und Maschine wählen.`, 'margin-bottom:14px;'),
...bulk.gruppen.map(gruppeRow),
h('div', { style: 'margin:6px 0 4px;' }, [
segToggle(bulk.mode, [
{ key: 'direct', label: '⚡ Direktdruck' },
{ key: 'queue', label: '⏳ Warteschlange' },
], (k) => { bulk.mode = k; }),
]),
bulk.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;margin-top:8px;' }, bulk.err) : null,
bulk.msg ? subdued(bulk.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', () => { bulk.open = false; }),
btn('In Produktion geben', submitBulk, 'primary'),
]);
return overlay([modalKopf('Sammel-Druck', () => { bulk.open = false; }), body, foot], () => { bulk.open = false; }, '640px');
}
function musterModal() {
if (!manuell.open) return null;
const briefTpls = templates.value.filter((t) => t.type === 'brief');
@@ -653,6 +1026,23 @@ const Board = defineComponent({
h('div', { style: 'color:rgba(255,255,255,.7);font-size:12px;' }, 'Klicken zum Schließen'),
]);
}
function dateienModal() {
if (!dateien.open) return null;
const body = h('div', { style: 'padding:16px 20px;' }, [
subdued('Vom Kunden hochgeladene Original-Dokumente (keine generierten SVGs).', 'margin-bottom:12px;'),
dateien.laden ? subdued('Wird geladen …')
: dateien.err ? subdued(dateien.err)
: h('div', { style: 'display:flex;flex-direction:column;gap:8px;' }, dateien.items.map((it) =>
h('div', { style: CARD + 'display:flex;justify-content:space-between;align-items:center;gap:12px;' }, [
h('div', { style: 'min-width:0;' }, [
h('div', { style: 'font-weight:600;word-break:break-all;' }, it.name),
it.type ? subdued(it.type) : null,
]),
btn('⤓ Herunterladen', () => dlDatei(it), 'primary'),
]))),
]);
return overlay([modalKopf(`Dateien · #${dateien.order?.order_number || ''}`, () => { dateien.open = false; }), body], () => { dateien.open = false; }, '620px');
}
// ── Gesamt-Render ─────────────────────────────────────────────────────
// In <private-view> gehängt → normaler Directus-Content-Bereich + Kopf.
@@ -672,18 +1062,34 @@ const Board = defineComponent({
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; } }),
sichtbareOrders().length ? btn(alleSichtbarGewaehlt() ? '☐ Auswahl aufheben' : '☑ Alle auswählen', alleAuswaehlen) : null,
btn('↻ Aktualisieren', laden),
]);
// Leicht getönte Inhaltsfläche → weiße Karten heben sich klar ab (wie im Referenz-Dashboard).
const bulkBar = auswahl.value.length ? h('div', { style: `display:flex;align-items:center;gap:12px;flex-wrap:wrap;background:${ACCENT};color:#fff;padding:10px 16px;border-radius:8px;margin-bottom:12px;` }, [
h('span', { style: 'font-weight:600;' }, `${auswahl.value.length} Auftrag/Aufträge ausgewählt`),
h('button', { type: 'button', onClick: openBulk, style: `cursor:pointer;border:none;background:#fff;color:${ACCENT};border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;font-weight:600;` }, '🖨 In Druck geben'),
h('button', { type: 'button', onClick: chargePdf, style: `cursor:pointer;border:none;background:#fff;color:${ACCENT};border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;font-weight:600;` }, '📄 PDFs drucken'),
h('select', {
style: `cursor:pointer;border:none;background:#fff;color:${ACCENT};border-radius:7px;padding:7px 12px;font:inherit;font-size:13px;font-weight:600;`,
value: '', onChange: (e) => { if (e.target.value) { bulkStatus(e.target.value); e.target.value = ''; } },
}, [
h('option', { value: '' }, 'Status setzen …'),
...Object.entries(STATUS_LABEL).map(([k, v]) => h('option', { value: k }, v)),
]),
h('button', { type: 'button', onClick: auswahlLeeren, style: 'cursor:pointer;border:1px solid rgba(255,255,255,.6);background:transparent;color:#fff;border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;' }, 'Auswahl aufheben'),
]) : null;
const inhalt = h('div', { style: 'padding:24px 28px;min-height:100%;background:var(--background-subdued,#f1f4f9);' }, [
kopfzeile,
bulkBar,
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);
const modale = [druckModal(), bulkModal(), musterModal(), empfModal(), dateienModal(), lightboxModal()].filter(Boolean);
return h(resolveComponent('private-view'), { title: 'Produktion' }, {
navigation: () => navi,

View File

@@ -20,6 +20,7 @@
"dotenv": "^16.4.5",
"express": "^4.19.0",
"multer": "^2.0.2",
"pdf-lib": "^1.17.1",
"uuid": "^9.0.1",
"xml2js": "^0.6.2"
},

View File

@@ -0,0 +1,69 @@
/**
* Charge-Controller – Sammel-PDF für API-Chargen.
*
* Führt die hochgeladenen Brief-PDFs mehrerer Aufträge (Datei-Produkte, z. B.
* Unterschriften-Service) zu EINER PDF zusammen, die am Bürodrucker in einem
* Rutsch gedruckt werden kann. Reihenfolge = Auftragsnummer (aufsteigend), damit
* sie zur Kuvert-Reihenfolge im Plotter passt.
*/
const { PDFDocument } = require('pdf-lib');
const directus = require('../../services/directus-service');
/**
* POST /api/charge/merge-pdf
* Body: { orderIds: (number|string)[] } → application/pdf (zusammengeführt)
*/
async function mergePdf(req, res, next) {
try {
const ids = Array.isArray(req.body?.orderIds) ? req.body.orderIds.filter((v) => v != null) : [];
if (!ids.length) {
return res.status(400).json({ error: 'Invalid request', message: 'orderIds (array) is required.' });
}
const orders = await directus.fetchOrdersMinimal(ids);
if (!orders || !orders.length) {
return res.status(404).json({ error: 'Not found', message: 'Keine Aufträge gefunden.' });
}
// Datei-IDs in Auftragsnummern-Reihenfolge (orders ist bereits sortiert).
const fileIds = [];
for (const o of orders) {
const sf = Array.isArray(o.source_files) ? o.source_files.filter(Boolean) : [];
if (sf.length) fileIds.push(...sf);
else if (o.source_file) fileIds.push(o.source_file);
}
if (!fileIds.length) {
return res.status(400).json({ error: 'No files', message: 'Keine Druckdateien an den Aufträgen.' });
}
const merged = await PDFDocument.create();
let ok = 0;
const skipped = [];
for (const fid of fileIds) {
try {
const buf = await directus.fetchAssetBuffer(fid);
const src = await PDFDocument.load(buf, { ignoreEncryption: true });
const pages = await merged.copyPages(src, src.getPageIndices());
pages.forEach((p) => merged.addPage(p));
ok += 1;
} catch (e) {
skipped.push(fid);
console.warn(`[Charge] PDF ${fid} übersprungen: ${e.message}`);
}
}
if (!ok) {
return res.status(400).json({ error: 'Merge failed', message: 'Keine gültige PDF-Datei gefunden.' });
}
const bytes = await merged.save();
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="charge.pdf"');
res.setHeader('X-Skrift-Merged', String(ok));
if (skipped.length) res.setHeader('X-Skrift-Skipped', String(skipped.length));
return res.status(200).send(Buffer.from(bytes));
} catch (err) {
return next(err);
}
}
module.exports = { mergePdf };

View File

@@ -11,6 +11,8 @@
const { runGeneration } = require('./order-controller');
const directus = require('../../services/directus-service');
const { countLetterPages } = require('../../lib/svg-generator');
const { replacePlaceholders } = require('../../services/placeholder-service');
// Directus-Formatschlüssel → Backend-Formate
const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
@@ -45,10 +47,28 @@ function placeholdersOf(entry) {
/** Übersetzt einen Directus-Auftrag in die Dokumentliste für runGeneration. */
function buildDocuments(order, entries) {
const font = order.font || 'tilda';
const letterFormat = LETTER_FORMAT[order.format?.key] || 'a4';
// Eigene Format-Schlüssel (freie Formate) unverändert durchreichen.
const letterFormat = LETTER_FORMAT[order.format?.key] || order.format?.key || 'a4';
const envFormat = ENVELOPE_FORMAT[order.envelope_format] || 'c6';
const wantsEnvelope = !!order.needs_envelope && order.envelope_labeling !== 'keine';
const envelopeType = order.envelope_labeling === 'freitext' ? 'custom' : 'recipient';
// Datei-Produkte (z. B. Unterschriftenservice): das „Schriftstück" ist das vom
// Kunden hochgeladene Dokument – es wird NICHT generiert. Kuverts hingegen schon.
const istDatei = order.product?.input_mode === 'datei';
// Kuvert-only-Produkt: es wird KEIN Schriftstück generiert, nur die (adressierten)
// Kuverts. Etwaige hochgeladene PDF-Briefe liegen als source_files am Auftrag.
const istKuvertOnly = order.product?.type === 'envelope';
// Natürliche Handschrift-Variation (Default an), am Auftrag gespeichert.
const realistisch = order.realistic !== false;
// Relative Schriftgröße (getrennt Brief/Kuvert), aus dem Auftrag. 1 = Standard.
const fontScaleLetter = Number(order.font_scale_letter) || 1;
const fontScaleEnv = Number(order.font_scale_envelope) || 1;
// Signatur(en) auf dem Brief (manuelle Aufträge): SVG je [[signaturN]] + Feinjustierung.
const sigManual = order.signatures_manual && typeof order.signatures_manual === 'object' ? order.signatures_manual : null;
const sigMap = sigManual?.signatures || {};
const sigOff = sigManual?.offsets || {};
// Mehrseitiges Schriftstück (Auto-Fluss über N Seiten) – nur wenn am Auftrag gesetzt.
const istMehrseitig = !!order.multi_page;
const documents = [];
@@ -58,24 +78,35 @@ function buildDocuments(order, entries) {
// Briefnummer 1 gehört. Vorschau nummeriert identisch (siehe skrift-orders).
const nr = Number(entry.letter_number) || (i + 1);
if (!istDatei && !istKuvertOnly) {
// Abweichender Text je Schriftstück (#5) hat Vorrang; sonst Standardtext.
const briefText = (entry.text && String(entry.text).trim()) ? entry.text : (order.text_template || '');
documents.push({
index: nr,
type: 'letter',
text: order.text_template || '',
text: briefText,
format: letterFormat,
font,
placeholders,
fontScale: fontScaleLetter,
signatures: sigMap,
signatureOffsets: sigOff,
multiPage: istMehrseitig,
realisticHandwriting: realistisch,
});
}
if (wantsEnvelope) {
documents.push({
index: nr,
type: 'envelope',
envelopeType,
text: envelopeType === 'recipient' ? addressText(entry) : (order.text_template || ''),
text: envelopeType === 'recipient' ? addressText(entry) : (order.envelope_text || order.text_template || ''),
format: envFormat,
font,
placeholders,
fontScale: fontScaleEnv,
realisticHandwriting: realistisch,
});
}
});
@@ -115,6 +146,12 @@ async function generateFromDirectus(req, res, next) {
const { documents, letterFormat, envFormat, wantsEnvelope } = buildDocuments(order, loaded.entries);
// Nichts zu generieren (z. B. Datei-Produkt ohne Kuvert) → sauber überspringen.
if (!documents.length) {
console.log(`[Directus] ${order.order_number}: nichts zu generieren (Datei-Produkt ohne Kuvert).`);
return;
}
console.log(`[Directus] Generiere ${order.order_number}: ${documents.length} Dokumente`);
const result = await runGeneration({
@@ -162,4 +199,27 @@ async function generateFromDirectus(req, res, next) {
}
}
module.exports = { generateFromDirectus };
/**
* POST /api/order/pagecount
* Body: { text, format, fontScale, entries: [{ placeholders }] }
* Liefert je Dokument die Seitenzahl (nur Umbruch-Zählung, kein Scriptalizer/SVG).
*/
function pageCounts(req, res) {
const { text, format, fontScale, entries } = req.body || {};
if (!text || !String(text).trim()) {
return res.status(400).json({ error: 'Invalid request', message: 'text is required' });
}
const fmt = format || 'a4';
const scale = Number(fontScale) || 1;
const list = Array.isArray(entries) && entries.length ? entries : [{}];
const counts = list.map((e, i) => {
const ph = { ...(e && typeof e.placeholders === 'object' ? e.placeholders : {}), Briefnummer: String(i + 1) };
let pages = 1;
try { pages = countLetterPages(replacePlaceholders(String(text), ph), fmt, { fontScale: scale }); }
catch { pages = 1; }
return { index: i + 1, pages };
});
return res.json({ counts });
}
module.exports = { generateFromDirectus, pageCounts };

View File

@@ -3,7 +3,7 @@ const fsSync = require('fs');
const path = require('path');
const config = require('../../config');
const { scriptalizeBatch } = require('../../services/scriptalizer-service');
const { generateLetterSVG, generateEnvelopeSVG } = require('../../lib/svg-generator');
const { generateLetterSVG, generateEnvelopeSVG, generateLetterPages } = require('../../lib/svg-generator');
const { replacePlaceholders, generateAllCSVs } = require('../../services/placeholder-service');
const { TAB_MARKER } = require('../../lib/svg-font-engine');
@@ -188,7 +188,8 @@ async function runGeneration({ orderNumber, letters, metadata }) {
index: letter.index !== undefined ? letter.index : loopIndex,
loopIndex,
processedText: text,
signatures: sigPlaceholders,
// Signaturen aus Platzhaltern UND direkt übergebene (manuelle Aufträge).
signatures: { ...(letter.signatures || {}), ...sigPlaceholders },
format: letter.format || 'a4',
font: letter.font || 'tilda',
type: letter.type || 'letter',
@@ -219,7 +220,7 @@ async function runGeneration({ orderNumber, letters, metadata }) {
for (const letter of fontLetters) {
const raw = letter.processedText;
const parts = raw.split(/(\t|\[\[signatur\d+\]\]|[&|])/gi);
const parts = raw.split(/(\t|\[\[signatur\d+\]\]|\[\[seitenumbruch\]\]|[&|])/gi);
const segments = [], markers = [];
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0) segments.push(parts[i]);
@@ -271,7 +272,42 @@ async function runGeneration({ orderNumber, letters, metadata }) {
const letter = processedLetters[i];
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 pad = String(absoluteIndex).padStart(3, '0');
// Mehrseitiger Brief → N Seiten-Dateien letter_NNN_pM.svg (Seite 1..N).
if (!isEnv && letter.multiPage) {
const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText;
let pages;
try {
({ pages } = generateLetterPages(scriptalizedText, letter.format, {
font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale,
}));
} catch (err) {
const e = new Error(`Failed to generate SVG for letter ${i}: ${err.message}`);
e.status = 500; e.label = 'SVG generation failed'; throw e;
}
// Entweder ALLE Seiten aus der Vorschau übernehmen oder ALLE neu rendern –
// nicht mischen, sonst unterschiedliche Handschrift-Variation je Seite.
let alleInVorschau = false;
if (previewSession) {
alleInVorschau = true;
for (let p = 0; p < pages.length; p++) {
try { await fs.access(path.join(previewsRoot, previewSession, `letter_${pad}_p${p + 1}.svg`)); }
catch { alleInVorschau = false; break; }
}
}
for (let p = 0; p < pages.length; p++) {
const fn = `letter_${pad}_p${p + 1}.svg`;
const fp = path.join(outputDir, fn);
if (alleInVorschau) await fs.copyFile(path.join(previewsRoot, previewSession, fn), fp);
else await fs.writeFile(fp, pages[p], 'utf8');
generatedLetters.push(fn);
}
console.log(`[Order] letter_${pad}: ${pages.length} Seiten${alleInVorschau ? ' (aus Vorschau)' : ''}.`);
continue;
}
const filename = `${isEnv ? 'envelope' : 'letter'}_${pad}.svg`;
const filepath = isEnv ? path.join(envelopesDir, filename) : path.join(outputDir, filename);
// 1:1 aus der Vorschau übernehmen, wenn für dieses Dokument eine
@@ -293,12 +329,12 @@ async function runGeneration({ orderNumber, letters, metadata }) {
if (isEnv) {
svgResult = generateEnvelopeSVG(
scriptalizedText, letter.format, letter.envelopeType,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false }
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale }
);
} else {
svgResult = generateLetterSVG(
scriptalizedText, letter.format,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, signatures: letter.signatures || {} }
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, signatures: letter.signatures || {}, signatureOffsets: letter.signatureOffsets || {}, fontScale: letter.fontScale }
);
}
} catch (err) {

View File

@@ -7,6 +7,7 @@ const { scriptalizeBatch } = require("../../services/scriptalizer-service");
const {
generateLetterSVG,
generateEnvelopeSVG,
generateLetterPages,
} = require("../../lib/svg-generator");
const {
replacePlaceholders,
@@ -75,7 +76,8 @@ async function generateBatch(req, res, next) {
...letter,
index: letter.index !== undefined ? letter.index : loopIndex,
processedText: text,
signatures: sigPlaceholders,
// Signaturen aus Platzhaltern UND direkt mitgeschickte (manuelle Aufträge).
signatures: { ...(letter.signatures || {}), ...sigPlaceholders },
format: letter.format || "a4",
font: letter.font || "tilda",
type: letter.type || "letter",
@@ -99,7 +101,7 @@ async function generateBatch(req, res, next) {
for (const letter of processedLetters) {
const raw = letter.processedText;
// Splitten an \t UND [[signaturN]] (mit capture-Gruppe → Marker bleiben erhalten)
const parts = raw.split(/(\t|\[\[signatur\d+\]\]|[&|])/gi);
const parts = raw.split(/(\t|\[\[signatur\d+\]\]|\[\[seitenumbruch\]\]|[&|])/gi);
const segments = [], markers = [];
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0) segments.push(parts[i]);
@@ -157,63 +159,62 @@ async function generateBatch(req, res, next) {
for (let i = 0; i < processedLetters.length; i++) {
const letter = processedLetters[i];
const scriptalizedText = scriptalizedTexts[i] || letter.processedText;
let svgResult;
const absoluteIndex = letter.index !== undefined ? letter.index : i;
const pad = String(absoluteIndex).padStart(3, "0");
const recipientName = letter.placeholders?.name || letter.placeholders?.vorname || `Brief ${absoluteIndex + 1}`;
try {
if (letter.type === "envelope") {
svgResult = generateEnvelopeSVG(
scriptalizedText,
letter.format,
letter.envelopeType,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false }
const svgResult = generateEnvelopeSVG(
scriptalizedText, letter.format, letter.envelopeType,
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale }
);
if (svgResult.overflow) hasOverflow = true;
const filename = `envelope_${pad}.svg`;
await fs.writeFile(path.join(sessionDir, filename), svgResult.svg, "utf8");
files.push({
index: absoluteIndex, filename, url: `/api/preview/${finalSessionId}/${filename}`,
lineCount: svgResult.lineCount, lineLimit: svgResult.lineLimit, overflow: svgResult.overflow,
recipientName: null,
});
} else if (letter.multiPage) {
// Mehrseitiger Brief: Text fließt über N Seiten – kein Überlauf-Fehler.
const { pages } = generateLetterPages(scriptalizedText, letter.format, {
font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale,
});
for (let p = 0; p < pages.length; p++) {
const filename = `letter_${pad}_p${p + 1}.svg`;
await fs.writeFile(path.join(sessionDir, filename), pages[p], "utf8");
files.push({
index: absoluteIndex, page: p + 1, pageCount: pages.length, filename,
url: `/api/preview/${finalSessionId}/${filename}`, recipientName,
});
}
} else {
// Briefe: Zeileninfo mit zurückgeben
svgResult = generateLetterSVG(scriptalizedText, letter.format, {
font: letter.font,
realisticHandwriting: letter.realisticHandwriting ?? false,
signatures: letter.signatures || {},
const svgResult = generateLetterSVG(scriptalizedText, letter.format, {
font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false,
signatures: letter.signatures || {}, signatureOffsets: letter.signatureOffsets || {},
fontScale: letter.fontScale,
});
if (svgResult.overflow) {
hasOverflow = true;
console.log(`[Preview] Letter ${i} has overflow: ${svgResult.lineCount}/${svgResult.lineLimit} lines`);
}
const filename = `letter_${pad}.svg`;
await fs.writeFile(path.join(sessionDir, filename), svgResult.svg, "utf8");
files.push({
index: absoluteIndex, filename, url: `/api/preview/${finalSessionId}/${filename}`,
lineCount: svgResult.lineCount, lineLimit: svgResult.lineLimit, overflow: svgResult.overflow,
recipientName,
});
}
} catch (err) {
console.error(
`[Preview] SVG generation failed for letter ${i}:`,
err.message
);
console.error(`[Preview] SVG generation failed for letter ${i}:`, err.message);
return res.status(500).json({
error: "SVG generation failed",
message: `Failed to generate SVG for letter ${i}: ${err.message}`,
});
}
// Track overflow
if (svgResult.overflow) {
hasOverflow = true;
console.log(`[Preview] Letter ${i} has overflow: ${svgResult.lineCount}/${svgResult.lineLimit} lines`);
}
// Save SVG file - verwende den absoluten Index aus letter
// Unterscheide zwischen Briefen und Umschlägen im Dateinamen
const absoluteIndex = letter.index !== undefined ? letter.index : i;
const prefix = letter.type === 'envelope' ? 'envelope' : 'letter';
const filename = `${prefix}_${String(absoluteIndex).padStart(3, "0")}.svg`;
const filepath = path.join(sessionDir, filename);
await fs.writeFile(filepath, svgResult.svg, "utf8");
// Empfänger-Info für bessere Fehlermeldung
const recipientName = letter.placeholders?.name || letter.placeholders?.vorname || `Brief ${absoluteIndex + 1}`;
files.push({
index: absoluteIndex,
filename,
url: `/api/preview/${finalSessionId}/${filename}`,
lineCount: svgResult.lineCount,
lineLimit: svgResult.lineLimit,
overflow: svgResult.overflow,
recipientName: letter.type !== 'envelope' ? recipientName : null,
});
}
console.log(`[Preview] Generated ${files.length} SVG files`);

View File

@@ -0,0 +1,13 @@
const express = require('express');
const router = express.Router();
const chargeController = require('../controllers/charge-controller');
const { authenticateApiToken } = require('../middleware/auth');
/**
* POST /api/charge/merge-pdf
* Führt die Brief-PDFs mehrerer Aufträge zu einer PDF zusammen (Sammeldruck).
* Body: { orderIds: (number|string)[] }
*/
router.post('/merge-pdf', authenticateApiToken, chargeController.mergePdf);
module.exports = router;

View File

@@ -144,4 +144,11 @@ router.post('/register', authenticateApiToken, orderController.registerOrder);
*/
router.post('/from-directus', authenticateApiToken, directusController.generateFromDirectus);
/**
* POST /api/order/pagecount
* Seitenzahl je Dokument (mehrseitig) – ohne Scriptalizer/SVG, nur Umbruch-Zählung.
* Body: { text, format, fontScale, entries: [{ placeholders }] }
*/
router.post('/pagecount', authenticateApiToken, directusController.pageCounts);
module.exports = router;

View File

@@ -1,70 +1,91 @@
const PX_PER_MM = 3.78; // 96dpi
const FONT_SIZE_PX = 26;
// Maximale Zeilenanzahl pro Format (für Textvalidierung)
const LINE_LIMITS = {
a4: 27,
a6p: 14,
a6l: 11,
// ── Formate: Defaults (Fallback) + datengetriebener Cache ───────────────────
// Die Werte kommen zentral aus Directus (Collection render_formats) und werden
// im Speicher gehalten (stündlich / manuell aufgefrischt via applyRenderFormats).
// Ist Directus nicht erreichbar oder ein Format nicht hinterlegt, greifen die
// eingebauten Defaults – die Generierung läuft also immer.
const DEFAULT_LINE_LIMITS = { a4: 27, a6p: 14, a6l: 11 };
const DEFAULT_FORMAT_LAYOUTS = {
a4: { marginTopMm: 25, marginLeftMm: 20, lineHeightFactor: 1.35 },
a6p: { marginTopMm: 8, marginLeftMm: 8, lineHeightFactor: 1.3 },
a6l: { marginTopMm: 8, marginLeftMm: 8, marginRightMm: 14, lineHeightFactor: 1.2 },
din_lang: { marginTopMm: 50, marginLeftMm: 10, lineHeightFactor: 1.2 },
c6: { marginTopMm: 60, marginLeftMm: 10, lineHeightFactor: 1.2 },
};
// Layout Konfiguration pro Format + Orientation
const FORMAT_LAYOUTS = {
// A4 Hochformat
a4: {
marginTopMm: 25,
marginLeftMm: 20,
lineHeightFactor: 1.35,
},
// A6 Hochformat
a6p: {
marginTopMm: 8,
marginLeftMm: 8,
lineHeightFactor: 1.3,
},
// A6 Querformat (landscape)
a6l: {
marginTopMm: 8,
marginLeftMm: 8,
marginRightMm: 14,
lineHeightFactor: 1.2,
},
// DIN Lang Kuvert (Querformat)
din_lang: {
marginTopMm: 50,
marginLeftMm: 10, // Links unten in Ecke
lineHeightFactor: 1.2,
},
// C6 Kuvert (Querformat)
c6: {
marginTopMm: 60,
marginLeftMm: 10, // Links unten in Ecke
lineHeightFactor: 1.2,
},
};
// Seiten-Dimensionen
const PAGE_FORMATS = {
const DEFAULT_PAGE_FORMATS = {
a4: { widthMm: 210, heightMm: 297 },
a6p: { widthMm: 105, heightMm: 148 },
a6l: { widthMm: 148, heightMm: 105 }, // Querformat: getauscht
a6l: { widthMm: 148, heightMm: 105 },
din_lang: { widthMm: 220, heightMm: 110 },
c6: { widthMm: 162, heightMm: 114 },
};
// Live-Cache (wird von applyRenderFormats ersetzt). Start = Defaults.
let LINE_LIMITS = { ...DEFAULT_LINE_LIMITS };
let FORMAT_LAYOUTS = { ...DEFAULT_FORMAT_LAYOUTS };
let PAGE_FORMATS = { ...DEFAULT_PAGE_FORMATS };
/**
* Übernimmt Format-Definitionen aus Directus (render_formats) in den Cache.
* Baut immer auf den Defaults auf, damit Teilangaben nichts kaputt machen.
* rows: [{ key, page_width_mm, page_height_mm, margin_top_mm, margin_left_mm,
* margin_right_mm, line_height_factor, line_limit }]
*/
function applyRenderFormats(rows) {
const pf = { ...DEFAULT_PAGE_FORMATS };
const fl = { ...DEFAULT_FORMAT_LAYOUTS };
const ll = { ...DEFAULT_LINE_LIMITS };
for (const r of Array.isArray(rows) ? rows : []) {
const k = String(r.key || '').toLowerCase();
if (!k) continue;
if (r.page_width_mm && r.page_height_mm) {
pf[k] = { widthMm: Number(r.page_width_mm), heightMm: Number(r.page_height_mm) };
}
const alt = fl[k] || {};
const layout = {
marginTopMm: Number(r.margin_top_mm ?? alt.marginTopMm ?? 25),
marginLeftMm: Number(r.margin_left_mm ?? alt.marginLeftMm ?? 20),
lineHeightFactor: Number(r.line_height_factor ?? alt.lineHeightFactor ?? 1.35),
};
const mr = r.margin_right_mm ?? alt.marginRightMm;
if (mr != null && mr !== '') layout.marginRightMm = Number(mr);
fl[k] = layout;
if (r.line_limit != null && r.line_limit !== '') ll[k] = Number(r.line_limit);
}
PAGE_FORMATS = pf;
FORMAT_LAYOUTS = fl;
LINE_LIMITS = ll;
signaledMisses.clear();
}
// mm in Pixel umrechnen
function mmToPx(mm) {
return mm * PX_PER_MM;
}
// ── Refresh bei unbekanntem Format ──────────────────────────────────────────
// Wird ein Format angefragt, das (noch) nicht im Cache liegt, meldet page-layout
// das einmalig an einen Handler (den der Server auf refreshRenderFormats setzt).
// So werden neu in Directus angelegte Formate sofort nachgeladen, ohne die
// stündliche Aktualisierung abzuwarten. Für DIESEN Aufruf greift noch der
// Default – der nächste Aufruf hat das Format.
let missHandler = null;
const signaledMisses = new Set();
function setFormatMissHandler(fn) { missHandler = fn; }
function signalMiss(key) {
const k = String(key || '').toLowerCase();
if (!missHandler || !k || signaledMisses.has(k)) return;
signaledMisses.add(k);
try { missHandler(k); } catch { /* egal */ }
}
// Layout für Format holen
function getLayoutForFormat(format) {
const normalizedFormat = String(format).toLowerCase();
const layoutConfig = FORMAT_LAYOUTS[normalizedFormat];
if (!layoutConfig) signalMiss(normalizedFormat);
if (!layoutConfig) {
// Fallback auf a4
@@ -86,6 +107,7 @@ function getLayoutForFormat(format) {
// Seiten-Dimensionen holen
function getPageDimensions(format) {
const normalizedFormat = String(format).toLowerCase();
if (!PAGE_FORMATS[normalizedFormat]) signalMiss(normalizedFormat);
const dimensions = PAGE_FORMATS[normalizedFormat] || PAGE_FORMATS.a4;
return {
@@ -107,18 +129,18 @@ function escapeXml(str) {
// Zeilenlimit für Format holen
function getLineLimit(format) {
const normalizedFormat = String(format).toLowerCase();
if (!LINE_LIMITS[normalizedFormat]) signalMiss(normalizedFormat);
return LINE_LIMITS[normalizedFormat] || LINE_LIMITS.a4;
}
module.exports = {
PX_PER_MM,
FONT_SIZE_PX,
FORMAT_LAYOUTS,
PAGE_FORMATS,
LINE_LIMITS,
mmToPx,
getLayoutForFormat,
getPageDimensions,
getLineLimit,
escapeXml
escapeXml,
applyRenderFormats,
setFormatMissHandler,
};

View File

@@ -78,8 +78,10 @@ const TAB_MARKER = '';
/**
* Breite einer Textzeile in Pixeln messen.
* Unterstützt TAB_MARKER: springt zur nächsten Tabstop-Position (6 × fontSizePx).
* maxWidthPx (optional) begrenzt den Tab-Sprung auf den rechten Rand, damit viele
* Tabs nicht über die Seite hinauslaufen. Ohne Angabe (Infinity) unverändert.
*/
function measureTextWidthPx({ text, fontFileName, fontSizePx }) {
function measureTextWidthPx({ text, fontFileName, fontSizePx, maxWidthPx = Infinity }) {
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
const scale = fontSizePx / unitsPerEm;
const tabStopPx = fontSizePx * 6;
@@ -88,7 +90,7 @@ function measureTextWidthPx({ text, fontFileName, fontSizePx }) {
for (const ch of String(text || '')) {
if (ch === TAB_MARKER) {
x = Math.ceil((x + 1) / tabStopPx) * tabStopPx;
x = Math.min(Math.ceil((x + 1) / tabStopPx) * tabStopPx, maxWidthPx);
continue;
}
@@ -126,6 +128,19 @@ function wrapTextToLinesByWidth({
measureTextWidthPx({ text: t, fontFileName, fontSizePx });
for (const para of paragraphs) {
// Führende Tabs = bewusste Positionierung der Zeile (z.B. Datum nach rechts).
// Solche Zeilen NICHT nach Wortbreite umbrechen, sonst würde der Text als
// "zu breit" gewertet und die Wörter fielen nach links auf eine neue Zeile.
// Der eigentliche Versatz (bzw. Rechtsbündigkeit bei Überlauf) passiert im Layout.
const lead = para.match(new RegExp(`^${TAB_MARKER}+`));
if (lead) {
const rest = para.slice(lead[0].length);
if (!rest.includes(TAB_MARKER) && measure(rest) <= maxWidthPx) {
allLines.push(para);
continue;
}
}
// Tokens: Wörter und TAB_MARKER getrennt ermitteln.
// TAB_MARKER durch einen Platzhalter ersetzen, mit \s+ splitten, dann zurücktauschen.
const TAB_PLACEHOLDER = '';
@@ -208,6 +223,7 @@ function layoutTextToPaths({
startY,
lineHeightPx,
realisticHandwriting = false,
maxWidthPx = Infinity,
}) {
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
@@ -223,13 +239,32 @@ function layoutTextToPaths({
const tabStopPx = fontSizePx * 6;
for (let i = 0; i < line.length; i++) {
// Führende Tabs vorab auflösen: normalerweise an die Tab-Position rücken;
// würde der restliche Zeileninhalt dort über den rechten Rand laufen, die
// Zeile stattdessen rechtsbündig an den Rand setzen (z.B. Datum ganz rechts,
// ohne dass etwas daneben steht). Nur aktiv, wenn maxWidthPx bekannt ist.
let startIdx = 0;
if (line[0] === TAB_MARKER) {
let leadCount = 0;
while (line[leadCount] === TAB_MARKER) leadCount++;
let tx = 0;
for (let t = 0; t < leadCount; t++) {
tx = Math.min(Math.ceil((tx + 1) / tabStopPx) * tabStopPx, maxWidthPx);
}
const rest = line.slice(leadCount);
const restW = measureTextWidthPx({ text: rest, fontFileName, fontSizePx, maxWidthPx });
if (tx + restW > maxWidthPx) tx = Math.max(0, maxWidthPx - restW);
x = startX + tx;
startIdx = leadCount;
}
for (let i = startIdx; i < line.length; i++) {
const ch = line[i];
// Tabulator-Marker → zur nächsten Tabstop-Position springen
// Tabulator-Marker → zur nächsten Tabstop-Position springen (an den Rand geclampt)
if (ch === TAB_MARKER) {
const relX = x - startX;
const nextTab = Math.ceil((relX + 1) / tabStopPx) * tabStopPx;
const nextTab = Math.min(Math.ceil((relX + 1) / tabStopPx) * tabStopPx, maxWidthPx);
x = startX + nextTab;
continue;
}

View File

@@ -3,13 +3,15 @@ const {
getLayoutForFormat,
getPageDimensions,
getLineLimit,
escapeXml
escapeXml,
mmToPx
} = require('./page-layout');
const {
layoutTextToPaths,
wrapTextToLinesByWidth,
measureTextWidthPx
measureTextWidthPx,
TAB_MARKER
} = require('./svg-font-engine');
// Höhe einer Signatur in Seiten-Pixeln (Signatur-Canvas war 600×160; wir reservieren 160px * scale)
@@ -65,11 +67,22 @@ function generateLetterSVG(text, format, options = {}) {
const fontKey = normalizeFontKey(options.font);
const fontConfig = SVG_FONT_CONFIG[fontKey];
const signatures = options.signatures || {};
// Feinjustierung je Signatur: { signatur1: { x: mm, y: mm, scale: Faktor } }.
const signatureOffsets = options.signatureOffsets || {};
// Einseitig: Seitenumbruch-Marker gibt es hier nicht – als Zeilenumbruch behandeln,
// damit er nie wörtlich gedruckt wird (falls „Mehrseitig" nicht aktiv ist).
text = String(text || '').replace(/\[\[seitenumbruch\]\]/gi, '\n');
const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format);
const { marginTopPx, marginLeftPx, marginRightPx, lineHeightPx } = getLayoutForFormat(format);
const { marginTopPx, marginLeftPx, marginRightPx, lineHeightPx: baseLineHeightPx } = getLayoutForFormat(format);
const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx);
// Relative Schriftgröße (nur manuelle Aufträge setzen ≠ 1). Schrift UND Zeilenhöhe
// skalieren mit, damit sich Zeilen nicht überlappen. Default 1 = unverändert.
const fontScale = Math.max(0.8, Math.min(1.5, Number(options.fontScale) || 1));
const fontSizePx = FONT_SIZE_PX * fontScale;
const lineHeightPx = baseLineHeightPx * fontScale;
// ── Signatur-Marker aus Text extrahieren ──────────────────────────────────
const sigMarkerRe = /\[\[(signatur\d+)\]\]/gi;
const hasSigMarkers = sigMarkerRe.test(String(text || ''));
@@ -80,18 +93,19 @@ function generateLetterSVG(text, format, options = {}) {
text: String(text || ''),
maxWidthPx,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
fontSizePx,
});
const firstBaselineY = marginTopPx + FONT_SIZE_PX;
const firstBaselineY = marginTopPx + fontSizePx;
const paths = layoutTextToPaths({
lines,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
fontSizePx,
startX: marginLeftPx,
startY: firstBaselineY,
lineHeightPx,
realisticHandwriting: options.realisticHandwriting ?? false,
maxWidthPx,
});
const pathElements = paths
@@ -102,7 +116,8 @@ function generateLetterSVG(text, format, options = {}) {
vector-effect="non-scaling-stroke"/>`)
.join('\n ');
const lineLimit = getLineLimit(format);
// Größere Schrift → weniger Zeilen passen aufs Blatt (Limit gegenläufig skalieren).
const lineLimit = Math.max(1, Math.round(getLineLimit(format) / fontScale));
return {
svg: `<svg xmlns="http://www.w3.org/2000/svg"
width="${widthMm}mm" height="${heightMm}mm"
@@ -142,9 +157,9 @@ function generateLetterSVG(text, format, options = {}) {
}
const allSvgElements = [];
let currentY = marginTopPx + FONT_SIZE_PX;
let currentY = marginTopPx + fontSizePx;
let totalLineCount = 0;
const lineLimit = getLineLimit(format);
const lineLimit = Math.max(1, Math.round(getLineLimit(format) / fontScale));
let si = 0;
while (si < segments.length) {
@@ -157,16 +172,17 @@ function generateLetterSVG(text, format, options = {}) {
text: seg.content,
maxWidthPx,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
fontSizePx,
});
const paths = layoutTextToPaths({
lines,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
fontSizePx,
startX: marginLeftPx,
startY: currentY,
lineHeightPx,
realisticHandwriting: options.realisticHandwriting ?? false,
maxWidthPx,
});
paths.forEach(p => allSvgElements.push(
`<path d="${escapeXml(p.d)}" transform="${p.transform}"
@@ -188,8 +204,13 @@ function generateLetterSVG(text, format, options = {}) {
sigRow.forEach((sigSeg, slotIdx) => {
const svgString = signatures[sigSeg.key] || null;
if (svgString) {
const sigX = marginLeftPx + slotIdx * sigSlotW;
const sigEl = buildSignatureElement(svgString, sigX, currentY, sigScale);
// Feinjustierung: Versatz in mm + Größen-Faktor relativ zur Auto-Position.
const off = signatureOffsets[sigSeg.key] || {};
const dx = mmToPx(Number(off.x) || 0);
const dy = mmToPx(Number(off.y) || 0);
const sc = Number(off.scale) > 0 ? Number(off.scale) : 1;
const sigX = marginLeftPx + slotIdx * sigSlotW + dx;
const sigEl = buildSignatureElement(svgString, sigX, currentY + dy, sigScale * sc);
if (sigEl) allSvgElements.push(sigEl);
}
});
@@ -223,7 +244,12 @@ function generateEnvelopeSVG(text, format, type, options = {}) {
const fontConfig = SVG_FONT_CONFIG[fontKey];
const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format);
const { marginTopPx, marginLeftPx, lineHeightPx } = getLayoutForFormat(format);
const { marginTopPx, marginLeftPx, lineHeightPx: baseLineHeightPx } = getLayoutForFormat(format);
// Relative Schriftgröße (getrennt vom Brief einstellbar). Default 1 = unverändert.
const fontScale = Math.max(0.8, Math.min(1.5, Number(options.fontScale) || 1));
const fontSizePx = FONT_SIZE_PX * fontScale;
const lineHeightPx = baseLineHeightPx * fontScale;
let lines;
let startX;
@@ -237,12 +263,12 @@ function generateEnvelopeSVG(text, format, type, options = {}) {
text: String(text || ''),
maxWidthPx,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
fontSizePx,
});
// Zentrierung berechnen
const totalTextHeight = lines.length * lineHeightPx;
startY = (heightPx - totalTextHeight) / 2 + FONT_SIZE_PX;
startY = (heightPx - totalTextHeight) / 2 + fontSizePx;
// Jede Zeile einzeln zentrieren
// startX wird später pro Zeile berechnet
@@ -256,11 +282,11 @@ function generateEnvelopeSVG(text, format, type, options = {}) {
text: String(text || ''),
maxWidthPx,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
fontSizePx,
});
startX = marginLeftPx;
startY = marginTopPx + FONT_SIZE_PX;
startY = marginTopPx + fontSizePx;
}
let paths = [];
@@ -272,14 +298,14 @@ function generateEnvelopeSVG(text, format, type, options = {}) {
const lineWidth = measureTextWidthPx({
text: line,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX
fontSizePx
});
const centeredX = (widthPx - lineWidth) / 2;
const linePaths = layoutTextToPaths({
lines: [line],
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
fontSizePx,
startX: centeredX,
startY: currentY,
lineHeightPx,
@@ -294,7 +320,7 @@ function generateEnvelopeSVG(text, format, type, options = {}) {
paths = layoutTextToPaths({
lines,
fontFileName: fontConfig.file,
fontSizePx: FONT_SIZE_PX,
fontSizePx,
startX,
startY,
lineHeightPx,
@@ -326,16 +352,110 @@ function generateEnvelopeSVG(text, format, type, options = {}) {
// Overflow-Erkennung: zu viele Zeilen für die verfügbare Fläche
const overflowStartY = (type === 'custom')
? (heightPx - lines.length * lineHeightPx) / 2 + FONT_SIZE_PX
: marginTopPx + FONT_SIZE_PX;
const available = heightPx - overflowStartY - FONT_SIZE_PX * 0.5;
? (heightPx - lines.length * lineHeightPx) / 2 + fontSizePx
: marginTopPx + fontSizePx;
const available = heightPx - overflowStartY - fontSizePx * 0.5;
const lineLimit = Math.max(1, Math.floor(available / lineHeightPx) + 1);
const lineCount = lines.length;
return { svg: svg.trim(), overflow: lineCount > lineLimit, lineCount, lineLimit };
}
/**
* Mehrseitiges Schriftstück: Der Text fließt automatisch über so viele Seiten,
* wie nötig (linesPerPage = Zeilenlimit des Formats). Ein erzwungener Umbruch ist
* über den Marker [[seitenumbruch]] möglich – jeder so getrennte Block beginnt auf
* einer neuen Seite. Gibt die Seiten in natürlicher Reihenfolge (Seite 1..N) zurück.
*
* Bewusst OHNE Signatur-Unterstützung (kommt als eigener Schritt). Tab-/Rechts-
* bündig-Logik und relative Schriftgröße greifen pro Seite wie beim Einzelbrief.
*/
function generateLetterPages(text, format, options = {}) {
const fontKey = normalizeFontKey(options.font);
const fontConfig = SVG_FONT_CONFIG[fontKey];
const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format);
const { marginTopPx, marginLeftPx, marginRightPx, lineHeightPx: baseLineHeightPx } = getLayoutForFormat(format);
const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx);
const fontScale = Math.max(0.8, Math.min(1.5, Number(options.fontScale) || 1));
const fontSizePx = FONT_SIZE_PX * fontScale;
const lineHeightPx = baseLineHeightPx * fontScale;
const linesPerPage = Math.max(1, Math.round(getLineLimit(format) / fontScale));
// Erzwungene Seitenumbrüche zuerst; danach jeden Block nach Zeilen auto-fließen.
const bloecke = String(text || '').split(/\[\[seitenumbruch\]\]/gi);
const seitenZeilen = [];
for (const block of bloecke) {
const lines = wrapTextToLinesByWidth({ text: block, maxWidthPx, fontFileName: fontConfig.file, fontSizePx });
for (let i = 0; i < lines.length; i += linesPerPage) {
seitenZeilen.push(lines.slice(i, i + linesPerPage));
}
}
if (seitenZeilen.length === 0) seitenZeilen.push(['']);
// Leere Seiten verwerfen: enthält eine Seite kein sichtbares Zeichen (nur
// Leerzeichen, Tabs, Umbrüche), wird sie weder generiert noch gedruckt.
// Bleibt nichts übrig (komplett leerer Text), eine einzige Seite behalten.
const istLeer = (lines) => lines.join('').split(TAB_MARKER).join('').replace(/\s/g, '') === '';
let seiten = seitenZeilen.filter((lines) => !istLeer(lines));
if (seiten.length === 0) seiten = [['']];
const pages = seiten.map((lines) => {
const paths = layoutTextToPaths({
lines,
fontFileName: fontConfig.file,
fontSizePx,
startX: marginLeftPx,
startY: marginTopPx + fontSizePx,
lineHeightPx,
realisticHandwriting: options.realisticHandwriting ?? false,
maxWidthPx,
});
const pathElements = paths
.map(p => `<path d="${escapeXml(p.d)}"
transform="${p.transform}"
stroke="#000000" stroke-width="0.6" fill="none"
stroke-linecap="round" stroke-linejoin="round"
vector-effect="non-scaling-stroke"/>`)
.join('\n ');
return `<svg xmlns="http://www.w3.org/2000/svg"
width="${widthMm}mm" height="${heightMm}mm"
viewBox="0 0 ${widthPx} ${heightPx}">
<rect x="0" y="0" width="${widthPx}" height="${heightPx}" fill="#FFFFFF"/>
${pathElements}
</svg>`.trim();
});
return { pages, pageCount: pages.length, lineCount: seiten.reduce((a, l) => a + l.length, 0) };
}
/**
* Zählt, wie viele Seiten ein Text ergäbe (gleiche Logik wie generateLetterPages,
* aber ohne SVG/Scriptalizer) – für die Seiten-Übersicht je Schriftstück.
*/
function countLetterPages(text, format, options = {}) {
const fontConfig = SVG_FONT_CONFIG[normalizeFontKey(options.font)];
const { widthPx } = getPageDimensions(format);
const { marginLeftPx, marginRightPx } = getLayoutForFormat(format);
const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx);
const fontScale = Math.max(0.8, Math.min(1.5, Number(options.fontScale) || 1));
const fontSizePx = FONT_SIZE_PX * fontScale;
const linesPerPage = Math.max(1, Math.round(getLineLimit(format) / fontScale));
const seitenZeilen = [];
for (const block of String(text || '').split(/\[\[seitenumbruch\]\]/gi)) {
const lines = wrapTextToLinesByWidth({ text: block, maxWidthPx, fontFileName: fontConfig.file, fontSizePx });
for (let i = 0; i < lines.length; i += linesPerPage) seitenZeilen.push(lines.slice(i, i + linesPerPage));
}
const istLeer = (lines) => lines.join('').split(TAB_MARKER).join('').replace(/\s/g, '') === '';
const seiten = seitenZeilen.filter((lines) => !istLeer(lines));
return Math.max(1, seiten.length);
}
module.exports = {
generateLetterSVG,
generateEnvelopeSVG
generateEnvelopeSVG,
generateLetterPages,
countLetterPages
};

View File

@@ -4,11 +4,37 @@ const config = require('./config');
const previewRoutes = require('./api/routes/preview-routes');
const orderRoutes = require('./api/routes/order-routes');
const chargeRoutes = require('./api/routes/charge-routes');
const healthRoutes = require('./api/routes/health-routes');
const errorHandler = require('./api/middleware/error-handler');
const requestLogger = require('./api/middleware/request-logger');
const { applyRenderFormats, setFormatMissHandler } = require('./lib/page-layout');
const { fetchRenderFormats } = require('./services/directus-service');
// Zentrale Format-Definitionen aus Directus in den Cache holen. Läuft beim Start
// und stündlich; Fehler sind unkritisch (die Defaults im page-layout greifen).
let lastFormatRefresh = 0;
async function refreshRenderFormats() {
lastFormatRefresh = Date.now();
try {
const rows = await fetchRenderFormats();
applyRenderFormats(rows);
console.log(`[Formate] ${Array.isArray(rows) ? rows.length : 0} Format(e) aus Directus geladen.`);
} catch (e) {
console.warn(`[Formate] Konnte Directus-Formate nicht laden (nutze Defaults): ${e.message}`);
}
}
// Unbekanntes Format angefragt → sofort nachladen (max. alle 30 s, damit ein
// dauerhaft unbekanntes Format kein Dauerfeuer auslöst).
setFormatMissHandler((key) => {
if (Date.now() - lastFormatRefresh < 30 * 1000) return;
console.log(`[Formate] Unbekanntes Format "${key}" angefragt – lade Formate nach.`);
refreshRenderFormats();
});
const app = express();
// Middleware
@@ -21,6 +47,17 @@ app.use(requestLogger);
app.use('/health', healthRoutes);
app.use('/api/preview', previewRoutes);
app.use('/api/order', orderRoutes);
app.use('/api/charge', chargeRoutes);
// Format-Cache manuell auffrischen (Token-geschützt) – für „sofort übernehmen".
app.post('/api/formats/refresh', async (req, res) => {
const token = config.auth && config.auth.apiToken;
if (token && req.headers['x-api-token'] !== token) {
return res.status(401).json({ error: 'API token required' });
}
await refreshRenderFormats();
res.json({ ok: true });
});
// Error handling
app.use(errorHandler);
@@ -39,6 +76,9 @@ app.listen(config.port, () => {
console.log('════════════════════════════════════════════════════════════');
console.log(`Server running at http://localhost:${config.port}`);
console.log('════════════════════════════════════════════════════════════\n');
// Formate initial laden und stündlich auffrischen (zentral aus Directus).
refreshRenderFormats();
setInterval(refreshRenderFormats, 60 * 60 * 1000);
});
// Graceful shutdown

View File

@@ -41,9 +41,10 @@ async function directusFetch(path, options = {}) {
async function fetchOrder(orderId) {
const fields = [
'id', 'order_number', 'customer_email', 'font', 'status',
'needs_envelope', 'envelope_labeling', 'envelope_format',
'needs_envelope', 'envelope_labeling', 'envelope_text', 'envelope_format',
'text_template', 'motif_mode', 'own_cards', 'entries_count', 'preview_session',
'source_file', 'product.key', 'product.type', 'product.input_mode', 'format.key',
'font_scale_letter', 'font_scale_envelope', 'signatures_manual', 'multi_page', 'realistic',
].join(',');
const order = await directusFetch(`/items/orders/${encodeURIComponent(orderId)}?fields=${fields}`);
@@ -77,4 +78,36 @@ async function addStatusHistory(orderId, status, note) {
});
}
module.exports = { fetchOrder, updateOrder, addStatusHistory };
/** Lädt mehrere Aufträge (nur die für den Charge-Sammeldruck nötigen Felder),
* sortiert nach Auftragsnummer. */
async function fetchOrdersMinimal(ids) {
const idList = ids.map((v) => encodeURIComponent(v)).join(',');
return directusFetch(
`/items/orders?filter[id][_in]=${idList}&limit=-1&sort=order_number` +
'&fields=id,order_number,source_files,source_file',
);
}
/** Lädt eine Directus-Datei (z. B. hochgeladenes Brief-PDF) als Buffer. */
async function fetchAssetBuffer(fileId) {
requireConfig();
const res = await fetch(config.directus.url.replace(/\/$/, '') + '/assets/' + encodeURIComponent(fileId), {
headers: { Authorization: `Bearer ${config.directus.token}` },
});
if (!res.ok) {
const e = new Error(`Directus asset ${fileId}: ${res.status}`);
e.status = res.status === 404 ? 404 : 502;
throw e;
}
return Buffer.from(await res.arrayBuffer());
}
/** Zentrale Format-Definitionen (Seitengröße, Ränder, Zeilenlimit) aus Directus. */
async function fetchRenderFormats() {
return directusFetch(
'/items/render_formats?filter[active][_eq]=true&limit=-1' +
'&fields=key,page_width_mm,page_height_mm,margin_top_mm,margin_left_mm,margin_right_mm,line_height_factor,line_limit',
);
}
module.exports = { fetchOrder, updateOrder, addStatusHistory, fetchRenderFormats, fetchOrdersMinimal, fetchAssetBuffer };

View File

@@ -46,10 +46,17 @@ class Directus:
self.s.headers["Authorization"] = f"Bearer {token}"
def get(self, path, **params):
"""Für /items/… – Directus verpackt die Nutzdaten in `data`."""
r = self.s.get(self.base + path, params=params, timeout=30)
r.raise_for_status()
return r.json().get("data")
def get_raw(self, path, **params):
"""Für Custom-Endpunkte (z. B. /skrift-orders/files) ohne `data`-Hülle."""
r = self.s.get(self.base + path, params=params, timeout=30)
r.raise_for_status()
return r.json()
def patch(self, path, body):
r = self.s.patch(self.base + path, json=body, timeout=30)
r.raise_for_status()
@@ -152,10 +159,29 @@ def parse_nummern(spec):
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 seiten_reihenfolge(files):
"""Seiten je Brief ABSTEIGEND (…p3, p2, p1), Briefe aufsteigend.
Der Plotter stapelt face-up: zuletzt gedruckt liegt oben. Damit am Ende
Seite 1 obenauf liegt, muss je Brief die letzte Seite zuerst gedruckt werden.
Ergebnis: 1.3, 1.2, 1.1, 2.3, 2.2, 2.1, … Einseitige Briefe (ohne _pN) und
andere Typen (Kuvert/Signatur) bleiben unverändert.
"""
def key(f):
name = str(f.get("name", "")).lower()
brief, seite = 0, 1
if name.startswith("letter_"):
rest = name[7:]
if rest.endswith(".svg"):
rest = rest[:-4]
if "_p" in rest:
a, _, b = rest.partition("_p")
brief = int(a) if a.isdigit() else 0
seite = int(b) if b.isdigit() else 1
elif rest.isdigit():
brief = int(rest)
return (brief, -seite)
return sorted(files, key=key)
def main():
@@ -167,29 +193,83 @@ def main():
print(f"[Agent] gestartet für Maschine {machine_id} gegen {cfg['directus_url']}")
KIND = {"brief": "schriftstueck", "umschlag": "umschlag", "signatur": "signatur"}
# Sicherheitsfenster: Sehen wir nach dem Senden binnen dieser Zeit kein
# „Printing" (Job war sehr kurz), gilt er als fertig – verhindert Deadlock.
start_grace = max(60, poll * 6)
# Kontext des aktuell an die Maschine gesendeten Jobs (oder None). Der
# nächste Job wird ERST gesendet, wenn die Maschine wieder „Idle" ist –
# sonst würde ein neuer write_svg (clear=1) den laufenden Druck verwerfen.
laufend = None
# Heartbeat gedrosselt: nur schreiben, wenn sich der Status ändert oder
# spätestens alle 60 s – sonst flutet der PATCH bei jedem Poll das Log.
beat_state = {"status": None, "ts": 0.0}
def beat(status):
now = time.monotonic()
if status != beat_state["status"] or now - beat_state["ts"] >= 60:
_heartbeat(dx, machine_id, status)
beat_state["status"] = status
beat_state["ts"] = now
while True:
try:
maschine = dx.get(f"/items/machines/{machine_id}",
fields="base_url,mid,username,password,active")
fields="base_url,mid,username,password,active,current_format")
if not maschine or not maschine.get("active", True):
_heartbeat(dx, machine_id, "offline")
beat("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")
st = plotter.status() or {}
s = str(st.get("status", "")).strip().lower()
job = _naechster_job(dx, machine_id)
if not job:
# Fehler (z. B. Papierende): fortsetzen, aber nichts Neues senden.
if s in ("error", "fehler"):
plotter.recover()
beat("fehler")
time.sleep(poll)
continue
_druck(dx, plotter, job, KIND)
# Maschine druckt noch → warten (auf keinen Fall einen neuen Job senden).
if s == "printing":
if laufend is not None:
laufend["gestartet"] = True
beat("druckt")
time.sleep(poll)
continue
# Nur bei sicherem „Idle" handeln. Unbekannt/leer → abwarten.
if s != "idle":
beat("bereit" if s else "offline")
time.sleep(poll)
continue
beat("bereit")
# Ein zuvor gesendeter Job ist fertig gedruckt (Idle nach Printing) →
# abschließen. Das start_grace fängt sehr kurze Jobs ab, bei denen wir
# das „Printing" nie zu Gesicht bekommen.
if laufend is not None:
if laufend.get("gestartet") or (time.monotonic() - laufend["ts"] > start_grace):
_abschluss(dx, laufend)
laufend = None
else:
time.sleep(poll)
continue
# Nächsten Job nur senden, wenn nichts (mehr) läuft.
if laufend is None:
current_format = (maschine.get("current_format") or "").strip()
# Format-Warteschlange: nur passendes Format, kein Auto-Anschluss.
if current_format:
job = _naechster_job(dx, machine_id, current_format)
if job:
laufend = _sende(dx, plotter, job, KIND)
time.sleep(poll)
except requests.HTTPError as e:
print(f"[Agent] HTTP-Fehler: {e}")
@@ -206,12 +286,14 @@ def _heartbeat(dx, machine_id, status):
pass
def _naechster_job(dx, machine_id):
def _naechster_job(dx, machine_id, current_format):
jobs = dx.get(
"/items/jobs",
**{
"filter[machine][_eq]": machine_id,
"filter[status][_eq]": "queued",
# Nur Jobs des aktuell eingelegten Formats.
"filter[format][_eq]": current_format,
"sort": "-priority,date_created",
"limit": 1,
"fields": "id,type,output_ref,params,numbers,"
@@ -223,7 +305,13 @@ def _naechster_job(dx, machine_id):
return jobs[0] if jobs else None
def _druck(dx, plotter, job, KIND):
def _sende(dx, plotter, job, KIND):
"""Sendet einen Job an die Maschine und markiert ihn als „printing".
Gibt einen Kontext zum späteren Abschluss zurück (oder None bei Fehler).
Es wird NICHT auf das Druckende gewartet – das übernimmt die Hauptschleife
(erst wenn die Maschine wieder „Idle" ist, wird der nächste Job gesendet).
"""
job_id = job["id"]
order = job.get("order") or {}
order_id = order.get("id")
@@ -238,7 +326,7 @@ def _druck(dx, plotter, job, KIND):
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}")
print(f"[Agent] Job {job_id}: {typ} für {order_nr} → an Maschine senden")
dx.patch(f"/items/jobs/{job_id}", {"status": "printing", "error": None})
# Produktionsstatus fürs Kanban: sobald gedruckt wird → „im_druck".
@@ -248,18 +336,25 @@ def _druck(dx, plotter, job, KIND):
try:
if not order_nr:
raise RuntimeError("Auftragsnummer fehlt am Job.")
liste = dx.get(f"/skrift-orders/files/{order_nr}") or {}
liste = dx.get_raw(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.
# Positionsbasiert (1 = erstes Dokument), NICHT über die Zahl im
# Dateinamen: Alt-Aufträge sind 0-basiert (letter_000.svg), neue
# 1-basiert (letter_001.svg). Die Position stimmt in beiden Fällen.
if nummern is not None:
passende = [f for f in passende if datei_nummer(f.get("name")) in nummern]
passende = [f for i, f in enumerate(passende, start=1) if i in nummern]
if not passende:
raise RuntimeError(f"Keine Dateien vom Typ '{gewuenscht}' für {order_nr} gefunden.")
# Mehrseitige Briefe: Seiten je Brief absteigend an den Plotter (Stapel-
# Reihenfolge, damit Seite 1 am Ende oben liegt). Einseitige unverändert.
passende = seiten_reihenfolge(passende)
dateien = []
for f in passende:
inhalt = dx.download(f"/skrift-orders/files/{order_nr}/download", {"rel": f["rel"]})
@@ -267,8 +362,28 @@ def _druck(dx, plotter, job, KIND):
plotter.login()
plotter.write(dateien, tpl)
print(f"[Agent] Job {job_id} an Maschine übergeben ({len(dateien)} Dateien) – warte auf Fertigstellung.")
return {"job_id": job_id, "order_id": order_id, "order_nr": order_nr,
"typ": typ, "gestartet": False, "ts": time.monotonic()}
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}")
return None
def _abschluss(dx, ctx):
"""Schließt einen fertig gedruckten Job ab: Status, Indikatoren, Kuvertierung.
Wird erst aufgerufen, wenn die Maschine nach dem Druck wieder „Idle" meldet.
"""
job_id = ctx["job_id"]
order_id = ctx.get("order_id")
order_nr = ctx.get("order_nr")
typ = ctx.get("typ")
try:
dx.patch(f"/items/jobs/{job_id}", {"status": "printed"})
except Exception as e:
print(f"[Agent] Konnte Job {job_id} nicht auf 'printed' setzen: {e}")
# Indikator am Auftrag setzen (Schriftstück bzw. Umschlag gedruckt) und
# bei Vollständigkeit automatisch in die Kuvertierung schieben.
if order_id:
@@ -284,10 +399,7 @@ def _druck(dx, plotter, job, KIND):
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}")
print(f"[Agent] Job {job_id} fertig gedruckt.")
if __name__ == "__main__":

View File

@@ -123,6 +123,17 @@ function Dialog({ title, onClose, children }: { title: string; onClose: () => vo
);
}
/** Konfigurator samt lokalem Cache zurücksetzen (Login bleibt erhalten). */
function konfiguratorZuruecksetzen() {
if (!window.confirm('Konfigurator zurücksetzen? Alle Eingaben und der lokale Zwischenspeicher werden gelöscht (die Anmeldung bleibt bestehen).')) return;
try {
Object.keys(localStorage)
.filter((k) => k.startsWith('skrift.') && k !== 'skrift.session')
.forEach((k) => localStorage.removeItem(k));
} catch { /* localStorage nicht verfügbar – dann reicht der Reload */ }
window.location.reload();
}
function Hilfe({ onClose }: { onClose: () => void }) {
return (
<Dialog title="Hilfe & Kontakt" onClose={onClose}>
@@ -138,6 +149,14 @@ function Hilfe({ onClose }: { onClose: () => void }) {
</a>
</div>
{/* Rettungsanker bei hängengebliebenem Zustand (z. B. falscher Zeilen-Hinweis). */}
<div className="sk-help-reset">
<p className="sk-note">Zeigt der Konfigurator unerwartetes Verhalten (z. B. einen falschen Zeilen-Hinweis)? Setzen Sie ihn hier zurück:</p>
<button type="button" className="sk-btn sk-btn--ghost" onClick={konfiguratorZuruecksetzen}>
Konfigurator zurücksetzen
</button>
</div>
<div className="sk-legal-links">
<a href="https://skrift.de/agb" target="_blank" rel="noreferrer">AGB</a>
<a href="https://skrift.de/datenschutz" target="_blank" rel="noreferrer">Datenschutz</a>
@@ -162,7 +181,7 @@ export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: (
const [produkte, setProdukte] = useState<Product[]>([]);
const [form, setForm] = useState({
product: '', name: '', email: '', phone: '',
street: '', zip: '', city: '', country: 'Deutschland',
street: '', zip: '', city: '', country: 'Deutschland', message: '',
});
const [datenschutz, setDatenschutz] = useState(false);
const [status, setStatus] = useState<'formular' | 'sendet' | 'fertig'>('formular');
@@ -184,7 +203,7 @@ export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: (
}
}, []);
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const roh = e.target.value;
setForm({ ...form, [k]: k === 'phone' ? roh.replace(/[^0-9+ ]/g, '') : k === 'zip' ? roh.replace(/[^0-9]/g, '') : roh });
};
@@ -204,6 +223,7 @@ export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: (
zip: form.zip.trim(),
city: form.city.trim(),
country: form.country,
message: form.message.trim() || undefined,
});
setStatus('fertig');
} catch (err) {
@@ -267,6 +287,14 @@ export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: (
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
</select>
</div>
<div className="sk-field">
<label className="sk-label" htmlFor="m-message">
Nachricht <span className="sk-note" style={{ fontWeight: 400 }}>(optional)</span>
</label>
<textarea id="m-message" className="sk-input" rows={3} maxLength={1000}
placeholder="Wünsche oder Fragen zum Muster?"
value={form.message} onChange={set('message')} />
</div>
<label className="sk-check" style={{ marginBottom: 'var(--sk-space-4)' }}>
<input type="checkbox" checked={datenschutz} onChange={(e) => setDatenschutz(e.target.checked)} />

View File

@@ -69,6 +69,48 @@ export default function PreviewPanel({
}
function openZoom(svg: string) { setZoomBox(messeBox()); setZoomSvg(svg); }
/** Seitenverhältnis (Breite/Höhe) aus der viewBox – sperrt die Lightbox-Abmessungen. */
function seitenverhaeltnis(svg: string): string {
const m = svg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
return m ? `${m[1]} / ${m[2]}` : '210 / 297';
}
/** Musterbild der großen Vorschau als PNG herunterladen (SVG → Canvas). */
function bildSpeichern() {
if (!zoomSvg) return;
const m = zoomSvg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
const w = Math.round(m ? Number(m[1]) : 794);
const h = Math.round(m ? Number(m[2]) : 1123);
const scale = 2; // für ein scharfes Musterbild
const url = URL.createObjectURL(new Blob([zoomSvg], { type: 'image/svg+xml;charset=utf-8' }));
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = w * scale;
canvas.height = h * scale;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((png) => {
if (png) {
const a = document.createElement('a');
a.href = URL.createObjectURL(png);
a.download = 'skrift-musterbild.png';
a.click();
URL.revokeObjectURL(a.href);
}
URL.revokeObjectURL(url);
}, 'image/png');
} else {
URL.revokeObjectURL(url);
}
};
img.onerror = () => URL.revokeObjectURL(url);
img.src = url;
}
// Nach einer neuen Generierung wieder vorn beginnen.
useEffect(() => { setIndex(0); }, [docs]);
@@ -152,12 +194,18 @@ export default function PreviewPanel({
{zoomSvg && (
<div className="sk-lightbox" style={zoomBox} onClick={() => setZoomSvg(null)} role="dialog" aria-modal="true"
aria-label="Vergrößerte Vorschau">
<div className="sk-lightbox-sheet" onClick={(e) => e.stopPropagation()}
<div className="sk-lightbox-sheet" style={{ aspectRatio: seitenverhaeltnis(zoomSvg) }}
onClick={(e) => e.stopPropagation()}
dangerouslySetInnerHTML={{ __html: zoomSvg }} />
<div className="sk-lightbox-actions">
<button className="sk-lightbox-close" onClick={(e) => { e.stopPropagation(); bildSpeichern(); }}>
Bild speichern
</button>
<button className="sk-lightbox-close" onClick={() => setZoomSvg(null)} aria-label="Schließen">
Schließen
</button>
</div>
</div>
)}
</aside>
);

View File

@@ -171,6 +171,8 @@ export type CustomerAddress = {
city?: string;
country?: string;
};
/** Feste Adresse ohne Bezeichnung (Rechnung bzw. Lieferung). */
export type Adresse2 = { street?: string; zip?: string; city?: string; country?: string };
export type Customer = {
/** Directus-User-ID (UUID) – Kunde ist das Login-Konto selbst. */
id: string;
@@ -180,8 +182,13 @@ export type Customer = {
last_name: string | null;
company: string | null;
phone: string | null;
/** Mehrere hinterlegte Adressen (Rechnung/Lieferung). */
addresses: CustomerAddress[] | null;
/** Genau zwei feste Adressen ohne Namen. */
billing_address: Adresse2 | null;
shipping_address: Adresse2 | null;
/** Weitere Login-Mailadressen (nur Anzeige; Pflege durch den Betreiber). */
alt_emails: string[] | null;
/** Veraltet – frühere Mehrfach-Adressliste (nur für Alt-Daten). */
addresses?: CustomerAddress[] | null;
};
/**
@@ -190,7 +197,7 @@ export type Customer = {
*/
export async function fetchMyCustomer(): Promise<Customer | null> {
const data = await request(
'/users/me?fields=id,email,person_type,first_name,last_name,company,phone,addresses',
'/users/me?fields=id,email,person_type,first_name,last_name,company,phone,billing_address,shipping_address,alt_emails',
);
return data?.data ?? null;
}
@@ -204,7 +211,8 @@ export async function saveMyCustomer(c: Customer) {
last_name: c.last_name,
company: c.company,
phone: c.phone,
addresses: c.addresses ?? [],
billing_address: c.billing_address ?? null,
shipping_address: c.shipping_address ?? null,
},
});
}
@@ -401,6 +409,8 @@ export type OrderInput = {
text_briefing?: string;
/** Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF). */
source_file?: string;
/** Alle hochgeladenen Dokumente (Datei-Produkte, mehrere möglich). */
source_files?: string[];
/** Rechnungs-/Lieferadresse als Snapshot (für Bestätigung & Rechnung). */
billing?: Record<string, string>;
shipping_address?: Record<string, string> | null;
@@ -511,6 +521,7 @@ export type SampleRequest = {
zip: string;
city: string;
country: string;
message?: string;
};
export async function sendSampleRequest(input: SampleRequest) {

View File

@@ -47,14 +47,19 @@ export const VORLAGEN_SPALTEN = [
/** Spalten der Freitext-Variante: fünf frei beschreibbare Zeilen. */
export const FREITEXT_SPALTEN = ['Zeile 1', 'Zeile 2', 'Zeile 3', 'Zeile 4', 'Zeile 5'];
/** Erzeugt die Vorlagendatei und stößt den Download an. */
export async function ladeVorlageHerunter(zusatzSpalten: string[] = [], freitext = false) {
/**
* Erzeugt die Vorlagendatei und stößt den Download an.
* `nurZusatz` = nur die Platzhalter-Spalten (ohne Adressspalten) – für den
* Fall, dass nur Platzhalterwerte je Dokument gebraucht werden (Sammelversand
* ohne Kuvert), nicht aber Empfängeradressen.
*/
export async function ladeVorlageHerunter(zusatzSpalten: string[] = [], freitext = false, nurZusatz = false) {
const XLSX = await import('xlsx');
const basis = freitext ? FREITEXT_SPALTEN : VORLAGEN_SPALTEN;
const basis = nurZusatz ? [] : (freitext ? FREITEXT_SPALTEN : VORLAGEN_SPALTEN);
const kopf = [...basis, ...zusatzSpalten];
const beispielBasis = freitext
const beispielBasis = nurZusatz ? [] : (freitext
? ['Erika Mustermann', 'Hauptstraße 1', '10115 Berlin', '', '']
: ['Frau', 'Erika', 'Mustermann', 'Hauptstraße', '1', '10115', 'Berlin', 'DE'];
: ['Frau', 'Erika', 'Mustermann', 'Hauptstraße', '1', '10115', 'Berlin', 'DE']);
const beispiel = [...beispielBasis, ...zusatzSpalten.map(() => 'Beispielwert')];
const blatt = XLSX.utils.aoa_to_sheet([kopf, beispiel]);

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
ApiError,
assetUrl,
@@ -25,7 +26,6 @@ import {
type Product,
type PublicSettings,
type Quote,
type CustomerAddress,
} from '../lib/api';
import { ladePayPal, rendereButtons } from '../lib/paypal';
import { ladeVorlageHerunter, leseEmpfaengerDatei } from '../lib/excel';
@@ -106,6 +106,8 @@ type Entry = {
/** Alternative zur strukturierten Adresse. */
free_text: string;
placeholders: Record<string, string>;
/** Abweichender Text NUR für dieses Schriftstück (leer/undefined = Text aus #1). */
text?: string;
};
type Adresse = {
@@ -270,6 +272,10 @@ const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien'
export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: string }) {
const { publish, registerGoto } = useWizard();
const [searchParams] = useSearchParams();
// Externer/Etsy-Modus (?extern=1): Bestellung ohne Preise & Zahlung,
// nur Empfänger → Text → Übersicht (siehe schritte weiter unten).
const externModus = searchParams.get('extern') === '1';
// Zwischengespeicherten Entwurf einmalig laden (siehe ENTWURF_KEY).
const entwurf = useMemo(ladeEntwurf, []);
@@ -315,6 +321,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const [textservice, setTextservice] = useState(entwurf.textservice ?? false);
const [textBriefing, setTextBriefing] = useState(entwurf.textBriefing ?? '');
const [textTemplate, setTextTemplate] = useState(entwurf.textTemplate ?? '');
/** Aktuell im Textfeld angezeigtes Schriftstück (0 = Standardtext, der für alle gilt). */
const [textDocIndex, setTextDocIndex] = useState(0);
const [rechnung, setRechnung] = useState<Adresse>(entwurf.rechnung ?? leereAdresse());
const [lieferAbweichend, setLieferAbweichend] = useState(entwurf.lieferAbweichend ?? false);
@@ -344,7 +352,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const [sendet, setSendet] = useState(false);
const [bestellFehler, setBestellFehler] = useState<string[]>([]);
const [bestellung, setBestellung] = useState<{ nummer: string; zahlung: string; bezahlt: boolean } | null>(null);
const [bestellung, setBestellung] = useState<{ nummer: string; zahlung: string; bezahlt: boolean; extern?: boolean } | null>(null);
const [settings, setSettings] = useState<PublicSettings | null>(null);
const [preisKatalog, setPreisKatalog] = useState<Record<string, PriceItem>>({});
@@ -354,7 +362,10 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const verbotenRe = useMemo(() => {
const wl = settings?.char_whitelist;
if (!wl) return null;
try { return new RegExp(`[^${wl}]`, 'g'); } catch { return null; }
// Zeilenumbrüche sind im mehrzeiligen Text erlaubt; einzeilige Felder
// entfernen \n/\r ohnehin separat vor saeubere(). Tabs (\t) sind erlaubt,
// damit man z.B. das Datum an den rechten Rand rücken kann.
try { return new RegExp(`[^${wl}\\n\\r\\t]`, 'g'); } catch { return null; }
}, [settings?.char_whitelist]);
/** Entfernt verbotene Zeichen und meldet sie; gibt den bereinigten Wert zurück. */
const saeubere = (v: string) => {
@@ -373,7 +384,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
}, [zeichenHinweis]);
const [motivDatei, setMotivDatei] = useState<File | null>(null);
/** Hochgeladenes Dokument bei Datei-Produkten (z. B. zu unterschreibendes PDF). */
const [quellDatei, setQuellDatei] = useState<File | null>(null);
/** Hochgeladene Dokumente bei Datei-Produkten (mehrere PDF/Word/Excel möglich). */
const [quellDateien, setQuellDateien] = useState<File[]>([]);
const paypalRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
@@ -405,17 +417,16 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
fetchMyUnlockedProducts().then(setFreigeschaltet).catch(() => setFreigeschaltet([]));
}, []);
/** Gespeicherte Adressen des angemeldeten Kunden (für die Adressauswahl). */
const [kundenAdressen, setKundenAdressen] = useState<CustomerAddress[]>([]);
// Angemeldete Kunden: Einstufung und Rechnungsdaten übernehmen.
// Angemeldete Kunden: Einstufung + die zwei festen Adressen automatisch
// übernehmen (Rechnung → Rechnungsdaten, Lieferung → abweichende Lieferadresse).
// Kein Adress-Dropdown mehr; alles wird direkt eingesetzt.
useEffect(() => {
if (!loadSession()) return;
fetchMyCustomer()
.then((c) => {
if (!c) return;
if (c.person_type) setPersonType(c.person_type);
setKundenAdressen(c.addresses ?? []);
const b = c.billing_address ?? {};
setRechnung((r) => ({
...r,
first_name: c.first_name ?? r.first_name,
@@ -423,46 +434,27 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
company: c.company ?? r.company,
email: c.email ?? r.email,
phone: c.phone ?? r.phone,
street: b.street ?? r.street,
zip: b.zip ?? r.zip,
city: b.city ?? r.city,
country: b.country ?? r.country,
}));
// Lieferadresse vorbefüllen, aber „abweichende Lieferadresse" bleibt per
// Default AUS – der Kunde aktiviert sie bei Bedarf selbst.
const s = c.shipping_address ?? {};
if (s.street || s.zip || s.city) {
setLieferung((l) => ({
...l,
street: s.street ?? l.street,
zip: s.zip ?? l.zip,
city: s.city ?? l.city,
country: s.country ?? l.country,
}));
}
})
.catch(() => { /* Vorausfüllen ist optional */ });
}, []);
/** Adressfelder aus einer gespeicherten Adresse füllen. */
const uebernimm = (a: CustomerAddress) => (r: Adresse): Adresse => ({
...r,
street: a.street ?? r.street,
zip: a.zip ?? r.zip,
city: a.city ?? r.city,
country: a.country ?? r.country,
});
const adresseUebernehmen = (a: CustomerAddress) => setRechnung(uebernimm(a));
const lieferungUebernehmen = (a: CustomerAddress) => setLieferung(uebernimm(a));
/** Hervorgehobene Auswahl gespeicherter Adressen (nur mit Login und Adressen). */
const adressWahl = (id: string, uebernehmen: (a: CustomerAddress) => void) =>
kundenAdressen.length === 0 ? null : (
<div className="sk-field" style={{
marginTop: 'var(--sk-space-5)',
marginBottom: 'var(--sk-space-5)',
padding: 'var(--sk-space-4)',
border: '1px solid var(--sk-accent, #1E3A6A)',
borderRadius: 'var(--sk-radius, 10px)',
background: 'rgba(30, 58, 106, 0.04)',
}}>
<label className="sk-label" htmlFor={id}>Gespeicherte Adresse übernehmen</label>
<select id={id} className="sk-input" defaultValue=""
onChange={(e) => { const i = Number(e.target.value); if (!Number.isNaN(i) && kundenAdressen[i]) uebernehmen(kundenAdressen[i]); }}>
<option value="">Bitte wählen …</option>
{kundenAdressen.map((a, i) => (
<option key={i} value={i}>
{[a.label, a.street, [a.zip, a.city].filter(Boolean).join(' ')].filter(Boolean).join(' · ') || `Adresse ${i + 1}`}
</option>
))}
</select>
</div>
);
// Sichtbarkeit je Personentyp (im Admin über products.visibility pflegbar).
// Ein per Direktlink vorgewähltes Produkt bleibt immer sichtbar – sonst würde
// die Anfrage abbrechen, sobald z. B. „privat" bei einem Unternehmens-Produkt gewählt wird.
@@ -488,6 +480,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
// Datei-Produkt (z. B. Unterschriftenservice): im Text-Schritt lädt der Kunde
// ein Dokument hoch, statt Text zu tippen.
const dateiModus = gewaehltesProdukt?.input_mode === 'datei';
// Kein Preis im Konfigurator: Extern-Modus ODER Datei-Produkt (z. B.
// Unterschriftenservice – dort wird individuell abgerechnet).
const keinePreise = externModus || dateiModus;
// Eigener Format-Schritt nur, wenn es überhaupt eine Wahl gibt.
const brauchtFormatSchritt = produktFormate.length > 1;
@@ -509,7 +504,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
}
}, [vorwahlProdukt, product, products]);
// Genau ein Format → still setzen (kein Format-Schritt nötig).
// Genau ein Format → still setzen (kein Format-Schritt nötig). Bei mehreren
// Formaten (z. B. Postkarte hoch/quer) NICHT automatisch vorbelegen – der Kunde
// wählt im Format-Schritt (auch im Extern-Modus).
useEffect(() => {
if (produktFormate.length === 1 && format !== produktFormate[0].key) {
setFormat(produktFormate[0].key);
@@ -527,6 +524,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const brauchtAdressen =
shippingType === 'einzeln' || (needsEnvelope && labeling === 'empfaenger');
const umschlagFreitext = needsEnvelope && labeling === 'freitext';
// Empfänger-Schritt (früh definiert, da mehrere Effekte darauf zugreifen):
// „Individueller Text" bei Freitext-Kuvert, „Empfänger" bei Adressbedarf, sonst keiner.
const empfaengerSchritt = umschlagFreitext ? 'Individueller Text' : brauchtAdressen ? 'Empfänger' : null;
// Ab 26 Empfängern ist die Tabelle keine Option mehr.
useEffect(() => {
@@ -535,21 +535,44 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
/** Zeichengrenze richtet sich nach dem Format – wie bisher. */
const maxText = format?.startsWith('a4') ? MAX_TEXT.a4 : MAX_TEXT.a6;
const textZuLang = textTemplate.length > maxText;
// ── Text je Schriftstück (#5) ──────────────────────────────────────────────
// Pfeile im Textfeld: pro Dokument ein eigener Text möglich (Standard = #1).
// Nur im Tabellenmodus (≤ 25) und bei echtem Text (kein Textservice/Datei).
const proDokText = !textservice && !dateiModus && !nurUpload && entries.length > 1;
const textDocs = Math.min(entries.length, TABELLEN_GRENZE);
// Angezeigter Text: #1 = Standardtext (textTemplate); ab #2 der Override bzw.
// – solange nicht geändert – der Standardtext.
const aktDocText = textDocIndex === 0 ? textTemplate : (entries[textDocIndex]?.text ?? textTemplate);
const setDocText = (v: string) => {
const clean = saeubere(v);
if (textDocIndex === 0) { setTextTemplate(clean); return; }
setEntries((prev) => prev.map((e, i) => (i === textDocIndex ? { ...e, text: clean } : e)));
setPreviewSession(null);
};
const textZuLang = aktDocText.length > maxText;
// Ändert sich Text, Format oder Schrift, ist ein früher erkannter Überlauf
// hinfällig – erst die nächste Vorschau entscheidet erneut.
useEffect(() => { setTextUeberlauf(false); }, [textTemplate, format, font]);
// Dokument-Index nicht über die Anzahl hinaus stehen lassen.
useEffect(() => { if (textDocIndex >= textDocs) setTextDocIndex(0); }, [textDocIndex, textDocs]);
// Ändert sich irgendetwas, das die gerenderten Dokumente beeinflusst, wird die
// Vorschau-Session ungültig – sonst würde bei der Bestellung veralteter Inhalt
// 1:1 übernommen. Nur eine unveränderte Vorschau wird wiederverwendet.
useEffect(() => { setPreviewSession(null); },
[textTemplate, font, format, realistisch, labeling, needsEnvelope, umschlagText, entries]);
// Empfängerzeilen an die Menge angleichen (nur im Tabellenmodus).
// Empfängerzeilen an die Menge angleichen (nur im Tabellenmodus). Gilt auch im
// Extern-Modus: die (vom Betreiber vorgegebene) Menge erzeugt die passende
// Anzahl Zeilen – egal ob mit oder ohne Kuvert/Empfänger.
useEffect(() => {
if (nurUpload) return;
// Bei vielen Empfängern (Tabelle unpraktisch) wird auf Excel verwiesen und
// nicht synchronisiert – AUSSER im Extern-Modus ohne Empfänger-Schritt, wo die
// Menge einfach N Blanko-Dokumente erzeugen muss (auch über der Tabellengrenze).
if (nurUpload && (!externModus || empfaengerSchritt)) return;
setEntries((prev) => {
if (prev.length === quantity) return prev;
if (prev.length < quantity) {
@@ -557,29 +580,64 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
}
return prev.slice(0, quantity);
});
}, [quantity, nurUpload]);
}, [quantity, nurUpload, externModus, empfaengerSchritt]);
// Extern-Preset (vom Betreiber im Direktlink gesetzt) – der Kunde wählt nichts:
// ?extern=1&versand=sammel|einzeln&kuvert=0|1&beschriftung=empfaenger|freitext|keine&menge=N
// Die Menge ist maßgeblich (greift immer); nur ein Excel-Import passt sie an die
// tatsächlich gelesene Empfängerzahl an.
useEffect(() => {
if (!externModus) return;
const v = searchParams.get('versand'); if (v === 'einzeln' || v === 'sammel') setShippingType(v);
const k = searchParams.get('kuvert'); if (k === '1' || k === '0') setNeedsEnvelope(k === '1');
const b = searchParams.get('beschriftung'); if (b === 'empfaenger' || b === 'freitext' || b === 'keine') setLabeling(b);
const fmt = searchParams.get('format'); if (fmt) setFormat(fmt);
const m = parseInt(searchParams.get('menge') || '', 10);
if (Number.isFinite(m) && m > 0) { setQuantity(m); setQuantityChosen(true); setMengeText(String(m)); }
// Nur einmal beim Betreten des Extern-Modus anwenden.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externModus]);
// Abweichende Lieferadresse gibt es nur beim Sammelversand – sonst zurücksetzen.
useEffect(() => { if (shippingType !== 'sammel') setLieferAbweichend(false); }, [shippingType]);
/**
* Der Empfängerschritt entfällt vollständig, wenn weder Adressen noch ein
* Umschlagtext gebraucht werden. Bei „Individueller Text" heißt er anders,
* weil dort keine Adressen, sondern der Umschlagtext erfasst wird.
* (empfaengerSchritt ist oben – bei brauchtAdressen/umschlagFreitext – definiert.)
*/
const empfaengerSchritt = umschlagFreitext
? 'Individueller Text'
: brauchtAdressen
? 'Empfänger'
: null;
// Ist das aktuell gewählte Produkt ein „auf Anfrage"-Kontaktprodukt?
const kontaktSelektiert = kontaktProdukte.find((p) => p.key === product) ?? null;
// Produkt kommt aus dem Direktlink; Extern-Modus ist oben definiert.
const angemeldet = !!loadSession();
// „Sie sind" entfällt, wenn die Einstufung schon feststeht (eingeloggt mit
// person_type) oder im Extern-Modus.
const zeigeSieSind = !externModus && !(angemeldet && !!personType);
// Anfrageprodukte (Unterschriftenservice, Follow-ups) haben keinen Konfigurator –
// nach „Weiter" folgt direkt das Anfrageformular als eigene Seite.
const schritte = kontaktSelektiert
// nach „Weiter" folgt direkt das Anfrageformular als eigene Seite. Freigeschaltete
// Kunden landen NICHT hier (dann ist das Produkt bestellbar, s. bestellbar/kontaktProdukte).
const schritte = externModus
// Extern: der Kunde füllt NUR Empfänger + Text; Menge/Versand/Kuvert/Format
// legt der Betreiber über die Direktlink-Parameter fest (s. Extern-Preset).
// Keine Preise, keine Rechnungsdaten, keine Zahlung.
? [
// Format wählbar, wenn es eine echte Wahl gibt (z. B. Postkarte hoch/quer)
// und nicht per ?format=… vorgegeben wurde.
...(brauchtFormatSchritt && !searchParams.get('format') ? ['Format'] : []),
...(empfaengerSchritt ? [empfaengerSchritt] : []),
'Text',
'Rechnungsdaten',
'Übersicht',
]
: kontaktSelektiert
// Direktlink auf ein Kontaktprodukt → direkt zum Anfrageformular (ohne „Sie sind").
? (vorwahlProdukt ? ['Anfrage'] : ['Sie sind', 'Produkt', 'Anfrage'])
: [
'Sie sind',
...(zeigeSieSind ? ['Sie sind'] : []),
...(vorwahlProdukt ? [] : ['Produkt']),
...(brauchtFormatSchritt ? ['Format'] : []),
'Menge & Versand',
@@ -626,7 +684,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const preisMenge = quantityChosen ? quantity : REFERENZ_MENGE;
useEffect(() => {
if (!product || kontaktSelektiert) return; // Kontaktprodukte haben keinen Preis
// Kontaktprodukte, Extern-Modus und Datei-Produkte zeigen keinen Preis.
if (!product || kontaktSelektiert || keinePreise) { setQuote(null); return; }
let abgebrochen = false;
setQuoteFehler(null);
@@ -717,6 +776,15 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const genutztePlatzhalter = useMemo(() => textPlatzhalter(textTemplate), [textTemplate]);
/** Davon die eigenen (nicht aus der Adresse ableitbaren) – nur diese sind editierbar. */
const eigeneImText = genutztePlatzhalter.filter((p) => !standardSet.has(p.toLowerCase()));
/** Platzhalter im individuellen Umschlagtext – analog zur Text-Tabelle. */
const genutztePlatzhalterUmschlag = useMemo(() => textPlatzhalter(umschlagText), [umschlagText]);
/**
* Werden Platzhalterwerte NICHT bereits in einem Empfänger-Schritt erfasst
* (kein Adress-Schritt, kein Umschlag-Freitext)? Dann muss der Text-Schritt
* selbst Tabelle UND Excel-Upload für die Platzhalterwerte anbieten – auch
* beim Sammelversand ohne Kuvert.
*/
const platzhalterNurImText = !brauchtAdressen;
// PayPal-Buttons erscheinen erst nach der Bestellung – dann steht der
@@ -761,11 +829,13 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
* Der MwSt-Hinweis wird bewusst nicht angezeigt.
*/
const aufpreis = (key: string): Aufpreis => {
// Ohne Preise (Datei-Produkt/Extern) an keiner Option Aufpreise zeigen.
if (keinePreise) return null;
const it = preisKatalog[key];
if (!it || !it.price) return null;
// Nur Sammelversand wird für alle identisch (netto) angezeigt. Einzelversand
// (Porto) und alles andere: Privat=brutto, Unternehmen=netto.
const ohneMwStAnzeige = key === 'sammelversand';
const ohneMwStAnzeige = key === 'sammelversand' || key === 'sammelversand_klein';
const betrag = ohneMwStAnzeige || personType === 'unternehmen'
? it.price
: it.price * (1 + mwst / 100);
@@ -797,16 +867,21 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
: entries.every((e) => e.last_name.trim() && e.zip.trim() && e.city.trim());
}
case 'Text':
if (dateiModus) return !!quellDatei;
if (dateiModus) return quellDateien.length > 0;
if (textservice) return textBriefing.trim().length > 0;
return textTemplate.trim().length > 0 && !textZuLang && !textUeberlauf;
case 'Rechnungsdaten':
// Pflichtfelder wie im bestehenden Konfigurator (Telefon optional)
// Pflichtfelder wie im bestehenden Konfigurator (Telefon optional).
// Bei abweichender Lieferadresse sind deren Kernfelder ebenfalls Pflicht.
return (
!!rechnung.first_name.trim() && !!rechnung.last_name.trim() &&
istEmail(rechnung.email) &&
!!rechnung.street.trim() && !!rechnung.zip.trim() && !!rechnung.city.trim() &&
(personType !== 'unternehmen' || !!rechnung.company.trim())
(personType !== 'unternehmen' || !!rechnung.company.trim()) &&
(!lieferAbweichend || (
!!lieferung.first_name.trim() && !!lieferung.last_name.trim() &&
!!lieferung.street.trim() && !!lieferung.zip.trim() && !!lieferung.city.trim()
))
);
default: return false;
}
@@ -822,7 +897,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
if (!paypalMoeglich && zahlung === 'paypal') setZahlung('rechnung');
}, [paypalMoeglich, zahlung]);
const bestellbereit = agb && datenschutz && final && !!quote;
// Ohne Preis (Extern-Modus/Datei-Produkt) gibt es keinen quote – dort reicht die Zustimmung.
const bestellbereit = agb && datenschutz && final && (!!quote || keinePreise);
async function weiter() {
// Beim Verlassen des Text-Schritts prüfen, ob das Schriftstück zu viele
@@ -929,25 +1005,33 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
}
}
// Datei-Produkte (z. B. Unterschriftenservice): Dokument hochladen.
let quellDateiId: string | null = null;
if (dateiModus && quellDatei) {
// Datei-Produkte (z. B. Unterschriftenservice): ein oder mehrere
// Dokumente (PDF/Word/Excel) hochladen.
let quellDateiIds: string[] = [];
if (dateiModus && quellDateien.length) {
try {
quellDateiId = await uploadFile(quellDatei);
quellDateiIds = await Promise.all(quellDateien.map((f) => uploadFile(f)));
} catch {
setBestellFehler(['Das Dokument konnte nicht hochgeladen werden.']);
setBestellFehler(['Ein Dokument konnte nicht hochgeladen werden.']);
setSendet(false);
return;
}
}
// Extern-Modus (Etsy o. Ä.): keine Rechnungsdaten erfasst → Platzhalter,
// Bestellung ohne Zahlung, als „extern" markiert.
const bestellEmail = externModus && !rechnung.email.trim() ? 'extern@skrift.de' : rechnung.email.trim();
const bestellVorname = externModus && !rechnung.first_name.trim() ? 'Externe' : rechnung.first_name;
const bestellNachname = externModus && !rechnung.last_name.trim() ? 'Bestellung' : rechnung.last_name;
const ergebnis = await createOrder({
motif: istPostkarte && motifMode === 'katalog' ? motifId ?? undefined : undefined,
motif_upload: motivDateiId ?? undefined,
source_file: quellDateiId ?? undefined,
email: rechnung.email.trim(),
first_name: rechnung.first_name,
last_name: rechnung.last_name,
source_file: quellDateiIds[0] ?? undefined,
source_files: quellDateiIds.length ? quellDateiIds : undefined,
email: bestellEmail,
first_name: bestellVorname,
last_name: bestellNachname,
company: rechnung.company,
phone: rechnung.phone,
person_type: personType ?? 'privat',
@@ -965,14 +1049,14 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
...(designservice ? ['gestaltungsservice'] : []),
],
voucher_code: voucher.trim() || undefined,
payment_method: zahlung,
source: loadSession() ? 'portal' : 'configurator',
payment_method: keinePreise ? 'rechnung' : zahlung,
source: externModus ? 'extern' : (loadSession() ? 'portal' : 'configurator'),
// Gezeigte Vorschau 1:1 übernehmen (nur wenn unverändert seit der Vorschau).
preview_session: previewSession ?? undefined,
// Adress-Snapshot für Bestätigung/Rechnung.
billing: {
first_name: rechnung.first_name, last_name: rechnung.last_name, company: rechnung.company,
email: rechnung.email, phone: rechnung.phone, street: rechnung.street,
first_name: bestellVorname, last_name: bestellNachname, company: rechnung.company,
email: bestellEmail, phone: rechnung.phone, street: rechnung.street,
zip: rechnung.zip, city: rechnung.city, country: rechnung.country,
},
shipping_address: lieferAbweichend
@@ -992,6 +1076,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
country: e.country,
free_text: adressModus === 'freitext' ? e.free_text : '',
placeholders: e.placeholders,
// Abweichender Text nur, wenn wirklich gesetzt (sonst greift der Standardtext).
text: (e.text && e.text.trim()) ? e.text : undefined,
})),
});
// Auftrag ist abgesendet – der zwischengespeicherte Entwurf wird verworfen.
@@ -1000,6 +1086,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
nummer: ergebnis.order_number,
zahlung: ergebnis.payment_method,
bezahlt: ergebnis.payment_method !== 'paypal',
extern: externModus,
});
} catch (err) {
if (err instanceof ApiError) setBestellFehler(err.details.length ? err.details : [err.message]);
@@ -1030,7 +1117,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
</>
) : (
<p style={{ marginTop: 'var(--sk-space-3)' }} className="sk-note">
{bestellung.zahlung === 'rechnung'
{bestellung.extern
? 'Die Bestellung wird direkt in die Produktion gegeben – die Abrechnung läuft über die Plattform.'
: bestellung.zahlung === 'rechnung'
? 'Die Rechnung erhalten Sie per E-Mail. Die Produktion startet nach Zahlungseingang.'
: 'Ihre Zahlung ist eingegangen. Wir haben Ihnen eine Bestätigung per E-Mail geschickt.'}
</p>
@@ -1040,6 +1129,16 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
Den aktuellen Stand sehen Sie jederzeit unter „Aufträge" – melden Sie sich dafür mit Ihrer
E-Mail-Adresse an.
</p>
{/* Zurück zur Hauptseite – erst wenn die Bestellung abgeschlossen ist
(bei offener PayPal-Zahlung nicht, damit niemand vorher abspringt). */}
{!offenePayPalZahlung && (
<div style={{ marginTop: 'var(--sk-space-4)' }}>
<a className="sk-btn sk-btn--primary" href="https://skrift.de">
Zurück zu skrift.de
</a>
</div>
)}
</section>
</div>
);
@@ -1058,8 +1157,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
}}>{zeichenHinweis}</div>
)}
{/* Preiszeile erst ab dem zweiten Schritt (Produkt) und nur für
bepreisbare Produkte – Anfrageprodukte haben keinen Preis. */}
{step > 0 && !kontaktSelektiert && (
bepreisbare Produkte – Anfrage-, Extern- und Datei-Produkte ohne Preis. */}
{step > 0 && !kontaktSelektiert && !keinePreise && (
<div className="sk-pricebar">
<div className="sk-pricebar-inner">
<div className="sk-price-block">
@@ -1153,12 +1252,6 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
setQuantity(gueltig);
setMengeText(String(gueltig));
}} />
{nurUpload && (
<p className="sk-note" style={{ marginTop: 'var(--sk-space-2)' }}>
Ab {TABELLEN_GRENZE + 1} Stück laden Sie die Empfängerdaten als Excel-Datei hoch –
eine Vorlage stellen wir im Schritt „Empfänger" bereit.
</p>
)}
</div>
</section>
@@ -1171,7 +1264,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
preis={aufpreis('porto_inland')} />
<Option name="ship" selected={shippingType === 'sammel'} onSelect={() => waehleVersand('sammel')}
title="Sammelversand an Sie" desc="Sie erhalten alles zur eigenen Verteilung"
preis={aufpreis('sammelversand')} />
preis={aufpreis(quantity <= 5 ? 'sammelversand_klein' : 'sammelversand')} />
</div>
{shippingType === 'einzeln' && (
<p className="sk-note" style={{ marginTop: 'var(--sk-space-3)' }}>
@@ -1296,6 +1389,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
{/* ── Empfängerdaten ──────────────────────────────────── */}
{/* ── Individueller Umschlagtext (ersetzt die Adressen) ── */}
{aktuell === 'Individueller Text' && (
<>
<section className="sk-card sk-card--head">
<div className="sk-card-head">
<h2>Individueller Text</h2>
@@ -1304,16 +1398,62 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
<div className="sk-card-body">
<div className="sk-field" style={{ marginBottom: 0 }}>
<label className="sk-label" htmlFor="umschlagtext">Text für den Umschlag</label>
<input id="umschlagtext" className="sk-input" maxLength={MAX_UMSCHLAG_TEXT}
<textarea id="umschlagtext" className="sk-input" rows={4} maxLength={MAX_UMSCHLAG_TEXT}
value={umschlagText}
placeholder="z. B. Herzliche Grüße an [[Vorname]]"
onChange={(e) => setUmschlagText(saeubere(e.target.value.replace(/[\r\n]/g, '')))} />
onChange={(e) => setUmschlagText(saeubere(e.target.value))} />
<p className="sk-note" style={{ marginTop: 'var(--sk-space-2)' }}>
{umschlagText.length}/{MAX_UMSCHLAG_TEXT} Zeichen · keine Zeilenumbrüche
{umschlagText.length}/{MAX_UMSCHLAG_TEXT} Zeichen
</p>
</div>
</div>
</section>
{/* Platzhalter-Tabelle: erscheint, sobald der Umschlagtext Platzhalter
nutzt – je Empfänger ein Wert (analog zur Text-Tabelle). */}
{genutztePlatzhalterUmschlag.length > 0 && (
<section className="sk-card sk-card--head" style={{ marginTop: 'var(--sk-space-3)' }}>
<div className="sk-card-head">
<h2>Platzhalter-Werte</h2>
<p>Füllen Sie die im Umschlagtext genutzten Platzhalter je Empfänger aus.</p>
</div>
<div className="sk-card-body">
<div className="sk-table-wrap">
<table className="sk-table">
<thead>
<tr>
<th>#</th>
{genutztePlatzhalterUmschlag.map((p) => <th key={p}>{p}</th>)}
</tr>
</thead>
<tbody>
{entries.map((e, i) => (
<tr key={i}>
<td className="sk-table-no">{i + 1}</td>
{genutztePlatzhalterUmschlag.map((p) => {
const standard = standardSet.has(p.toLowerCase());
return (
<td key={p}>
<input
className="sk-cell"
readOnly={standard}
title={standard ? 'Kommt aus den Adressdaten' : undefined}
style={standard ? { background: 'rgba(15,23,42,0.04)', color: 'var(--sk-muted)' } : undefined}
value={standard ? standardWert(e, p) : (e.placeholders[p] ?? '')}
onChange={standard ? undefined : (ev) => setPlatzhalter(i, p, ev.target.value)}
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
)}
</>
)}
{aktuell === 'Empfänger' && (
@@ -1342,17 +1482,18 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
<hr className="sk-group-split" />
{/* Art der Erfassung */}
{/* Art der Erfassung. Die Tabellen-Option gibt es nur bis zur
Tabellengrenze – darüber ausschließlich Excel-Upload. */}
<div className="sk-options">
<Option name="eingabe" selected={eingabeArt === 'excel'}
onSelect={() => setEingabeArt('excel')}
title="Excel-Datei hochladen" />
{!nurUpload && (
<Option name="eingabe" selected={eingabeArt === 'tabelle'}
onSelect={() => !nurUpload && setEingabeArt('tabelle')}
onSelect={() => setEingabeArt('tabelle')}
title="Direkt in Tabelle eingeben"
desc={nurUpload
? `Ab ${TABELLEN_GRENZE + 1} Empfängern nicht möglich – bitte Datei hochladen`
: `Bis ${TABELLEN_GRENZE} Empfänger – ohne Datei-Upload`} />
desc={`Bis ${TABELLEN_GRENZE} Empfänger – ohne Datei-Upload`} />
)}
</div>
{eingabeArt === 'excel' ? (
@@ -1460,20 +1601,26 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
{dateiModus ? (
<section className="sk-card sk-card--head" style={{ marginTop: 'var(--sk-space-3)' }}>
<div className="sk-card-head">
<h2>Dokument hochladen</h2>
<p>Laden Sie das Dokument hoch, das wir unterschreiben und versenden sollen (PDF oder Word).</p>
<h2>Dokumente hochladen</h2>
<p>Laden Sie ein oder mehrere Dokumente hoch, die wir unterschreiben und versenden sollen (PDF, Word oder Excel).</p>
</div>
<div className="sk-card-body">
<label className="sk-drop">
<span className="sk-drop-title">Datei hier ablegen oder klicken</span>
<span className="sk-drop-sub" style={{ display: 'block' }}>.pdf, .doc oder .docx</span>
<input type="file" accept=".pdf,.doc,.docx"
onChange={(e) => setQuellDatei(e.target.files?.[0] ?? null)} />
<span className="sk-drop-title">Dateien hier ablegen oder klicken</span>
<span className="sk-drop-sub" style={{ display: 'block' }}>.pdf, .doc, .docx, .xls, .xlsx – mehrere möglich</span>
<input type="file" multiple accept=".pdf,.doc,.docx,.xls,.xlsx"
onChange={(e) => setQuellDateien((prev) => [...prev, ...Array.from(e.target.files ?? [])])} />
</label>
{quellDatei && (
<p className="sk-note" style={{ marginTop: 'var(--sk-space-3)' }}>
{quellDatei.name} – wird mit der Bestellung übertragen.
</p>
{quellDateien.length > 0 && (
<ul className="sk-note" style={{ marginTop: 'var(--sk-space-3)', paddingLeft: '1.1em' }}>
{quellDateien.map((f, i) => (
<li key={i}>
{f.name}
<button type="button" className="sk-btn sk-btn--ghost" style={{ marginLeft: 'var(--sk-space-2)', padding: '2px 8px' }}
onClick={() => setQuellDateien((prev) => prev.filter((_, idx) => idx !== i))}>Entfernen</button>
</li>
))}
</ul>
)}
</div>
</section>
@@ -1504,16 +1651,55 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
</div>
) : (
<div className="sk-field" style={{ marginBottom: 0 }}>
<label className="sk-label" htmlFor="text">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
<label className="sk-label" htmlFor="text" style={{ marginBottom: 0 }}>
Inhalt des Schriftstücks <span className="sk-note">· max. {maxText} Zeichen</span>
</label>
<textarea id="text" className="sk-input" rows={10} value={textTemplate}
{/* Pfeile: pro Schriftstück eigenen Text eingeben (Standard = #1). */}
{proDokText && (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<button type="button" className="sk-pager-btn" aria-label="Vorheriges Schriftstück"
disabled={textDocIndex === 0}
onClick={() => setTextDocIndex((i) => Math.max(0, i - 1))}>‹</button>
<span className="sk-note" style={{ whiteSpace: 'nowrap' }}>
Schriftstück {textDocIndex + 1}/{textDocs}
</span>
<button type="button" className="sk-pager-btn" aria-label="Nächstes Schriftstück"
disabled={textDocIndex >= textDocs - 1}
onClick={() => setTextDocIndex((i) => Math.min(textDocs - 1, i + 1))}>›</button>
</div>
)}
</div>
<textarea id="text" className="sk-input" rows={10} value={aktDocText}
maxLength={maxText}
onChange={(e) => setTextTemplate(saeubere(e.target.value))} />
onChange={(e) => setDocText(e.target.value)}
onKeyDown={(e) => {
// Tab-Taste fügt einen echten Tabulator ein (statt den Fokus
// zu verschieben) – für z.B. das Datum am rechten Rand.
if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
e.preventDefault();
if (aktDocText.length >= maxText) return;
const ta = e.currentTarget;
const s = ta.selectionStart ?? aktDocText.length;
const en = ta.selectionEnd ?? s;
setDocText(aktDocText.slice(0, s) + '\t' + aktDocText.slice(en));
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 1; });
}}
onBlur={() => {
// Leerzeilen am Ende automatisch entfernen (zählen sonst als
// Zeilen mit und können das Zeilenlimit fälschlich sprengen).
const beschnitten = aktDocText.replace(/(?:\r?\n[ \t]*)+$/, '');
if (beschnitten !== aktDocText) setDocText(beschnitten);
}} />
<p className={textZuLang ? 'sk-error' : 'sk-note'} style={{ marginTop: 'var(--sk-space-2)' }}>
{textTemplate.length}/{maxText} Zeichen
{aktDocText.length}/{maxText} Zeichen
{textZuLang && ' – Text ist zu lang für das gewählte Format.'}
</p>
{proDokText && textDocIndex > 0 && (
<p className="sk-error" style={{ marginTop: 'var(--sk-space-2)' }}>
Dieser Text wird nur für Schriftstück {textDocIndex + 1} verwendet (Standard = Text aus #1).
</p>
)}
<p className="sk-note" style={{ marginTop: '8px' }}>
{standardPlatzhalter.length > 0 ? (
<>
@@ -1547,7 +1733,39 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
</p>
</div>
<div className="sk-card-body">
{platzhalterNurImText && eigeneImText.length > 0 && (
<div className="sk-options" style={{ marginBottom: 'var(--sk-space-4)' }}>
<Option name="ph-eingabe" selected={eingabeArt === 'excel'}
onSelect={() => setEingabeArt('excel')} title="Excel-Datei hochladen" />
<Option name="ph-eingabe" selected={eingabeArt === 'tabelle'}
onSelect={() => !nurUpload && setEingabeArt('tabelle')}
title="Direkt in Tabelle eingeben"
desc={nurUpload
? `Ab ${TABELLEN_GRENZE + 1} Dokumenten nicht möglich – bitte Datei hochladen`
: `Bis ${TABELLEN_GRENZE} Dokumente – ohne Datei-Upload`} />
</div>
)}
{eingabeArt === 'excel' ? (
platzhalterNurImText ? (
<div>
<button type="button" className="sk-btn sk-btn--ghost"
onClick={() => ladeVorlageHerunter(eigeneImText, false, true)}>
Vorlage herunterladen
</button>
<label className="sk-drop" style={{ marginTop: 'var(--sk-space-4)' }}>
<span className="sk-drop-title">Ausgefüllte Datei hier ablegen oder klicken</span>
<span className="sk-drop-sub" style={{ display: 'block' }}>.xlsx oder .csv</span>
<input type="file" accept=".xlsx,.xls,.csv"
onChange={(e) => excelEinlesen(e.target.files?.[0] ?? null)} />
</label>
{excelDatei && !excelFehler && (
<p className="sk-note" style={{ marginTop: 'var(--sk-space-3)' }}>
{excelDatei.name} – {entries.length} Zeile(n) gelesen.
</p>
)}
{excelFehler && <p className="sk-error">{excelFehler}</p>}
</div>
) : (
<p className="sk-note">
Die Werte kommen aus Ihrer Excel-Datei.
{eigeneImText.length > 0 && (
@@ -1556,6 +1774,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
falls nötig.</>
)}
</p>
)
) : (
<div className="sk-table-wrap">
<table className="sk-table">
@@ -1608,8 +1827,6 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
<p>An diese Adresse stellen wir die Rechnung aus.</p>
</div>
<div className="sk-card-body">
{/* Adressauswahl für angemeldete Kunden mit gespeicherten Adressen. */}
{adressWahl('r-gespeichert', adresseUebernehmen)}
<Feld id="r-vn" label="Vorname" value={rechnung.first_name} required
onChange={(v) => setRechnung({ ...rechnung, first_name: v })} />
<Feld id="r-nn" label="Nachname" value={rechnung.last_name} required
@@ -1640,6 +1857,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
</div>
</section>
{/* Abweichende Lieferadresse gibt es nur beim Sammelversand –
beim Einzelversand geht alles direkt an die Empfänger. */}
{shippingType === 'sammel' && (
<section className="sk-card" style={{ marginTop: 'var(--sk-space-3)' }}>
<label className="sk-check">
<input type="checkbox" checked={lieferAbweichend}
@@ -1649,7 +1869,6 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
{lieferAbweichend && (
<div style={{ marginTop: '18px' }}>
{adressWahl('l-gespeichert', lieferungUebernehmen)}
<Feld id="l-vn" label="Vorname" value={lieferung.first_name} required
onChange={(v) => setLieferung({ ...lieferung, first_name: v })} />
<Feld id="l-nn" label="Nachname" value={lieferung.last_name} required
@@ -1670,11 +1889,22 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
</div>
)}
</section>
)}
</>
)}
{/* ── Übersicht ───────────────────────────────────────── */}
{aktuell === 'Übersicht' && (
<>
{keinePreise ? (
<section className="sk-card">
<p className="sk-note" style={{ margin: 0 }}>
{externModus
? 'Externe Bestellung – sie wird ohne Preis und ohne Zahlung direkt zur Produktion eingereiht (Abrechnung erfolgt über die Plattform).'
: 'Der Preis wird individuell berechnet – Sie erhalten die Rechnung anschließend per E-Mail.'}
</p>
</section>
) : (
<>
<section className="sk-card">
<h2>Kostenübersicht</h2>
@@ -1726,6 +1956,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
</p>
)}
</section>
</>
)}
<section style={{ marginTop: 'var(--sk-space-3)' }}>
<label className="sk-check sk-check--card">
@@ -1774,7 +2006,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
<button className="sk-btn sk-btn--primary sk-btn--block" style={{ marginTop: 'var(--sk-space-5)' }}
disabled={!bestellbereit || sendet} onClick={bestellen}>
{sendet ? 'Wird gesendet …' : 'Zahlungspflichtig bestellen'}
{sendet ? 'Wird gesendet …' : externModus ? 'Bestellung absenden' : keinePreise ? 'Kostenpflichtig bestellen' : 'Zahlungspflichtig bestellen'}
</button>
</section>
</>

View File

@@ -1,8 +1,7 @@
import { useEffect, useState } from 'react';
import { fetchMyCustomer, saveMyCustomer, type Customer, type CustomerAddress } from '../lib/api';
import { fetchMyCustomer, saveMyCustomer, type Customer, type Adresse2 } from '../lib/api';
const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien', 'Niederlande', 'Frankreich'];
const leereAdresse = (): CustomerAddress => ({ label: '', street: '', zip: '', city: '', country: 'Deutschland' });
/** Stammdaten des angemeldeten Kunden – füllen später den Konfigurator vor. */
export default function Stammdaten() {
@@ -37,13 +36,29 @@ export default function Stammdaten() {
}
}
// ── Adressen (mehrere möglich) ──
const adressen = c?.addresses ?? [];
const setAdressen = (next: CustomerAddress[]) => c && setC({ ...c, addresses: next });
const setAdrFeld = (i: number, k: keyof CustomerAddress, v: string) =>
setAdressen(adressen.map((a, idx) => (idx === i ? { ...a, [k]: v } : a)));
const addAdresse = () => setAdressen([...adressen, leereAdresse()]);
const removeAdresse = (i: number) => setAdressen(adressen.filter((_, idx) => idx !== i));
// ── Zwei feste Adressen (Rechnung + Lieferung, ohne Bezeichnung) ──
const setAdrFeld = (slot: 'billing_address' | 'shipping_address', k: keyof Adresse2, v: string) =>
c && setC({ ...c, [slot]: { ...(c[slot] ?? {}), [k]: v } });
const adressBlock = (slot: 'billing_address' | 'shipping_address', titel: string) => {
const a: Adresse2 = c?.[slot] ?? {};
return (
<div className="sk-check--card" style={{ padding: 'var(--sk-space-4)', marginBottom: 'var(--sk-space-3)', border: '1px solid var(--sk-border)', borderRadius: 'var(--sk-radius, 10px)' }}>
<label className="sk-label" style={{ marginBottom: 'var(--sk-space-2)', display: 'block' }}>{titel}</label>
<input className="sk-input" style={{ marginBottom: 'var(--sk-space-2)' }} placeholder="Straße & Hausnummer"
value={a.street ?? ''} onChange={(e) => setAdrFeld(slot, 'street', e.target.value)} />
<div style={{ display: 'flex', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
<input className="sk-input" placeholder="PLZ" value={a.zip ?? ''}
onChange={(e) => setAdrFeld(slot, 'zip', e.target.value.replace(/[^0-9]/g, ''))} style={{ flex: '0 0 30%' }} />
<input className="sk-input" placeholder="Ort" value={a.city ?? ''}
onChange={(e) => setAdrFeld(slot, 'city', e.target.value)} />
</div>
<select className="sk-input" value={a.country ?? 'Deutschland'} onChange={(e) => setAdrFeld(slot, 'country', e.target.value)}>
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
</select>
</div>
);
};
const feld = (k: keyof Customer, label: string, type = 'text') => (
<div className="sk-field">
@@ -92,34 +107,27 @@ export default function Stammdaten() {
<input className="sk-input" value={c.email} disabled />
</div>
{/* Adressen – beliebig viele; im Konfigurator wählbar. */}
{/* Zwei feste Adressen – werden automatisch im Bestellvorgang eingesetzt. */}
<div className="sk-field" style={{ marginBottom: 0 }}>
<label className="sk-label">Adressen</label>
{adressen.length === 0 && <p className="sk-note">Noch keine Adresse hinterlegt.</p>}
{adressen.map((a, i) => (
<div key={i} className="sk-check--card" style={{ padding: 'var(--sk-space-4)', marginBottom: 'var(--sk-space-3)', border: '1px solid var(--sk-border)', borderRadius: 'var(--sk-radius, 10px)' }}>
<div style={{ display: 'flex', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
<input className="sk-input" placeholder="Bezeichnung (z. B. Rechnung, Lieferung)"
value={a.label ?? ''} onChange={(e) => setAdrFeld(i, 'label', e.target.value)} />
<button type="button" className="sk-btn sk-btn--ghost" onClick={() => removeAdresse(i)}>Entfernen</button>
</div>
<input className="sk-input" style={{ marginBottom: 'var(--sk-space-2)' }} placeholder="Straße & Hausnummer"
value={a.street ?? ''} onChange={(e) => setAdrFeld(i, 'street', e.target.value)} />
<div style={{ display: 'flex', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
<input className="sk-input" placeholder="PLZ" value={a.zip ?? ''}
onChange={(e) => setAdrFeld(i, 'zip', e.target.value.replace(/[^0-9]/g, ''))} style={{ flex: '0 0 30%' }} />
<input className="sk-input" placeholder="Ort" value={a.city ?? ''}
onChange={(e) => setAdrFeld(i, 'city', e.target.value)} />
</div>
<select className="sk-input" value={a.country ?? 'Deutschland'} onChange={(e) => setAdrFeld(i, 'country', e.target.value)}>
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
</select>
</div>
))}
<button type="button" className="sk-btn sk-btn--ghost" onClick={addAdresse}>+ Adresse hinzufügen</button>
{adressBlock('billing_address', 'Rechnungsadresse')}
{adressBlock('shipping_address', 'Lieferadresse')}
</div>
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-5)' }} disabled={status === 'speichert'}>
{/* Zusätzliche Login-Mailadressen (Pflege durch Skrift). */}
{c.alt_emails && c.alt_emails.length > 0 && (
<div className="sk-field" style={{ marginTop: 'var(--sk-space-4)', marginBottom: 0 }}>
<label className="sk-label">Weitere Login-Adressen</label>
<p className="sk-note">{c.alt_emails.join(', ')}</p>
</div>
)}
{status === 'gespeichert' && (
<p className="sk-note" role="status" style={{
marginTop: 'var(--sk-space-4)', color: 'var(--sk-success, #2e7d32)', fontWeight: 600,
}}>✓ Stammdaten gespeichert.</p>
)}
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-4)' }} disabled={status === 'speichert'}>
{status === 'speichert' ? 'Wird gespeichert …' : status === 'gespeichert' ? 'Gespeichert' : 'Speichern'}
</button>
</form>

View File

@@ -366,7 +366,9 @@ button { font-family: inherit; }
.sk-pricebar {
position: sticky;
top: 0;
z-index: 10;
/* Muss ÜBER der (ebenfalls sticky) Vorschau liegen, sonst schiebt sich die
Vorschau beim Scrollen über die Preiszeile (Kopfzeile). */
z-index: 30;
background: var(--sk-white);
border-bottom: 1px solid var(--sk-border);
padding: var(--sk-space-3) var(--sk-space-6);
@@ -416,11 +418,15 @@ button { font-family: inherit; }
.sk-preview {
position: sticky;
top: calc(var(--sk-space-6) + 40px);
/* Unterhalb der sticky Preisleiste parken, damit beide sich nicht überlagern. */
top: 84px;
border: 1px solid var(--sk-border);
border-radius: var(--sk-radius-lg);
padding: var(--sk-space-4);
background: var(--sk-white);
/* Über den (sticky) Tabellenzellen, damit die Platzhalter-Tabelle nicht in
die Vorschau-Spalte hineinmalt – aber UNTER der Preisleiste (z-index 30). */
z-index: 20;
}
.sk-preview-head {
display: flex;
@@ -625,10 +631,12 @@ button { font-family: inherit; }
padding: 10px 12px;
white-space: nowrap;
background: var(--sk-card);
/* bleibt beim Scrollen stehen – wie im bestehenden Konfigurator */
/* bleibt beim Scrollen stehen – wie im bestehenden Konfigurator.
Niedriger z-index als Vorschau/Preisleiste, damit die Tabelle nicht
über benachbarte Panels malt. */
position: sticky;
top: 0;
z-index: 10;
z-index: 3;
border-bottom: 2px solid rgba(15, 23, 42, 0.1);
}
.sk-table td {
@@ -650,7 +658,7 @@ button { font-family: inherit; }
/* bleibt beim horizontalen Scrollen sichtbar */
position: sticky;
left: 0;
z-index: 5;
z-index: 2;
}
.sk-cell {
width: 100%;
@@ -686,22 +694,25 @@ button { font-family: inherit; }
background: var(--sk-white);
border-radius: var(--sk-radius);
padding: var(--sk-space-4);
/* Auf den Inhalt schrumpfen, damit die Seite als klar abgesetztes Blatt
schwebt und nicht über die halbe Fläche „ausläuft". */
box-sizing: border-box;
/* Das Seitenverhältnis wird inline aus der viewBox gesetzt (aspectRatio).
Zusammen mit beiden Grenzen skaliert das Blatt IMMER proportional in die
jeweils kleinere Grenze – nie verzerrt, egal welche Fenstergröße. */
max-width: min(92vw, 720px);
max-height: 80vh;
width: auto;
height: auto;
overflow: hidden;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
}
/* Über die Höhe begrenzen: Das SVG trägt sein A4-Seitenverhältnis (mm) selbst,
daher skaliert es proportional und die ganze Seite bleibt sichtbar – kein
Überlaufen mehr wie bei width:100%. */
.sk-lightbox-sheet svg {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: 78vh;
width: 100%;
height: 100%;
object-fit: contain;
}
/* Aktionszeile (Bild speichern / Schließen) unter dem Blatt. */
.sk-lightbox-actions { display: flex; gap: var(--sk-space-3); }
.sk-lightbox-close {
font: inherit;
font-size: var(--sk-size-sm);
@@ -1080,6 +1091,18 @@ textarea.sk-input { resize: vertical; }
font-size: 13px;
}
/* Reset-Hinweis + Button: eigener, abgesetzter Block mit Luft (sonst gequetscht). */
.sk-help-reset {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--sk-space-3);
margin-top: var(--sk-space-5);
padding-top: var(--sk-space-4);
border-top: 1px solid var(--sk-border);
}
.sk-help-reset .sk-note { margin: 0; }
/* Kontakt-Produkt: sieht aus wie eine Option, führt aber zum Angebot. */
.sk-option--contact { cursor: pointer; }
.sk-option--contact .sk-option-price-unit { color: var(--sk-accent); font-weight: 600; }

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/appshell.tsx","./src/components/icons.tsx","./src/components/offerform.tsx","./src/components/previewpanel.tsx","./src/lib/api.ts","./src/lib/dialogs.tsx","./src/lib/excel.ts","./src/lib/paypal.ts","./src/lib/status.ts","./src/lib/wizard.tsx","./src/routes/auftraege.tsx","./src/routes/configurator.tsx","./src/routes/login.tsx","./src/routes/stammdaten.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/appshell.tsx","./src/components/icons.tsx","./src/components/offerform.tsx","./src/components/previewpanel.tsx","./src/lib/api.ts","./src/lib/dialogs.tsx","./src/lib/excel.ts","./src/lib/paypal.ts","./src/lib/status.ts","./src/lib/wizard.tsx","./src/routes/auftraege.tsx","./src/routes/configurator.tsx","./src/routes/login.tsx","./src/routes/muster.tsx","./src/routes/stammdaten.tsx"],"version":"5.9.3"}

Binary file not shown.

View File

@@ -50,7 +50,7 @@ function skrift_preis_sync() {
return $json['data'];
};
$produkte = $hole('/items/products?fields=key,name,base_price,norm_qty,pricing_mode,type&filter[active][_eq]=true&limit=-1');
$produkte = $hole('/items/products?fields=key,name,base_price,norm_qty,price_factor_a,price_factor_e,pricing_mode,type&filter[active][_eq]=true&limit=-1');
$settings = $hole('/items/pricing_settings?fields=*');
$items = $hole('/items/price_items?fields=key,price,unit,category&filter[active][_eq]=true&limit=-1');
@@ -150,6 +150,9 @@ function skrift_preis_parameter($daten, $produktKey, $typ = 'privat') {
// Normalpreis-Menge kommt vom Produkt; ohne Wert kein Kleinmengen-Aufschlag.
$norm = isset($p['norm_qty']) ? (float) $p['norm_qty'] : 0.0;
// Kleinmengen-Aufschlag je Produkt (a = Deckel, e = Exponent). Leer → 1 / 3.
$a = (isset($p['price_factor_a']) && $p['price_factor_a'] !== null && $p['price_factor_a'] !== '') ? (float) $p['price_factor_a'] : 1.0;
$e = (isset($p['price_factor_e']) && $p['price_factor_e'] !== null && $p['price_factor_e'] !== '') ? (float) $p['price_factor_e'] : 3.0;
// Eine gemeinsame Formel.
$formel = !empty($s['multiplier_formula']) ? $s['multiplier_formula'] : '';
@@ -166,6 +169,8 @@ function skrift_preis_parameter($daten, $produktKey, $typ = 'privat') {
return array(
'base' => isset($p['base_price']) ? (float) $p['base_price'] : 0.0,
'norm' => $norm,
'a' => $a,
'e' => $e,
'formel' => $formel,
'mwst' => isset($s['mwst_percent']) ? (float) $s['mwst_percent'] : 19.0,
'tiers' => $tiers,
@@ -191,6 +196,7 @@ function skrift_preis_rechne_param($par, $menge, $mitKuvert) {
$mult = skrift_formel_eval($par['formel'], array(
'qty' => $menge, 'menge' => $menge,
'norm' => $par['norm'], 'base_price' => $par['base'],
'a' => $par['a'], 'e' => $par['e'],
), 1.0);
}
if (!is_finite($mult) || $mult <= 0) $mult = 1.0;
@@ -377,7 +383,7 @@ function skrift_preis_render($atts, $mitMwst) {
function berechne(m, mitKuvert) {
m = Math.max(1, parseInt(m, 10) || 1);
var mult = par.norm > 0
? formelEval(par.formel, { qty: m, menge: m, norm: par.norm, base_price: par.base })
? formelEval(par.formel, { qty: m, menge: m, norm: par.norm, base_price: par.base, a: par.a, e: par.e })
: 1;
if (!isFinite(mult) || mult <= 0) mult = 1;
var grund = par.base * m * mult;