Produktions-Modul, überarbeitete Preislogik und WordPress-Preisrechner
Directus - Neues Modul „Produktion": Board mit Neu/Warteschlange/Im Druck/ Kuvertierung/Versandfertig/Versendet/Archiv, zentraler Suche, Druck-Popup (Maschine, Template, Maße, Nummernbereich, Direktdruck vs. Warteschlange) und Empfängerliste mit Vorschau von Schriftstück und Kuvert. - Interface-Plugins: Auftragsdateien, Drucken, Testdruck, Empfängersuche. - Mailer-Hook für Auftrags- und Statusmails inkl. Signatur und AGB-Anhang. - Auftrag: Empfängerzeilen inline, Produktionsstatus (inkl. storniert), Versanddatum, Brief-/Kuvert-Indikatoren, Insights-Dashboard. - Absender aller Mails ist Skrift (Projektname + email_settings); die Angebotsanfrage nennt jetzt das angefragte Produkt. Preislogik - Eine gemeinsame Multiplikator-Formel; Normalpreis-Menge je Produkt (products.norm_qty). Alte B2B/B2C-Fallback-Felder entfernt. - Mengenrabatt-Staffel (ab X Stück Y %) in den Preis-Einstellungen. - Preis pro Stück ohne einmalige Kosten (unit_net_total). - Einzelversand rechnet Kuvert und Beschriftung nicht mehr doppelt. - Fehlerhafte Grundpreis-Formel legt die Preisauskunft nicht mehr lahm. - Briefnummern 1-basiert: Dateiname entspricht der Briefnummer. app.skrift.de - Direktlinks für Muster, Unterschriftenservice und Follow-ups; Muster als eigene Seite, Kontaktprodukte direkt im Anfrageformular. - Mehrere Adressen in den Stammdaten plus Adressauswahl im Konfigurator. - Verbotene Zeichen werden bei der Eingabe entfernt und gemeldet. - Zeilenüberlauf als Fehlerkasten vor „Weiter"; Mengenfeld leerbar, Standardmenge 100; Checkout-Checkboxen im Kartenstil. Produktion - skrift-agent: Poll-Modell je Maschine, Maß-Übersteuerung, Nummernfilter und automatischer Übergang in die Kuvertierung. WordPress - Neues Plugin „Skrift Preisrechner": Shortcodes inkl./exkl. MwSt, stündlicher Abgleich der Preisdaten in WordPress und ein sicherer Formel-Interpreter, damit Formeländerungen automatisch greifen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -31,3 +31,21 @@ EMAIL_SMTP_SECURE=false
|
|||||||
|
|
||||||
# Nur falls der Mailserver ein selbstsigniertes Zertifikat nutzt:
|
# Nur falls der Mailserver ein selbstsigniertes Zertifikat nutzt:
|
||||||
# EMAIL_SMTP_TLS_REJECT_UNAUTHORIZED=false
|
# EMAIL_SMTP_TLS_REJECT_UNAUTHORIZED=false
|
||||||
|
|
||||||
|
# ── Skrift-Backend (Vorschau + finaler Auftrag) ─────────────
|
||||||
|
# Intern über das gemeinsame npm-Netz erreichbar (container_name).
|
||||||
|
SKRIFT_BACKEND_URL=http://skrift-backend:4000
|
||||||
|
# MUSS exakt dem API_TOKEN aus Docker/skrift-backend/.env entsprechen,
|
||||||
|
# sonst antwortet das Backend mit 401 Unauthorized.
|
||||||
|
SKRIFT_BACKEND_TOKEN=CHANGE-ME-gleich-wie-backend-API_TOKEN
|
||||||
|
# Vorschauen pro Kennung und Tag
|
||||||
|
SKRIFT_PREVIEW_LIMIT=10
|
||||||
|
# Zieladresse für Angebots-/Musteranfragen
|
||||||
|
SKRIFT_TEAM_EMAIL=hello@skrift.de
|
||||||
|
|
||||||
|
# ── PayPal (server-seitige Zahlungsprüfung) ─────────────────
|
||||||
|
# Client-ID ist öffentlich (auch im Frontend), das Secret bleibt NUR hier.
|
||||||
|
PAYPAL_CLIENT_ID=
|
||||||
|
PAYPAL_CLIENT_SECRET=
|
||||||
|
# sandbox oder live
|
||||||
|
PAYPAL_ENV=sandbox
|
||||||
|
|||||||
@@ -94,27 +94,54 @@ const m2o = (field, related, extra = {}) =>
|
|||||||
meta: { interface: 'select-dropdown-m2o', note: extra.note, special: null },
|
meta: { interface: 'select-dropdown-m2o', note: extra.note, special: null },
|
||||||
schema: { is_nullable: extra.nullable !== false }, __related: related, __onDelete: extra.onDelete || 'SET NULL' });
|
schema: { is_nullable: extra.nullable !== false }, __related: related, __onDelete: extra.onDelete || 'SET NULL' });
|
||||||
|
|
||||||
|
// Kunden sind Directus-Login-Nutzer (directus_users). Kein eigener „customers"-
|
||||||
|
// Datensatz mehr – das Profil hängt direkt am Konto (Felder siehe USER_FIELDS).
|
||||||
|
|
||||||
|
// Unterfelder der Adressliste – ohne diese zeigt die „list"-Oberfläche ein
|
||||||
|
// leeres Formular („no visible fields").
|
||||||
|
const ADDRESS_SUBFIELDS = [
|
||||||
|
{ field: 'label', name: 'Bezeichnung', type: 'string', meta: { interface: 'input', width: 'full', options: { placeholder: 'z. B. Rechnung, Lieferung' } } },
|
||||||
|
{ field: 'street', name: 'Straße & Hausnummer', type: 'string', meta: { interface: 'input', width: 'full' } },
|
||||||
|
{ field: 'zip', name: 'PLZ', type: 'string', meta: { interface: 'input', width: 'half' } },
|
||||||
|
{ field: 'city', name: 'Ort', type: 'string', meta: { interface: 'input', width: 'half' } },
|
||||||
|
{ field: 'country', name: 'Land', type: 'string', meta: { interface: 'input', width: 'full', options: { placeholder: 'Deutschland' } } },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Nur die Felder, die Skrift wirklich braucht. Alias-Felder (signatures,
|
||||||
|
// discounts) zeigen die zugehörigen o2m-Collections direkt am Kundenkonto.
|
||||||
|
const USER_FIELDS = [
|
||||||
|
dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat', note: 'Einstufung – nur Betreiber ändert sie.' }),
|
||||||
|
f('company', 'string', { interface: 'input', note: 'Firmenname (bei Unternehmen).' }),
|
||||||
|
f('phone', 'string', { interface: 'input' }),
|
||||||
|
f('addresses', 'json', { interface: 'list', note: 'Adressen des Kunden', options: { fields: ADDRESS_SUBFIELDS } }),
|
||||||
|
f('notes', 'text', { interface: 'input-multiline', note: 'Interne Notizen (für den Kunden nicht sichtbar).' }),
|
||||||
|
{ field: 'signatures', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Hinterlegte Unterschriften des Kunden.' }, schema: null },
|
||||||
|
{ field: 'discounts', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Kundenspezifische Rabatte.' }, schema: null },
|
||||||
|
{ field: 'unlocked_products', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Individuell freigeschaltete Produkte.' }, schema: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
// directus_users-Felder, die Skrift nicht braucht – werden ausgeblendet.
|
||||||
|
const HIDE_USER_FIELDS = ['location', 'title', 'description', 'tags', 'avatar', 'language',
|
||||||
|
'appearance', 'theme_light', 'theme_dark', 'theme_light_overrides', 'theme_dark_overrides',
|
||||||
|
'text_direction', 'email_notifications'];
|
||||||
|
|
||||||
// ── Collections ─────────────────────────────────────────────────────────────
|
// ── Collections ─────────────────────────────────────────────────────────────
|
||||||
const collections = [
|
const collections = [
|
||||||
{ collection: 'customers', meta: { icon: 'person', note: 'Kunde – existiert auch ohne Login (Zuordnung per E-Mail).' }, fields: [
|
|
||||||
f('email', 'string', { interface: 'input', unique: true, required: true }),
|
|
||||||
dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat' }),
|
|
||||||
f('first_name', 'string', { interface: 'input' }),
|
|
||||||
f('last_name', 'string', { interface: 'input' }),
|
|
||||||
f('company', 'string', { interface: 'input' }),
|
|
||||||
f('phone', 'string', { interface: 'input' }),
|
|
||||||
f('addresses', 'json', { interface: 'list', note: 'Adressen des Kunden' }),
|
|
||||||
m2o('linked_user', 'directus_users', { note: 'Verknüpftes Login-Konto (falls aktiviert).' }),
|
|
||||||
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
|
||||||
] },
|
|
||||||
|
|
||||||
{ collection: 'products', meta: { icon: 'inventory_2', note: 'Produkte inkl. Kontakt-Produkte.' }, fields: [
|
{ collection: 'products', meta: { icon: 'inventory_2', note: 'Produkte inkl. Kontakt-Produkte.' }, fields: [
|
||||||
f('key', 'string', { interface: 'input', unique: true, required: true }),
|
f('key', 'string', { interface: 'input', unique: true, required: true }),
|
||||||
f('name', 'string', { interface: 'input', required: true }),
|
f('name', 'string', { interface: 'input', required: true }),
|
||||||
f('description', 'text', { interface: 'input-multiline' }),
|
f('description', 'text', { interface: 'input-multiline' }),
|
||||||
dropdown('type', ['letter', 'postcard', 'sample', 'contact'], { required: true }),
|
dropdown('type', ['letter', 'postcard', 'sample', 'contact'], { required: true }),
|
||||||
|
dropdown('visibility', ['alle', 'privat', 'unternehmen'], { default: 'alle', note: 'Für wen im Konfigurator sichtbar.' }),
|
||||||
dropdown('pricing_mode', ['formel', 'auf_anfrage'], { default: 'formel' }),
|
dropdown('pricing_mode', ['formel', 'auf_anfrage'], { default: 'formel' }),
|
||||||
f('base_price', 'float', { interface: 'input', note: 'Basispreis (netto). Leer bei auf_anfrage.' }),
|
f('base_price', 'float', { interface: 'input', note: 'Basispreis (netto). Leer bei auf_anfrage.' }),
|
||||||
|
// Ab dieser Menge gilt der Normalpreis (Multiplikator 1) – je Produkt.
|
||||||
|
// Leer = Rückfall auf die globale Norm-Menge in den Preis-Einstellungen.
|
||||||
|
f('norm_qty', 'float', { interface: 'input', note: 'Menge, ab der der Normalpreis gilt (Multiplikator 1). Leer = globaler Wert.' }),
|
||||||
|
// Eingabeart im Konfigurator: freier Text oder Datei-Upload (z. B. ein zu
|
||||||
|
// unterschreibendes PDF beim Unterschriftenservice).
|
||||||
|
dropdown('input_mode', ['text', 'datei'], { default: 'text', note: 'Text-Eingabe oder Datei-Upload.' }),
|
||||||
f('active', 'boolean', { interface: 'boolean', default: true }),
|
f('active', 'boolean', { interface: 'boolean', default: true }),
|
||||||
f('sort', 'integer', { interface: 'input' }),
|
f('sort', 'integer', { interface: 'input' }),
|
||||||
] },
|
] },
|
||||||
@@ -129,6 +156,7 @@ const collections = [
|
|||||||
{ collection: 'motifs', meta: { icon: 'image', note: 'Motiv-Katalog für Postkarten.' }, fields: [
|
{ collection: 'motifs', meta: { icon: 'image', note: 'Motiv-Katalog für Postkarten.' }, fields: [
|
||||||
f('name', 'string', { interface: 'input', required: true }),
|
f('name', 'string', { interface: 'input', required: true }),
|
||||||
m2o('image', 'directus_files'),
|
m2o('image', 'directus_files'),
|
||||||
|
dropdown('orientation', ['quer', 'hochkant'], { default: 'quer', note: 'Passend zum Postkartenformat.' }),
|
||||||
f('active', 'boolean', { interface: 'boolean', default: true }),
|
f('active', 'boolean', { interface: 'boolean', default: true }),
|
||||||
f('sort', 'integer', { interface: 'input' }),
|
f('sort', 'integer', { interface: 'input' }),
|
||||||
] },
|
] },
|
||||||
@@ -143,11 +171,33 @@ const collections = [
|
|||||||
] },
|
] },
|
||||||
|
|
||||||
{ collection: 'pricing_settings', meta: { icon: 'calculate', singleton: true, note: 'Formel + globale Werte.' }, fields: [
|
{ collection: 'pricing_settings', meta: { icon: 'calculate', singleton: true, note: 'Formel + globale Werte.' }, fields: [
|
||||||
f('formula', 'text', { interface: 'input-multiline', note: 'Preisformel (editierbar).' }),
|
f('formula', 'text', { interface: 'input-multiline', note: 'Grundpreis-Formel. Variablen: base_price, menge.' }),
|
||||||
|
// Mengen-Multiplikator (Kleinmengen-Aufschlag), je Segment editierbar.
|
||||||
|
// Variablen: qty, norm_b, norm_c, mind_b, mind_c, base_price. Funktionen:
|
||||||
|
// sqrt/abs/min/max/round. Vergleiche + Ternär (cond ? a : b) erlaubt.
|
||||||
|
// Beispiel: (qty >= norm_b) ? 1 : (2 - sqrt(qty / norm_b))
|
||||||
|
// Eine gemeinsame Formel für Privat und Unternehmen. `norm` ist die
|
||||||
|
// Normalpreis-Menge des jeweiligen Produkts (products.norm_qty).
|
||||||
|
f('multiplier_formula', 'text', { interface: 'input-multiline', note: 'Mengen-Multiplikator (gilt für Privat und Unternehmen). Variablen: qty, norm, base_price. Funktionen: sqrt, pow, min, max, round …' }),
|
||||||
f('mwst_percent', 'float', { interface: 'input', default: 19 }),
|
f('mwst_percent', 'float', { interface: 'input', default: 19 }),
|
||||||
|
// Mengenrabatt-Staffel: ab min_qty Stück gilt percent % Rabatt auf den
|
||||||
|
// Grundpreis. Es greift die höchste passende Stufe. Frei in Directus pflegbar.
|
||||||
|
{ field: 'quantity_discounts', type: 'json',
|
||||||
|
meta: { interface: 'list', note: 'Mengenrabatt-Staffel (ab X Stück Y %). Höchste passende Stufe greift.',
|
||||||
|
options: { fields: [
|
||||||
|
{ field: 'min_qty', name: 'ab Menge', type: 'integer', meta: { interface: 'input', width: 'half' } },
|
||||||
|
{ field: 'percent', name: 'Rabatt %', type: 'float', meta: { interface: 'input', width: 'half' } },
|
||||||
|
] } },
|
||||||
|
schema: {} },
|
||||||
f('paypal_limit_net', 'float', { interface: 'input', default: 200, note: 'Ab diesem Netto-Wert nur Rechnung.' }),
|
f('paypal_limit_net', 'float', { interface: 'input', default: 200, note: 'Ab diesem Netto-Wert nur Rechnung.' }),
|
||||||
f('char_whitelist', 'text', { interface: 'input-multiline', note: 'Erlaubte Zeichen (Regex-Klasse, 1:1 aus job-manager).' }),
|
f('char_whitelist', 'text', { interface: 'input-multiline', note: 'Erlaubte Zeichen (Regex-Klasse, 1:1 aus job-manager).' }),
|
||||||
f('currency', 'string', { interface: 'input', default: 'EUR' }),
|
f('currency', 'string', { interface: 'input', default: 'EUR' }),
|
||||||
|
// PayPal: NUR die Client-ID (öffentlich). Das Secret gehört nicht hierher –
|
||||||
|
// diese Collection ist für Gäste lesbar, und clientseitig wird es nicht gebraucht.
|
||||||
|
f('paypal_enabled', 'boolean', { interface: 'boolean', default: false }),
|
||||||
|
dropdown('paypal_mode', ['sandbox', 'live'], { default: 'sandbox' }),
|
||||||
|
f('paypal_client_id_sandbox', 'string', { interface: 'input', note: 'Öffentliche Client-ID (Sandbox).' }),
|
||||||
|
f('paypal_client_id_live', 'string', { interface: 'input', note: 'Öffentliche Client-ID (Live).' }),
|
||||||
] },
|
] },
|
||||||
|
|
||||||
{ collection: 'vouchers', meta: { icon: 'confirmation_number', note: 'Gutscheine.' }, fields: [
|
{ collection: 'vouchers', meta: { icon: 'confirmation_number', note: 'Gutscheine.' }, fields: [
|
||||||
@@ -162,7 +212,7 @@ const collections = [
|
|||||||
|
|
||||||
{ collection: 'orders', meta: { icon: 'receipt_long', note: 'Auftrag.' }, fields: [
|
{ collection: 'orders', meta: { icon: 'receipt_long', note: 'Auftrag.' }, fields: [
|
||||||
f('order_number', 'string', { interface: 'input', unique: true, required: true }),
|
f('order_number', 'string', { interface: 'input', unique: true, required: true }),
|
||||||
m2o('customer', 'customers'),
|
m2o('customer', 'directus_users', { note: 'Kunde = Login-Konto (Zuordnung per E-Mail).' }),
|
||||||
f('customer_email', 'string', { interface: 'input', required: true, note: 'Zuordnungsschlüssel.' }),
|
f('customer_email', 'string', { interface: 'input', required: true, note: 'Zuordnungsschlüssel.' }),
|
||||||
m2o('product', 'products'),
|
m2o('product', 'products'),
|
||||||
m2o('format', 'formats'),
|
m2o('format', 'formats'),
|
||||||
@@ -172,6 +222,13 @@ const collections = [
|
|||||||
dropdown('status', ['entwurf', 'vorbereitet', 'wartet_auf_zahlung', 'bezahlt', 'material_erwartet',
|
dropdown('status', ['entwurf', 'vorbereitet', 'wartet_auf_zahlung', 'bezahlt', 'material_erwartet',
|
||||||
'material_eingegangen', 'in_queue', 'in_produktion', 'gedruckt', 'versendet', 'abgeschlossen', 'storniert'],
|
'material_eingegangen', 'in_queue', 'in_produktion', 'gedruckt', 'versendet', 'abgeschlossen', 'storniert'],
|
||||||
{ default: 'entwurf' }),
|
{ default: 'entwurf' }),
|
||||||
|
// Produktionsstatus (fürs Kanban) – getrennt vom kaufmännischen Status.
|
||||||
|
dropdown('production_status', ['eingegangen', 'im_druck', 'kuvertieren', 'versandfertig', 'versendet', 'storniert'],
|
||||||
|
{ default: 'eingegangen', note: 'Produktions-/Kanban-Status.' }),
|
||||||
|
f('brief_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Schriftstücke gedruckt (vom Agent gesetzt).' }),
|
||||||
|
f('kuvert_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Umschläge gedruckt (vom Agent gesetzt).' }),
|
||||||
|
// Zeitpunkt des Versands – Basis fürs Auto-Archiv (nach 14 Tagen).
|
||||||
|
{ field: 'versandt_am', type: 'timestamp', meta: { interface: 'datetime', note: 'Wird beim Wechsel auf „versendet" gesetzt.' }, schema: { is_nullable: true } },
|
||||||
dropdown('shipping_type', ['einzeln', 'sammel'], { default: 'sammel' }),
|
dropdown('shipping_type', ['einzeln', 'sammel'], { default: 'sammel' }),
|
||||||
f('needs_envelope', 'boolean', { interface: 'boolean', default: false }),
|
f('needs_envelope', 'boolean', { interface: 'boolean', default: false }),
|
||||||
dropdown('envelope_labeling', ['empfaenger', 'freitext', 'keine']),
|
dropdown('envelope_labeling', ['empfaenger', 'freitext', 'keine']),
|
||||||
@@ -182,7 +239,17 @@ const collections = [
|
|||||||
f('own_cards', 'boolean', { interface: 'boolean', default: false, note: 'Eigene Karten (≤ 240 g/m²).' }),
|
f('own_cards', 'boolean', { interface: 'boolean', default: false, note: 'Eigene Karten (≤ 240 g/m²).' }),
|
||||||
f('text_briefing', 'text', { interface: 'input-multiline', note: 'Nur bei Schreibservice.' }),
|
f('text_briefing', 'text', { interface: 'input-multiline', note: 'Nur bei Schreibservice.' }),
|
||||||
f('text_template', 'text', { interface: 'input-multiline', note: 'Text mit [[Platzhaltern]].' }),
|
f('text_template', 'text', { interface: 'input-multiline', note: 'Text mit [[Platzhaltern]].' }),
|
||||||
|
f('preview_session', 'string', { interface: 'input', note: 'Vorschau-Session – gezeigte Dokumente werden 1:1 übernommen.' }),
|
||||||
|
// Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF/Word).
|
||||||
|
{ field: 'source_file', type: 'uuid',
|
||||||
|
meta: { interface: 'file', special: ['file'], note: 'Hochgeladenes Dokument (Datei-Produkte).' },
|
||||||
|
schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' },
|
||||||
|
// Rechnungs-/Lieferadresse als Snapshot (für Bestätigung, Rechnung, Versand).
|
||||||
|
f('billing', 'json', { interface: 'input-code', note: 'Rechnungsadresse (Snapshot bei Bestellung).' }),
|
||||||
|
f('shipping_address', 'json', { interface: 'input-code', note: 'Abweichende Lieferadresse (falls vorhanden).' }),
|
||||||
f('entries_count', 'integer', { interface: 'input', default: 0 }),
|
f('entries_count', 'integer', { interface: 'input', default: 0 }),
|
||||||
|
f('count_inland', 'integer', { interface: 'input', default: 0, note: 'Empfängerzeilen Deutschland.' }),
|
||||||
|
f('count_ausland', 'integer', { interface: 'input', default: 0, note: 'Empfängerzeilen Ausland.' }),
|
||||||
f('net_total', 'float', { interface: 'input' }),
|
f('net_total', 'float', { interface: 'input' }),
|
||||||
f('vat_amount', 'float', { interface: 'input' }),
|
f('vat_amount', 'float', { interface: 'input' }),
|
||||||
f('gross_total', 'float', { interface: 'input' }),
|
f('gross_total', 'float', { interface: 'input' }),
|
||||||
@@ -193,8 +260,30 @@ const collections = [
|
|||||||
f('artifact_path', 'string', { interface: 'input', note: 'Verweis auf Backend-Output.' }),
|
f('artifact_path', 'string', { interface: 'input', note: 'Verweis auf Backend-Output.' }),
|
||||||
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
{ field: 'date_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: {} },
|
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
|
||||||
|
// Produktionsansicht: durchsuchbare Empfängertabelle (Briefnummer + volle
|
||||||
|
// Anschrift, ganze Zeile) – wie im alten Job-Manager. Nur lesend/suchend.
|
||||||
|
{ field: 'entries_view', type: 'alias',
|
||||||
|
meta: { interface: 'skrift-order-entries', special: ['alias', 'no-data'],
|
||||||
|
note: 'Alle Empfänger durchsuchbar (Briefnummer + volle Anschrift).' },
|
||||||
|
schema: null },
|
||||||
|
// Empfängerzeilen direkt im Auftrag (Adresse bzw. alle Freitext-Zeilen
|
||||||
|
// bearbeitbar) – kein Wechsel auf eine eigene Seite nötig.
|
||||||
|
{ field: 'entries', type: 'alias',
|
||||||
|
meta: { interface: 'list-o2m', special: ['o2m'],
|
||||||
|
options: { template: '#{{letter_number}} · {{first_name}} {{last_name}} · {{zip}} {{city}}' },
|
||||||
|
note: 'Empfängerzeilen bearbeiten (Adresse korrigieren).' },
|
||||||
|
schema: null },
|
||||||
// m2m-Alias zu price_items über order_addons:
|
// m2m-Alias zu price_items über order_addons:
|
||||||
{ field: 'addons', type: 'alias', meta: { interface: 'list-m2m', special: ['m2m'] }, schema: null },
|
{ field: 'addons', type: 'alias', meta: { interface: 'list-m2m', special: ['m2m'] }, schema: null },
|
||||||
|
// Präsentationsfeld: zeigt die generierten Dateien per Interface-Plugin
|
||||||
|
// (Direktzugriff auf den Ausgabe-Ordner, keine Kopie).
|
||||||
|
{ field: 'files_view', type: 'alias',
|
||||||
|
meta: { interface: 'skrift-order-files', special: ['alias', 'no-data'], note: 'Generierte Dateien (Direktzugriff).' },
|
||||||
|
schema: null },
|
||||||
|
// Präsentationsfeld: „Drucken"-Button (reiht Druck-Jobs für eine Maschine ein).
|
||||||
|
{ field: 'print_action', type: 'alias',
|
||||||
|
meta: { interface: 'skrift-print-button', special: ['alias', 'no-data'], note: 'In die Druck-Warteschlange einreihen.' },
|
||||||
|
schema: null },
|
||||||
] },
|
] },
|
||||||
|
|
||||||
{ collection: 'order_entries', meta: { icon: 'table_rows', note: 'Empfängerzeilen (Tabelle/Excel).' }, fields: [
|
{ collection: 'order_entries', meta: { icon: 'table_rows', note: 'Empfängerzeilen (Tabelle/Excel).' }, fields: [
|
||||||
@@ -217,13 +306,67 @@ const collections = [
|
|||||||
m2o('price_item', 'price_items', { onDelete: 'CASCADE' }),
|
m2o('price_item', 'price_items', { onDelete: 'CASCADE' }),
|
||||||
] },
|
] },
|
||||||
|
|
||||||
{ collection: 'jobs', meta: { icon: 'print', note: 'Produktions-Job (Queue – wird später verfeinert).' }, fields: [
|
// Plotter/Schreibmaschinen – im Admin pflegbar, Basis für die Warteschlange.
|
||||||
m2o('order', 'orders', { onDelete: 'CASCADE' }),
|
{ collection: 'machines', meta: { icon: 'print', note: 'Produktionsmaschinen (Plotter).' }, fields: [
|
||||||
dropdown('status', ['queued', 'printing', 'printed', 'failed', 'shipped'], { default: 'queued' }),
|
f('name', 'string', { interface: 'input', required: true }),
|
||||||
f('priority', 'integer', { interface: 'input', default: 0 }),
|
f('base_url', 'string', { interface: 'input', required: true, note: 'z. B. http://192.168.2.32:90' }),
|
||||||
|
f('mid', 'string', { interface: 'input', note: 'Maschinen-ID an der Steuerung (optional).' }),
|
||||||
|
f('username', 'string', { interface: 'input', note: 'Login an der Steuerung (falls nötig).' }),
|
||||||
|
f('password', 'string', { interface: 'input', options: { masked: true }, note: 'Login-Passwort (falls nötig).' }),
|
||||||
|
f('poll_interval_seconds', 'integer', { interface: 'input', default: 5 }),
|
||||||
|
f('active', 'boolean', { interface: 'boolean', default: true }),
|
||||||
|
dropdown('status', ['unbekannt', 'bereit', 'druckt', 'fehler', 'offline'], { default: 'unbekannt', note: 'Vom Agent gemeldet.' }),
|
||||||
|
{ field: 'last_seen', type: 'timestamp', meta: { interface: 'datetime', note: 'Letzte Rückmeldung des Agents.' }, schema: {} },
|
||||||
|
f('notes', 'text', { interface: 'input-multiline' }),
|
||||||
|
] },
|
||||||
|
|
||||||
|
// Testdruck: beliebiges Template + Daten schnell generieren und drucken.
|
||||||
|
{ collection: 'test_prints', meta: { icon: 'science', note: 'Testdrucke zum Verproben von Templates & Maschinen.' }, fields: [
|
||||||
|
f('label', 'string', { interface: 'input', note: 'Bezeichnung (optional).' }),
|
||||||
|
m2o('template', 'format_templates', { note: 'Druck-Template (Platzierung an der Maschine).' }),
|
||||||
|
dropdown('format', ['a4', 'a6_hoch', 'a6_quer'], { default: 'a4', note: 'Layout für die Textgenerierung.' }),
|
||||||
|
dropdown('font', ['tilda', 'alva', 'ellie'], { default: 'tilda' }),
|
||||||
|
m2o('machine', 'machines'),
|
||||||
|
f('text', 'text', { interface: 'input-multiline', note: 'Testtext (Platzhalter werden nicht ersetzt).' }),
|
||||||
|
dropdown('status', ['neu', 'generiert', 'fehler'], { default: 'neu' }),
|
||||||
|
f('output_ref', 'string', { interface: 'input', readonly: true, note: 'Ausgabe-Ordner des Testdrucks.' }),
|
||||||
|
{ field: 'run_action', type: 'alias',
|
||||||
|
meta: { interface: 'skrift-testprint-button', special: ['alias', 'no-data'], note: 'Generieren & in die Druck-Warteschlange legen.' },
|
||||||
|
schema: null },
|
||||||
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
||||||
] },
|
] },
|
||||||
|
|
||||||
|
// Druck-Templates (Maße/Position/Rotation in Zoll) – aus config.json.
|
||||||
|
{ collection: 'format_templates', meta: { icon: 'straighten', note: 'Druck-Templates (Zoll).' }, fields: [
|
||||||
|
f('key', 'string', { interface: 'input', unique: true, required: true }),
|
||||||
|
f('name', 'string', { interface: 'input', required: true }),
|
||||||
|
dropdown('type', ['brief', 'umschlag', 'signatur'], { default: 'brief' }),
|
||||||
|
f('width', 'float', { interface: 'input', note: 'Zoll' }),
|
||||||
|
f('height', 'float', { interface: 'input', note: 'Zoll' }),
|
||||||
|
f('xpos', 'float', { interface: 'input', default: 0 }),
|
||||||
|
f('ypos', 'float', { interface: 'input', default: 0 }),
|
||||||
|
f('scale', 'float', { interface: 'input', default: 1 }),
|
||||||
|
f('rotation', 'integer', { interface: 'input', default: 0 }),
|
||||||
|
] },
|
||||||
|
|
||||||
|
{ collection: 'jobs', meta: { icon: 'print', note: 'Produktions-Job (Druck-Warteschlange je Maschine).' }, fields: [
|
||||||
|
m2o('order', 'orders', { onDelete: 'CASCADE' }),
|
||||||
|
f('output_ref', 'string', { interface: 'input', note: 'Ausgabe-Ordner ohne Auftrag (z. B. Testdruck).' }),
|
||||||
|
m2o('machine', 'machines'),
|
||||||
|
dropdown('type', ['brief', 'umschlag', 'signatur'], { default: 'brief', note: 'Was gedruckt wird.' }),
|
||||||
|
m2o('template', 'format_templates', { note: 'Aufgelöstes Druck-Template.' }),
|
||||||
|
dropdown('status', ['queued', 'printing', 'printed', 'failed', 'shipped'], { default: 'queued' }),
|
||||||
|
f('priority', 'integer', { interface: 'input', default: 0, note: 'Höher = zuerst. Direktdruck bekommt hohe Priorität.' }),
|
||||||
|
// Pro-Druck-Übersteuerung der Template-Maße (Breite/Höhe/X/Y/Scale/Rot).
|
||||||
|
// Vom Druck-Popup gesetzt; leer = Template-Werte verwenden.
|
||||||
|
f('params', 'json', { interface: 'input-code', note: 'Maß-Übersteuerung {width,height,xpos,ypos,scale,rotation} (leer = Template).' }),
|
||||||
|
// Welche Dokumentnummern gedruckt werden (1-basiert, z. B. „1-20" oder „1,3,5-7"). Leer = alle.
|
||||||
|
f('numbers', 'string', { interface: 'input', note: 'Zu druckende Briefnummern (1-basiert). Leer = alle.' }),
|
||||||
|
f('error', 'text', { interface: 'input-multiline', note: 'Fehlermeldung vom Agent (falls fehlgeschlagen).' }),
|
||||||
|
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
||||||
|
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
|
||||||
|
] },
|
||||||
|
|
||||||
{ collection: 'status_history', meta: { icon: 'history', note: 'Status-Timeline fürs Portal.' }, fields: [
|
{ collection: 'status_history', meta: { icon: 'history', note: 'Status-Timeline fürs Portal.' }, fields: [
|
||||||
m2o('order', 'orders', { onDelete: 'CASCADE' }),
|
m2o('order', 'orders', { onDelete: 'CASCADE' }),
|
||||||
f('status', 'string', { interface: 'input' }),
|
f('status', 'string', { interface: 'input' }),
|
||||||
@@ -232,6 +375,88 @@ const collections = [
|
|||||||
{ field: 'timestamp', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
{ field: 'timestamp', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
||||||
] },
|
] },
|
||||||
|
|
||||||
|
// Unterschriften je Kunde (SVG-Datei). Wird am Kundenkonto als o2m gezeigt.
|
||||||
|
{ collection: 'signatures', meta: { icon: 'draw', note: 'Unterschriften je Kunde.' }, fields: [
|
||||||
|
m2o('customer', 'directus_users', { onDelete: 'CASCADE' }),
|
||||||
|
f('label', 'string', { interface: 'input', note: 'z. B. „Standard", „mit Titel".' }),
|
||||||
|
// Datei-Upload (SVG) statt Textfeld.
|
||||||
|
{ field: 'file', type: 'uuid',
|
||||||
|
meta: { interface: 'file', special: ['file'], note: 'Signatur als SVG-Datei (Upload).' },
|
||||||
|
schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' },
|
||||||
|
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
||||||
|
] },
|
||||||
|
|
||||||
|
// Kundenspezifische Rabatte. Wird am Kundenkonto als o2m gezeigt.
|
||||||
|
{ collection: 'customer_discounts', meta: { icon: 'percent', note: 'Kundenspezifische Rabatte.' }, fields: [
|
||||||
|
m2o('customer', 'directus_users', { onDelete: 'CASCADE' }),
|
||||||
|
f('label', 'string', { interface: 'input', note: 'z. B. „Einführungsrabatt".' }),
|
||||||
|
dropdown('type', ['prozent', 'fest'], { default: 'prozent' }),
|
||||||
|
f('value', 'float', { interface: 'input', note: 'Prozent oder €-Betrag.' }),
|
||||||
|
dropdown('scope', ['alle', 'erste_n_stueck'], { default: 'alle', note: 'Gilt für alle oder nur die ersten N Stück.' }),
|
||||||
|
f('limit_stueck', 'integer', { interface: 'input', note: 'N bei „erste_n_stueck".' }),
|
||||||
|
f('active', 'boolean', { interface: 'boolean', default: true }),
|
||||||
|
f('valid_until', 'timestamp', { interface: 'datetime', note: 'Optionales Ablaufdatum.' }),
|
||||||
|
] },
|
||||||
|
|
||||||
|
// Für einzelne Kunden freigeschaltete Produkte (Override der Sichtbarkeit).
|
||||||
|
{ collection: 'customer_products', meta: { icon: 'lock_open', note: 'Freigeschaltete Produkte je Kunde.', hidden: true }, fields: [
|
||||||
|
m2o('customer', 'directus_users', { onDelete: 'CASCADE' }),
|
||||||
|
m2o('product', 'products', { onDelete: 'CASCADE' }),
|
||||||
|
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
||||||
|
] },
|
||||||
|
|
||||||
|
// E-Mail-Vorlagen (Betreff + HTML-Text, WYSIWYG). Ein Datensatz je Ereignis.
|
||||||
|
{ collection: 'email_templates', meta: { icon: 'mail', note: 'E-Mail-Vorlagen (Betreff + Text).' }, fields: [
|
||||||
|
f('key', 'string', { interface: 'input', unique: true, required: true, note: 'Ereignis-Schlüssel (login_code, order_created, versendet …).' }),
|
||||||
|
f('label', 'string', { interface: 'input', note: 'Bezeichnung im Admin.' }),
|
||||||
|
f('subject', 'string', { interface: 'input', required: true, note: 'Betreff. Platzhalter: {{order_id}}, {{name}}, {{code}}, {{link}} …' }),
|
||||||
|
f('body', 'text', { interface: 'input-rich-text-html', note: 'Text (WYSIWYG/HTML). Platzhalter in {{...}}.' }),
|
||||||
|
f('append_signature', 'boolean', { interface: 'boolean', default: true, note: 'Signatur automatisch anhängen (bei Mails mit eigenem Footer aus).' }),
|
||||||
|
f('attach_agb', 'boolean', { interface: 'boolean', default: false, note: 'AGB-PDF an diese Mail anhängen.' }),
|
||||||
|
f('enabled', 'boolean', { interface: 'boolean', default: true }),
|
||||||
|
] },
|
||||||
|
|
||||||
|
// Gemeinsame E-Mail-Einstellungen (Signatur, Portal-Link, AGB, Footer).
|
||||||
|
{ collection: 'email_settings', meta: { icon: 'alternate_email', singleton: true, note: 'Absender, Signatur, Portal-Link, AGB + Footer für alle Mails.' }, fields: [
|
||||||
|
f('sender_name', 'string', { interface: 'input', note: 'Absendername (erscheint als Absender, z. B. „Skrift").' }),
|
||||||
|
f('sender_email', 'string', { interface: 'input', note: 'Absenderadresse (muss vom SMTP-Server erlaubt sein).' }),
|
||||||
|
f('signature', 'text', { interface: 'input-rich-text-html', note: 'Signatur unter Mails ohne eigenen Footer (WYSIWYG/HTML).' }),
|
||||||
|
f('portal_url', 'string', { interface: 'input', note: 'Basis-URL des Kundenportals (Platzhalter {{link}}).' }),
|
||||||
|
f('company_footer', 'text', { interface: 'input-rich-text-html', note: 'Footer-Adresszeile (Platzhalter {{footer}}).' }),
|
||||||
|
{ field: 'agb_file', type: 'uuid',
|
||||||
|
meta: { interface: 'file', special: ['file'], note: 'AGB als PDF – wird an Mails mit „AGB anhängen" gehängt. Zum Ändern einfach neue Datei hochladen.' },
|
||||||
|
schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' },
|
||||||
|
] },
|
||||||
|
|
||||||
|
// Standard-Mustertexte für den manuellen Muster-Druck (Du-/Sie-Form).
|
||||||
|
// Platzhalter [[abschnitt]] füllt der Bediener im Druck-Dialog kurz aus.
|
||||||
|
{ collection: 'muster_settings', meta: { icon: 'science', singleton: true, note: 'Standard-Mustertexte (Du/Sie) für den Muster-Druck.' }, fields: [
|
||||||
|
f('text_du', 'text', { interface: 'input-multiline', note: 'Standard-Mustertext (Du-Form). Platzhalter [[abschnitt]] wird im Druck-Dialog gefüllt.' }),
|
||||||
|
f('text_sie', 'text', { interface: 'input-multiline', note: 'Standard-Mustertext (Sie-Form). Platzhalter [[abschnitt]] wird im Druck-Dialog gefüllt.' }),
|
||||||
|
] },
|
||||||
|
|
||||||
|
// Angebots- und Musteranfragen aus der App.
|
||||||
|
{ collection: 'offer_requests', meta: { icon: 'request_quote', note: 'Angebots- und Musteranfragen aus der App.' }, fields: [
|
||||||
|
dropdown('type', ['angebot', 'muster'], { default: 'angebot' }),
|
||||||
|
f('name', 'string', { interface: 'input', required: true }),
|
||||||
|
f('email', 'string', { interface: 'input', required: true }),
|
||||||
|
f('phone', 'string', { interface: 'input' }),
|
||||||
|
f('quantity', 'integer', { interface: 'input' }),
|
||||||
|
f('product', 'string', { interface: 'input', note: 'Bei Muster: gewünschtes Produkt.' }),
|
||||||
|
f('message', 'text', { interface: 'input-multiline' }),
|
||||||
|
f('address', 'json', { interface: 'input-code', note: 'Bei Muster: Lieferadresse.' }),
|
||||||
|
dropdown('status', ['neu', 'in_bearbeitung', 'beantwortet', 'abgelehnt'], { default: 'neu' }),
|
||||||
|
m2o('customer', 'directus_users', { note: 'Falls die Anfrage von einem bestehenden Konto kommt.' }),
|
||||||
|
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
||||||
|
] },
|
||||||
|
|
||||||
|
// Vorschau-Kontingent (serverseitig – der Client darf das nicht bestimmen).
|
||||||
|
{ collection: 'preview_usage', meta: { icon: 'speed', hidden: true, note: 'Vorschau-Verbrauch je Kennung und Tag.' }, fields: [
|
||||||
|
f('key', 'string', { interface: 'input', required: true, note: 'E-Mail oder anonyme Kennung.' }),
|
||||||
|
f('day', 'string', { interface: 'input', required: true }),
|
||||||
|
f('count', 'integer', { interface: 'input', default: 0 }),
|
||||||
|
] },
|
||||||
|
|
||||||
// Einmalcodes für den passwortlosen Login (nur die Extension greift darauf zu).
|
// Einmalcodes für den passwortlosen Login (nur die Extension greift darauf zu).
|
||||||
{ collection: 'login_codes', meta: { icon: 'key', hidden: true, note: 'Einmalcodes für den passwortlosen Login (nur Hashes).' }, fields: [
|
{ collection: 'login_codes', meta: { icon: 'key', hidden: true, note: 'Einmalcodes für den passwortlosen Login (nur Hashes).' }, fields: [
|
||||||
f('email', 'string', { interface: 'input', required: true }),
|
f('email', 'string', { interface: 'input', required: true }),
|
||||||
@@ -268,6 +493,29 @@ async function applySchema() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Profilfelder direkt am Login-Konto (Kunde = directus_users).
|
||||||
|
console.log('› Kundenfelder an directus_users');
|
||||||
|
for (const fld of USER_FIELDS) {
|
||||||
|
if (!existingFields.has(`directus_users.${fld.field}`)) {
|
||||||
|
await api('POST', '/fields/directus_users', fld);
|
||||||
|
log('field +', `directus_users.${fld.field}`);
|
||||||
|
} else {
|
||||||
|
// Vorhandenes Feld: Oberfläche/Optionen aktualisieren (z. B. Adressliste).
|
||||||
|
try { await api('PATCH', `/fields/directus_users/${fld.field}`, { meta: fld.meta }); } catch { /* egal */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ungenutzte Standardfelder ausblenden. Direkt patchen (System-Felder existieren
|
||||||
|
// immer); ein Fehler wird geloggt statt still verschluckt – so sieht man, falls
|
||||||
|
// ein Feldname in dieser Directus-Version anders heißt.
|
||||||
|
for (const field of HIDE_USER_FIELDS) {
|
||||||
|
try {
|
||||||
|
await api('PATCH', `/fields/directus_users/${field}`, { meta: { hidden: true } });
|
||||||
|
log('feld versteckt', `directus_users.${field}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(` Feld directus_users.${field} nicht ausgeblendet: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('› Beziehungen (m2o)');
|
console.log('› Beziehungen (m2o)');
|
||||||
for (const r of relationsToCreate) {
|
for (const r of relationsToCreate) {
|
||||||
if (existingRels.has(`${r.collection}.${r.field}`)) { continue; }
|
if (existingRels.has(`${r.collection}.${r.field}`)) { continue; }
|
||||||
@@ -285,6 +533,16 @@ async function applySchema() {
|
|||||||
await api('PATCH', '/relations/order_addons/price_item', { meta: { junction_field: 'order' } });
|
await api('PATCH', '/relations/order_addons/price_item', { meta: { junction_field: 'order' } });
|
||||||
log('m2m verdrahtet');
|
log('m2m verdrahtet');
|
||||||
} catch (e) { console.warn(' m2m-Verdrahtung übersprungen (bitte melden):', e.message); }
|
} catch (e) { console.warn(' m2m-Verdrahtung übersprungen (bitte melden):', e.message); }
|
||||||
|
|
||||||
|
// o2m am Kundenkonto: Unterschriften + Rabatte direkt im User anzeigen.
|
||||||
|
console.log('› o2m-Verdrahtung');
|
||||||
|
try {
|
||||||
|
await api('PATCH', '/relations/signatures/customer', { meta: { one_field: 'signatures' } });
|
||||||
|
await api('PATCH', '/relations/customer_discounts/customer', { meta: { one_field: 'discounts' } });
|
||||||
|
await api('PATCH', '/relations/customer_products/customer', { meta: { one_field: 'unlocked_products' } });
|
||||||
|
await api('PATCH', '/relations/order_entries/order', { meta: { one_field: 'entries' } });
|
||||||
|
log('o2m verdrahtet');
|
||||||
|
} catch (e) { console.warn(' o2m-Verdrahtung übersprungen (bitte melden):', e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Seed-Daten ──────────────────────────────────────────────────────────────
|
// ── Seed-Daten ──────────────────────────────────────────────────────────────
|
||||||
@@ -299,11 +557,14 @@ async function seedItem(collection, uniqueField, item) {
|
|||||||
async function applySeed() {
|
async function applySeed() {
|
||||||
console.log('› Seed');
|
console.log('› Seed');
|
||||||
const products = [
|
const products = [
|
||||||
{ key: 'briefe', name: 'Briefe', type: 'letter', pricing_mode: 'formel', base_price: 4.0, active: true, sort: 1 },
|
{ 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', pricing_mode: 'formel', base_price: 1.3, active: true, sort: 2 },
|
{ key: 'postkarten', name: 'Postkarten', type: 'postcard', visibility: 'alle', pricing_mode: 'formel', base_price: 1.3, norm_qty: 100, active: true, sort: 2 },
|
||||||
{ key: 'muster', name: 'Muster', type: 'sample', pricing_mode: 'formel', base_price: 0, active: true, sort: 3 },
|
{ key: 'muster', name: 'Muster', type: 'sample', visibility: 'alle', pricing_mode: 'formel', base_price: 0, active: true, sort: 3 },
|
||||||
{ key: 'unterschriftenservice', name: 'Unterschriftenservice', type: 'contact', pricing_mode: 'auf_anfrage', active: true, sort: 4 },
|
// Datei-Produkt: pro Kunde freischaltbar, Kunde lädt ein PDF/Word hoch.
|
||||||
{ key: 'follow_ups', name: 'Automatisierte Follow-ups', type: 'contact', pricing_mode: 'auf_anfrage', active: true, sort: 5 },
|
// pricing_mode „formel" + base_price, damit es für freigeschaltete Kunden
|
||||||
|
// bestellbar ist; für andere bleibt es (type contact) ein Anfrage-Produkt.
|
||||||
|
{ key: 'unterschriftenservice', name: 'Unterschriftenservice', type: 'contact', visibility: 'unternehmen', pricing_mode: 'formel', base_price: 5.0, input_mode: 'datei', active: true, sort: 4 },
|
||||||
|
{ key: 'follow_ups', name: 'Automatisierte Follow-ups', type: 'contact', visibility: 'unternehmen', pricing_mode: 'auf_anfrage', active: true, sort: 5 },
|
||||||
];
|
];
|
||||||
const productByKey = {};
|
const productByKey = {};
|
||||||
for (const p of products) productByKey[p.key] = await seedItem('products', 'key', p);
|
for (const p of products) productByKey[p.key] = await seedItem('products', 'key', p);
|
||||||
@@ -334,18 +595,119 @@ async function applySeed() {
|
|||||||
await api('PATCH', '/items/pricing_settings', {
|
await api('PATCH', '/items/pricing_settings', {
|
||||||
formula: 'base_price * menge', mwst_percent: 19, paypal_limit_net: 200,
|
formula: 'base_price * menge', mwst_percent: 19, paypal_limit_net: 200,
|
||||||
currency: 'EUR', char_whitelist: CHAR_WHITELIST,
|
currency: 'EUR', char_whitelist: CHAR_WHITELIST,
|
||||||
|
// Beispiel-Staffel (relativ hoch angesetzt) – in Directus anpassbar.
|
||||||
|
quantity_discounts: [
|
||||||
|
{ min_qty: 250, percent: 5 },
|
||||||
|
{ min_qty: 500, percent: 10 },
|
||||||
|
{ min_qty: 1000, percent: 15 },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
log('seed singleton pricing_settings');
|
log('seed singleton pricing_settings');
|
||||||
}
|
}
|
||||||
|
// Gemeinsame Mengen-Multiplikator-Formel: nur setzen, wenn leer (Edits bleiben
|
||||||
|
// erhalten). `norm` kommt je Produkt aus products.norm_qty.
|
||||||
|
if (!ps || !ps.multiplier_formula) {
|
||||||
|
await api('PATCH', '/items/pricing_settings', {
|
||||||
|
multiplier_formula: '(qty >= norm) ? 1 : (1 + 2 * pow(1 - qty / norm, 3))',
|
||||||
|
});
|
||||||
|
log('seed multiplier-formel');
|
||||||
|
}
|
||||||
|
|
||||||
|
// E-Mail-Vorlagen (nur anlegen, wenn noch nicht vorhanden – Edits bleiben erhalten).
|
||||||
|
const G = 'Guten Tag {{name}},'; // gemeinsame Anrede
|
||||||
|
// Reiche Bestellbestätigung (Design an das bisherige n8n-Template angelehnt,
|
||||||
|
// aber mit flachen {{platzhaltern}}, die der Mailer-Hook befüllt).
|
||||||
|
const orderCreatedBody = `
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color:#f5f5f5;font-family:Arial,Helvetica,sans-serif;color:#333;">
|
||||||
|
<tr><td style="padding:20px 0;">
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" align="center" style="background:#fff;border-radius:8px;overflow:hidden;">
|
||||||
|
<tr><td style="background:#1a365d;padding:30px 40px;text-align:center;"><h1 style="margin:0;color:#fff;font-size:26px;">Bestellbestätigung</h1></td></tr>
|
||||||
|
<tr><td style="padding:36px 40px 8px;"><p style="margin:0 0 16px;font-size:18px;">Guten Tag {{name}},</p><p style="margin:0 0 16px;">vielen Dank für Ihre Bestellung bei Skrift. Wir haben sie erhalten und bearbeiten sie schnellstmöglich.</p></td></tr>
|
||||||
|
<tr><td style="padding:0 40px 24px;"><table role="presentation" width="100%" style="background:#f0f7ff;border-radius:8px;border-left:4px solid #1a365d;"><tr><td style="padding:18px;"><p style="margin:0;font-size:14px;color:#666;">Ihre Bestellnummer:</p><p style="margin:4px 0 0;font-size:22px;font-weight:700;color:#1a365d;">{{order_id}}</p></td></tr></table></td></tr>
|
||||||
|
<tr><td style="padding:8px 40px 4px;"><h2 style="margin:0 0 12px;font-size:18px;color:#1a365d;">Ihre Bestellung</h2>
|
||||||
|
<table role="presentation" width="100%">
|
||||||
|
<tr><td width="40%" style="padding:6px 0;color:#666;">Produkt:</td><td style="padding:6px 0;font-weight:600;">{{product}}</td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:#666;">Menge:</td><td style="padding:6px 0;font-weight:600;">{{menge}} Stück</td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:#666;">Format:</td><td style="padding:6px 0;font-weight:600;">{{format}}</td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:#666;">Versand:</td><td style="padding:6px 0;font-weight:600;">{{versand}}</td></tr>
|
||||||
|
</table>{{kuvert_block}}</td></tr>
|
||||||
|
<tr><td style="padding:16px 40px 4px;"><h2 style="margin:0 0 12px;font-size:18px;color:#1a365d;">Preisübersicht</h2>
|
||||||
|
<table role="presentation" width="100%">
|
||||||
|
<tr><td style="padding:6px 0;color:#666;">Zwischensumme (netto):</td><td style="padding:6px 0;text-align:right;">{{netto}} {{waehrung}}</td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:#666;">MwSt. ({{mwst_satz}} %):</td><td style="padding:6px 0;text-align:right;">{{mwst_betrag}} {{waehrung}}</td></tr>
|
||||||
|
<tr><td colspan="2" style="padding:8px 0;"><hr style="border:none;border-top:2px solid #1a365d;margin:0;"></td></tr>
|
||||||
|
<tr><td style="padding:6px 0;font-size:17px;font-weight:700;color:#1a365d;">Gesamt (brutto):</td><td style="padding:6px 0;text-align:right;font-size:17px;font-weight:700;color:#1a365d;">{{brutto}} {{waehrung}}</td></tr>
|
||||||
|
</table></td></tr>
|
||||||
|
<tr><td style="padding:16px 40px 4px;"><h2 style="margin:0 0 12px;font-size:18px;color:#1a365d;">Rechnungsadresse</h2><p style="margin:0;line-height:1.6;">{{rechnungsadresse}}</p></td></tr>
|
||||||
|
{{lieferadresse_block}}
|
||||||
|
<tr><td style="padding:16px 40px;"><h2 style="margin:0 0 10px;font-size:18px;color:#1a365d;">Wie geht es weiter?</h2><ol style="margin:0;padding-left:20px;line-height:1.7;"><li>Wir prüfen Ihre Bestellung und bereiten die Produktion vor.</li><li>Nach Fertigstellung erhalten Sie eine Versandbestätigung.</li></ol></td></tr>
|
||||||
|
<tr><td style="background:#f5f5f5;padding:24px 40px;text-align:center;border-top:1px solid #e0e0e0;"><p style="margin:0 0 8px;font-size:14px;color:#666;">Mit freundlichen Grüßen<br><strong>Ihr Skrift-Team</strong></p><p style="margin:12px 0 0;font-size:12px;color:#999;">{{footer}}</p></td></tr>
|
||||||
|
</table></td></tr></table>`.trim();
|
||||||
|
|
||||||
|
const templates = [
|
||||||
|
{ key: 'login_code', label: 'Anmeldecode', subject: 'Ihr Anmeldecode: {{code}}', append_signature: true, attach_agb: false,
|
||||||
|
body: `<p>Guten Tag,</p><p>Ihr Anmeldecode für Skrift lautet:</p>` +
|
||||||
|
`<p style="font-size:28px;font-weight:700;letter-spacing:4px">{{code}}</p>` +
|
||||||
|
`<p>Der Code ist 10 Minuten gültig. Falls Sie keine Anmeldung angefordert haben, ignorieren Sie diese E-Mail.</p>` },
|
||||||
|
{ key: 'order_created', label: 'Bestellbestätigung', subject: 'Ihre Bestellung {{order_id}} ist eingegangen',
|
||||||
|
append_signature: false, attach_agb: true, body: orderCreatedBody },
|
||||||
|
{ key: 'bezahlt', label: 'Status: bezahlt', subject: 'Zahlung erhalten – {{order_id}}', append_signature: true, attach_agb: false,
|
||||||
|
body: `<p>${G}</p><p>Ihre Zahlung zu Auftrag <strong>{{order_id}}</strong> ist bei uns eingegangen. Wir starten die Produktion.</p>` },
|
||||||
|
{ key: 'in_produktion', label: 'Status: in Produktion', subject: '{{order_id}} ist in Produktion', append_signature: true, attach_agb: false,
|
||||||
|
body: `<p>${G}</p><p>Ihr Auftrag <strong>{{order_id}}</strong> ist jetzt in Produktion.</p>` },
|
||||||
|
{ key: 'versendet', label: 'Status: versendet', subject: '{{order_id}} wurde versendet', append_signature: true, attach_agb: false,
|
||||||
|
body: `<p>${G}</p><p>Ihre Schriftstücke zu Auftrag <strong>{{order_id}}</strong> wurden versendet bzw. an Sie übergeben. Vielen Dank für Ihr Vertrauen!</p>` },
|
||||||
|
{ key: 'abgeschlossen', label: 'Status: abgeschlossen', subject: '{{order_id}} abgeschlossen', append_signature: true, attach_agb: false,
|
||||||
|
body: `<p>${G}</p><p>Ihr Auftrag <strong>{{order_id}}</strong> ist abgeschlossen. Wir freuen uns auf Ihre nächste Bestellung.</p>` },
|
||||||
|
{ key: 'storniert', label: 'Status: storniert', subject: '{{order_id}} wurde storniert', append_signature: true, attach_agb: false,
|
||||||
|
body: `<p>${G}</p><p>Ihr Auftrag <strong>{{order_id}}</strong> wurde storniert. Bei Fragen sind wir gerne für Sie da.</p>{{reason}}` },
|
||||||
|
];
|
||||||
|
for (const t of templates) await seedItem('email_templates', 'key', { enabled: true, ...t });
|
||||||
|
|
||||||
|
// Druck-Templates (aus der bisherigen config.json der Produktions-App).
|
||||||
|
const formatTemplates = [
|
||||||
|
{ key: 'a4_single', name: 'A4 Brief – Einzeln', type: 'brief', width: 8.27, height: 11.69, xpos: 0.0, ypos: -0.3, scale: 1.0, rotation: 0 },
|
||||||
|
{ key: 'a6_v1', name: 'A6 Brief – Hochformat', type: 'brief', width: 4.1, height: 5.8, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 },
|
||||||
|
{ key: 'a6_v2', name: 'A6 Brief – Querformat', type: 'brief', width: 4.1, height: 5.8, xpos: 4.0, ypos: -0.3, scale: 1.0, rotation: 90 },
|
||||||
|
{ key: 'kuvert_a_v1', name: 'Kuvert DIN Lang', type: 'umschlag', width: 4.49, height: 6.38, xpos: 4.2, ypos: -0.2, scale: 1.0, rotation: 90 },
|
||||||
|
{ key: 'kuvert_b_v1', name: 'Kuvert C6', type: 'umschlag', width: 3.9, height: 8.27, xpos: 4.49, ypos: -0.2, scale: 1.0, rotation: 90 },
|
||||||
|
];
|
||||||
|
for (const t of formatTemplates) await seedItem('format_templates', 'key', t);
|
||||||
|
|
||||||
|
// email_settings (Singleton) – Signatur nur setzen, wenn leer.
|
||||||
|
const es = await api('GET', '/items/email_settings');
|
||||||
|
if (!es || !es.signature) {
|
||||||
|
await api('PATCH', '/items/email_settings', {
|
||||||
|
sender_name: 'Skrift',
|
||||||
|
sender_email: (process.env.EMAIL_FROM || 'noreply@skrift.de').trim(),
|
||||||
|
signature: '<p>Mit freundlichen Grüßen<br>Ihr Skrift-Team</p>',
|
||||||
|
portal_url: (process.env.CORS_ORIGIN || 'https://app.skrift.de').split(',')[0].trim(),
|
||||||
|
company_footer: 'Skrift | Hundscheiderweg 4 | 66679 Losheim am See | www.skrift.de',
|
||||||
|
});
|
||||||
|
log('seed singleton email_settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
// muster_settings (Singleton) – Standardtexte nur setzen, wenn leer.
|
||||||
|
const ms = await api('GET', '/items/muster_settings');
|
||||||
|
if (!ms || (!ms.text_du && !ms.text_sie)) {
|
||||||
|
await api('PATCH', '/items/muster_settings', {
|
||||||
|
text_du: 'Hallo [[Vorname]],\n\nschön, dass Du Skrift ausprobierst! Dieser Brief wurde von einem Roboter mit einem echten Stift geschrieben.\n\n[[abschnitt]]\n\nHerzliche Grüße\nDein Skrift-Team',
|
||||||
|
text_sie: 'Sehr geehrte/r [[Anrede]] [[Nachname]],\n\nschön, dass Sie Skrift ausprobieren! Dieser Brief wurde von einem Roboter mit einem echten Stift geschrieben.\n\n[[abschnitt]]\n\nMit freundlichen Grüßen\nIhr Skrift-Team',
|
||||||
|
});
|
||||||
|
log('seed singleton muster_settings');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rollen / Policies / Rechte (Directus 11) ────────────────────────────────
|
// ── Rollen / Policies / Rechte (Directus 11) ────────────────────────────────
|
||||||
const BIZ = ['customers', 'products', 'formats', 'motifs', 'price_items', 'pricing_settings',
|
const BIZ = ['products', 'formats', 'motifs', 'price_items', 'pricing_settings',
|
||||||
'vouchers', 'voucher_redemptions', 'orders', 'order_entries', 'order_addons', 'jobs', 'status_history'];
|
'vouchers', 'voucher_redemptions', 'orders', 'order_entries', 'order_addons', 'jobs', 'status_history',
|
||||||
const OWN_ORDER = { customer: { linked_user: { _eq: '$CURRENT_USER' } } };
|
'offer_requests', 'signatures', 'customer_discounts', 'customer_products',
|
||||||
const OWN_VIA_ORDER = { order: { customer: { linked_user: { _eq: '$CURRENT_USER' } } } };
|
'email_templates', 'email_settings', 'muster_settings', 'machines', 'format_templates', 'test_prints'];
|
||||||
|
// Kunde = Login-Konto: Aufträge hängen direkt am Nutzer.
|
||||||
|
const OWN_ORDER = { customer: { _eq: '$CURRENT_USER' } };
|
||||||
|
const OWN_VIA_ORDER = { order: { customer: { _eq: '$CURRENT_USER' } } };
|
||||||
|
|
||||||
const CUSTOMER_ROLE_NAME = 'Kunde (Login)';
|
const CUSTOMER_ROLE_NAME = 'Kunde';
|
||||||
|
|
||||||
/** Benennt eine bestehende Rolle um (idempotent – tut nichts, wenn schon umbenannt). */
|
/** Benennt eine bestehende Rolle um (idempotent – tut nichts, wenn schon umbenannt). */
|
||||||
async function renameRole(oldName, newName) {
|
async function renameRole(oldName, newName) {
|
||||||
@@ -400,17 +762,28 @@ async function applyPermissions() {
|
|||||||
await ensurePermission(staffPol.id, col, act);
|
await ensurePermission(staffPol.id, col, act);
|
||||||
|
|
||||||
// Customer: nur Eigenes + Lesen der Kataloge
|
// Customer: nur Eigenes + Lesen der Kataloge
|
||||||
// (Alt-Name "Customer" umbenennen, damit er sich von der Collection "Kunden" unterscheidet.)
|
// Alt-Namen auf den finalen "Kunde" ziehen (idempotent).
|
||||||
await renameRole('Customer', CUSTOMER_ROLE_NAME);
|
await renameRole('Customer', CUSTOMER_ROLE_NAME);
|
||||||
|
await renameRole('Kunde (Login)', CUSTOMER_ROLE_NAME);
|
||||||
const custRole = await ensureRole(CUSTOMER_ROLE_NAME, 'person');
|
const custRole = await ensureRole(CUSTOMER_ROLE_NAME, 'person');
|
||||||
const custPol = await ensurePolicy('Customer', { app: false });
|
const custPol = await ensurePolicy('Customer', { app: false });
|
||||||
await ensureAccess(custRole.id, custPol.id);
|
await ensureAccess(custRole.id, custPol.id);
|
||||||
await ensurePermission(custPol.id, 'customers', 'read', ['*'], { linked_user: { _eq: '$CURRENT_USER' } });
|
// Kunde = eigenes Login-Konto: eigenes Profil lesen …
|
||||||
await ensurePermission(custPol.id, 'customers', 'update', ['first_name', 'last_name', 'company', 'phone', 'addresses'], { linked_user: { _eq: '$CURRENT_USER' } });
|
await ensurePermission(custPol.id, 'directus_users', 'read',
|
||||||
|
['id', 'email', 'first_name', 'last_name', 'company', 'phone', 'person_type', 'addresses'],
|
||||||
|
{ id: { _eq: '$CURRENT_USER' } });
|
||||||
|
// … und begrenzt selbst pflegen. person_type bewusst NICHT enthalten – die
|
||||||
|
// Einstufung privat/Unternehmen legt der Betreiber fest, nicht der Kunde.
|
||||||
|
await ensurePermission(custPol.id, 'directus_users', 'update',
|
||||||
|
['first_name', 'last_name', 'company', 'phone', 'addresses'],
|
||||||
|
{ id: { _eq: '$CURRENT_USER' } });
|
||||||
for (const act of ['create', 'read', 'update']) await ensurePermission(custPol.id, 'orders', act, ['*'], OWN_ORDER);
|
for (const 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);
|
for (const col of ['order_entries', 'order_addons']) for (const act of ['create', 'read', 'update']) await ensurePermission(custPol.id, col, act, ['*'], OWN_VIA_ORDER);
|
||||||
await ensurePermission(custPol.id, 'status_history', 'read', ['*'], OWN_VIA_ORDER);
|
await ensurePermission(custPol.id, 'status_history', 'read', ['*'], OWN_VIA_ORDER);
|
||||||
await ensurePermission(custPol.id, 'jobs', 'read', ['*'], OWN_VIA_ORDER);
|
await ensurePermission(custPol.id, 'jobs', 'read', ['*'], OWN_VIA_ORDER);
|
||||||
|
// Eigene Produkt-Freischaltungen lesen (bestimmt, welche Produkte der Kunde
|
||||||
|
// im Konfigurator konfigurieren darf).
|
||||||
|
await ensurePermission(custPol.id, 'customer_products', 'read', ['*'], { customer: { _eq: '$CURRENT_USER' } });
|
||||||
for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings'])
|
for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings'])
|
||||||
await ensurePermission(custPol.id, col, 'read');
|
await ensurePermission(custPol.id, col, 'read');
|
||||||
|
|
||||||
@@ -425,11 +798,16 @@ async function applyPermissions() {
|
|||||||
const svcRole = await ensureRole('Service', 'smart_toy');
|
const svcRole = await ensureRole('Service', 'smart_toy');
|
||||||
const svcPol = await ensurePolicy('Service', { app: false });
|
const svcPol = await ensurePolicy('Service', { app: false });
|
||||||
await ensureAccess(svcRole.id, svcPol.id);
|
await ensureAccess(svcRole.id, svcPol.id);
|
||||||
for (const col of ['orders', 'order_entries', 'order_addons', 'jobs', 'customers', 'products', 'formats', 'motifs', 'price_items'])
|
for (const col of ['orders', 'order_entries', 'order_addons', 'jobs', 'products', 'formats',
|
||||||
|
'motifs', 'price_items', 'machines', 'format_templates'])
|
||||||
await ensurePermission(svcPol.id, col, 'read');
|
await ensurePermission(svcPol.id, col, 'read');
|
||||||
await ensurePermission(svcPol.id, 'orders', 'update', ['status', 'artifact_path', 'payment_status', 'payment_ref']);
|
await ensurePermission(svcPol.id, 'orders', 'update',
|
||||||
|
['status', 'artifact_path', 'payment_status', 'payment_ref', 'production_status', 'brief_gedruckt', 'kuvert_gedruckt']);
|
||||||
await ensurePermission(svcPol.id, 'jobs', 'create');
|
await ensurePermission(svcPol.id, 'jobs', 'create');
|
||||||
await ensurePermission(svcPol.id, 'jobs', 'update', ['status', 'priority']);
|
// Agent meldet Druckfortschritt zurück.
|
||||||
|
await ensurePermission(svcPol.id, 'jobs', 'update', ['status', 'priority', 'error']);
|
||||||
|
// Agent meldet Maschinenzustand (Heartbeat).
|
||||||
|
await ensurePermission(svcPol.id, 'machines', 'update', ['status', 'last_seen']);
|
||||||
await ensurePermission(svcPol.id, 'status_history', 'create');
|
await ensurePermission(svcPol.id, 'status_history', 'create');
|
||||||
|
|
||||||
console.log(' Hinweis: Für Service einen Nutzer in Rolle "Service" anlegen und dessen',
|
console.log(' Hinweis: Für Service einen Nutzer in Rolle "Service" anlegen und dessen',
|
||||||
@@ -440,7 +818,6 @@ async function applyPermissions() {
|
|||||||
async function applyLabels() {
|
async function applyLabels() {
|
||||||
console.log('› Anzeigenamen');
|
console.log('› Anzeigenamen');
|
||||||
const labels = {
|
const labels = {
|
||||||
customers: { de: ['Kunde', 'Kunden'], en: ['Customer record', 'Customer records'] },
|
|
||||||
orders: { de: ['Auftrag', 'Aufträge'], en: ['Order', 'Orders'] },
|
orders: { de: ['Auftrag', 'Aufträge'], en: ['Order', 'Orders'] },
|
||||||
order_entries: { de: ['Empfängerzeile', 'Empfängerzeilen'], en: ['Entry', 'Entries'] },
|
order_entries: { de: ['Empfängerzeile', 'Empfängerzeilen'], en: ['Entry', 'Entries'] },
|
||||||
price_items: { de: ['Preisposition', 'Preise'], en: ['Price item', 'Price items'] },
|
price_items: { de: ['Preisposition', 'Preise'], en: ['Price item', 'Price items'] },
|
||||||
@@ -460,6 +837,173 @@ async function applyLabels() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Navigation gruppieren ────────────────────────────────────────────────────
|
||||||
|
// Hauptansicht bewusst schlank: nur Aufträge, Jobs, Gutscheine, Verlauf.
|
||||||
|
// Alles Konfigurations-artige (Produkte, Preise, Motive …) wandert in einen
|
||||||
|
// Ordner „Einstellungen"; reine Detail-/Technik-Collections werden versteckt.
|
||||||
|
async function organizeNav() {
|
||||||
|
console.log('› Navigation gruppieren');
|
||||||
|
const FOLDER = 'einstellungen';
|
||||||
|
|
||||||
|
// Ordner (Collection ohne Tabelle) anlegen, falls noch nicht vorhanden.
|
||||||
|
let folderDa = true;
|
||||||
|
try { await api('GET', `/collections/${FOLDER}`); } catch { folderDa = false; }
|
||||||
|
if (!folderDa) {
|
||||||
|
await api('POST', '/collections', {
|
||||||
|
collection: FOLDER, schema: null,
|
||||||
|
meta: { icon: 'settings', note: 'Konfiguration: Produkte, Preise, Motive, Gutschein-Einlösungen …',
|
||||||
|
translations: [
|
||||||
|
{ language: 'de-DE', translation: 'Einstellungen', singular: 'Einstellung', plural: 'Einstellungen' },
|
||||||
|
{ language: 'en-US', translation: 'Settings', singular: 'Setting', plural: 'Settings' },
|
||||||
|
] },
|
||||||
|
});
|
||||||
|
log('folder +', FOLDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = async (col, meta) => {
|
||||||
|
try { await api('PATCH', `/collections/${col}`, { meta }); }
|
||||||
|
catch (e) { console.warn(` Nav ${col} übersprungen: ${e.message}`); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hauptebene
|
||||||
|
for (const col of ['orders', 'jobs', 'vouchers', 'status_history'])
|
||||||
|
await patch(col, { group: null, hidden: false });
|
||||||
|
// Unter „Einstellungen"
|
||||||
|
for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', 'offer_requests',
|
||||||
|
'email_templates', 'email_settings', 'muster_settings', 'machines', 'format_templates', 'test_prints'])
|
||||||
|
await patch(col, { group: FOLDER, hidden: false });
|
||||||
|
// Reine Detail-/Technik-Collections aus der Navigation nehmen (Signaturen und
|
||||||
|
// Rabatte werden direkt am Kundenkonto gepflegt, nicht als eigener Menüpunkt).
|
||||||
|
// Empfängerzeilen werden direkt im Auftrag gepflegt (o2m) → keine eigene Seite.
|
||||||
|
for (const col of ['order_entries', 'order_addons', 'voucher_redemptions', 'preview_usage',
|
||||||
|
'login_codes', 'signatures', 'customer_discounts', 'customer_products'])
|
||||||
|
await patch(col, { hidden: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Anzeige-Vorlagen für Verknüpfungen ──────────────────────────────────────
|
||||||
|
// Ohne diese zeigt Directus bei m2o-Feldern nur die ID (z. B. Produkt „1").
|
||||||
|
// Mit „related-values" + Template erscheint der aussagekräftige Wert.
|
||||||
|
async function applyDisplays() {
|
||||||
|
console.log('› Anzeige-Vorlagen (m2o)');
|
||||||
|
const displays = [
|
||||||
|
['orders', 'product', '{{name}}'],
|
||||||
|
['orders', 'format', '{{name}}'],
|
||||||
|
['orders', 'motif', '{{name}}'],
|
||||||
|
['orders', 'customer', '{{first_name}} {{last_name}}'],
|
||||||
|
['orders', 'voucher', '{{code}}'],
|
||||||
|
['formats', 'product', '{{name}}'],
|
||||||
|
['jobs', 'order', '{{order_number}}'],
|
||||||
|
['jobs', 'machine', '{{name}}'],
|
||||||
|
['jobs', 'template', '{{name}}'],
|
||||||
|
['test_prints', 'template', '{{name}}'],
|
||||||
|
['test_prints', 'machine', '{{name}}'],
|
||||||
|
['status_history', 'order', '{{order_number}}'],
|
||||||
|
['order_entries', 'order', '{{order_number}}'],
|
||||||
|
['order_addons', 'order', '{{order_number}}'],
|
||||||
|
['order_addons', 'price_item', '{{label}}'],
|
||||||
|
['signatures', 'customer', '{{first_name}} {{last_name}}'],
|
||||||
|
['customer_discounts', 'customer', '{{first_name}} {{last_name}}'],
|
||||||
|
['customer_products', 'customer', '{{first_name}} {{last_name}}'],
|
||||||
|
['customer_products', 'product', '{{name}}'],
|
||||||
|
['offer_requests', 'customer', '{{first_name}} {{last_name}}'],
|
||||||
|
['voucher_redemptions', 'voucher', '{{code}}'],
|
||||||
|
];
|
||||||
|
for (const [collection, field, template] of displays) {
|
||||||
|
try {
|
||||||
|
await api('PATCH', `/fields/${collection}/${field}`, {
|
||||||
|
meta: { display: 'related-values', display_options: { template } },
|
||||||
|
});
|
||||||
|
} catch (e) { console.warn(` Display ${collection}.${field} übersprungen: ${e.message}`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Altlasten entfernen ─────────────────────────────────────────────────────
|
||||||
|
// Ersetzt durch: eine gemeinsame `multiplier_formula` + `products.norm_qty`.
|
||||||
|
async function removeDeprecatedFields() {
|
||||||
|
console.log('› Alte Felder entfernen');
|
||||||
|
const weg = [
|
||||||
|
['pricing_settings', 'multiplier_formula_business'],
|
||||||
|
['pricing_settings', 'multiplier_formula_privat'],
|
||||||
|
['pricing_settings', 'norm_qty_business'],
|
||||||
|
['pricing_settings', 'norm_qty_privat'],
|
||||||
|
['pricing_settings', 'min_qty_business'],
|
||||||
|
['pricing_settings', 'min_qty_privat'],
|
||||||
|
];
|
||||||
|
for (const [col, fld] of weg) {
|
||||||
|
try { await api('DELETE', `/fields/${col}/${fld}`); log('field -', `${col}.${fld}`); }
|
||||||
|
catch { /* bereits entfernt */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Projektname ─────────────────────────────────────────────────────────────
|
||||||
|
// Directus nutzt den Projektnamen als ABSENDERNAMEN aller Mails. Steht hier
|
||||||
|
// „Directus", kommen die Mails auch so beim Kunden an.
|
||||||
|
async function applyProjectName() {
|
||||||
|
console.log('› Projektname');
|
||||||
|
try {
|
||||||
|
await api('PATCH', '/settings', { project_name: 'Skrift' });
|
||||||
|
log('settings', 'project_name = Skrift');
|
||||||
|
} catch (e) { console.warn(` Projektname übersprungen: ${e.message}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auftrags-Übersicht (Produktion) ─────────────────────────────────────────
|
||||||
|
// Sammel-Anzeigevorlage + globaler Tabellen-Preset: auf einen Blick sieht man
|
||||||
|
// je Auftrag Nummer, Empfängeranzahl, Kuvert-Bedarf, Versandart und Bezahlt.
|
||||||
|
async function applyOrdersLayout() {
|
||||||
|
console.log('› Auftrags-Übersicht (Liste)');
|
||||||
|
try {
|
||||||
|
await api('PATCH', '/collections/orders', {
|
||||||
|
meta: { display_template: '{{order_number}} · {{entries_count}}× · {{shipping_type}} · {{payment_status}}' },
|
||||||
|
});
|
||||||
|
} catch (e) { console.warn(` Sammel-Template orders übersprungen: ${e.message}`); }
|
||||||
|
|
||||||
|
const spalten = ['order_number', 'customer', 'entries_count', 'needs_envelope',
|
||||||
|
'shipping_type', 'payment_status', 'production_status', 'date_created'];
|
||||||
|
try {
|
||||||
|
const vorhanden = await api('GET',
|
||||||
|
'/presets?filter[collection][_eq]=orders&filter[role][_null]=true&filter[user][_null]=true&filter[bucket][_null]=true&limit=1');
|
||||||
|
const preset = {
|
||||||
|
collection: 'orders', bucket: null, role: null, user: null,
|
||||||
|
layout: 'tabular',
|
||||||
|
layout_query: { tabular: { fields: spalten, sort: ['-date_created'], page: 1 } },
|
||||||
|
layout_options: { tabular: { widths: {} } },
|
||||||
|
};
|
||||||
|
if (vorhanden && vorhanden[0]) await api('PATCH', `/presets/${vorhanden[0].id}`, preset);
|
||||||
|
else { await api('POST', '/presets', preset); log('preset +', 'orders (Übersicht)'); }
|
||||||
|
} catch (e) { console.warn(` Listen-Preset orders übersprungen: ${e.message}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Insights-Dashboard „Übersicht" ──────────────────────────────────────────
|
||||||
|
// Zählt neue Aufträge, Anfragen und Musterbestellungen auf einen Blick.
|
||||||
|
async function applyDashboard() {
|
||||||
|
console.log('› Dashboard (Insights)');
|
||||||
|
try {
|
||||||
|
let dash = (await api('GET', `/dashboards?filter[name][_eq]=${encodeURIComponent('Übersicht')}&limit=1`))?.[0];
|
||||||
|
if (!dash) {
|
||||||
|
dash = await api('POST', '/dashboards', {
|
||||||
|
name: 'Übersicht', icon: 'dashboard', note: 'Neue Aufträge, Anfragen & Muster auf einen Blick.',
|
||||||
|
});
|
||||||
|
log('dashboard +', 'Übersicht');
|
||||||
|
}
|
||||||
|
const vorhanden = new Set(((await api('GET', `/panels?filter[dashboard][_eq]=${dash.id}&limit=-1&fields=name`)) || []).map((p) => p.name));
|
||||||
|
const metric = (name, icon, collection, filter, x) => ({
|
||||||
|
dashboard: dash.id, name, icon, type: 'metric', show_header: true,
|
||||||
|
position_x: x, position_y: 1, width: 6, height: 6,
|
||||||
|
options: { collection, field: 'id', function: 'count', filter },
|
||||||
|
});
|
||||||
|
const panels = [
|
||||||
|
metric('Neue Aufträge', 'receipt_long', 'orders', { production_status: { _eq: 'eingegangen' } }, 1),
|
||||||
|
metric('Neue Anfragen', 'request_quote', 'offer_requests', { _and: [{ type: { _eq: 'angebot' } }, { status: { _eq: 'neu' } }] }, 7),
|
||||||
|
metric('Neue Muster', 'science', 'offer_requests', { _and: [{ type: { _eq: 'muster' } }, { status: { _eq: 'neu' } }] }, 13),
|
||||||
|
];
|
||||||
|
for (const p of panels) {
|
||||||
|
if (vorhanden.has(p.name)) continue;
|
||||||
|
try { await api('POST', '/panels', p); log('panel +', p.name); }
|
||||||
|
catch (e) { console.warn(` Panel ${p.name} übersprungen: ${e.message}`); }
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn(' Dashboard übersprungen (bitte melden):', e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main ────────────────────────────────────────────────────────────────────
|
// ── Main ────────────────────────────────────────────────────────────────────
|
||||||
(async () => {
|
(async () => {
|
||||||
await login();
|
await login();
|
||||||
@@ -467,6 +1011,12 @@ async function applyLabels() {
|
|||||||
await applySchema();
|
await applySchema();
|
||||||
await applySeed();
|
await applySeed();
|
||||||
await applyLabels();
|
await applyLabels();
|
||||||
|
await organizeNav();
|
||||||
|
await applyDisplays();
|
||||||
|
await removeDeprecatedFields();
|
||||||
|
await applyProjectName();
|
||||||
|
await applyOrdersLayout();
|
||||||
|
await applyDashboard();
|
||||||
if (SKIP_PERMISSIONS) console.log('› Rechte übersprungen (SKIP_PERMISSIONS=1)');
|
if (SKIP_PERMISSIONS) console.log('› Rechte übersprungen (SKIP_PERMISSIONS=1)');
|
||||||
else await applyPermissions();
|
else await applyPermissions();
|
||||||
console.log('✓ Fertig.');
|
console.log('✓ Fertig.');
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ services:
|
|||||||
- directus-uploads:/directus/uploads
|
- directus-uploads:/directus/uploads
|
||||||
# Bind-Mount: Extensions kommen aus dem Repo-Ordner ./extensions
|
# Bind-Mount: Extensions kommen aus dem Repo-Ordner ./extensions
|
||||||
- ./extensions:/directus/extensions
|
- ./extensions:/directus/extensions
|
||||||
|
# Generierte Auftragsdateien read-only einblenden (gleicher Ordner wie
|
||||||
|
# das Backend beschreibt / SFTP-Ziel) – für Direktzugriff statt Kopie.
|
||||||
|
- /var/skrift-output:/var/skrift-output:ro
|
||||||
environment:
|
environment:
|
||||||
SECRET: ${DIRECTUS_SECRET}
|
SECRET: ${DIRECTUS_SECRET}
|
||||||
|
|
||||||
@@ -68,6 +71,19 @@ services:
|
|||||||
EMAIL_SMTP_USER: ${EMAIL_SMTP_USER}
|
EMAIL_SMTP_USER: ${EMAIL_SMTP_USER}
|
||||||
EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD}
|
EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD}
|
||||||
EMAIL_SMTP_SECURE: ${EMAIL_SMTP_SECURE}
|
EMAIL_SMTP_SECURE: ${EMAIL_SMTP_SECURE}
|
||||||
|
|
||||||
|
# Skrift-Backend (SVG-Generierung: Vorschau + finaler Auftrag).
|
||||||
|
# SKRIFT_BACKEND_TOKEN MUSS dem API_TOKEN des Backends entsprechen,
|
||||||
|
# sonst antwortet das Backend mit 401.
|
||||||
|
SKRIFT_BACKEND_URL: ${SKRIFT_BACKEND_URL:-http://skrift-backend:4000}
|
||||||
|
SKRIFT_BACKEND_TOKEN: ${SKRIFT_BACKEND_TOKEN}
|
||||||
|
SKRIFT_PREVIEW_LIMIT: ${SKRIFT_PREVIEW_LIMIT:-10}
|
||||||
|
SKRIFT_TEAM_EMAIL: ${SKRIFT_TEAM_EMAIL:-hello@skrift.de}
|
||||||
|
|
||||||
|
# PayPal – server-seitige Zahlungsprüfung (Secret liegt NUR hier, nie im Frontend)
|
||||||
|
PAYPAL_CLIENT_ID: ${PAYPAL_CLIENT_ID}
|
||||||
|
PAYPAL_CLIENT_SECRET: ${PAYPAL_CLIENT_SECRET}
|
||||||
|
PAYPAL_ENV: ${PAYPAL_ENV:-sandbox}
|
||||||
expose:
|
expose:
|
||||||
- "8055"
|
- "8055"
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import crypto from 'node:crypto';
|
|||||||
const CODE_TTL_MIN = 10;
|
const CODE_TTL_MIN = 10;
|
||||||
const MAX_ATTEMPTS = 5;
|
const MAX_ATTEMPTS = 5;
|
||||||
const RESEND_COOLDOWN_SEC = 60;
|
const RESEND_COOLDOWN_SEC = 60;
|
||||||
const CUSTOMER_ROLE_NAME = 'Kunde (Login)';
|
const CUSTOMER_ROLE_NAME = 'Kunde';
|
||||||
|
|
||||||
const handler = (router, { services, getSchema, env, logger }) => {
|
const handler = (router, { services, getSchema, env, logger }) => {
|
||||||
const { ItemsService, MailService, UsersService, AuthenticationService, RolesService } = services;
|
const { ItemsService, MailService, UsersService, AuthenticationService, RolesService } = services;
|
||||||
@@ -35,6 +35,12 @@ const handler = (router, { services, getSchema, env, logger }) => {
|
|||||||
crypto.createHash('sha256').update(`${code}:${email}:${env.SECRET}`).digest('hex');
|
crypto.createHash('sha256').update(`${code}:${email}:${env.SECRET}`).digest('hex');
|
||||||
const genCode = () => String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
|
const genCode = () => String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
|
||||||
|
|
||||||
|
// Temporäres Passwort, das jede übliche Passwort-Richtlinie erfüllt
|
||||||
|
// (Groß-, Kleinbuchstabe, Ziffer, Sonderzeichen). Wird nie angezeigt und
|
||||||
|
// rotiert bei jedem Login.
|
||||||
|
const tempPasswort = () =>
|
||||||
|
`Aa1!${crypto.randomBytes(24).toString('base64url').replace(/[^A-Za-z0-9]/g, '')}`;
|
||||||
|
|
||||||
const ctx = async () => ({ schema: await getSchema(), accountability: null });
|
const ctx = async () => ({ schema: await getSchema(), accountability: null });
|
||||||
|
|
||||||
async function findUserByEmail(email, schema) {
|
async function findUserByEmail(email, schema) {
|
||||||
@@ -48,18 +54,41 @@ const handler = (router, { services, getSchema, env, logger }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendCodeMail(email, code) {
|
async function sendCodeMail(email, code) {
|
||||||
const mail = new MailService({ schema: await getSchema(), accountability: null });
|
const schema = await getSchema();
|
||||||
await mail.send({
|
|
||||||
to: email,
|
// Standardinhalt (Fallback, falls keine Vorlage hinterlegt ist).
|
||||||
subject: `Ihr Anmeldecode: ${code}`,
|
let subject = `Ihr Anmeldecode: ${code}`;
|
||||||
html: `
|
let body =
|
||||||
<p>Guten Tag,</p>
|
`<p>Guten Tag,</p><p>Ihr Anmeldecode für Skrift lautet:</p>` +
|
||||||
<p>Ihr Anmeldecode für Skrift lautet:</p>
|
`<p style="font-size:28px;font-weight:700;letter-spacing:4px;margin:16px 0;">${code}</p>` +
|
||||||
<p style="font-size:28px;font-weight:700;letter-spacing:4px;margin:16px 0;">${code}</p>
|
`<p>Der Code ist ${CODE_TTL_MIN} Minuten gültig. Falls Sie keine Anmeldung ` +
|
||||||
<p>Der Code ist ${CODE_TTL_MIN} Minuten gültig. Falls Sie keine Anmeldung
|
`angefordert haben, können Sie diese E-Mail ignorieren.</p>`;
|
||||||
angefordert haben, können Sie diese E-Mail ignorieren.</p>
|
let signature = '';
|
||||||
`,
|
let absender = '';
|
||||||
|
|
||||||
|
// Editierbare Vorlage „login_code" + gemeinsame Signatur laden (falls vorhanden).
|
||||||
|
try {
|
||||||
|
const rows = await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({
|
||||||
|
filter: { key: { _eq: 'login_code' }, enabled: { _eq: true } }, limit: 1, fields: ['subject', 'body'],
|
||||||
});
|
});
|
||||||
|
const tpl = rows?.[0];
|
||||||
|
const fuelle = (s) => String(s || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (k === 'code' ? code : ''));
|
||||||
|
if (tpl) {
|
||||||
|
if (tpl.subject) subject = fuelle(tpl.subject);
|
||||||
|
if (tpl.body) body = fuelle(tpl.body);
|
||||||
|
}
|
||||||
|
const es = await new ItemsService('email_settings', { schema, accountability: null })
|
||||||
|
.readSingleton({ fields: ['signature', 'sender_name', 'sender_email'] });
|
||||||
|
if (es?.signature) signature = es.signature;
|
||||||
|
if (es?.sender_email) {
|
||||||
|
absender = es.sender_name ? `${es.sender_name} <${es.sender_email}>` : es.sender_email;
|
||||||
|
}
|
||||||
|
} catch { /* Vorlage optional – Fallback oben greift */ }
|
||||||
|
|
||||||
|
const mail = new MailService({ schema, accountability: null });
|
||||||
|
const nachricht = { to: email, subject, html: body + signature };
|
||||||
|
if (absender) nachricht.from = absender;
|
||||||
|
await mail.send(nachricht);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Code anfordern ────────────────────────────────────────────────────────
|
// ── Code anfordern ────────────────────────────────────────────────────────
|
||||||
@@ -146,28 +175,38 @@ const handler = (router, { services, getSchema, env, logger }) => {
|
|||||||
|
|
||||||
// Offizieller Login-Pfad mit rotierendem Zufallspasswort (s. Kopfkommentar).
|
// Offizieller Login-Pfad mit rotierendem Zufallspasswort (s. Kopfkommentar).
|
||||||
const users = new UsersService({ schema, accountability: null });
|
const users = new UsersService({ schema, accountability: null });
|
||||||
const tempPassword = crypto.randomBytes(32).toString('base64url');
|
const tempPassword = tempPasswort();
|
||||||
await users.updateOne(user.id, { password: tempPassword });
|
await users.updateOne(user.id, { password: tempPassword });
|
||||||
|
|
||||||
|
// Die Signatur von AuthenticationService.login variiert zwischen
|
||||||
|
// Directus-Versionen. Der Aufruf mit ('default', payload) hat bei dir per
|
||||||
|
// curl funktioniert – daher zuerst diese Variante, dann Fallbacks.
|
||||||
const auth = new AuthenticationService({ schema, accountability: null });
|
const auth = new AuthenticationService({ schema, accountability: null });
|
||||||
const result = await auth.login('default', { email, password: tempPassword });
|
const versuche = [
|
||||||
|
() => auth.login('default', { email, password: tempPassword }),
|
||||||
// Kundendatensatz mit dem Login-Konto verknüpfen, falls noch offen.
|
() => auth.login({ email, password: tempPassword }),
|
||||||
const customers = new ItemsService('customers', { schema, accountability: null });
|
() => auth.login({ email, password: tempPassword }, {}),
|
||||||
const cust = await customers.readByQuery({
|
];
|
||||||
filter: { email: { _eq: email } }, limit: 1, fields: ['id', 'linked_user'],
|
let result, letzterFehler;
|
||||||
});
|
for (const versuch of versuche) {
|
||||||
if (cust?.[0] && !cust[0].linked_user) {
|
try { result = await versuch(); break; }
|
||||||
await customers.updateOne(cust[0].id, { linked_user: user.id });
|
catch (e) { letzterFehler = e; }
|
||||||
|
}
|
||||||
|
if (!result) {
|
||||||
|
logger.error(`[skrift-auth] login fehlgeschlagen: ${letzterFehler?.message}`);
|
||||||
|
const e = new Error(`Login-Service: ${letzterFehler?.message || 'unbekannt'}`);
|
||||||
|
e.detail = letzterFehler?.message;
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kunde = Login-Konto selbst – keine separate Verknüpfung mehr nötig.
|
||||||
return res.json({
|
return res.json({
|
||||||
access_token: result.accessToken ?? result.access_token,
|
access_token: result.accessToken ?? result.access_token,
|
||||||
refresh_token: result.refreshToken ?? result.refresh_token,
|
refresh_token: result.refreshToken ?? result.refresh_token,
|
||||||
expires: result.expires,
|
expires: result.expires,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`[skrift-auth] verify-code: ${err.message}`);
|
logger.error(`[skrift-auth] verify-code: ${err.stack || err.message}`);
|
||||||
return res.status(500).json({ error: 'Anmeldung fehlgeschlagen.' });
|
return res.status(500).json({ error: 'Anmeldung fehlgeschlagen.' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -193,28 +232,18 @@ const handler = (router, { services, getSchema, env, logger }) => {
|
|||||||
return res.status(500).json({ error: 'Konto konnte nicht angelegt werden.' });
|
return res.status(500).json({ error: 'Konto konnte nicht angelegt werden.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kunde = Login-Konto: Profil hängt direkt am Nutzer.
|
||||||
const users = new UsersService({ schema, accountability: null });
|
const users = new UsersService({ schema, accountability: null });
|
||||||
const userId = await users.createOne({
|
await users.createOne({
|
||||||
email,
|
email,
|
||||||
password: crypto.randomBytes(32).toString('base64url'),
|
password: crypto.randomBytes(32).toString('base64url'),
|
||||||
role: role.id,
|
role: role.id,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
first_name: req.body?.first_name ?? null,
|
first_name: req.body?.first_name ?? null,
|
||||||
last_name: req.body?.last_name ?? null,
|
last_name: req.body?.last_name ?? null,
|
||||||
});
|
|
||||||
|
|
||||||
// Kundendatensatz anlegen oder verknüpfen (Zuordnung immer per E-Mail).
|
|
||||||
const customers = new ItemsService('customers', { schema, accountability: null });
|
|
||||||
const cust = (await customers.readByQuery({
|
|
||||||
filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
|
|
||||||
}))?.[0];
|
|
||||||
if (cust) await customers.updateOne(cust.id, { linked_user: userId });
|
|
||||||
else await customers.createOne({
|
|
||||||
email, linked_user: userId,
|
|
||||||
first_name: req.body?.first_name ?? null,
|
|
||||||
last_name: req.body?.last_name ?? null,
|
|
||||||
person_type: req.body?.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
|
|
||||||
company: req.body?.company ?? null,
|
company: req.body?.company ?? null,
|
||||||
|
phone: req.body?.phone ?? null,
|
||||||
|
person_type: req.body?.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({ ok: true, existed: false });
|
return res.json({ ok: true, existed: false });
|
||||||
|
|||||||
158
Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js
vendored
Normal file
158
Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js
vendored
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Skrift – Mail-Hook
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Versendet Kunden-E-Mails auf Basis editierbarer Vorlagen (`email_templates`)
|
||||||
|
* mit gemeinsamer Signatur/Footer (`email_settings`) und optionalem AGB-Anhang.
|
||||||
|
*
|
||||||
|
* orders.items.create → Vorlage „order_created" (reiche Bestellbestätigung)
|
||||||
|
* orders.items.update → Vorlage mit dem Schlüssel des neuen Status
|
||||||
|
* (z. B. „bezahlt", „versendet"), falls vorhanden+aktiv
|
||||||
|
*
|
||||||
|
* Der Login-Code wird bewusst in skrift-auth versendet (dort entsteht er).
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const STATUS_LABEL = {
|
||||||
|
wartet_auf_zahlung: 'Wartet auf Zahlung', bezahlt: 'Bezahlt',
|
||||||
|
material_erwartet: 'Material erwartet', material_eingegangen: 'Material eingegangen',
|
||||||
|
in_queue: 'In Warteschlange', in_produktion: 'In Produktion', gedruckt: 'Gedruckt',
|
||||||
|
versendet: 'Versendet', abgeschlossen: 'Abgeschlossen', storniert: 'Storniert',
|
||||||
|
};
|
||||||
|
const ENVELOPE_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
|
||||||
|
const LABELING_LABEL = { empfaenger: 'Empfängeradresse', freitext: 'Individueller Text', keine: 'Keine' };
|
||||||
|
const UPLOAD_ROOT = process.env.STORAGE_LOCAL_ROOT || '/directus/uploads';
|
||||||
|
|
||||||
|
const render = (str, vars) =>
|
||||||
|
String(str || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : ''));
|
||||||
|
const eur = (n) => (Number(n) || 0).toFixed(2).replace('.', ',');
|
||||||
|
const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
||||||
|
|
||||||
|
/** Formatiert einen Adress-Snapshot (billing/shipping) als HTML. */
|
||||||
|
function adresseHtml(a) {
|
||||||
|
if (!a || typeof a !== 'object') return '';
|
||||||
|
const zeilen = [];
|
||||||
|
if (a.company) zeilen.push(`<strong>${esc(a.company)}</strong>`);
|
||||||
|
zeilen.push(esc([a.first_name, a.last_name].filter(Boolean).join(' ')));
|
||||||
|
if (a.street) zeilen.push(esc(a.street));
|
||||||
|
zeilen.push(esc([a.zip, a.city].filter(Boolean).join(' ')));
|
||||||
|
if (a.country) zeilen.push(esc(a.country));
|
||||||
|
let html = zeilen.filter(Boolean).join('<br>');
|
||||||
|
if (a.email) html += `<br><br>E-Mail: ${esc(a.email)}`;
|
||||||
|
if (a.phone) html += `<br>Telefon: ${esc(a.phone)}`;
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (register, { services, getSchema, logger }) => {
|
||||||
|
const { ItemsService, MailService } = services;
|
||||||
|
|
||||||
|
async function loadTemplate(key, schema) {
|
||||||
|
const rows = await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({
|
||||||
|
filter: { key: { _eq: key }, enabled: { _eq: true } },
|
||||||
|
limit: 1, fields: ['subject', 'body', 'append_signature', 'attach_agb'],
|
||||||
|
});
|
||||||
|
return rows?.[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings(schema) {
|
||||||
|
try {
|
||||||
|
return await new ItemsService('email_settings', { schema, accountability: null })
|
||||||
|
.readSingleton({ fields: ['signature', 'portal_url', 'company_footer', 'agb_file', 'sender_name', 'sender_email'] });
|
||||||
|
} catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AGB-PDF (aus email_settings.agb_file) als Nodemailer-Anhang, direkt von Platte. */
|
||||||
|
async function agbAnhang(schema, settings) {
|
||||||
|
if (!settings?.agb_file) return null;
|
||||||
|
try {
|
||||||
|
const file = await new ItemsService('directus_files', { schema, accountability: null })
|
||||||
|
.readOne(settings.agb_file, { fields: ['filename_disk', 'filename_download', 'type'] });
|
||||||
|
if (!file?.filename_disk) return null;
|
||||||
|
const buf = fs.readFileSync(path.join(UPLOAD_ROOT, file.filename_disk));
|
||||||
|
return { filename: file.filename_download || 'AGB.pdf', content: buf, contentType: file.type || 'application/pdf' };
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`[skrift-mailer] AGB-Anhang übersprungen: ${e.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendForOrder(orderId, key, extraVars = {}) {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const tpl = await loadTemplate(key, schema);
|
||||||
|
if (!tpl) return; // keine (aktive) Vorlage → keine Mail
|
||||||
|
|
||||||
|
const order = await new ItemsService('orders', { schema, accountability: null }).readOne(orderId, {
|
||||||
|
fields: [
|
||||||
|
'order_number', 'customer_email', 'entries_count', 'shipping_type',
|
||||||
|
'needs_envelope', 'envelope_labeling', 'envelope_format',
|
||||||
|
'net_total', 'vat_amount', 'gross_total', 'billing', 'shipping_address',
|
||||||
|
'customer.first_name', 'customer.last_name', 'customer.company',
|
||||||
|
'product.name', 'format.name',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (!order?.customer_email) return;
|
||||||
|
|
||||||
|
const settings = await loadSettings(schema);
|
||||||
|
const name = [order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(' ')
|
||||||
|
|| order.customer?.company || '';
|
||||||
|
|
||||||
|
const net = Number(order.net_total) || 0;
|
||||||
|
const vat = Number(order.vat_amount) || 0;
|
||||||
|
const mwstSatz = net > 0 ? Math.round((vat / net) * 100) : 19;
|
||||||
|
|
||||||
|
const kuvertBlock = (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine')
|
||||||
|
? `<table role="presentation" width="100%" style="background:#fafafa;border-radius:6px;margin-top:12px;"><tr><td style="padding:12px 15px;">
|
||||||
|
<p style="margin:0 0 6px;font-weight:600;color:#1a365d;">Umschlag</p>
|
||||||
|
<p style="margin:0;font-size:14px;color:#666;">Format: ${ENVELOPE_LABEL[order.envelope_format] || '—'} · Beschriftung: ${LABELING_LABEL[order.envelope_labeling] || '—'}</p>
|
||||||
|
</td></tr></table>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const liefer = adresseHtml(order.shipping_address);
|
||||||
|
const lieferBlock = liefer
|
||||||
|
? `<tr><td style="padding:16px 40px 4px;"><h2 style="margin:0 0 12px;font-size:18px;color:#1a365d;">Lieferadresse</h2><p style="margin:0;line-height:1.6;">${liefer}</p></td></tr>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const vars = {
|
||||||
|
order_id: order.order_number, name, link: settings.portal_url || '',
|
||||||
|
product: esc(order.product?.name || ''), menge: order.entries_count ?? '',
|
||||||
|
format: esc(order.format?.name || ''),
|
||||||
|
versand: order.shipping_type === 'einzeln' ? 'Einzelversand durch Skrift' : 'Sammellieferung',
|
||||||
|
kuvert_block: kuvertBlock,
|
||||||
|
netto: eur(net), mwst_satz: mwstSatz, mwst_betrag: eur(vat), brutto: eur(order.gross_total), waehrung: 'EUR',
|
||||||
|
rechnungsadresse: adresseHtml(order.billing),
|
||||||
|
lieferadresse_block: lieferBlock,
|
||||||
|
footer: settings.company_footer || '',
|
||||||
|
...extraVars,
|
||||||
|
};
|
||||||
|
|
||||||
|
let html = render(tpl.body, vars);
|
||||||
|
if (tpl.append_signature !== false && settings.signature) html += render(settings.signature, vars);
|
||||||
|
|
||||||
|
const nachricht = { to: order.customer_email, subject: render(tpl.subject, vars), html };
|
||||||
|
// Absender aus den Einstellungen (z. B. „Skrift <noreply@skrift.de>").
|
||||||
|
if (settings.sender_email) {
|
||||||
|
nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
|
||||||
|
}
|
||||||
|
if (tpl.attach_agb) {
|
||||||
|
const anhang = await agbAnhang(schema, settings);
|
||||||
|
if (anhang) nachricht.attachments = [anhang];
|
||||||
|
}
|
||||||
|
await new MailService({ schema, accountability: null }).send(nachricht);
|
||||||
|
logger.info(`[skrift-mailer] „${key}" an ${order.customer_email} (${order.order_number}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
register.action('orders.items.create', async ({ key, payload }) => {
|
||||||
|
if (payload?.status === 'entwurf') return;
|
||||||
|
try { await sendForOrder(key, 'order_created'); }
|
||||||
|
catch (e) { logger.warn(`[skrift-mailer] order_created: ${e.message}`); }
|
||||||
|
});
|
||||||
|
|
||||||
|
register.action('orders.items.update', async ({ keys, payload }) => {
|
||||||
|
if (!payload || payload.status === undefined) return;
|
||||||
|
const status = payload.status;
|
||||||
|
for (const id of keys || []) {
|
||||||
|
try { await sendForOrder(id, status, { status: STATUS_LABEL[status] || status }); }
|
||||||
|
catch (e) { logger.warn(`[skrift-mailer] status ${status}: ${e.message}`); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "directus-extension-skrift-mailer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Versendet E-Mails (Bestellbestätigung + Statusänderungen) anhand editierbarer Vorlagen (email_templates + email_settings).",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "hook",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "dist/index.js",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
149
Docker/directus/extensions/directus-extension-skrift-orderentries/dist/index.js
vendored
Normal file
149
Docker/directus/extensions/directus-extension-skrift-orderentries/dist/index.js
vendored
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* Skrift – Interface „Empfänger (durchsuchbar)"
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Zeigt im Auftrag alle Empfängerzeilen als kompakte, durchsuchbare Tabelle –
|
||||||
|
* die Produktionsansicht aus dem alten Job-Manager: pro Zeile die Briefnummer
|
||||||
|
* und die VOLLE Anschrift bzw. der Freitext (nicht nur der Name). Das Suchfeld
|
||||||
|
* filtert über die ganze Zeile (Name, Straße, Ort, Land, Platzhalter …).
|
||||||
|
*
|
||||||
|
* Rein lesend/suchend – bearbeitet wird eine Zeile weiterhin über das darunter
|
||||||
|
* liegende o2m-Feld „Empfänger (bearbeiten)".
|
||||||
|
*
|
||||||
|
* Bewusst von Hand als ESM geschrieben (kein Build-Schritt): Vue und der
|
||||||
|
* Extensions-SDK werden von Directus zur Laufzeit bereitgestellt.
|
||||||
|
*/
|
||||||
|
import { defineComponent, ref, computed, inject, watch, onMounted, h } from 'vue';
|
||||||
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
|
|
||||||
|
const INLAND = /^(de|deutschland|germany)$/i;
|
||||||
|
|
||||||
|
/** Baut die volle Anzeigezeile einer Empfängerzeile. */
|
||||||
|
function volleZeile(e) {
|
||||||
|
if (e.free_text && String(e.free_text).trim()) {
|
||||||
|
return String(e.free_text).trim().split(/\r?\n/).map((s) => s.trim()).filter(Boolean).join(' · ');
|
||||||
|
}
|
||||||
|
const name = [e.salutation, e.first_name, e.last_name].filter(Boolean).join(' ');
|
||||||
|
const strasse = [e.street, e.house_no].filter(Boolean).join(' ');
|
||||||
|
const ort = [e.zip, e.city].filter(Boolean).join(' ');
|
||||||
|
const land = e.country && !INLAND.test(String(e.country).trim()) ? e.country : null;
|
||||||
|
return [name, strasse, ort, land].filter(Boolean).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zusätzliche freie Platzhalter als „Key: Wert"-Liste. */
|
||||||
|
function extras(e) {
|
||||||
|
if (!e.placeholders || typeof e.placeholders !== 'object') return '';
|
||||||
|
return Object.entries(e.placeholders)
|
||||||
|
.filter(([, v]) => v !== null && v !== undefined && String(v).trim() !== '')
|
||||||
|
.map(([k, v]) => `${k}: ${v}`).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmpfaengerSuche = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const api = useApi();
|
||||||
|
const values = inject('values', ref({}));
|
||||||
|
const rows = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const geladen = ref(false);
|
||||||
|
const suche = ref('');
|
||||||
|
|
||||||
|
const orderId = () => (values && values.value ? values.value.id : null);
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
const id = orderId();
|
||||||
|
if (!id) { rows.value = []; geladen.value = true; return; }
|
||||||
|
loading.value = true; error.value = '';
|
||||||
|
try {
|
||||||
|
const res = await api.get('/items/order_entries', {
|
||||||
|
params: {
|
||||||
|
'filter[order][_eq]': id,
|
||||||
|
sort: 'letter_number',
|
||||||
|
limit: -1,
|
||||||
|
fields: '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const data = (res.data && res.data.data) || [];
|
||||||
|
rows.value = data.map((e) => ({
|
||||||
|
nr: e.letter_number,
|
||||||
|
zeile: volleZeile(e),
|
||||||
|
extra: extras(e),
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e && e.response && e.response.data && e.response.data.errors &&
|
||||||
|
e.response.data.errors[0] && e.response.data.errors[0].message) ||
|
||||||
|
(e && e.message) || 'Empfänger konnten nicht geladen werden.';
|
||||||
|
} finally {
|
||||||
|
loading.value = false; geladen.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(laden);
|
||||||
|
watch(orderId, laden);
|
||||||
|
|
||||||
|
const treffer = computed(() => {
|
||||||
|
const q = suche.value.trim().toLowerCase();
|
||||||
|
if (!q) return rows.value;
|
||||||
|
return rows.value.filter((r) =>
|
||||||
|
(`#${r.nr} ${r.zeile} ${r.extra}`).toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
const hinweis = (txt) => h('div', { style: 'color:var(--foreground-subdued);font-size:14px;padding:8px 0;' }, txt);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (loading.value) return hinweis('Empfänger werden geladen …');
|
||||||
|
if (error.value) return hinweis(error.value);
|
||||||
|
if (!geladen.value) return hinweis('');
|
||||||
|
if (!rows.value.length) return hinweis('Noch keine Empfängerzeilen.');
|
||||||
|
|
||||||
|
const suchfeld = h('div', { style: 'display:flex;align-items:center;gap:10px;margin-bottom:10px;' }, [
|
||||||
|
h('input', {
|
||||||
|
type: 'search',
|
||||||
|
value: suche.value,
|
||||||
|
placeholder: 'Empfänger durchsuchen (Name, Straße, Ort, Platzhalter …)',
|
||||||
|
onInput: (ev) => { suche.value = ev.target.value; },
|
||||||
|
style: 'flex:1;padding:8px 12px;border:1px solid var(--border-normal);border-radius:6px;' +
|
||||||
|
'background:var(--background-page);color:var(--foreground-normal);font:inherit;font-size:14px;',
|
||||||
|
}),
|
||||||
|
h('span', { style: 'font-size:12px;color:var(--foreground-subdued);white-space:nowrap;' },
|
||||||
|
`${treffer.value.length} von ${rows.value.length}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const zeilen = treffer.value.map((r) =>
|
||||||
|
h('div', {
|
||||||
|
key: r.nr,
|
||||||
|
style: 'display:flex;align-items:baseline;gap:12px;padding:7px 12px;' +
|
||||||
|
'border:1px solid var(--border-subdued);border-radius:6px;',
|
||||||
|
}, [
|
||||||
|
h('span', {
|
||||||
|
style: 'min-width:44px;font-family:var(--family-monospace, monospace);font-size:13px;' +
|
||||||
|
'font-weight:600;color:var(--primary);',
|
||||||
|
}, `#${r.nr}`),
|
||||||
|
h('div', { style: 'flex:1;font-size:14px;line-height:1.4;word-break:break-word;' }, [
|
||||||
|
h('span', {}, r.zeile || '—'),
|
||||||
|
r.extra
|
||||||
|
? h('span', { style: 'display:block;font-size:12px;color:var(--foreground-subdued);margin-top:2px;' }, r.extra)
|
||||||
|
: null,
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = treffer.value.length
|
||||||
|
? h('div', { style: 'display:flex;flex-direction:column;gap:5px;' }, zeilen)
|
||||||
|
: hinweis('Keine Treffer.');
|
||||||
|
|
||||||
|
return h('div', {}, [suchfeld, body]);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: 'skrift-order-entries',
|
||||||
|
name: 'Empfänger (durchsuchbar)',
|
||||||
|
icon: 'search',
|
||||||
|
description: 'Durchsuchbare Empfängertabelle: Briefnummer + volle Anschrift (Produktionsansicht).',
|
||||||
|
component: EmpfaengerSuche,
|
||||||
|
types: ['alias'],
|
||||||
|
localTypes: ['presentation'],
|
||||||
|
group: 'presentation',
|
||||||
|
options: null,
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "directus-extension-skrift-orderentries",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Interface: durchsuchbare Empfängertabelle eines Auftrags (Briefnummer + volle Anschrift) – Produktionsansicht wie im alten Job-Manager.",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "interface",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "dist/index.js",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
105
Docker/directus/extensions/directus-extension-skrift-orderfiles/dist/index.js
vendored
Normal file
105
Docker/directus/extensions/directus-extension-skrift-orderfiles/dist/index.js
vendored
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Skrift – Interface „Auftragsdateien"
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Zeigt im Auftrag die generierten Dateien (Schriftstücke, Umschläge,
|
||||||
|
* Platzhalter-CSV) direkt aus dem gemounteten Ausgabe-Ordner an – ohne Kopie
|
||||||
|
* in den Directus-Speicher. Die Liste und der Download laufen über den
|
||||||
|
* Endpoint /skrift-orders/files/* und die angemeldete Directus-Session
|
||||||
|
* (kein Token in der URL nötig).
|
||||||
|
*
|
||||||
|
* Bewusst von Hand als ESM geschrieben (kein Build-Schritt): Vue und der
|
||||||
|
* Extensions-SDK werden von Directus zur Laufzeit bereitgestellt.
|
||||||
|
*/
|
||||||
|
import { defineComponent, ref, inject, watch, onMounted, h } from 'vue';
|
||||||
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
|
|
||||||
|
const KIND_LABEL = {
|
||||||
|
schriftstueck: 'Schriftstück',
|
||||||
|
umschlag: 'Umschlag',
|
||||||
|
platzhalter: 'Platzhalter-CSV',
|
||||||
|
sonstiges: 'Datei',
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuftragsDateien = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const api = useApi();
|
||||||
|
const values = inject('values', ref({}));
|
||||||
|
const files = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const geladen = ref(false);
|
||||||
|
|
||||||
|
function nummer() {
|
||||||
|
return values && values.value ? values.value.order_number : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
const nr = nummer();
|
||||||
|
if (!nr) { files.value = []; geladen.value = true; return; }
|
||||||
|
loading.value = true; error.value = '';
|
||||||
|
try {
|
||||||
|
const res = await api.get('/skrift-orders/files/' + encodeURIComponent(nr));
|
||||||
|
files.value = (res.data && res.data.files) || [];
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e && e.response && e.response.data && e.response.data.error) || (e && e.message) || 'Fehler beim Laden.';
|
||||||
|
} finally {
|
||||||
|
loading.value = false; geladen.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download(f) {
|
||||||
|
const nr = nummer();
|
||||||
|
if (!nr) return;
|
||||||
|
try {
|
||||||
|
const res = await api.get('/skrift-orders/files/' + encodeURIComponent(nr) + '/download', {
|
||||||
|
params: { rel: f.rel }, responseType: 'blob',
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(res.data);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = f.name;
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
error.value = 'Download fehlgeschlagen.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(laden);
|
||||||
|
watch(nummer, laden);
|
||||||
|
|
||||||
|
const hinweis = (txt) => h('div', { style: 'color:var(--foreground-subdued);font-size:14px;padding:8px 0;' }, txt);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (loading.value) return hinweis('Dateien werden geladen …');
|
||||||
|
if (error.value) return hinweis(error.value);
|
||||||
|
if (!files.value.length) return hinweis(geladen.value ? 'Noch keine generierten Dateien.' : '');
|
||||||
|
return h('div', { style: 'display:flex;flex-direction:column;gap:6px;' }, files.value.map((f) =>
|
||||||
|
h('div', {
|
||||||
|
key: f.rel,
|
||||||
|
style: 'display:flex;align-items:center;gap:12px;padding:8px 12px;border:1px solid var(--border-normal);border-radius:6px;',
|
||||||
|
}, [
|
||||||
|
h('span', { style: 'font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);min-width:110px;' },
|
||||||
|
KIND_LABEL[f.kind] || 'Datei'),
|
||||||
|
h('span', { style: 'flex:1;font-family:var(--family-monospace, monospace);font-size:13px;word-break:break-all;' }, f.name),
|
||||||
|
h('button', {
|
||||||
|
type: 'button',
|
||||||
|
onClick: () => download(f),
|
||||||
|
style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-subdued);border-radius:6px;padding:4px 12px;font:inherit;font-size:13px;',
|
||||||
|
}, 'Herunterladen'),
|
||||||
|
]),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: 'skrift-order-files',
|
||||||
|
name: 'Auftragsdateien',
|
||||||
|
icon: 'folder_open',
|
||||||
|
description: 'Zeigt die generierten Dateien des Auftrags (Direktzugriff auf den Server-Ordner).',
|
||||||
|
component: AuftragsDateien,
|
||||||
|
types: ['alias'],
|
||||||
|
localTypes: ['presentation'],
|
||||||
|
group: 'presentation',
|
||||||
|
options: null,
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "directus-extension-skrift-orderfiles",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Interface: zeigt die generierten Auftragsdateien per Direktzugriff auf den Ausgabe-Ordner (kein Download in Directus, keine Kopie).",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "interface",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "dist/index.js",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
83
Docker/directus/extensions/directus-extension-skrift-printbutton/dist/index.js
vendored
Normal file
83
Docker/directus/extensions/directus-extension-skrift-printbutton/dist/index.js
vendored
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Skrift – Interface „Drucken"
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Zeigt im Auftrag eine Maschinen-Auswahl + „Drucken"-Button. Der Button reiht
|
||||||
|
* den Auftrag über POST /skrift-orders/enqueue als Druck-Job(s) ein (Brief +
|
||||||
|
* ggf. Umschlag, mit aufgelöstem Template). Läuft über die angemeldete Session.
|
||||||
|
*
|
||||||
|
* Bewusst von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
|
||||||
|
*/
|
||||||
|
import { defineComponent, ref, inject, onMounted, h } from 'vue';
|
||||||
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
|
|
||||||
|
const PrintButton = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const api = useApi();
|
||||||
|
const values = inject('values', ref({}));
|
||||||
|
const machines = ref([]);
|
||||||
|
const selected = ref(null);
|
||||||
|
const busy = ref(false);
|
||||||
|
const msg = ref('');
|
||||||
|
const err = ref('');
|
||||||
|
|
||||||
|
async function ladeMaschinen() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/items/machines', { params: { fields: 'id,name,active', limit: -1 } });
|
||||||
|
machines.value = (res.data?.data || []).filter((m) => m.active !== false);
|
||||||
|
if (machines.value.length && selected.value == null) selected.value = machines.value[0].id;
|
||||||
|
} catch {
|
||||||
|
err.value = 'Maschinen konnten nicht geladen werden.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(ladeMaschinen);
|
||||||
|
|
||||||
|
async function drucken() {
|
||||||
|
const orderId = values && values.value ? values.value.id : null;
|
||||||
|
if (!orderId) { err.value = 'Auftrag zuerst speichern.'; return; }
|
||||||
|
if (selected.value == null) { err.value = 'Bitte eine Maschine wählen.'; return; }
|
||||||
|
busy.value = true; err.value = ''; msg.value = '';
|
||||||
|
try {
|
||||||
|
const res = await api.post('/skrift-orders/enqueue', { order: orderId, machine: selected.value });
|
||||||
|
const n = (res.data?.jobs || []).length;
|
||||||
|
msg.value = `${n} Druck-Job(s) eingereiht.`;
|
||||||
|
} catch (e) {
|
||||||
|
err.value = (e && e.response && e.response.data && e.response.data.error) || (e && e.message) || 'Fehler beim Einreihen.';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (!machines.value.length) {
|
||||||
|
return h('div', { style: 'color:var(--foreground-subdued);font-size:13px;' },
|
||||||
|
err.value || 'Keine aktive Maschine – bitte unter Einstellungen → Maschinen anlegen.');
|
||||||
|
}
|
||||||
|
return h('div', { style: 'display:flex;flex-direction:column;gap:8px;' }, [
|
||||||
|
h('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;' }, [
|
||||||
|
h('select', {
|
||||||
|
style: 'padding:6px 10px;border:1px solid var(--border-normal);border-radius:6px;background:var(--background-page);font:inherit;',
|
||||||
|
onChange: (e) => { selected.value = e.target.value; },
|
||||||
|
}, machines.value.map((m) => h('option', { value: m.id, selected: String(m.id) === String(selected.value) }, m.name))),
|
||||||
|
h('button', {
|
||||||
|
type: 'button', disabled: busy.value, onClick: drucken,
|
||||||
|
style: 'cursor:pointer;border:1px solid var(--primary);background:var(--primary);color:#fff;border-radius:6px;padding:6px 16px;font:inherit;',
|
||||||
|
}, busy.value ? 'Wird eingereiht …' : 'Drucken'),
|
||||||
|
]),
|
||||||
|
msg.value ? h('div', { style: 'color:var(--success, #2e7d32);font-size:13px;' }, msg.value) : null,
|
||||||
|
err.value ? h('div', { style: 'color:var(--danger, #c62828);font-size:13px;' }, err.value) : null,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: 'skrift-print-button',
|
||||||
|
name: 'Drucken',
|
||||||
|
icon: 'print',
|
||||||
|
description: 'Reiht den Auftrag als Druck-Job für eine Maschine ein.',
|
||||||
|
component: PrintButton,
|
||||||
|
types: ['alias'],
|
||||||
|
localTypes: ['presentation'],
|
||||||
|
group: 'presentation',
|
||||||
|
options: null,
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "directus-extension-skrift-printbutton",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Interface: reiht einen Auftrag als Druck-Job für eine Maschine ein (POST /skrift-orders/enqueue).",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "interface",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "dist/index.js",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
701
Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js
vendored
Normal file
701
Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js
vendored
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
/**
|
||||||
|
* Skrift – Modul „Produktion"
|
||||||
|
* ===========================================================================
|
||||||
|
* Die Produktions-Kommandozentrale: ein Board mit sechs Status-Umschaltern,
|
||||||
|
* zentraler Suche und pro Auftrag den drei Aktionen Drucken / Empfänger /
|
||||||
|
* Dateien – man muss die Ansicht für nichts verlassen.
|
||||||
|
*
|
||||||
|
* 1 Neu – neue Aufträge + Muster- und Angebotsanfragen
|
||||||
|
* 2 Warteschlange – eingereihte Druck-Jobs, nach Maschine unterteilt
|
||||||
|
* 3 Im Druck – laufende Aufträge (Brief/Kuvert getrennt gekennzeichnet)
|
||||||
|
* 4 Kuvertierung – fertig gedruckt, wird eingetütet
|
||||||
|
* 5 Versandfertig – bereit zum Versand
|
||||||
|
* 6 Versendet – abgeschlossen
|
||||||
|
*
|
||||||
|
* Druck-Popup wie im alten Job-Manager: Maschine + je Typ Template mit
|
||||||
|
* vorgeschlagenem Format, editierbaren Maßen und Nummern-Bereich, dazu
|
||||||
|
* Warteschlange vs. Direktdruck (Direkt gesperrt, wenn die Maschine druckt).
|
||||||
|
* Muster: manueller Druck mit Adresse aus der Anfrage + Du/Sie-Standardtext.
|
||||||
|
*
|
||||||
|
* Bewusst von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
|
||||||
|
*/
|
||||||
|
import { defineComponent, ref, reactive, computed, onMounted, h, Teleport, resolveComponent } from 'vue';
|
||||||
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
|
|
||||||
|
// Auftragsformat → Standard-Template-Schlüssel (wie im Backend /enqueue).
|
||||||
|
const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6_v2' };
|
||||||
|
const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
|
||||||
|
const INLAND = /^(de|deutschland|germany)$/i;
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: 'neu', label: '🆕 Neu' },
|
||||||
|
{ key: 'warteschlange', label: '⏳ Warteschlange' },
|
||||||
|
{ key: 'druck', label: '🖨 Im Druck' },
|
||||||
|
{ key: 'kuvertierung', label: '✉ Kuvertierung' },
|
||||||
|
{ key: 'versandfertig', label: '📦 Versandfertig' },
|
||||||
|
{ key: 'versendet', label: '🚚 Versendet' },
|
||||||
|
{ key: 'archiv', label: '🗄 Archiv' },
|
||||||
|
];
|
||||||
|
const STATUS_LABEL = {
|
||||||
|
eingegangen: 'Eingegangen', im_druck: 'Im Druck', kuvertieren: 'Kuvertierung',
|
||||||
|
versandfertig: 'Versandfertig', versendet: 'Versendet', storniert: 'Storniert',
|
||||||
|
};
|
||||||
|
// Tage, nach denen ein versendeter Auftrag automatisch ins Archiv wandert.
|
||||||
|
const ARCHIV_TAGE = 14;
|
||||||
|
const PARAM_FELDER = [
|
||||||
|
['width', 'Breite'], ['height', 'Höhe'], ['xpos', 'X'], ['ypos', 'Y'], ['scale', 'Scale'], ['rotation', 'Rot°'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── kleine Stil-/Render-Helfer ──────────────────────────────────────────────
|
||||||
|
// Weiße Karte, die sich klar von der (leicht getönten) Inhaltsfläche abhebt.
|
||||||
|
const CARD = 'border:1px solid var(--border-subdued);border-radius:8px;padding:16px 18px;background:var(--background-page,#fff);box-shadow:0 1px 3px rgba(0,0,0,.08);';
|
||||||
|
// Getönte Sektion (Briefe/Kuverts) mit Rand + Radius; Abstand über margin-bottom.
|
||||||
|
const SECTION = 'background:var(--background-subdued,#f1f4f9);border:1px solid var(--border-normal);border-radius:8px;padding:16px 18px;margin-bottom:16px;';
|
||||||
|
// Deutlich sichtbares Eingabefeld (weiß auf getönter Sektion).
|
||||||
|
const S_FELD = 'border:1px solid var(--border-normal);border-radius:7px;padding:6px 10px;background:var(--background-page,#fff);';
|
||||||
|
const S_INPUT = 'padding:7px 10px;border:1px solid var(--border-normal);border-radius:7px;background:var(--background-page);color:var(--foreground-normal);font:inherit;font-size:13px;';
|
||||||
|
const S_SELECT = S_INPUT + 'cursor:pointer;';
|
||||||
|
|
||||||
|
// Directus-Primärfarbe (Marke) + Kartenfarben je Vorgangstyp.
|
||||||
|
const ACCENT = '#1E50A5'; // Aufträge / Druck → blau
|
||||||
|
const FARBE_MUSTER = '#d13438'; // Musteranfrage → rot
|
||||||
|
const FARBE_ANGEBOT = '#e07b00'; // Angebotsanfrage → orange
|
||||||
|
|
||||||
|
function btn(label, onClick, kind) {
|
||||||
|
const base = 'cursor:pointer;border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;border:1px solid;';
|
||||||
|
const style = kind === 'primary'
|
||||||
|
? base + `border-color:${ACCENT};background:${ACCENT};color:#fff;`
|
||||||
|
: kind === 'danger'
|
||||||
|
? base + 'border-color:var(--danger,#c62828);background:transparent;color:var(--danger,#c62828);'
|
||||||
|
: base + 'border-color:var(--border-normal);background:var(--background-subdued);color:var(--foreground-normal);';
|
||||||
|
return h('button', { type: 'button', onClick, style }, label);
|
||||||
|
}
|
||||||
|
function chip(text, farbe) {
|
||||||
|
return h('span', {
|
||||||
|
style: `display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:2px 8px;border-radius:20px;`
|
||||||
|
+ `background:${farbe || 'var(--background-subdued)'};color:var(--foreground-subdued);white-space:nowrap;`,
|
||||||
|
}, text);
|
||||||
|
}
|
||||||
|
function subdued(text, extra) {
|
||||||
|
return h('div', { style: `color:var(--foreground-subdued);font-size:13px;${extra || ''}` }, text);
|
||||||
|
}
|
||||||
|
// Segmentierter Umschalter mit sichtbarem Rahmen: Optionen teilen sich die Breite.
|
||||||
|
function segToggle(value, opts, onSel) {
|
||||||
|
return h('div', { style: 'display:flex;width:100%;border:1px solid var(--border-normal);border-radius:8px;overflow:hidden;background:var(--background-subdued,#f1f4f9);' },
|
||||||
|
opts.map((o, i) => h('button', {
|
||||||
|
type: 'button', disabled: o.disabled, onClick: () => { if (!o.disabled) onSel(o.key); },
|
||||||
|
style: `flex:1;padding:12px 8px;font:inherit;font-size:13px;text-align:center;cursor:${o.disabled ? 'not-allowed' : 'pointer'};border:none;`
|
||||||
|
+ (i > 0 ? 'border-left:1px solid var(--border-normal);' : '')
|
||||||
|
+ (value === o.key ? `background:${ACCENT};color:#fff;font-weight:700;` : 'background:transparent;color:var(--foreground-normal);')
|
||||||
|
+ (o.disabled ? 'opacity:.45;' : ''),
|
||||||
|
}, o.label)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Datenhelfer ─────────────────────────────────────────────────────────────
|
||||||
|
function volleZeile(e) {
|
||||||
|
if (e.free_text && String(e.free_text).trim()) {
|
||||||
|
return String(e.free_text).trim().split(/\r?\n/).map((s) => s.trim()).filter(Boolean).join(' · ');
|
||||||
|
}
|
||||||
|
const name = [e.salutation, e.first_name, e.last_name].filter(Boolean).join(' ');
|
||||||
|
const strasse = [e.street, e.house_no].filter(Boolean).join(' ');
|
||||||
|
const ort = [e.zip, e.city].filter(Boolean).join(' ');
|
||||||
|
const land = e.country && !INLAND.test(String(e.country).trim()) ? e.country : null;
|
||||||
|
return [name, strasse, ort, land].filter(Boolean).join(' · ');
|
||||||
|
}
|
||||||
|
function extras(e) {
|
||||||
|
if (!e.placeholders || typeof e.placeholders !== 'object') return '';
|
||||||
|
return Object.entries(e.placeholders)
|
||||||
|
.filter(([, v]) => v !== null && v !== undefined && String(v).trim() !== '')
|
||||||
|
.map(([k, v]) => `${k}: ${v}`).join(' · ');
|
||||||
|
}
|
||||||
|
function kundeName(o) {
|
||||||
|
const c = o.customer || {};
|
||||||
|
return [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const Board = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const api = useApi();
|
||||||
|
const tab = ref('neu');
|
||||||
|
const suche = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const fehler = ref('');
|
||||||
|
const meldung = ref('');
|
||||||
|
|
||||||
|
const orders = ref([]);
|
||||||
|
const requests = ref([]);
|
||||||
|
const jobs = ref([]);
|
||||||
|
const machines = ref([]);
|
||||||
|
const templates = ref([]);
|
||||||
|
const muster = ref({ text_du: '', text_sie: '' });
|
||||||
|
|
||||||
|
// ── Modale ──
|
||||||
|
const druck = reactive({ open: false, order: null, machine: null, mode: 'queue', busy: false, brief: null, umschlag: null, msg: '', err: '' });
|
||||||
|
const manuell = reactive({ open: false, req: null, form: 'sie', font: 'tilda', format: 'a4', template: null, machine: null, mode: 'queue', text: '', msg: '', err: '' });
|
||||||
|
const empf = reactive({ open: false, mode: 'order', titel: '', order: null, suche: '', rows: [], files: [],
|
||||||
|
info: '', laden: false, selected: null, pl: '', pe: '', pLoad: false, perr: '', lb: '', lbTitel: '' });
|
||||||
|
|
||||||
|
// ── Laden ──
|
||||||
|
// Basis-Felder ohne die evtl. noch nicht gebootstrapten Felder – diese
|
||||||
|
// werden separat versucht, damit ein fehlendes Feld nicht das ganze Board sperrt.
|
||||||
|
const ORDER_FELDER = 'id,order_number,status,payment_status,production_status,needs_envelope,envelope_format,'
|
||||||
|
+ 'envelope_labeling,shipping_type,entries_count,count_inland,count_ausland,brief_gedruckt,kuvert_gedruckt,'
|
||||||
|
+ 'date_updated,date_created,format.key,format.name,product.name,product.input_mode,'
|
||||||
|
+ 'customer.first_name,customer.last_name,customer.email,customer.phone';
|
||||||
|
async function ladeOrders() {
|
||||||
|
try {
|
||||||
|
return await api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER + ',versandt_am' } });
|
||||||
|
} catch {
|
||||||
|
// Feld „versandt_am" fehlt noch (Bootstrap nicht gelaufen) → ohne laden,
|
||||||
|
// Archiv nutzt ersatzweise date_updated.
|
||||||
|
return api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function laden() {
|
||||||
|
loading.value = true; fehler.value = '';
|
||||||
|
try {
|
||||||
|
const [o, r, j, m, t, ms] = await Promise.all([
|
||||||
|
ladeOrders(),
|
||||||
|
api.get('/items/offer_requests', { params: {
|
||||||
|
limit: -1, sort: '-date_created',
|
||||||
|
filter: { status: { _in: ['neu', 'in_bearbeitung'] } },
|
||||||
|
fields: 'id,type,name,email,phone,quantity,product,message,address,status,date_created',
|
||||||
|
} }),
|
||||||
|
api.get('/items/jobs', { params: {
|
||||||
|
limit: -1, sort: '-priority,date_created',
|
||||||
|
filter: { status: { _in: ['queued', 'printing', 'failed'] } },
|
||||||
|
fields: 'id,type,status,priority,numbers,output_ref,error,date_created,machine.id,machine.name,order.id,order.order_number',
|
||||||
|
} }),
|
||||||
|
api.get('/items/machines', { params: { limit: -1, fields: 'id,name,active,status,last_seen' } }),
|
||||||
|
api.get('/items/format_templates', { params: { limit: -1, fields: 'id,key,name,type,width,height,xpos,ypos,scale,rotation' } }),
|
||||||
|
api.get('/items/muster_settings', { params: { fields: 'text_du,text_sie' } }).catch(() => ({ data: { data: { text_du: '', text_sie: '' } } })),
|
||||||
|
]);
|
||||||
|
orders.value = o.data?.data || [];
|
||||||
|
requests.value = r.data?.data || [];
|
||||||
|
jobs.value = j.data?.data || [];
|
||||||
|
machines.value = (m.data?.data || []);
|
||||||
|
templates.value = t.data?.data || [];
|
||||||
|
muster.value = ms.data?.data || { text_du: '', text_sie: '' };
|
||||||
|
} catch (e) {
|
||||||
|
fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.';
|
||||||
|
} finally { loading.value = false; }
|
||||||
|
}
|
||||||
|
onMounted(laden);
|
||||||
|
|
||||||
|
const aktiveMaschinen = computed(() => machines.value.filter((m) => m.active !== false));
|
||||||
|
const maschineBusy = (id) => jobs.value.some((j) => j.status === 'printing' && String(j.machine?.id) === String(id));
|
||||||
|
|
||||||
|
// Aufträge mit offenen/laufenden Jobs → gehören in „Warteschlange"/„Im Druck", nicht mehr in „Neu".
|
||||||
|
const orderIdsInArbeit = computed(() => {
|
||||||
|
const s = new Set();
|
||||||
|
for (const j of jobs.value) if (j.order?.id && (j.status === 'queued' || j.status === 'printing')) s.add(String(j.order.id));
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
|
||||||
|
function trifftSuche(hay) {
|
||||||
|
const q = suche.value.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
return hay.toLowerCase().includes(q);
|
||||||
|
}
|
||||||
|
const orderHay = (o) => [o.order_number, kundeName(o), o.customer?.email, o.customer?.phone,
|
||||||
|
o.product?.name, o.format?.name, o.shipping_type, o.production_status, o.payment_status].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
// Tab-Listen
|
||||||
|
const neueOrders = computed(() => orders.value.filter((o) =>
|
||||||
|
(o.production_status || 'eingegangen') === 'eingegangen' && !orderIdsInArbeit.value.has(String(o.id)) && trifftSuche(orderHay(o))));
|
||||||
|
const neueRequests = computed(() => requests.value.filter((r) =>
|
||||||
|
trifftSuche([r.name, r.email, r.phone, r.product, r.message, r.type].filter(Boolean).join(' '))));
|
||||||
|
const ordersByStatus = (st) => orders.value.filter((o) => (o.production_status || 'eingegangen') === st && trifftSuche(orderHay(o)));
|
||||||
|
// Archiv: storniert ODER seit mehr als 14 Tagen versendet.
|
||||||
|
const alterTage = (ts) => (ts ? (Date.now() - new Date(ts).getTime()) / 86400000 : Infinity);
|
||||||
|
const istArchiviert = (o) => o.production_status === 'storniert'
|
||||||
|
|| (o.production_status === 'versendet' && alterTage(o.versandt_am || o.date_updated) > ARCHIV_TAGE);
|
||||||
|
const versendetAktiv = () => orders.value.filter((o) => o.production_status === 'versendet' && !istArchiviert(o) && trifftSuche(orderHay(o)));
|
||||||
|
const archivListe = () => orders.value.filter((o) => istArchiviert(o) && trifftSuche(orderHay(o)));
|
||||||
|
const queuedByMachine = computed(() => {
|
||||||
|
const groups = {};
|
||||||
|
for (const m of aktiveMaschinen.value) groups[m.id] = { machine: m, jobs: [] };
|
||||||
|
groups.__ohne = { machine: { name: 'Ohne Maschine' }, jobs: [] };
|
||||||
|
for (const j of jobs.value) {
|
||||||
|
if (j.status !== 'queued') continue;
|
||||||
|
if (!trifftSuche([j.order?.order_number, j.output_ref, j.type].filter(Boolean).join(' '))) continue;
|
||||||
|
(groups[j.machine?.id] || groups.__ohne).jobs.push(j);
|
||||||
|
}
|
||||||
|
return Object.values(groups).filter((g) => g.jobs.length);
|
||||||
|
});
|
||||||
|
const tabCount = (key) => {
|
||||||
|
if (key === 'neu') return neueOrders.value.length + neueRequests.value.length;
|
||||||
|
if (key === 'warteschlange') return jobs.value.filter((j) => j.status === 'queued').length;
|
||||||
|
if (key === 'druck') return ordersByStatus('im_druck').length;
|
||||||
|
if (key === 'kuvertierung') return ordersByStatus('kuvertieren').length;
|
||||||
|
if (key === 'versandfertig') return ordersByStatus('versandfertig').length;
|
||||||
|
if (key === 'versendet') return versendetAktiv().length;
|
||||||
|
if (key === 'archiv') return archivListe().length;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
function flash(msg) { meldung.value = msg; setTimeout(() => { if (meldung.value === msg) meldung.value = ''; }, 4000); }
|
||||||
|
|
||||||
|
// ── Statuswechsel eines Auftrags ──
|
||||||
|
async function setStatus(order, neu) {
|
||||||
|
const patch = { production_status: neu };
|
||||||
|
// Versanddatum festhalten (Basis fürs Auto-Archiv nach 14 Tagen).
|
||||||
|
if (neu === 'versendet' && !order.versandt_am) patch.versandt_am = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
await api.patch(`/items/orders/${order.id}`, patch);
|
||||||
|
order.production_status = neu;
|
||||||
|
if (patch.versandt_am) order.versandt_am = patch.versandt_am;
|
||||||
|
flash(`${order.order_number} → ${STATUS_LABEL[neu]}`);
|
||||||
|
} catch (e) { fehler.value = e?.message || 'Statuswechsel fehlgeschlagen.'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Druck-Popup ──
|
||||||
|
const tplByKey = (key, typ) => templates.value.find((t) => t.key === key && t.type === typ) || templates.value.find((t) => t.type === typ) || null;
|
||||||
|
const tplToFelder = (t) => ({ tplId: t?.id ?? null, width: t?.width ?? 0, height: t?.height ?? 0, xpos: t?.xpos ?? 0, ypos: t?.ypos ?? 0, scale: t?.scale ?? 1, rotation: t?.rotation ?? 0 });
|
||||||
|
|
||||||
|
function openDruck(order) {
|
||||||
|
const bt = tplByKey(LETTER_TPL[order.format?.key] || 'a4_single', 'brief');
|
||||||
|
const wantsEnv = order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine';
|
||||||
|
const et = wantsEnv ? tplByKey(ENVELOPE_TPL[order.envelope_format] || 'kuvert_a_v1', 'umschlag') : null;
|
||||||
|
const n = Math.max(1, order.entries_count || 1);
|
||||||
|
druck.order = order;
|
||||||
|
druck.machine = aktiveMaschinen.value[0]?.id ?? null;
|
||||||
|
druck.mode = 'queue'; druck.msg = ''; druck.err = '';
|
||||||
|
druck.brief = { enabled: true, numbers: `1-${n}`, done: !!order.brief_gedruckt, ...tplToFelder(bt) };
|
||||||
|
druck.umschlag = wantsEnv ? { enabled: true, numbers: `1-${n}`, done: !!order.kuvert_gedruckt, ...tplToFelder(et) } : null;
|
||||||
|
druck.open = true;
|
||||||
|
}
|
||||||
|
function druckTplWechsel(sec, tplId) {
|
||||||
|
const t = templates.value.find((x) => String(x.id) === String(tplId));
|
||||||
|
if (!t) return;
|
||||||
|
Object.assign(druck[sec], tplToFelder(t));
|
||||||
|
}
|
||||||
|
async function submitDruck() {
|
||||||
|
if (!druck.machine) { druck.err = 'Bitte Maschine wählen.'; return; }
|
||||||
|
const sections = {};
|
||||||
|
const bau = (sec) => ({ enabled: true, template: sec.tplId, numbers: sec.numbers || '',
|
||||||
|
params: { width: sec.width, height: sec.height, xpos: sec.xpos, ypos: sec.ypos, scale: sec.scale, rotation: sec.rotation } });
|
||||||
|
if (druck.brief?.enabled) sections.brief = bau(druck.brief);
|
||||||
|
if (druck.umschlag?.enabled) sections.umschlag = bau(druck.umschlag);
|
||||||
|
if (!Object.keys(sections).length) { druck.err = 'Kein Abschnitt gewählt.'; return; }
|
||||||
|
druck.err = ''; druck.msg = 'Wird eingereiht …';
|
||||||
|
try {
|
||||||
|
const res = await api.post('/skrift-orders/enqueue', { order: druck.order.id, machine: druck.machine, mode: druck.mode, sections });
|
||||||
|
const n = res.data?.jobs?.length || 0;
|
||||||
|
const modus = res.data?.mode === 'direct' ? 'Direktdruck' : 'Warteschlange';
|
||||||
|
flash(`${n} Job(s) eingereiht (${modus}).`);
|
||||||
|
druck.open = false;
|
||||||
|
await laden();
|
||||||
|
} catch (e) { druck.err = e?.response?.data?.error || e?.message || 'Einreihen fehlgeschlagen.'; druck.msg = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Muster: manueller Druck ──
|
||||||
|
function musterAufloesen() {
|
||||||
|
const roh = manuell.form === 'du' ? muster.value.text_du : muster.value.text_sie;
|
||||||
|
const a = manuell.req?.address && typeof manuell.req.address === 'object' ? manuell.req.address : {};
|
||||||
|
const name = manuell.req?.name || '';
|
||||||
|
const teile = name.split(' ');
|
||||||
|
const vor = teile[0] || ''; const nach = teile.slice(1).join(' ');
|
||||||
|
manuell.text = String(roh || '')
|
||||||
|
.replace(/\[\[Vorname\]\]/gi, a.first_name || a.vorname || vor)
|
||||||
|
.replace(/\[\[Nachname\]\]/gi, a.last_name || a.nachname || nach)
|
||||||
|
.replace(/\[\[Anrede\]\]/gi, a.salutation || a.anrede || '')
|
||||||
|
.replace(/\[\[Name\]\]/gi, name);
|
||||||
|
}
|
||||||
|
function openMuster(req) {
|
||||||
|
manuell.req = req; manuell.form = 'sie'; manuell.font = 'tilda'; manuell.format = 'a4';
|
||||||
|
manuell.template = tplByKey('a4_single', 'brief')?.id ?? templates.value.find((t) => t.type === 'brief')?.id ?? null;
|
||||||
|
manuell.machine = aktiveMaschinen.value[0]?.id ?? null; manuell.mode = 'queue';
|
||||||
|
manuell.msg = ''; manuell.err = '';
|
||||||
|
musterAufloesen();
|
||||||
|
manuell.open = true;
|
||||||
|
}
|
||||||
|
async function submitMuster() {
|
||||||
|
if (!manuell.text.trim()) { manuell.err = 'Text fehlt.'; return; }
|
||||||
|
if (!manuell.template || !manuell.machine) { manuell.err = 'Template und Maschine wählen.'; return; }
|
||||||
|
manuell.err = ''; manuell.msg = 'Wird erzeugt …';
|
||||||
|
try {
|
||||||
|
const res = await api.post('/skrift-orders/manual-print', {
|
||||||
|
text: manuell.text, font: manuell.font, format: manuell.format,
|
||||||
|
template: manuell.template, machine: manuell.machine, mode: manuell.mode,
|
||||||
|
offer_request: manuell.req?.id || null,
|
||||||
|
});
|
||||||
|
const modus = res.data?.mode === 'direct' ? 'Direktdruck' : 'Warteschlange';
|
||||||
|
flash(`Muster erzeugt & eingereiht (${modus}).`);
|
||||||
|
manuell.open = false;
|
||||||
|
await laden();
|
||||||
|
} catch (e) { manuell.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Fehlgeschlagen.'; manuell.msg = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empfänger-Drawer (mit Dokument-Vorschau je Zeile) ──
|
||||||
|
const dateiNummer = (name) => {
|
||||||
|
const z = String(name || '').replace(/\.[^.]*$/, '').replace(/\D/g, '');
|
||||||
|
return z ? parseInt(z, 10) : null;
|
||||||
|
};
|
||||||
|
async function openEmpf(order) {
|
||||||
|
empf.open = true; empf.mode = 'order'; empf.titel = `Empfänger · ${order.order_number}`;
|
||||||
|
empf.order = order.order_number; empf.suche = ''; empf.rows = []; empf.files = []; empf.info = '';
|
||||||
|
empf.selected = null; empf.pl = ''; empf.pe = ''; empf.perr = ''; empf.lb = ''; empf.laden = true;
|
||||||
|
try {
|
||||||
|
const [ent, fl] = await Promise.all([
|
||||||
|
api.get('/items/order_entries', { params: { 'filter[order][_eq]': order.id, sort: 'letter_number', limit: -1, fields: '*' } }),
|
||||||
|
api.get('/skrift-orders/files/' + encodeURIComponent(order.order_number)).catch(() => ({ data: { files: [] } })),
|
||||||
|
]);
|
||||||
|
empf.rows = (ent.data?.data || []).map((e) => ({ nr: e.letter_number, zeile: volleZeile(e), extra: extras(e) }));
|
||||||
|
empf.files = fl.data?.files || [];
|
||||||
|
} catch (e) { empf.info = e?.message || 'Empfänger konnten nicht geladen werden.'; } finally { empf.laden = false; }
|
||||||
|
}
|
||||||
|
function openReqInfo(req) {
|
||||||
|
empf.open = true; empf.mode = 'request'; empf.titel = `Anfrage · ${req.name}`; empf.suche = ''; empf.info = '';
|
||||||
|
empf.files = []; empf.selected = null; empf.pl = ''; empf.pe = ''; empf.lb = '';
|
||||||
|
const a = req.address && typeof req.address === 'object' ? Object.entries(req.address).map(([k, v]) => `${k}: ${v}`).join(' · ') : (req.address || '');
|
||||||
|
empf.rows = [
|
||||||
|
{ nr: '✉', zeile: `${req.name} · ${req.email}${req.phone ? ' · ' + req.phone : ''}`, extra: '' },
|
||||||
|
{ nr: '📦', zeile: a || 'Keine Adresse angegeben.', extra: '' },
|
||||||
|
{ nr: '💬', zeile: req.message || '—', extra: `Produkt: ${req.product || '—'} · Menge: ${req.quantity ?? '—'}` },
|
||||||
|
];
|
||||||
|
empf.laden = false;
|
||||||
|
}
|
||||||
|
async function empfBlob(f) {
|
||||||
|
const res = await api.get('/skrift-orders/files/' + encodeURIComponent(empf.order) + '/download', { params: { rel: f.rel }, responseType: 'blob' });
|
||||||
|
return URL.createObjectURL(res.data);
|
||||||
|
}
|
||||||
|
// Positionsbasierter Abgleich: der i-te Empfänger → die i-te Datei. Robust
|
||||||
|
// gegen alte (0-basierte) und neue (1-basierte) Dateinamen.
|
||||||
|
const nachNummer = (kind) => empf.files
|
||||||
|
.filter((f) => f.kind === kind)
|
||||||
|
.sort((a, b) => (dateiNummer(a.name) ?? 0) - (dateiNummer(b.name) ?? 0));
|
||||||
|
async function previewEntry(nr, rang) {
|
||||||
|
empf.selected = nr; empf.perr = ''; empf.pLoad = true;
|
||||||
|
if (empf.pl) URL.revokeObjectURL(empf.pl); if (empf.pe) URL.revokeObjectURL(empf.pe);
|
||||||
|
empf.pl = ''; empf.pe = '';
|
||||||
|
try {
|
||||||
|
const brief = nachNummer('schriftstueck')[rang] || empf.files.find((f) => f.kind === 'schriftstueck' && dateiNummer(f.name) === nr);
|
||||||
|
const kuvert = nachNummer('umschlag')[rang] || empf.files.find((f) => f.kind === 'umschlag' && dateiNummer(f.name) === nr);
|
||||||
|
if (brief) empf.pl = await empfBlob(brief);
|
||||||
|
if (kuvert) empf.pe = await empfBlob(kuvert);
|
||||||
|
if (!brief && !kuvert) {
|
||||||
|
empf.perr = empf.files.length
|
||||||
|
? 'Für diese Nummer sind noch keine Dateien generiert.'
|
||||||
|
: 'Für diesen Auftrag wurden noch keine Dateien generiert (erst nach Abschluss/Generierung).';
|
||||||
|
}
|
||||||
|
} catch (e) { empf.perr = 'Vorschau fehlgeschlagen.'; } finally { empf.pLoad = false; }
|
||||||
|
}
|
||||||
|
const empfTreffer = computed(() => {
|
||||||
|
const q = empf.suche.trim().toLowerCase();
|
||||||
|
if (!q) return empf.rows;
|
||||||
|
return empf.rows.filter((r) => `#${r.nr} ${r.zeile} ${r.extra}`.toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Job abbrechen ──
|
||||||
|
async function cancelJob(job) {
|
||||||
|
try { await api.delete(`/items/jobs/${job.id}`); flash('Job entfernt.'); await laden(); }
|
||||||
|
catch (e) { fehler.value = e?.message || 'Job konnte nicht entfernt werden.'; }
|
||||||
|
}
|
||||||
|
async function setRequestStatus(req, st) {
|
||||||
|
try { await api.patch(`/items/offer_requests/${req.id}`, { status: st }); flash('Anfrage aktualisiert.'); await laden(); }
|
||||||
|
catch (e) { fehler.value = e?.message || 'Fehlgeschlagen.'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render-Bausteine ──────────────────────────────────────────────────
|
||||||
|
function actionButtons(order) {
|
||||||
|
// Drucken + Empfänger (Empfänger enthält die Vorschau je Dokument).
|
||||||
|
return h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
|
||||||
|
btn('🖨 Drucken', () => openDruck(order), 'primary'),
|
||||||
|
btn('👥 Empfänger', () => openEmpf(order)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function statusSelect(order) {
|
||||||
|
return h('select', {
|
||||||
|
style: S_SELECT + 'font-size:12px;padding:4px 8px;',
|
||||||
|
value: order.production_status || 'eingegangen',
|
||||||
|
onChange: (e) => setStatus(order, e.target.value),
|
||||||
|
}, Object.entries(STATUS_LABEL).map(([k, v]) => h('option', { value: k, selected: (order.production_status || 'eingegangen') === k }, v)));
|
||||||
|
}
|
||||||
|
function orderCard(order, opts) {
|
||||||
|
const o = order; const zeigeBadges = opts?.badges;
|
||||||
|
const kopf = h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;' }, [
|
||||||
|
h('div', {}, [
|
||||||
|
h('div', { style: 'font-weight:700;font-size:15px;' }, `#${o.order_number}`),
|
||||||
|
subdued(`${kundeName(o)}${o.customer?.email ? ' · ' + o.customer.email : ''}${o.customer?.phone ? ' · ' + o.customer.phone : ''}`, 'margin-top:2px;'),
|
||||||
|
]),
|
||||||
|
statusSelect(o),
|
||||||
|
]);
|
||||||
|
const meta = h('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;margin:10px 0;' }, [
|
||||||
|
chip(`${o.entries_count || 0} Briefe`),
|
||||||
|
chip(o.needs_envelope ? `${o.entries_count || 0} Kuverts` : 'ohne Kuvert'),
|
||||||
|
chip(o.shipping_type === 'einzeln' ? 'Einzelversand' : 'Sammelversand'),
|
||||||
|
chip(o.product?.name || 'Produkt?'),
|
||||||
|
chip(o.format?.name || o.format?.key || 'Format?'),
|
||||||
|
chip(o.payment_status === 'bezahlt' ? '✓ bezahlt' : (o.payment_status === 'storniert' ? 'storniert' : 'offen'),
|
||||||
|
o.payment_status === 'bezahlt' ? 'var(--success-25,rgba(46,125,50,.15))' : 'var(--warning-25,rgba(230,160,0,.15))'),
|
||||||
|
]);
|
||||||
|
const badges = zeigeBadges ? h('div', { style: 'display:flex;gap:8px;margin-bottom:10px;' }, [
|
||||||
|
chip(o.brief_gedruckt ? '✓ Brief gedruckt' : '… Brief offen', o.brief_gedruckt ? 'var(--success-25,rgba(46,125,50,.15))' : 'var(--background-subdued)'),
|
||||||
|
o.needs_envelope ? chip(o.kuvert_gedruckt ? '✓ Kuvert gedruckt' : '… Kuvert offen', o.kuvert_gedruckt ? 'var(--success-25,rgba(46,125,50,.15))' : 'var(--background-subdued)') : null,
|
||||||
|
]) : null;
|
||||||
|
return h('div', { key: 'o' + o.id, style: CARD + `margin-bottom:14px;border-left:4px solid ${ACCENT};` }, [kopf, meta, badges, actionButtons(o)]);
|
||||||
|
}
|
||||||
|
function requestCard(req) {
|
||||||
|
const istMuster = req.type === 'muster';
|
||||||
|
const kopf = h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;' }, [
|
||||||
|
h('div', {}, [
|
||||||
|
h('div', { style: 'font-weight:700;font-size:15px;' }, `${istMuster ? '🧪 Muster' : '📝 Angebot'} · ${req.name}`),
|
||||||
|
subdued(`${req.email}${req.phone ? ' · ' + req.phone : ''}`, 'margin-top:2px;'),
|
||||||
|
]),
|
||||||
|
h('select', { style: S_SELECT + 'font-size:12px;padding:4px 8px;', value: req.status, onChange: (e) => setRequestStatus(req, e.target.value) },
|
||||||
|
['neu', 'in_bearbeitung', 'beantwortet', 'abgelehnt'].map((s) => h('option', { value: s, selected: req.status === s }, s))),
|
||||||
|
]);
|
||||||
|
const meta = h('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;margin:10px 0;' }, [
|
||||||
|
chip(req.product || 'Produkt?'), req.quantity ? chip(`Menge ${req.quantity}`) : null,
|
||||||
|
]);
|
||||||
|
const nachricht = req.message ? subdued(req.message, 'margin:0 0 10px;white-space:pre-wrap;') : null;
|
||||||
|
const aktionen = h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
|
||||||
|
// Muster (Druckprodukt) → manueller Druck; individuelles Angebot → kein Druck.
|
||||||
|
istMuster ? btn('🖨 Muster drucken', () => openMuster(req), 'primary') : null,
|
||||||
|
btn('👤 Details', () => openReqInfo(req)),
|
||||||
|
]);
|
||||||
|
return h('div', { key: 'r' + req.id, style: CARD + `margin-bottom:14px;border-left:4px solid ${istMuster ? FARBE_MUSTER : FARBE_ANGEBOT};` }, [kopf, meta, nachricht, aktionen]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabInhalt() {
|
||||||
|
if (tab.value === 'neu') {
|
||||||
|
const items = [...neueOrders.value.map((o) => orderCard(o)), ...neueRequests.value.map(requestCard)];
|
||||||
|
return items.length ? items : subdued('Nichts Neues.');
|
||||||
|
}
|
||||||
|
if (tab.value === 'warteschlange') {
|
||||||
|
const gruppen = queuedByMachine.value;
|
||||||
|
if (!gruppen.length) return subdued('Warteschlange leer.');
|
||||||
|
return gruppen.map((g) => h('div', { style: 'margin-bottom:18px;' }, [
|
||||||
|
h('div', { style: 'font-weight:700;margin-bottom:8px;' }, `🖨 ${g.machine.name}${g.machine.status ? ' · ' + g.machine.status : ''}`),
|
||||||
|
...g.jobs.map((j) => h('div', { key: 'j' + j.id, style: CARD + 'margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;' }, [
|
||||||
|
h('div', {}, [
|
||||||
|
h('div', { style: 'font-weight:600;' }, `#${j.order?.order_number || j.output_ref || j.id} · ${j.type}`),
|
||||||
|
subdued(`${j.numbers ? 'Nr. ' + j.numbers : 'alle'}${j.priority ? ' · Direkt' : ''}`),
|
||||||
|
]),
|
||||||
|
btn('Entfernen', () => cancelJob(j), 'danger'),
|
||||||
|
])),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
if (tab.value === 'druck') {
|
||||||
|
const list = ordersByStatus('im_druck');
|
||||||
|
return list.length ? list.map((o) => orderCard(o, { badges: true })) : subdued('Nichts im Druck.');
|
||||||
|
}
|
||||||
|
if (tab.value === 'versendet') {
|
||||||
|
const list = versendetAktiv();
|
||||||
|
return list.length ? list.map((o) => orderCard(o)) : subdued('Nichts (kürzlich) versendet.');
|
||||||
|
}
|
||||||
|
if (tab.value === 'archiv') {
|
||||||
|
const list = archivListe();
|
||||||
|
return list.length ? list.map((o) => orderCard(o)) : subdued('Archiv leer.');
|
||||||
|
}
|
||||||
|
const map = { kuvertierung: 'kuvertieren', versandfertig: 'versandfertig' };
|
||||||
|
const st = map[tab.value];
|
||||||
|
const list = ordersByStatus(st);
|
||||||
|
return list.length ? list.map((o) => orderCard(o, { badges: st === 'kuvertieren' })) : subdued('Leer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modale rendern ──────────────────────────────────────────────────────
|
||||||
|
function overlay(inhalt, close, breite) {
|
||||||
|
return h('div', {
|
||||||
|
onClick: (e) => { if (e.target === e.currentTarget) close(); },
|
||||||
|
style: 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:flex-start;justify-content:center;padding:5vh 16px;overflow:auto;',
|
||||||
|
}, [h('div', { style: `background:var(--background-page,#fff);border:1px solid var(--border-subdued);border-radius:12px;width:${breite || '620px'};max-width:100%;box-shadow:0 20px 60px rgba(0,0,0,.35);` }, inhalt)]);
|
||||||
|
}
|
||||||
|
function modalKopf(titel, close) {
|
||||||
|
return h('div', { style: 'display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid var(--border-subdued);' }, [
|
||||||
|
h('div', { style: 'font-weight:700;font-size:16px;' }, titel),
|
||||||
|
btn('✕', close),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function feldGrid(sec, secKey) {
|
||||||
|
return h('div', { style: 'display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:12px 0;' }, PARAM_FELDER.map(([k, lbl]) =>
|
||||||
|
h('div', { style: S_FELD + 'display:flex;flex-direction:column;' }, [
|
||||||
|
h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--foreground-subdued);margin-bottom:2px;' }, lbl),
|
||||||
|
h('input', { type: 'number', step: '0.01', value: sec[k],
|
||||||
|
style: 'border:none;background:transparent;color:var(--foreground-normal);font:inherit;font-size:15px;font-weight:600;width:100%;padding:0;outline:none;',
|
||||||
|
onInput: (e) => { druck[secKey][k] = parseFloat(e.target.value) || 0; } }),
|
||||||
|
])));
|
||||||
|
}
|
||||||
|
function druckSektion(secKey, titel, ikon, typ) {
|
||||||
|
const sec = druck[secKey]; if (!sec) return null;
|
||||||
|
const opts = templates.value.filter((t) => t.type === typ);
|
||||||
|
return h('div', { style: SECTION }, [
|
||||||
|
h('div', { style: 'display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;' }, [
|
||||||
|
h('label', { style: 'display:flex;align-items:center;gap:8px;font-weight:700;font-size:15px;' }, [
|
||||||
|
h('input', { type: 'checkbox', checked: sec.enabled, onChange: (e) => { druck[secKey].enabled = e.target.checked; }, style: 'width:16px;height:16px;' }),
|
||||||
|
`${ikon} ${titel}`,
|
||||||
|
]),
|
||||||
|
sec.done ? chip('✓ bereits gedruckt', 'var(--success-25,rgba(46,125,50,.15))') : null,
|
||||||
|
]),
|
||||||
|
h('select', { style: S_SELECT + 'width:100%;margin-bottom:6px;', value: sec.tplId, onChange: (e) => druckTplWechsel(secKey, e.target.value) },
|
||||||
|
opts.map((t) => h('option', { value: t.id, selected: String(sec.tplId) === String(t.id) }, t.name))),
|
||||||
|
feldGrid(sec, secKey),
|
||||||
|
h('div', { style: S_FELD + 'display:flex;flex-direction:column;gap:4px;' }, [
|
||||||
|
h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--foreground-subdued);' }, 'Zu druckende Nummern'),
|
||||||
|
h('input', { value: sec.numbers, placeholder: 'z. B. 1,4,8 oder 1-9 oder 1-3,5,8',
|
||||||
|
style: 'border:none;background:transparent;color:var(--foreground-normal);font:inherit;font-size:14px;width:100%;padding:0;outline:none;',
|
||||||
|
onInput: (e) => { druck[secKey].numbers = e.target.value; } }),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function druckModal() {
|
||||||
|
if (!druck.open) return null;
|
||||||
|
const busy = druck.machine != null && maschineBusy(druck.machine);
|
||||||
|
const body = h('div', { style: 'padding:18px 20px;' }, [
|
||||||
|
h('div', { style: 'display:flex;flex-direction:column;gap:4px;margin-bottom:14px;' }, [
|
||||||
|
h('label', { style: 'font-size:12px;color:var(--foreground-subdued);' }, 'Maschine'),
|
||||||
|
aktiveMaschinen.value.length
|
||||||
|
? h('select', { style: S_SELECT + 'width:100%;', value: druck.machine, onChange: (e) => { druck.machine = e.target.value; if (maschineBusy(e.target.value)) druck.mode = 'queue'; } },
|
||||||
|
aktiveMaschinen.value.map((m) => h('option', { value: m.id, selected: String(druck.machine) === String(m.id) }, `${m.name}${maschineBusy(m.id) ? ' (druckt)' : ''}`)))
|
||||||
|
: subdued('Keine aktive Maschine – bitte unter Einstellungen → Maschinen anlegen.'),
|
||||||
|
]),
|
||||||
|
druckSektion('brief', 'Briefe', '📄', 'brief'),
|
||||||
|
druckSektion('umschlag', 'Kuverts', '✉', 'umschlag'),
|
||||||
|
h('div', { style: 'margin:12px 0 4px;' }, [
|
||||||
|
segToggle(busy ? 'queue' : druck.mode, [
|
||||||
|
{ key: 'direct', label: '⚡ Direktdruck', disabled: busy },
|
||||||
|
{ key: 'queue', label: '⏳ Warteschlange' },
|
||||||
|
], (k) => { druck.mode = k; }),
|
||||||
|
busy ? subdued('Maschine druckt gerade → nur Warteschlange.', 'margin-top:6px;') : null,
|
||||||
|
]),
|
||||||
|
druck.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;margin-top:8px;' }, druck.err) : null,
|
||||||
|
druck.msg ? subdued(druck.msg, 'margin-top:8px;') : null,
|
||||||
|
]);
|
||||||
|
const foot = h('div', { style: 'display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border-subdued);' }, [
|
||||||
|
btn('Abbrechen', () => { druck.open = false; }),
|
||||||
|
btn('In Produktion geben', submitDruck, 'primary'),
|
||||||
|
]);
|
||||||
|
return overlay([modalKopf(`Drucken · #${druck.order.order_number}`, () => { druck.open = false; }), body, foot], () => { druck.open = false; }, '640px');
|
||||||
|
}
|
||||||
|
function musterModal() {
|
||||||
|
if (!manuell.open) return null;
|
||||||
|
const briefTpls = templates.value.filter((t) => t.type === 'brief');
|
||||||
|
const body = h('div', { style: 'padding:18px 20px;display:flex;flex-direction:column;gap:12px;' }, [
|
||||||
|
h('div', { style: 'display:flex;gap:16px;align-items:center;' }, [
|
||||||
|
h('label', { style: 'display:flex;align-items:center;gap:6px;' }, [h('input', { type: 'radio', name: 'anrede', checked: manuell.form === 'sie', onChange: () => { manuell.form = 'sie'; musterAufloesen(); } }), 'Sie-Form']),
|
||||||
|
h('label', { style: 'display:flex;align-items:center;gap:6px;' }, [h('input', { type: 'radio', name: 'anrede', checked: manuell.form === 'du', onChange: () => { manuell.form = 'du'; musterAufloesen(); } }), 'Du-Form']),
|
||||||
|
subdued('Adresse aus der Anfrage übernommen · [[abschnitt]] bitte füllen'),
|
||||||
|
]),
|
||||||
|
h('textarea', { style: S_INPUT + 'width:100%;min-height:200px;font-family:var(--family-monospace,monospace);', value: manuell.text, onInput: (e) => { manuell.text = e.target.value; } }),
|
||||||
|
h('div', { style: 'display:grid;grid-template-columns:repeat(4,1fr);gap:8px;' }, [
|
||||||
|
h('div', {}, [h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Schrift'),
|
||||||
|
h('select', { style: S_SELECT + 'width:100%;', value: manuell.font, onChange: (e) => { manuell.font = e.target.value; } }, ['tilda', 'alva', 'ellie'].map((f) => h('option', { value: f, selected: manuell.font === f }, f)))]),
|
||||||
|
h('div', {}, [h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Format'),
|
||||||
|
h('select', { style: S_SELECT + 'width:100%;', value: manuell.format, onChange: (e) => { manuell.format = e.target.value; } },
|
||||||
|
[['a4', 'A4'], ['a6_hoch', 'A6 hoch'], ['a6_quer', 'A6 quer']].map(([v, l]) => h('option', { value: v, selected: manuell.format === v }, l)))]),
|
||||||
|
h('div', {}, [h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Template'),
|
||||||
|
h('select', { style: S_SELECT + 'width:100%;', value: manuell.template, onChange: (e) => { manuell.template = e.target.value; } }, briefTpls.map((t) => h('option', { value: t.id, selected: String(manuell.template) === String(t.id) }, t.name)))]),
|
||||||
|
h('div', {}, [h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Maschine'),
|
||||||
|
h('select', { style: S_SELECT + 'width:100%;', value: manuell.machine, onChange: (e) => { manuell.machine = e.target.value; } }, aktiveMaschinen.value.map((m) => h('option', { value: m.id, selected: String(manuell.machine) === String(m.id) }, m.name)))]),
|
||||||
|
]),
|
||||||
|
segToggle(manuell.mode, [
|
||||||
|
{ key: 'direct', label: '⚡ Direktdruck' },
|
||||||
|
{ key: 'queue', label: '⏳ Warteschlange' },
|
||||||
|
], (k) => { manuell.mode = k; }),
|
||||||
|
manuell.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;' }, manuell.err) : null,
|
||||||
|
manuell.msg ? subdued(manuell.msg) : null,
|
||||||
|
]);
|
||||||
|
const foot = h('div', { style: 'display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border-subdued);' }, [
|
||||||
|
btn('Abbrechen', () => { manuell.open = false; }),
|
||||||
|
btn('Erzeugen & drucken', submitMuster, 'primary'),
|
||||||
|
]);
|
||||||
|
return overlay([modalKopf(`Muster drucken · ${manuell.req?.name || ''}`, () => { manuell.open = false; }), body, foot], () => { manuell.open = false; }, '680px');
|
||||||
|
}
|
||||||
|
function empfModal() {
|
||||||
|
if (!empf.open) return null;
|
||||||
|
const istOrder = empf.mode === 'order';
|
||||||
|
// Linke Spalte (~70 %): Suche + klickbare Empfängerliste.
|
||||||
|
const liste = h('div', { style: `flex:1 1 66%;min-width:260px;` }, [
|
||||||
|
h('input', { type: 'search', value: empf.suche, placeholder: 'Durchsuchen (Name, Straße, Ort, Platzhalter …)', style: S_INPUT + 'width:100%;margin-bottom:12px;', onInput: (e) => { empf.suche = e.target.value; } }),
|
||||||
|
empf.laden ? subdued('Wird geladen …') : empf.info ? subdued(empf.info)
|
||||||
|
: h('div', { style: 'display:flex;flex-direction:column;gap:6px;max-height:62vh;overflow:auto;' }, empfTreffer.value.length
|
||||||
|
? empfTreffer.value.map((r) => h('div', {
|
||||||
|
onClick: istOrder ? () => previewEntry(r.nr, empf.rows.findIndex((x) => x.nr === r.nr)) : undefined,
|
||||||
|
style: 'display:flex;gap:12px;align-items:baseline;border:1px solid var(--border-subdued);border-radius:8px;padding:12px 14px;'
|
||||||
|
+ `${istOrder ? 'cursor:pointer;' : ''}`
|
||||||
|
+ (istOrder && empf.selected === r.nr ? `border-color:${ACCENT};background:rgba(30,80,165,.07);` : 'background:var(--background-page);'),
|
||||||
|
}, [
|
||||||
|
h('span', { style: `min-width:40px;font-weight:700;color:${ACCENT};font-family:var(--family-monospace,monospace);` }, `#${r.nr}`),
|
||||||
|
h('div', { style: 'flex:1;' }, [h('div', {}, r.zeile || '—'), r.extra ? subdued(r.extra, 'margin-top:2px;') : null]),
|
||||||
|
]))
|
||||||
|
: subdued('Keine Treffer.')),
|
||||||
|
]);
|
||||||
|
// Rechte Spalte (~30 %): Vorschau; Klick aufs Bild → große Lightbox.
|
||||||
|
const vorschauBox = (titel, url) => h('div', { style: 'display:flex;flex-direction:column;gap:6px;' }, [
|
||||||
|
h('div', { style: 'font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--foreground-subdued);' }, titel),
|
||||||
|
h('div', {
|
||||||
|
onClick: url ? () => { empf.lb = url; empf.lbTitel = titel; } : undefined,
|
||||||
|
style: 'border:1px solid var(--border-subdued);border-radius:8px;background:#fff;display:flex;align-items:center;justify-content:center;min-height:150px;padding:8px;'
|
||||||
|
+ (url ? 'cursor:zoom-in;' : ''),
|
||||||
|
}, url ? h('img', { src: url, style: 'max-width:100%;max-height:30vh;object-fit:contain;' }) : subdued('—')),
|
||||||
|
]);
|
||||||
|
const vorschau = istOrder ? h('div', { style: 'flex:0 0 30%;min-width:220px;display:flex;flex-direction:column;gap:14px;' },
|
||||||
|
empf.selected == null ? [subdued('Empfänger anklicken → Vorschau (Klick aufs Bild = groß).')]
|
||||||
|
: empf.pLoad ? [subdued('Vorschau wird geladen …')]
|
||||||
|
: [
|
||||||
|
empf.perr ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;' }, empf.perr) : null,
|
||||||
|
vorschauBox('📄 Schriftstück', empf.pl),
|
||||||
|
vorschauBox('✉ Kuvert', empf.pe),
|
||||||
|
]) : null;
|
||||||
|
const body = h('div', { style: 'padding:16px 20px;display:flex;gap:20px;flex-wrap:wrap;' }, [liste, vorschau]);
|
||||||
|
return overlay([modalKopf(empf.titel, () => { empf.open = false; }), body], () => { empf.open = false; }, istOrder ? '980px' : '620px');
|
||||||
|
}
|
||||||
|
// Große Einzelvorschau (Lightbox) über dem Empfänger-Dialog.
|
||||||
|
function lightboxModal() {
|
||||||
|
if (!empf.lb) return null;
|
||||||
|
return h('div', {
|
||||||
|
onClick: () => { empf.lb = ''; },
|
||||||
|
style: 'position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:1100;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:4vh 4vw;gap:14px;cursor:zoom-out;',
|
||||||
|
}, [
|
||||||
|
h('div', { style: 'color:#fff;font-size:13px;text-transform:uppercase;letter-spacing:.05em;' }, empf.lbTitel),
|
||||||
|
h('img', { src: empf.lb, style: 'max-width:100%;max-height:82vh;object-fit:contain;background:#fff;border-radius:8px;' }),
|
||||||
|
h('div', { style: 'color:rgba(255,255,255,.7);font-size:12px;' }, 'Klicken zum Schließen'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gesamt-Render ─────────────────────────────────────────────────────
|
||||||
|
// In <private-view> gehängt → normaler Directus-Content-Bereich + Kopf.
|
||||||
|
// Die sechs Umschalter liegen in der nativen Navigationsspalte links.
|
||||||
|
return () => {
|
||||||
|
const navi = h('div', { style: 'padding:12px 10px;' }, TABS.map((t) =>
|
||||||
|
h('button', {
|
||||||
|
type: 'button', onClick: () => { tab.value = t.key; },
|
||||||
|
style: 'display:flex;justify-content:space-between;align-items:center;gap:8px;width:100%;text-align:left;'
|
||||||
|
+ 'cursor:pointer;border:none;border-radius:6px;padding:10px 12px;margin-bottom:3px;font:inherit;font-size:14px;'
|
||||||
|
+ (tab.value === t.key ? `background:${ACCENT};color:#fff;font-weight:600;` : 'background:transparent;color:var(--foreground-normal);'),
|
||||||
|
}, [
|
||||||
|
h('span', {}, t.label),
|
||||||
|
h('span', { style: 'font-size:12px;opacity:.75;' }, String(tabCount(t.key))),
|
||||||
|
])));
|
||||||
|
|
||||||
|
const kopfzeile = h('div', { style: 'display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:18px;' }, [
|
||||||
|
h('input', { type: 'search', value: suche.value, placeholder: '🔎 Auftrag, Kunde, E-Mail, Produkt …',
|
||||||
|
style: S_INPUT + 'flex:1;min-width:240px;', onInput: (e) => { suche.value = e.target.value; } }),
|
||||||
|
btn('↻ Aktualisieren', laden),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Leicht getönte Inhaltsfläche → weiße Karten heben sich klar ab (wie im Referenz-Dashboard).
|
||||||
|
const inhalt = h('div', { style: 'padding:24px 28px;min-height:100%;background:var(--background-subdued,#f1f4f9);' }, [
|
||||||
|
kopfzeile,
|
||||||
|
meldung.value ? h('div', { style: 'background:var(--success-25,rgba(46,125,50,.15));color:var(--success,#2e7d32);padding:9px 14px;border-radius:8px;margin-bottom:12px;font-size:13px;' }, meldung.value) : null,
|
||||||
|
fehler.value ? h('div', { style: 'background:var(--danger-25,rgba(198,40,40,.12));color:var(--danger,#c62828);padding:9px 14px;border-radius:8px;margin-bottom:12px;font-size:13px;' }, fehler.value) : null,
|
||||||
|
loading.value ? subdued('Lädt …') : h('div', { style: 'display:flex;flex-direction:column;gap:2px;' }, tabInhalt()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const modale = [druckModal(), musterModal(), empfModal(), lightboxModal()].filter(Boolean);
|
||||||
|
|
||||||
|
return h(resolveComponent('private-view'), { title: 'Produktion' }, {
|
||||||
|
navigation: () => navi,
|
||||||
|
default: () => [inhalt, modale.length ? h(Teleport, { to: 'body' }, modale) : null],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: 'skrift-produktion',
|
||||||
|
name: 'Produktion',
|
||||||
|
icon: 'precision_manufacturing',
|
||||||
|
routes: [{ path: '', component: Board }],
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "directus-extension-skrift-produktion",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Modul: Produktions-Kommandozentrale (Status-Board, Druck-Popup, Empfänger/Dateien, Muster-Druck).",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "module",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "dist/index.js",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
59
Docker/directus/extensions/directus-extension-skrift-testprint/dist/index.js
vendored
Normal file
59
Docker/directus/extensions/directus-extension-skrift-testprint/dist/index.js
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Skrift – Interface „Testdruck generieren & drucken"
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Button im test_prints-Datensatz: erzeugt aus Template + Text ein SVG und
|
||||||
|
* reiht es als Druck-Job für die gewählte Maschine ein
|
||||||
|
* (POST /skrift-orders/test-print). Datensatz vorher speichern.
|
||||||
|
*
|
||||||
|
* Von Hand als ESM (kein Build) – Vue + SDK stellt Directus zur Laufzeit bereit.
|
||||||
|
*/
|
||||||
|
import { defineComponent, ref, inject, h } from 'vue';
|
||||||
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
|
|
||||||
|
const TestPrintButton = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const api = useApi();
|
||||||
|
const values = inject('values', ref({}));
|
||||||
|
const busy = ref(false);
|
||||||
|
const msg = ref('');
|
||||||
|
const err = ref('');
|
||||||
|
|
||||||
|
async function ausloesen() {
|
||||||
|
const id = values && values.value ? values.value.id : null;
|
||||||
|
if (!id) { err.value = 'Bitte den Datensatz zuerst speichern.'; return; }
|
||||||
|
busy.value = true; err.value = ''; msg.value = '';
|
||||||
|
try {
|
||||||
|
const res = await api.post('/skrift-orders/test-print', { id });
|
||||||
|
msg.value = `Generiert & eingereiht (Job ${res.data?.job}, Ordner ${res.data?.ref}).`;
|
||||||
|
} catch (e) {
|
||||||
|
const d = e && e.response && e.response.data;
|
||||||
|
err.value = (d && (d.error + (d.detail ? ' – ' + d.detail : ''))) || (e && e.message) || 'Fehler.';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => h('div', { style: 'display:flex;flex-direction:column;gap:8px;' }, [
|
||||||
|
h('button', {
|
||||||
|
type: 'button', disabled: busy.value, onClick: ausloesen,
|
||||||
|
style: 'align-self:flex-start;cursor:pointer;border:1px solid var(--primary);background:var(--primary);color:#fff;border-radius:6px;padding:8px 18px;font:inherit;',
|
||||||
|
}, busy.value ? 'Wird generiert …' : 'Generieren & drucken'),
|
||||||
|
msg.value ? h('div', { style: 'color:var(--success, #2e7d32);font-size:13px;' }, msg.value) : null,
|
||||||
|
err.value ? h('div', { style: 'color:var(--danger, #c62828);font-size:13px;' }, err.value) : null,
|
||||||
|
h('div', { style: 'color:var(--foreground-subdued);font-size:12px;' },
|
||||||
|
'Hinweis: Datensatz mit Template, Format, Maschine und Text speichern, dann klicken.'),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: 'skrift-testprint-button',
|
||||||
|
name: 'Testdruck',
|
||||||
|
icon: 'science',
|
||||||
|
description: 'Generiert einen Testdruck und reiht ihn zum Drucken ein.',
|
||||||
|
component: TestPrintButton,
|
||||||
|
types: ['alias'],
|
||||||
|
localTypes: ['presentation'],
|
||||||
|
group: 'presentation',
|
||||||
|
options: null,
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "directus-extension-skrift-testprint",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Interface: generiert einen Testdruck aus einem Template + Text und reiht ihn zum Drucken ein (POST /skrift-orders/test-print).",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "interface",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "dist/index.js",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,9 +54,12 @@ function buildDocuments(order, entries) {
|
|||||||
|
|
||||||
entries.forEach((entry, i) => {
|
entries.forEach((entry, i) => {
|
||||||
const placeholders = placeholdersOf(entry);
|
const placeholders = placeholdersOf(entry);
|
||||||
|
// Dateinummer = Briefnummer (1-basiert), damit letter_001.svg zu
|
||||||
|
// Briefnummer 1 gehört. Vorschau nummeriert identisch (siehe skrift-orders).
|
||||||
|
const nr = Number(entry.letter_number) || (i + 1);
|
||||||
|
|
||||||
documents.push({
|
documents.push({
|
||||||
index: i,
|
index: nr,
|
||||||
type: 'letter',
|
type: 'letter',
|
||||||
text: order.text_template || '',
|
text: order.text_template || '',
|
||||||
format: letterFormat,
|
format: letterFormat,
|
||||||
@@ -66,7 +69,7 @@ function buildDocuments(order, entries) {
|
|||||||
|
|
||||||
if (wantsEnvelope) {
|
if (wantsEnvelope) {
|
||||||
documents.push({
|
documents.push({
|
||||||
index: i,
|
index: nr,
|
||||||
type: 'envelope',
|
type: 'envelope',
|
||||||
envelopeType,
|
envelopeType,
|
||||||
text: envelopeType === 'recipient' ? addressText(entry) : (order.text_template || ''),
|
text: envelopeType === 'recipient' ? addressText(entry) : (order.text_template || ''),
|
||||||
@@ -123,6 +126,10 @@ async function generateFromDirectus(req, res, next) {
|
|||||||
envelopeBeschriftung: wantsEnvelope ? order.envelope_labeling : null,
|
envelopeBeschriftung: wantsEnvelope ? order.envelope_labeling : null,
|
||||||
directusOrderId: orderId,
|
directusOrderId: orderId,
|
||||||
customerEmail: order.customer_email,
|
customerEmail: order.customer_email,
|
||||||
|
// Vorschau-Session → gezeigte Dokumente 1:1 übernehmen.
|
||||||
|
previewSession: order.preview_session || null,
|
||||||
|
// Eingegebener Brieftext → als brieftext.txt im Ausgabeordner ablegen.
|
||||||
|
rawText: order.text_template || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -261,26 +261,43 @@ async function runGeneration({ orderNumber, letters, metadata }) {
|
|||||||
// Step 4: Generate SVG files - separate letters and envelopes
|
// Step 4: Generate SVG files - separate letters and envelopes
|
||||||
const generatedLetters = [];
|
const generatedLetters = [];
|
||||||
const generatedEnvelopes = [];
|
const generatedEnvelopes = [];
|
||||||
|
// Vorschau-Session (falls vorhanden): bereits gezeigte Dokumente werden 1:1
|
||||||
|
// aus dem Vorschau-Cache übernommen, damit der Kunde exakt das gedruckte
|
||||||
|
// Ergebnis erhält, das er gesehen hat (gleiche Zufalls-Handschrift).
|
||||||
|
const previewSession = (metadata || {}).previewSession;
|
||||||
|
const previewsRoot = config.paths.previews;
|
||||||
|
|
||||||
for (let i = 0; i < processedLetters.length; i++) {
|
for (let i = 0; i < processedLetters.length; i++) {
|
||||||
const letter = processedLetters[i];
|
const letter = processedLetters[i];
|
||||||
// Verwende loopIndex für scriptalizedMap Zuordnung
|
const absoluteIndex = letter.index !== undefined ? letter.index : i;
|
||||||
const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText;
|
const isEnv = letter.type === 'envelope';
|
||||||
|
const filename = `${isEnv ? 'envelope' : 'letter'}_${String(absoluteIndex).padStart(3, '0')}.svg`;
|
||||||
let svgResult;
|
const filepath = isEnv ? path.join(envelopesDir, filename) : path.join(outputDir, filename);
|
||||||
|
|
||||||
|
// 1:1 aus der Vorschau übernehmen, wenn für dieses Dokument eine
|
||||||
|
// Vorschau-Datei existiert.
|
||||||
|
if (previewSession) {
|
||||||
|
const cached = path.join(previewsRoot, previewSession, filename);
|
||||||
try {
|
try {
|
||||||
if (letter.type === 'envelope') {
|
await fs.access(cached);
|
||||||
|
await fs.copyFile(cached, filepath);
|
||||||
|
(isEnv ? generatedEnvelopes : generatedLetters).push(filename);
|
||||||
|
console.log(`[Order] ${filename} 1:1 aus der Vorschau übernommen.`);
|
||||||
|
continue;
|
||||||
|
} catch { /* keine Vorschau-Datei → normal rendern */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText;
|
||||||
|
let svgResult;
|
||||||
|
try {
|
||||||
|
if (isEnv) {
|
||||||
svgResult = generateEnvelopeSVG(
|
svgResult = generateEnvelopeSVG(
|
||||||
scriptalizedText,
|
scriptalizedText, letter.format, letter.envelopeType,
|
||||||
letter.format,
|
|
||||||
letter.envelopeType,
|
|
||||||
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false }
|
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
svgResult = generateLetterSVG(
|
svgResult = generateLetterSVG(
|
||||||
scriptalizedText,
|
scriptalizedText, letter.format,
|
||||||
letter.format,
|
|
||||||
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, signatures: letter.signatures || {} }
|
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, signatures: letter.signatures || {} }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -292,20 +309,8 @@ async function runGeneration({ orderNumber, letters, metadata }) {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save SVG file - unterscheide zwischen Briefen und Umschlägen
|
|
||||||
const absoluteIndex = letter.index !== undefined ? letter.index : i;
|
|
||||||
|
|
||||||
if (letter.type === 'envelope') {
|
|
||||||
const filename = `envelope_${String(absoluteIndex).padStart(3, '0')}.svg`;
|
|
||||||
const filepath = path.join(envelopesDir, filename);
|
|
||||||
await fs.writeFile(filepath, svgResult.svg, 'utf8');
|
await fs.writeFile(filepath, svgResult.svg, 'utf8');
|
||||||
generatedEnvelopes.push(filename);
|
(isEnv ? generatedEnvelopes : generatedLetters).push(filename);
|
||||||
} else {
|
|
||||||
const filename = `letter_${String(absoluteIndex).padStart(3, '0')}.svg`;
|
|
||||||
const filepath = path.join(outputDir, filename);
|
|
||||||
await fs.writeFile(filepath, svgResult.svg, 'utf8');
|
|
||||||
generatedLetters.push(filename);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Order] Generated ${generatedLetters.length} letters, ${generatedEnvelopes.length} envelopes`);
|
console.log(`[Order] Generated ${generatedLetters.length} letters, ${generatedEnvelopes.length} envelopes`);
|
||||||
@@ -367,6 +372,12 @@ async function runGeneration({ orderNumber, letters, metadata }) {
|
|||||||
const orderMetadataPath = path.join(outputDir, 'order-metadata.json');
|
const orderMetadataPath = path.join(outputDir, 'order-metadata.json');
|
||||||
await fs.writeFile(orderMetadataPath, JSON.stringify(orderMetadata, null, 2), 'utf8');
|
await fs.writeFile(orderMetadataPath, JSON.stringify(orderMetadata, null, 2), 'utf8');
|
||||||
|
|
||||||
|
// Eingegebenen Text als reine Textdatei mitablegen (falls übergeben).
|
||||||
|
if (meta.rawText) {
|
||||||
|
try { await fs.writeFile(path.join(outputDir, 'brieftext.txt'), String(meta.rawText), 'utf8'); }
|
||||||
|
catch (e) { console.warn('[Order] brieftext.txt nicht geschrieben:', e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[Order] Order ${orderNumber} generated successfully`);
|
console.log(`[Order] Order ${orderNumber} generated successfully`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ async function fetchOrder(orderId) {
|
|||||||
const fields = [
|
const fields = [
|
||||||
'id', 'order_number', 'customer_email', 'font', 'status',
|
'id', 'order_number', 'customer_email', 'font', 'status',
|
||||||
'needs_envelope', 'envelope_labeling', 'envelope_format',
|
'needs_envelope', 'envelope_labeling', 'envelope_format',
|
||||||
'text_template', 'motif_mode', 'own_cards', 'entries_count',
|
'text_template', 'motif_mode', 'own_cards', 'entries_count', 'preview_session',
|
||||||
'product.key', 'product.type', 'format.key',
|
'source_file', 'product.key', 'product.type', 'product.input_mode', 'format.key',
|
||||||
].join(',');
|
].join(',');
|
||||||
|
|
||||||
const order = await directusFetch(`/items/orders/${encodeURIComponent(orderId)}?fields=${fields}`);
|
const order = await directusFetch(`/items/orders/${encodeURIComponent(orderId)}?fields=${fields}`);
|
||||||
|
|||||||
@@ -48,19 +48,30 @@ function generatePlaceholderCSV(letters) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sammle alle unique Keys
|
// Keys in Auftrittsreihenfolge sammeln (NICHT alphabetisch sortieren – sonst
|
||||||
const allKeys = new Set();
|
// stimmt die Spaltenreihenfolge nicht mit der Tabelle auf der Website überein).
|
||||||
|
const auftritt = [];
|
||||||
letterItems.forEach(letter => {
|
letterItems.forEach(letter => {
|
||||||
if (letter.placeholders) {
|
if (letter.placeholders) {
|
||||||
Object.keys(letter.placeholders).forEach(key => allKeys.add(key));
|
Object.keys(letter.placeholders).forEach(key => {
|
||||||
|
if (!auftritt.includes(key)) auftritt.push(key);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (allKeys.size === 0) {
|
if (auftritt.length === 0) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = ['BriefNr', ...Array.from(allKeys).sort()];
|
// Strukturierte Adressfelder zuerst (wie im Konfigurator), eigene Platzhalter
|
||||||
|
// danach in Auftrittsreihenfolge. Briefnummer steckt bereits in „BriefNr".
|
||||||
|
const BEVORZUGT = ['Anrede', 'Vorname', 'Nachname', 'Strasse', 'PLZ', 'Ort', 'Land'];
|
||||||
|
const vorhanden = new Set(auftritt);
|
||||||
|
const geordnet = [
|
||||||
|
...BEVORZUGT.filter(k => vorhanden.has(k)),
|
||||||
|
...auftritt.filter(k => !BEVORZUGT.includes(k) && k !== 'Briefnummer'),
|
||||||
|
];
|
||||||
|
const keys = ['BriefNr', ...geordnet];
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
const header = keys.join(',');
|
const header = keys.join(',');
|
||||||
|
|||||||
44
Produktion/skrift-agent/README.md
Normal file
44
Produktion/skrift-agent/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Skrift Produktions-Agent
|
||||||
|
|
||||||
|
Schlanker lokaler Agent (Windows) neben dem Plotter. Steuerung läuft über
|
||||||
|
Directus – der Agent macht nur: **Heartbeat → Job holen → drucken → melden**.
|
||||||
|
|
||||||
|
## Einrichtung
|
||||||
|
|
||||||
|
1. In Directus einen Nutzer der Rolle **Service** anlegen und einen **statischen
|
||||||
|
Token** erzeugen.
|
||||||
|
2. Die Maschine in Directus unter **Einstellungen → Maschinen** anlegen
|
||||||
|
(`base_url`, ggf. `mid`/Login). Die numerische ID der Maschine ist die
|
||||||
|
`machine_id` für die Agent-Config.
|
||||||
|
3. `config.example.json` → `config.json` kopieren und ausfüllen
|
||||||
|
(`directus_url`, `directus_token`, `machine_id`).
|
||||||
|
4. Abhängigkeiten installieren und starten:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\pip install -r requirements.txt
|
||||||
|
.venv\Scripts\python agent.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
- Der Agent meldet zyklisch `machines.status` + `last_seen` (Heartbeat).
|
||||||
|
- Er zieht den nächsten `jobs`-Eintrag seiner Maschine mit `status=queued`
|
||||||
|
(sortiert nach `priority`, dann Alter).
|
||||||
|
- Er lädt die SVGs des Auftrags passend zum Job-Typ (Schriftstück/Umschlag)
|
||||||
|
über den Directus-Datei-Endpoint (`/skrift-orders/files/...`) und schickt sie
|
||||||
|
mit den Template-Werten (Maße/Position/Rotation) an den Plotter.
|
||||||
|
- Status zurück: `printing → printed` bzw. `failed` (+ Fehlermeldung).
|
||||||
|
- Bei Maschinenfehler (z. B. Papierende) ruft er `recover_write` auf.
|
||||||
|
|
||||||
|
## Jobs entstehen
|
||||||
|
|
||||||
|
Über den Admin-Trigger `POST /skrift-orders/enqueue { order, machine }`
|
||||||
|
(bzw. später ein Button in der Webapp). Pro Auftrag entsteht ein Brief-Job und
|
||||||
|
– falls Kuvert beschriftet – ein Umschlag-Job, jeweils mit aufgelöstem Template.
|
||||||
|
|
||||||
|
## Signaturen / AutoRecover
|
||||||
|
|
||||||
|
Der Signatur-Service bleibt vorerst unberührt. Das win32-`AutoRecover`
|
||||||
|
(klickt das Java-Fenster) kann weiter separat laufen; der Agent nutzt zusätzlich
|
||||||
|
den API-`recover_write` der Maschine.
|
||||||
294
Produktion/skrift-agent/agent.py
Normal file
294
Produktion/skrift-agent/agent.py
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
"""
|
||||||
|
Skrift – schlanker Produktions-Agent
|
||||||
|
====================================
|
||||||
|
Läuft lokal (Windows) neben dem Plotter. Er macht bewusst nur drei Dinge:
|
||||||
|
|
||||||
|
1. Heartbeat – meldet den Maschinenzustand an Directus (machines.status/last_seen)
|
||||||
|
2. Poll – holt den nächsten Druck-Job seiner Maschine (jobs, status=queued)
|
||||||
|
3. Drucken – lädt die SVGs vom Directus-Datei-Endpoint, schickt sie an den
|
||||||
|
Plotter und meldet den Status zurück (printing → printed/failed)
|
||||||
|
|
||||||
|
Alles andere (Aufträge, Warteschlange, Templates, Maschinenpflege) steuerst du in
|
||||||
|
Directus / der Webapp. Der Agent kennt nur seine eine `machine`-ID.
|
||||||
|
|
||||||
|
Konfiguration: config.json (siehe config.example.json).
|
||||||
|
Start: python agent.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import tempfile
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
HIER = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def lade_config():
|
||||||
|
pfad = os.path.join(HIER, "config.json")
|
||||||
|
if not os.path.exists(pfad):
|
||||||
|
print("config.json fehlt – bitte config.example.json kopieren und ausfüllen.")
|
||||||
|
sys.exit(1)
|
||||||
|
with open(pfad, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
class Directus:
|
||||||
|
"""Minimaler Directus-Client (Service-Token)."""
|
||||||
|
|
||||||
|
def __init__(self, base_url, token):
|
||||||
|
self.base = base_url.rstrip("/")
|
||||||
|
self.s = requests.Session()
|
||||||
|
self.s.headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
|
def get(self, path, **params):
|
||||||
|
r = self.s.get(self.base + path, params=params, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json().get("data")
|
||||||
|
|
||||||
|
def patch(self, path, body):
|
||||||
|
r = self.s.patch(self.base + path, json=body, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json().get("data")
|
||||||
|
|
||||||
|
def download(self, path, params):
|
||||||
|
r = self.s.get(self.base + path, params=params, timeout=120)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.content
|
||||||
|
|
||||||
|
|
||||||
|
class Plotter:
|
||||||
|
"""Sendet SVGs an die Maschinen-API (Auszug aus der bisherigen App)."""
|
||||||
|
|
||||||
|
def __init__(self, machine):
|
||||||
|
self.base = str(machine.get("base_url", "")).rstrip("/")
|
||||||
|
self.mid = machine.get("mid") or ""
|
||||||
|
self.user = machine.get("username") or ""
|
||||||
|
self.passwd = machine.get("password") or ""
|
||||||
|
self.s = requests.Session()
|
||||||
|
|
||||||
|
def login(self):
|
||||||
|
if not self.user:
|
||||||
|
return True
|
||||||
|
r = self.s.get(f"{self.base}/login", params={"uin": self.user, "passwd": self.passwd}, timeout=10)
|
||||||
|
return r.json().get("error", -1) == 0
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
params = {"mids": self.mid} if self.mid else {}
|
||||||
|
try:
|
||||||
|
r = self.s.get(f"{self.base}/machine_status", params=params, timeout=10)
|
||||||
|
data = r.json()
|
||||||
|
return data[0] if isinstance(data, list) and data else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def recover(self):
|
||||||
|
params = {"mid": self.mid} if self.mid else {}
|
||||||
|
try:
|
||||||
|
r = self.s.get(f"{self.base}/recover_write", params=params, timeout=10)
|
||||||
|
return r.json().get("code", -1) == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fmt(v):
|
||||||
|
v = float(v or 0)
|
||||||
|
return str(int(v)) if v == int(v) else str(v)
|
||||||
|
|
||||||
|
def write(self, dateien, tpl):
|
||||||
|
"""dateien: [(name, bytes)]; tpl: Template-Dict mit width/height/... (Zoll)."""
|
||||||
|
basis = {
|
||||||
|
"width": f"{self._fmt(tpl.get('width'))}in",
|
||||||
|
"height": f"{self._fmt(tpl.get('height'))}in",
|
||||||
|
"xpos": f"{self._fmt(tpl.get('xpos'))}in",
|
||||||
|
"ypos": f"{self._fmt(tpl.get('ypos'))}in",
|
||||||
|
"rotation": self._fmt(tpl.get("rotation")),
|
||||||
|
"scale": self._fmt(tpl.get("scale") if tpl.get("scale") is not None else 1),
|
||||||
|
"repeat": "1",
|
||||||
|
}
|
||||||
|
if self.mid:
|
||||||
|
basis["mid"] = self.mid
|
||||||
|
letzte = len(dateien) - 1
|
||||||
|
antwort = {}
|
||||||
|
for i, (name, inhalt) in enumerate(dateien):
|
||||||
|
data = {**basis, "clear": "1" if i == 0 else "0", "start": "1" if i == letzte else "0"}
|
||||||
|
files = [("file", (name, io.BytesIO(inhalt), "image/svg+xml"))]
|
||||||
|
r = self.s.post(f"{self.base}/write_svg", data=data, files=files, timeout=60)
|
||||||
|
antwort = r.json()
|
||||||
|
if antwort.get("error", -1) != 0:
|
||||||
|
raise RuntimeError(f"Maschine meldet Fehler {antwort.get('error')} bei {name}")
|
||||||
|
return antwort
|
||||||
|
|
||||||
|
|
||||||
|
def jetzt():
|
||||||
|
return dt.datetime.now(dt.timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_nummern(spec):
|
||||||
|
"""„1-3,5,7-8" → {1,2,3,5,7,8}. Leer/None → None (= alle drucken)."""
|
||||||
|
if not spec or not str(spec).strip():
|
||||||
|
return None
|
||||||
|
treffer = set()
|
||||||
|
for teil in str(spec).split(","):
|
||||||
|
teil = teil.strip()
|
||||||
|
if not teil:
|
||||||
|
continue
|
||||||
|
if "-" in teil:
|
||||||
|
try:
|
||||||
|
a, b = teil.split("-", 1)
|
||||||
|
a, b = int(a), int(b)
|
||||||
|
treffer.update(range(min(a, b), max(a, b) + 1))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
treffer.add(int(teil))
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return treffer or None
|
||||||
|
|
||||||
|
|
||||||
|
def datei_nummer(name):
|
||||||
|
"""Zahl aus einem Dateinamen ziehen (letter_003.svg → 3)."""
|
||||||
|
ziffern = "".join(ch for ch in os.path.splitext(os.path.basename(name))[0] if ch.isdigit())
|
||||||
|
return int(ziffern) if ziffern else None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = lade_config()
|
||||||
|
dx = Directus(cfg["directus_url"], cfg["directus_token"])
|
||||||
|
machine_id = cfg["machine_id"]
|
||||||
|
poll = int(cfg.get("poll_interval_seconds", 5))
|
||||||
|
|
||||||
|
print(f"[Agent] gestartet für Maschine {machine_id} gegen {cfg['directus_url']}")
|
||||||
|
|
||||||
|
KIND = {"brief": "schriftstueck", "umschlag": "umschlag", "signatur": "signatur"}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
maschine = dx.get(f"/items/machines/{machine_id}",
|
||||||
|
fields="base_url,mid,username,password,active")
|
||||||
|
if not maschine or not maschine.get("active", True):
|
||||||
|
_heartbeat(dx, machine_id, "offline")
|
||||||
|
time.sleep(poll)
|
||||||
|
continue
|
||||||
|
|
||||||
|
plotter = Plotter(maschine)
|
||||||
|
mstatus = plotter.status()
|
||||||
|
# Bei Fehler (z. B. Papierende) automatisch fortsetzen.
|
||||||
|
if mstatus and str(mstatus.get("status", "")).lower() in ("error", "fehler"):
|
||||||
|
plotter.recover()
|
||||||
|
_heartbeat(dx, machine_id, "bereit")
|
||||||
|
|
||||||
|
job = _naechster_job(dx, machine_id)
|
||||||
|
if not job:
|
||||||
|
time.sleep(poll)
|
||||||
|
continue
|
||||||
|
|
||||||
|
_druck(dx, plotter, job, KIND)
|
||||||
|
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
print(f"[Agent] HTTP-Fehler: {e}")
|
||||||
|
time.sleep(poll)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Agent] Fehler: {e}")
|
||||||
|
time.sleep(poll)
|
||||||
|
|
||||||
|
|
||||||
|
def _heartbeat(dx, machine_id, status):
|
||||||
|
try:
|
||||||
|
dx.patch(f"/items/machines/{machine_id}", {"status": status, "last_seen": jetzt()})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _naechster_job(dx, machine_id):
|
||||||
|
jobs = dx.get(
|
||||||
|
"/items/jobs",
|
||||||
|
**{
|
||||||
|
"filter[machine][_eq]": machine_id,
|
||||||
|
"filter[status][_eq]": "queued",
|
||||||
|
"sort": "-priority,date_created",
|
||||||
|
"limit": 1,
|
||||||
|
"fields": "id,type,output_ref,params,numbers,"
|
||||||
|
"order.id,order.order_number,order.needs_envelope,"
|
||||||
|
"template.width,template.height,"
|
||||||
|
"template.xpos,template.ypos,template.scale,template.rotation",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return jobs[0] if jobs else None
|
||||||
|
|
||||||
|
|
||||||
|
def _druck(dx, plotter, job, KIND):
|
||||||
|
job_id = job["id"]
|
||||||
|
order = job.get("order") or {}
|
||||||
|
order_id = order.get("id")
|
||||||
|
# Ordner: entweder Auftragsnummer oder ein output_ref (z. B. Testdruck).
|
||||||
|
order_nr = job.get("output_ref") or order.get("order_number")
|
||||||
|
tpl = dict(job.get("template") or {})
|
||||||
|
# Pro-Druck-Übersteuerung der Maße (aus dem Druck-Popup) hat Vorrang.
|
||||||
|
params = job.get("params")
|
||||||
|
if isinstance(params, dict):
|
||||||
|
for k in ("width", "height", "xpos", "ypos", "scale", "rotation"):
|
||||||
|
if params.get(k) is not None:
|
||||||
|
tpl[k] = params[k]
|
||||||
|
nummern = parse_nummern(job.get("numbers"))
|
||||||
|
typ = job.get("type") or "brief"
|
||||||
|
print(f"[Agent] Job {job_id}: {typ} für {order_nr}")
|
||||||
|
|
||||||
|
dx.patch(f"/items/jobs/{job_id}", {"status": "printing", "error": None})
|
||||||
|
# Produktionsstatus fürs Kanban: sobald gedruckt wird → „im_druck".
|
||||||
|
if order_id:
|
||||||
|
try: dx.patch(f"/items/orders/{order_id}", {"production_status": "im_druck"})
|
||||||
|
except Exception: pass
|
||||||
|
try:
|
||||||
|
if not order_nr:
|
||||||
|
raise RuntimeError("Auftragsnummer fehlt am Job.")
|
||||||
|
liste = dx.get(f"/skrift-orders/files/{order_nr}") or {}
|
||||||
|
gewuenscht = KIND.get(typ, "schriftstueck")
|
||||||
|
passende = sorted(
|
||||||
|
[f for f in (liste.get("files") or []) if f.get("kind") == gewuenscht],
|
||||||
|
key=lambda f: f.get("name", ""),
|
||||||
|
)
|
||||||
|
# Nur bestimmte Briefnummern drucken, falls im Job angegeben.
|
||||||
|
if nummern is not None:
|
||||||
|
passende = [f for f in passende if datei_nummer(f.get("name")) in nummern]
|
||||||
|
if not passende:
|
||||||
|
raise RuntimeError(f"Keine Dateien vom Typ '{gewuenscht}' für {order_nr} gefunden.")
|
||||||
|
|
||||||
|
dateien = []
|
||||||
|
for f in passende:
|
||||||
|
inhalt = dx.download(f"/skrift-orders/files/{order_nr}/download", {"rel": f["rel"]})
|
||||||
|
dateien.append((f["name"], inhalt))
|
||||||
|
|
||||||
|
plotter.login()
|
||||||
|
plotter.write(dateien, tpl)
|
||||||
|
|
||||||
|
dx.patch(f"/items/jobs/{job_id}", {"status": "printed"})
|
||||||
|
# Indikator am Auftrag setzen (Schriftstück bzw. Umschlag gedruckt) und
|
||||||
|
# bei Vollständigkeit automatisch in die Kuvertierung schieben.
|
||||||
|
if order_id:
|
||||||
|
feld = "kuvert_gedruckt" if typ == "umschlag" else "brief_gedruckt"
|
||||||
|
try:
|
||||||
|
dx.patch(f"/items/orders/{order_id}", {feld: True})
|
||||||
|
stand = dx.get(f"/items/orders/{order_id}",
|
||||||
|
fields="brief_gedruckt,kuvert_gedruckt,needs_envelope,production_status") or {}
|
||||||
|
braucht_kuvert = bool(stand.get("needs_envelope"))
|
||||||
|
fertig = stand.get("brief_gedruckt") and (stand.get("kuvert_gedruckt") or not braucht_kuvert)
|
||||||
|
if fertig and stand.get("production_status") == "im_druck":
|
||||||
|
dx.patch(f"/items/orders/{order_id}", {"production_status": "kuvertieren"})
|
||||||
|
print(f"[Agent] Auftrag {order_nr} vollständig gedruckt → Kuvertierung.")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print(f"[Agent] Job {job_id} gedruckt ({len(dateien)} Dateien).")
|
||||||
|
except Exception as e:
|
||||||
|
dx.patch(f"/items/jobs/{job_id}", {"status": "failed", "error": str(e)[:1000]})
|
||||||
|
print(f"[Agent] Job {job_id} fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
6
Produktion/skrift-agent/config.example.json
Normal file
6
Produktion/skrift-agent/config.example.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"directus_url": "https://admin.skrift.de",
|
||||||
|
"directus_token": "SERVICE-TOKEN-HIER-EINTRAGEN",
|
||||||
|
"machine_id": 1,
|
||||||
|
"poll_interval_seconds": 5
|
||||||
|
}
|
||||||
1
Produktion/skrift-agent/requirements.txt
Normal file
1
Produktion/skrift-agent/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
requests>=2.31
|
||||||
9
Webseite/skrift-app/.dockerignore
Normal file
9
Webseite/skrift-app/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.log
|
||||||
|
README.md
|
||||||
4
Webseite/skrift-app/.env.example
Normal file
4
Webseite/skrift-app/.env.example
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Kopieren zu .env.local (wird nicht committet)
|
||||||
|
|
||||||
|
# Directus-Instanz (Daten, Auth, Bestellungen)
|
||||||
|
VITE_DIRECTUS_URL=https://admin.skrift.de
|
||||||
30
Webseite/skrift-app/Dockerfile
Normal file
30
Webseite/skrift-app/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Mehrstufig: Node baut die App, nginx liefert nur noch statische Dateien aus.
|
||||||
|
# Im Endimage ist kein Node und kein node_modules mehr enthalten.
|
||||||
|
|
||||||
|
# ── Build ───────────────────────────────────────────────────
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Die Directus-URL wird zur BUILD-Zeit ins Bundle geschrieben (Vite-Prinzip),
|
||||||
|
# nicht zur Laufzeit. Daher als Build-Argument.
|
||||||
|
ARG VITE_DIRECTUS_URL
|
||||||
|
ENV VITE_DIRECTUS_URL=$VITE_DIRECTUS_URL
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Auslieferung ────────────────────────────────────────────
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# BusyBox-wget (in nginx:alpine) kennt --spider/--tries nicht zuverlässig –
|
||||||
|
# stattdessen die Startseite nach /dev/null laden. Schlägt nur fehl, wenn
|
||||||
|
# nginx wirklich nicht antwortet.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -q -O /dev/null http://127.0.0.1/ || exit 1
|
||||||
29
Webseite/skrift-app/docker-compose.yml
Normal file
29
Webseite/skrift-app/docker-compose.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Skrift-App (Konfigurator + Portal) hinter dem Nginx Proxy Manager.
|
||||||
|
#
|
||||||
|
# Im NPM als Proxy Host anlegen:
|
||||||
|
# Domain Names : app.skrift.de
|
||||||
|
# Scheme : http
|
||||||
|
# Forward Hostname : skrift-app
|
||||||
|
# Forward Port : 80
|
||||||
|
# SSL : Let's-Encrypt-Zertifikat aktivieren
|
||||||
|
#
|
||||||
|
# WICHTIG: VITE_DIRECTUS_URL wird beim BUILD ins Bundle geschrieben.
|
||||||
|
# Nach einer Änderung der URL muss neu gebaut werden (nicht nur neu gestartet).
|
||||||
|
|
||||||
|
services:
|
||||||
|
skrift-app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
VITE_DIRECTUS_URL: ${VITE_DIRECTUS_URL:-https://admin.skrift.de}
|
||||||
|
container_name: skrift-app
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
|
networks:
|
||||||
|
- npm
|
||||||
|
|
||||||
|
networks:
|
||||||
|
npm:
|
||||||
|
name: nginx-proxy-manager_default
|
||||||
|
external: true
|
||||||
23
Webseite/skrift-app/index.html
Normal file
23
Webseite/skrift-app/index.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!-- Konfigurator/Portal sind Anwendung, kein Suchmaschinen-Inhalt. -->
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<title>Skrift</title>
|
||||||
|
<meta name="description" content="Handschriftliche Briefe und Postkarten – konfigurieren und Aufträge verwalten." />
|
||||||
|
<meta name="theme-color" content="#1E3A6A" />
|
||||||
|
|
||||||
|
<!-- Icons liegen in public/ – siehe public/README.md -->
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="alternate icon" href="/favicon.ico" sizes="32x32" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<!-- Schriften werden lokal ausgeliefert (siehe public/fonts) – keine externen Anfragen. -->
|
||||||
|
<link rel="preload" href="/fonts/work-sans-variable.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
42
Webseite/skrift-app/nginx.conf
Normal file
42
Webseite/skrift-app/nginx.conf
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
|
||||||
|
# Sicherheits-Header (TLS terminiert der Nginx Proxy Manager davor)
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Gehashte Assets dürfen dauerhaft gecacht werden.
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Schriften: lokal ausgeliefert, lange cachebar.
|
||||||
|
location /fonts/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
types {
|
||||||
|
font/woff2 woff2;
|
||||||
|
font/ttf ttf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# index.html nie cachen – sonst sehen Nutzer nach einem Deploy die alte App.
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-store, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA-Fallback: /portal, /login usw. existieren nicht als Dateien.
|
||||||
|
# Ohne diese Zeile führt ein Reload auf einer Unterseite zu 404.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
1884
Webseite/skrift-app/package-lock.json
generated
Normal file
1884
Webseite/skrift-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
Webseite/skrift-app/package.json
Normal file
25
Webseite/skrift-app/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "skrift-app",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "Skrift – Konfigurator und Kundenportal (eine App).",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.2",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
31
Webseite/skrift-app/public/README.md
Normal file
31
Webseite/skrift-app/public/README.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# public/ – statische Dateien
|
||||||
|
|
||||||
|
Alles hier wird **unverändert** unter dem Wurzelpfad ausgeliefert:
|
||||||
|
`public/favicon.svg` → `https://app.skrift.de/favicon.svg`.
|
||||||
|
|
||||||
|
Das ist der richtige Ort für Dateien mit **festem Namen** (Icons, robots.txt).
|
||||||
|
Grafiken, die in Komponenten importiert werden, gehören dagegen nach
|
||||||
|
`src/assets/` – die bekommen beim Build einen Hash und sind damit cache-sicher.
|
||||||
|
|
||||||
|
## Hier ablegen
|
||||||
|
|
||||||
|
| Datei | Zweck | Empfehlung |
|
||||||
|
|---|---|---|
|
||||||
|
| `logo.svg` | Logo in der Kopfzeile | SVG, einfarbig in `#1E3A6A`, Höhe ~28 px optisch |
|
||||||
|
| `favicon.svg` | Browser-Tab (moderne Browser) | quadratisch, schlichte Bildmarke – Schriftzug wird bei 16 px unlesbar |
|
||||||
|
| `favicon.ico` | Rückfall für ältere Browser | 32×32 |
|
||||||
|
| `apple-touch-icon.png` | iOS-Startbildschirm | 180×180, ohne Transparenz |
|
||||||
|
| `fonts/` | Schriften (siehe `fonts/README.md`) | – |
|
||||||
|
|
||||||
|
## Verhalten, solange Dateien fehlen
|
||||||
|
|
||||||
|
- **Logo:** Die Kopfzeile zeigt automatisch den Schriftzug „Skrift" als Text.
|
||||||
|
Sobald `logo.svg` existiert, wird stattdessen das Bild angezeigt – kein
|
||||||
|
Codeänderung nötig.
|
||||||
|
- **Favicon:** Der Browser zeigt sein Standard-Icon. Kein Fehler.
|
||||||
|
|
||||||
|
## Icons erzeugen
|
||||||
|
|
||||||
|
Aus einem quadratischen SVG lassen sich die übrigen Formate schnell ableiten,
|
||||||
|
z. B. mit <https://realfavicongenerator.net> – dabei nur die oben genannten
|
||||||
|
Dateien behalten, den Rest braucht es nicht.
|
||||||
46
Webseite/skrift-app/public/favicon.svg
Normal file
46
Webseite/skrift-app/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
41
Webseite/skrift-app/public/fonts/README.md
Normal file
41
Webseite/skrift-app/public/fonts/README.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Schriften (lokal, DSGVO-konform)
|
||||||
|
|
||||||
|
Work Sans wird **lokal** ausgeliefert. Es darf **keine** Anfrage an
|
||||||
|
`fonts.googleapis.com` oder `fonts.gstatic.com` gehen – sonst fließt die
|
||||||
|
IP-Adresse des Besuchers ohne Einwilligung an Google.
|
||||||
|
|
||||||
|
## Benötigte Datei
|
||||||
|
|
||||||
|
Wir nutzen die **Variable Font** – eine Datei deckt alle Gewichte (100–900) ab.
|
||||||
|
Abgelegt wird sie in diesem Ordner unter einem dieser Namen:
|
||||||
|
|
||||||
|
```
|
||||||
|
work-sans-variable.woff2 ← bevorzugt (klein)
|
||||||
|
WorkSans-VariableFont_wght.ttf ← Rückfall (funktioniert, aber groß)
|
||||||
|
```
|
||||||
|
|
||||||
|
Beide sind in `src/styles/global.css` als `@font-face` hinterlegt; der Browser
|
||||||
|
nimmt automatisch die erste verfügbare.
|
||||||
|
|
||||||
|
## Empfohlen: TTF nach woff2 umwandeln
|
||||||
|
|
||||||
|
Die Original-TTF von Google ist mehrere hundert KB, als woff2 bleiben davon
|
||||||
|
meist unter 100 KB übrig – bei gleicher Qualität. Einmalig:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install fonttools brotli
|
||||||
|
fonttools ttLib.woff2 compress WorkSans-VariableFont_wght.ttf
|
||||||
|
# Ergebnis umbenennen zu: work-sans-variable.woff2
|
||||||
|
```
|
||||||
|
|
||||||
|
Wer kein Python möchte, kann die woff2-Variante auch direkt beziehen:
|
||||||
|
<https://gwfh.mranftl.com/fonts/work-sans> (Charsets `latin` + `latin-ext`).
|
||||||
|
|
||||||
|
Work Sans steht unter der SIL Open Font License 1.1 – freie Nutzung, auch
|
||||||
|
kommerziell.
|
||||||
|
|
||||||
|
## Prüfen
|
||||||
|
|
||||||
|
Nach dem Start in den DevTools unter *Netzwerk* filtern: Es darf **kein**
|
||||||
|
Request an eine Google-Domain auftauchen. Die Schrift kommt von
|
||||||
|
`/fonts/work-sans-*`.
|
||||||
BIN
Webseite/skrift-app/public/fonts/WorkSans-VariableFont_wght.ttf
Normal file
BIN
Webseite/skrift-app/public/fonts/WorkSans-VariableFont_wght.ttf
Normal file
Binary file not shown.
11
Webseite/skrift-app/public/logo.svg
Normal file
11
Webseite/skrift-app/public/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 28 KiB |
58
Webseite/skrift-app/src/App.tsx
Normal file
58
Webseite/skrift-app/src/App.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
import AppShell from './components/AppShell';
|
||||||
|
import { WizardProvider } from './lib/wizard';
|
||||||
|
import { DialogsProvider } from './lib/dialogs';
|
||||||
|
import Login from './routes/Login';
|
||||||
|
import Auftraege from './routes/Auftraege';
|
||||||
|
import Stammdaten from './routes/Stammdaten';
|
||||||
|
import Configurator from './routes/Configurator';
|
||||||
|
import MusterSeite from './routes/Muster';
|
||||||
|
import { loadSession } from './lib/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produkt-Direktlinks (Slug → Produktschlüssel): wählen das Produkt vor, der
|
||||||
|
* Konfigurator startet auf Schritt 1. Mehrere freundliche Slugs erlaubt
|
||||||
|
* (z. B. /followups zeigt auf den Schlüssel follow_ups).
|
||||||
|
*/
|
||||||
|
const PRODUKT_PFADE: Record<string, string> = {
|
||||||
|
briefe: 'briefe',
|
||||||
|
postkarten: 'postkarten',
|
||||||
|
unterschriftenservice: 'unterschriftenservice',
|
||||||
|
follow_ups: 'follow_ups',
|
||||||
|
followups: 'follow_ups',
|
||||||
|
'follow-ups': 'follow_ups',
|
||||||
|
};
|
||||||
|
|
||||||
|
function Geschuetzt({ children }: { children: React.ReactNode }) {
|
||||||
|
return loadSession() ? <>{children}</> : <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<DialogsProvider>
|
||||||
|
<WizardProvider>
|
||||||
|
<AppShell>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Configurator />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
{/* Muster als eigene Seite (kein Popup) – Direktlink /muster. */}
|
||||||
|
<Route path="/muster" element={<MusterSeite />} />
|
||||||
|
<Route
|
||||||
|
path="/auftraege"
|
||||||
|
element={<Geschuetzt><Auftraege /></Geschuetzt>}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/stammdaten"
|
||||||
|
element={<Geschuetzt><Stammdaten /></Geschuetzt>}
|
||||||
|
/>
|
||||||
|
{/* Produkt-Direktlinks – Produktwahl entfällt, Personentyp bleibt. */}
|
||||||
|
{Object.entries(PRODUKT_PFADE).map(([slug, key]) => (
|
||||||
|
<Route key={slug} path={`/${slug}`} element={<Configurator vorwahlProdukt={key} />} />
|
||||||
|
))}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</AppShell>
|
||||||
|
</WizardProvider>
|
||||||
|
</DialogsProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
497
Webseite/skrift-app/src/components/AppShell.tsx
Normal file
497
Webseite/skrift-app/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
fetchMyCustomer,
|
||||||
|
fetchProducts,
|
||||||
|
fetchStatusEvents,
|
||||||
|
getSeenAt,
|
||||||
|
loadSession,
|
||||||
|
logout,
|
||||||
|
markNotificationsSeen,
|
||||||
|
sendSampleRequest,
|
||||||
|
type Product,
|
||||||
|
type StatusEvent,
|
||||||
|
} from '../lib/api';
|
||||||
|
import { STATUS_LABEL } from '../lib/status';
|
||||||
|
import { useDialogs } from '../lib/dialogs';
|
||||||
|
import { useWizard } from '../lib/wizard';
|
||||||
|
import OfferForm from './OfferForm';
|
||||||
|
import {
|
||||||
|
IconBell,
|
||||||
|
IconCheck,
|
||||||
|
IconChevronDown,
|
||||||
|
IconClose,
|
||||||
|
IconHelp,
|
||||||
|
IconList,
|
||||||
|
IconMail,
|
||||||
|
IconMenu,
|
||||||
|
IconOffer,
|
||||||
|
IconPen,
|
||||||
|
IconPhone,
|
||||||
|
IconUser,
|
||||||
|
} from './Icons';
|
||||||
|
|
||||||
|
const KONTAKT = { mail: 'hello@skrift.de', tel: '+49 6872 2153' };
|
||||||
|
/** Routen, unter denen der Konfigurator läuft (Start + Direktlinks). */
|
||||||
|
const KONFIGURATOR_PFADE = ['/', '/briefe', '/postkarten', '/unterschriftenservice', '/follow_ups', '/followups', '/follow-ups'];
|
||||||
|
const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien', 'Niederlande', 'Frankreich'];
|
||||||
|
const istEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.trim());
|
||||||
|
|
||||||
|
function Wordmark() {
|
||||||
|
const [logoFehlt, setLogoFehlt] = useState(false);
|
||||||
|
return (
|
||||||
|
<Link to="/" className="sk-wordmark" aria-label="Skrift – Startseite">
|
||||||
|
{logoFehlt ? 'Skrift' : <img src="/logo.svg" alt="Skrift" className="sk-logo" onError={() => setLogoFehlt(true)} />}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Konto-Dropdown in der Kopfleiste: Name/Rolle + Abmelden (bzw. Anmelden). */
|
||||||
|
function UserMenu() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const angemeldet = !!loadSession();
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const [kunde, setKunde] = useState<{ name: string; email: string; rolle: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!angemeldet) return;
|
||||||
|
fetchMyCustomer()
|
||||||
|
.then((c) => {
|
||||||
|
if (!c) return;
|
||||||
|
setKunde({
|
||||||
|
name: [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email,
|
||||||
|
email: c.email,
|
||||||
|
rolle: c.person_type === 'unternehmen' ? 'Unternehmen' : 'Privatkunde',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => { /* Anzeige ist unkritisch */ });
|
||||||
|
}, [angemeldet]);
|
||||||
|
|
||||||
|
if (!angemeldet) {
|
||||||
|
return (
|
||||||
|
<Link to="/login" className="sk-btn sk-btn--ghost">
|
||||||
|
Anmelden
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button className="sk-user" onClick={() => setOffen((v) => !v)} aria-expanded={offen}>
|
||||||
|
<span className="sk-user-avatar"><IconUser size={16} /></span>
|
||||||
|
<span className="sk-user-name">{kunde?.name ?? 'Konto'}</span>
|
||||||
|
<IconChevronDown size={16} className="sk-user-caret" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{offen && (
|
||||||
|
<>
|
||||||
|
<div className="sk-menu-backdrop" onClick={() => setOffen(false)} />
|
||||||
|
<div className="sk-popover sk-popover--menu">
|
||||||
|
<div className="sk-menu-head">
|
||||||
|
<div className="sk-menu-name">{kunde?.name}</div>
|
||||||
|
<div className="sk-menu-sub">{kunde?.email}</div>
|
||||||
|
<div className="sk-menu-sub">{kunde?.rolle}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="sk-menu-item"
|
||||||
|
onClick={() => { logout(); setOffen(false); navigate('/login', { replace: true }); }}
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schlichtes Overlay für Hilfe und Angebotsanfrage. */
|
||||||
|
function Dialog({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="sk-overlay" role="dialog" aria-modal="true" aria-label={title} onClick={onClose}>
|
||||||
|
<div className="sk-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="sk-dialog-head">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<button className="sk-icon-btn" onClick={onClose} aria-label="Schließen">
|
||||||
|
<IconClose />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Hilfe({ onClose }: { onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<Dialog title="Hilfe & Kontakt" onClose={onClose}>
|
||||||
|
<p className="sk-note">Sie erreichen uns direkt – wir melden uns werktags am selben Tag.</p>
|
||||||
|
<div className="sk-contact">
|
||||||
|
<a href={`mailto:${KONTAKT.mail}`} className="sk-contact-row">
|
||||||
|
<IconMail />
|
||||||
|
<span>{KONTAKT.mail}</span>
|
||||||
|
</a>
|
||||||
|
<a href={`tel:${KONTAKT.tel.replace(/\s/g, '')}`} className="sk-contact-row">
|
||||||
|
<IconPhone />
|
||||||
|
<span>{KONTAKT.tel}</span>
|
||||||
|
</a>
|
||||||
|
</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>
|
||||||
|
<a href="https://skrift.de/impressum" target="_blank" rel="noreferrer">Impressum</a>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Angebot({ onClose, vorbelegung }: { onClose: () => void; vorbelegung?: string }) {
|
||||||
|
// vorbelegung = gewähltes Kontakt-Produkt; wird separat übermittelt, nicht im Text.
|
||||||
|
return (
|
||||||
|
<Dialog title="Individuelles Angebot anfordern" onClose={onClose}>
|
||||||
|
<OfferForm produkt={vorbelegung} onClose={onClose} />
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Muster bestellen – kostenlos, mit Produktwahl und Lieferadresse.
|
||||||
|
* variant='page' rendert als eigene Seite (Direktlink /muster) statt als Popup. */
|
||||||
|
export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: () => void; variant?: 'dialog' | 'page' }) {
|
||||||
|
const [produkte, setProdukte] = useState<Product[]>([]);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
product: '', name: '', email: '', phone: '',
|
||||||
|
street: '', zip: '', city: '', country: 'Deutschland',
|
||||||
|
});
|
||||||
|
const [datenschutz, setDatenschutz] = useState(false);
|
||||||
|
const [status, setStatus] = useState<'formular' | 'sendet' | 'fertig'>('formular');
|
||||||
|
const [fehler, setFehler] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProducts()
|
||||||
|
.then((ps) => setProdukte(ps.filter((p) => p.type === 'letter' || p.type === 'postcard')))
|
||||||
|
.catch(() => setProdukte([]));
|
||||||
|
if (loadSession()) {
|
||||||
|
fetchMyCustomer()
|
||||||
|
.then((c) => c && setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
name: [c.first_name, c.last_name].filter(Boolean).join(' ') || f.name,
|
||||||
|
email: c.email || f.email,
|
||||||
|
phone: c.phone || f.phone,
|
||||||
|
})))
|
||||||
|
.catch(() => { /* optional */ });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
const roh = e.target.value;
|
||||||
|
setForm({ ...form, [k]: k === 'phone' ? roh.replace(/[^0-9+ ]/g, '') : k === 'zip' ? roh.replace(/[^0-9]/g, '') : roh });
|
||||||
|
};
|
||||||
|
|
||||||
|
async function absenden(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!istEmail(form.email)) { setFehler(['Bitte geben Sie eine gültige E-Mail-Adresse an.']); return; }
|
||||||
|
setFehler([]);
|
||||||
|
setStatus('sendet');
|
||||||
|
try {
|
||||||
|
await sendSampleRequest({
|
||||||
|
product: form.product,
|
||||||
|
name: form.name.trim(),
|
||||||
|
email: form.email.trim(),
|
||||||
|
phone: form.phone.trim() || undefined,
|
||||||
|
street: form.street.trim(),
|
||||||
|
zip: form.zip.trim(),
|
||||||
|
city: form.city.trim(),
|
||||||
|
country: form.country,
|
||||||
|
});
|
||||||
|
setStatus('fertig');
|
||||||
|
} catch (err) {
|
||||||
|
setFehler(err instanceof ApiError ? (err.details.length ? err.details : [err.message]) : ['Anfrage fehlgeschlagen.']);
|
||||||
|
setStatus('formular');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rahmen je nach Modus: Popup (Dialog) oder eigene Seite (sk-view).
|
||||||
|
const wrap = (titel: string, inhalt: React.ReactNode) =>
|
||||||
|
variant === 'page'
|
||||||
|
? <div className="sk-view"><h1>{titel}</h1>{inhalt}</div>
|
||||||
|
: <Dialog title={titel} onClose={onClose}>{inhalt}</Dialog>;
|
||||||
|
|
||||||
|
if (status === 'fertig') {
|
||||||
|
return wrap('Muster ist unterwegs', (
|
||||||
|
<>
|
||||||
|
<p>Vielen Dank – Ihr kostenloses Muster wird vorbereitet und verschickt.</p>
|
||||||
|
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-4)' }} onClick={onClose}>
|
||||||
|
{variant === 'page' ? 'Zur Startseite' : 'Schließen'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
const feld = (k: keyof typeof form, label: string, opt = false, type = 'text') => (
|
||||||
|
<div className="sk-field">
|
||||||
|
<label className={`sk-label${opt ? '' : ' sk-label--required'}`} htmlFor={`m-${k}`}>
|
||||||
|
{label} {opt && <span className="sk-note" style={{ fontWeight: 400 }}>(optional)</span>}
|
||||||
|
</label>
|
||||||
|
<input id={`m-${k}`} className="sk-input" type={type} required={!opt}
|
||||||
|
value={form[k]} onChange={set(k)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const gueltig = form.product && form.name.trim() && istEmail(form.email) && form.phone.trim()
|
||||||
|
&& form.street.trim() && form.zip.trim() && form.city.trim() && datenschutz;
|
||||||
|
|
||||||
|
return wrap('Muster bestellen', (
|
||||||
|
<>
|
||||||
|
<p className="sk-note">Wir schicken Ihnen kostenlos ein Handschrift-Muster zum Anfassen.</p>
|
||||||
|
<form style={{ marginTop: 'var(--sk-space-4)' }} onSubmit={absenden}>
|
||||||
|
<div className="sk-field">
|
||||||
|
<label className="sk-label sk-label--required" htmlFor="m-product">
|
||||||
|
Von welchem Produkt möchten Sie ein Muster?
|
||||||
|
</label>
|
||||||
|
<select id="m-product" className="sk-input" required value={form.product} onChange={set('product')}>
|
||||||
|
<option value="" disabled>Bitte wählen …</option>
|
||||||
|
{produkte.map((p) => <option key={p.key} value={p.key}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{feld('name', 'Name')}
|
||||||
|
{feld('email', 'E-Mail-Adresse', false, 'email')}
|
||||||
|
{feld('phone', 'Telefon', false, 'tel')}
|
||||||
|
{feld('street', 'Straße und Hausnummer')}
|
||||||
|
{feld('zip', 'PLZ')}
|
||||||
|
{feld('city', 'Stadt')}
|
||||||
|
<div className="sk-field">
|
||||||
|
<label className="sk-label sk-label--required" htmlFor="m-country">Land</label>
|
||||||
|
<select id="m-country" className="sk-input" value={form.country} onChange={set('country')}>
|
||||||
|
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="sk-check" style={{ marginBottom: 'var(--sk-space-4)' }}>
|
||||||
|
<input type="checkbox" checked={datenschutz} onChange={(e) => setDatenschutz(e.target.checked)} />
|
||||||
|
<span>
|
||||||
|
Ich habe die{' '}
|
||||||
|
<a href="https://skrift.de/datenschutz" target="_blank" rel="noreferrer">Datenschutzerklärung</a>{' '}
|
||||||
|
zur Kenntnis genommen und bin mit der Verarbeitung meiner Daten zur Bearbeitung meiner
|
||||||
|
Anfrage einverstanden.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{fehler.length > 0 && (
|
||||||
|
<ul className="sk-error" style={{ margin: '0 0 var(--sk-space-3)', paddingLeft: '1.1em' }}>
|
||||||
|
{fehler.map((f) => <li key={f}>{f}</li>)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="sk-btn sk-btn--primary sk-btn--block" disabled={status === 'sendet' || !gueltig}>
|
||||||
|
{status === 'sendet' ? 'Wird gesendet …' : 'Muster anfordern'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
|
const { steps, current, goto } = useWizard();
|
||||||
|
const location = useLocation();
|
||||||
|
const { open } = useDialogs();
|
||||||
|
const angemeldet = !!loadSession();
|
||||||
|
|
||||||
|
// Ablauf nur im Konfigurator zeigen (Start + Direktlinks /briefe, /postkarten) –
|
||||||
|
// nicht in Aufträgen/Stammdaten.
|
||||||
|
const zeigeAblauf = KONFIGURATOR_PFADE.includes(location.pathname) && steps.length > 0;
|
||||||
|
|
||||||
|
const item = ({ isActive }: { isActive: boolean }) => `sk-nav-item${isActive ? ' sk-nav-item--active' : ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sk-sidebar">
|
||||||
|
<nav className="sk-nav">
|
||||||
|
<NavLink to="/" className={item} end onClick={onNavigate}>
|
||||||
|
<IconPen />
|
||||||
|
<span>Neuer Auftrag</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/auftraege" className={item} onClick={onNavigate}>
|
||||||
|
<IconList />
|
||||||
|
<span>Aufträge</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/stammdaten" className={item} onClick={onNavigate}>
|
||||||
|
<IconUser />
|
||||||
|
<span>Stammdaten</span>
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{zeigeAblauf && (
|
||||||
|
<div className="sk-nav-section sk-nav-section--divider">
|
||||||
|
<div className="sk-nav-label">Ablauf</div>
|
||||||
|
<ol className="sk-process">
|
||||||
|
{steps.map((label, i) => {
|
||||||
|
const erledigt = i < current;
|
||||||
|
const klasse = `sk-process-item${
|
||||||
|
i === current ? ' sk-process-item--current' : erledigt ? ' sk-process-item--done' : ''
|
||||||
|
}`;
|
||||||
|
const inhalt = (
|
||||||
|
<>
|
||||||
|
<span className="sk-process-mark">{erledigt ? <IconCheck size={14} /> : i + 1}</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
// Erledigte Schritte sind anklickbar (Rücksprung); der aktuelle und
|
||||||
|
// kommende Schritte bleiben passiv.
|
||||||
|
return (
|
||||||
|
<li key={label}>
|
||||||
|
{erledigt ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${klasse} sk-process-item--clickable`}
|
||||||
|
onClick={() => { goto(i); onNavigate?.(); }}
|
||||||
|
title="Zu diesem Schritt zurückspringen"
|
||||||
|
>
|
||||||
|
{inhalt}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className={klasse}>{inhalt}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!angemeldet && (
|
||||||
|
<p className="sk-nav-hint">
|
||||||
|
Melden Sie sich an, um Aufträge und Stammdaten zu sehen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="sk-sidebar-foot sk-nav-section--divider">
|
||||||
|
<NavLink to="/muster" className="sk-nav-item sk-nav-item--quiet" onClick={onNavigate}>
|
||||||
|
<IconMail />
|
||||||
|
<span>Muster bestellen</span>
|
||||||
|
</NavLink>
|
||||||
|
<button className="sk-nav-item sk-nav-item--quiet" onClick={() => { open('angebot'); onNavigate?.(); }}>
|
||||||
|
<IconOffer />
|
||||||
|
<span>Individuelles Angebot</span>
|
||||||
|
</button>
|
||||||
|
<button className="sk-nav-item sk-nav-item--quiet" onClick={() => { open('hilfe'); onNavigate?.(); }}>
|
||||||
|
<IconHelp />
|
||||||
|
<span>Hilfe</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benachrichtigungen speisen sich aus dem Statusverlauf der eigenen Aufträge –
|
||||||
|
* dafür braucht es keine eigene Collection. Als „gelesen" gilt alles, was älter
|
||||||
|
* ist als der zuletzt geöffnete Zeitpunkt (lokal gespeichert).
|
||||||
|
*/
|
||||||
|
function Notifications() {
|
||||||
|
const [events, setEvents] = useState<StatusEvent[]>([]);
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const [seenAt, setSeenAt] = useState(getSeenAt);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatusEvents()
|
||||||
|
.then(setEvents)
|
||||||
|
.catch(() => {
|
||||||
|
/* Benachrichtigungen sind nicht kritisch */
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ungelesen = events.filter((e) => new Date(e.timestamp).getTime() > seenAt).length;
|
||||||
|
|
||||||
|
function umschalten() {
|
||||||
|
const next = !offen;
|
||||||
|
setOffen(next);
|
||||||
|
if (next && ungelesen > 0) {
|
||||||
|
markNotificationsSeen();
|
||||||
|
setSeenAt(Date.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="sk-icon-btn"
|
||||||
|
aria-label={ungelesen ? `${ungelesen} neue Benachrichtigungen` : 'Benachrichtigungen'}
|
||||||
|
onClick={umschalten}
|
||||||
|
>
|
||||||
|
<IconBell />
|
||||||
|
{ungelesen > 0 && <span className="sk-dot" aria-hidden="true" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{offen && (
|
||||||
|
<div className="sk-popover">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="sk-note">Keine Benachrichtigungen.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="sk-notif-list">
|
||||||
|
{events.slice(0, 5).map((e) => (
|
||||||
|
<li key={e.id} className="sk-notif">
|
||||||
|
<div className="sk-notif-title">
|
||||||
|
{e.order?.order_number ?? 'Auftrag'} · {STATUS_LABEL[e.status] ?? e.status}
|
||||||
|
</div>
|
||||||
|
{e.note && <div className="sk-notif-note">{e.note}</div>}
|
||||||
|
<div className="sk-notif-time">
|
||||||
|
{new Intl.DateTimeFormat('de-DE', { dateStyle: 'short', timeStyle: 'short' }).format(
|
||||||
|
new Date(e.timestamp),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Topbar({ onMenu }: { onMenu: () => void }) {
|
||||||
|
const angemeldet = !!loadSession();
|
||||||
|
return (
|
||||||
|
<header className="sk-topbar">
|
||||||
|
<div className="sk-topbar-left">
|
||||||
|
{/* Menü-Umschalter nur auf schmalen Geräten sichtbar (CSS). */}
|
||||||
|
<button className="sk-icon-btn sk-menu-toggle" aria-label="Menü öffnen" onClick={onMenu}>
|
||||||
|
<IconMenu />
|
||||||
|
</button>
|
||||||
|
<Wordmark />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sk-topbar-right">
|
||||||
|
{angemeldet && <Notifications />}
|
||||||
|
<UserMenu />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const [drawerOffen, setDrawerOffen] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
const { dialog, vorbelegung, close } = useDialogs();
|
||||||
|
|
||||||
|
// Beim Seitenwechsel das mobile Menü schließen.
|
||||||
|
useEffect(() => { setDrawerOffen(false); }, [location.pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sk-app">
|
||||||
|
<Topbar onMenu={() => setDrawerOffen(true)} />
|
||||||
|
|
||||||
|
<div className={`sk-sidebar-wrap${drawerOffen ? ' sk-sidebar-wrap--open' : ''}`}>
|
||||||
|
<Sidebar onNavigate={() => setDrawerOffen(false)} />
|
||||||
|
</div>
|
||||||
|
{drawerOffen && <div className="sk-backdrop" onClick={() => setDrawerOffen(false)} />}
|
||||||
|
|
||||||
|
<main className="sk-content">{children}</main>
|
||||||
|
|
||||||
|
{/* Dialoge zentral – von Seitenleiste und Konfigurator aus geöffnet. */}
|
||||||
|
{dialog === 'hilfe' && <Hilfe onClose={close} />}
|
||||||
|
{dialog === 'angebot' && <Angebot onClose={close} vorbelegung={vorbelegung} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
Webseite/skrift-app/src/components/Icons.tsx
Normal file
111
Webseite/skrift-app/src/components/Icons.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Single-Line-Icons als Inline-SVG.
|
||||||
|
*
|
||||||
|
* Bewusst ohne Icon-Bibliothek: keine externe Anfrage (DSGVO), kein zusätzliches
|
||||||
|
* Bundle, und eine einheitliche Strichstärke über alle Symbole.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Props = { size?: number; className?: string };
|
||||||
|
|
||||||
|
const base = (size: number) => ({
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
strokeLinecap: 'round' as const,
|
||||||
|
strokeLinejoin: 'round' as const,
|
||||||
|
'aria-hidden': true,
|
||||||
|
focusable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const IconPen = ({ size = 18, className }: Props) => (
|
||||||
|
<svg {...base(size)} className={className}>
|
||||||
|
<path d="M12 20h9" />
|
||||||
|
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconList = ({ size = 18, className }: Props) => (
|
||||||
|
<svg {...base(size)} className={className}>
|
||||||
|
<path d="M8 6h13M8 12h13M8 18h13" />
|
||||||
|
<path d="M3 6h.01M3 12h.01M3 18h.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconUser = ({ size = 18, className }: Props) => (
|
||||||
|
<svg {...base(size)} className={className}>
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconBell = ({ size = 18, className }: Props) => (
|
||||||
|
<svg {...base(size)} className={className}>
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||||
|
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconHelp = ({ size = 18, className }: Props) => (
|
||||||
|
<svg {...base(size)} className={className}>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3" />
|
||||||
|
<path d="M12 17h.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconOffer = ({ size = 18, className }: Props) => (
|
||||||
|
<svg {...base(size)} className={className}>
|
||||||
|
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z" />
|
||||||
|
<path d="M14 3v5h5" />
|
||||||
|
<path d="M12 11v6M9 14h6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconCheck = ({ size = 18, className }: Props) => (
|
||||||
|
<svg {...base(size)} className={className}>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconMail = ({ size = 18, className }: Props) => (
|
||||||
|
<svg {...base(size)} className={className}>
|
||||||
|
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||||
|
<path d="m3 7 9 6 9-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconPhone = ({ size = 18, className }: Props) => (
|
||||||
|
<svg {...base(size)} className={className}>
|
||||||
|
<path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2 4.2 2 2 0 0 1 4 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.6a2 2 0 0 1-.5 2.1L8.1 9.6a16 16 0 0 0 6 6l1.2-1.1a2 2 0 0 1 2.1-.5c.8.3 1.7.6 2.6.7a2 2 0 0 1 1.7 2Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconClose = ({ size = 18, className }: Props) => (
|
||||||
|
<svg {...base(size)} className={className}>
|
||||||
|
<path d="M18 6 6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconBuilding = ({ size = 18, className }: Props) => (
|
||||||
|
<svg {...base(size)} className={className}>
|
||||||
|
<path d="M3 21h18" />
|
||||||
|
<path d="M5 21V5a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v16" />
|
||||||
|
<path d="M19 21V9a2 2 0 0 0-2-2h-2" />
|
||||||
|
<path d="M9 7h2M9 11h2M9 15h2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconMenu = ({ size = 18, className }: Props) => (
|
||||||
|
<svg {...base(size)} className={className}>
|
||||||
|
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IconChevronDown = ({ size = 18, className }: Props) => (
|
||||||
|
<svg {...base(size)} className={className}>
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
135
Webseite/skrift-app/src/components/OfferForm.tsx
Normal file
135
Webseite/skrift-app/src/components/OfferForm.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ApiError, fetchMyCustomer, loadSession, sendOfferRequest } from '../lib/api';
|
||||||
|
|
||||||
|
const istEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.trim());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anfrageformular „Individuelles Angebot".
|
||||||
|
*
|
||||||
|
* Wird an zwei Stellen genutzt: im Konfigurator als eigene Seite (nach der Wahl
|
||||||
|
* eines Anfrageprodukts) und in der Seitenleiste als Dialog. Deshalb rendert es
|
||||||
|
* nur den Inhalt – die Umrahmung (Karte bzw. Overlay) kommt von außen.
|
||||||
|
*
|
||||||
|
* `onClose` blendet im Dialog den Schließen-Button ein; inline (im Konfigurator)
|
||||||
|
* bleibt er weg.
|
||||||
|
*/
|
||||||
|
export default function OfferForm({ produkt, onClose }: { produkt?: string; onClose?: () => void }) {
|
||||||
|
const gewaehltesProdukt = produkt?.trim() || '';
|
||||||
|
const [form, setForm] = useState({ name: '', email: '', phone: '', quantity: '', message: '' });
|
||||||
|
const [datenschutz, setDatenschutz] = useState(false);
|
||||||
|
const [status, setStatus] = useState<'formular' | 'sendet' | 'fertig'>('formular');
|
||||||
|
const [fehler, setFehler] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Bei angemeldeten Kunden Name und E-Mail vorausfüllen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loadSession()) return;
|
||||||
|
fetchMyCustomer()
|
||||||
|
.then((c) => {
|
||||||
|
if (!c) return;
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
name: [c.first_name, c.last_name].filter(Boolean).join(' ') || f.name,
|
||||||
|
email: c.email || f.email,
|
||||||
|
phone: c.phone || f.phone,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.catch(() => { /* Vorausfüllen ist optional */ });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const roh = e.target.value;
|
||||||
|
setForm({ ...form, [k]: k === 'phone' ? roh.replace(/[^0-9+ ]/g, '') : roh });
|
||||||
|
};
|
||||||
|
|
||||||
|
async function absenden(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!istEmail(form.email)) { setFehler(['Bitte geben Sie eine gültige E-Mail-Adresse an.']); return; }
|
||||||
|
if (!form.phone.trim()) { setFehler(['Bitte geben Sie eine Telefonnummer an.']); return; }
|
||||||
|
setFehler([]);
|
||||||
|
setStatus('sendet');
|
||||||
|
try {
|
||||||
|
await sendOfferRequest({
|
||||||
|
name: form.name.trim(),
|
||||||
|
email: form.email.trim(),
|
||||||
|
phone: form.phone.trim() || undefined,
|
||||||
|
quantity: form.quantity ? Number(form.quantity) : null,
|
||||||
|
message: form.message.trim(),
|
||||||
|
product: gewaehltesProdukt || undefined,
|
||||||
|
});
|
||||||
|
setStatus('fertig');
|
||||||
|
} catch (err) {
|
||||||
|
setFehler(err instanceof ApiError ? (err.details.length ? err.details : [err.message]) : ['Anfrage konnte nicht gesendet werden.']);
|
||||||
|
setStatus('formular');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'fertig') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>Vielen Dank – wir melden uns werktags innerhalb eines Tages mit einem Angebot bei Ihnen.</p>
|
||||||
|
{onClose && (
|
||||||
|
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-4)' }} onClick={onClose}>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="sk-note">
|
||||||
|
Größere Mengen, Sonderformate, Unterschriftenservice oder automatisierte Follow-ups – schildern Sie
|
||||||
|
kurz Ihr Vorhaben.
|
||||||
|
</p>
|
||||||
|
{gewaehltesProdukt && (
|
||||||
|
<div className="sk-menu-head" style={{ borderRadius: 'var(--sk-radius)', border: '1px solid var(--sk-border)', marginTop: 'var(--sk-space-3)' }}>
|
||||||
|
<div className="sk-menu-sub">Ihre Anfrage betrifft</div>
|
||||||
|
<div className="sk-menu-name">{gewaehltesProdukt}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form style={{ marginTop: 'var(--sk-space-4)' }} onSubmit={absenden}>
|
||||||
|
<div className="sk-field">
|
||||||
|
<label className="sk-label" htmlFor="a-name">Name</label>
|
||||||
|
<input id="a-name" className="sk-input" value={form.name} onChange={set('name')} required />
|
||||||
|
</div>
|
||||||
|
<div className="sk-field">
|
||||||
|
<label className="sk-label" htmlFor="a-mail">E-Mail</label>
|
||||||
|
<input id="a-mail" className="sk-input" type="email" value={form.email} onChange={set('email')} required />
|
||||||
|
</div>
|
||||||
|
<div className="sk-field">
|
||||||
|
<label className="sk-label sk-label--required" htmlFor="a-tel">Telefon</label>
|
||||||
|
<input id="a-tel" className="sk-input" type="tel" value={form.phone} onChange={set('phone')} required />
|
||||||
|
</div>
|
||||||
|
<div className="sk-field">
|
||||||
|
<label className="sk-label" htmlFor="a-menge">Ungefähre Menge <span className="sk-note">(optional)</span></label>
|
||||||
|
<input id="a-menge" className="sk-input" type="number" min={1} value={form.quantity} onChange={set('quantity')} />
|
||||||
|
</div>
|
||||||
|
<div className="sk-field">
|
||||||
|
<label className="sk-label" htmlFor="a-text">Ihr Vorhaben</label>
|
||||||
|
<textarea id="a-text" className="sk-input" rows={4} value={form.message} onChange={set('message')} required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="sk-check" style={{ marginBottom: 'var(--sk-space-4)' }}>
|
||||||
|
<input type="checkbox" checked={datenschutz} onChange={(e) => setDatenschutz(e.target.checked)} required />
|
||||||
|
<span>
|
||||||
|
Ich habe die{' '}
|
||||||
|
<a href="https://skrift.de/datenschutz" target="_blank" rel="noreferrer">Datenschutzerklärung</a>{' '}
|
||||||
|
zur Kenntnis genommen und bin mit der Verarbeitung meiner Daten zur Bearbeitung meiner
|
||||||
|
Anfrage einverstanden.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{fehler.length > 0 && (
|
||||||
|
<ul className="sk-error" style={{ margin: '0 0 var(--sk-space-3)', paddingLeft: '1.1em' }}>
|
||||||
|
{fehler.map((f) => <li key={f}>{f}</li>)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="sk-btn sk-btn--primary sk-btn--block" disabled={status === 'sendet' || !datenschutz || !form.phone.trim()}>
|
||||||
|
{status === 'sendet' ? 'Wird gesendet …' : 'Anfrage senden'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
Webseite/skrift-app/src/components/PreviewPanel.tsx
Normal file
164
Webseite/skrift-app/src/components/PreviewPanel.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live-Vorschau neben dem Formular.
|
||||||
|
*
|
||||||
|
* Die Vorschau ist absichtlich kontingentiert: Jede Generierung kostet einen
|
||||||
|
* Aufruf der Scriptalizer-API (Tageslimit). Der Zähler ist daher Teil der
|
||||||
|
* Bedienung und kein Beiwerk.
|
||||||
|
*
|
||||||
|
* Schriftstück und zugehöriger Umschlag gehören zusammen und werden gemeinsam
|
||||||
|
* gezeigt (Schriftstück oben, Umschlag unten). Die Pfeile blättern je Empfänger.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PreviewDoc = { art: 'brief' | 'umschlag'; svg: string };
|
||||||
|
|
||||||
|
/** Ein Empfänger-Dokument: Schriftstück + optional zugehöriger Umschlag. */
|
||||||
|
type Gruppe = { brief?: PreviewDoc; umschlag?: PreviewDoc };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
titel: string;
|
||||||
|
format: string;
|
||||||
|
docs: PreviewDoc[];
|
||||||
|
gesamtDokumente: number;
|
||||||
|
verbleibend: number;
|
||||||
|
maximal: number;
|
||||||
|
laedt: boolean;
|
||||||
|
fehler: string | null;
|
||||||
|
onGenerate: () => void;
|
||||||
|
bereit: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Flache [brief, umschlag, brief, …]-Liste zu Empfänger-Gruppen bündeln. */
|
||||||
|
function gruppiere(docs: PreviewDoc[]): Gruppe[] {
|
||||||
|
const gruppen: Gruppe[] = [];
|
||||||
|
for (const d of docs) {
|
||||||
|
if (d.art === 'umschlag') {
|
||||||
|
const letzte = gruppen[gruppen.length - 1];
|
||||||
|
if (letzte && letzte.brief && !letzte.umschlag) letzte.umschlag = d;
|
||||||
|
else gruppen.push({ umschlag: d });
|
||||||
|
} else {
|
||||||
|
gruppen.push({ brief: d });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gruppen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PreviewPanel({
|
||||||
|
titel, format, docs, gesamtDokumente, verbleibend, maximal, laedt, fehler, onGenerate, bereit,
|
||||||
|
}: Props) {
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const [zoomSvg, setZoomSvg] = useState<string | null>(null);
|
||||||
|
// Die Zoom-Fläche wird auf den Inhaltsbereich begrenzt (unterhalb der
|
||||||
|
// Preiszeile, rechts der Seitenleiste), statt das ganze Fenster zu belegen.
|
||||||
|
const [zoomBox, setZoomBox] = useState<React.CSSProperties>({});
|
||||||
|
|
||||||
|
const gruppen = useMemo(() => gruppiere(docs), [docs]);
|
||||||
|
|
||||||
|
function messeBox(): React.CSSProperties {
|
||||||
|
if (typeof window === 'undefined' || window.innerWidth <= 900) return {};
|
||||||
|
const content = document.querySelector('.sk-content');
|
||||||
|
if (!content) return {};
|
||||||
|
const c = content.getBoundingClientRect();
|
||||||
|
const bar = document.querySelector('.sk-pricebar');
|
||||||
|
const top = Math.max(c.top, bar ? bar.getBoundingClientRect().bottom : c.top);
|
||||||
|
return {
|
||||||
|
top: `${top}px`, left: `${c.left}px`, width: `${c.width}px`,
|
||||||
|
height: `${Math.max(0, c.bottom - top)}px`, right: 'auto', bottom: 'auto',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function openZoom(svg: string) { setZoomBox(messeBox()); setZoomSvg(svg); }
|
||||||
|
|
||||||
|
// Nach einer neuen Generierung wieder vorn beginnen.
|
||||||
|
useEffect(() => { setIndex(0); }, [docs]);
|
||||||
|
|
||||||
|
// Vergrößerte Ansicht: Escape schließt, Resize misst die Fläche neu.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!zoomSvg) return;
|
||||||
|
const zu = (e: KeyboardEvent) => e.key === 'Escape' && setZoomSvg(null);
|
||||||
|
const neu = () => setZoomBox(messeBox());
|
||||||
|
window.addEventListener('keydown', zu);
|
||||||
|
window.addEventListener('resize', neu);
|
||||||
|
return () => { window.removeEventListener('keydown', zu); window.removeEventListener('resize', neu); };
|
||||||
|
}, [zoomSvg]);
|
||||||
|
|
||||||
|
const aktuell = gruppen[index] ?? null;
|
||||||
|
const anzahl = gruppen.length || gesamtDokumente;
|
||||||
|
|
||||||
|
/** Ein einzelnes Dokument (Schriftstück oder Umschlag) mit Beschriftung. */
|
||||||
|
const DocBox = ({ label, svg }: { label: string; svg: string }) => (
|
||||||
|
<figure
|
||||||
|
className="sk-preview-doc"
|
||||||
|
onClick={() => openZoom(svg)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
title="Zum Vergrößern klicken"
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') openZoom(svg); }}
|
||||||
|
>
|
||||||
|
<figcaption className="sk-preview-doc-label">{label}</figcaption>
|
||||||
|
{/* Das SVG stammt aus unserer eigenen Generierung, nicht aus Nutzereingaben. */}
|
||||||
|
<div className="sk-preview-doc-sheet" dangerouslySetInnerHTML={{ __html: svg }} />
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sk-preview">
|
||||||
|
<div className="sk-preview-head">
|
||||||
|
<div>
|
||||||
|
<div className="sk-preview-title">Vorschau: {titel || '—'}</div>
|
||||||
|
<div className="sk-preview-sub">{format || '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{laedt ? (
|
||||||
|
<div className="sk-preview-sheet"><p className="sk-note">Vorschau wird erzeugt …</p></div>
|
||||||
|
) : aktuell ? (
|
||||||
|
<div className="sk-preview-docs">
|
||||||
|
{aktuell.brief && <DocBox label={`Schriftstück${format ? ` · ${format}` : ''}`} svg={aktuell.brief.svg} />}
|
||||||
|
{aktuell.umschlag && <DocBox label="Umschlag" svg={aktuell.umschlag.svg} />}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="sk-preview-sheet">
|
||||||
|
<p className="sk-note" style={{ textAlign: 'center' }}>Noch keine Vorschau erzeugt.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{anzahl > 0 && (
|
||||||
|
<div className="sk-preview-pager">
|
||||||
|
<button className="sk-pager-btn" aria-label="Vorheriges Dokument"
|
||||||
|
disabled={index === 0 || gruppen.length <= 1}
|
||||||
|
onClick={() => setIndex((i) => Math.max(0, i - 1))}>‹</button>
|
||||||
|
<span className="sk-pager-label">
|
||||||
|
Dokument {gruppen.length ? index + 1 : 0} von {anzahl}
|
||||||
|
</span>
|
||||||
|
<button className="sk-pager-btn" aria-label="Nächstes Dokument"
|
||||||
|
disabled={index >= gruppen.length - 1 || gruppen.length <= 1}
|
||||||
|
onClick={() => setIndex((i) => Math.min(gruppen.length - 1, i + 1))}>›</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fehler && <p className="sk-error">{fehler}</p>}
|
||||||
|
|
||||||
|
<div className="sk-preview-quota">
|
||||||
|
Verbleibende Vorschau-Anfragen: {verbleibend}/{maximal}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="sk-btn sk-btn--ghost sk-btn--block" onClick={onGenerate}
|
||||||
|
disabled={!bereit || laedt || verbleibend <= 0}
|
||||||
|
title={!bereit ? 'Bitte zuerst Text angeben' : undefined}>
|
||||||
|
{laedt ? 'Wird erzeugt …' : 'Vorschau generieren'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{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()}
|
||||||
|
dangerouslySetInnerHTML={{ __html: zoomSvg }} />
|
||||||
|
<button className="sk-lightbox-close" onClick={() => setZoomSvg(null)} aria-label="Schließen">
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
548
Webseite/skrift-app/src/lib/api.ts
Normal file
548
Webseite/skrift-app/src/lib/api.ts
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
/**
|
||||||
|
* Directus-Client.
|
||||||
|
*
|
||||||
|
* Bewusst ohne SDK: Wir brauchen vor allem unsere eigenen Endpunkte
|
||||||
|
* (/skrift-auth, /skrift-orders) plus einfache Item-Abfragen – ein schlanker
|
||||||
|
* fetch-Wrapper ist überschaubarer und hält uns von SDK-Versionswechseln fern.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = (import.meta.env.VITE_DIRECTUS_URL ?? '').replace(/\/$/, '');
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'skrift.session';
|
||||||
|
|
||||||
|
export type Session = {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
/** Ablaufzeitpunkt als Unix-ms. */
|
||||||
|
expires_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadSession(): Session | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as Session) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSession(s: Session | null) {
|
||||||
|
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||||
|
else localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
/** Einzelne Validierungsmeldungen, falls der Endpunkt mehrere liefert. */
|
||||||
|
details: string[];
|
||||||
|
constructor(message: string, status: number, details: string[] = []) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parse(res: Response) {
|
||||||
|
const text = await res.text();
|
||||||
|
let data: any = null;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
/* keine JSON-Antwort */
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
// Directus liefert errors als Objekte ({message}), unsere eigenen
|
||||||
|
// Endpunkte als reine Strings – beides muss ankommen.
|
||||||
|
const roh = Array.isArray(data?.errors) ? data.errors : [];
|
||||||
|
const details: string[] = roh
|
||||||
|
.map((e: unknown) => (typeof e === 'string' ? e : (e as { message?: string })?.message))
|
||||||
|
.filter((m: unknown): m is string => typeof m === 'string' && m.length > 0);
|
||||||
|
|
||||||
|
const basis = details[0] ?? data?.error ?? data?.message ?? res.statusText;
|
||||||
|
// Manche eigenen Endpunkte hängen einen Diagnose-Grund an (data.detail).
|
||||||
|
const msg = typeof data?.detail === 'string' ? `${basis} (${data.detail})` : basis;
|
||||||
|
throw new ApiError(msg, res.status, details);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erneuert die Session, wenn das Access-Token abgelaufen ist. */
|
||||||
|
async function ensureFreshToken(): Promise<Session | null> {
|
||||||
|
const session = loadSession();
|
||||||
|
if (!session) return null;
|
||||||
|
// 30 s Puffer, damit ein Request nicht mitten im Ablauf landet.
|
||||||
|
if (Date.now() < session.expires_at - 30_000) return session;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh_token: session.refresh_token, mode: 'json' }),
|
||||||
|
});
|
||||||
|
const data = await parse(res);
|
||||||
|
const next: Session = {
|
||||||
|
access_token: data.data?.access_token ?? data.access_token,
|
||||||
|
refresh_token: data.data?.refresh_token ?? data.refresh_token,
|
||||||
|
expires_at: Date.now() + (data.data?.expires ?? data.expires ?? 900_000),
|
||||||
|
};
|
||||||
|
saveSession(next);
|
||||||
|
return next;
|
||||||
|
} catch {
|
||||||
|
saveSession(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestOptions = { method?: string; body?: unknown; auth?: boolean };
|
||||||
|
|
||||||
|
async function request(path: string, { method = 'GET', body, auth = true }: RequestOptions = {}) {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
|
||||||
|
if (auth) {
|
||||||
|
const session = await ensureFreshToken();
|
||||||
|
if (session) headers.Authorization = `Bearer ${session.access_token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(BASE + path, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return parse(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Auth (passwortlos, siehe Extension skrift-auth) ──────────────────────── */
|
||||||
|
|
||||||
|
export async function requestLoginCode(email: string) {
|
||||||
|
return request('/skrift-auth/request-code', { method: 'POST', body: { email }, auth: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyLoginCode(email: string, code: string): Promise<Session> {
|
||||||
|
const data = await request('/skrift-auth/verify-code', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, code },
|
||||||
|
auth: false,
|
||||||
|
});
|
||||||
|
const session: Session = {
|
||||||
|
access_token: data.access_token,
|
||||||
|
refresh_token: data.refresh_token,
|
||||||
|
expires_at: Date.now() + (data.expires ?? 900_000),
|
||||||
|
};
|
||||||
|
saveSession(session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
saveSession(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Daten ────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type Order = {
|
||||||
|
id: number;
|
||||||
|
order_number: string;
|
||||||
|
status: string;
|
||||||
|
gross_total: number | null;
|
||||||
|
entries_count: number | null;
|
||||||
|
date_created: string | null;
|
||||||
|
product?: { name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Aufträge des angemeldeten Kunden – die Einschränkung erfolgt in Directus. */
|
||||||
|
export async function fetchMyOrders(): Promise<Order[]> {
|
||||||
|
const data = await request(
|
||||||
|
'/items/orders?fields=id,order_number,status,gross_total,entries_count,date_created,product.name' +
|
||||||
|
'&sort=-date_created&limit=100',
|
||||||
|
);
|
||||||
|
return data?.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMe() {
|
||||||
|
const data = await request('/users/me?fields=id,email,first_name,last_name');
|
||||||
|
return data?.data ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stammdaten ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type CustomerAddress = {
|
||||||
|
label?: string;
|
||||||
|
street?: string;
|
||||||
|
zip?: string;
|
||||||
|
city?: string;
|
||||||
|
country?: string;
|
||||||
|
};
|
||||||
|
export type Customer = {
|
||||||
|
/** Directus-User-ID (UUID) – Kunde ist das Login-Konto selbst. */
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
person_type: 'privat' | 'unternehmen' | null;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
company: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
/** Mehrere hinterlegte Adressen (Rechnung/Lieferung). */
|
||||||
|
addresses: CustomerAddress[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profil des angemeldeten Kunden. Kunde = Login-Konto (directus_users),
|
||||||
|
* daher direkt über /users/me – kein separater „customers"-Datensatz mehr.
|
||||||
|
*/
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
return data?.data ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** person_type fehlt bewusst – die Einstufung ändert nur der Betreiber. */
|
||||||
|
export async function saveMyCustomer(c: Customer) {
|
||||||
|
return request('/users/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: {
|
||||||
|
first_name: c.first_name,
|
||||||
|
last_name: c.last_name,
|
||||||
|
company: c.company,
|
||||||
|
phone: c.phone,
|
||||||
|
addresses: c.addresses ?? [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Katalog (öffentlich lesbar – auch für Gäste) ─────────────────────────── */
|
||||||
|
|
||||||
|
export type Product = {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
type: 'letter' | 'postcard' | 'sample' | 'contact';
|
||||||
|
pricing_mode: 'formel' | 'auf_anfrage';
|
||||||
|
/** Für wen das Produkt sichtbar ist (im Admin pflegbar). */
|
||||||
|
visibility: 'alle' | 'privat' | 'unternehmen';
|
||||||
|
/** Eingabeart im Konfigurator: freier Text oder Datei-Upload. */
|
||||||
|
input_mode: 'text' | 'datei';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Format = { id: number; key: string; name: string; product: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produkt-Schlüssel, die für den angemeldeten Kunden individuell freigeschaltet
|
||||||
|
* sind (z. B. Unterschriftenservice). Nur mit Login sinnvoll; sonst leer.
|
||||||
|
*/
|
||||||
|
export async function fetchMyUnlockedProducts(): Promise<string[]> {
|
||||||
|
if (!loadSession()) return [];
|
||||||
|
try {
|
||||||
|
const data = await request('/items/customer_products?fields=product.key&limit=-1');
|
||||||
|
return (data?.data ?? [])
|
||||||
|
.map((r: { product?: { key?: string } }) => r.product?.key)
|
||||||
|
.filter((k: unknown): k is string => typeof k === 'string');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProducts(): Promise<Product[]> {
|
||||||
|
// fields=* damit ein noch fehlendes Feld (z. B. visibility vor dem Bootstrap)
|
||||||
|
// den Aufruf nicht scheitern lässt.
|
||||||
|
const data = await request(
|
||||||
|
'/items/products?fields=*&filter[active][_eq]=true&sort=sort&limit=-1',
|
||||||
|
{ auth: false },
|
||||||
|
);
|
||||||
|
return (data?.data ?? []).map((p: Product) => ({ ...p, visibility: p.visibility ?? 'alle', input_mode: p.input_mode ?? 'text' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchFormats(): Promise<Format[]> {
|
||||||
|
const data = await request(
|
||||||
|
'/items/formats?fields=id,key,name,product&filter[active][_eq]=true&limit=-1',
|
||||||
|
{ auth: false },
|
||||||
|
);
|
||||||
|
return data?.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Preis & Bestellung (Extension skrift-orders) ─────────────────────────── */
|
||||||
|
|
||||||
|
export type QuoteLine = { label: string; amount: number; per_unit?: boolean };
|
||||||
|
export type Quote = {
|
||||||
|
lines: QuoteLine[];
|
||||||
|
net_total: number;
|
||||||
|
/** Netto-Summe nur der pro-Stück-Positionen (ohne einmalige Kosten). */
|
||||||
|
unit_net_total: number;
|
||||||
|
vat_amount: number;
|
||||||
|
gross_total: number;
|
||||||
|
mwst_percent: number;
|
||||||
|
paypal_allowed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuoteInput = {
|
||||||
|
product: string;
|
||||||
|
format?: string;
|
||||||
|
quantity: number;
|
||||||
|
shipping_type: 'einzeln' | 'sammel';
|
||||||
|
needs_envelope: boolean;
|
||||||
|
envelope_labeling?: 'empfaenger' | 'freitext' | 'keine';
|
||||||
|
motif_mode?: string;
|
||||||
|
addons?: string[];
|
||||||
|
voucher_code?: string;
|
||||||
|
/** Optional: bekannte Kunden-E-Mail, damit hinterlegte Kundenrabatte greifen. */
|
||||||
|
email?: string;
|
||||||
|
/** Segment für den Mengen-Multiplikator (B2B/B2C). */
|
||||||
|
person_type?: 'privat' | 'unternehmen';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Preis serverseitig berechnen lassen – der Client rechnet nie selbst. */
|
||||||
|
export async function fetchQuote(input: QuoteInput): Promise<Quote> {
|
||||||
|
return request('/skrift-orders/quote', { method: 'POST', body: input, auth: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Motive ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type Motif = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
image: string | null;
|
||||||
|
orientation: 'quer' | 'hochkant' | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchMotifs(): Promise<Motif[]> {
|
||||||
|
const data = await request(
|
||||||
|
'/items/motifs?fields=id,name,image,orientation&filter[active][_eq]=true&sort=sort&limit=-1',
|
||||||
|
{ auth: false },
|
||||||
|
);
|
||||||
|
return data?.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Öffentliche URL einer Directus-Datei. */
|
||||||
|
export const assetUrl = (id: string) => `${BASE}/assets/${id}`;
|
||||||
|
|
||||||
|
/* ── Preis-Positionen (für Aufpreise an den Optionen) ─────────────────────── */
|
||||||
|
|
||||||
|
export type PriceItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
category: string;
|
||||||
|
price: number;
|
||||||
|
unit: 'einmalig' | 'pro_stueck' | 'prozent';
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchPriceItems(): Promise<PriceItem[]> {
|
||||||
|
const data = await request(
|
||||||
|
'/items/price_items?fields=key,label,category,price,unit&filter[active][_eq]=true&limit=-1',
|
||||||
|
{ auth: false },
|
||||||
|
);
|
||||||
|
return data?.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Vorschau ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type PreviewDoc = { art: 'brief' | 'umschlag'; svg: string };
|
||||||
|
/** Ein Schriftstück, dessen Text nicht auf das Format passt (Zeilen-Überlauf). */
|
||||||
|
export type PreviewOverflow = { index: number; lineCount: number; lineLimit: number };
|
||||||
|
export type PreviewResult = {
|
||||||
|
docs: PreviewDoc[];
|
||||||
|
remaining: number;
|
||||||
|
limit: number;
|
||||||
|
/** true, wenn mindestens ein Schriftstück zu lang für das Format ist. */
|
||||||
|
has_overflow?: boolean;
|
||||||
|
overflow?: PreviewOverflow[];
|
||||||
|
/** Vorschau-Session – bei der Bestellung mitschicken, damit gezeigte
|
||||||
|
* Dokumente 1:1 übernommen werden. */
|
||||||
|
session?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchPreviewQuota(kennung: string): Promise<{ remaining: number; limit: number }> {
|
||||||
|
const p = kennung.includes('@') ? `email=${encodeURIComponent(kennung)}` : `client_id=${encodeURIComponent(kennung)}`;
|
||||||
|
return request(`/skrift-orders/preview/quota?${p}`, { auth: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PreviewInput = {
|
||||||
|
email?: string;
|
||||||
|
client_id?: string;
|
||||||
|
text: string;
|
||||||
|
font: string;
|
||||||
|
format?: string;
|
||||||
|
realistic?: boolean;
|
||||||
|
envelope_labeling?: string;
|
||||||
|
envelope_text?: string;
|
||||||
|
entries: unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generatePreview(input: PreviewInput): Promise<PreviewResult> {
|
||||||
|
return request('/skrift-orders/preview', { method: 'POST', body: input, auth: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reine Zeilen-/Überlauf-Prüfung (beim „Weiter" vom Text-Schritt) – verbraucht
|
||||||
|
* KEIN Vorschau-Kontingent und liefert keine SVGs.
|
||||||
|
*/
|
||||||
|
export async function validateText(
|
||||||
|
input: PreviewInput,
|
||||||
|
): Promise<{ has_overflow?: boolean; overflow?: PreviewOverflow[] }> {
|
||||||
|
return request('/skrift-orders/preview', { method: 'POST', body: { ...input, validate: true }, auth: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bestellung absenden ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type OrderInput = {
|
||||||
|
email: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
company?: string;
|
||||||
|
phone?: string;
|
||||||
|
person_type: 'privat' | 'unternehmen';
|
||||||
|
product: string;
|
||||||
|
format?: string;
|
||||||
|
font: string;
|
||||||
|
shipping_type: 'einzeln' | 'sammel';
|
||||||
|
needs_envelope: boolean;
|
||||||
|
envelope_labeling?: string;
|
||||||
|
motif_mode?: string;
|
||||||
|
text_template?: string;
|
||||||
|
text_briefing?: string;
|
||||||
|
/** Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF). */
|
||||||
|
source_file?: string;
|
||||||
|
/** Rechnungs-/Lieferadresse als Snapshot (für Bestätigung & Rechnung). */
|
||||||
|
billing?: Record<string, string>;
|
||||||
|
shipping_address?: Record<string, string> | null;
|
||||||
|
/** Vorschau-Session (gezeigte Dokumente werden 1:1 übernommen). */
|
||||||
|
preview_session?: string;
|
||||||
|
addons?: string[];
|
||||||
|
voucher_code?: string;
|
||||||
|
payment_method: 'paypal' | 'rechnung';
|
||||||
|
source?: string;
|
||||||
|
entries: unknown[];
|
||||||
|
/** Motiv aus dem Katalog (ID) bzw. hochgeladene Datei (Directus-Datei-ID). */
|
||||||
|
motif?: number;
|
||||||
|
motif_upload?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrderResult = {
|
||||||
|
order_number: string;
|
||||||
|
net_total: number;
|
||||||
|
vat_amount: number;
|
||||||
|
gross_total: number;
|
||||||
|
payment_method: 'paypal' | 'rechnung';
|
||||||
|
paypal_allowed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createOrder(input: OrderInput): Promise<OrderResult> {
|
||||||
|
return request('/skrift-orders', { method: 'POST', body: input, auth: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vermerkt eine erfolgreiche PayPal-Zahlung am Auftrag. */
|
||||||
|
export async function confirmPayment(order_number: string, payment_ref: string) {
|
||||||
|
return request('/skrift-orders/payment', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { order_number, payment_ref },
|
||||||
|
auth: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Öffentliche Einstellungen (Preisformel, PayPal-Client-ID) ─────────────── */
|
||||||
|
|
||||||
|
export type PublicSettings = {
|
||||||
|
mwst_percent: number;
|
||||||
|
/** Erlaubte Zeichen als Regex-Klasse (z. B. "\\x20-\\x7EÄÖÜ…"). */
|
||||||
|
char_whitelist: string | null;
|
||||||
|
paypal_limit_net: number;
|
||||||
|
paypal_enabled: boolean;
|
||||||
|
paypal_mode: 'sandbox' | 'live';
|
||||||
|
paypal_client_id_sandbox: string | null;
|
||||||
|
paypal_client_id_live: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bewusst `fields=*`: Wenn einzelne Felder (z. B. die PayPal-Spalten) noch
|
||||||
|
* nicht angelegt sind, würde eine explizite Feldliste den ganzen Aufruf
|
||||||
|
* scheitern lassen.
|
||||||
|
*/
|
||||||
|
export async function fetchSettings(): Promise<PublicSettings | null> {
|
||||||
|
const data = await request('/items/pricing_settings?fields=*', { auth: false });
|
||||||
|
return data?.data ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die zum Modus passende öffentliche Client-ID. */
|
||||||
|
export const paypalClientId = (s: PublicSettings | null) =>
|
||||||
|
!s || !s.paypal_enabled
|
||||||
|
? null
|
||||||
|
: (s.paypal_mode === 'live' ? s.paypal_client_id_live : s.paypal_client_id_sandbox) || null;
|
||||||
|
|
||||||
|
/* ── Datei-Upload (Motiv) ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Lädt eine Datei nach Directus und liefert deren ID. */
|
||||||
|
export async function uploadFile(datei: File): Promise<string> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', datei);
|
||||||
|
|
||||||
|
const session = await ensureFreshToken();
|
||||||
|
const res = await fetch(`${BASE}/files`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: session ? { Authorization: `Bearer ${session.access_token}` } : undefined,
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const data = await parse(res);
|
||||||
|
return data?.data?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Angebotsanfrage ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type OfferRequest = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
quantity?: number | null;
|
||||||
|
message: string;
|
||||||
|
/** Bei Anfrage aus einem Kontakt-Produkt: dessen Name. */
|
||||||
|
product?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function sendOfferRequest(input: OfferRequest) {
|
||||||
|
return request('/skrift-orders/offer-request', { method: 'POST', body: input, auth: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Musterbestellung (kostenlos) ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type SampleRequest = {
|
||||||
|
product: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
street: string;
|
||||||
|
zip: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function sendSampleRequest(input: SampleRequest) {
|
||||||
|
return request('/skrift-orders/sample-request', { method: 'POST', body: input, auth: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Statusverlauf & Benachrichtigungen ───────────────────────────────────── */
|
||||||
|
|
||||||
|
export type StatusEvent = {
|
||||||
|
id: number;
|
||||||
|
status: string;
|
||||||
|
note: string | null;
|
||||||
|
timestamp: string;
|
||||||
|
order: { id: number; order_number: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Statusverlauf – Directus liefert nur Einträge zu eigenen Aufträgen. */
|
||||||
|
export async function fetchStatusEvents(orderId?: number): Promise<StatusEvent[]> {
|
||||||
|
const filter = orderId ? `&filter[order][_eq]=${orderId}` : '';
|
||||||
|
const data = await request(
|
||||||
|
`/items/status_history?fields=id,status,note,timestamp,order.id,order.order_number` +
|
||||||
|
`&sort=-timestamp&limit=30${filter}`,
|
||||||
|
);
|
||||||
|
return data?.data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEEN_KEY = 'skrift.notifications.seen';
|
||||||
|
|
||||||
|
export function getSeenAt(): number {
|
||||||
|
const raw = localStorage.getItem(SEEN_KEY);
|
||||||
|
return raw ? Number(raw) : 0;
|
||||||
|
}
|
||||||
|
export function markNotificationsSeen() {
|
||||||
|
localStorage.setItem(SEEN_KEY, String(Date.now()));
|
||||||
|
}
|
||||||
37
Webseite/skrift-app/src/lib/dialogs.tsx
Normal file
37
Webseite/skrift-app/src/lib/dialogs.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { createContext, useContext, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentrale Dialog-Steuerung: Hilfe, Individuelles Angebot und Muster bestellen
|
||||||
|
* werden an einer Stelle (AppShell) gerendert, aber von überall geöffnet
|
||||||
|
* (Seitenleiste, Konfigurator, Kontakt-Produkte).
|
||||||
|
*/
|
||||||
|
export type DialogName = 'hilfe' | 'angebot' | 'muster' | null;
|
||||||
|
|
||||||
|
type Ctx = {
|
||||||
|
dialog: DialogName;
|
||||||
|
/** Optionaler Vorbelegungstext (z. B. gewähltes Kontakt-Produkt). */
|
||||||
|
vorbelegung: string;
|
||||||
|
open: (d: Exclude<DialogName, null>, vorbelegung?: string) => void;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DialogContext = createContext<Ctx>({ dialog: null, vorbelegung: '', open: () => {}, close: () => {} });
|
||||||
|
|
||||||
|
export function DialogsProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [dialog, setDialog] = useState<DialogName>(null);
|
||||||
|
const [vorbelegung, setVorbelegung] = useState('');
|
||||||
|
return (
|
||||||
|
<DialogContext.Provider
|
||||||
|
value={{
|
||||||
|
dialog,
|
||||||
|
vorbelegung,
|
||||||
|
open: (d, v = '') => { setVorbelegung(v); setDialog(d); },
|
||||||
|
close: () => setDialog(null),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDialogs = () => useContext(DialogContext);
|
||||||
109
Webseite/skrift-app/src/lib/excel.ts
Normal file
109
Webseite/skrift-app/src/lib/excel.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Excel-Import und -Vorlage für Empfängerdaten.
|
||||||
|
*
|
||||||
|
* Die Vorlage wird im Browser erzeugt – so bleibt sie automatisch synchron mit
|
||||||
|
* den erwarteten Spalten und muss nicht als Datei gepflegt werden.
|
||||||
|
*
|
||||||
|
* SheetJS wird bewusst erst bei Bedarf nachgeladen (dynamischer Import): Die
|
||||||
|
* Bibliothek ist größer als die restliche App und wird nur von wenigen Kunden
|
||||||
|
* gebraucht – sie gehört nicht ins Start-Bundle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ExcelEntry = {
|
||||||
|
salutation: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
street: string;
|
||||||
|
house_no: string;
|
||||||
|
zip: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
free_text: string;
|
||||||
|
placeholders: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Feste Spalten; alles Weitere wird als freier Platzhalter übernommen. */
|
||||||
|
const SPALTEN: Record<string, keyof ExcelEntry> = {
|
||||||
|
anrede: 'salutation',
|
||||||
|
vorname: 'first_name',
|
||||||
|
nachname: 'last_name',
|
||||||
|
name: 'last_name',
|
||||||
|
strasse: 'street',
|
||||||
|
straße: 'street',
|
||||||
|
nr: 'house_no',
|
||||||
|
hausnummer: 'house_no',
|
||||||
|
plz: 'zip',
|
||||||
|
ort: 'city',
|
||||||
|
stadt: 'city',
|
||||||
|
land: 'country',
|
||||||
|
};
|
||||||
|
|
||||||
|
const norm = (s: string) => s.trim().toLowerCase().replace(/\s+/g, '');
|
||||||
|
|
||||||
|
export const VORLAGEN_SPALTEN = [
|
||||||
|
'Anrede', 'Vorname', 'Nachname', 'Strasse', 'Nr', 'PLZ', 'Ort', 'Land',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 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) {
|
||||||
|
const XLSX = await import('xlsx');
|
||||||
|
const basis = freitext ? FREITEXT_SPALTEN : VORLAGEN_SPALTEN;
|
||||||
|
const kopf = [...basis, ...zusatzSpalten];
|
||||||
|
const beispielBasis = freitext
|
||||||
|
? ['Erika Mustermann', 'Hauptstraße 1', '10115 Berlin', '', '']
|
||||||
|
: ['Frau', 'Erika', 'Mustermann', 'Hauptstraße', '1', '10115', 'Berlin', 'DE'];
|
||||||
|
const beispiel = [...beispielBasis, ...zusatzSpalten.map(() => 'Beispielwert')];
|
||||||
|
|
||||||
|
const blatt = XLSX.utils.aoa_to_sheet([kopf, beispiel]);
|
||||||
|
blatt['!cols'] = kopf.map(() => ({ wch: 16 }));
|
||||||
|
const mappe = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(mappe, blatt, 'Empfaenger');
|
||||||
|
XLSX.writeFile(mappe, 'skrift-empfaenger-vorlage.xlsx');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest eine hochgeladene Datei ein und wandelt sie in Empfängerzeilen. */
|
||||||
|
export async function leseEmpfaengerDatei(datei: File): Promise<ExcelEntry[]> {
|
||||||
|
const XLSX = await import('xlsx');
|
||||||
|
const puffer = await datei.arrayBuffer();
|
||||||
|
const mappe = XLSX.read(puffer, { type: 'array' });
|
||||||
|
const blatt = mappe.Sheets[mappe.SheetNames[0]];
|
||||||
|
if (!blatt) throw new Error('Die Datei enthält kein Tabellenblatt.');
|
||||||
|
|
||||||
|
const zeilen = XLSX.utils.sheet_to_json<Record<string, unknown>>(blatt, { defval: '' });
|
||||||
|
if (!zeilen.length) throw new Error('Die Datei enthält keine Datenzeilen.');
|
||||||
|
|
||||||
|
return zeilen.map((zeile) => {
|
||||||
|
const eintrag: ExcelEntry = {
|
||||||
|
salutation: '', first_name: '', last_name: '', street: '',
|
||||||
|
house_no: '', zip: '', city: '', country: 'DE',
|
||||||
|
free_text: '', placeholders: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Freitext-Vorlage: „Zeile 1..5" werden zur mehrzeiligen Adresse.
|
||||||
|
const freitextZeilen: string[] = [];
|
||||||
|
|
||||||
|
for (const [spalte, wert] of Object.entries(zeile)) {
|
||||||
|
const text = String(wert ?? '').trim();
|
||||||
|
const zeilenTreffer = /^zeile\s*(\d+)$/i.exec(spalte.trim());
|
||||||
|
const feld = SPALTEN[norm(spalte)];
|
||||||
|
|
||||||
|
if (zeilenTreffer) {
|
||||||
|
freitextZeilen[Number(zeilenTreffer[1]) - 1] = text;
|
||||||
|
} else if (feld && feld !== 'placeholders') {
|
||||||
|
(eintrag[feld] as string) = text;
|
||||||
|
} else if (text) {
|
||||||
|
// Unbekannte Spalten werden zu Platzhaltern – so lassen sich
|
||||||
|
// beliebige eigene Felder im Text verwenden.
|
||||||
|
eintrag.placeholders[spalte.trim()] = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (freitextZeilen.length) {
|
||||||
|
eintrag.free_text = freitextZeilen.map((z) => z ?? '').join('\n').replace(/\n+$/, '');
|
||||||
|
}
|
||||||
|
return eintrag;
|
||||||
|
});
|
||||||
|
}
|
||||||
86
Webseite/skrift-app/src/lib/paypal.ts
Normal file
86
Webseite/skrift-app/src/lib/paypal.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* PayPal-Anbindung – clientseitig, wie im bisherigen Konfigurator.
|
||||||
|
*
|
||||||
|
* Es wird ausschließlich die öffentliche Client-ID verwendet; das Secret bleibt
|
||||||
|
* außen vor. Die Zahlung wird über `actions.order.create/capture` direkt gegen
|
||||||
|
* PayPal abgewickelt, die Referenz danach in Directus vermerkt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let geladen: Promise<void> | null = null;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
paypal?: {
|
||||||
|
Buttons: (config: Record<string, unknown>) => {
|
||||||
|
render: (el: HTMLElement) => Promise<void>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lädt das PayPal-SDK genau einmal nach. */
|
||||||
|
export function ladePayPal(clientId: string): Promise<void> {
|
||||||
|
if (window.paypal) return Promise.resolve();
|
||||||
|
if (geladen) return geladen;
|
||||||
|
|
||||||
|
geladen = new Promise<void>((resolve, reject) => {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src =
|
||||||
|
`https://www.paypal.com/sdk/js?client-id=${encodeURIComponent(clientId)}` +
|
||||||
|
'¤cy=EUR&intent=capture&disable-funding=venmo,paylater,credit';
|
||||||
|
script.async = true;
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => {
|
||||||
|
geladen = null;
|
||||||
|
reject(new Error('PayPal konnte nicht geladen werden.'));
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
return geladen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PayPalOptions = {
|
||||||
|
betrag: number;
|
||||||
|
beschreibung: string;
|
||||||
|
onApproved: (referenz: string) => void | Promise<void>;
|
||||||
|
onError: (meldung: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rendert die PayPal-Buttons in das übergebene Element. */
|
||||||
|
export async function rendereButtons(el: HTMLElement, o: PayPalOptions) {
|
||||||
|
if (!window.paypal) throw new Error('PayPal ist nicht geladen.');
|
||||||
|
el.innerHTML = '';
|
||||||
|
|
||||||
|
await window.paypal
|
||||||
|
.Buttons({
|
||||||
|
style: { shape: 'rect', layout: 'vertical', color: 'blue', label: 'paypal' },
|
||||||
|
|
||||||
|
createOrder: (_data: unknown, actions: any) =>
|
||||||
|
actions.order.create({
|
||||||
|
intent: 'CAPTURE',
|
||||||
|
purchase_units: [
|
||||||
|
{
|
||||||
|
amount: { currency_code: 'EUR', value: o.betrag.toFixed(2) },
|
||||||
|
description: o.beschreibung.slice(0, 127),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
application_context: {
|
||||||
|
brand_name: 'Skrift',
|
||||||
|
locale: 'de-DE',
|
||||||
|
shipping_preference: 'NO_SHIPPING',
|
||||||
|
user_action: 'PAY_NOW',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
onApprove: (_data: unknown, actions: any) =>
|
||||||
|
actions.order.capture().then((details: any) => {
|
||||||
|
const capture = details?.purchase_units?.[0]?.payments?.captures?.[0];
|
||||||
|
if (capture?.status === 'COMPLETED') return o.onApproved(capture.id);
|
||||||
|
o.onError('Die Zahlung wurde nicht abgeschlossen.');
|
||||||
|
}),
|
||||||
|
|
||||||
|
onError: () => o.onError('Bei der Zahlung ist ein Fehler aufgetreten.'),
|
||||||
|
})
|
||||||
|
.render(el);
|
||||||
|
}
|
||||||
18
Webseite/skrift-app/src/lib/status.ts
Normal file
18
Webseite/skrift-app/src/lib/status.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/** Anzeigetexte für die Auftragsstatus aus Directus – an einer Stelle gepflegt. */
|
||||||
|
export const STATUS_LABEL: Record<string, string> = {
|
||||||
|
entwurf: 'Entwurf',
|
||||||
|
vorbereitet: 'Für Sie vorbereitet',
|
||||||
|
wartet_auf_zahlung: 'Wartet auf Zahlung',
|
||||||
|
bezahlt: 'Bezahlt',
|
||||||
|
material_erwartet: 'Material erwartet',
|
||||||
|
material_eingegangen: 'Material eingegangen',
|
||||||
|
in_queue: 'In der Warteschlange',
|
||||||
|
in_produktion: 'In Produktion',
|
||||||
|
gedruckt: 'Geschrieben',
|
||||||
|
versendet: 'Versendet',
|
||||||
|
abgeschlossen: 'Abgeschlossen',
|
||||||
|
storniert: 'Storniert',
|
||||||
|
fehler: 'Fehler',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statusText = (s: string) => STATUS_LABEL[s] ?? s;
|
||||||
46
Webseite/skrift-app/src/lib/wizard.tsx
Normal file
46
Webseite/skrift-app/src/lib/wizard.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Konfigurator meldet seine Schritte hierher, damit die Seitenleiste den
|
||||||
|
* Fortschritt anzeigen kann – ohne dass Shell und Wizard voneinander wissen
|
||||||
|
* müssen. Umgekehrt darf die Seitenleiste über `goto` zu einem bereits
|
||||||
|
* erledigten Schritt zurückspringen; der Konfigurator registriert dafür seinen
|
||||||
|
* Handler (nur Rückwärtssprünge sind erlaubt).
|
||||||
|
*/
|
||||||
|
export type WizardState = { steps: string[]; current: number };
|
||||||
|
|
||||||
|
type Ctx = WizardState & {
|
||||||
|
publish: (s: WizardState) => void;
|
||||||
|
/** Vom Konfigurator aufgerufen: setzt den aktiven Sprung-Handler. */
|
||||||
|
registerGoto: (fn: (i: number) => void) => void;
|
||||||
|
/** Von der Seitenleiste aufgerufen: springe zu Schritt i. */
|
||||||
|
goto: (i: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WizardContext = createContext<Ctx>({
|
||||||
|
steps: [], current: 0, publish: () => {}, registerGoto: () => {}, goto: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function WizardProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [state, setState] = useState<WizardState>({ steps: [], current: 0 });
|
||||||
|
const gotoRef = useRef<(i: number) => void>(() => {});
|
||||||
|
|
||||||
|
const publish = useCallback((next: WizardState) => {
|
||||||
|
setState((prev) =>
|
||||||
|
prev.current === next.current && prev.steps.join('|') === next.steps.join('|')
|
||||||
|
? prev // unnötige Renders vermeiden
|
||||||
|
: next,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
const registerGoto = useCallback((fn: (i: number) => void) => { gotoRef.current = fn; }, []);
|
||||||
|
const goto = useCallback((i: number) => gotoRef.current(i), []);
|
||||||
|
|
||||||
|
const value = useMemo<Ctx>(
|
||||||
|
() => ({ ...state, publish, registerGoto, goto }),
|
||||||
|
[state, publish, registerGoto, goto],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <WizardContext.Provider value={value}>{children}</WizardContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useWizard = () => useContext(WizardContext);
|
||||||
14
Webseite/skrift-app/src/main.tsx
Normal file
14
Webseite/skrift-app/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
import './styles/tokens.css';
|
||||||
|
import './styles/global.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
123
Webseite/skrift-app/src/routes/Auftraege.tsx
Normal file
123
Webseite/skrift-app/src/routes/Auftraege.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { ApiError, fetchMyOrders, fetchStatusEvents, type Order, type StatusEvent } from '../lib/api';
|
||||||
|
import { statusText } from '../lib/status';
|
||||||
|
import { IconChevronDown } from '../components/Icons';
|
||||||
|
|
||||||
|
const euro = (v: number | null) =>
|
||||||
|
v == null ? '—' : new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v);
|
||||||
|
|
||||||
|
const datum = (v: string | null) =>
|
||||||
|
v ? new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(new Date(v)) : '';
|
||||||
|
|
||||||
|
const zeitpunkt = (v: string) =>
|
||||||
|
new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(v));
|
||||||
|
|
||||||
|
/** Statusverlauf eines Auftrags – wird erst bei Bedarf geladen. */
|
||||||
|
function Verlauf({ orderId }: { orderId: number }) {
|
||||||
|
const [events, setEvents] = useState<StatusEvent[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatusEvents(orderId)
|
||||||
|
.then(setEvents)
|
||||||
|
.catch(() => setEvents([]));
|
||||||
|
}, [orderId]);
|
||||||
|
|
||||||
|
if (events === null) return <p className="sk-note">Verlauf wird geladen …</p>;
|
||||||
|
if (events.length === 0) return <p className="sk-note">Noch kein Verlauf vorhanden.</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ol className="sk-timeline">
|
||||||
|
{events.map((e) => (
|
||||||
|
<li key={e.id} className="sk-timeline-item">
|
||||||
|
<span className="sk-timeline-mark" />
|
||||||
|
<div>
|
||||||
|
<div className="sk-timeline-body">{statusText(e.status)}</div>
|
||||||
|
{e.note && <div className="sk-timeline-time">{e.note}</div>}
|
||||||
|
<div className="sk-timeline-time">{zeitpunkt(e.timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Auftraege() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [orders, setOrders] = useState<Order[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [offen, setOffen] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMyOrders()
|
||||||
|
.then(setOrders)
|
||||||
|
.catch((err) => {
|
||||||
|
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Aufträge konnten nicht geladen werden.');
|
||||||
|
});
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sk-view">
|
||||||
|
<h1>Aufträge</h1>
|
||||||
|
<p className="sk-note" style={{ margin: 'var(--sk-space-2) 0 var(--sk-space-5)' }}>
|
||||||
|
Alle Ihre Aufträge mit aktuellem Stand. Klicken Sie einen Auftrag an, um den Verlauf zu sehen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className="sk-error">{error}</p>}
|
||||||
|
{orders === null && !error && <p className="sk-note">Wird geladen …</p>}
|
||||||
|
|
||||||
|
{orders && orders.length === 0 && (
|
||||||
|
<div className="sk-empty">
|
||||||
|
<p style={{ marginBottom: 'var(--sk-space-4)' }}>Sie haben noch keine Aufträge.</p>
|
||||||
|
<Link to="/" className="sk-btn sk-btn--primary">
|
||||||
|
Ersten Auftrag anlegen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{orders && orders.length > 0 && (
|
||||||
|
<ul className="sk-order-list">
|
||||||
|
{orders.map((o) => (
|
||||||
|
<li key={o.id}>
|
||||||
|
<button
|
||||||
|
className="sk-order sk-order--clickable"
|
||||||
|
aria-expanded={offen === o.id}
|
||||||
|
onClick={() => setOffen(offen === o.id ? null : o.id)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span className="sk-order-no" style={{ display: 'block' }}>
|
||||||
|
{o.order_number}
|
||||||
|
</span>
|
||||||
|
<span className="sk-order-meta">
|
||||||
|
{[o.product?.name, o.entries_count ? `${o.entries_count} Stück` : null, datum(o.date_created)]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 'var(--sk-space-4)' }}>
|
||||||
|
<span className="sk-chip">{statusText(o.status)}</span>
|
||||||
|
<span className="sk-order-total">{euro(o.gross_total)}</span>
|
||||||
|
<IconChevronDown
|
||||||
|
size={18}
|
||||||
|
className={`sk-order-caret${offen === o.id ? ' sk-order-caret--open' : ''}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{offen === o.id && (
|
||||||
|
<div className="sk-card" style={{ marginTop: 'var(--sk-space-2)' }}>
|
||||||
|
<h3 style={{ fontSize: 'var(--sk-size-sm)' }}>Verlauf</h3>
|
||||||
|
<Verlauf orderId={o.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1830
Webseite/skrift-app/src/routes/Configurator.tsx
Normal file
1830
Webseite/skrift-app/src/routes/Configurator.tsx
Normal file
File diff suppressed because it is too large
Load Diff
121
Webseite/skrift-app/src/routes/Login.tsx
Normal file
121
Webseite/skrift-app/src/routes/Login.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { requestLoginCode, verifyLoginCode, ApiError } from '../lib/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passwortloser Login: E-Mail eingeben → Code per Mail → Code eingeben.
|
||||||
|
* Die Antwort auf "Code anfordern" ist bewusst immer positiv – der Server
|
||||||
|
* verrät nicht, ob es zu einer Adresse ein Konto gibt.
|
||||||
|
*/
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [step, setStep] = useState<'email' | 'code'>('email');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function submitEmail(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await requestLoginCode(email.trim());
|
||||||
|
setStep('code');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Anfrage fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCode(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await verifyLoginCode(email.trim(), code.trim());
|
||||||
|
navigate('/auftraege', { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Anmeldung fehlgeschlagen.');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sk-view" style={{ maxWidth: 'var(--sk-form-width)' }}>
|
||||||
|
<h1>Anmelden</h1>
|
||||||
|
|
||||||
|
{step === 'email' ? (
|
||||||
|
<form className="sk-card" style={{ marginTop: 'var(--sk-space-5)' }} onSubmit={submitEmail}>
|
||||||
|
<p className="sk-note" style={{ marginBottom: 'var(--sk-space-5)' }}>
|
||||||
|
Sofern Sie bereits eine Bestellung bei uns aufgegeben haben, senden wir Ihnen einen
|
||||||
|
Anmeldecode an Ihre E-Mail-Adresse. Kein Passwort nötig.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="sk-field">
|
||||||
|
<label className="sk-label" htmlFor="email">
|
||||||
|
E-Mail-Adresse
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
className="sk-input"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="sk-btn sk-btn--primary sk-btn--block" disabled={busy}>
|
||||||
|
{busy ? 'Wird gesendet …' : 'Code anfordern'}
|
||||||
|
</button>
|
||||||
|
{error && <p className="sk-error">{error}</p>}
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form className="sk-card" style={{ marginTop: 'var(--sk-space-5)' }} onSubmit={submitCode}>
|
||||||
|
<p className="sk-note" style={{ marginBottom: 'var(--sk-space-5)' }}>
|
||||||
|
Wir haben einen Code an <strong>{email}</strong> gesendet. Er ist 10 Minuten gültig.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="sk-field">
|
||||||
|
<label className="sk-label" htmlFor="code">
|
||||||
|
Anmeldecode
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="code"
|
||||||
|
className="sk-input sk-input--code"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
maxLength={6}
|
||||||
|
pattern="\d{6}"
|
||||||
|
required
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="sk-btn sk-btn--primary sk-btn--block" disabled={busy || code.length !== 6}>
|
||||||
|
{busy ? 'Wird geprüft …' : 'Anmelden'}
|
||||||
|
</button>
|
||||||
|
{error && <p className="sk-error">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sk-btn sk-btn--ghost sk-btn--block"
|
||||||
|
style={{ marginTop: 'var(--sk-space-3)' }}
|
||||||
|
onClick={() => {
|
||||||
|
setStep('email');
|
||||||
|
setCode('');
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Andere Adresse verwenden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
Webseite/skrift-app/src/routes/Muster.tsx
Normal file
8
Webseite/skrift-app/src/routes/Muster.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Muster } from '../components/AppShell';
|
||||||
|
|
||||||
|
/** Musteranfrage als eigene Seite (Direktlink /muster) – kein Popup. */
|
||||||
|
export default function MusterSeite() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return <Muster variant="page" onClose={() => navigate('/')} />;
|
||||||
|
}
|
||||||
129
Webseite/skrift-app/src/routes/Stammdaten.tsx
Normal file
129
Webseite/skrift-app/src/routes/Stammdaten.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { fetchMyCustomer, saveMyCustomer, type Customer, type CustomerAddress } 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() {
|
||||||
|
const [c, setC] = useState<Customer | null>(null);
|
||||||
|
const [status, setStatus] = useState<'laden' | 'bereit' | 'speichert' | 'gespeichert'>('laden');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMyCustomer()
|
||||||
|
.then((data) => {
|
||||||
|
setC(data);
|
||||||
|
setStatus('bereit');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setError('Stammdaten konnten nicht geladen werden.');
|
||||||
|
setStatus('bereit');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function speichern(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!c) return;
|
||||||
|
setStatus('speichert');
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await saveMyCustomer(c);
|
||||||
|
setStatus('gespeichert');
|
||||||
|
setTimeout(() => setStatus('bereit'), 2500);
|
||||||
|
} catch {
|
||||||
|
setError('Speichern fehlgeschlagen.');
|
||||||
|
setStatus('bereit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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));
|
||||||
|
|
||||||
|
const feld = (k: keyof Customer, label: string, type = 'text') => (
|
||||||
|
<div className="sk-field">
|
||||||
|
<label className="sk-label" htmlFor={k}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={k}
|
||||||
|
className="sk-input"
|
||||||
|
type={type}
|
||||||
|
value={(c?.[k] as string) ?? ''}
|
||||||
|
onChange={(e) => c && setC({ ...c, [k]: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sk-view">
|
||||||
|
<h1>Stammdaten</h1>
|
||||||
|
<p className="sk-note" style={{ margin: 'var(--sk-space-2) 0 var(--sk-space-5)' }}>
|
||||||
|
Diese Angaben füllen wir bei neuen Aufträgen automatisch für Sie aus.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{status === 'laden' && <p className="sk-note">Wird geladen …</p>}
|
||||||
|
{error && <p className="sk-error">{error}</p>}
|
||||||
|
|
||||||
|
{c && (
|
||||||
|
<form className="sk-card" onSubmit={speichern}>
|
||||||
|
{/* Die Einstufung legt Skrift fest – sie bestimmt die Preisdarstellung. */}
|
||||||
|
<div className="sk-field">
|
||||||
|
<label className="sk-label">Sie bestellen als</label>
|
||||||
|
<input
|
||||||
|
className="sk-input"
|
||||||
|
value={c.person_type === 'unternehmen' ? 'Unternehmen' : 'Privatperson'}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{feld('first_name', 'Vorname')}
|
||||||
|
{feld('last_name', 'Nachname')}
|
||||||
|
{c.person_type === 'unternehmen' && feld('company', 'Firma')}
|
||||||
|
{feld('phone', 'Telefon', 'tel')}
|
||||||
|
|
||||||
|
<div className="sk-field">
|
||||||
|
<label className="sk-label">E-Mail</label>
|
||||||
|
<input className="sk-input" value={c.email} disabled />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Adressen – beliebig viele; im Konfigurator wählbar. */}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-5)' }} disabled={status === 'speichert'}>
|
||||||
|
{status === 'speichert' ? 'Wird gespeichert …' : status === 'gespeichert' ? 'Gespeichert' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1137
Webseite/skrift-app/src/styles/global.css
Normal file
1137
Webseite/skrift-app/src/styles/global.css
Normal file
File diff suppressed because it is too large
Load Diff
86
Webseite/skrift-app/src/styles/tokens.css
Normal file
86
Webseite/skrift-app/src/styles/tokens.css
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Skrift – Design-Tokens
|
||||||
|
*
|
||||||
|
* Übernommen aus dem bestehenden System (skrift-configurator/configurator.css
|
||||||
|
* und skrift-job-manager/job-app.css), damit App, Konfigurator und
|
||||||
|
* Auftragsmanager identisch aussehen. Werte hier nicht „verbessern" – sie sind
|
||||||
|
* die gemeinsame Grundlage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ── Farben (1:1 aus configurator.css) ──────────────────── */
|
||||||
|
--sk-bg: #f7f7f8;
|
||||||
|
--sk-card: #ffffff;
|
||||||
|
--sk-text: #0f172a;
|
||||||
|
--sk-muted: rgba(15, 23, 42, 0.68);
|
||||||
|
--sk-border: rgba(15, 23, 42, 0.1);
|
||||||
|
--sk-border-strong: rgba(15, 23, 42, 0.16);
|
||||||
|
--sk-accent: #1e3a6a;
|
||||||
|
--sk-accent-hover: #1a3258;
|
||||||
|
--sk-success: #059669;
|
||||||
|
--sk-warning: #f59e0b;
|
||||||
|
--sk-error: #dc2626;
|
||||||
|
|
||||||
|
/* Navigationsflächen dieser App.
|
||||||
|
Vorschlag: tiefes Marken-Navy statt des grellen Royalblaus – ruhiger und
|
||||||
|
markenkonsistent (identisch mit --sk-accent). Alternative: #0f172a (Slate).*/
|
||||||
|
--sk-nav: #1e3a6a;
|
||||||
|
--sk-nav-text: #ffffff;
|
||||||
|
--sk-nav-text-dim: rgba(255, 255, 255, 0.82);
|
||||||
|
--sk-nav-text-faint: rgba(255, 255, 255, 0.68);
|
||||||
|
|
||||||
|
/* Aliasse für bestehende Klassennamen in dieser App */
|
||||||
|
--sk-primary: var(--sk-accent);
|
||||||
|
--sk-primary-hover: var(--sk-accent-hover);
|
||||||
|
--sk-text-muted: var(--sk-muted);
|
||||||
|
--sk-text-on-primary: #ffffff;
|
||||||
|
--sk-white: var(--sk-card);
|
||||||
|
--sk-bg-1: var(--sk-bg);
|
||||||
|
--sk-bg-2: rgba(15, 23, 42, 0.04);
|
||||||
|
--sk-bg-3: #c9b7a8;
|
||||||
|
--sk-danger: var(--sk-error);
|
||||||
|
|
||||||
|
/* ── Radius & Abstände ──────────────────────────────────── */
|
||||||
|
--sk-radius-sm: 6px;
|
||||||
|
--sk-radius: 8px;
|
||||||
|
--sk-radius-lg: 8px; /* im Bestandssystem gibt es nur einen Radius */
|
||||||
|
--sk-gap: 18px;
|
||||||
|
|
||||||
|
--sk-space-1: 4px;
|
||||||
|
--sk-space-2: 8px;
|
||||||
|
--sk-space-3: 12px;
|
||||||
|
--sk-space-4: 16px;
|
||||||
|
--sk-space-5: 18px;
|
||||||
|
--sk-space-6: 24px;
|
||||||
|
--sk-space-7: 32px;
|
||||||
|
--sk-space-8: 48px;
|
||||||
|
|
||||||
|
/* ── Typografie ─────────────────────────────────────────── */
|
||||||
|
/* Work Sans als Familie, Größen aus dem Bestandssystem:
|
||||||
|
Bedienelemente 14px, Hilfetexte 12px, Kartentitel 16px.
|
||||||
|
(Die früher genannten 18px galten dem Fließtext und ließen die
|
||||||
|
Oberfläche „herangezoomt" wirken.) */
|
||||||
|
--sk-font: 'Work Sans', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||||
|
|
||||||
|
--sk-size-base: 14px;
|
||||||
|
--sk-size-xs: 12px;
|
||||||
|
--sk-size-sm: 13px;
|
||||||
|
--sk-size-md: 14px;
|
||||||
|
--sk-size-lg: 16px;
|
||||||
|
--sk-size-xl: 20px;
|
||||||
|
--sk-size-2xl: 24px;
|
||||||
|
--sk-leading: 1.5;
|
||||||
|
--sk-leading-tight: 1.3;
|
||||||
|
|
||||||
|
--sk-weight-normal: 400;
|
||||||
|
--sk-weight-medium: 500;
|
||||||
|
--sk-weight-semibold: 600;
|
||||||
|
--sk-weight-bold: 700;
|
||||||
|
--sk-weight-price: 750;
|
||||||
|
|
||||||
|
/* ── Layout ─────────────────────────────────────────────── */
|
||||||
|
--sk-app-width: 1400px;
|
||||||
|
--sk-sidebar-width: 248px;
|
||||||
|
--sk-read-width: 1200px; /* wie .sk-job-app */
|
||||||
|
--sk-form-width: 520px;
|
||||||
|
}
|
||||||
10
Webseite/skrift-app/src/vite-env.d.ts
vendored
Normal file
10
Webseite/skrift-app/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
/** URL der Directus-Instanz (Daten, Auth, Bestellungen). */
|
||||||
|
readonly VITE_DIRECTUS_URL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
20
Webseite/skrift-app/tsconfig.json
Normal file
20
Webseite/skrift-app/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
8
Webseite/skrift-app/vite.config.ts
Normal file
8
Webseite/skrift-app/vite.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: { port: 5173 },
|
||||||
|
build: { outDir: 'dist', sourcemap: false },
|
||||||
|
});
|
||||||
155
Webseite/skrift-preisrechner/formel.php
Normal file
155
Webseite/skrift-preisrechner/formel.php
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Kleiner, sicherer Formel-Interpreter (kein eval).
|
||||||
|
* Unterstützt: Zahlen, Variablen, + - * /, Klammern, unäres Minus,
|
||||||
|
* Vergleiche (>= <= > < == !=), Ternär (a ? b : c) und die Funktionen
|
||||||
|
* sqrt, pow, abs, min, max, round, floor, ceil.
|
||||||
|
*
|
||||||
|
* Damit rechnet das Plugin mit GENAU DER Formel, die in Directus hinterlegt
|
||||||
|
* ist – Änderungen an der Formel wirken automatisch mit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
/** Zerlegt den Ausdruck in Tokens. */
|
||||||
|
function skrift_formel_tokens($ausdruck) {
|
||||||
|
$s = (string) $ausdruck;
|
||||||
|
$s = str_replace('Math.', '', $s); // Math.sqrt(...) → sqrt(...)
|
||||||
|
$s = preg_replace('/%(\w+)%/', '$1', $s); // %qty% → qty
|
||||||
|
$tokens = array();
|
||||||
|
$pos = 0; $len = strlen($s);
|
||||||
|
while ($pos < $len) {
|
||||||
|
$c = $s[$pos];
|
||||||
|
if (ctype_space($c)) { $pos++; continue; }
|
||||||
|
if (ctype_digit($c) || ($c === '.' && $pos + 1 < $len && ctype_digit($s[$pos + 1]))) {
|
||||||
|
$start = $pos;
|
||||||
|
while ($pos < $len && (ctype_digit($s[$pos]) || $s[$pos] === '.')) $pos++;
|
||||||
|
$tokens[] = array('num', (float) substr($s, $start, $pos - $start));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ctype_alpha($c) || $c === '_') {
|
||||||
|
$start = $pos;
|
||||||
|
while ($pos < $len && (ctype_alnum($s[$pos]) || $s[$pos] === '_')) $pos++;
|
||||||
|
$tokens[] = array('id', substr($s, $start, $pos - $start));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$zwei = substr($s, $pos, 2);
|
||||||
|
if (in_array($zwei, array('>=', '<=', '==', '!='), true)) { $tokens[] = array('op', $zwei); $pos += 2; continue; }
|
||||||
|
$tokens[] = array('op', $c); $pos++;
|
||||||
|
}
|
||||||
|
return $tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skrift_formel_ist($t, $i, $wert) {
|
||||||
|
return $i < count($t) && $t[$i][0] === 'op' && $t[$i][1] === $wert;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skrift_formel_fn($name, $args) {
|
||||||
|
$a0 = isset($args[0]) ? (float) $args[0] : 0.0;
|
||||||
|
switch ($name) {
|
||||||
|
case 'sqrt': return $a0 < 0 ? 0.0 : sqrt($a0);
|
||||||
|
case 'pow': return pow($a0, isset($args[1]) ? (float) $args[1] : 0.0);
|
||||||
|
case 'abs': return abs($a0);
|
||||||
|
case 'min': return empty($args) ? 0.0 : min($args);
|
||||||
|
case 'max': return empty($args) ? 0.0 : max($args);
|
||||||
|
case 'round': return round($a0);
|
||||||
|
case 'floor': return floor($a0);
|
||||||
|
case 'ceil': return ceil($a0);
|
||||||
|
}
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skrift_formel_primary(&$i, $t, $v) {
|
||||||
|
if ($i >= count($t)) return 0.0;
|
||||||
|
$tk = $t[$i];
|
||||||
|
if ($tk[0] === 'num') { $i++; return $tk[1]; }
|
||||||
|
if ($tk[0] === 'id') {
|
||||||
|
$name = $tk[1]; $i++;
|
||||||
|
if (skrift_formel_ist($t, $i, '(')) {
|
||||||
|
$i++; $args = array();
|
||||||
|
if (!skrift_formel_ist($t, $i, ')')) {
|
||||||
|
$args[] = skrift_formel_ternary($i, $t, $v);
|
||||||
|
while (skrift_formel_ist($t, $i, ',')) { $i++; $args[] = skrift_formel_ternary($i, $t, $v); }
|
||||||
|
}
|
||||||
|
if (skrift_formel_ist($t, $i, ')')) $i++;
|
||||||
|
return skrift_formel_fn($name, $args);
|
||||||
|
}
|
||||||
|
return isset($v[$name]) ? (float) $v[$name] : 0.0;
|
||||||
|
}
|
||||||
|
if ($tk[0] === 'op' && $tk[1] === '(') {
|
||||||
|
$i++; $r = skrift_formel_ternary($i, $t, $v);
|
||||||
|
if (skrift_formel_ist($t, $i, ')')) $i++;
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
$i++; return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skrift_formel_unary(&$i, $t, $v) {
|
||||||
|
if (skrift_formel_ist($t, $i, '-')) { $i++; return -skrift_formel_unary($i, $t, $v); }
|
||||||
|
if (skrift_formel_ist($t, $i, '+')) { $i++; return skrift_formel_unary($i, $t, $v); }
|
||||||
|
return skrift_formel_primary($i, $t, $v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function skrift_formel_mul(&$i, $t, $v) {
|
||||||
|
$l = skrift_formel_unary($i, $t, $v);
|
||||||
|
while (skrift_formel_ist($t, $i, '*') || skrift_formel_ist($t, $i, '/')) {
|
||||||
|
$op = $t[$i][1]; $i++;
|
||||||
|
$r = skrift_formel_unary($i, $t, $v);
|
||||||
|
$l = $op === '*' ? $l * $r : ($r == 0 ? 0.0 : $l / $r);
|
||||||
|
}
|
||||||
|
return $l;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skrift_formel_add(&$i, $t, $v) {
|
||||||
|
$l = skrift_formel_mul($i, $t, $v);
|
||||||
|
while (skrift_formel_ist($t, $i, '+') || skrift_formel_ist($t, $i, '-')) {
|
||||||
|
$op = $t[$i][1]; $i++;
|
||||||
|
$r = skrift_formel_mul($i, $t, $v);
|
||||||
|
$l = $op === '+' ? $l + $r : $l - $r;
|
||||||
|
}
|
||||||
|
return $l;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skrift_formel_compare(&$i, $t, $v) {
|
||||||
|
$l = skrift_formel_add($i, $t, $v);
|
||||||
|
while ($i < count($t) && $t[$i][0] === 'op'
|
||||||
|
&& in_array($t[$i][1], array('>=', '<=', '==', '!=', '>', '<'), true)) {
|
||||||
|
$op = $t[$i][1]; $i++;
|
||||||
|
$r = skrift_formel_add($i, $t, $v);
|
||||||
|
switch ($op) {
|
||||||
|
case '>=': $l = $l >= $r; break;
|
||||||
|
case '<=': $l = $l <= $r; break;
|
||||||
|
case '>': $l = $l > $r; break;
|
||||||
|
case '<': $l = $l < $r; break;
|
||||||
|
case '==': $l = $l == $r; break;
|
||||||
|
case '!=': $l = $l != $r; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $l;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skrift_formel_ternary(&$i, $t, $v) {
|
||||||
|
$c = skrift_formel_compare($i, $t, $v);
|
||||||
|
if (skrift_formel_ist($t, $i, '?')) {
|
||||||
|
$i++;
|
||||||
|
$a = skrift_formel_ternary($i, $t, $v);
|
||||||
|
if (skrift_formel_ist($t, $i, ':')) $i++;
|
||||||
|
$b = skrift_formel_ternary($i, $t, $v);
|
||||||
|
return $c ? $a : $b;
|
||||||
|
}
|
||||||
|
return $c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wertet den Ausdruck aus. Bei Problemen wird $fallback zurückgegeben. */
|
||||||
|
function skrift_formel_eval($ausdruck, $vars, $fallback = 1.0) {
|
||||||
|
if (!is_string($ausdruck) || trim($ausdruck) === '') return $fallback;
|
||||||
|
try {
|
||||||
|
$t = skrift_formel_tokens($ausdruck);
|
||||||
|
$i = 0;
|
||||||
|
$r = (float) skrift_formel_ternary($i, $t, $vars);
|
||||||
|
if (!is_finite($r)) return $fallback;
|
||||||
|
return $r;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
416
Webseite/skrift-preisrechner/skrift-preisrechner.php
Normal file
416
Webseite/skrift-preisrechner/skrift-preisrechner.php
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Skrift Preisrechner
|
||||||
|
* Description: Preisrechner für Briefe/Postkarten per Shortcode. Holt Preisdaten stündlich per WP-Cron aus dem Skrift-Backend (öffentliche Read-API, ohne Token) und speichert sie in WordPress. Die Anzeige rechnet ohne Netzwerkzugriff – dadurch ohne Verzögerung. Shortcodes: [skrift_preis_inkl] und [skrift_preis_exkl].
|
||||||
|
* Version: 2.0.0
|
||||||
|
* Author: Skrift
|
||||||
|
*
|
||||||
|
* Sicherheit: Es werden ausschließlich ÖFFENTLICHE Werte gelesen (Basispreis,
|
||||||
|
* Formel-Parameter, MwSt, Porto) – keine Secrets, kein API-Token.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) exit;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/formel.php';
|
||||||
|
|
||||||
|
const SKRIFT_PREIS_OPTION = 'skrift_preis_daten';
|
||||||
|
const SKRIFT_PREIS_CRON = 'skrift_preis_sync';
|
||||||
|
/** Nach dieser Zeit gelten gespeicherte Daten als veraltet (Cron-Ausfall). */
|
||||||
|
const SKRIFT_PREIS_MAX_ALTER = 7200; // 2 Stunden
|
||||||
|
|
||||||
|
/** Basis-URL des Skrift-Backends (Directus). Per Filter überschreibbar. */
|
||||||
|
function skrift_preis_backend_url() {
|
||||||
|
return rtrim(apply_filters('skrift_preis_backend_url', 'https://admin.skrift.de'), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holt die Preisdaten frisch aus Directus und legt sie in einer WP-Option ab.
|
||||||
|
* Wird stündlich per Cron aufgerufen – nicht im normalen Seitenaufruf.
|
||||||
|
* Bei Fehlern bleiben die zuletzt gespeicherten Daten erhalten.
|
||||||
|
*/
|
||||||
|
function skrift_preis_sync() {
|
||||||
|
$base = skrift_preis_backend_url();
|
||||||
|
$fehler = null;
|
||||||
|
$hole = function ($pfad) use ($base, &$fehler) {
|
||||||
|
$res = wp_remote_get($base . $pfad, array('timeout' => 10));
|
||||||
|
if (is_wp_error($res)) {
|
||||||
|
if ($fehler === null) $fehler = 'Backend nicht erreichbar (' . $res->get_error_message() . ')';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$code = wp_remote_retrieve_response_code($res);
|
||||||
|
if ($code !== 200) {
|
||||||
|
if ($fehler === null) $fehler = 'Backend antwortete mit HTTP ' . $code . ' auf ' . $pfad;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$json = json_decode(wp_remote_retrieve_body($res), true);
|
||||||
|
if (!is_array($json) || !array_key_exists('data', $json)) {
|
||||||
|
if ($fehler === null) $fehler = 'Unerwartete Antwort auf ' . $pfad;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $json['data'];
|
||||||
|
};
|
||||||
|
|
||||||
|
$produkte = $hole('/items/products?fields=key,name,base_price,norm_qty,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');
|
||||||
|
|
||||||
|
// Nur bei Erfolg überschreiben – sonst bleiben die alten Werte stehen.
|
||||||
|
if ($fehler === null && is_array($produkte) && !empty($produkte)) {
|
||||||
|
update_option(SKRIFT_PREIS_OPTION, array(
|
||||||
|
'produkte' => $produkte,
|
||||||
|
'settings' => is_array($settings) ? $settings : array(),
|
||||||
|
'items' => is_array($items) ? $items : array(),
|
||||||
|
'zeit' => time(),
|
||||||
|
'fehler' => null,
|
||||||
|
), false); // kein autoload – wird nur bei Bedarf geladen
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fehler am vorhandenen Datensatz vermerken (für die Diagnose in der Ausgabe).
|
||||||
|
$alt = get_option(SKRIFT_PREIS_OPTION);
|
||||||
|
if (is_array($alt)) {
|
||||||
|
$alt['fehler'] = $fehler;
|
||||||
|
update_option(SKRIFT_PREIS_OPTION, $alt, false);
|
||||||
|
} else {
|
||||||
|
update_option(SKRIFT_PREIS_OPTION, array(
|
||||||
|
'produkte' => array(), 'settings' => array(), 'items' => array(),
|
||||||
|
'zeit' => 0, 'fehler' => $fehler,
|
||||||
|
), false);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
add_action(SKRIFT_PREIS_CRON, 'skrift_preis_sync');
|
||||||
|
|
||||||
|
/** Stündlichen Cron beim Aktivieren einplanen und sofort einmal synchronisieren. */
|
||||||
|
register_activation_hook(__FILE__, function () {
|
||||||
|
if (!wp_next_scheduled(SKRIFT_PREIS_CRON)) {
|
||||||
|
wp_schedule_event(time() + 60, 'hourly', SKRIFT_PREIS_CRON);
|
||||||
|
}
|
||||||
|
skrift_preis_sync();
|
||||||
|
});
|
||||||
|
register_deactivation_hook(__FILE__, function () {
|
||||||
|
$ts = wp_next_scheduled(SKRIFT_PREIS_CRON);
|
||||||
|
if ($ts) wp_unschedule_event($ts, SKRIFT_PREIS_CRON);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Sicherheitsnetz: fehlt der Cron-Eintrag (z. B. nach Migration), neu planen. */
|
||||||
|
add_action('init', function () {
|
||||||
|
if (!wp_next_scheduled(SKRIFT_PREIS_CRON)) {
|
||||||
|
wp_schedule_event(time() + 60, 'hourly', SKRIFT_PREIS_CRON);
|
||||||
|
}
|
||||||
|
// Manuelles Aktualisieren als Admin: ?skrift_preis_refresh=1
|
||||||
|
if (isset($_GET['skrift_preis_refresh']) && current_user_can('manage_options')) {
|
||||||
|
skrift_preis_sync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die gespeicherten Preisdaten. Kein HTTP im Normalfall.
|
||||||
|
* Nur wenn noch nie synchronisiert wurde (oder die Daten deutlich veraltet
|
||||||
|
* sind), wird einmalig nachgeholt.
|
||||||
|
*/
|
||||||
|
function skrift_preis_daten() {
|
||||||
|
$d = get_option(SKRIFT_PREIS_OPTION);
|
||||||
|
$leer = !is_array($d) || empty($d['produkte']);
|
||||||
|
$alt = is_array($d) && isset($d['zeit']) && (time() - (int) $d['zeit']) > SKRIFT_PREIS_MAX_ALTER;
|
||||||
|
if ($leer || $alt) {
|
||||||
|
skrift_preis_sync();
|
||||||
|
$d = get_option(SKRIFT_PREIS_OPTION);
|
||||||
|
}
|
||||||
|
if (!is_array($d)) {
|
||||||
|
$d = array('produkte' => array(), 'settings' => array(), 'items' => array(), 'zeit' => 0, 'fehler' => 'Keine Daten.');
|
||||||
|
}
|
||||||
|
return $d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einen Preis-Positionswert (z. B. porto_inland) aus dem Katalog lesen. */
|
||||||
|
function skrift_preis_item($items, $key) {
|
||||||
|
foreach ($items as $it) {
|
||||||
|
if (isset($it['key']) && $it['key'] === $key) return (float) $it['price'];
|
||||||
|
}
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Produkt per Schlüssel finden. */
|
||||||
|
function skrift_preis_produkt($produkte, $key) {
|
||||||
|
foreach ($produkte as $p) {
|
||||||
|
if (isset($p['key']) && $p['key'] === $key) return $p;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die für die Rechnung nötigen Parameter eines Produkts – Grundlage sowohl für
|
||||||
|
* die PHP-Rechnung als auch für die identische Rechnung im Browser.
|
||||||
|
*/
|
||||||
|
function skrift_preis_parameter($daten, $produktKey, $typ = 'privat') {
|
||||||
|
$s = $daten['settings'];
|
||||||
|
$p = skrift_preis_produkt($daten['produkte'], $produktKey);
|
||||||
|
if (!$p) return null;
|
||||||
|
|
||||||
|
// Normalpreis-Menge kommt vom Produkt; ohne Wert kein Kleinmengen-Aufschlag.
|
||||||
|
$norm = isset($p['norm_qty']) ? (float) $p['norm_qty'] : 0.0;
|
||||||
|
// Eine gemeinsame Formel.
|
||||||
|
$formel = !empty($s['multiplier_formula']) ? $s['multiplier_formula'] : '';
|
||||||
|
|
||||||
|
$tiers = array();
|
||||||
|
if (isset($s['quantity_discounts']) && is_array($s['quantity_discounts'])) {
|
||||||
|
foreach ($s['quantity_discounts'] as $t) {
|
||||||
|
$tiers[] = array(
|
||||||
|
'min_qty' => isset($t['min_qty']) ? (int) $t['min_qty'] : 0,
|
||||||
|
'percent' => isset($t['percent']) ? (float) $t['percent'] : 0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'base' => isset($p['base_price']) ? (float) $p['base_price'] : 0.0,
|
||||||
|
'norm' => $norm,
|
||||||
|
'formel' => $formel,
|
||||||
|
'mwst' => isset($s['mwst_percent']) ? (float) $s['mwst_percent'] : 19.0,
|
||||||
|
'tiers' => $tiers,
|
||||||
|
'porto' => skrift_preis_item($daten['items'], 'porto_inland'),
|
||||||
|
'name' => isset($p['name']) ? $p['name'] : $produktKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kernrechnung in PHP (spiegelt die Standard-Formel des Backends):
|
||||||
|
* grund = base * menge * multiplikator
|
||||||
|
* multiplik. = menge >= norm ? 1 : 2 - sqrt(menge / norm)
|
||||||
|
* danach Mengenrabatt-Staffel, dann / menge = Preis pro Stück (netto).
|
||||||
|
*/
|
||||||
|
function skrift_preis_rechne_param($par, $menge, $mitKuvert) {
|
||||||
|
if (!$par) return null;
|
||||||
|
$menge = max(1, (int) $menge);
|
||||||
|
|
||||||
|
// Multiplikator exakt mit der in Directus hinterlegten Formel berechnen.
|
||||||
|
// Ohne Normalpreis-Menge am Produkt greift kein Kleinmengen-Aufschlag.
|
||||||
|
$mult = 1.0;
|
||||||
|
if ($par['norm'] > 0) {
|
||||||
|
$mult = skrift_formel_eval($par['formel'], array(
|
||||||
|
'qty' => $menge, 'menge' => $menge,
|
||||||
|
'norm' => $par['norm'], 'base_price' => $par['base'],
|
||||||
|
), 1.0);
|
||||||
|
}
|
||||||
|
if (!is_finite($mult) || $mult <= 0) $mult = 1.0;
|
||||||
|
|
||||||
|
$grund = $par['base'] * $menge * $mult;
|
||||||
|
|
||||||
|
$besteMin = -1; $bestePercent = 0.0;
|
||||||
|
foreach ($par['tiers'] as $t) {
|
||||||
|
if ($menge >= $t['min_qty'] && $t['percent'] > 0 && $t['min_qty'] > $besteMin) {
|
||||||
|
$besteMin = $t['min_qty']; $bestePercent = $t['percent'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($bestePercent > 0) $grund *= (1 - $bestePercent / 100);
|
||||||
|
|
||||||
|
$netto = $grund / $menge;
|
||||||
|
if ($mitKuvert) $netto += $par['porto'];
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'netto' => round($netto, 2),
|
||||||
|
'brutto' => round($netto * (1 + $par['mwst'] / 100), 2),
|
||||||
|
'mwst' => $par['mwst'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** REST-Endpunkt bleibt bestehen (PHP-Rechnung), z. B. für eigene Integrationen. */
|
||||||
|
add_action('rest_api_init', function () {
|
||||||
|
register_rest_route('skrift/v1', '/preis', array(
|
||||||
|
'methods' => 'GET',
|
||||||
|
'permission_callback' => '__return_true',
|
||||||
|
'callback' => function (WP_REST_Request $req) {
|
||||||
|
$daten = skrift_preis_daten();
|
||||||
|
$par = skrift_preis_parameter(
|
||||||
|
$daten,
|
||||||
|
sanitize_text_field($req->get_param('produkt')),
|
||||||
|
$req->get_param('typ') === 'unternehmen' ? 'unternehmen' : 'privat'
|
||||||
|
);
|
||||||
|
$r = skrift_preis_rechne_param(
|
||||||
|
$par,
|
||||||
|
(int) $req->get_param('menge'),
|
||||||
|
$req->get_param('kuvert') === '1' || $req->get_param('kuvert') === 'true'
|
||||||
|
);
|
||||||
|
if (!$r) {
|
||||||
|
$grund = !empty($daten['fehler']) ? $daten['fehler'] : 'Produkt nicht gefunden.';
|
||||||
|
return new WP_Error('skrift_preis', $grund, array('status' => 404));
|
||||||
|
}
|
||||||
|
return rest_ensure_response($r);
|
||||||
|
},
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Darstellung. $mitMwst bestimmt, WELCHER Preis gezeigt wird – der Besucher
|
||||||
|
* wählt das bewusst nicht selbst.
|
||||||
|
*
|
||||||
|
* Die Parameter stehen im Seitenquelltext; die Neuberechnung passiert dadurch
|
||||||
|
* sofort im Browser (kein Netzwerkzugriff, keine Latenz) mit exakt derselben
|
||||||
|
* Formel wie in PHP.
|
||||||
|
*/
|
||||||
|
function skrift_preis_render($atts, $mitMwst) {
|
||||||
|
$a = shortcode_atts(array('produkt' => 'briefe', 'typ' => 'privat', 'menge' => 100), $atts);
|
||||||
|
$daten = skrift_preis_daten();
|
||||||
|
$par = skrift_preis_parameter($daten, $a['produkt'], $a['typ']);
|
||||||
|
|
||||||
|
if (!$par) {
|
||||||
|
if (!empty($daten['fehler'])) {
|
||||||
|
return '<p><strong>Preisrechner nicht verfügbar.</strong><br><span style="color:#64748b;font-size:13px;">'
|
||||||
|
. esc_html($daten['fehler']) . '</span></p>';
|
||||||
|
}
|
||||||
|
$bekannt = array();
|
||||||
|
foreach ($daten['produkte'] as $pp) { if (isset($pp['key'])) $bekannt[] = $pp['key']; }
|
||||||
|
return '<p><strong>Preisrechner:</strong> Produkt „' . esc_html($a['produkt']) . '" nicht gefunden.'
|
||||||
|
. (count($bekannt) ? '<br><span style="color:#64748b;font-size:13px;">Verfügbar: '
|
||||||
|
. esc_html(implode(', ', $bekannt)) . '</span>' : '') . '</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$startMenge = max(1, (int) $a['menge']);
|
||||||
|
$start = skrift_preis_rechne_param($par, $startMenge, false);
|
||||||
|
$startWert = $mitMwst ? $start['brutto'] : $start['netto'];
|
||||||
|
$hinweis = $mitMwst
|
||||||
|
? 'inkl. ' . rtrim(rtrim(number_format($par['mwst'], 1, ',', '.'), '0'), ',') . ' % MwSt.'
|
||||||
|
: 'zzgl. MwSt.';
|
||||||
|
|
||||||
|
$id = 'skpreis_' . wp_rand(1000, 9999);
|
||||||
|
|
||||||
|
ob_start(); ?>
|
||||||
|
<div class="sk-preisrechner" id="<?php echo esc_attr($id); ?>" style="width:100%;box-sizing:border-box;color:#1A1A1A;font-family:var(--e-global-typography-text-font-family, inherit);font-weight:var(--e-global-typography-text-font-weight, inherit);">
|
||||||
|
<label style="display:block;font-size:0.875em;margin-bottom:8px;color:#1A1A1A;">Anzahl</label>
|
||||||
|
<input type="number" min="1" value="<?php echo esc_attr($startMenge); ?>" class="skp-menge"
|
||||||
|
style="width:100%;box-sizing:border-box;padding:12px 14px;border:1px solid #d8dde5;border-radius:8px;font-family:inherit;font-size:1em;color:#1A1A1A;margin-bottom:18px;">
|
||||||
|
<label style="display:flex;align-items:flex-start;gap:10px;font-size:0.95em;color:#1A1A1A;margin-bottom:20px;cursor:pointer;">
|
||||||
|
<input type="checkbox" class="skp-kuvert" style="margin-top:4px;flex:0 0 auto;">
|
||||||
|
<span>Mit handbeschriebenem Kuvert und Direktversand an die Empfänger</span>
|
||||||
|
</label>
|
||||||
|
<div style="border-top:1px solid #e6e6e6;padding-top:16px;display:flex;justify-content:space-between;align-items:baseline;gap:16px;">
|
||||||
|
<span style="font-size:1em;">Preis pro Stück</span>
|
||||||
|
<strong class="skp-wert" style="font-size:1.5em;font-family:var(--e-global-typography-primary-font-family, inherit);white-space:nowrap;"><?php echo esc_html(number_format($startWert, 2, ',', '.')); ?> €</strong>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right;font-size:0.8em;opacity:.65;margin-top:4px;"><?php echo esc_html($hinweis); ?></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var wrap = document.getElementById(<?php echo wp_json_encode($id); ?>);
|
||||||
|
if (!wrap) return;
|
||||||
|
var par = <?php echo wp_json_encode($par); ?>;
|
||||||
|
var mitMwst = <?php echo $mitMwst ? 'true' : 'false'; ?>;
|
||||||
|
var menge = wrap.querySelector('.skp-menge');
|
||||||
|
var kuvert = wrap.querySelector('.skp-kuvert');
|
||||||
|
var wert = wrap.querySelector('.skp-wert');
|
||||||
|
|
||||||
|
// Formel-Interpreter (Spiegel der PHP-Fassung) – wertet die in Directus
|
||||||
|
// hinterlegte Formel aus, damit Änderungen automatisch mitwirken.
|
||||||
|
function formelEval(ausdruck, vars) {
|
||||||
|
if (!ausdruck) return 1;
|
||||||
|
var s = String(ausdruck).replace(/Math\./g, '').replace(/%(\w+)%/g, '$1');
|
||||||
|
var T = [], i = 0;
|
||||||
|
while (i < s.length) {
|
||||||
|
var c = s[i];
|
||||||
|
if (/\s/.test(c)) { i++; continue; }
|
||||||
|
if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(s[i + 1] || ''))) {
|
||||||
|
var st = i; while (i < s.length && /[0-9.]/.test(s[i])) i++;
|
||||||
|
T.push(['num', parseFloat(s.slice(st, i))]); continue;
|
||||||
|
}
|
||||||
|
if (/[A-Za-z_]/.test(c)) {
|
||||||
|
var sa = i; while (i < s.length && /[A-Za-z0-9_]/.test(s[i])) i++;
|
||||||
|
T.push(['id', s.slice(sa, i)]); continue;
|
||||||
|
}
|
||||||
|
var zwei = s.substr(i, 2);
|
||||||
|
if (['>=', '<=', '==', '!='].indexOf(zwei) >= 0) { T.push(['op', zwei]); i += 2; continue; }
|
||||||
|
T.push(['op', c]); i++;
|
||||||
|
}
|
||||||
|
var k = 0;
|
||||||
|
function ist(w) { return k < T.length && T[k][0] === 'op' && T[k][1] === w; }
|
||||||
|
function fn(n, a) {
|
||||||
|
var x = a[0] || 0;
|
||||||
|
switch (n) {
|
||||||
|
case 'sqrt': return x < 0 ? 0 : Math.sqrt(x);
|
||||||
|
case 'pow': return Math.pow(x, a[1] || 0);
|
||||||
|
case 'abs': return Math.abs(x);
|
||||||
|
case 'min': return Math.min.apply(null, a);
|
||||||
|
case 'max': return Math.max.apply(null, a);
|
||||||
|
case 'round': return Math.round(x);
|
||||||
|
case 'floor': return Math.floor(x);
|
||||||
|
case 'ceil': return Math.ceil(x);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
function primary() {
|
||||||
|
if (k >= T.length) return 0;
|
||||||
|
var t = T[k];
|
||||||
|
if (t[0] === 'num') { k++; return t[1]; }
|
||||||
|
if (t[0] === 'id') {
|
||||||
|
var n = t[1]; k++;
|
||||||
|
if (ist('(')) {
|
||||||
|
k++; var a = [];
|
||||||
|
if (!ist(')')) { a.push(ternary()); while (ist(',')) { k++; a.push(ternary()); } }
|
||||||
|
if (ist(')')) k++;
|
||||||
|
return fn(n, a);
|
||||||
|
}
|
||||||
|
return vars[n] !== undefined ? Number(vars[n]) : 0;
|
||||||
|
}
|
||||||
|
if (ist('(')) { k++; var r = ternary(); if (ist(')')) k++; return r; }
|
||||||
|
k++; return 0;
|
||||||
|
}
|
||||||
|
function unary() { if (ist('-')) { k++; return -unary(); } if (ist('+')) { k++; return unary(); } return primary(); }
|
||||||
|
function mul() { var l = unary(); while (ist('*') || ist('/')) { var o = T[k][1]; k++; var r = unary(); l = o === '*' ? l * r : (r === 0 ? 0 : l / r); } return l; }
|
||||||
|
function add() { var l = mul(); while (ist('+') || ist('-')) { var o = T[k][1]; k++; var r = mul(); l = o === '+' ? l + r : l - r; } return l; }
|
||||||
|
function cmp() {
|
||||||
|
var l = add();
|
||||||
|
while (k < T.length && T[k][0] === 'op' && ['>=', '<=', '==', '!=', '>', '<'].indexOf(T[k][1]) >= 0) {
|
||||||
|
var o = T[k][1]; k++; var r = add();
|
||||||
|
l = o === '>=' ? l >= r : o === '<=' ? l <= r : o === '>' ? l > r : o === '<' ? l < r : o === '==' ? l == r : l != r;
|
||||||
|
}
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
function ternary() {
|
||||||
|
var c = cmp();
|
||||||
|
if (ist('?')) { k++; var a = ternary(); if (ist(':')) k++; var b = ternary(); return c ? a : b; }
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
var out = Number(ternary());
|
||||||
|
return isFinite(out) ? out : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
: 1;
|
||||||
|
if (!isFinite(mult) || mult <= 0) mult = 1;
|
||||||
|
var grund = par.base * m * mult;
|
||||||
|
var besteMin = -1, bestePercent = 0;
|
||||||
|
(par.tiers || []).forEach(function (t) {
|
||||||
|
if (m >= t.min_qty && t.percent > 0 && t.min_qty > besteMin) { besteMin = t.min_qty; bestePercent = t.percent; }
|
||||||
|
});
|
||||||
|
if (bestePercent > 0) grund *= (1 - bestePercent / 100);
|
||||||
|
var netto = grund / m;
|
||||||
|
if (mitKuvert) netto += par.porto;
|
||||||
|
return mitMwst ? netto * (1 + par.mwst / 100) : netto;
|
||||||
|
}
|
||||||
|
function fmt(v) { return v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'; }
|
||||||
|
function aktualisiere() { wert.textContent = fmt(berechne(menge.value, kuvert.checked)); }
|
||||||
|
|
||||||
|
menge.addEventListener('input', aktualisiere);
|
||||||
|
kuvert.addEventListener('change', aktualisiere);
|
||||||
|
// Erst beim Verlassen des Feldes auf 1 zurücksetzen – währenddessen darf es leer sein.
|
||||||
|
menge.addEventListener('blur', function () {
|
||||||
|
var n = parseInt(menge.value, 10);
|
||||||
|
if (!(n >= 1)) { menge.value = '1'; aktualisiere(); }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [skrift_preis_inkl produkt="briefe"] – Preis inkl. MwSt. */
|
||||||
|
add_shortcode('skrift_preis_inkl', function ($atts) { return skrift_preis_render($atts, true); });
|
||||||
|
|
||||||
|
/** [skrift_preis_exkl produkt="briefe"] – Preis exkl. MwSt. */
|
||||||
|
add_shortcode('skrift_preis_exkl', function ($atts) { return skrift_preis_render($atts, false); });
|
||||||
|
|
||||||
|
/** Alt-Shortcode bleibt gültig und zeigt den Bruttopreis. */
|
||||||
|
add_shortcode('skrift_preis', function ($atts) { return skrift_preis_render($atts, true); });
|
||||||
Reference in New Issue
Block a user