Compare commits
10 Commits
main
...
feature/di
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57d48409e6 | ||
|
|
c535af5781 | ||
|
|
1d5c1a14d4 | ||
|
|
db419bf8eb | ||
|
|
0b2f2ba50a | ||
|
|
26040bd68f | ||
|
|
3cb93d078e | ||
|
|
6232cecdb1 | ||
|
|
f77ddb6a4c | ||
|
|
445ebdec5a |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -34,6 +34,11 @@ dist/
|
|||||||
# ihr dist/ IST der Quellcode und muss ins Repo.
|
# ihr dist/ IST der Quellcode und muss ins Repo.
|
||||||
!Docker/directus/extensions/*/dist/
|
!Docker/directus/extensions/*/dist/
|
||||||
|
|
||||||
|
# ── Website-/DB-Backups (NIE ins Repo – enthalten PII, DB-Dump, Secrets) ──
|
||||||
|
/*.zip
|
||||||
|
skrift.de__*.zip
|
||||||
|
*sqldump*.sql
|
||||||
|
|
||||||
# ── OS / Editor ─────────────────────────────────────────────
|
# ── OS / Editor ─────────────────────────────────────────────
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ SKRIFT_BACKEND_TOKEN=CHANGE-ME-gleich-wie-backend-API_TOKEN
|
|||||||
SKRIFT_PREVIEW_LIMIT=10
|
SKRIFT_PREVIEW_LIMIT=10
|
||||||
# Zieladresse für Angebots-/Musteranfragen
|
# Zieladresse für Angebots-/Musteranfragen
|
||||||
SKRIFT_TEAM_EMAIL=hello@skrift.de
|
SKRIFT_TEAM_EMAIL=hello@skrift.de
|
||||||
|
# Shared Secret für den WordPress-Webhook (JetFormBuilder → /skrift-orders/inbound/*).
|
||||||
|
# Frei wählbarer langer Zufallswert; muss identisch als ?token=... in der
|
||||||
|
# Webhook-URL stehen. Ohne diesen Wert lehnt der Webhook alle Anfragen ab.
|
||||||
|
SKRIFT_WEBHOOK_TOKEN=CHANGE-ME-langer-zufallswert
|
||||||
|
# Signatur-Plotter-Service (intern über das npm-Netz). Default passt zum container_name.
|
||||||
|
SKRIFT_SIGN_URL=http://skrift-signature-service:8000
|
||||||
|
|
||||||
# ── PayPal (server-seitige Zahlungsprüfung) ─────────────────
|
# ── PayPal (server-seitige Zahlungsprüfung) ─────────────────
|
||||||
# Client-ID ist öffentlich (auch im Frontend), das Secret bleibt NUR hier.
|
# Client-ID ist öffentlich (auch im Frontend), das Secret bleibt NUR hier.
|
||||||
|
|||||||
@@ -56,10 +56,27 @@ async function api(method, path, body) {
|
|||||||
return data?.data ?? data;
|
return data?.data ?? data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Prüft, ob der gesetzte Token wirklich gültig ist. */
|
||||||
|
async function tokenGueltig() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(DIRECTUS_URL + '/users/me?fields=id', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
if (token) return;
|
// Ein mitgegebener DIRECTUS_TOKEN (z. B. versehentlich aus der .env
|
||||||
|
// übernommen) wird geprüft – sonst schlägt erst der nächste Aufruf mit einem
|
||||||
|
// wenig hilfreichen 401 fehl.
|
||||||
|
if (token) {
|
||||||
|
if (await tokenGueltig()) return;
|
||||||
|
console.warn(' DIRECTUS_TOKEN ist ungültig – versuche Login mit ADMIN_EMAIL/ADMIN_PASSWORD.');
|
||||||
|
token = '';
|
||||||
|
}
|
||||||
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
|
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
|
||||||
console.error('Weder DIRECTUS_TOKEN noch ADMIN_EMAIL/ADMIN_PASSWORD gesetzt.');
|
console.error('DIRECTUS_TOKEN ungültig bzw. nicht gesetzt und ADMIN_EMAIL/ADMIN_PASSWORD fehlen.');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const res = await fetch(DIRECTUS_URL + '/auth/login', {
|
const res = await fetch(DIRECTUS_URL + '/auth/login', {
|
||||||
@@ -113,8 +130,16 @@ const USER_FIELDS = [
|
|||||||
dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat', note: 'Einstufung – nur Betreiber ändert sie.' }),
|
dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat', note: 'Einstufung – nur Betreiber ändert sie.' }),
|
||||||
f('company', 'string', { interface: 'input', note: 'Firmenname (bei Unternehmen).' }),
|
f('company', 'string', { interface: 'input', note: 'Firmenname (bei Unternehmen).' }),
|
||||||
f('phone', 'string', { interface: 'input' }),
|
f('phone', 'string', { interface: 'input' }),
|
||||||
f('addresses', 'json', { interface: 'list', note: 'Adressen des Kunden', options: { fields: ADDRESS_SUBFIELDS } }),
|
// Genau zwei feste Adressen je Konto (ohne Namen/Bezeichnung): Rechnung + Lieferung.
|
||||||
|
// Werden automatisch an der passenden Stelle im Bestellvorgang eingesetzt.
|
||||||
|
f('billing_address', 'json', { interface: 'input-code', note: 'Rechnungsadresse {street,zip,city,country}.' }),
|
||||||
|
f('shipping_address', 'json', { interface: 'input-code', note: 'Lieferadresse {street,zip,city,country}.' }),
|
||||||
|
// Veraltet (frühere Mehrfach-Adressliste) – bleibt für Alt-Daten erhalten.
|
||||||
|
f('addresses', 'json', { interface: 'list', hidden: true, note: 'Veraltet – ersetzt durch billing_address/shipping_address.', options: { fields: ADDRESS_SUBFIELDS } }),
|
||||||
f('notes', 'text', { interface: 'input-multiline', note: 'Interne Notizen (für den Kunden nicht sichtbar).' }),
|
f('notes', 'text', { interface: 'input-multiline', note: 'Interne Notizen (für den Kunden nicht sichtbar).' }),
|
||||||
|
// Zusätzliche Login-Mailadressen: Wer sich mit einer dieser Mails per Code
|
||||||
|
// anmeldet, landet im SELBEN Konto (voller Zugriff). Liste von E-Mails.
|
||||||
|
f('alt_emails', 'json', { interface: 'tags', note: 'Weitere Login-Mailadressen (gleiches Konto).' }),
|
||||||
{ field: 'signatures', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Hinterlegte Unterschriften des Kunden.' }, schema: null },
|
{ field: '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: '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 },
|
{ field: 'unlocked_products', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Individuell freigeschaltete Produkte.' }, schema: null },
|
||||||
@@ -132,13 +157,19 @@ const collections = [
|
|||||||
f('key', 'string', { interface: 'input', unique: true, required: true }),
|
f('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', 'envelope', 'sample', 'contact'], { required: true }),
|
||||||
dropdown('visibility', ['alle', 'privat', 'unternehmen'], { default: 'alle', note: 'Für wen im Konfigurator sichtbar.' }),
|
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.
|
// Ab dieser Menge gilt der Normalpreis (Multiplikator 1) – je Produkt.
|
||||||
// Leer = Rückfall auf die globale Norm-Menge in den Preis-Einstellungen.
|
// 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.' }),
|
f('norm_qty', 'float', { interface: 'input', note: 'Menge, ab der der Normalpreis gilt (Multiplikator 1). Leer = globaler Wert.' }),
|
||||||
|
// Kleinmengen-Aufschlag je Produkt (fließt als a/e in die Multiplikator-Formel):
|
||||||
|
// a = Deckel (bei Menge→0 ist der Multiplikator 1 + a), e = Abklingform
|
||||||
|
// (großes e = Aufschlag fällt schnell; kleines e = bleibt über mittlere Mengen hoch).
|
||||||
|
// Leer → a = 1, e = 3 (bisheriges Verhalten). Postkarten z. B. a = 1.5, e = 2.
|
||||||
|
f('price_factor_a', 'float', { interface: 'input', default: 1, note: 'Kleinmengen-Aufschlag: Deckel a (Multiplikator bei Menge→0 = 1 + a). Leer = 1.' }),
|
||||||
|
f('price_factor_e', 'float', { interface: 'input', default: 3, note: 'Kleinmengen-Aufschlag: Exponent e (größer = fällt schneller ab). Leer = 3.' }),
|
||||||
// Eingabeart im Konfigurator: freier Text oder Datei-Upload (z. B. ein zu
|
// Eingabeart im Konfigurator: freier Text oder Datei-Upload (z. B. ein zu
|
||||||
// unterschreibendes PDF beim Unterschriftenservice).
|
// unterschreibendes PDF beim Unterschriftenservice).
|
||||||
dropdown('input_mode', ['text', 'datei'], { default: 'text', note: 'Text-Eingabe oder Datei-Upload.' }),
|
dropdown('input_mode', ['text', 'datei'], { default: 'text', note: 'Text-Eingabe oder Datei-Upload.' }),
|
||||||
@@ -178,7 +209,7 @@ const collections = [
|
|||||||
// Beispiel: (qty >= norm_b) ? 1 : (2 - sqrt(qty / norm_b))
|
// Beispiel: (qty >= norm_b) ? 1 : (2 - sqrt(qty / norm_b))
|
||||||
// Eine gemeinsame Formel für Privat und Unternehmen. `norm` ist die
|
// Eine gemeinsame Formel für Privat und Unternehmen. `norm` ist die
|
||||||
// Normalpreis-Menge des jeweiligen Produkts (products.norm_qty).
|
// Normalpreis-Menge des jeweiligen Produkts (products.norm_qty).
|
||||||
f('multiplier_formula', 'text', { interface: 'input-multiline', note: 'Mengen-Multiplikator (gilt für Privat und Unternehmen). Variablen: qty, norm, base_price. Funktionen: sqrt, pow, min, max, round …' }),
|
f('multiplier_formula', 'text', { interface: 'input-multiline', note: 'Mengen-Multiplikator (gilt für Privat und Unternehmen). Variablen: qty, norm, base_price, a, e (a/e je Produkt). Funktionen: sqrt, pow, min, max, round …' }),
|
||||||
f('mwst_percent', 'float', { interface: 'input', default: 19 }),
|
f('mwst_percent', 'float', { interface: 'input', default: 19 }),
|
||||||
// Mengenrabatt-Staffel: ab min_qty Stück gilt percent % Rabatt auf den
|
// Mengenrabatt-Staffel: ab min_qty Stück gilt percent % Rabatt auf den
|
||||||
// Grundpreis. Es greift die höchste passende Stufe. Frei in Directus pflegbar.
|
// Grundpreis. Es greift die höchste passende Stufe. Frei in Directus pflegbar.
|
||||||
@@ -218,7 +249,19 @@ const collections = [
|
|||||||
m2o('format', 'formats'),
|
m2o('format', 'formats'),
|
||||||
dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat' }),
|
dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat' }),
|
||||||
dropdown('font', ['tilda', 'alva', 'ellie']),
|
dropdown('font', ['tilda', 'alva', 'ellie']),
|
||||||
dropdown('source', ['configurator', 'portal', 'operator'], { default: 'configurator' }),
|
f('realistic', 'boolean', { interface: 'boolean', default: true, note: 'Natürliche Handschrift-Variation (Jitter/Neigung).' }),
|
||||||
|
m2o('api_client', 'api_clients', { onDelete: 'SET NULL', note: 'Gesetzt bei Aufträgen über die Public-API.' }),
|
||||||
|
dropdown('shipping_day', ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'], { note: 'Vom Kunden gewünschter Versandtag (optional, v. a. Public-API).' }),
|
||||||
|
f('font_scale_letter', 'float', { interface: 'input', default: 1, note: 'Relative Schriftgröße Brief (0.8–1.5, 1 = Standard).' }),
|
||||||
|
f('font_scale_envelope', 'float', { interface: 'input', default: 1, note: 'Relative Schriftgröße Kuvert (0.8–1.5, 1 = Standard).' }),
|
||||||
|
f('signatures_manual', 'json', { interface: 'input-code', note: 'Signatur(en) auf dem Brief bei manuellen Aufträgen: { signatures:{signatur1:svg}, offsets:{signatur1:{x,y,scale}} }.' }),
|
||||||
|
f('multi_page', 'boolean', { interface: 'boolean', default: false, note: 'Mehrseitiges Schriftstück (Auto-Fluss über mehrere A4-Seiten).' }),
|
||||||
|
f('page_count', 'integer', { interface: 'input', note: 'Seiten je Schriftstück (bei mehrseitig; wird bei der Generierung gesetzt).' }),
|
||||||
|
// Kontakt-/Akquise-Steuerung (siehe Collection „contacts").
|
||||||
|
f('keep_contacts', 'boolean', { interface: 'boolean', default: false, note: 'In Kontakte speichern: legt die Empfänger dieses Auftrags als Kontakte/Leads an (nur auf ausdrückliche Anforderung). Schützt zugleich die Empfängerdaten vor dem DSGVO-Strip.' }),
|
||||||
|
f('contacts_created', 'boolean', { interface: 'boolean', default: false, hidden: true, note: 'Guard: Kontakte für diesen Auftrag wurden bereits angelegt.' }),
|
||||||
|
f('entries_stripped', 'boolean', { interface: 'boolean', default: false, hidden: true, note: 'Guard: Empfängerdaten wurden nach Ablauf der Aufbewahrung anonymisiert.' }),
|
||||||
|
dropdown('source', ['configurator', 'portal', 'operator', 'extern', 'api'], { default: 'configurator', note: '„extern" = andere Plattform (z. B. Etsy); „api" = Public-API-Zugang.' }),
|
||||||
dropdown('status', ['entwurf', 'vorbereitet', 'wartet_auf_zahlung', 'bezahlt', 'material_erwartet',
|
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' }),
|
||||||
@@ -229,9 +272,11 @@ const collections = [
|
|||||||
f('kuvert_gedruckt', 'boolean', { interface: 'boolean', default: false, note: 'Umschläge 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).
|
// Zeitpunkt des Versands – Basis fürs Auto-Archiv (nach 14 Tagen).
|
||||||
{ field: 'versandt_am', type: 'timestamp', meta: { interface: 'datetime', note: 'Wird beim Wechsel auf „versendet" gesetzt.' }, schema: { is_nullable: true } },
|
{ field: 'versandt_am', type: 'timestamp', meta: { interface: 'datetime', note: 'Wird beim Wechsel auf „versendet" gesetzt.' }, schema: { is_nullable: true } },
|
||||||
|
{ field: 'production_status_changed_at', type: 'timestamp', meta: { interface: 'datetime', note: 'Zeitpunkt der letzten Produktionsstatus-Änderung (für „status_since" der Public-API).' }, schema: { is_nullable: true } },
|
||||||
dropdown('shipping_type', ['einzeln', 'sammel'], { default: 'sammel' }),
|
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']),
|
||||||
|
f('envelope_text', 'text', { interface: 'input-multiline', note: 'Individueller Kuverttext (bei envelope_labeling = freitext).' }),
|
||||||
dropdown('envelope_format', ['c6', 'dinlang'], { note: 'Automatisch aus Format.' }),
|
dropdown('envelope_format', ['c6', 'dinlang'], { note: 'Automatisch aus Format.' }),
|
||||||
dropdown('motif_mode', ['katalog', 'upload', 'ohne', 'eigene_karten']),
|
dropdown('motif_mode', ['katalog', 'upload', 'ohne', 'eigene_karten']),
|
||||||
m2o('motif', 'motifs'),
|
m2o('motif', 'motifs'),
|
||||||
@@ -242,8 +287,10 @@ const collections = [
|
|||||||
f('preview_session', 'string', { interface: 'input', note: 'Vorschau-Session – gezeigte Dokumente werden 1:1 übernommen.' }),
|
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).
|
// Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF/Word).
|
||||||
{ field: 'source_file', type: 'uuid',
|
{ field: 'source_file', type: 'uuid',
|
||||||
meta: { interface: 'file', special: ['file'], note: 'Hochgeladenes Dokument (Datei-Produkte).' },
|
meta: { interface: 'file', special: ['file'], note: 'Hochgeladenes Dokument (Datei-Produkte) – erstes/primäres.' },
|
||||||
schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' },
|
schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' },
|
||||||
|
// Alle hochgeladenen Dokumente (mehrere PDF/Word/Excel möglich) als Datei-ID-Liste.
|
||||||
|
f('source_files', 'json', { interface: 'tags', note: 'Alle hochgeladenen Dokumente (Datei-IDs).' }),
|
||||||
// Rechnungs-/Lieferadresse als Snapshot (für Bestätigung, Rechnung, Versand).
|
// Rechnungs-/Lieferadresse als Snapshot (für Bestätigung, Rechnung, Versand).
|
||||||
f('billing', 'json', { interface: 'input-code', note: 'Rechnungsadresse (Snapshot bei Bestellung).' }),
|
f('billing', 'json', { interface: 'input-code', note: 'Rechnungsadresse (Snapshot bei Bestellung).' }),
|
||||||
f('shipping_address', 'json', { interface: 'input-code', note: 'Abweichende Lieferadresse (falls vorhanden).' }),
|
f('shipping_address', 'json', { interface: 'input-code', note: 'Abweichende Lieferadresse (falls vorhanden).' }),
|
||||||
@@ -299,6 +346,7 @@ const collections = [
|
|||||||
f('country', 'string', { interface: 'input' }),
|
f('country', 'string', { interface: 'input' }),
|
||||||
f('free_text', 'text', { interface: 'input-multiline', note: 'Alternativ zur strukturierten Adresse.' }),
|
f('free_text', 'text', { interface: 'input-multiline', note: 'Alternativ zur strukturierten Adresse.' }),
|
||||||
f('placeholders', 'json', { interface: 'list', note: 'Zusätzliche Platzhalterwerte.' }),
|
f('placeholders', 'json', { interface: 'list', note: 'Zusätzliche Platzhalterwerte.' }),
|
||||||
|
f('text', 'text', { interface: 'input-multiline', note: 'Abweichender Brieftext NUR für dieses Schriftstück (leer = Standardtext des Auftrags).' }),
|
||||||
] },
|
] },
|
||||||
|
|
||||||
{ collection: 'order_addons', meta: { icon: 'add_shopping_cart', note: 'Junction Auftrag ↔ Zusatzleistung.', hidden: true }, fields: [
|
{ collection: 'order_addons', meta: { icon: 'add_shopping_cart', note: 'Junction Auftrag ↔ Zusatzleistung.', hidden: true }, fields: [
|
||||||
@@ -317,6 +365,10 @@ const collections = [
|
|||||||
f('active', 'boolean', { interface: 'boolean', default: true }),
|
f('active', 'boolean', { interface: 'boolean', default: true }),
|
||||||
dropdown('status', ['unbekannt', 'bereit', 'druckt', 'fehler', 'offline'], { default: 'unbekannt', note: 'Vom Agent gemeldet.' }),
|
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: {} },
|
{ field: 'last_seen', type: 'timestamp', meta: { interface: 'datetime', note: 'Letzte Rückmeldung des Agents.' }, schema: {} },
|
||||||
|
// Aktuell eingelegtes Format: Der Agent druckt NUR Jobs mit passendem
|
||||||
|
// format-Schlüssel und springt nie selbstständig auf ein anderes Format.
|
||||||
|
// Leer = Maschine wartet, bis im Board ein Format als eingelegt gewählt wird.
|
||||||
|
f('current_format', 'string', { interface: 'input', note: 'Aktuell eingelegtes Format (Agent druckt nur passende Jobs). Leer = wartet auf Formatwahl.' }),
|
||||||
f('notes', 'text', { interface: 'input-multiline' }),
|
f('notes', 'text', { interface: 'input-multiline' }),
|
||||||
] },
|
] },
|
||||||
|
|
||||||
@@ -349,12 +401,36 @@ const collections = [
|
|||||||
f('rotation', 'integer', { interface: 'input', default: 0 }),
|
f('rotation', 'integer', { interface: 'input', default: 0 }),
|
||||||
] },
|
] },
|
||||||
|
|
||||||
|
// Zentrale Rendering-Formate: Seitengröße, Ränder, Zeilenhöhe/-limit. Das
|
||||||
|
// Backend liest diese (gecacht) statt hartkodierter Werte – so lassen sich
|
||||||
|
// eigene Formate anlegen, ohne Code zu ändern (eine Quelle für App + Backend).
|
||||||
|
{ collection: 'render_formats', meta: { icon: 'crop_free', note: 'Rendering-Formate (Seitengröße, Ränder, Zeilenlimit) – zentral fürs Backend.' }, fields: [
|
||||||
|
f('key', 'string', { interface: 'input', unique: true, required: true, note: 'Technischer Schlüssel, z. B. a4, a6p, din_lang, oder ein eigener.' }),
|
||||||
|
f('name', 'string', { interface: 'input', required: true }),
|
||||||
|
dropdown('kind', ['brief', 'umschlag'], { default: 'brief' }),
|
||||||
|
f('page_width_mm', 'float', { interface: 'input', required: true, note: 'Seitenbreite in mm.' }),
|
||||||
|
f('page_height_mm', 'float', { interface: 'input', required: true, note: 'Seitenhöhe in mm.' }),
|
||||||
|
f('margin_top_mm', 'float', { interface: 'input', default: 25 }),
|
||||||
|
f('margin_left_mm', 'float', { interface: 'input', default: 20 }),
|
||||||
|
f('margin_right_mm', 'float', { interface: 'input', note: 'Optional (v. a. Querformat).' }),
|
||||||
|
f('line_height_factor', 'float', { interface: 'input', default: 1.35, note: 'Zeilenhöhe = Schriftgröße × Faktor.' }),
|
||||||
|
f('line_limit', 'integer', { interface: 'input', note: 'Max. Zeilen. Leer = automatisch aus Höhe.' }),
|
||||||
|
// Zugehöriges Plotter-Druck-Template (Platzierung an der Maschine).
|
||||||
|
m2o('print_template', 'format_templates'),
|
||||||
|
f('active', 'boolean', { interface: 'boolean', default: true }),
|
||||||
|
] },
|
||||||
|
|
||||||
{ collection: 'jobs', meta: { icon: 'print', note: 'Produktions-Job (Druck-Warteschlange je Maschine).' }, fields: [
|
{ collection: 'jobs', meta: { icon: 'print', note: 'Produktions-Job (Druck-Warteschlange je Maschine).' }, fields: [
|
||||||
m2o('order', 'orders', { onDelete: 'CASCADE' }),
|
m2o('order', 'orders', { onDelete: 'CASCADE' }),
|
||||||
f('output_ref', 'string', { interface: 'input', note: 'Ausgabe-Ordner ohne Auftrag (z. B. Testdruck).' }),
|
f('output_ref', 'string', { interface: 'input', note: 'Ausgabe-Ordner ohne Auftrag (z. B. Testdruck).' }),
|
||||||
m2o('machine', 'machines'),
|
m2o('machine', 'machines'),
|
||||||
dropdown('type', ['brief', 'umschlag', 'signatur'], { default: 'brief', note: 'Was gedruckt wird.' }),
|
dropdown('type', ['brief', 'umschlag', 'signatur'], { default: 'brief', note: 'Was gedruckt wird.' }),
|
||||||
m2o('template', 'format_templates', { note: 'Aufgelöstes Druck-Template.' }),
|
m2o('template', 'format_templates', { note: 'Aufgelöstes Druck-Template.' }),
|
||||||
|
// Format-Schlüssel des Jobs (Brief: a4/a6…, Kuvert: dinlang/c6). Grundlage
|
||||||
|
// für die Format-Warteschlange: der Agent druckt nur Jobs, deren Format dem
|
||||||
|
// an der Maschine eingelegten Format entspricht.
|
||||||
|
f('format', 'string', { interface: 'input', note: 'Format-Schlüssel (Brief: a4/a6…, Kuvert: dinlang/c6). Grundlage der Format-Warteschlange.' }),
|
||||||
|
f('format_label', 'string', { interface: 'input', note: 'Lesbare Formatbezeichnung für die Warteschlangen-Ansicht.' }),
|
||||||
dropdown('status', ['queued', 'printing', 'printed', 'failed', 'shipped'], { default: 'queued' }),
|
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.' }),
|
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).
|
// Pro-Druck-Übersteuerung der Template-Maße (Breite/Höhe/X/Y/Scale/Rot).
|
||||||
@@ -375,6 +451,33 @@ const collections = [
|
|||||||
{ field: 'timestamp', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
{ field: 'timestamp', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
||||||
] },
|
] },
|
||||||
|
|
||||||
|
// API-Zugänge für Kunden (Public-API). Keys werden nur als SHA-256-Hash gespeichert.
|
||||||
|
{ collection: 'api_clients', meta: { icon: 'vpn_key', note: 'API-Zugänge für Kunden (Public-API).' }, fields: [
|
||||||
|
f('name', 'string', { interface: 'input', required: true, note: 'Bezeichnung, z. B. Firmenname.' }),
|
||||||
|
f('key_plain', 'string', { interface: 'input', note: 'Wird beim Anlegen automatisch erzeugt und EINMALIG angezeigt – jetzt kopieren! Verschwindet bei der nächsten Änderung. Nur der Hash bleibt gespeichert.' }),
|
||||||
|
f('key_prefix', 'string', { interface: 'input', note: 'Erkennungspräfix des Keys (nur Anzeige).' }),
|
||||||
|
f('key_hash', 'string', { interface: 'input', note: 'SHA-256 des API-Keys – automatisch gesetzt. Nicht manuell ändern.' }),
|
||||||
|
m2o('customer', 'directus_users', { note: 'Verknüpftes Kundenkonto (optional).' }),
|
||||||
|
// Mehrere erlaubte Produkte (m2m). Die Order gibt per „product" an, welches genutzt wird.
|
||||||
|
{ field: 'products', type: 'alias', meta: { interface: 'list-m2m', special: ['m2m'], note: 'Erlaubte Produkte für diesen Zugang (Mehrfachauswahl).' }, schema: null },
|
||||||
|
// Einzelprodukt (optional, Fallback/alt) – wird nur genutzt, wenn keine m2m-Produkte gesetzt sind.
|
||||||
|
m2o('product', 'products', { note: 'Einzelprodukt (Fallback). Für mehrere Produkte „products" nutzen.' }),
|
||||||
|
f('active', 'boolean', { interface: 'boolean', default: true }),
|
||||||
|
f('webhook_url', 'string', { interface: 'input', note: 'Optionaler Webhook für Statusänderungen (derzeit ungenutzt – Kunde pollt).' }),
|
||||||
|
f('config', 'json', { interface: 'input-code', note: 'Kundenspezifische Konfiguration (optional).' }),
|
||||||
|
f('last_used', 'timestamp', { interface: 'datetime' }),
|
||||||
|
// AGB/Vertrag – einmalige Zustimmung beim Onboarding festhalten (Nachweis).
|
||||||
|
f('terms_version', 'string', { interface: 'input', note: 'Akzeptierte AGB-Version, z. B. „2026-01".' }),
|
||||||
|
f('terms_accepted_at', 'timestamp', { interface: 'datetime', note: 'Zeitpunkt der AGB-/Vertragszustimmung.' }),
|
||||||
|
f('contract_ref', 'string', { interface: 'input', note: 'Referenz zum unterschriebenen Vertrag (Aktenzeichen/Link).' }),
|
||||||
|
] },
|
||||||
|
|
||||||
|
// Junction API-Zugang ↔ Produkt (m2m für api_clients.products).
|
||||||
|
{ collection: 'api_client_products', meta: { icon: 'link', note: 'Junction API-Zugang ↔ Produkt.', hidden: true }, fields: [
|
||||||
|
m2o('api_client', 'api_clients', { onDelete: 'CASCADE' }),
|
||||||
|
m2o('product', 'products', { onDelete: 'CASCADE' }),
|
||||||
|
] },
|
||||||
|
|
||||||
// Unterschriften je Kunde (SVG-Datei). Wird am Kundenkonto als o2m gezeigt.
|
// Unterschriften je Kunde (SVG-Datei). Wird am Kundenkonto als o2m gezeigt.
|
||||||
{ collection: 'signatures', meta: { icon: 'draw', note: 'Unterschriften je Kunde.' }, fields: [
|
{ collection: 'signatures', meta: { icon: 'draw', note: 'Unterschriften je Kunde.' }, fields: [
|
||||||
m2o('customer', 'directus_users', { onDelete: 'CASCADE' }),
|
m2o('customer', 'directus_users', { onDelete: 'CASCADE' }),
|
||||||
@@ -383,6 +486,12 @@ const collections = [
|
|||||||
{ field: 'file', type: 'uuid',
|
{ field: 'file', type: 'uuid',
|
||||||
meta: { interface: 'file', special: ['file'], note: 'Signatur als SVG-Datei (Upload).' },
|
meta: { interface: 'file', special: ['file'], note: 'Signatur als SVG-Datei (Upload).' },
|
||||||
schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' },
|
schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' },
|
||||||
|
// Voreingestellte Platzierung relativ zum Marker im PDF (§§SIG1§§ …).
|
||||||
|
// Wird im Signatur-Modul als Vorgabe geladen, dort manuell überschreibbar.
|
||||||
|
f('marker_key', 'string', { interface: 'input', default: 'SIG1', note: 'Marker-Token im PDF (ohne §§), z. B. SIG1.' }),
|
||||||
|
f('offset_x_mm', 'float', { interface: 'input', default: 0, note: 'Versatz X zum Marker (mm).' }),
|
||||||
|
f('offset_y_mm', 'float', { interface: 'input', default: 0, note: 'Versatz Y zum Marker (mm).' }),
|
||||||
|
f('width_mm', 'float', { interface: 'input', default: 40, note: 'Breite der Signatur (mm).' }),
|
||||||
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
||||||
] },
|
] },
|
||||||
|
|
||||||
@@ -420,6 +529,7 @@ const collections = [
|
|||||||
{ collection: 'email_settings', meta: { icon: 'alternate_email', singleton: true, note: 'Absender, Signatur, Portal-Link, AGB + Footer für alle Mails.' }, fields: [
|
{ 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_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('sender_email', 'string', { interface: 'input', note: 'Absenderadresse (muss vom SMTP-Server erlaubt sein).' }),
|
||||||
|
f('team_email', 'string', { interface: 'input', note: 'Interne Benachrichtigungen (neue Bestellung/Muster/Anfrage). Leer = keine Team-Mails.' }),
|
||||||
f('signature', 'text', { interface: 'input-rich-text-html', note: 'Signatur unter Mails ohne eigenen Footer (WYSIWYG/HTML).' }),
|
f('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('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}}).' }),
|
f('company_footer', 'text', { interface: 'input-rich-text-html', note: 'Footer-Adresszeile (Platzhalter {{footer}}).' }),
|
||||||
@@ -435,6 +545,15 @@ const collections = [
|
|||||||
f('text_sie', 'text', { interface: 'input-multiline', note: 'Standard-Mustertext (Sie-Form). Platzhalter [[abschnitt]] wird im Druck-Dialog gefüllt.' }),
|
f('text_sie', 'text', { interface: 'input-multiline', note: 'Standard-Mustertext (Sie-Form). Platzhalter [[abschnitt]] wird im Druck-Dialog gefüllt.' }),
|
||||||
] },
|
] },
|
||||||
|
|
||||||
|
// Frei anlegbare Mustertexte (beliebig viele) – erscheinen im „Manueller Druck"-
|
||||||
|
// Dropdown. Ersetzt langfristig die feste Du/Sie-Vorgabe (die als Fallback bleibt).
|
||||||
|
{ collection: 'muster_texts', meta: { icon: 'science', note: 'Frei anlegbare Mustertexte für den manuellen Druck.' }, fields: [
|
||||||
|
f('label', 'string', { interface: 'input', required: true, note: 'Bezeichnung im Dropdown.' }),
|
||||||
|
f('text', 'text', { interface: 'input-multiline', note: 'Mustertext. Platzhalter [[…]] erlaubt.' }),
|
||||||
|
f('sort', 'integer', { interface: 'input' }),
|
||||||
|
f('active', 'boolean', { interface: 'boolean', default: true }),
|
||||||
|
] },
|
||||||
|
|
||||||
// Angebots- und Musteranfragen aus der App.
|
// Angebots- und Musteranfragen aus der App.
|
||||||
{ collection: 'offer_requests', meta: { icon: 'request_quote', note: 'Angebots- und Musteranfragen aus der App.' }, fields: [
|
{ collection: 'offer_requests', meta: { icon: 'request_quote', note: 'Angebots- und Musteranfragen aus der App.' }, fields: [
|
||||||
dropdown('type', ['angebot', 'muster'], { default: 'angebot' }),
|
dropdown('type', ['angebot', 'muster'], { default: 'angebot' }),
|
||||||
@@ -466,6 +585,45 @@ const collections = [
|
|||||||
f('used', 'boolean', { interface: 'boolean', default: false }),
|
f('used', 'boolean', { interface: 'boolean', default: false }),
|
||||||
{ 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: {} },
|
||||||
] },
|
] },
|
||||||
|
|
||||||
|
// Kontakte/Leads – Empfänger gesendeter Schriftstücke. Werden bei Versand
|
||||||
|
// automatisch angelegt (Hook) und nach 30 Tagen wieder gelöscht, außer der
|
||||||
|
// Auftrag ist als „Kontakte behalten" (Akquise) markiert. Telefon/E-Mail sind
|
||||||
|
// manuell zu pflegen (Empfänger liefern sie nicht mit). CSV-Import/-Export
|
||||||
|
// über die normale Collection-Seite.
|
||||||
|
{ collection: 'contacts', meta: { icon: 'contacts', note: 'Kontakte/Leads – Empfänger gesendeter Schriftstücke.' }, fields: [
|
||||||
|
f('salutation', 'string', { interface: 'input' }),
|
||||||
|
f('first_name', 'string', { interface: 'input' }),
|
||||||
|
f('last_name', 'string', { interface: 'input' }),
|
||||||
|
f('company', 'string', { interface: 'input' }),
|
||||||
|
f('street', 'string', { interface: 'input' }),
|
||||||
|
f('house_no', 'string', { interface: 'input' }),
|
||||||
|
f('zip', 'string', { interface: 'input' }),
|
||||||
|
f('city', 'string', { interface: 'input' }),
|
||||||
|
f('country', 'string', { interface: 'input' }),
|
||||||
|
f('email', 'string', { interface: 'input', note: 'Manuell nachtragen.' }),
|
||||||
|
f('phone', 'string', { interface: 'input', note: 'Manuell nachtragen. Klick im Kontakte-Modul öffnet GroundWire.' }),
|
||||||
|
f('notes', 'text', { interface: 'input-multiline', note: 'Anruf-/Lead-Notizen.' }),
|
||||||
|
dropdown('lead_status', ['neu', 'kontaktiert', 'interessiert', 'kein_interesse', 'kunde'], { default: 'neu', note: 'Nur Kontakte mit „kein_interesse" werden nach 30 Tagen automatisch gelöscht; alle anderen bleiben.' }),
|
||||||
|
m2o('source_order', 'orders', { onDelete: 'SET NULL', note: 'Auftrag, aus dem der Kontakt stammt.' }),
|
||||||
|
m2o('source_entry', 'order_entries', { onDelete: 'SET NULL', note: 'Empfängerzeile, aus der der Kontakt stammt.' }),
|
||||||
|
f('sent_at', 'timestamp', { interface: 'datetime', note: 'Versandzeitpunkt – Grundlage für die 30-Tage-Löschung.' }),
|
||||||
|
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
||||||
|
{ field: 'date_updated', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-updated'] }, schema: {} },
|
||||||
|
] },
|
||||||
|
|
||||||
|
// Standardaktionen im Kontakte-Modul (Rückruf-Wiedervorlage, Angebot senden).
|
||||||
|
// „angebot" verschickt sofort die Eckdaten an die interne Adresse; „rueckruf"
|
||||||
|
// löst am fälligen Tag früh eine Erinnerungs-Mail aus (Hook skrift-contacts).
|
||||||
|
{ collection: 'contact_actions', meta: { icon: 'task_alt', hidden: true, note: 'Aktionen/Wiedervorlagen je Kontakt (Rückruf, Angebot).' }, fields: [
|
||||||
|
m2o('contact', 'contacts', { onDelete: 'CASCADE' }),
|
||||||
|
dropdown('type', ['rueckruf', 'angebot', 'mail'], { default: 'rueckruf' }),
|
||||||
|
f('due_date', 'timestamp', { interface: 'datetime', note: 'Fälligkeit (nur Rückruf) – Basis der Erinnerungs-Mail.' }),
|
||||||
|
f('note', 'text', { interface: 'input-multiline', note: 'Notiz zur Aktion (optional).' }),
|
||||||
|
dropdown('status', ['offen', 'erledigt'], { default: 'offen' }),
|
||||||
|
f('notified', 'boolean', { interface: 'boolean', default: false, note: 'Erinnerungs-Mail bereits verschickt (verhindert Wiederholung).' }),
|
||||||
|
{ field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} },
|
||||||
|
] },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Schema anlegen ──────────────────────────────────────────────────────────
|
// ── Schema anlegen ──────────────────────────────────────────────────────────
|
||||||
@@ -531,6 +689,9 @@ async function applySchema() {
|
|||||||
try {
|
try {
|
||||||
await api('PATCH', '/relations/order_addons/order', { meta: { one_field: 'addons', junction_field: 'price_item' } });
|
await api('PATCH', '/relations/order_addons/order', { meta: { one_field: 'addons', junction_field: 'price_item' } });
|
||||||
await api('PATCH', '/relations/order_addons/price_item', { meta: { junction_field: 'order' } });
|
await api('PATCH', '/relations/order_addons/price_item', { meta: { junction_field: 'order' } });
|
||||||
|
// m2m api_clients.products ↔ products über api_client_products
|
||||||
|
await api('PATCH', '/relations/api_client_products/api_client', { meta: { one_field: 'products', junction_field: 'product' } });
|
||||||
|
await api('PATCH', '/relations/api_client_products/product', { meta: { junction_field: 'api_client' } });
|
||||||
log('m2m verdrahtet');
|
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); }
|
||||||
|
|
||||||
@@ -557,8 +718,10 @@ 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', visibility: 'alle', pricing_mode: 'formel', base_price: 4.0, norm_qty: 100, active: true, sort: 1 },
|
{ key: 'briefe', name: 'Briefe', type: 'letter', visibility: 'alle', pricing_mode: 'formel', base_price: 4.0, norm_qty: 100, price_factor_a: 1, price_factor_e: 3, active: true, sort: 1 },
|
||||||
{ key: 'postkarten', name: 'Postkarten', type: 'postcard', visibility: 'alle', pricing_mode: 'formel', base_price: 1.3, norm_qty: 100, active: true, sort: 2 },
|
// Postkarten: höherer Deckel + sanfterer Exponent, damit kleine/mittlere
|
||||||
|
// Mengen nicht zu günstig werden (Fixaufwand relativ größer als bei Briefen).
|
||||||
|
{ key: 'postkarten', name: 'Postkarten', type: 'postcard', visibility: 'alle', pricing_mode: 'formel', base_price: 1.3, norm_qty: 100, price_factor_a: 1.5, price_factor_e: 2, active: true, sort: 2 },
|
||||||
{ key: 'muster', name: 'Muster', type: 'sample', visibility: 'alle', pricing_mode: 'formel', base_price: 0, active: true, sort: 3 },
|
{ key: 'muster', name: 'Muster', type: 'sample', visibility: 'alle', pricing_mode: 'formel', base_price: 0, active: true, sort: 3 },
|
||||||
// Datei-Produkt: pro Kunde freischaltbar, Kunde lädt ein PDF/Word hoch.
|
// Datei-Produkt: pro Kunde freischaltbar, Kunde lädt ein PDF/Word hoch.
|
||||||
// pricing_mode „formel" + base_price, damit es für freigeschaltete Kunden
|
// pricing_mode „formel" + base_price, damit es für freigeschaltete Kunden
|
||||||
@@ -568,6 +731,21 @@ async function applySeed() {
|
|||||||
];
|
];
|
||||||
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);
|
||||||
|
// Unterschriftenservice muss im Datei-Modus laufen (Text-Schritt = Upload) UND
|
||||||
|
// bepreisbar sein (formel), damit freigeschaltete Kunden ihn regulär bestellen
|
||||||
|
// können. Auch für bestehende Datensätze erzwingen (seedItem aktualisiert nicht).
|
||||||
|
try {
|
||||||
|
const us = productByKey.unterschriftenservice;
|
||||||
|
if (us?.id) {
|
||||||
|
const patch = {};
|
||||||
|
if (us.input_mode !== 'datei') patch.input_mode = 'datei';
|
||||||
|
if (us.pricing_mode !== 'formel') patch.pricing_mode = 'formel';
|
||||||
|
if (Object.keys(patch).length) {
|
||||||
|
await api('PATCH', `/items/products/${us.id}`, patch);
|
||||||
|
log('unterschriftenservice →', JSON.stringify(patch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* nicht kritisch */ }
|
||||||
|
|
||||||
const formats = [
|
const formats = [
|
||||||
{ product: productByKey.briefe.id, key: 'a4', name: 'A4', active: true },
|
{ product: productByKey.briefe.id, key: 'a4', name: 'A4', active: true },
|
||||||
@@ -581,6 +759,7 @@ async function applySeed() {
|
|||||||
{ key: 'porto_inland', label: 'Porto Inland', category: 'versand', price: 2.2, unit: 'pro_stueck', active: true },
|
{ key: 'porto_inland', label: 'Porto Inland', category: 'versand', price: 2.2, unit: 'pro_stueck', active: true },
|
||||||
{ key: 'porto_ausland', label: 'Porto Ausland', category: 'versand', price: 2.5, unit: 'pro_stueck', active: true },
|
{ key: 'porto_ausland', label: 'Porto Ausland', category: 'versand', price: 2.5, unit: 'pro_stueck', active: true },
|
||||||
{ key: 'sammelversand', label: 'Sammelversand', category: 'versand', price: 4.95, unit: 'einmalig', active: true },
|
{ key: 'sammelversand', label: 'Sammelversand', category: 'versand', price: 4.95, unit: 'einmalig', active: true },
|
||||||
|
{ key: 'sammelversand_klein', label: 'Sammelversand (bis 5 Stück)', category: 'versand', price: 1.80, unit: 'einmalig', active: true },
|
||||||
{ key: 'kuvert', label: 'Kuvert', category: 'kuvert', price: 0.5, unit: 'pro_stueck', active: true },
|
{ key: 'kuvert', label: 'Kuvert', category: 'kuvert', price: 0.5, unit: 'pro_stueck', active: true },
|
||||||
{ key: 'beschriftung', label: 'Kuvert-Beschriftung', category: 'beschriftung', price: 0.5, unit: 'pro_stueck', active: true },
|
{ key: 'beschriftung', label: 'Kuvert-Beschriftung', category: 'beschriftung', price: 0.5, unit: 'pro_stueck', active: true },
|
||||||
{ key: 'schreibservice', label: 'Schreibservice', category: 'zusatzleistung', price: 0, unit: 'einmalig', active: true },
|
{ key: 'schreibservice', label: 'Schreibservice', category: 'zusatzleistung', price: 0, unit: 'einmalig', active: true },
|
||||||
@@ -605,12 +784,30 @@ async function applySeed() {
|
|||||||
log('seed singleton pricing_settings');
|
log('seed singleton pricing_settings');
|
||||||
}
|
}
|
||||||
// Gemeinsame Mengen-Multiplikator-Formel: nur setzen, wenn leer (Edits bleiben
|
// Gemeinsame Mengen-Multiplikator-Formel: nur setzen, wenn leer (Edits bleiben
|
||||||
// erhalten). `norm` kommt je Produkt aus products.norm_qty.
|
// erhalten). `norm` kommt je Produkt aus products.norm_qty, a/e je Produkt.
|
||||||
|
const CANON_MULT = '(qty >= norm) ? 1 : (1 + a * pow(1 - qty / norm, e))';
|
||||||
if (!ps || !ps.multiplier_formula) {
|
if (!ps || !ps.multiplier_formula) {
|
||||||
await api('PATCH', '/items/pricing_settings', {
|
await api('PATCH', '/items/pricing_settings', { multiplier_formula: CANON_MULT });
|
||||||
multiplier_formula: '(qty >= norm) ? 1 : (1 + 2 * pow(1 - qty / norm, 3))',
|
|
||||||
});
|
|
||||||
log('seed multiplier-formel');
|
log('seed multiplier-formel');
|
||||||
|
} else {
|
||||||
|
// Migration: eine bestehende Formel mit fest verdrahtetem Koeffizient/Exponent
|
||||||
|
// (1 + N * pow(1 - qty / norm, M)) auf die a/e-Variante umstellen und die
|
||||||
|
// Altwerte in die Produkte übernehmen – Verhalten bleibt exakt erhalten.
|
||||||
|
const m = String(ps.multiplier_formula).match(
|
||||||
|
/^\(\s*qty\s*>=\s*norm\s*\)\s*\?\s*1\s*:\s*\(\s*1\s*\+\s*([\d.]+)\s*\*\s*pow\(\s*1\s*-\s*qty\s*\/\s*norm\s*,\s*([\d.]+)\s*\)\s*\)$/,
|
||||||
|
);
|
||||||
|
if (m) {
|
||||||
|
const aOld = Number(m[1]); const eOld = Number(m[2]);
|
||||||
|
await api('PATCH', '/items/pricing_settings', { multiplier_formula: CANON_MULT });
|
||||||
|
const prods = await api('GET', '/items/products?fields=id,price_factor_a,price_factor_e&limit=-1');
|
||||||
|
for (const p of (prods || [])) {
|
||||||
|
const patch = {};
|
||||||
|
if (p.price_factor_a == null) patch.price_factor_a = aOld;
|
||||||
|
if (p.price_factor_e == null) patch.price_factor_e = eOld;
|
||||||
|
if (Object.keys(patch).length) await api('PATCH', `/items/products/${p.id}`, patch);
|
||||||
|
}
|
||||||
|
log(`migriere multiplier-formel → a/e (Altwerte a=${aOld}, e=${eOld} in Produkte übernommen)`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// E-Mail-Vorlagen (nur anlegen, wenn noch nicht vorhanden – Edits bleiben erhalten).
|
// E-Mail-Vorlagen (nur anlegen, wenn noch nicht vorhanden – Edits bleiben erhalten).
|
||||||
@@ -661,18 +858,52 @@ async function applySeed() {
|
|||||||
body: `<p>${G}</p><p>Ihr Auftrag <strong>{{order_id}}</strong> ist abgeschlossen. Wir freuen uns auf Ihre nächste Bestellung.</p>` },
|
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,
|
{ 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}}` },
|
body: `<p>${G}</p><p>Ihr Auftrag <strong>{{order_id}}</strong> wurde storniert. Bei Fragen sind wir gerne für Sie da.</p>{{reason}}` },
|
||||||
|
// Separate, frei editierbare Mails für Unterschriftenservice- und Extern-Bestellungen.
|
||||||
|
{ key: 'unterschriften_eingang', label: 'Unterschriften: Eingang', subject: 'Ihr Unterschriften-Auftrag {{order_number}} ist eingegangen', append_signature: true, attach_agb: false,
|
||||||
|
body: `<p>${G}</p><p>vielen Dank – Ihr Auftrag <strong>{{order_number}}</strong> für den Unterschriftenservice ist bei uns eingegangen. Wir prüfen Ihr Dokument und melden uns mit den nächsten Schritten.</p>` },
|
||||||
|
{ key: 'extern_eingang', label: 'Externe Bestellung: Eingang', subject: 'Ihre Bestellung {{order_number}} ist eingegangen', append_signature: true, attach_agb: false,
|
||||||
|
body: `<p>${G}</p><p>vielen Dank – Ihre Bestellung <strong>{{order_number}}</strong> ist bei uns eingegangen und geht direkt in die Produktion.</p>` },
|
||||||
];
|
];
|
||||||
for (const t of templates) await seedItem('email_templates', 'key', { enabled: true, ...t });
|
for (const t of templates) await seedItem('email_templates', 'key', { enabled: true, ...t });
|
||||||
|
|
||||||
// Druck-Templates (aus der bisherigen config.json der Produktions-App).
|
// Druck-Templates – Werte 1:1 aus der config.json des alten Job-Managers.
|
||||||
|
// Je Format gibt es zwei Varianten (v1/v2); die zweite ist die zusätzliche
|
||||||
|
// Platzierung (u. a. für individuelle Kuvert-Beschriftung). Werte werden per
|
||||||
|
// Upsert gesetzt, damit falsche Altwerte beim Bootstrap korrigiert werden.
|
||||||
const formatTemplates = [
|
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: 'a4_single', name: 'A4 Brief', type: 'brief', width: 8.27, height: 11.69, xpos: 0.0, ypos: -0.3, scale: 1.0, rotation: 0 },
|
||||||
{ key: 'a6_v1', name: 'A6 Brief – Hochformat', type: 'brief', width: 4.1, height: 5.8, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 },
|
{ key: 'a6_v1', name: 'A6 Hochformat – Variante 1', type: 'brief', width: 4.1, height: 5.8, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 },
|
||||||
{ key: 'a6_v2', name: 'A6 Brief – Querformat', type: 'brief', width: 4.1, height: 5.8, xpos: 4.0, ypos: -0.3, scale: 1.0, rotation: 90 },
|
{ key: 'a6_v2', name: 'A6 Hochformat – Variante 2', type: 'brief', width: 4.1, height: 5.8, xpos: 4.0, ypos: -0.3, scale: 1.0, rotation: 90 },
|
||||||
{ key: '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: 'a6l_v1', name: 'A6 Querformat – Variante 1', type: 'brief', width: 5.8, height: 4.1, xpos: -0.3, ypos: -0.3, scale: 1.0, rotation: 0 },
|
||||||
{ key: 'kuvert_b_v1', name: 'Kuvert C6', type: 'umschlag', width: 3.9, height: 8.27, xpos: 4.49, ypos: -0.2, scale: 1.0, rotation: 90 },
|
{ key: 'a6l_v2', name: 'A6 Querformat – Variante 2', type: 'brief', width: 5.8, height: 4.1, xpos: 4.0, ypos: -0.3, scale: 1.0, rotation: 90 },
|
||||||
|
// DIN Lang – v1 = Empfängeradresse, v2 = individuelle Beschriftung.
|
||||||
|
{ key: 'kuvert_a_v1', name: 'Kuvert DIN Lang – Variante 1', type: 'umschlag', width: 4.33, height: 8.27, xpos: 4.0, ypos: -0.2, scale: 1.0, rotation: 90 },
|
||||||
|
{ key: 'kuvert_a_v2', name: 'Kuvert DIN Lang – Variante 2', type: 'umschlag', width: 3.9, height: 8.27, xpos: 4.49, ypos: -0.2, scale: 1.0, rotation: 90 },
|
||||||
|
// C6 – v1 = Empfängeradresse, v2 = individuelle Beschriftung.
|
||||||
|
{ key: 'kuvert_b_v1', name: 'Kuvert C6 – Variante 1', type: 'umschlag', width: 4.49, height: 6.38, xpos: 4.2, ypos: -0.2, scale: 1.0, rotation: 90 },
|
||||||
|
{ key: 'kuvert_b_v2', name: 'Kuvert C6 – Variante 2', type: 'umschlag', width: 4.33, height: 6.38, xpos: 4.0, ypos: -0.2, scale: 1.0, rotation: 90 },
|
||||||
];
|
];
|
||||||
for (const t of formatTemplates) await seedItem('format_templates', 'key', t);
|
for (const t of formatTemplates) {
|
||||||
|
const vorhanden = await api('GET', `/items/format_templates?filter[key][_eq]=${encodeURIComponent(t.key)}&limit=1&fields=id`);
|
||||||
|
if (vorhanden && vorhanden.length) await api('PATCH', `/items/format_templates/${vorhanden[0].id}`, t);
|
||||||
|
else { await api('POST', '/items/format_templates', t); log('seed + format_templates.' + t.key); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rendering-Formate (zentral fürs Backend). Werte = bisherige Defaults.
|
||||||
|
const renderFormats = [
|
||||||
|
{ key: 'a4', name: 'A4', kind: 'brief', page_width_mm: 210, page_height_mm: 297, margin_top_mm: 25, margin_left_mm: 20, line_height_factor: 1.35, line_limit: 27, active: true },
|
||||||
|
{ key: 'a6p', name: 'A6 Hochformat', kind: 'brief', page_width_mm: 105, page_height_mm: 148, margin_top_mm: 8, margin_left_mm: 8, line_height_factor: 1.3, line_limit: 14, active: true },
|
||||||
|
{ key: 'a6l', name: 'A6 Querformat', kind: 'brief', page_width_mm: 148, page_height_mm: 105, margin_top_mm: 8, margin_left_mm: 8, margin_right_mm: 14, line_height_factor: 1.2, line_limit: 11, active: true },
|
||||||
|
{ key: 'din_lang', name: 'DIN Lang Kuvert', kind: 'umschlag', page_width_mm: 220, page_height_mm: 110, margin_top_mm: 50, margin_left_mm: 10, line_height_factor: 1.2, active: true },
|
||||||
|
{ key: 'c6', name: 'C6 Kuvert', kind: 'umschlag', page_width_mm: 162, page_height_mm: 114, margin_top_mm: 60, margin_left_mm: 10, line_height_factor: 1.2, active: true },
|
||||||
|
];
|
||||||
|
// Upsert (nicht nur anlegen), damit falsche Alt-Maße korrigiert werden – z. B.
|
||||||
|
// Postkarten (a6p/a6l), die sonst nicht aufs Kartenformat passen.
|
||||||
|
for (const rf of renderFormats) {
|
||||||
|
const vorhanden = await api('GET', `/items/render_formats?filter[key][_eq]=${encodeURIComponent(rf.key)}&limit=1&fields=id`);
|
||||||
|
if (vorhanden && vorhanden.length) await api('PATCH', `/items/render_formats/${vorhanden[0].id}`, rf);
|
||||||
|
else { await api('POST', '/items/render_formats', rf); log('seed + render_formats.' + rf.key); }
|
||||||
|
}
|
||||||
|
|
||||||
// email_settings (Singleton) – Signatur nur setzen, wenn leer.
|
// email_settings (Singleton) – Signatur nur setzen, wenn leer.
|
||||||
const es = await api('GET', '/items/email_settings');
|
const es = await api('GET', '/items/email_settings');
|
||||||
@@ -680,6 +911,7 @@ async function applySeed() {
|
|||||||
await api('PATCH', '/items/email_settings', {
|
await api('PATCH', '/items/email_settings', {
|
||||||
sender_name: 'Skrift',
|
sender_name: 'Skrift',
|
||||||
sender_email: (process.env.EMAIL_FROM || 'noreply@skrift.de').trim(),
|
sender_email: (process.env.EMAIL_FROM || 'noreply@skrift.de').trim(),
|
||||||
|
team_email: (process.env.SKRIFT_TEAM_EMAIL || 'hello@skrift.de').trim(),
|
||||||
signature: '<p>Mit freundlichen Grüßen<br>Ihr Skrift-Team</p>',
|
signature: '<p>Mit freundlichen Grüßen<br>Ihr Skrift-Team</p>',
|
||||||
portal_url: (process.env.CORS_ORIGIN || 'https://app.skrift.de').split(',')[0].trim(),
|
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',
|
company_footer: 'Skrift | Hundscheiderweg 4 | 66679 Losheim am See | www.skrift.de',
|
||||||
@@ -696,13 +928,23 @@ async function applySeed() {
|
|||||||
});
|
});
|
||||||
log('seed singleton muster_settings');
|
log('seed singleton muster_settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// muster_texts – Starteinträge (Du/Sie) nur anlegen, wenn noch keine da sind.
|
||||||
|
const mtx = await api('GET', '/items/muster_texts?limit=1');
|
||||||
|
if (!mtx || !mtx.length) {
|
||||||
|
await api('POST', '/items/muster_texts', { label: 'Sie-Form', sort: 1, active: true,
|
||||||
|
text: 'Sehr geehrte/r [[Anrede]] [[Nachname]],\n\nschön, dass Sie Skrift ausprobieren! Dieser Brief wurde von einem Roboter mit einem echten Stift geschrieben.\n\n[[abschnitt]]\n\nMit freundlichen Grüßen\nIhr Skrift-Team' });
|
||||||
|
await api('POST', '/items/muster_texts', { label: 'Du-Form', sort: 2, active: true,
|
||||||
|
text: 'Hallo [[Vorname]],\n\nschön, dass Du Skrift ausprobierst! Dieser Brief wurde von einem Roboter mit einem echten Stift geschrieben.\n\n[[abschnitt]]\n\nHerzliche Grüße\nDein Skrift-Team' });
|
||||||
|
log('seed muster_texts');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rollen / Policies / Rechte (Directus 11) ────────────────────────────────
|
// ── Rollen / Policies / Rechte (Directus 11) ────────────────────────────────
|
||||||
const BIZ = ['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',
|
||||||
'offer_requests', 'signatures', 'customer_discounts', 'customer_products',
|
'offer_requests', 'signatures', 'customer_discounts', 'customer_products', 'contacts',
|
||||||
'email_templates', 'email_settings', 'muster_settings', 'machines', 'format_templates', 'test_prints'];
|
'email_templates', 'email_settings', 'muster_settings', 'muster_texts', 'machines', 'format_templates', 'render_formats', 'test_prints'];
|
||||||
// Kunde = Login-Konto: Aufträge hängen direkt am Nutzer.
|
// Kunde = Login-Konto: Aufträge hängen direkt am Nutzer.
|
||||||
const OWN_ORDER = { customer: { _eq: '$CURRENT_USER' } };
|
const OWN_ORDER = { customer: { _eq: '$CURRENT_USER' } };
|
||||||
const OWN_VIA_ORDER = { order: { customer: { _eq: '$CURRENT_USER' } } };
|
const OWN_VIA_ORDER = { order: { customer: { _eq: '$CURRENT_USER' } } };
|
||||||
@@ -743,10 +985,28 @@ async function ensureAccess(roleId, policyId) {
|
|||||||
await api('POST', '/access', { role: roleId, policy: policyId });
|
await api('POST', '/access', { role: roleId, policy: policyId });
|
||||||
log('access +', `${roleId || 'public'} ↔ policy`);
|
log('access +', `${roleId || 'public'} ↔ policy`);
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Legt eine Berechtigung an ODER gleicht eine bestehende an.
|
||||||
|
*
|
||||||
|
* Wichtig: Früher wurde ein vorhandener Eintrag unangetastet gelassen. Dadurch
|
||||||
|
* blieben einmal angelegte, zu enge Feldlisten für immer bestehen – spätere
|
||||||
|
* Erweiterungen (z. B. production_status für den Produktions-Agent) kamen nie
|
||||||
|
* an und führten zu 403-Fehlern. Der Bootstrap ist die Quelle der Wahrheit,
|
||||||
|
* deshalb werden Felder und Filter jetzt aktiv nachgezogen.
|
||||||
|
*/
|
||||||
async function ensurePermission(policyId, collection, action, fields = ['*'], permissions = {}) {
|
async function ensurePermission(policyId, collection, action, fields = ['*'], permissions = {}) {
|
||||||
const existing = await api('GET',
|
const existing = await api('GET',
|
||||||
`/permissions?filter[policy][_eq]=${policyId}&filter[collection][_eq]=${collection}&filter[action][_eq]=${action}&limit=1`);
|
`/permissions?filter[policy][_eq]=${policyId}&filter[collection][_eq]=${collection}&filter[action][_eq]=${action}&limit=1&fields=id,fields,permissions`);
|
||||||
if (existing && existing.length) return;
|
if (existing && existing.length) {
|
||||||
|
const cur = existing[0];
|
||||||
|
const gleich = JSON.stringify(cur.fields ?? null) === JSON.stringify(fields)
|
||||||
|
&& JSON.stringify(cur.permissions ?? {}) === JSON.stringify(permissions);
|
||||||
|
if (!gleich) {
|
||||||
|
await api('PATCH', `/permissions/${cur.id}`, { fields, permissions });
|
||||||
|
log('perm ~', `${collection}.${action}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
await api('POST', '/permissions', { policy: policyId, collection, action, fields, permissions, validation: {} });
|
await api('POST', '/permissions', { policy: policyId, collection, action, fields, permissions, validation: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,12 +1030,12 @@ async function applyPermissions() {
|
|||||||
await ensureAccess(custRole.id, custPol.id);
|
await ensureAccess(custRole.id, custPol.id);
|
||||||
// Kunde = eigenes Login-Konto: eigenes Profil lesen …
|
// Kunde = eigenes Login-Konto: eigenes Profil lesen …
|
||||||
await ensurePermission(custPol.id, 'directus_users', 'read',
|
await ensurePermission(custPol.id, 'directus_users', 'read',
|
||||||
['id', 'email', 'first_name', 'last_name', 'company', 'phone', 'person_type', 'addresses'],
|
['id', 'email', 'first_name', 'last_name', 'company', 'phone', 'person_type', 'addresses', 'billing_address', 'shipping_address', 'alt_emails'],
|
||||||
{ id: { _eq: '$CURRENT_USER' } });
|
{ id: { _eq: '$CURRENT_USER' } });
|
||||||
// … und begrenzt selbst pflegen. person_type bewusst NICHT enthalten – die
|
// … und begrenzt selbst pflegen. person_type bewusst NICHT enthalten – die
|
||||||
// Einstufung privat/Unternehmen legt der Betreiber fest, nicht der Kunde.
|
// Einstufung privat/Unternehmen legt der Betreiber fest, nicht der Kunde.
|
||||||
await ensurePermission(custPol.id, 'directus_users', 'update',
|
await ensurePermission(custPol.id, 'directus_users', 'update',
|
||||||
['first_name', 'last_name', 'company', 'phone', 'addresses'],
|
['first_name', 'last_name', 'company', 'phone', 'addresses', 'billing_address', 'shipping_address'],
|
||||||
{ id: { _eq: '$CURRENT_USER' } });
|
{ 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);
|
||||||
@@ -786,6 +1046,9 @@ async function applyPermissions() {
|
|||||||
await ensurePermission(custPol.id, 'customer_products', 'read', ['*'], { customer: { _eq: '$CURRENT_USER' } });
|
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');
|
||||||
|
// Datei-Upload: Motiv-Bilder und hochzuladende Dokumente (Unterschriftenservice).
|
||||||
|
await ensurePermission(custPol.id, 'directus_files', 'create');
|
||||||
|
await ensurePermission(custPol.id, 'directus_files', 'read', ['*'], { uploaded_by: { _eq: '$CURRENT_USER' } });
|
||||||
|
|
||||||
// Public (Gast): nur aktive Kataloge lesen
|
// Public (Gast): nur aktive Kataloge lesen
|
||||||
const pubPol = await ensurePolicy('Public (Skrift)', { app: false });
|
const pubPol = await ensurePolicy('Public (Skrift)', { app: false });
|
||||||
@@ -799,7 +1062,7 @@ async function applyPermissions() {
|
|||||||
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', 'products', 'formats',
|
for (const col of ['orders', 'order_entries', 'order_addons', 'jobs', 'products', 'formats',
|
||||||
'motifs', 'price_items', 'machines', 'format_templates'])
|
'motifs', 'price_items', 'machines', 'format_templates', 'render_formats'])
|
||||||
await ensurePermission(svcPol.id, col, 'read');
|
await ensurePermission(svcPol.id, col, 'read');
|
||||||
await ensurePermission(svcPol.id, 'orders', 'update',
|
await ensurePermission(svcPol.id, 'orders', 'update',
|
||||||
['status', 'artifact_path', 'payment_status', 'payment_ref', 'production_status', 'brief_gedruckt', 'kuvert_gedruckt']);
|
['status', 'artifact_path', 'payment_status', 'payment_ref', 'production_status', 'brief_gedruckt', 'kuvert_gedruckt']);
|
||||||
@@ -809,6 +1072,9 @@ async function applyPermissions() {
|
|||||||
// Agent meldet Maschinenzustand (Heartbeat).
|
// Agent meldet Maschinenzustand (Heartbeat).
|
||||||
await ensurePermission(svcPol.id, 'machines', 'update', ['status', 'last_seen']);
|
await ensurePermission(svcPol.id, 'machines', 'update', ['status', 'last_seen']);
|
||||||
await ensurePermission(svcPol.id, 'status_history', 'create');
|
await ensurePermission(svcPol.id, 'status_history', 'create');
|
||||||
|
// Hochgeladene Druckdateien lesen (Backend führt PDFs für den Charge-Sammeldruck
|
||||||
|
// zusammen und lädt sie dafür über /assets).
|
||||||
|
await ensurePermission(svcPol.id, 'directus_files', 'read');
|
||||||
|
|
||||||
console.log(' Hinweis: Für Service einen Nutzer in Rolle "Service" anlegen und dessen',
|
console.log(' Hinweis: Für Service einen Nutzer in Rolle "Service" anlegen und dessen',
|
||||||
'statischen Token in .env von Backend/Produktion eintragen.');
|
'statischen Token in .env von Backend/Produktion eintragen.');
|
||||||
@@ -866,11 +1132,11 @@ async function organizeNav() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Hauptebene
|
// Hauptebene
|
||||||
for (const col of ['orders', 'jobs', 'vouchers', 'status_history'])
|
for (const col of ['orders', 'jobs', 'vouchers', 'status_history', 'contacts'])
|
||||||
await patch(col, { group: null, hidden: false });
|
await patch(col, { group: null, hidden: false });
|
||||||
// Unter „Einstellungen"
|
// Unter „Einstellungen"
|
||||||
for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', 'offer_requests',
|
for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings', 'offer_requests',
|
||||||
'email_templates', 'email_settings', 'muster_settings', 'machines', 'format_templates', 'test_prints'])
|
'email_templates', 'email_settings', 'muster_settings', 'muster_texts', 'machines', 'format_templates', 'render_formats', 'test_prints'])
|
||||||
await patch(col, { group: FOLDER, hidden: false });
|
await patch(col, { group: FOLDER, hidden: false });
|
||||||
// Reine Detail-/Technik-Collections aus der Navigation nehmen (Signaturen und
|
// Reine Detail-/Technik-Collections aus der Navigation nehmen (Signaturen und
|
||||||
// Rabatte werden direkt am Kundenkonto gepflegt, nicht als eigener Menüpunkt).
|
// Rabatte werden direkt am Kundenkonto gepflegt, nicht als eigener Menüpunkt).
|
||||||
@@ -907,6 +1173,7 @@ async function applyDisplays() {
|
|||||||
['customer_products', 'product', '{{name}}'],
|
['customer_products', 'product', '{{name}}'],
|
||||||
['offer_requests', 'customer', '{{first_name}} {{last_name}}'],
|
['offer_requests', 'customer', '{{first_name}} {{last_name}}'],
|
||||||
['voucher_redemptions', 'voucher', '{{code}}'],
|
['voucher_redemptions', 'voucher', '{{code}}'],
|
||||||
|
['contacts', 'source_order', '{{order_number}}'],
|
||||||
];
|
];
|
||||||
for (const [collection, field, template] of displays) {
|
for (const [collection, field, template] of displays) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ services:
|
|||||||
SKRIFT_PREVIEW_LIMIT: ${SKRIFT_PREVIEW_LIMIT:-10}
|
SKRIFT_PREVIEW_LIMIT: ${SKRIFT_PREVIEW_LIMIT:-10}
|
||||||
SKRIFT_TEAM_EMAIL: ${SKRIFT_TEAM_EMAIL:-hello@skrift.de}
|
SKRIFT_TEAM_EMAIL: ${SKRIFT_TEAM_EMAIL:-hello@skrift.de}
|
||||||
|
|
||||||
|
# Shared Secret für den WordPress-Webhook (JetFormBuilder → /skrift-orders/inbound/*).
|
||||||
|
# Muss identisch als ?token=... in der Webhook-URL stehen.
|
||||||
|
SKRIFT_WEBHOOK_TOKEN: ${SKRIFT_WEBHOOK_TOKEN}
|
||||||
|
|
||||||
|
# Signatur-Plotter-Service (Vorschau/Render im Unterschriftenservice-Modul).
|
||||||
|
SKRIFT_SIGN_URL: ${SKRIFT_SIGN_URL:-http://skrift-signature-service:8000}
|
||||||
|
|
||||||
# PayPal – server-seitige Zahlungsprüfung (Secret liegt NUR hier, nie im Frontend)
|
# PayPal – server-seitige Zahlungsprüfung (Secret liegt NUR hier, nie im Frontend)
|
||||||
PAYPAL_CLIENT_ID: ${PAYPAL_CLIENT_ID}
|
PAYPAL_CLIENT_ID: ${PAYPAL_CLIENT_ID}
|
||||||
PAYPAL_CLIENT_SECRET: ${PAYPAL_CLIENT_SECRET}
|
PAYPAL_CLIENT_SECRET: ${PAYPAL_CLIENT_SECRET}
|
||||||
|
|||||||
572
Docker/directus/extensions/directus-extension-skrift-api/dist/index.js
vendored
Normal file
572
Docker/directus/extensions/directus-extension-skrift-api/dist/index.js
vendored
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
/**
|
||||||
|
* Skrift – Public-API (v1) für Kunden
|
||||||
|
* ===========================================================================
|
||||||
|
* Eigener, versionierter API-Namespace, gedacht hinter einer eigenen Domain
|
||||||
|
* (z. B. api.skrift.de → NPM proxyt auf /skrift-api/*). Der Kunde sieht NIE das
|
||||||
|
* Directus-Dashboard.
|
||||||
|
*
|
||||||
|
* POST /v1/orders – Auftrag anlegen (Brief/Postkarte: Text ODER Datei)
|
||||||
|
* GET /v1/orders/:nummer – Status abfragen (wartend | in_bearbeitung | versendet)
|
||||||
|
* POST /v1/files – Druckdatei hochladen (Base64) → file_id
|
||||||
|
* GET /v1/openapi.json – OpenAPI-Spezifikation
|
||||||
|
* GET /docs – interaktive Doku (Scalar)
|
||||||
|
* POST /v1/clients – (Admin) neuen API-Zugang + Key erzeugen
|
||||||
|
*
|
||||||
|
* Auth: Header `X-Api-Key: <key>` (NICHT Authorization – den reserviert Directus).
|
||||||
|
* Gespeichert wird nur der SHA-256-Hash (api_clients). Jeder Zugang ist an GENAU
|
||||||
|
* EIN Produkt gebunden.
|
||||||
|
*/
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
// Verzeichnis dieser Extension (für die selbst gehosteten Swagger-UI-Assets).
|
||||||
|
const EXT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const BACKEND_URL = (process.env.SKRIFT_BACKEND_URL || 'http://skrift-backend:4000').replace(/\/$/, '');
|
||||||
|
const BACKEND_TOKEN = process.env.SKRIFT_BACKEND_TOKEN || '';
|
||||||
|
const STORAGE = (process.env.STORAGE_LOCATIONS || 'local').split(',')[0].trim() || 'local';
|
||||||
|
|
||||||
|
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
||||||
|
const pad3 = (n) => String(n).padStart(3, '0');
|
||||||
|
const WOCHENTAGE = ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal order → public status (pending | processing | shipped | cancelled).
|
||||||
|
* Der Betreiber steuert im Produktions-Board `production_status`; der kaufmännische
|
||||||
|
* `status` dient als Rückfall. Beide werden berücksichtigt.
|
||||||
|
*/
|
||||||
|
function kundenStatus(order) {
|
||||||
|
const ps = String(order?.production_status || '');
|
||||||
|
const s = String(order?.status || '');
|
||||||
|
if (ps === 'storniert' || s === 'storniert') return 'cancelled';
|
||||||
|
if (ps === 'versendet' || s === 'versendet' || s === 'abgeschlossen') return 'shipped';
|
||||||
|
if (['im_druck', 'kuvertieren', 'versandfertig'].includes(ps) || ['in_produktion', 'gedruckt'].includes(s)) return 'processing';
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selbst ausgelieferte HTML-Doku (kein externes Script → CSP-sicher). Inline-CSS.
|
||||||
|
const DOCS_HTML = `<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Skrift API Reference</title>
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;font:16px/1.6 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;background:#f6f8fb}
|
||||||
|
.wrap{max-width:900px;margin:0 auto;padding:32px 20px 80px}
|
||||||
|
h1{font-size:30px;margin:0 0 4px}
|
||||||
|
h2{font-size:22px;margin:40px 0 12px;padding-top:14px;border-top:1px solid #e2e8f0}
|
||||||
|
h3{font-size:15px;margin:18px 0 6px}
|
||||||
|
code{background:#eef2f7;padding:2px 6px;border-radius:5px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:14px}
|
||||||
|
pre{background:#0f172a;color:#e6edf3;padding:16px;border-radius:10px;overflow:auto;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:1.5}
|
||||||
|
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:18px 20px;margin:14px 0}
|
||||||
|
.ep{display:flex;align-items:center;gap:10px;font-family:ui-monospace,monospace;font-size:15px;margin-bottom:6px}
|
||||||
|
.m{font-weight:700;color:#fff;border-radius:6px;padding:2px 8px;font-size:12px}
|
||||||
|
.post{background:#16a34a}.get{background:#2563eb}
|
||||||
|
table{border-collapse:collapse;width:100%;margin:8px 0;font-size:14px}
|
||||||
|
th,td{text-align:left;border-bottom:1px solid #e2e8f0;padding:7px 8px;vertical-align:top}
|
||||||
|
th{color:#64748b;font-weight:600}
|
||||||
|
.muted{color:#64748b}
|
||||||
|
.tag{display:inline-block;background:#eef2f7;border-radius:6px;padding:1px 8px;font-size:13px;margin:0 6px 4px 0}
|
||||||
|
a{color:#1E50A5}
|
||||||
|
</style></head>
|
||||||
|
<body><div class="wrap">
|
||||||
|
<h1>Skrift API Reference</h1>
|
||||||
|
<p class="muted">Handwritten mail as a service · API v1 · Base URL <code>https://dev.skrift.de</code></p>
|
||||||
|
|
||||||
|
<h2>Overview</h2>
|
||||||
|
<div class="card">
|
||||||
|
<p>REST API over HTTPS. Request and response bodies are JSON (<code>Content-Type: application/json</code>, UTF-8). An order yields one document and/or one envelope per recipient; the <b>quantity is derived from the number of recipients</b>. One or more products are bound to your API key; each order selects one via the <code>product</code> field.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Authentication</h2>
|
||||||
|
<div class="card">
|
||||||
|
<p>Every request must include your API key in the <code>X-Api-Key</code> header:</p>
|
||||||
|
<pre>X-Api-Key: <API_KEY></pre>
|
||||||
|
<p class="muted">Each key is scoped to exactly one product. Keys are secrets — never expose them in client-side code.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Errors</h2>
|
||||||
|
<div class="card">
|
||||||
|
<p>Errors use standard HTTP status codes and a uniform body:</p>
|
||||||
|
<pre>{ "error": "human-readable message" }</pre>
|
||||||
|
<table>
|
||||||
|
<tr><th>Status</th><th>Meaning</th></tr>
|
||||||
|
<tr><td>200 / 201</td><td>Success / order created</td></tr>
|
||||||
|
<tr><td>400</td><td>Invalid request (missing or invalid fields)</td></tr>
|
||||||
|
<tr><td>401</td><td>Missing or invalid API key</td></tr>
|
||||||
|
<tr><td>404</td><td>Resource not found</td></tr>
|
||||||
|
<tr><td>413</td><td>Payload too large (file > 80 MB)</td></tr>
|
||||||
|
<tr><td>415</td><td>Unsupported media type (only PDF is accepted)</td></tr>
|
||||||
|
<tr><td>500</td><td>Internal error</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Order status</h2>
|
||||||
|
<div class="card">
|
||||||
|
<p>The <code>status</code> field returns one of:</p>
|
||||||
|
<span class="tag">pending</span> received, not yet in production
|
||||||
|
<span class="tag">processing</span> being written / printed
|
||||||
|
<span class="tag">shipped</span> dispatched
|
||||||
|
<span class="tag">cancelled</span> cancelled
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Create an order</h2>
|
||||||
|
<div class="card">
|
||||||
|
<div class="ep"><span class="m post">POST</span> /v1/orders</div>
|
||||||
|
<p>Creates an order and returns its order number.</p>
|
||||||
|
<h3>Request body</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Field</th><th>Type</th><th>Description</th></tr>
|
||||||
|
<tr><td>product</td><td>string</td><td>Product key to use. Required if your key allows multiple products; optional (auto) if exactly one.</td></tr>
|
||||||
|
<tr><td>recipients</td><td>array</td><td><b>Required.</b> One entry per recipient; count = quantity. Address as <code>free_text</code> (up to 5 lines, separated by <code>\\n</code>).</td></tr>
|
||||||
|
<tr><td>document_format</td><td>string</td><td>Document format: <code>a4</code>, <code>a6h</code> or <code>a6l</code>.</td></tr>
|
||||||
|
<tr><td>font</td><td>string</td><td><code>tilda</code>, <code>alva</code> or <code>ellie</code>.</td></tr>
|
||||||
|
<tr><td>realistic</td><td>boolean</td><td>Natural handwriting variation. Default <code>true</code>.</td></tr>
|
||||||
|
<tr><td>shipping_type</td><td>string</td><td><code>bulk</code> (consolidated shipment to you; default) or <code>single</code> (direct to each recipient).</td></tr>
|
||||||
|
<tr><td>shipping_day</td><td>string</td><td>Preferred dispatch weekday, e.g. <code>montag</code> (optional).</td></tr>
|
||||||
|
<tr><td>letter.text</td><td>string</td><td>Letter body, <b>fully written out — no placeholders</b>. Long text flows across multiple pages automatically; <code>[[seitenumbruch]]</code> forces a page break.</td></tr>
|
||||||
|
<tr><td>letter.files</td><td>array</td><td><code>file_id</code>(s) from <code>POST /v1/files</code> (file / PDF products).</td></tr>
|
||||||
|
<tr><td>envelope.mode</td><td>string</td><td><code>none</code> (no envelope) or <code>recipient</code> (recipient address on the envelope). Envelope size is selected automatically: A4 → DIN Lang, otherwise C6.</td></tr>
|
||||||
|
</table>
|
||||||
|
<h3>Example — text letter</h3>
|
||||||
|
<pre>curl -X POST https://dev.skrift.de/v1/orders \\
|
||||||
|
-H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"product":"briefe","document_format":"a4","font":"tilda","realistic":true,
|
||||||
|
"shipping_type":"bulk","shipping_day":"montag",
|
||||||
|
"letter":{"text":"Dear Anna,\\n\\nthank you for ..."},
|
||||||
|
"envelope":{"mode":"recipient"},
|
||||||
|
"recipients":[
|
||||||
|
{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}
|
||||||
|
]
|
||||||
|
}'</pre>
|
||||||
|
<h3>Example — print uploaded PDFs (file product)</h3>
|
||||||
|
<pre>curl -X POST https://dev.skrift.de/v1/orders \\
|
||||||
|
-H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
|
||||||
|
-d '{"document_format":"a4","letter":{"files":["<file_id>"]},
|
||||||
|
"envelope":{"mode":"recipient"},
|
||||||
|
"recipients":[{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}]}'</pre>
|
||||||
|
<h3>Example — address envelopes only</h3>
|
||||||
|
<pre>curl -X POST https://dev.skrift.de/v1/orders \\
|
||||||
|
-H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
|
||||||
|
-d '{"envelope":{"mode":"recipient"},
|
||||||
|
"recipients":[{"free_text":"Anna Meier\\nMusterstr. 1\\n12345 Musterstadt"}]}'</pre>
|
||||||
|
<h3>Response <span class="muted">· 201</span></h3>
|
||||||
|
<pre>{ "order_number": "18-08-26-001", "status": "pending" }</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Retrieve order status</h2>
|
||||||
|
<div class="card">
|
||||||
|
<div class="ep"><span class="m get">GET</span> /v1/orders/{order_number}</div>
|
||||||
|
<p>Returns the current status of one of your orders. <code>status_since</code> is the ISO 8601 timestamp (UTC) since which the order has been in its current status.</p>
|
||||||
|
<pre>curl https://dev.skrift.de/v1/orders/18-08-26-001 -H "X-Api-Key: <KEY>"
|
||||||
|
|
||||||
|
{ "order_number":"18-08-26-001", "status":"shipped", "status_since":"2026-08-20T09:14:00.000Z", "recipients":1 }</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Upload a PDF</h2>
|
||||||
|
<div class="card">
|
||||||
|
<div class="ep"><span class="m post">POST</span> /v1/files</div>
|
||||||
|
<p>Uploads a base64-encoded print file. <b>PDF only</b>, max. 80 MB. Returns a <code>file_id</code> to reference in <code>letter.files</code>.</p>
|
||||||
|
<pre>curl -X POST https://dev.skrift.de/v1/files \\
|
||||||
|
-H "X-Api-Key: <KEY>" -H "Content-Type: application/json" \\
|
||||||
|
-d '{"filename":"letter.pdf","content_base64":"<BASE64>"}'
|
||||||
|
|
||||||
|
{ "file_id": "..." }</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="muted" style="margin-top:30px">Machine-readable specification: <a href="v1/openapi.json">OpenAPI (JSON)</a> — import into Postman, Insomnia, or any OpenAPI client.</p>
|
||||||
|
</div></body></html>`;
|
||||||
|
|
||||||
|
// Swagger UI (selbst gehostet). Assets liegen als swagger-ui.css + swagger-ui-bundle.js
|
||||||
|
// im Extension-Ordner; init.js wird generiert. Kein CDN → CSP-/Brave-sicher.
|
||||||
|
const SWAGGER_HTML = `<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Skrift API Reference</title>
|
||||||
|
<link rel="stylesheet" href="docs-assets/swagger-ui.css">
|
||||||
|
<style>body{margin:0;background:#fafafa}.swagger-ui .topbar{display:none}</style>
|
||||||
|
</head><body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="docs-assets/swagger-ui-bundle.js"></script>
|
||||||
|
<script src="docs-assets/init.js"></script>
|
||||||
|
</body></html>`;
|
||||||
|
|
||||||
|
const INIT_JS = "window.addEventListener('DOMContentLoaded',function(){window.ui=SwaggerUIBundle({url:'v1/openapi.json',dom_id:'#swagger-ui',deepLinking:true,presets:[SwaggerUIBundle.presets.apis],layout:'BaseLayout'});});";
|
||||||
|
|
||||||
|
const handler = (router, { services, getSchema, logger }) => {
|
||||||
|
const { ItemsService, FilesService } = services;
|
||||||
|
const svcFactory = (schema) => (c) => new ItemsService(c, { schema, accountability: null });
|
||||||
|
|
||||||
|
/** Zeichen-Whitelist (wie im übrigen System) als Sanitizer laden. */
|
||||||
|
async function ladeClean(schema) {
|
||||||
|
try {
|
||||||
|
const ps = await new ItemsService('pricing_settings', { schema, accountability: null })
|
||||||
|
.readSingleton({ fields: ['char_whitelist'] });
|
||||||
|
const wl = ps?.char_whitelist;
|
||||||
|
const re = wl ? new RegExp(`[^${wl}\\n\\r\\t]`, 'g') : null;
|
||||||
|
return (v) => (typeof v === 'string' && re ? v.replace(re, '') : v);
|
||||||
|
} catch { return (v) => v; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API-Key aus dem X-Api-Key-Header lesen und den aktiven Zugang finden.
|
||||||
|
* (NICHT Authorization: Bearer – den reserviert Directus für eigene Tokens
|
||||||
|
* und lehnt fremde Werte global mit INVALID_CREDENTIALS ab.) */
|
||||||
|
async function authClient(req) {
|
||||||
|
const raw = String(req.headers['x-api-key'] || '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const schema = await getSchema();
|
||||||
|
const rows = await new ItemsService('api_clients', { schema, accountability: null }).readByQuery({
|
||||||
|
filter: { key_hash: { _eq: sha256(raw) }, active: { _eq: true } },
|
||||||
|
limit: 1,
|
||||||
|
fields: ['id', 'name', 'customer', 'webhook_url', 'config',
|
||||||
|
'product.id', 'product.key', 'product.type', 'product.input_mode',
|
||||||
|
'products.product.id', 'products.product.key', 'products.product.type', 'products.product.input_mode'],
|
||||||
|
});
|
||||||
|
const client = rows?.[0] || null;
|
||||||
|
if (client) {
|
||||||
|
// Letzte Nutzung vermerken (best effort, nicht blockierend).
|
||||||
|
new ItemsService('api_clients', { schema, accountability: null })
|
||||||
|
.updateOne(client.id, { last_used: new Date().toISOString() }).catch(() => {});
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fehler = (res, code, msg) => res.status(code).json({ error: msg });
|
||||||
|
|
||||||
|
// ── POST /v1/files ─────────────────────────────────────────────────────────
|
||||||
|
// Body: { filename, content_base64 } → { file_id }. NUR PDF (Sicherheit).
|
||||||
|
router.post('/v1/files', async (req, res) => {
|
||||||
|
const client = await authClient(req);
|
||||||
|
if (!client) return fehler(res, 401, 'Missing or invalid API key.');
|
||||||
|
const { filename, content_base64 } = req.body || {};
|
||||||
|
if (!filename || !content_base64) return fehler(res, 400, 'filename and content_base64 are required.');
|
||||||
|
if (!/\.pdf$/i.test(String(filename))) return fehler(res, 415, 'Only PDF files are accepted (filename must end in .pdf).');
|
||||||
|
let buf;
|
||||||
|
try { buf = Buffer.from(String(content_base64), 'base64'); } catch { return fehler(res, 400, 'content_base64 is not valid base64.'); }
|
||||||
|
if (!buf.length) return fehler(res, 400, 'File is empty.');
|
||||||
|
if (buf.length > 80 * 1024 * 1024) return fehler(res, 413, 'File too large (max. 80 MB).');
|
||||||
|
// Inhalts-Prüfung: echte PDF-Signatur, nicht nur die Endung.
|
||||||
|
if (buf.slice(0, 5).toString('latin1') !== '%PDF-') return fehler(res, 415, 'File is not a valid PDF.');
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const filesSvc = new FilesService({ schema, accountability: null });
|
||||||
|
const id = await filesSvc.uploadOne(Readable.from(buf), {
|
||||||
|
storage: STORAGE,
|
||||||
|
filename_download: String(filename),
|
||||||
|
title: String(filename),
|
||||||
|
type: 'application/pdf',
|
||||||
|
});
|
||||||
|
return res.json({ file_id: id });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[skrift-api] files: ${err.stack || err.message}`);
|
||||||
|
return fehler(res, 500, 'File upload failed.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /v1/orders ─────────────────────────────────────────────────────────
|
||||||
|
router.post('/v1/orders', async (req, res) => {
|
||||||
|
const client = await authClient(req);
|
||||||
|
if (!client) return fehler(res, 401, 'Missing or invalid API key.');
|
||||||
|
|
||||||
|
const body = req.body || {};
|
||||||
|
|
||||||
|
// Erlaubte Produkte des Keys: m2m-Liste + (Fallback) Einzelprodukt, dedupliziert.
|
||||||
|
const erlaubt = {};
|
||||||
|
if (Array.isArray(client.products)) for (const j of client.products) if (j && j.product && j.product.id != null) erlaubt[j.product.id] = j.product;
|
||||||
|
if (client.product && client.product.id != null) erlaubt[client.product.id] = client.product;
|
||||||
|
const erlaubteListe = Object.values(erlaubt);
|
||||||
|
if (!erlaubteListe.length) return fehler(res, 409, 'No product is assigned to this API key.');
|
||||||
|
|
||||||
|
// Produktwahl: body.product (Key) muss erlaubt sein; bei genau einem Produkt optional.
|
||||||
|
const gewuenscht = String(body.product || '').trim();
|
||||||
|
let product;
|
||||||
|
if (gewuenscht) {
|
||||||
|
product = erlaubteListe.find((p) => String(p.key) === gewuenscht);
|
||||||
|
if (!product) return fehler(res, 400, `product "${gewuenscht}" is not allowed for this key. Allowed: ${erlaubteListe.map((p) => p.key).join(', ')}.`);
|
||||||
|
} else if (erlaubteListe.length === 1) {
|
||||||
|
product = erlaubteListe[0];
|
||||||
|
} else {
|
||||||
|
return fehler(res, 400, `Field "product" is required. Allowed: ${erlaubteListe.map((p) => p.key).join(', ')}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipients = Array.isArray(body.recipients) ? body.recipients : [];
|
||||||
|
if (!recipients.length) return fehler(res, 400, 'At least one recipient is required.');
|
||||||
|
if (recipients.length > 5000) return fehler(res, 400, 'Too many recipients (max. 5000).');
|
||||||
|
|
||||||
|
const istKuvertOnly = product.type === 'envelope';
|
||||||
|
const istDatei = product.input_mode === 'datei';
|
||||||
|
const letter = body.letter || {};
|
||||||
|
const files = Array.isArray(letter.files) ? letter.files.filter(Boolean) : [];
|
||||||
|
// Kuvert-only: kein Brief nötig (nur adressierte Kuverts); PDF-Dateien optional.
|
||||||
|
if (!istKuvertOnly) {
|
||||||
|
if (istDatei && !files.length) return fehler(res, 400, 'This product requires uploaded print files (letter.files).');
|
||||||
|
if (!istDatei && !String(letter.text || '').trim()) return fehler(res, 400, 'letter.text is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const svc = svcFactory(schema);
|
||||||
|
const clean = await ladeClean(schema);
|
||||||
|
|
||||||
|
// Schriftstückformat: nur A4 / A6H / A6L (freundliche Werte → interne Keys).
|
||||||
|
const FORMAT_MAP = { a4: 'a4', a6h: 'a6_hoch', a6l: 'a6_quer', a6_hoch: 'a6_hoch', a6_quer: 'a6_quer' };
|
||||||
|
const fmtKey = FORMAT_MAP[String(body.document_format || '').trim().toLowerCase()] || null;
|
||||||
|
if (!fmtKey && !istKuvertOnly) return fehler(res, 400, 'document_format must be a4, a6h or a6l.');
|
||||||
|
let formatId = null;
|
||||||
|
if (fmtKey) {
|
||||||
|
const fr = await svc('formats').readByQuery({ filter: { key: { _eq: fmtKey } }, limit: 1, fields: ['id'] });
|
||||||
|
formatId = fr?.[0]?.id ?? null;
|
||||||
|
if (!formatId) return fehler(res, 400, `Format ${fmtKey} is not active.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kuvert: NUR "recipient" (Empfängeradresse) oder "none" (gar kein Kuvert).
|
||||||
|
const envMode = String((body.envelope || {}).mode || '').toLowerCase() === 'recipient'
|
||||||
|
? 'recipient' : (istKuvertOnly ? 'recipient' : 'none');
|
||||||
|
// Kuvertformat automatisch aus dem Dokumentformat: A4 → DIN Lang, sonst C6.
|
||||||
|
const envFormat = envMode === 'none' ? null : (fmtKey === 'a4' ? 'dinlang' : 'c6');
|
||||||
|
const hatDateien = files.length > 0;
|
||||||
|
|
||||||
|
const font = ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda';
|
||||||
|
// Versandart nur Englisch: "single" (direkt an Empfänger) / "bulk" (Sammelversand an dich).
|
||||||
|
const shippingType = String(body.shipping_type || '').toLowerCase() === 'single' ? 'einzeln' : 'sammel';
|
||||||
|
// Gewünschter Versandtag (optional), z. B. "montag".
|
||||||
|
const shippingDay = WOCHENTAGE.includes(String(body.shipping_day || '').toLowerCase())
|
||||||
|
? String(body.shipping_day).toLowerCase() : null;
|
||||||
|
|
||||||
|
const orderData = {
|
||||||
|
api_client: client.id,
|
||||||
|
customer: client.customer || null,
|
||||||
|
customer_email: (client.config && client.config.email) || null,
|
||||||
|
product: product.id,
|
||||||
|
format: formatId,
|
||||||
|
person_type: 'unternehmen',
|
||||||
|
font,
|
||||||
|
realistic: body.realistic !== false,
|
||||||
|
source: 'api',
|
||||||
|
status: 'in_queue',
|
||||||
|
production_status: 'eingegangen',
|
||||||
|
payment_status: 'offen',
|
||||||
|
payment_method: 'rechnung',
|
||||||
|
shipping_type: shippingType,
|
||||||
|
needs_envelope: envMode === 'recipient',
|
||||||
|
envelope_labeling: envMode === 'recipient' ? 'empfaenger' : 'keine',
|
||||||
|
envelope_text: null,
|
||||||
|
envelope_format: envFormat,
|
||||||
|
// Kein Brieftext bei Datei- oder Kuvert-only-Produkten.
|
||||||
|
text_template: (istDatei || istKuvertOnly) ? null : clean(String(letter.text || '')),
|
||||||
|
// Hochgeladene PDF-Briefe (Datei-Produkt ODER optional bei Kuvert-only).
|
||||||
|
source_files: (istDatei || (istKuvertOnly && hatDateien)) ? files : null,
|
||||||
|
source_file: (istDatei || (istKuvertOnly && hatDateien)) ? (files[0] || null) : null,
|
||||||
|
entries_count: recipients.length,
|
||||||
|
shipping_day: shippingDay,
|
||||||
|
// Mehrseitigkeit läuft selbstständig (Auto-Fluss); nicht vom Kunden wählbar.
|
||||||
|
multi_page: true,
|
||||||
|
// Schriftgröße nicht vom Kunden wählbar.
|
||||||
|
font_scale_letter: 1,
|
||||||
|
font_scale_envelope: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auftragsnummer (Tages-Präfix) mit Kollisions-Wiederholung.
|
||||||
|
const jt = new Date();
|
||||||
|
const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`;
|
||||||
|
const naechsteNummer = async () => {
|
||||||
|
const heutige = await svc('orders').readByQuery({ filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'] });
|
||||||
|
return `${praefix}-${pad3((heutige?.length ?? 0) + 1)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let orderId, orderNumber;
|
||||||
|
for (let v = 0; v < 6; v += 1) {
|
||||||
|
orderNumber = await naechsteNummer();
|
||||||
|
try { orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); break; }
|
||||||
|
catch (e) { if (v === 5 || !/unique|duplicate|bereits|exists/i.test(String(e?.message || ''))) throw e; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empfängerzeilen – Briefnummer wird vom System vergeben.
|
||||||
|
const entriesSvc = svc('order_entries');
|
||||||
|
let n = 0;
|
||||||
|
for (const e of recipients) {
|
||||||
|
n += 1;
|
||||||
|
await entriesSvc.createOne({
|
||||||
|
order: orderId, letter_number: n,
|
||||||
|
salutation: clean(e.salutation ?? null), first_name: clean(e.first_name ?? null), last_name: clean(e.last_name ?? null),
|
||||||
|
street: clean(e.street ?? null), house_no: clean(e.house_no ?? null), zip: clean(e.zip ?? null),
|
||||||
|
city: clean(e.city ?? null), country: clean(e.country ?? null), free_text: clean(e.free_text ?? null),
|
||||||
|
// Keine Platzhalter über die API – der Brieftext wird 1:1 verwendet.
|
||||||
|
placeholders: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Über die Public-API angelegt.' }).catch(() => {});
|
||||||
|
|
||||||
|
// Generierung IMMER anstoßen (fire-and-forget) – für jedes Produkt. Das
|
||||||
|
// Backend entscheidet, was zu erzeugen ist: Schriftstücke bei Handschrift-
|
||||||
|
// Produkten, Kuverts immer wenn gewünscht; bei Datei-Produkten (z. B.
|
||||||
|
// Unterschriftenservice) NUR die Kuverts. Ohne zu Erzeugendes = No-Op.
|
||||||
|
fetch(`${BACKEND_URL}/api/order/from-directus`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
|
||||||
|
body: JSON.stringify({ orderId }),
|
||||||
|
}).catch((e) => logger.warn(`[skrift-api] Generierung nicht gestartet: ${e.message}`));
|
||||||
|
|
||||||
|
return res.status(201).json({ order_number: orderNumber, status: 'pending' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[skrift-api] orders: ${err.stack || err.message}`);
|
||||||
|
return fehler(res, 500, 'Order could not be created.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GET /v1/orders/:nummer ──────────────────────────────────────────────────
|
||||||
|
router.get('/v1/orders/:nummer', async (req, res) => {
|
||||||
|
const client = await authClient(req);
|
||||||
|
if (!client) return fehler(res, 401, 'Missing or invalid API key.');
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const rows = await new ItemsService('orders', { schema, accountability: null }).readByQuery({
|
||||||
|
// Nur eigene Aufträge des Zugangs.
|
||||||
|
filter: { order_number: { _eq: String(req.params.nummer) }, api_client: { _eq: client.id } },
|
||||||
|
limit: 1, fields: ['order_number', 'status', 'production_status', 'production_status_changed_at',
|
||||||
|
'versandt_am', 'entries_count', 'date_created'],
|
||||||
|
});
|
||||||
|
const order = rows?.[0];
|
||||||
|
if (!order) return fehler(res, 404, 'Order not found.');
|
||||||
|
const status = kundenStatus(order);
|
||||||
|
// Seit wann der Status so ist: Zeitpunkt der letzten Produktionsstatus-
|
||||||
|
// Änderung; bei „shipped" ersatzweise der Versandzeitpunkt, sonst Anlage.
|
||||||
|
const statusSince = order.production_status_changed_at
|
||||||
|
|| (status === 'shipped' ? order.versandt_am : null)
|
||||||
|
|| order.date_created || null;
|
||||||
|
return res.json({
|
||||||
|
order_number: order.order_number,
|
||||||
|
status,
|
||||||
|
status_since: statusSince,
|
||||||
|
recipients: order.entries_count ?? null,
|
||||||
|
created_at: order.date_created ?? null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[skrift-api] status: ${err.stack || err.message}`);
|
||||||
|
return fehler(res, 500, 'Failed to retrieve order status.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /v1/clients (Admin) – neuen Zugang + Key erzeugen ───────────────────
|
||||||
|
// Nur mit Directus-Admin-Token/Session. Der Klartext-Key wird EINMALIG geliefert.
|
||||||
|
router.post('/v1/clients', async (req, res) => {
|
||||||
|
if (!req.accountability || req.accountability.admin !== true) return fehler(res, 403, 'Administrators only.');
|
||||||
|
const { name, product, products, customer, webhook_url } = req.body || {};
|
||||||
|
// Mehrere Produkte (Array von IDs) bevorzugt; „product" (einzeln) als Kurzform.
|
||||||
|
const prodIds = Array.isArray(products) ? products.filter((x) => x != null) : (product != null ? [product] : []);
|
||||||
|
if (!name || !prodIds.length) return fehler(res, 400, 'name and products (array of product IDs) are required.');
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const key = `sk_live_${crypto.randomBytes(24).toString('base64url')}`;
|
||||||
|
const id = await new ItemsService('api_clients', { schema, accountability: null }).createOne({
|
||||||
|
name: String(name), products: prodIds.map((pid) => ({ product: pid })),
|
||||||
|
customer: customer || null, webhook_url: webhook_url || null,
|
||||||
|
key_hash: sha256(key), key_prefix: key.slice(0, 14), active: true,
|
||||||
|
});
|
||||||
|
// Klartext-Key nur JETZT – wird nirgends gespeichert.
|
||||||
|
return res.status(201).json({ id, name, api_key: key, note: 'Store this key securely — it will not be shown again.' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[skrift-api] clients: ${err.stack || err.message}`);
|
||||||
|
return fehler(res, 500, 'API client could not be created.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── OpenAPI + Doku ──────────────────────────────────────────────────────────
|
||||||
|
const OPENAPI = {
|
||||||
|
openapi: '3.0.3',
|
||||||
|
info: { title: 'Skrift API', version: '1.0.0', description: 'Handwritten mail as a service. Authentication: API key in the X-Api-Key header. Bodies are JSON (UTF-8).' },
|
||||||
|
servers: [{ url: 'https://dev.skrift.de' }],
|
||||||
|
components: {
|
||||||
|
securitySchemes: { ApiKey: { type: 'apiKey', in: 'header', name: 'X-Api-Key' } },
|
||||||
|
schemas: {
|
||||||
|
Recipient: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
free_text: { type: 'string', description: 'Recipient address as free text, up to 5 lines separated by \\n.' },
|
||||||
|
salutation: { type: 'string' }, first_name: { type: 'string' }, last_name: { type: 'string' },
|
||||||
|
street: { type: 'string' }, house_no: { type: 'string' }, zip: { type: 'string' }, city: { type: 'string' }, country: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OrderRequest: {
|
||||||
|
type: 'object', required: ['recipients'],
|
||||||
|
properties: {
|
||||||
|
product: { type: 'string', description: 'Product key to use. Required if the API key is bound to more than one product; optional (auto) if exactly one.' },
|
||||||
|
document_format: { type: 'string', enum: ['a4', 'a6h', 'a6l'], description: 'Document format.' },
|
||||||
|
font: { type: 'string', enum: ['tilda', 'alva', 'ellie'] },
|
||||||
|
realistic: { type: 'boolean', default: true, description: 'Natural handwriting variation.' },
|
||||||
|
shipping_type: { type: 'string', enum: ['single', 'bulk'], default: 'bulk', description: 'single = direct to each recipient; bulk = consolidated shipment to you.' },
|
||||||
|
shipping_day: { type: 'string', enum: ['montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag', 'sonntag'], description: 'Preferred dispatch weekday (optional).' },
|
||||||
|
letter: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
text: { type: 'string', description: 'Letter body, fully written out (no placeholders). Long text flows across multiple pages automatically; [[seitenumbruch]] forces a page break.' },
|
||||||
|
files: { type: 'array', items: { type: 'string' }, description: 'file_id(s) from POST /v1/files (file / PDF products).' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
envelope: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
mode: { type: 'string', enum: ['none', 'recipient'], default: 'none', description: 'none = no envelope; recipient = recipient address on the envelope. Envelope size is selected automatically (A4 → DIN Lang, otherwise C6).' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
recipients: { type: 'array', items: { $ref: '#/components/schemas/Recipient' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
security: [{ ApiKey: [] }],
|
||||||
|
paths: {
|
||||||
|
'/v1/orders': {
|
||||||
|
post: {
|
||||||
|
summary: 'Create an order', description: 'Quantity equals the number of recipients. Returns the order number.',
|
||||||
|
requestBody: { required: true, content: { 'application/json': { schema: { $ref: '#/components/schemas/OrderRequest' } } } },
|
||||||
|
responses: { 201: { description: 'Order created', content: { 'application/json': { schema: { type: 'object', properties: { order_number: { type: 'string' }, status: { type: 'string' } } } } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/v1/orders/{order_number}': {
|
||||||
|
get: {
|
||||||
|
summary: 'Retrieve order status', parameters: [{ name: 'order_number', in: 'path', required: true, schema: { type: 'string' } }],
|
||||||
|
responses: { 200: { description: 'Order status', content: { 'application/json': { schema: { type: 'object', properties: { order_number: { type: 'string' }, status: { type: 'string', enum: ['pending', 'processing', 'shipped', 'cancelled'] }, status_since: { type: 'string', format: 'date-time', description: 'ISO 8601 timestamp since which the order has held its current status.' }, recipients: { type: 'integer' } } } } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/v1/files': {
|
||||||
|
post: {
|
||||||
|
summary: 'Upload a PDF (PDF only, max. 80 MB)',
|
||||||
|
requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['filename', 'content_base64'], properties: { filename: { type: 'string', description: 'Must end in .pdf.' }, content_base64: { type: 'string', description: 'PDF as base64.' } } } } } },
|
||||||
|
responses: { 200: { description: 'Uploaded', content: { 'application/json': { schema: { type: 'object', properties: { file_id: { type: 'string' } } } } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get('/v1/openapi.json', (_req, res) => res.json(OPENAPI));
|
||||||
|
|
||||||
|
router.get('/docs', (_req, res) => {
|
||||||
|
// Swagger UI, wenn die Assets im Extension-Ordner liegen; sonst die statische
|
||||||
|
// HTML-Doku als Fallback (nie leer). Beides CSP-/Brave-sicher (kein CDN).
|
||||||
|
let hasSwagger = false;
|
||||||
|
try { hasSwagger = fs.existsSync(path.join(EXT_DIR, 'swagger-ui-bundle.js')); } catch { /* ignore */ }
|
||||||
|
res.type('html').send(hasSwagger ? SWAGGER_HTML : DOCS_HTML);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Statische Doku-Assets (Swagger UI) – von der eigenen Domain, whitelisted.
|
||||||
|
router.get('/docs-assets/:file', (req, res) => {
|
||||||
|
const name = String(req.params.file || '');
|
||||||
|
if (name === 'init.js') { res.type('application/javascript').send(INIT_JS); return; }
|
||||||
|
const TYPES = { 'swagger-ui.css': 'text/css', 'swagger-ui-bundle.js': 'application/javascript' };
|
||||||
|
if (!TYPES[name]) return res.status(404).json({ error: 'Not found.' });
|
||||||
|
try {
|
||||||
|
const buf = fs.readFileSync(path.join(EXT_DIR, name));
|
||||||
|
res.setHeader('Content-Type', TYPES[name]);
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||||
|
return res.send(buf);
|
||||||
|
} catch {
|
||||||
|
return res.status(404).json({ error: 'Docs asset missing – place swagger-ui.css and swagger-ui-bundle.js in the extension folder.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Objekt-Form mit fester id → Mount unter /skrift-api (wie skrift-orders).
|
||||||
|
export default { id: 'skrift-api', handler };
|
||||||
2
Docker/directus/extensions/directus-extension-skrift-api/dist/swagger-ui-bundle.js
vendored
Normal file
2
Docker/directus/extensions/directus-extension-skrift-api/dist/swagger-ui-bundle.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
Docker/directus/extensions/directus-extension-skrift-api/dist/swagger-ui.css
vendored
Normal file
3
Docker/directus/extensions/directus-extension-skrift-api/dist/swagger-ui.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "directus-extension-skrift-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Public-API für Kunden (v1): Aufträge anlegen, Status abfragen, Datei-Upload. Key-Auth über api_clients (SHA-256).",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "endpoint",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "dist/index.js",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
Docker/directus/extensions/directus-extension-skrift-apikey/dist/index.js
vendored
Normal file
37
Docker/directus/extensions/directus-extension-skrift-apikey/dist/index.js
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Skrift – API-Key-Hook
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Beim Anlegen eines api_clients-Eintrags wird automatisch ein API-Key erzeugt:
|
||||||
|
* - key_hash = SHA-256 des Keys (dauerhaft; nur damit authentifiziert die API)
|
||||||
|
* - key_prefix = Erkennungspräfix (Anzeige)
|
||||||
|
* - key_plain = Klartext-Key, EINMALIG sichtbar (jetzt kopieren)
|
||||||
|
*
|
||||||
|
* Bei der nächsten Änderung des Eintrags wird key_plain wieder geleert
|
||||||
|
* („reveal once"). Der Klartext wird also nie dauerhaft gespeichert.
|
||||||
|
*/
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
||||||
|
|
||||||
|
export default (register) => {
|
||||||
|
// Anlegen: Key erzeugen, sofern noch keiner gesetzt ist.
|
||||||
|
register.filter('api_clients.items.create', (payload) => {
|
||||||
|
if (payload && typeof payload === 'object' && !payload.key_hash) {
|
||||||
|
const key = `sk_live_${crypto.randomBytes(24).toString('base64url')}`;
|
||||||
|
payload.key_hash = sha256(key);
|
||||||
|
payload.key_prefix = key.slice(0, 14);
|
||||||
|
payload.key_plain = key; // einmalige Anzeige
|
||||||
|
if (payload.active === undefined) payload.active = true;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Jede spätere Änderung entfernt den Klartext (nur direkt nach Anlage sichtbar),
|
||||||
|
// außer er wird gerade explizit gesetzt.
|
||||||
|
register.filter('api_clients.items.update', (payload) => {
|
||||||
|
if (payload && typeof payload === 'object' && payload.key_plain === undefined) {
|
||||||
|
payload.key_plain = null;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "directus-extension-skrift-apikey",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Erzeugt beim Anlegen eines api_clients automatisch einen API-Key (SHA-256 gespeichert, Klartext einmalig in key_plain).",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "hook",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "dist/index.js",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,14 +43,26 @@ const handler = (router, { services, getSchema, env, logger }) => {
|
|||||||
|
|
||||||
const ctx = async () => ({ schema: await getSchema(), accountability: null });
|
const ctx = async () => ({ schema: await getSchema(), accountability: null });
|
||||||
|
|
||||||
|
// Findet das Konto per primärer E-Mail ODER per zusätzlicher Login-Mail
|
||||||
|
// (alt_emails). So melden sich Zweit-Mails im SELBEN Konto an.
|
||||||
async function findUserByEmail(email, schema) {
|
async function findUserByEmail(email, schema) {
|
||||||
const users = new UsersService({ schema, accountability: null });
|
const users = new UsersService({ schema, accountability: null });
|
||||||
const found = await users.readByQuery({
|
const felder = ['id', 'email', 'status', 'alt_emails'];
|
||||||
filter: { email: { _eq: email } },
|
const primaer = await users.readByQuery({ filter: { email: { _eq: email } }, fields: felder, limit: 1 });
|
||||||
fields: ['id', 'email', 'status'],
|
if (primaer?.[0]) return primaer[0];
|
||||||
limit: 1,
|
// Zusätzliche Login-Mails: JSON-Feld ist DB-abhängig schwer filterbar, daher
|
||||||
});
|
// die (wenigen) Konten mit alt_emails laden und exakt in JS prüfen.
|
||||||
return found?.[0] || null;
|
try {
|
||||||
|
const cand = await users.readByQuery({ filter: { alt_emails: { _nnull: true } }, fields: felder, limit: -1 });
|
||||||
|
const treffer = (cand || []).find((u) => {
|
||||||
|
const liste = Array.isArray(u.alt_emails)
|
||||||
|
? u.alt_emails
|
||||||
|
: (typeof u.alt_emails === 'string' ? (() => { try { return JSON.parse(u.alt_emails); } catch { return []; } })() : []);
|
||||||
|
return Array.isArray(liste) && liste.map((x) => norm(x)).includes(email);
|
||||||
|
});
|
||||||
|
if (treffer) return treffer;
|
||||||
|
} catch { /* alt_emails evtl. noch nicht vorhanden → ignorieren */ }
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendCodeMail(email, code) {
|
async function sendCodeMail(email, code) {
|
||||||
@@ -173,6 +185,11 @@ const handler = (router, { services, getSchema, env, logger }) => {
|
|||||||
if (!user || user.status !== 'active')
|
if (!user || user.status !== 'active')
|
||||||
return res.status(401).json({ error: 'Kein aktives Konto zu dieser Adresse.' });
|
return res.status(401).json({ error: 'Kein aktives Konto zu dieser Adresse.' });
|
||||||
|
|
||||||
|
// WICHTIG: Directus kennt nur die PRIMÄRE E-Mail als Login. Wurde der Code
|
||||||
|
// an eine Zweit-Mail geschickt, melden wir dennoch mit der primären an –
|
||||||
|
// dadurch landet die Zweit-Mail im selben Konto (voller Zugriff).
|
||||||
|
const loginEmail = norm(user.email);
|
||||||
|
|
||||||
// Offizieller Login-Pfad mit rotierendem Zufallspasswort (s. Kopfkommentar).
|
// Offizieller Login-Pfad mit rotierendem Zufallspasswort (s. Kopfkommentar).
|
||||||
const users = new UsersService({ schema, accountability: null });
|
const users = new UsersService({ schema, accountability: null });
|
||||||
const tempPassword = tempPasswort();
|
const tempPassword = tempPasswort();
|
||||||
@@ -183,9 +200,9 @@ const handler = (router, { services, getSchema, env, logger }) => {
|
|||||||
// curl funktioniert – daher zuerst diese Variante, dann Fallbacks.
|
// curl funktioniert – daher zuerst diese Variante, dann Fallbacks.
|
||||||
const auth = new AuthenticationService({ schema, accountability: null });
|
const auth = new AuthenticationService({ schema, accountability: null });
|
||||||
const versuche = [
|
const versuche = [
|
||||||
() => auth.login('default', { email, password: tempPassword }),
|
() => auth.login('default', { email: loginEmail, password: tempPassword }),
|
||||||
() => auth.login({ email, password: tempPassword }),
|
() => auth.login({ email: loginEmail, password: tempPassword }),
|
||||||
() => auth.login({ email, password: tempPassword }, {}),
|
() => auth.login({ email: loginEmail, password: tempPassword }, {}),
|
||||||
];
|
];
|
||||||
let result, letzterFehler;
|
let result, letzterFehler;
|
||||||
for (const versuch of versuche) {
|
for (const versuch of versuche) {
|
||||||
|
|||||||
205
Docker/directus/extensions/directus-extension-skrift-contacts/dist/index.js
vendored
Normal file
205
Docker/directus/extensions/directus-extension-skrift-contacts/dist/index.js
vendored
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* Skrift – Kontakte-Hook (Anlage auf Anforderung, Aktionen, DSGVO-Purge)
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* Rund um die Collections `contacts` und `contact_actions`:
|
||||||
|
*
|
||||||
|
* 1) ANLAGE NUR AUF ANFORDERUNG — Kontakte werden NICHT automatisch bei jedem
|
||||||
|
* Versand gespeichert. Erst wenn am Auftrag „In Kontakte speichern"
|
||||||
|
* (orders.keep_contacts) gesetzt wird, entsteht je Empfängerzeile ein
|
||||||
|
* Kontakt (Guard: orders.contacts_created). Diese Kontakte bleiben dauerhaft.
|
||||||
|
*
|
||||||
|
* 2) STANDARDAKTIONEN (contact_actions) —
|
||||||
|
* • type „angebot" → sofort eine Mail mit den Eckdaten (Name, Adresse,
|
||||||
|
* E-Mail, Telefon, Notizen) an die interne Adresse (fürs Rechnungsprogramm).
|
||||||
|
* • type „rueckruf" → am fälligen Tag (Zeitplan) eine Erinnerungs-Mail.
|
||||||
|
*
|
||||||
|
* 3) DSGVO-STRIP (optional, per Env) — Empfängerdaten (order_entries) alter,
|
||||||
|
* versendeter Aufträge OHNE „In Kontakte speichern" werden anonymisiert.
|
||||||
|
* Standard: nur DRY-RUN (Log). Scharf via SKRIFT_CONTACTS_STRIP_ENTRIES=1.
|
||||||
|
* Kontakte selbst werden NICHT automatisch gelöscht (nur manuell).
|
||||||
|
*
|
||||||
|
* Env:
|
||||||
|
* SKRIFT_CONTACTS_REMINDER_CRON Cron der Rückruf-Erinnerung (Default „0 7 * * *").
|
||||||
|
* SKRIFT_CONTACTS_PURGE_CRON Cron des DSGVO-Strip (Default „0 3 * * *").
|
||||||
|
* SKRIFT_CONTACTS_STRIP_ENTRIES „1"/„true" = Empfängerdaten wirklich löschen.
|
||||||
|
* SKRIFT_TEAM_EMAIL / EMAIL_FROM Fallback-Empfänger, falls email_settings.team_email leer.
|
||||||
|
*
|
||||||
|
* Von Hand als ESM (kein Build-Schritt), analog zu skrift-mailer/skrift-apikey.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RETENTION_DAYS = 30;
|
||||||
|
// Alle 15 Minuten prüfen, damit die Erinnerung nahe der gewählten Uhrzeit kommt.
|
||||||
|
const REMINDER_CRON = process.env.SKRIFT_CONTACTS_REMINDER_CRON || '*/15 * * * *';
|
||||||
|
const PURGE_CRON = process.env.SKRIFT_CONTACTS_PURGE_CRON || '0 3 * * *';
|
||||||
|
const STRIP_ENTRIES = /^(1|true|yes|on)$/i.test(String(process.env.SKRIFT_CONTACTS_STRIP_ENTRIES || ''));
|
||||||
|
|
||||||
|
const ENTRY_PII = ['salutation', 'first_name', 'last_name', 'street', 'house_no',
|
||||||
|
'zip', 'city', 'country', 'free_text', 'placeholders', 'text'];
|
||||||
|
|
||||||
|
const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
||||||
|
const htmlZuText = (html) => String(html || '')
|
||||||
|
.replace(/<\s*br\s*\/?>/gi, '\n').replace(/<\/(p|div|tr|h[1-6]|li|table)>/gi, '\n')
|
||||||
|
.replace(/<[^>]+>/g, '').replace(/ /gi, ' ')
|
||||||
|
.replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>')
|
||||||
|
.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||||
|
|
||||||
|
const contactName = (c) => [c?.salutation, c?.first_name, c?.last_name].filter(Boolean).join(' ')
|
||||||
|
|| c?.company || c?.email || c?.phone || '(ohne Namen)';
|
||||||
|
|
||||||
|
// Eckdaten eines Kontakts als HTML-Block (für Angebots-/Erinnerungs-Mails).
|
||||||
|
function contactHtml(c, extraNote) {
|
||||||
|
const anschrift = [
|
||||||
|
c.company ? `<strong>${esc(c.company)}</strong>` : '',
|
||||||
|
esc([c.salutation, c.first_name, c.last_name].filter(Boolean).join(' ')),
|
||||||
|
esc([c.street, c.house_no].filter(Boolean).join(' ')),
|
||||||
|
esc([c.zip, c.city].filter(Boolean).join(' ')),
|
||||||
|
c.country ? esc(c.country) : '',
|
||||||
|
].filter(Boolean).join('<br>');
|
||||||
|
const zeilen = [];
|
||||||
|
zeilen.push(`<p style="margin:0 0 4px;font-weight:600;">Anschrift</p><p style="margin:0 0 12px;line-height:1.6;">${anschrift || '—'}</p>`);
|
||||||
|
zeilen.push('<p style="margin:0 0 12px;">'
|
||||||
|
+ `E-Mail: ${c.email ? esc(c.email) : '—'}<br>Telefon: ${c.phone ? esc(c.phone) : '—'}</p>`);
|
||||||
|
const notes = [c.notes, extraNote].filter((n) => n && String(n).trim()).join('\n\n');
|
||||||
|
if (notes) zeilen.push(`<p style="margin:0 0 4px;font-weight:600;">Notizen</p><p style="margin:0;line-height:1.6;white-space:pre-wrap;">${esc(notes)}</p>`);
|
||||||
|
return zeilen.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (register, { services, getSchema, logger }) => {
|
||||||
|
const { ItemsService, MailService } = services;
|
||||||
|
const svc = (coll, schema) => new ItemsService(coll, { schema, accountability: null });
|
||||||
|
|
||||||
|
async function loadSettings(schema) {
|
||||||
|
try { const rows = await svc('email_settings', schema).readByQuery({ limit: 1 }); return rows?.[0] || {}; }
|
||||||
|
catch { return {}; }
|
||||||
|
}
|
||||||
|
async function sendTeamMail(schema, subject, html) {
|
||||||
|
const settings = await loadSettings(schema);
|
||||||
|
const to = settings.team_email || process.env.SKRIFT_TEAM_EMAIL || process.env.EMAIL_FROM;
|
||||||
|
if (!to) { logger.warn('[skrift-contacts] keine Team-Adresse (email_settings.team_email) – Mail übersprungen'); return; }
|
||||||
|
const msg = { to, subject, html, text: htmlZuText(html) };
|
||||||
|
if (settings.sender_email) msg.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
|
||||||
|
await new MailService({ schema, accountability: null }).send(msg);
|
||||||
|
logger.info(`[skrift-contacts] Mail „${subject}" an ${to}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1) Kontakt-Anlage NUR wenn „In Kontakte speichern" gesetzt wird ─────────
|
||||||
|
function contactFromEntry(order, e) {
|
||||||
|
const c = {
|
||||||
|
salutation: e.salutation || null, first_name: e.first_name || null, last_name: e.last_name || null,
|
||||||
|
street: e.street || null, house_no: e.house_no || null, zip: e.zip || null, city: e.city || null,
|
||||||
|
country: e.country || null, lead_status: 'neu',
|
||||||
|
source_order: order.id, source_entry: e.id, sent_at: order.versandt_am || null,
|
||||||
|
};
|
||||||
|
if (!c.first_name && !c.last_name && e.free_text) {
|
||||||
|
const lines = String(e.free_text).split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (lines.length) c.last_name = lines[0];
|
||||||
|
if (lines.length) c.notes = 'Adresse:\n' + lines.join('\n');
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
async function saveContactsForOrder(orderId) {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const orders = svc('orders', schema);
|
||||||
|
const order = await orders.readOne(orderId, {
|
||||||
|
fields: ['id', 'order_number', 'versandt_am', 'contacts_created'],
|
||||||
|
});
|
||||||
|
if (!order) return;
|
||||||
|
if (order.contacts_created) return; // schon angelegt → nichts weiter zu tun
|
||||||
|
const entries = await svc('order_entries', schema).readByQuery({
|
||||||
|
filter: { order: { _eq: orderId } }, limit: -1,
|
||||||
|
fields: ['id', 'salutation', 'first_name', 'last_name', 'street', 'house_no', 'zip', 'city', 'country', 'free_text'],
|
||||||
|
});
|
||||||
|
if (entries?.length) await svc('contacts', schema).createMany(entries.map((e) => contactFromEntry(order, e)));
|
||||||
|
await orders.updateOne(orderId, { contacts_created: true });
|
||||||
|
logger.info(`[skrift-contacts] ${entries?.length || 0} Kontakt(e) aus Auftrag ${order.order_number} gespeichert`);
|
||||||
|
}
|
||||||
|
|
||||||
|
register.action('orders.items.update', async ({ keys, payload }) => {
|
||||||
|
if (payload?.keep_contacts !== true) return; // nur auf ausdrückliche Anforderung
|
||||||
|
for (const id of keys || []) {
|
||||||
|
try { await saveContactsForOrder(id); }
|
||||||
|
catch (e) { logger.warn(`[skrift-contacts] speichern ${id}: ${e.message}`); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 2) Standardaktionen ─────────────────────────────────────────────────────
|
||||||
|
register.action('contact_actions.items.create', async ({ key, payload }) => {
|
||||||
|
const typ = payload?.type;
|
||||||
|
if (typ !== 'angebot' && typ !== 'mail') return; // Rückruf läuft über den Zeitplan
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const a = await svc('contact_actions', schema).readOne(key, {
|
||||||
|
fields: ['id', 'note', 'contact.salutation', 'contact.first_name', 'contact.last_name', 'contact.company',
|
||||||
|
'contact.street', 'contact.house_no', 'contact.zip', 'contact.city', 'contact.country',
|
||||||
|
'contact.email', 'contact.phone', 'contact.notes'],
|
||||||
|
});
|
||||||
|
if (!a?.contact) return;
|
||||||
|
const name = contactName(a.contact);
|
||||||
|
const betreff = typ === 'mail' ? `Notiz: ${name}` : `Angebot vorbereiten: ${name}`;
|
||||||
|
const einleitung = typ === 'mail' ? `Notiz zu <strong>${esc(name)}</strong>:` : `Angebot vorbereiten für <strong>${esc(name)}</strong>:`;
|
||||||
|
const html = `<p style="margin:0 0 12px;">${einleitung}</p>${contactHtml(a.contact, a.note)}`;
|
||||||
|
await sendTeamMail(schema, betreff, html);
|
||||||
|
} catch (e) { logger.warn(`[skrift-contacts] Aktions-Mail (${typ}): ${e.message}`); }
|
||||||
|
});
|
||||||
|
|
||||||
|
register.schedule(REMINDER_CRON, async () => {
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
const actions = svc('contact_actions', schema);
|
||||||
|
const due = await actions.readByQuery({
|
||||||
|
filter: { _and: [{ type: { _eq: 'rueckruf' } }, { status: { _eq: 'offen' } }, { notified: { _eq: false } }, { due_date: { _lte: nowIso } }] },
|
||||||
|
fields: ['id', 'note', 'due_date', 'contact.salutation', 'contact.first_name', 'contact.last_name', 'contact.company',
|
||||||
|
'contact.street', 'contact.house_no', 'contact.zip', 'contact.city', 'contact.country', 'contact.email', 'contact.phone', 'contact.notes'],
|
||||||
|
limit: -1,
|
||||||
|
});
|
||||||
|
for (const a of due || []) {
|
||||||
|
if (!a.contact) { await actions.updateOne(a.id, { notified: true }); continue; }
|
||||||
|
const name = contactName(a.contact);
|
||||||
|
const wann = a.due_date ? new Date(a.due_date).toLocaleString('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'short', timeStyle: 'short' }) : '';
|
||||||
|
const html = `<p style="margin:0 0 12px;">Fälliger Rückruf${wann ? ' (' + esc(wann) + ' Uhr)' : ''} bei <strong>${esc(name)}</strong>`
|
||||||
|
+ `${a.contact.phone ? ' · <strong>' + esc(a.contact.phone) + '</strong>' : ''}:</p>${contactHtml(a.contact, a.note)}`;
|
||||||
|
try { await sendTeamMail(schema, `Rückruf fällig: ${name}`, html); await actions.updateOne(a.id, { notified: true }); }
|
||||||
|
catch (e) { logger.warn(`[skrift-contacts] Rückruf-Mail ${a.id}: ${e.message}`); }
|
||||||
|
}
|
||||||
|
} catch (e) { logger.warn(`[skrift-contacts] Erinnerungslauf: ${e.message}`); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 3) Täglicher Aufräumlauf ────────────────────────────────────────────────
|
||||||
|
register.schedule(PURGE_CRON, async () => {
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000).toISOString();
|
||||||
|
|
||||||
|
// 3a) Kontakte mit „kein Interesse", seit 30 Tagen unverändert → löschen.
|
||||||
|
// Alle anderen Lead-Status bleiben dauerhaft erhalten.
|
||||||
|
const contacts = svc('contacts', schema);
|
||||||
|
const raus = await contacts.readByQuery({
|
||||||
|
filter: { _and: [{ lead_status: { _eq: 'kein_interesse' } }, { date_updated: { _lt: cutoff } }] },
|
||||||
|
fields: ['id'], limit: -1,
|
||||||
|
});
|
||||||
|
if (raus?.length) { await contacts.deleteMany(raus.map((r) => r.id)); logger.info(`[skrift-contacts] ${raus.length} Kontakt(e) „kein Interesse" (>30 Tage) gelöscht`); }
|
||||||
|
|
||||||
|
// 3b) DSGVO-Strip der Empfängerdaten im Auftrag (optional, per Env).
|
||||||
|
const orders = svc('orders', schema);
|
||||||
|
const alte = await orders.readByQuery({
|
||||||
|
filter: { _and: [
|
||||||
|
{ keep_contacts: { _eq: false } }, { entries_stripped: { _eq: false } },
|
||||||
|
{ production_status: { _eq: 'versendet' } }, { versandt_am: { _lt: cutoff } },
|
||||||
|
] },
|
||||||
|
fields: ['id', 'order_number'], limit: -1,
|
||||||
|
});
|
||||||
|
for (const o of alte || []) {
|
||||||
|
const entries = await svc('order_entries', schema).readByQuery({ filter: { order: { _eq: o.id } }, fields: ['id'], limit: -1 });
|
||||||
|
if (!STRIP_ENTRIES) {
|
||||||
|
logger.info(`[skrift-contacts] DSGVO DRY-RUN: würde Empfängerdaten von Auftrag ${o.order_number} (${entries?.length || 0} Zeilen) anonymisieren – scharf via SKRIFT_CONTACTS_STRIP_ENTRIES=1`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const wipe = Object.fromEntries(ENTRY_PII.map((k) => [k, null]));
|
||||||
|
if (entries?.length) await svc('order_entries', schema).updateMany(entries.map((r) => r.id), wipe);
|
||||||
|
await orders.updateOne(o.id, { entries_stripped: true });
|
||||||
|
logger.info(`[skrift-contacts] DSGVO: Empfängerdaten von Auftrag ${o.order_number} anonymisiert`);
|
||||||
|
}
|
||||||
|
} catch (e) { logger.warn(`[skrift-contacts] DSGVO-Lauf: ${e.message}`); }
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "directus-extension-skrift-contacts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Legt bei Versand automatisch Kontakte je Empfänger an und löscht sie (samt Empfängerdaten im Auftrag) nach 30 Tagen – außer der Auftrag ist als „Kontakte behalten“ markiert.",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "hook",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "dist/index.js",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
397
Docker/directus/extensions/directus-extension-skrift-kontakte/dist/index.js
vendored
Normal file
397
Docker/directus/extensions/directus-extension-skrift-kontakte/dist/index.js
vendored
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
/**
|
||||||
|
* Skrift – Modul „Kontakte" (Mini-CRM)
|
||||||
|
* ===========================================================================
|
||||||
|
* Handytaugliches Lead-Board über die Collection `contacts`: alle Empfänger,
|
||||||
|
* denen ein Schriftstück gesendet wurde. Fokus auf Nachtelefonieren:
|
||||||
|
*
|
||||||
|
* • Liste mit Suche + Status-Filter, ein Tipp öffnet den Kontakt.
|
||||||
|
* • Detail: Name/Adresse, Telefon als GroundWire-Anruf-Button, E-Mail,
|
||||||
|
* Lead-Status, „Behalten" und ein Notizfeld – alles mit Auto-Speichern.
|
||||||
|
* • ◀/▶ blättert durch die gefilterte Liste, ohne zurückzuspringen.
|
||||||
|
* • CSV-Export der (gefilterten) Kontakte. Import über die normale
|
||||||
|
* Directus-Collection-Seite (Content → Kontakte → Import).
|
||||||
|
*
|
||||||
|
* Anruf-Schema fest GroundWire (groundwire://call?number=…). Die Konstante DIAL
|
||||||
|
* unten ist die einzige Stelle, falls ein anderes Format nötig wird.
|
||||||
|
*
|
||||||
|
* Von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
|
||||||
|
*/
|
||||||
|
import { defineComponent, ref, reactive, computed, watch, onMounted, h, resolveComponent } from 'vue';
|
||||||
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
|
|
||||||
|
const ACCENT = '#1E50A5';
|
||||||
|
// GroundWire-Anruf-Link (Acrobits „Call URI"): scheme:NUMMER?dialAction=…
|
||||||
|
// Die Nummer steht direkt hinter dem Doppelpunkt (kein „//"), ohne Leerzeichen.
|
||||||
|
// dialAction=voiceCall → sofortiger VoIP-Anruf über GroundWire. Für nur
|
||||||
|
// Vorbefüllen (Nummer eintippen, selbst tippen) den ?dialAction-Teil weglassen.
|
||||||
|
const cleanNr = (nr) => String(nr || '').replace(/[^\d+]/g, '');
|
||||||
|
const DIAL = (nr) => 'groundwire:' + cleanNr(nr) + '?dialAction=voiceCall';
|
||||||
|
|
||||||
|
const LEAD = [
|
||||||
|
{ key: 'neu', label: 'Neu', color: '#6b7280' },
|
||||||
|
{ key: 'kontaktiert', label: 'Kontaktiert', color: ACCENT },
|
||||||
|
{ key: 'interessiert', label: 'Interessiert', color: '#e07b00' },
|
||||||
|
{ key: 'kein_interesse', label: 'Kein Interesse', color: '#c62828' },
|
||||||
|
{ key: 'kunde', label: 'Kunde', color: '#2e7d32' },
|
||||||
|
];
|
||||||
|
const LEAD_LABEL = Object.fromEntries(LEAD.map((l) => [l.key, l.label]));
|
||||||
|
const LEAD_COLOR = Object.fromEntries(LEAD.map((l) => [l.key, l.color]));
|
||||||
|
|
||||||
|
const FIELDS = ['id', 'salutation', 'first_name', 'last_name', 'company', 'street', 'house_no',
|
||||||
|
'zip', 'city', 'country', 'email', 'phone', 'notes', 'lead_status', 'sent_at',
|
||||||
|
'source_order.order_number'];
|
||||||
|
|
||||||
|
// ── Stil-Helfer ──────────────────────────────────────────────────────────────
|
||||||
|
const CARD = 'border:1px solid var(--border-subdued);border-radius:10px;padding:16px 18px;background:var(--background-page,#fff);box-shadow:0 1px 3px rgba(0,0,0,.08);';
|
||||||
|
// Klar erkennbares Eingabefeld: kräftigere Umrandung + leicht getönter Grund,
|
||||||
|
// damit es sich deutlich von der weißen Karte abhebt.
|
||||||
|
const S_INPUT = 'width:100%;padding:11px 12px;border:1.5px solid var(--border-normal,#c4cdde);border-radius:8px;background:var(--background-subdued,#eef1f7);color:var(--foreground-normal);font:inherit;font-size:15px;box-sizing:border-box;';
|
||||||
|
const LBL = 'font-size:12px;font-weight:600;color:var(--foreground-subdued);margin:0 0 4px;text-transform:uppercase;letter-spacing:.03em;';
|
||||||
|
|
||||||
|
function btn(label, onClick, kind) {
|
||||||
|
const base = 'cursor:pointer;border-radius:8px;padding:10px 16px;font:inherit;font-size:14px;border:1px solid;';
|
||||||
|
const style = kind === 'primary'
|
||||||
|
? base + `border-color:${ACCENT};background:${ACCENT};color:#fff;`
|
||||||
|
: kind === 'ghost'
|
||||||
|
? base + 'border-color:transparent;background:transparent;color:var(--foreground-normal);'
|
||||||
|
: base + 'border-color:var(--border-normal);background:var(--background-subdued);color:var(--foreground-normal);';
|
||||||
|
return h('button', { type: 'button', onClick, style }, label);
|
||||||
|
}
|
||||||
|
function chip(text, farbe) {
|
||||||
|
return h('span', {
|
||||||
|
style: 'display:inline-flex;align-items:center;gap:4px;font-size:12px;padding:3px 10px;border-radius:20px;'
|
||||||
|
+ `background:${farbe || 'var(--background-subdued)'};color:#fff;white-space:nowrap;`,
|
||||||
|
}, text);
|
||||||
|
}
|
||||||
|
function subdued(text, extra) {
|
||||||
|
return h('div', { style: `color:var(--foreground-subdued);font-size:14px;${extra || ''}` }, text);
|
||||||
|
}
|
||||||
|
function feld(label, node) {
|
||||||
|
return h('div', { style: 'margin-bottom:14px;' }, [h('div', { style: LBL }, label), node]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const anzeigeName = (k) => [k.salutation, k.first_name, k.last_name].filter(Boolean).join(' ')
|
||||||
|
|| k.company || k.email || k.phone || '(ohne Namen)';
|
||||||
|
const adresse = (k) => [[k.street, k.house_no].filter(Boolean).join(' '),
|
||||||
|
[k.zip, k.city].filter(Boolean).join(' '), k.country].filter(Boolean).join(', ');
|
||||||
|
|
||||||
|
const Board = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const api = useApi();
|
||||||
|
const kontakte = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const fehler = ref('');
|
||||||
|
const meldung = ref('');
|
||||||
|
const suche = ref('');
|
||||||
|
const statusFilter = ref('');
|
||||||
|
const aktivId = ref(null);
|
||||||
|
const savedFlash = ref(null); // Feld-Key, der zuletzt gespeichert wurde
|
||||||
|
const aktionen = ref([]); // offene/erledigte Aktionen des aktiven Kontakts
|
||||||
|
const rueckruf = reactive({ open: false, date: '', time: '09:00' });
|
||||||
|
const mailMich = reactive({ open: false, note: '' });
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
loading.value = true; fehler.value = '';
|
||||||
|
try {
|
||||||
|
const r = await api.get('/items/contacts', {
|
||||||
|
params: { limit: -1, sort: ['-sent_at', '-date_created'], fields: FIELDS },
|
||||||
|
});
|
||||||
|
kontakte.value = r.data.data || [];
|
||||||
|
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Fehler beim Laden.'; }
|
||||||
|
finally { loading.value = false; }
|
||||||
|
}
|
||||||
|
onMounted(laden);
|
||||||
|
|
||||||
|
const gefiltert = computed(() => {
|
||||||
|
const q = suche.value.trim().toLowerCase();
|
||||||
|
return kontakte.value.filter((k) => {
|
||||||
|
if (statusFilter.value && (k.lead_status || 'neu') !== statusFilter.value) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
const hay = [k.salutation, k.first_name, k.last_name, k.company, k.zip, k.city,
|
||||||
|
k.email, k.phone, k.source_order?.order_number].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
return hay.includes(q);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const aktiv = computed(() => kontakte.value.find((k) => k.id === aktivId.value) || null);
|
||||||
|
const aktivIndex = computed(() => gefiltert.value.findIndex((k) => k.id === aktivId.value));
|
||||||
|
|
||||||
|
async function save(id, patch, feldKey) {
|
||||||
|
try {
|
||||||
|
await api.patch(`/items/contacts/${id}`, patch);
|
||||||
|
const k = kontakte.value.find((x) => x.id === id);
|
||||||
|
if (k) Object.assign(k, patch);
|
||||||
|
if (feldKey) { savedFlash.value = feldKey; setTimeout(() => { if (savedFlash.value === feldKey) savedFlash.value = null; }, 1500); }
|
||||||
|
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Speichern fehlgeschlagen.'; }
|
||||||
|
}
|
||||||
|
// Speichert nur, wenn sich der Wert wirklich geändert hat (Blur-Handler).
|
||||||
|
const saveIfChanged = (k, key) => (ev) => {
|
||||||
|
const val = ev.target.value;
|
||||||
|
if ((k[key] || '') !== val) save(k.id, { [key]: val || null }, key);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Standardaktionen (Rückruf / Angebot) ─────────────────────────────────
|
||||||
|
async function ladeAktionen(id) {
|
||||||
|
if (!id) { aktionen.value = []; return; }
|
||||||
|
try {
|
||||||
|
const r = await api.get('/items/contact_actions', {
|
||||||
|
params: { filter: { contact: { _eq: id } }, sort: ['-date_created'], limit: -1,
|
||||||
|
fields: ['id', 'type', 'due_date', 'note', 'status', 'notified'] },
|
||||||
|
});
|
||||||
|
aktionen.value = r.data.data || [];
|
||||||
|
} catch { aktionen.value = []; }
|
||||||
|
}
|
||||||
|
watch(aktivId, (id) => { rueckruf.open = false; rueckruf.date = ''; rueckruf.time = '09:00'; mailMich.open = false; mailMich.note = ''; ladeAktionen(id); });
|
||||||
|
|
||||||
|
const isoDatum = (d) => { const x = new Date(); x.setHours(0, 0, 0, 0); x.setDate(x.getDate() + d); return x; };
|
||||||
|
const ymd = (dt) => `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
async function planeRueckruf(k) {
|
||||||
|
if (!rueckruf.date) { fehler.value = 'Bitte ein Datum wählen.'; return; }
|
||||||
|
try {
|
||||||
|
await api.post('/items/contact_actions', {
|
||||||
|
contact: k.id, type: 'rueckruf', status: 'offen',
|
||||||
|
due_date: new Date(rueckruf.date + 'T' + (rueckruf.time || '09:00') + ':00').toISOString(),
|
||||||
|
});
|
||||||
|
rueckruf.open = false; rueckruf.date = ''; rueckruf.time = '09:00';
|
||||||
|
await ladeAktionen(k.id);
|
||||||
|
meldung.value = 'Rückruf-Erinnerung gesetzt.'; setTimeout(() => { meldung.value = ''; }, 2500);
|
||||||
|
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Rückruf nicht anlegen.'; }
|
||||||
|
}
|
||||||
|
async function sendeAngebot(k) {
|
||||||
|
try {
|
||||||
|
await api.post('/items/contact_actions', { contact: k.id, type: 'angebot', status: 'erledigt' });
|
||||||
|
await ladeAktionen(k.id);
|
||||||
|
meldung.value = 'Angebot-Eckdaten wurden dir per Mail geschickt.'; setTimeout(() => { meldung.value = ''; }, 3500);
|
||||||
|
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Angebot nicht senden.'; }
|
||||||
|
}
|
||||||
|
// Legt eine Musterbestellung an: erscheint als Musteranfrage in
|
||||||
|
// Produktion → Neu und wird über den bestehenden „Muster drucken"-Workflow
|
||||||
|
// gedruckt. Keine Mail an den Kontakt.
|
||||||
|
async function sendeMuster(k) {
|
||||||
|
const name = [k.salutation, k.first_name, k.last_name].filter(Boolean).join(' ') || k.company || 'Kontakt';
|
||||||
|
try {
|
||||||
|
await api.post('/items/offer_requests', {
|
||||||
|
type: 'muster', status: 'neu', name, product: 'Muster',
|
||||||
|
email: k.email || 'muster@skrift.de', phone: k.phone || null,
|
||||||
|
address: {
|
||||||
|
company: k.company || '', salutation: k.salutation || '',
|
||||||
|
first_name: k.first_name || '', last_name: k.last_name || '',
|
||||||
|
street: [k.street, k.house_no].filter(Boolean).join(' '),
|
||||||
|
zip: k.zip || '', city: k.city || '', country: k.country || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
meldung.value = 'Musterbestellung angelegt – erscheint in Produktion → Neu.'; setTimeout(() => { meldung.value = ''; }, 3500);
|
||||||
|
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Muster nicht anlegen.'; }
|
||||||
|
}
|
||||||
|
// Schickt dir (interne Adresse) die Eckdaten des Kontakts + deine Notiz per Mail.
|
||||||
|
async function sendeMail(k) {
|
||||||
|
if (!mailMich.note.trim()) { fehler.value = 'Bitte eine Notiz eingeben.'; return; }
|
||||||
|
try {
|
||||||
|
await api.post('/items/contact_actions', { contact: k.id, type: 'mail', status: 'erledigt', note: mailMich.note });
|
||||||
|
mailMich.open = false; mailMich.note = '';
|
||||||
|
meldung.value = 'Deine Notiz wurde dir per Mail geschickt.'; setTimeout(() => { meldung.value = ''; }, 3500);
|
||||||
|
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e.message || 'Konnte Mail nicht senden.'; }
|
||||||
|
}
|
||||||
|
async function erledige(a, k) {
|
||||||
|
try { await api.patch(`/items/contact_actions/${a.id}`, { status: 'erledigt' }); await ladeAktionen(k.id); }
|
||||||
|
catch (e) { fehler.value = e?.message || 'Konnte nicht als erledigt markieren.'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCsv() {
|
||||||
|
const rows = gefiltert.value;
|
||||||
|
const cols = [['salutation', 'Anrede'], ['first_name', 'Vorname'], ['last_name', 'Nachname'],
|
||||||
|
['company', 'Firma'], ['street', 'Straße'], ['house_no', 'Hausnr'], ['zip', 'PLZ'], ['city', 'Ort'],
|
||||||
|
['country', 'Land'], ['email', 'E-Mail'], ['phone', 'Telefon'], ['lead_status', 'Status'],
|
||||||
|
['notes', 'Notizen'], ['sent_at', 'Versendet am']];
|
||||||
|
const esc = (v) => { const s = v == null ? '' : String(v); return /[",\n;]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; };
|
||||||
|
const csv = '' + cols.map((c) => c[1]).join(',') + '\n'
|
||||||
|
+ rows.map((r) => cols.map((c) => esc(r[c[0]])).join(',')).join('\n');
|
||||||
|
const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' }));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = `kontakte-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Liste ──
|
||||||
|
function karte(k) {
|
||||||
|
return h('div', {
|
||||||
|
onClick: () => { aktivId.value = k.id; fehler.value = ''; },
|
||||||
|
style: CARD + 'cursor:pointer;margin-bottom:10px;display:flex;justify-content:space-between;align-items:flex-start;gap:12px;',
|
||||||
|
}, [
|
||||||
|
h('div', { style: 'min-width:0;' }, [
|
||||||
|
h('div', { style: 'font-size:16px;font-weight:600;margin-bottom:2px;' }, anzeigeName(k)),
|
||||||
|
subdued(adresse(k) || '—', 'font-size:13px;'),
|
||||||
|
k.phone ? subdued('📞 ' + k.phone, 'font-size:13px;margin-top:2px;') : null,
|
||||||
|
]),
|
||||||
|
h('div', { style: 'display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex-shrink:0;' }, [
|
||||||
|
chip(LEAD_LABEL[k.lead_status || 'neu'], LEAD_COLOR[k.lead_status || 'neu']),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listenAnsicht() {
|
||||||
|
const kopf = h('div', { style: 'display:flex;flex-direction:column;gap:10px;margin-bottom:16px;' }, [
|
||||||
|
h('input', {
|
||||||
|
type: 'search', value: suche.value, placeholder: '🔎 Name, Ort, Telefon, E-Mail …',
|
||||||
|
style: S_INPUT, onInput: (e) => { suche.value = e.target.value; },
|
||||||
|
}),
|
||||||
|
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;' }, [
|
||||||
|
h('select', {
|
||||||
|
style: S_INPUT + 'width:auto;flex:1;min-width:150px;cursor:pointer;',
|
||||||
|
onChange: (e) => { statusFilter.value = e.target.value; },
|
||||||
|
}, [
|
||||||
|
h('option', { value: '', selected: statusFilter.value === '' }, 'Alle Status'),
|
||||||
|
...LEAD.map((l) => h('option', { value: l.key, selected: statusFilter.value === l.key }, l.label)),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;justify-content:space-between;' }, [
|
||||||
|
subdued(`${gefiltert.value.length} Kontakt(e)`),
|
||||||
|
h('div', { style: 'display:flex;gap:8px;' }, [
|
||||||
|
btn('↻ Aktualisieren', laden),
|
||||||
|
btn('⭳ CSV-Export', exportCsv),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
const liste = loading.value ? subdued('Lädt …')
|
||||||
|
: gefiltert.value.length ? h('div', {}, gefiltert.value.map(karte))
|
||||||
|
: subdued('Keine Kontakte. Sobald ein Auftrag versendet wird, erscheinen die Empfänger hier automatisch.');
|
||||||
|
const hinweis = subdued('CSV-Import: Content → Kontakte → Import (oben rechts).', 'margin-top:16px;font-size:12px;');
|
||||||
|
return [kopf, liste, hinweis];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detail ──
|
||||||
|
function detailAnsicht() {
|
||||||
|
const k = aktiv.value;
|
||||||
|
if (!k) { aktivId.value = null; return listenAnsicht(); }
|
||||||
|
const idx = aktivIndex.value;
|
||||||
|
const gesamt = gefiltert.value.length;
|
||||||
|
const geh = (d) => {
|
||||||
|
const l = gefiltert.value;
|
||||||
|
let i = aktivIndex.value; if (i < 0) return;
|
||||||
|
i += d; if (i < 0 || i >= l.length) return;
|
||||||
|
aktivId.value = l[i].id; fehler.value = '';
|
||||||
|
};
|
||||||
|
const flash = (key) => savedFlash.value === key ? h('span', { style: 'color:#2e7d32;font-size:12px;margin-left:8px;' }, '✓ gespeichert') : null;
|
||||||
|
|
||||||
|
const navRow = h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:14px;' }, [
|
||||||
|
btn('◀ Liste', () => { aktivId.value = null; }, 'ghost'),
|
||||||
|
h('div', { style: 'display:flex;align-items:center;gap:8px;' }, [
|
||||||
|
h('button', { type: 'button', disabled: idx <= 0, onClick: () => geh(-1),
|
||||||
|
style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-subdued);border-radius:8px;padding:8px 12px;font-size:16px;' + (idx <= 0 ? 'opacity:.4;cursor:not-allowed;' : '') }, '◀'),
|
||||||
|
subdued(gesamt ? `${idx + 1} / ${gesamt}` : '–'),
|
||||||
|
h('button', { type: 'button', disabled: idx >= gesamt - 1, onClick: () => geh(1),
|
||||||
|
style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-subdued);border-radius:8px;padding:8px 12px;font-size:16px;' + (idx >= gesamt - 1 ? 'opacity:.4;cursor:not-allowed;' : '') }, '▶'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const kopf = h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:14px;' }, [
|
||||||
|
h('div', {}, [
|
||||||
|
h('div', { style: 'font-size:22px;font-weight:700;' }, anzeigeName(k)),
|
||||||
|
adresse(k) ? subdued(adresse(k), 'margin-top:2px;') : null,
|
||||||
|
k.source_order?.order_number ? subdued('aus Auftrag ' + k.source_order.order_number, 'font-size:12px;margin-top:2px;') : null,
|
||||||
|
]),
|
||||||
|
chip(LEAD_LABEL[k.lead_status || 'neu'], LEAD_COLOR[k.lead_status || 'neu']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Anruf-Button (GroundWire) – nur wenn eine Nummer da ist.
|
||||||
|
const anruf = k.phone
|
||||||
|
? h('a', { href: DIAL(k.phone),
|
||||||
|
style: `display:flex;align-items:center;justify-content:center;gap:8px;text-decoration:none;background:#2e7d32;color:#fff;border-radius:10px;padding:14px;font-size:17px;font-weight:600;margin-bottom:14px;` },
|
||||||
|
[h('span', {}, '📞'), h('span', {}, 'Anrufen · ' + k.phone)])
|
||||||
|
: subdued('Keine Telefonnummer – unten eintragen, dann erscheint der Anruf-Button.', 'margin-bottom:14px;');
|
||||||
|
|
||||||
|
const telFeld = feld(h('span', {}, ['Telefon', flash('phone')]),
|
||||||
|
h('input', { type: 'tel', value: k.phone || '', placeholder: '+49 …', style: S_INPUT, onBlur: saveIfChanged(k, 'phone') }));
|
||||||
|
const mailFeld = feld(h('span', {}, ['E-Mail', flash('email')]),
|
||||||
|
h('div', { style: 'display:flex;gap:8px;align-items:center;' }, [
|
||||||
|
h('input', { type: 'email', value: k.email || '', placeholder: 'name@…', style: S_INPUT, onBlur: saveIfChanged(k, 'email') }),
|
||||||
|
k.email ? h('a', { href: 'mailto:' + k.email, style: 'text-decoration:none;font-size:20px;' }, '✉') : null,
|
||||||
|
]));
|
||||||
|
|
||||||
|
const statusFeld = feld('Lead-Status',
|
||||||
|
h('select', { style: S_INPUT + 'cursor:pointer;', onChange: (e) => save(k.id, { lead_status: e.target.value }, 'lead_status') },
|
||||||
|
LEAD.map((l) => h('option', { value: l.key, selected: (k.lead_status || 'neu') === l.key }, l.label))));
|
||||||
|
|
||||||
|
const notizFeld = feld(h('span', {}, ['Notizen', flash('notes')]),
|
||||||
|
h('textarea', { value: k.notes || '', rows: 5, placeholder: 'Gesprächsnotizen …',
|
||||||
|
style: S_INPUT + 'resize:vertical;line-height:1.5;', onBlur: saveIfChanged(k, 'notes') }));
|
||||||
|
|
||||||
|
const namensFelder = h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px;' }, [
|
||||||
|
feld('Vorname', h('input', { value: k.first_name || '', style: S_INPUT, onBlur: saveIfChanged(k, 'first_name') })),
|
||||||
|
feld('Nachname', h('input', { value: k.last_name || '', style: S_INPUT, onBlur: saveIfChanged(k, 'last_name') })),
|
||||||
|
feld('Firma', h('input', { value: k.company || '', style: S_INPUT, onBlur: saveIfChanged(k, 'company') })),
|
||||||
|
feld('Ort', h('input', { value: k.city || '', style: S_INPUT, onBlur: saveIfChanged(k, 'city') })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Standardaktionen (deutlich als farbige Buttons) ──
|
||||||
|
const aktBase = 'cursor:pointer;border-radius:8px;padding:11px 15px;font:inherit;font-size:14px;font-weight:600;'
|
||||||
|
+ 'border:1.5px solid;text-decoration:none;display:inline-flex;align-items:center;gap:6px;';
|
||||||
|
const aktStil = (rand, grund, text) => aktBase + `border-color:${rand};background:${grund};color:${text};`;
|
||||||
|
const S_RUECK = aktStil(ACCENT, 'rgba(30,80,165,.10)', ACCENT);
|
||||||
|
const S_ANGEBOT = aktStil('#e07b00', 'rgba(224,123,0,.12)', '#b35f00');
|
||||||
|
const S_MUSTER = aktStil('#6b3fa0', 'rgba(107,63,160,.12)', '#5a2f90');
|
||||||
|
const S_MAIL = aktStil('#0b7285', 'rgba(11,114,133,.12)', '#0b6273');
|
||||||
|
const aktionsLeiste = h('div', { class: 'sk-akt' }, [
|
||||||
|
h('button', { type: 'button', style: rueckruf.open ? aktStil(ACCENT, ACCENT, '#fff') : S_RUECK, onClick: () => { rueckruf.open = !rueckruf.open; if (rueckruf.open && !rueckruf.date) rueckruf.date = ymd(isoDatum(1)); } }, '📞 Rückruf planen'),
|
||||||
|
h('button', { type: 'button', style: S_ANGEBOT, onClick: () => sendeAngebot(k) }, '📧 Angebot senden'),
|
||||||
|
h('button', { type: 'button', style: S_MUSTER, onClick: () => sendeMuster(k) }, '🧪 Muster senden'),
|
||||||
|
h('button', { type: 'button', style: mailMich.open ? aktStil('#0b7285', '#0b7285', '#fff') : S_MAIL, onClick: () => { mailMich.open = !mailMich.open; } }, '✉️ Mail an mich'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const preset = (label, d) => h('button', { type: 'button',
|
||||||
|
style: 'cursor:pointer;border-radius:20px;padding:6px 12px;font:inherit;font-size:13px;border:1px solid var(--border-normal);'
|
||||||
|
+ (rueckruf.date === ymd(isoDatum(d)) ? `background:${ACCENT};color:#fff;` : 'background:var(--background-page);color:var(--foreground-normal);'),
|
||||||
|
onClick: () => { rueckruf.date = ymd(isoDatum(d)); } }, label);
|
||||||
|
const rueckrufForm = rueckruf.open ? h('div', { style: 'border:1px solid var(--border-normal);border-radius:10px;padding:14px;margin-top:10px;margin-bottom:12px;background:var(--background-subdued);' }, [
|
||||||
|
h('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px;' }, [preset('Morgen', 1), preset('In 3 Tagen', 3), preset('Nächste Woche', 7)]),
|
||||||
|
h('div', { style: 'display:flex;gap:10px;flex-wrap:wrap;' }, [
|
||||||
|
h('div', { style: 'flex:2;min-width:150px;' }, feld('Datum', h('input', { type: 'date', value: rueckruf.date, style: S_INPUT, onInput: (e) => { rueckruf.date = e.target.value; } }))),
|
||||||
|
h('div', { style: 'flex:1;min-width:110px;' }, feld('Uhrzeit', h('input', { type: 'time', value: rueckruf.time, style: S_INPUT, onInput: (e) => { rueckruf.time = e.target.value; } }))),
|
||||||
|
]),
|
||||||
|
h('div', { style: 'display:flex;gap:8px;' }, [btn('Erinnerung setzen', () => planeRueckruf(k), 'primary'), btn('Abbrechen', () => { rueckruf.open = false; }, 'ghost')]),
|
||||||
|
]) : null;
|
||||||
|
|
||||||
|
const mailForm = mailMich.open ? h('div', { style: 'border:1px solid var(--border-normal);border-radius:10px;padding:14px;margin-top:10px;margin-bottom:12px;background:var(--background-subdued);' }, [
|
||||||
|
feld('Notiz an mich', h('textarea', { value: mailMich.note, rows: 3, placeholder: 'Was soll dir per Mail zugehen? (Eckdaten kommen automatisch mit)', style: S_INPUT + 'resize:vertical;', onInput: (e) => { mailMich.note = e.target.value; } })),
|
||||||
|
h('div', { style: 'display:flex;gap:8px;' }, [btn('Mail an mich senden', () => sendeMail(k), 'primary'), btn('Abbrechen', () => { mailMich.open = false; }, 'ghost')]),
|
||||||
|
]) : null;
|
||||||
|
|
||||||
|
const offene = aktionen.value.filter((a) => a.type === 'rueckruf' && a.status === 'offen');
|
||||||
|
const offeneListe = offene.length ? h('div', { style: 'margin-bottom:12px;display:flex;flex-direction:column;gap:6px;' }, offene.map((a) => h('div', {
|
||||||
|
style: 'display:flex;align-items:center;justify-content:space-between;gap:10px;border:1px solid var(--border-normal);border-radius:8px;padding:8px 12px;background:var(--background-page);',
|
||||||
|
}, [
|
||||||
|
h('div', { style: 'font-size:13px;' }, [
|
||||||
|
h('span', { style: 'font-weight:600;' }, '📞 Rückruf ' + (a.due_date ? new Date(a.due_date).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '')),
|
||||||
|
a.note ? h('div', { style: 'color:var(--foreground-subdued);margin-top:2px;white-space:pre-wrap;' }, a.note) : null,
|
||||||
|
]),
|
||||||
|
btn('✓ erledigt', () => erledige(a, k), 'default'),
|
||||||
|
]))) : null;
|
||||||
|
|
||||||
|
const aktionenBlock = h('div', { style: 'margin-bottom:14px;' }, [
|
||||||
|
h('div', { style: LBL }, 'Aktionen'), aktionsLeiste, rueckrufForm, mailForm, offeneListe,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [navRow, h('div', { style: CARD }, [kopf, anruf, notizFeld, statusFeld, aktionenBlock, telFeld, mailFeld, namensFelder])];
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const inhalt = h('div', { style: 'max-width:760px;margin:0 auto;padding:20px 16px 60px;' }, [
|
||||||
|
// Aktions-Buttons: am Desktop nebeneinander (gleich breit), am Handy gestapelt in voller Breite.
|
||||||
|
h('style', {}, '.sk-akt{display:flex;gap:8px;flex-wrap:wrap;}.sk-akt>*{flex:1 1 auto;justify-content:center;}@media(max-width:600px){.sk-akt{flex-direction:column;}.sk-akt>*{width:100%;flex:none;}}'),
|
||||||
|
fehler.value ? h('div', { style: 'background:var(--danger-25,rgba(198,40,40,.12));color:var(--danger,#c62828);padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:14px;' }, fehler.value) : null,
|
||||||
|
meldung.value ? h('div', { style: 'background:var(--success-25,rgba(46,125,50,.15));color:var(--success,#2e7d32);padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:14px;' }, meldung.value) : null,
|
||||||
|
aktivId.value ? detailAnsicht() : listenAnsicht(),
|
||||||
|
]);
|
||||||
|
return h(resolveComponent('private-view'), { title: 'Kontakte' }, {
|
||||||
|
default: () => h('div', { style: 'min-height:100%;background:var(--background-subdued,#f1f4f9);' }, [inhalt]),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: 'skrift-kontakte',
|
||||||
|
name: 'Kontakte',
|
||||||
|
icon: 'contacts',
|
||||||
|
routes: [{ path: '', component: Board }],
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "directus-extension-skrift-kontakte",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Modul: Kontakte/Leads – handytaugliches Mini-CRM (Anrufen via GroundWire, Notizen, Durchblättern, CSV-Export).",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "module",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "dist/index.js",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,11 +23,59 @@ const ENVELOPE_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
|
|||||||
const LABELING_LABEL = { empfaenger: 'Empfängeradresse', freitext: 'Individueller Text', keine: 'Keine' };
|
const LABELING_LABEL = { empfaenger: 'Empfängeradresse', freitext: 'Individueller Text', keine: 'Keine' };
|
||||||
const UPLOAD_ROOT = process.env.STORAGE_LOCAL_ROOT || '/directus/uploads';
|
const UPLOAD_ROOT = process.env.STORAGE_LOCAL_ROOT || '/directus/uploads';
|
||||||
|
|
||||||
|
// Eingebaute Fallback-Vorlagen: greifen NUR, wenn in `email_templates` keine
|
||||||
|
// aktive Vorlage mit passendem Schlüssel existiert. So gehen Bestätigung und
|
||||||
|
// die kundenrelevanten Status-Mails auch ohne konfigurierte Vorlage sicher raus
|
||||||
|
// (analog zum Login-Fallback in skrift-auth). Interne Status (in_queue, gedruckt …)
|
||||||
|
// haben bewusst KEINEN Fallback → für die braucht es eine aktive Vorlage.
|
||||||
|
const FALLBACK_TEMPLATES = {
|
||||||
|
order_created: {
|
||||||
|
subject: 'Bestellbestätigung {{order_id}}',
|
||||||
|
body: `<p>Hallo {{name}},</p>
|
||||||
|
<p>vielen Dank für Ihre Bestellung <strong>{{order_id}}</strong>. Wir haben Ihren Auftrag erhalten und melden uns mit den nächsten Schritten.</p>`,
|
||||||
|
},
|
||||||
|
bezahlt: {
|
||||||
|
subject: 'Zahlung erhalten – Bestellung {{order_id}}',
|
||||||
|
body: `<p>Hallo {{name}},</p>
|
||||||
|
<p>Ihre Zahlung zur Bestellung <strong>{{order_id}}</strong> ist bei uns eingegangen. Wir starten nun mit der Bearbeitung.</p>`,
|
||||||
|
},
|
||||||
|
versendet: {
|
||||||
|
subject: 'Ihre Bestellung {{order_id}} ist unterwegs',
|
||||||
|
body: `<p>Hallo {{name}},</p>
|
||||||
|
<p>Ihre Bestellung <strong>{{order_id}}</strong> wurde versendet.</p>`,
|
||||||
|
},
|
||||||
|
abgeschlossen: {
|
||||||
|
subject: 'Bestellung {{order_id}} abgeschlossen',
|
||||||
|
body: `<p>Hallo {{name}},</p>
|
||||||
|
<p>Ihre Bestellung <strong>{{order_id}}</strong> ist abgeschlossen. Vielen Dank für Ihr Vertrauen!</p>`,
|
||||||
|
},
|
||||||
|
storniert: {
|
||||||
|
subject: 'Bestellung {{order_id}} storniert',
|
||||||
|
body: `<p>Hallo {{name}},</p>
|
||||||
|
<p>Ihre Bestellung <strong>{{order_id}}</strong> wurde storniert. Bei Fragen antworten Sie einfach auf diese E-Mail.</p>`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const render = (str, vars) =>
|
const render = (str, vars) =>
|
||||||
String(str || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : ''));
|
String(str || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : ''));
|
||||||
const eur = (n) => (Number(n) || 0).toFixed(2).replace('.', ',');
|
const eur = (n) => (Number(n) || 0).toFixed(2).replace('.', ',');
|
||||||
const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
const esc = (s) => String(s == null ? '' : s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c]));
|
||||||
|
|
||||||
|
// Antwortadresse für Kundenmails – echte, überwachte Adresse statt „noreply".
|
||||||
|
const REPLY_TO = 'hello@skrift.de';
|
||||||
|
|
||||||
|
/** Grobe Text-Alternative aus dem HTML. Reine HTML-Mails ohne Text-Teil kosten
|
||||||
|
* bei Spamfiltern (SpamAssassin) sichere Punkte – der Text-Part senkt die Wertung. */
|
||||||
|
const htmlZuText = (html) => String(html || '')
|
||||||
|
.replace(/<\s*br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<\/(p|div|tr|h[1-6]|li|table)>/gi, '\n')
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/ /gi, ' ')
|
||||||
|
.replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>')
|
||||||
|
.replace(/[ \t]+\n/g, '\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
|
||||||
/** Formatiert einen Adress-Snapshot (billing/shipping) als HTML. */
|
/** Formatiert einen Adress-Snapshot (billing/shipping) als HTML. */
|
||||||
function adresseHtml(a) {
|
function adresseHtml(a) {
|
||||||
if (!a || typeof a !== 'object') return '';
|
if (!a || typeof a !== 'object') return '';
|
||||||
@@ -61,6 +109,45 @@ export default (register, { services, getSchema, logger }) => {
|
|||||||
} catch { return {}; }
|
} catch { return {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Konfigurierter Team-Empfänger (separat gelesen, damit ein evtl. noch nicht
|
||||||
|
* gebootstraptes Feld die Kundenmails nicht beeinträchtigt). */
|
||||||
|
async function teamEmpfaenger(schema) {
|
||||||
|
try {
|
||||||
|
const es = await new ItemsService('email_settings', { schema, accountability: null })
|
||||||
|
.readSingleton({ fields: ['team_email'] });
|
||||||
|
return (es?.team_email || '').trim();
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schlanke interne Benachrichtigung „Neue Bestellung …" (Empfänger konfigurierbar). */
|
||||||
|
async function teamNotify(orderId) {
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const to = await teamEmpfaenger(schema);
|
||||||
|
if (!to) return;
|
||||||
|
const settings = await loadSettings(schema);
|
||||||
|
const order = await new ItemsService('orders', { schema, accountability: null })
|
||||||
|
.readOne(orderId, { fields: ['order_number', 'product.name', 'source'] });
|
||||||
|
if (!order) return;
|
||||||
|
// API-Aufträge NICHT einzeln melden – dafür gibt es die Tagesübersicht (18:00).
|
||||||
|
if (order.source === 'api') return;
|
||||||
|
const html = `<p>Neue Bestellung <strong>${order.order_number}</strong>${order.product?.name ? ` (${order.product.name})` : ''} – im Produktions-Board unter „Neu".</p>`;
|
||||||
|
const nachricht = {
|
||||||
|
to,
|
||||||
|
subject: `Neue Bestellung ${order.order_number}`,
|
||||||
|
html,
|
||||||
|
text: htmlZuText(html),
|
||||||
|
replyTo: REPLY_TO,
|
||||||
|
};
|
||||||
|
if (settings.sender_email) {
|
||||||
|
nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
|
||||||
|
}
|
||||||
|
await new MailService({ schema, accountability: null }).send(nachricht);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`[skrift-mailer] Team-Benachrichtigung: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** AGB-PDF (aus email_settings.agb_file) als Nodemailer-Anhang, direkt von Platte. */
|
/** AGB-PDF (aus email_settings.agb_file) als Nodemailer-Anhang, direkt von Platte. */
|
||||||
async function agbAnhang(schema, settings) {
|
async function agbAnhang(schema, settings) {
|
||||||
if (!settings?.agb_file) return null;
|
if (!settings?.agb_file) return null;
|
||||||
@@ -78,8 +165,8 @@ export default (register, { services, getSchema, logger }) => {
|
|||||||
|
|
||||||
async function sendForOrder(orderId, key, extraVars = {}) {
|
async function sendForOrder(orderId, key, extraVars = {}) {
|
||||||
const schema = await getSchema();
|
const schema = await getSchema();
|
||||||
const tpl = await loadTemplate(key, schema);
|
const tpl = await loadTemplate(key, schema) || FALLBACK_TEMPLATES[key];
|
||||||
if (!tpl) return; // keine (aktive) Vorlage → keine Mail
|
if (!tpl) return; // weder aktive Vorlage noch Fallback → keine Mail
|
||||||
|
|
||||||
const order = await new ItemsService('orders', { schema, accountability: null }).readOne(orderId, {
|
const order = await new ItemsService('orders', { schema, accountability: null }).readOne(orderId, {
|
||||||
fields: [
|
fields: [
|
||||||
@@ -87,10 +174,13 @@ export default (register, { services, getSchema, logger }) => {
|
|||||||
'needs_envelope', 'envelope_labeling', 'envelope_format',
|
'needs_envelope', 'envelope_labeling', 'envelope_format',
|
||||||
'net_total', 'vat_amount', 'gross_total', 'billing', 'shipping_address',
|
'net_total', 'vat_amount', 'gross_total', 'billing', 'shipping_address',
|
||||||
'customer.first_name', 'customer.last_name', 'customer.company',
|
'customer.first_name', 'customer.last_name', 'customer.company',
|
||||||
'product.name', 'format.name',
|
'product.name', 'product.input_mode', 'format.name',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (!order?.customer_email) return;
|
if (!order?.customer_email) return;
|
||||||
|
// Datei-Produkte (Unterschriftenservice) erhalten KEINE Standard-Bestell-
|
||||||
|
// bestätigung – dafür geht eine eigene Vorlage aus dem Bestell-Endpunkt raus.
|
||||||
|
if (key === 'order_created' && order.product?.input_mode === 'datei') return;
|
||||||
|
|
||||||
const settings = await loadSettings(schema);
|
const settings = await loadSettings(schema);
|
||||||
const name = [order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(' ')
|
const name = [order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(' ')
|
||||||
@@ -128,7 +218,7 @@ export default (register, { services, getSchema, logger }) => {
|
|||||||
let html = render(tpl.body, vars);
|
let html = render(tpl.body, vars);
|
||||||
if (tpl.append_signature !== false && settings.signature) html += render(settings.signature, vars);
|
if (tpl.append_signature !== false && settings.signature) html += render(settings.signature, vars);
|
||||||
|
|
||||||
const nachricht = { to: order.customer_email, subject: render(tpl.subject, vars), html };
|
const nachricht = { to: order.customer_email, subject: render(tpl.subject, vars), html, text: htmlZuText(html), replyTo: REPLY_TO };
|
||||||
// Absender aus den Einstellungen (z. B. „Skrift <noreply@skrift.de>").
|
// Absender aus den Einstellungen (z. B. „Skrift <noreply@skrift.de>").
|
||||||
if (settings.sender_email) {
|
if (settings.sender_email) {
|
||||||
nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
|
nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
|
||||||
@@ -145,6 +235,8 @@ export default (register, { services, getSchema, logger }) => {
|
|||||||
if (payload?.status === 'entwurf') return;
|
if (payload?.status === 'entwurf') return;
|
||||||
try { await sendForOrder(key, 'order_created'); }
|
try { await sendForOrder(key, 'order_created'); }
|
||||||
catch (e) { logger.warn(`[skrift-mailer] order_created: ${e.message}`); }
|
catch (e) { logger.warn(`[skrift-mailer] order_created: ${e.message}`); }
|
||||||
|
// Schlanke interne Benachrichtigung „Neue Bestellung …" an das Team.
|
||||||
|
await teamNotify(key);
|
||||||
});
|
});
|
||||||
|
|
||||||
register.action('orders.items.update', async ({ keys, payload }) => {
|
register.action('orders.items.update', async ({ keys, payload }) => {
|
||||||
@@ -155,4 +247,45 @@ export default (register, { services, getSchema, logger }) => {
|
|||||||
catch (e) { logger.warn(`[skrift-mailer] status ${status}: ${e.message}`); }
|
catch (e) { logger.warn(`[skrift-mailer] status ${status}: ${e.message}`); }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Tagesübersicht der API-Aufträge (statt Einzelmail je Auftrag) ───────────
|
||||||
|
// Einmal täglich (Default 18:00): pro API-Kunde eine Zusammenfassung der heute
|
||||||
|
// eingegangenen Aufträge, aufgeschlüsselt nach Versandtag.
|
||||||
|
const API_DIGEST_CRON = process.env.SKRIFT_API_DIGEST_CRON || '0 18 * * *';
|
||||||
|
register.schedule(API_DIGEST_CRON, async () => {
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const to = await teamEmpfaenger(schema);
|
||||||
|
if (!to) return;
|
||||||
|
const settings = await loadSettings(schema);
|
||||||
|
const start = new Date(); start.setHours(0, 0, 0, 0);
|
||||||
|
const orders = await new ItemsService('orders', { schema, accountability: null }).readByQuery({
|
||||||
|
filter: { _and: [{ source: { _eq: 'api' } }, { date_created: { _gte: start.toISOString() } }] },
|
||||||
|
fields: ['order_number', 'shipping_day', 'api_client.id', 'api_client.name', 'product.name'],
|
||||||
|
limit: -1, sort: ['order_number'],
|
||||||
|
});
|
||||||
|
if (!orders || !orders.length) return;
|
||||||
|
|
||||||
|
const byClient = new Map();
|
||||||
|
for (const o of orders) {
|
||||||
|
const kid = o.api_client?.id ?? 'x';
|
||||||
|
let g = byClient.get(kid);
|
||||||
|
if (!g) { g = { name: o.api_client?.name || 'API-Kunde', orders: [] }; byClient.set(kid, g); }
|
||||||
|
g.orders.push(o);
|
||||||
|
}
|
||||||
|
for (const g of byClient.values()) {
|
||||||
|
const byDay = {};
|
||||||
|
for (const o of g.orders) { const d = o.shipping_day || 'ohne Versandtag'; byDay[d] = (byDay[d] || 0) + 1; }
|
||||||
|
const zeilen = Object.entries(byDay).map(([d, n]) => `<li>${esc(d)}: <strong>${n}</strong></li>`).join('');
|
||||||
|
const html = `<p>Heute über die API eingegangen von <strong>${esc(g.name)}</strong>: <strong>${g.orders.length}</strong> Auftrag/Aufträge.</p>`
|
||||||
|
+ `<p>Nach Versandtag:</p><ul>${zeilen}</ul>`
|
||||||
|
+ '<p>Im Produktions-Board unter „Neu" als Charge(n) gruppiert.</p>';
|
||||||
|
const nachricht = { to, subject: `API-Tagesübersicht ${g.name} – ${g.orders.length} neue`, html, text: htmlZuText(html), replyTo: REPLY_TO };
|
||||||
|
if (settings.sender_email) nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email;
|
||||||
|
try { await new MailService({ schema, accountability: null }).send(nachricht); }
|
||||||
|
catch (e) { logger.warn(`[skrift-mailer] API-Tagesmail ${g.name}: ${e.message}`); }
|
||||||
|
}
|
||||||
|
logger.info(`[skrift-mailer] API-Tagesübersicht verschickt: ${byClient.size} Kunde(n)`);
|
||||||
|
} catch (e) { logger.warn(`[skrift-mailer] API-Tageslauf: ${e.message}`); }
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
684
Docker/directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js
vendored
Normal file
684
Docker/directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js
vendored
Normal file
@@ -0,0 +1,684 @@
|
|||||||
|
/**
|
||||||
|
* Skrift – Modul „Neuer Auftrag"
|
||||||
|
* ===========================================================================
|
||||||
|
* Eigenständige, manuelle Auftragsanlage im Admin (eigenes Modul, kein Teil des
|
||||||
|
* Produktions-Boards). Nutzt ausschließlich die zentralen Backend-Endpunkte:
|
||||||
|
* POST /skrift-orders/preview – Vorschau (Admin ohne Kontingent)
|
||||||
|
* POST /skrift-orders/manual-order – Auftrag intern anlegen (keine Zahlung)
|
||||||
|
* Der Auftrag erscheint danach als „eingegangen" im Produktions-Board.
|
||||||
|
*
|
||||||
|
* Von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
|
||||||
|
*/
|
||||||
|
import { defineComponent, ref, reactive, onMounted, h, resolveComponent, Teleport } from 'vue';
|
||||||
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
|
|
||||||
|
const ACCENT = '#1E50A5';
|
||||||
|
// Weißes, randloses Eingabefeld – hebt sich durch Kontrast + leichten Schatten
|
||||||
|
// vom hellblauen Bereich ab (kein Rahmen).
|
||||||
|
const WFELD = 'background:#fff;border:none;border-radius:8px;padding:9px 12px;font:inherit;font-size:14px;color:#1A1A1A;outline:none;box-shadow:0 1px 2px rgba(0,0,0,.08);';
|
||||||
|
const WSELECT = WFELD + 'cursor:pointer;';
|
||||||
|
// Hellblauer Bereich (kein Rahmen).
|
||||||
|
const BEREICH = 'background:rgba(30,80,165,.06);border-radius:12px;padding:18px 20px;';
|
||||||
|
const STANDARD_PLATZHALTER = ['Anrede', 'Vorname', 'Nachname', 'Strasse', 'PLZ', 'Ort', 'Land', 'Briefnummer'];
|
||||||
|
|
||||||
|
function btn(label, onClick, kind) {
|
||||||
|
const base = 'cursor:pointer;border-radius:8px;padding:8px 16px;font:inherit;font-size:14px;border:none;';
|
||||||
|
const style = kind === 'primary' ? base + `background:${ACCENT};color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.15);`
|
||||||
|
: base + `background:#fff;color:${ACCENT};box-shadow:0 1px 2px rgba(0,0,0,.1);`;
|
||||||
|
return h('button', { type: 'button', onClick, style }, label);
|
||||||
|
}
|
||||||
|
function subdued(t, extra) { return h('div', { style: `color:var(--foreground-subdued);font-size:13px;${extra || ''}` }, t); }
|
||||||
|
function segToggle(value, opts, onSel) {
|
||||||
|
return h('div', { style: 'display:flex;width:100%;border-radius:8px;overflow:hidden;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.08);' },
|
||||||
|
opts.map((o) => h('button', {
|
||||||
|
type: 'button', onClick: () => onSel(o.key),
|
||||||
|
style: `flex:1;padding:10px 8px;font:inherit;font-size:13px;text-align:center;cursor:pointer;border:none;`
|
||||||
|
+ (value === o.key ? `background:${ACCENT};color:#fff;font-weight:700;` : `background:transparent;color:${ACCENT};`),
|
||||||
|
}, o.label)));
|
||||||
|
}
|
||||||
|
function bereichKopf(txt) { return h('div', { style: `font-size:15px;font-weight:800;color:${ACCENT};margin-bottom:14px;` }, txt); }
|
||||||
|
function label(txt) { return h('label', { style: 'display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);margin-bottom:4px;' }, txt); }
|
||||||
|
|
||||||
|
const NeuAuftrag = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const api = useApi();
|
||||||
|
const produkte = ref([]);
|
||||||
|
const formate = ref([]);
|
||||||
|
const verbotenRe = ref(null);
|
||||||
|
const musterTexte = ref({ text_du: '', text_sie: '' });
|
||||||
|
// Frei anlegbare Mustertexte (Collection muster_texts). Fallback: Du/Sie aus muster_settings.
|
||||||
|
const musterListe = ref([]);
|
||||||
|
// Vorhandene Kundensignaturen (für „Unterschrift auf den Brief setzen").
|
||||||
|
const signaturListe = ref([]);
|
||||||
|
const geladen = ref(false);
|
||||||
|
// Aktives Unterwerkzeug: 'neu' (manueller Druck) oder 'signatur' (Unterschriftenservice).
|
||||||
|
const ansicht = ref('neu');
|
||||||
|
// Unterschriftenservice: Auftrag laden → Dokument + Kundensignaturen wählen →
|
||||||
|
// Vorschau (Marker + Offsets) → A4-SVGs herunterladen.
|
||||||
|
const sig = reactive({
|
||||||
|
modus: 'auftrag', // 'auftrag' (aus Auftrag laden) oder 'manuell' (direkt hochladen)
|
||||||
|
orderNr: '', order: null, docs: [], docId: null, docName: '', signaturen: [],
|
||||||
|
laden: false, pdf: '', msg: '', err: '',
|
||||||
|
});
|
||||||
|
// Datei nach Directus hochladen (liefert die Datei-ID).
|
||||||
|
async function sigUpload(file) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const res = await api.post('/files', fd);
|
||||||
|
return res.data?.data?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leereZeile = () => ({ free_text: '', placeholders: {} });
|
||||||
|
const neu = reactive({
|
||||||
|
product: '', format: '', font: 'tilda',
|
||||||
|
// Relative Schriftgröße in % (getrennt Brief/Kuvert); 100 = Standard.
|
||||||
|
fontSizeLetter: 100, fontSizeEnvelope: 100,
|
||||||
|
// Unterschrift auf dem Brief ([[signatur1]]): SVG-Inhalt + Feinjustierung.
|
||||||
|
sigSvg: '', sigLabel: '', sigX: 0, sigY: 0, sigScale: 100,
|
||||||
|
// Mehrseitiges Schriftstück (Auto-Fluss über mehrere A4-Seiten).
|
||||||
|
mehrseitig: false, seitenInfo: '',
|
||||||
|
// Kuvert-Adresse je Empfänger: '5zeilen' (5 Zeilen) oder 'individuell' (einzeilig).
|
||||||
|
adressModus: '5zeilen', kuverts: false, realistic: true, customer_email: '', text: '', rows: [leereZeile()],
|
||||||
|
importOpen: false, importText: '',
|
||||||
|
docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '',
|
||||||
|
});
|
||||||
|
// Kuverts nur generieren, wenn angehakt (Beschriftung aus der Empfängeradresse).
|
||||||
|
const kuvertPayload = () => (neu.kuverts
|
||||||
|
? { needs_envelope: true, envelope_labeling: 'empfaenger', envelope_text: '' }
|
||||||
|
: { needs_envelope: false, envelope_labeling: 'keine', envelope_text: '' });
|
||||||
|
|
||||||
|
async function laden() {
|
||||||
|
try {
|
||||||
|
const [p, f, ps, mt, mtx, sg] = await Promise.all([
|
||||||
|
api.get('/items/products', { params: { limit: -1, filter: { active: { _eq: true }, type: { _in: ['letter', 'postcard'] } }, fields: 'key,name,type' } }),
|
||||||
|
api.get('/items/formats', { params: { limit: -1, filter: { active: { _eq: true } }, fields: 'key,name,product.key' } }),
|
||||||
|
api.get('/items/pricing_settings', { params: { fields: 'char_whitelist' } }).catch(() => ({ data: { data: {} } })),
|
||||||
|
api.get('/items/muster_settings', { params: { fields: 'text_du,text_sie' } }).catch(() => ({ data: { data: {} } })),
|
||||||
|
api.get('/items/muster_texts', { params: { limit: -1, filter: { active: { _eq: true } }, sort: 'sort', fields: 'id,label,text' } }).catch(() => ({ data: { data: [] } })),
|
||||||
|
api.get('/items/signatures', { params: { limit: -1, sort: '-date_created', fields: 'id,label,file,customer.first_name,customer.last_name' } }).catch(() => ({ data: { data: [] } })),
|
||||||
|
]);
|
||||||
|
produkte.value = p.data?.data || [];
|
||||||
|
formate.value = f.data?.data || [];
|
||||||
|
musterTexte.value = mt.data?.data || { text_du: '', text_sie: '' };
|
||||||
|
musterListe.value = mtx.data?.data || [];
|
||||||
|
signaturListe.value = sg.data?.data || [];
|
||||||
|
const wl = ps.data?.data?.char_whitelist;
|
||||||
|
// Zeilenumbrüche immer erlauben (Brieftext ist mehrzeilig). Einzeilige
|
||||||
|
// Felder entfernen \n/\r ohnehin separat, daher hier gefahrlos. Tabs (\t)
|
||||||
|
// sind erlaubt, damit man z.B. das Datum an den rechten Rand rücken kann.
|
||||||
|
try { verbotenRe.value = wl ? new RegExp(`[^${wl}\\n\\r\\t]`, 'g') : null; } catch { verbotenRe.value = null; }
|
||||||
|
neu.product = produkte.value[0]?.key || 'briefe';
|
||||||
|
neu.format = neuFormate()[0]?.key || '';
|
||||||
|
} catch (e) {
|
||||||
|
neu.err = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.';
|
||||||
|
} finally { geladen.value = true; }
|
||||||
|
}
|
||||||
|
onMounted(laden);
|
||||||
|
|
||||||
|
const saeubere = (v) => {
|
||||||
|
if (!verbotenRe.value) return v;
|
||||||
|
const treffer = String(v).match(verbotenRe.value);
|
||||||
|
if (!treffer || !treffer.length) return v;
|
||||||
|
const einmalig = [...new Set(treffer)].map((c) => (c === ' ' ? '␣' : c)).join(' ');
|
||||||
|
neu.hinweis = `Zeichen ${einmalig} ist nicht erlaubt und wurde entfernt.`;
|
||||||
|
setTimeout(() => { if (neu.hinweis.includes(einmalig)) neu.hinweis = ''; }, 4000);
|
||||||
|
return String(v).replace(verbotenRe.value, '');
|
||||||
|
};
|
||||||
|
// Alle aktiven Formate zur Auswahl (nicht aufs Produkt eingeschränkt) –
|
||||||
|
// so lassen sich auch freie/eigene Formate direkt wählen.
|
||||||
|
const neuFormate = () => formate.value;
|
||||||
|
// Wie viele Adresszeilen je Empfänger: 5 („5 Zeilen") bzw. 1 („Individueller Text").
|
||||||
|
const zeilenAnzahl = () => (neu.adressModus === 'individuell' ? 1 : 5);
|
||||||
|
const freitext = (e, z) => (String(e.free_text || '').split('\n')[z] ?? '');
|
||||||
|
const setFreitext = (i, z, v) => {
|
||||||
|
const max = zeilenAnzahl();
|
||||||
|
const zeilen = String(neu.rows[i].free_text || '').split('\n');
|
||||||
|
while (zeilen.length < max) zeilen.push('');
|
||||||
|
zeilen[z] = saeubere(String(v).replace(/[\r\n]/g, ''));
|
||||||
|
neu.rows[i].free_text = zeilen.slice(0, max).join('\n').replace(/\n+$/, '');
|
||||||
|
neu.previewSession = null;
|
||||||
|
};
|
||||||
|
// Individuelle Platzhalter je Empfänger (z. B. [[Gutschein]]).
|
||||||
|
const setPlatzhalter = (i, name, v) => {
|
||||||
|
neu.rows[i].placeholders = { ...(neu.rows[i].placeholders || {}), [name]: saeubere(v) };
|
||||||
|
neu.previewSession = null;
|
||||||
|
};
|
||||||
|
// Spalten-Einfügen: eine ganze Excel-Spalte (Strg+V) in EINE Zelle einfügen.
|
||||||
|
// Zeilenumbrüche trennen die Empfänger; Kommata bleiben Teil des Werts (kein
|
||||||
|
// Trennzeichen). Legt automatisch so viele Empfänger an wie eingefügte Zeilen
|
||||||
|
// und füllt die Spalte ab der aktuellen Zeile nach unten.
|
||||||
|
// Rückgabe true = mehrzeilig eingefügt (Default-Paste unterdrücken).
|
||||||
|
const spalteEinfuegen = (startI, apply, text) => {
|
||||||
|
const werte = String(text || '').replace(/\r\n?/g, '\n').split('\n');
|
||||||
|
while (werte.length > 1 && werte[werte.length - 1].trim() === '') werte.pop();
|
||||||
|
if (werte.length <= 1) return false; // Einzelwert → normales Einfügen zulassen
|
||||||
|
while (neu.rows.length < startI + werte.length) neu.rows.push(leereZeile());
|
||||||
|
werte.forEach((w, k) => apply(startI + k, w));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
// Platzhalter aus dem Brieftext ziehen (ohne den automatischen [[Briefnummer]]).
|
||||||
|
const customPlaceholders = () => {
|
||||||
|
const set = new Set();
|
||||||
|
for (const m of String(neu.text || '').matchAll(/\[\[([^\]]+)\]\]/g)) {
|
||||||
|
const name = m[1].trim();
|
||||||
|
if (name && name.toLowerCase() !== 'briefnummer') set.add(name);
|
||||||
|
}
|
||||||
|
return [...set];
|
||||||
|
};
|
||||||
|
const addZeile = () => neu.rows.push(leereZeile());
|
||||||
|
const delZeile = (i) => { neu.rows.splice(i, 1); if (!neu.rows.length) neu.rows.push(leereZeile()); };
|
||||||
|
// Empfänger zählt, wenn eine Adresszeile ODER ein Platzhalterwert gefüllt ist.
|
||||||
|
const entries = () => neu.rows.filter((e) => String(e.free_text || '').trim()
|
||||||
|
|| (e.placeholders && Object.values(e.placeholders).some((v) => String(v || '').trim())));
|
||||||
|
|
||||||
|
// Aus Excel/CSV eingefügte Zeilen übernehmen. Erst die individuellen
|
||||||
|
// Platzhalter-Spalten, dann die Adresszeilen (Tab/Semikolon/Komma-getrennt).
|
||||||
|
function importUebernehmen() {
|
||||||
|
const ph = customPlaceholders();
|
||||||
|
const max = zeilenAnzahl();
|
||||||
|
const zeilen = String(neu.importText || '').split(/\r?\n/).map((z) => z.trim()).filter(Boolean);
|
||||||
|
if (!zeilen.length) { neu.importOpen = false; return; }
|
||||||
|
const rows = zeilen.map((z) => {
|
||||||
|
const zellen = z.split(/\t|;|,/).map((c) => saeubere(c.trim()));
|
||||||
|
const row = leereZeile();
|
||||||
|
ph.forEach((name, i) => { row.placeholders = { ...row.placeholders, [name]: zellen[i] || '' }; });
|
||||||
|
row.free_text = zellen.slice(ph.length, ph.length + max).join('\n');
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
neu.rows = rows;
|
||||||
|
neu.importText = ''; neu.importOpen = false; neu.previewSession = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unterschrift auf dem Brief ([[signatur1]]) ────────────────────────────
|
||||||
|
// Sorgt dafür, dass der Platzhalter im Text steht (sonst rendert keine Signatur).
|
||||||
|
function sigEinfuegen() {
|
||||||
|
if (!neu.text.includes('[[signatur1]]')) neu.text = `${neu.text.trimEnd()}\n[[signatur1]]`;
|
||||||
|
neu.previewSession = null;
|
||||||
|
}
|
||||||
|
async function sigWaehlen(id) {
|
||||||
|
neu.sigSvg = ''; neu.sigLabel = '';
|
||||||
|
if (!id) { neu.previewSession = null; return; }
|
||||||
|
const s = signaturListe.value.find((x) => String(x.id) === String(id));
|
||||||
|
if (!s?.file) { neu.err = 'Diese Signatur hat keine SVG-Datei.'; return; }
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/assets/${s.file}`, { responseType: 'text' });
|
||||||
|
neu.sigSvg = typeof res.data === 'string' ? res.data : String(res.data || '');
|
||||||
|
neu.sigLabel = s.label || 'Unterschrift';
|
||||||
|
sigEinfuegen();
|
||||||
|
} catch { neu.err = 'Signatur konnte nicht geladen werden.'; }
|
||||||
|
}
|
||||||
|
function sigDatei(e) {
|
||||||
|
const file = e.target.files && e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const r = new FileReader();
|
||||||
|
r.onload = () => { neu.sigSvg = String(r.result || ''); neu.sigLabel = file.name; sigEinfuegen(); };
|
||||||
|
r.readAsText(file);
|
||||||
|
}
|
||||||
|
function sigEntfernen() {
|
||||||
|
neu.sigSvg = ''; neu.sigLabel = '';
|
||||||
|
neu.text = neu.text.replace(/\n?\[\[signatur1\]\]/g, '');
|
||||||
|
neu.previewSession = null;
|
||||||
|
}
|
||||||
|
// Signatur-Anteil der Payloads (Vorschau + Anlage). „brief"-Präfix, weil es
|
||||||
|
// bereits ein sigPayload() für den Unterschriften-Service (PDF) gibt.
|
||||||
|
const briefSigPayload = () => (neu.sigSvg
|
||||||
|
? { signatures: { signatur1: neu.sigSvg },
|
||||||
|
signature_offsets: { signatur1: { x: Number(neu.sigX) || 0, y: Number(neu.sigY) || 0, scale: (Number(neu.sigScale) || 100) / 100 } } }
|
||||||
|
: {});
|
||||||
|
|
||||||
|
async function preview() {
|
||||||
|
// Vorschau ohne Empfänger erlaubt – das Backend nutzt dann eine Blankozeile.
|
||||||
|
if (!neu.text.trim()) { neu.err = 'Bitte zuerst einen Text eingeben.'; return; }
|
||||||
|
neu.pLaedt = true; neu.err = ''; neu.msg = '';
|
||||||
|
try {
|
||||||
|
const kv = kuvertPayload();
|
||||||
|
const res = await api.post('/skrift-orders/preview', {
|
||||||
|
text: neu.text, font: neu.font, format: neu.format, realistic: neu.realistic,
|
||||||
|
envelope_labeling: kv.envelope_labeling, envelope_text: kv.envelope_text,
|
||||||
|
font_scale_letter: neu.fontSizeLetter / 100, font_scale_envelope: neu.fontSizeEnvelope / 100,
|
||||||
|
multi_page: neu.mehrseitig,
|
||||||
|
...briefSigPayload(),
|
||||||
|
entries: entries().slice(0, 3),
|
||||||
|
});
|
||||||
|
const d = res.data || {};
|
||||||
|
if (d.has_overflow) {
|
||||||
|
// Zeigen, wie viele Zeilen zu lang (aus dem ersten überlaufenden Schriftstück).
|
||||||
|
const of = (d.overflow || [])[0];
|
||||||
|
const zuViel = of ? Math.max(1, (Number(of.lineCount) || 0) - (Number(of.lineLimit) || 0)) : 0;
|
||||||
|
neu.err = of
|
||||||
|
? `Text ist ${zuViel} Zeile${zuViel === 1 ? '' : 'n'} zu lang – ${of.lineCount} statt max. ${of.lineLimit} Zeilen. Bitte kürzen.`
|
||||||
|
: 'Text ist zu lang für das Format – bitte kürzen.';
|
||||||
|
neu.docs = []; neu.previewSession = null;
|
||||||
|
} else { neu.docs = d.docs || d.previews || []; neu.previewSession = d.session || d.sessionId || null; }
|
||||||
|
} catch (e) { neu.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Vorschau fehlgeschlagen.'; }
|
||||||
|
finally { neu.pLaedt = false; }
|
||||||
|
}
|
||||||
|
// Seiten je Schriftstück berechnen (mehrseitig) und nach Seitenzahl gruppieren:
|
||||||
|
// „2 Seiten: 1, 3, 4" · „3 Seiten: 2, 6, 7" …
|
||||||
|
async function seitenBerechnen() {
|
||||||
|
if (!neu.text.trim()) { neu.err = 'Bitte zuerst einen Text eingeben.'; return; }
|
||||||
|
neu.seitenInfo = 'Berechne …';
|
||||||
|
try {
|
||||||
|
const res = await api.post('/skrift-orders/pagecount', {
|
||||||
|
text: neu.text, format: neu.format,
|
||||||
|
font_scale_letter: neu.fontSizeLetter / 100,
|
||||||
|
entries: entries(),
|
||||||
|
});
|
||||||
|
const counts = res.data?.counts || [];
|
||||||
|
const gruppen = new Map();
|
||||||
|
counts.forEach((c) => { const g = gruppen.get(c.pages) || []; g.push(c.index); gruppen.set(c.pages, g); });
|
||||||
|
const zeilen = [...gruppen.keys()].sort((a, b) => a - b)
|
||||||
|
.map((p) => `${p} Seite${p === 1 ? '' : 'n'}: ${gruppen.get(p).join(', ')}`);
|
||||||
|
neu.seitenInfo = zeilen.join('\n') || 'Keine Schriftstücke.';
|
||||||
|
} catch (e) {
|
||||||
|
neu.seitenInfo = '';
|
||||||
|
neu.err = e?.response?.data?.error || e?.message || 'Seitenzählung fehlgeschlagen.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function absenden() {
|
||||||
|
// Empfänger sind optional – ohne Empfänger genau ein Schriftstück (Blankozeile).
|
||||||
|
const ent = entries();
|
||||||
|
const payloadEntries = ent.length ? ent : [leereZeile()];
|
||||||
|
neu.err = ''; neu.msg = 'Wird angelegt …';
|
||||||
|
try {
|
||||||
|
const kv = kuvertPayload();
|
||||||
|
const res = await api.post('/skrift-orders/manual-order', {
|
||||||
|
product: neu.product, format: neu.format, font: neu.font,
|
||||||
|
realistic: neu.realistic,
|
||||||
|
font_scale_letter: neu.fontSizeLetter / 100, font_scale_envelope: neu.fontSizeEnvelope / 100,
|
||||||
|
multi_page: neu.mehrseitig,
|
||||||
|
...briefSigPayload(),
|
||||||
|
needs_envelope: kv.needs_envelope, envelope_labeling: kv.envelope_labeling, envelope_text: kv.envelope_text,
|
||||||
|
shipping_type: 'sammel',
|
||||||
|
customer_email: neu.customer_email.trim() || undefined,
|
||||||
|
text_template: neu.text, preview_session: neu.previewSession || undefined,
|
||||||
|
entries: payloadEntries,
|
||||||
|
});
|
||||||
|
const nr = res.data?.order_number || '';
|
||||||
|
// Felder bewusst NICHT zurücksetzen – der letzte Auftrag bleibt stehen,
|
||||||
|
// damit er sich als Vorlage für den nächsten leicht anpassen lässt.
|
||||||
|
neu.msg = `✓ Auftrag ${nr} angelegt – liegt im Produktions-Board unter „Neu". Felder bleiben stehen; mit „Formular leeren" zurücksetzen.`;
|
||||||
|
} catch (e) { neu.err = e?.response?.data?.error || e?.message || 'Anlegen fehlgeschlagen.'; neu.msg = ''; }
|
||||||
|
}
|
||||||
|
function zuruecksetzen() {
|
||||||
|
Object.assign(neu, {
|
||||||
|
product: produkte.value[0]?.key || 'briefe', format: neuFormate()[0]?.key || '', font: 'tilda',
|
||||||
|
fontSizeLetter: 100, fontSizeEnvelope: 100,
|
||||||
|
sigSvg: '', sigLabel: '', sigX: 0, sigY: 0, sigScale: 100, mehrseitig: false, seitenInfo: '',
|
||||||
|
adressModus: '5zeilen', kuverts: false, realistic: true, customer_email: '', text: '', rows: [leereZeile()],
|
||||||
|
importOpen: false, importText: '',
|
||||||
|
docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const musterEinfuegen = (wert) => {
|
||||||
|
// Freie Mustertexte (per ID) bevorzugt; sonst Fallback Du/Sie.
|
||||||
|
let t = '';
|
||||||
|
if (wert === 'du' || wert === 'sie') t = wert === 'du' ? musterTexte.value.text_du : musterTexte.value.text_sie;
|
||||||
|
else t = (musterListe.value.find((m) => String(m.id) === String(wert)) || {}).text || '';
|
||||||
|
if (!t) return;
|
||||||
|
neu.text = neu.text.trim() ? `${neu.text}\n\n${t}` : t;
|
||||||
|
neu.previewSession = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Weißes Dropdown / Eingabefeld.
|
||||||
|
const sel = (val, opts, on, extra) => h('select', { style: WSELECT + 'width:100%;' + (extra || ''), value: val, onChange: on },
|
||||||
|
opts.map((o) => h('option', { value: o.v, selected: String(val) === String(o.v) }, o.t)));
|
||||||
|
const svgUri = (svg) => { try { return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); } catch { return null; } };
|
||||||
|
const docBild = (d) => {
|
||||||
|
const svg = d && typeof d.svg === 'string' ? d.svg : null;
|
||||||
|
const src = svg ? svgUri(svg) : (typeof d === 'string' ? d : (d?.dataUrl || d?.url || d?.image || null));
|
||||||
|
const seite = (d?.page && d?.pageCount) ? ` · Seite ${d.page}/${d.pageCount}` : '';
|
||||||
|
const txt = (d?.art === 'umschlag' ? '✉ Kuvert' : '📄 Schriftstück') + seite;
|
||||||
|
return h('div', { style: 'display:flex;flex-direction:column;align-items:center;gap:4px;' }, [
|
||||||
|
src ? h('img', { src, onClick: () => { neu.lb = src; }, style: 'width:100%;max-height:260px;object-fit:contain;border-radius:6px;background:#fff;cursor:zoom-in;box-shadow:0 1px 3px rgba(0,0,0,.12);' })
|
||||||
|
: h('div', { style: 'width:100%;height:200px;border-radius:6px;display:flex;align-items:center;justify-content:center;background:#fff;color:var(--foreground-subdued);' }, 'Dok.'),
|
||||||
|
h('span', { style: 'font-size:11px;color:var(--foreground-subdued);' }, txt),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
// Weißes, einzeiliges Zellenfeld (feste Breite → Zeile scrollt bei Bedarf).
|
||||||
|
const feld = (lbl, val, on, ph, onPaste) => h('div', { style: 'display:flex;flex-direction:column;gap:3px;flex:1 1 0;min-width:90px;' }, [
|
||||||
|
h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);' }, lbl),
|
||||||
|
h('input', { value: val, placeholder: ph || '', style: WFELD + 'width:100%;padding:7px 9px;font-size:13px;', onInput: on, onPaste }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
function formular() {
|
||||||
|
// Empfängerzeile: erst individuelle Platzhalter, dann die Adresszeilen.
|
||||||
|
const zeile = (e, i) => [
|
||||||
|
...customPlaceholders().map((name) => feld(name, e.placeholders?.[name] || '', (ev) => setPlatzhalter(i, name, ev.target.value), '',
|
||||||
|
(ev) => { if (spalteEinfuegen(i, (ri, v) => setPlatzhalter(ri, name, v), ev.clipboardData && ev.clipboardData.getData('text'))) ev.preventDefault(); })),
|
||||||
|
...Array.from({ length: zeilenAnzahl() }, (_, z) => feld('Zeile ' + (z + 1), freitext(e, z), (ev) => setFreitext(i, z, ev.target.value), '',
|
||||||
|
(ev) => { if (spalteEinfuegen(i, (ri, v) => setFreitext(ri, z, v), ev.clipboardData && ev.clipboardData.getData('text'))) ev.preventDefault(); })),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Bereich 1 – Produkt
|
||||||
|
const b1 = h('div', { style: BEREICH }, [
|
||||||
|
bereichKopf('📄 Produkt'),
|
||||||
|
h('div', { style: 'display:grid;grid-template-columns:repeat(3,1fr);gap:12px;' }, [
|
||||||
|
h('div', {}, [label('Produkt'), sel(neu.product, produkte.value.map((p) => ({ v: p.key, t: p.name })), (e) => { neu.product = e.target.value; neu.format = neuFormate()[0]?.key || ''; neu.previewSession = null; })]),
|
||||||
|
h('div', {}, [label('Format'), sel(neu.format, neuFormate().map((f) => ({ v: f.key, t: f.name })), (e) => { neu.format = e.target.value; neu.previewSession = null; })]),
|
||||||
|
h('div', {}, [label('Schrift'), sel(neu.font, [['tilda', 'Tilda'], ['alva', 'Alva'], ['ellie', 'Ellie']].map(([v, t]) => ({ v, t })), (e) => { neu.font = e.target.value; neu.previewSession = null; })]),
|
||||||
|
]),
|
||||||
|
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px;' }, [
|
||||||
|
h('div', {}, [label('Schriftgröße Brief (%)'),
|
||||||
|
// Frei tippbar; erst beim Verlassen des Feldes auf 80–150 begrenzen.
|
||||||
|
h('input', { type: 'number', min: 80, max: 150, step: 5, value: neu.fontSizeLetter, style: WFELD + 'width:100%;',
|
||||||
|
onInput: (e) => { neu.fontSizeLetter = e.target.value; neu.previewSession = null; },
|
||||||
|
onBlur: (e) => { const n = Math.round(Number(e.target.value)); neu.fontSizeLetter = (Number.isFinite(n) && n) ? Math.max(80, Math.min(150, n)) : 100; } })]),
|
||||||
|
h('div', {}, [label('Schriftgröße Kuvert (%)'),
|
||||||
|
h('input', { type: 'number', min: 80, max: 150, step: 5, value: neu.fontSizeEnvelope, style: WFELD + 'width:100%;',
|
||||||
|
onInput: (e) => { neu.fontSizeEnvelope = e.target.value; neu.previewSession = null; },
|
||||||
|
onBlur: (e) => { const n = Math.round(Number(e.target.value)); neu.fontSizeEnvelope = (Number.isFinite(n) && n) ? Math.max(80, Math.min(150, n)) : 100; } })]),
|
||||||
|
]),
|
||||||
|
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;margin-top:12px;cursor:pointer;' }, [
|
||||||
|
h('input', { type: 'checkbox', checked: neu.realistic, onChange: (e) => { neu.realistic = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
|
||||||
|
'Natürliche Handschrift (leichte Variation)',
|
||||||
|
]),
|
||||||
|
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;margin-top:8px;cursor:pointer;' }, [
|
||||||
|
h('input', { type: 'checkbox', checked: neu.mehrseitig, onChange: (e) => { neu.mehrseitig = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
|
||||||
|
'Mehrseitiges Schriftstück (Text fließt automatisch über mehrere A4-Seiten)',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Bereich 2 – Kuvert (Empfängertabelle)
|
||||||
|
const b2 = h('div', { style: BEREICH }, [
|
||||||
|
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;' }, [
|
||||||
|
bereichKopf('✉ Kuvert'),
|
||||||
|
h('div', { style: 'width:300px;' }, [segToggle(neu.adressModus, [{ key: '5zeilen', label: '5 Zeilen' }, { key: 'individuell', label: 'Individueller Text' }], (k) => { neu.adressModus = k; neu.previewSession = null; })]),
|
||||||
|
]),
|
||||||
|
h('div', { style: 'display:flex;flex-direction:column;gap:8px;max-height:34vh;overflow:auto;' },
|
||||||
|
neu.rows.map((e, i) => h('div', { style: 'display:flex;gap:8px;align-items:center;' }, [
|
||||||
|
h('span', { style: `min-width:22px;font-weight:700;color:${ACCENT};` }, `#${i + 1}`),
|
||||||
|
h('div', { style: 'flex:1;min-width:0;display:flex;gap:6px;flex-wrap:nowrap;overflow-x:auto;padding-bottom:2px;' }, zeile(e, i)),
|
||||||
|
h('button', { type: 'button', onClick: () => delZeile(i), style: 'cursor:pointer;border:none;background:transparent;color:var(--danger,#c62828);font-size:18px;padding:2px 6px;' }, '×'),
|
||||||
|
]))),
|
||||||
|
h('div', { style: 'margin-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:12px;' }, [
|
||||||
|
btn('+ Empfänger', addZeile),
|
||||||
|
btn(neu.importOpen ? 'Import schließen' : '⇪ Aus Excel einfügen', () => { neu.importOpen = !neu.importOpen; }),
|
||||||
|
]),
|
||||||
|
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;margin-top:12px;' }, [
|
||||||
|
h('input', { type: 'checkbox', checked: neu.kuverts, onChange: (e) => { neu.kuverts = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
|
||||||
|
'Kuverts generieren',
|
||||||
|
]),
|
||||||
|
neu.importOpen ? h('div', { style: 'margin-top:12px;' }, [
|
||||||
|
subdued('Aus Excel kopieren und einfügen – eine Zeile pro Empfänger, Spalten mit Tab/Semikolon/Komma. '
|
||||||
|
+ 'Reihenfolge: erst individuelle Platzhalter, dann ' + (neu.adressModus === 'individuell' ? 'die eine Adresszeile.' : 'Zeile 1–5.'), 'margin-bottom:6px;'),
|
||||||
|
h('textarea', { value: neu.importText, rows: 5, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.importText = e.target.value; } }),
|
||||||
|
h('div', { style: 'margin-top:6px;' }, [btn('Übernehmen', importUebernehmen, 'primary')]),
|
||||||
|
]) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Bereich 3 – Text (Mustertext-Dropdown direkt darüber)
|
||||||
|
const b3 = h('div', { style: BEREICH }, [
|
||||||
|
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;' }, [
|
||||||
|
bereichKopf('✍ Text'),
|
||||||
|
h('select', { style: WSELECT + 'font-size:13px;padding:7px 10px;width:auto;', onChange: (e) => { if (e.target.value) { musterEinfuegen(e.target.value); e.target.value = ''; } } },
|
||||||
|
[h('option', { value: '' }, 'Mustertext einfügen …')].concat(
|
||||||
|
musterListe.value.length
|
||||||
|
? musterListe.value.map((m) => h('option', { value: m.id }, m.label || `Text ${m.id}`))
|
||||||
|
: [h('option', { value: 'sie' }, 'Sie-Form'), h('option', { value: 'du' }, 'Du-Form')],
|
||||||
|
)),
|
||||||
|
]),
|
||||||
|
label(`Brieftext · ${neu.text.length} Zeichen · [[Platzhalter]] erlaubt`),
|
||||||
|
h('textarea', { value: neu.text, rows: 8, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.text = saeubere(e.target.value); neu.previewSession = null; }, onKeydown: (e) => {
|
||||||
|
// Tab-Taste fügt einen echten Tabulator ein (statt den Fokus zu verschieben).
|
||||||
|
if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const ta = e.target, s = ta.selectionStart, en = ta.selectionEnd;
|
||||||
|
neu.text = saeubere(neu.text.slice(0, s) + '\t' + neu.text.slice(en));
|
||||||
|
neu.previewSession = null;
|
||||||
|
requestAnimationFrame(() => { try { ta.selectionStart = ta.selectionEnd = s + 1; } catch {} });
|
||||||
|
} }),
|
||||||
|
// ── Unterschrift auf den Brief ─────────────────────────────────────
|
||||||
|
h('div', { style: 'margin-top:16px;border-top:1px solid rgba(30,80,165,.15);padding-top:14px;' }, [
|
||||||
|
label('Unterschrift auf den Brief (optional) · Platzhalter [[signatur1]]'),
|
||||||
|
h('div', { style: 'display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;' }, [
|
||||||
|
h('select', { style: WSELECT + 'width:100%;', onChange: (e) => sigWaehlen(e.target.value) },
|
||||||
|
[h('option', { value: '' }, 'Vorhandene Unterschrift wählen …')].concat(
|
||||||
|
signaturListe.value.map((s) => {
|
||||||
|
const kunde = [s.customer?.first_name, s.customer?.last_name].filter(Boolean).join(' ');
|
||||||
|
return h('option', { value: s.id }, (s.label || `Signatur ${s.id}`) + (kunde ? ` · ${kunde}` : ''));
|
||||||
|
}))),
|
||||||
|
h('label', { style: 'cursor:pointer;border-radius:8px;padding:8px 16px;font:inherit;font-size:14px;border:none;background:#fff;color:' + ACCENT + ';box-shadow:0 1px 2px rgba(0,0,0,.1);white-space:nowrap;' }, [
|
||||||
|
'⇪ SVG hochladen',
|
||||||
|
h('input', { type: 'file', accept: '.svg,image/svg+xml', style: 'display:none;', onChange: sigDatei }),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
neu.sigSvg ? h('div', { style: 'margin-top:12px;' }, [
|
||||||
|
h('div', { style: 'display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px;' }, [
|
||||||
|
h('span', { style: `color:${ACCENT};font-size:13px;font-weight:600;` }, `✓ ${neu.sigLabel} – als [[signatur1]] im Text`),
|
||||||
|
btn('Entfernen', sigEntfernen),
|
||||||
|
]),
|
||||||
|
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;' }, [
|
||||||
|
h('div', {}, [label('Versatz X (mm)'), h('input', { type: 'number', step: 1, value: neu.sigX, style: WFELD + 'width:100%;', onInput: (e) => { neu.sigX = e.target.value; neu.previewSession = null; } })]),
|
||||||
|
h('div', {}, [label('Versatz Y (mm)'), h('input', { type: 'number', step: 1, value: neu.sigY, style: WFELD + 'width:100%;', onInput: (e) => { neu.sigY = e.target.value; neu.previewSession = null; } })]),
|
||||||
|
h('div', {}, [label('Größe (%)'), h('input', { type: 'number', step: 5, value: neu.sigScale, style: WFELD + 'width:100%;', onInput: (e) => { neu.sigScale = e.target.value; neu.previewSession = null; } })]),
|
||||||
|
]),
|
||||||
|
subdued('Positiv X = nach rechts, positiv Y = nach unten. Nach dem Anpassen „Aktualisieren" in der Vorschau klicken.', 'margin-top:8px;'),
|
||||||
|
]) : null,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Bereich 4 – Kunde
|
||||||
|
const b4 = h('div', { style: BEREICH }, [
|
||||||
|
bereichKopf('👤 Kunde'),
|
||||||
|
h('div', { style: 'max-width:420px;' }, [
|
||||||
|
label('Kunde verknüpfen (E-Mail, optional)'),
|
||||||
|
h('input', { type: 'email', value: neu.customer_email, placeholder: 'kunde@example.de', style: WFELD + 'width:100%;', onInput: (e) => { neu.customer_email = e.target.value; } }),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const aktionen = h('div', { class: 'sk-btnrow', style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
|
||||||
|
neu.hinweis ? h('div', { style: `color:var(--danger,#c62828);font-size:13px;width:100%;` }, neu.hinweis) : null,
|
||||||
|
neu.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;width:100%;' }, neu.err) : null,
|
||||||
|
neu.msg ? h('div', { style: 'width:100%;' }, subdued(neu.msg)) : null,
|
||||||
|
btn(neu.pLaedt ? 'Vorschau …' : '👁 Vorschau', preview),
|
||||||
|
btn('In Druck geben', absenden, 'primary'),
|
||||||
|
btn('Formular leeren', zuruecksetzen),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const links = h('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:16px;' }, [b1, b2, b3, b4, aktionen]);
|
||||||
|
|
||||||
|
// Vorschau rechts daneben (eigener hellblauer Bereich).
|
||||||
|
const rechts = h('div', { style: BEREICH + 'flex:0 0 340px;position:sticky;top:20px;align-self:flex-start;' }, [
|
||||||
|
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;' }, [
|
||||||
|
bereichKopf('👁 Vorschau'),
|
||||||
|
btn(neu.pLaedt ? '…' : 'Aktualisieren', preview),
|
||||||
|
]),
|
||||||
|
neu.pLaedt ? subdued('Wird erzeugt …')
|
||||||
|
: neu.docs.length ? h('div', { style: 'display:flex;flex-direction:column;gap:12px;' }, neu.docs.slice(0, 12).map(docBild))
|
||||||
|
: subdued('Noch keine Vorschau. „Aktualisieren" klicken (Klick aufs Bild = groß).'),
|
||||||
|
neu.mehrseitig ? h('div', { style: 'margin-top:14px;border-top:1px solid rgba(30,80,165,.15);padding-top:12px;' }, [
|
||||||
|
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px;' }, [
|
||||||
|
h('div', { style: `font-size:13px;font-weight:700;color:${ACCENT};` }, 'Seiten je Schriftstück'),
|
||||||
|
btn('Berechnen', seitenBerechnen),
|
||||||
|
]),
|
||||||
|
neu.seitenInfo
|
||||||
|
? h('pre', { style: 'margin:0;white-space:pre-wrap;font:inherit;font-size:13px;color:var(--foreground);' }, neu.seitenInfo)
|
||||||
|
: subdued('„Berechnen" klicken – zeigt, wie viele Seiten je Empfänger entstehen.'),
|
||||||
|
]) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return h('div', { style: 'display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap;' }, [links, rechts]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unterschriftenservice ────────────────────────────────────────────────
|
||||||
|
async function sigLaden() {
|
||||||
|
sig.err = ''; sig.msg = ''; sig.pdf = ''; sig.order = null; sig.signaturen = []; sig.docs = []; sig.docId = null;
|
||||||
|
const nr = sig.orderNr.trim();
|
||||||
|
if (!nr) { sig.err = 'Bitte Auftragsnummer eingeben.'; return; }
|
||||||
|
sig.laden = true;
|
||||||
|
try {
|
||||||
|
const o = (await api.get('/items/orders', { params: {
|
||||||
|
filter: { order_number: { _eq: nr } }, limit: 1,
|
||||||
|
fields: 'id,order_number,source_file,source_files,customer.id',
|
||||||
|
} })).data?.data?.[0];
|
||||||
|
if (!o) { sig.err = `Auftrag ${nr} nicht gefunden.`; sig.laden = false; return; }
|
||||||
|
sig.order = o;
|
||||||
|
const docs = Array.isArray(o.source_files) && o.source_files.length
|
||||||
|
? o.source_files : (o.source_file ? [o.source_file] : []);
|
||||||
|
sig.docs = docs;
|
||||||
|
sig.docId = docs[0] || null;
|
||||||
|
if (o.customer?.id) {
|
||||||
|
const rows = (await api.get('/items/signatures', { params: {
|
||||||
|
filter: { customer: { _eq: o.customer.id } }, limit: -1,
|
||||||
|
fields: 'id,label,file,marker_key,offset_x_mm,offset_y_mm,width_mm',
|
||||||
|
} })).data?.data || [];
|
||||||
|
sig.signaturen = rows.filter((r) => r.file).map((r) => ({
|
||||||
|
file_id: r.file, label: r.label || 'Signatur', aktiv: true,
|
||||||
|
marker_key: r.marker_key || 'SIG1', position: 'genau',
|
||||||
|
width_mm: r.width_mm ?? 40, offset_x_mm: r.offset_x_mm ?? 0, offset_y_mm: r.offset_y_mm ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (!sig.docs.length) sig.err = 'An diesem Auftrag ist kein Dokument hochgeladen.';
|
||||||
|
else if (!sig.signaturen.length) sig.err = 'Für diesen Kunden ist keine Unterschrift (mit Datei) hinterlegt.';
|
||||||
|
} catch (e) { sig.err = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.'; }
|
||||||
|
finally { sig.laden = false; }
|
||||||
|
}
|
||||||
|
const sigAktive = () => sig.signaturen.filter((s) => s.aktiv);
|
||||||
|
const sigPayload = () => ({ order: sig.order?.id || undefined, document_id: sig.docId || undefined, signatures: sigAktive() });
|
||||||
|
// PDF direkt hochladen (manueller Modus).
|
||||||
|
async function sigPdfUpload(file) {
|
||||||
|
if (!file) return;
|
||||||
|
sig.err = ''; sig.msg = 'Dokument wird hochgeladen …'; sig.pdf = '';
|
||||||
|
try { sig.docId = await sigUpload(file); sig.docName = file.name; sig.msg = ''; }
|
||||||
|
catch (e) { sig.err = 'Dokument-Upload fehlgeschlagen.'; sig.msg = ''; }
|
||||||
|
}
|
||||||
|
// Signatur-SVG hochladen und als Zeile hinzufügen (manuell oder ergänzend).
|
||||||
|
async function sigSvgUpload(file) {
|
||||||
|
if (!file) return;
|
||||||
|
sig.err = '';
|
||||||
|
try {
|
||||||
|
const id = await sigUpload(file);
|
||||||
|
sig.signaturen.push({ file_id: id, label: file.name, aktiv: true, marker_key: 'SIG' + (sig.signaturen.length + 1), position: 'genau', width_mm: 40, offset_x_mm: 0, offset_y_mm: 0 });
|
||||||
|
sig.pdf = '';
|
||||||
|
} catch (e) { sig.err = 'Signatur-Upload fehlgeschlagen.'; }
|
||||||
|
}
|
||||||
|
async function sigVorschau() {
|
||||||
|
if (!sig.docId) { sig.err = 'Bitte ein Dokument wählen/hochladen.'; return; }
|
||||||
|
if (!sigAktive().length) { sig.err = 'Mindestens eine Unterschrift aktivieren.'; return; }
|
||||||
|
sig.err = ''; sig.msg = 'Vorschau wird erzeugt …'; sig.pdf = '';
|
||||||
|
try {
|
||||||
|
const res = await api.post('/skrift-orders/signature-preview', sigPayload());
|
||||||
|
sig.pdf = 'data:application/pdf;base64,' + res.data.pdf_base64;
|
||||||
|
sig.msg = '';
|
||||||
|
} catch (e) { sig.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Vorschau fehlgeschlagen.'; sig.msg = ''; }
|
||||||
|
}
|
||||||
|
async function sigDownload() {
|
||||||
|
if (!sig.docId || !sigAktive().length) { sig.err = 'Dokument und Signatur wählen.'; return; }
|
||||||
|
sig.err = ''; sig.msg = 'A4-Signaturen werden erzeugt …';
|
||||||
|
try {
|
||||||
|
const res = await api.post('/skrift-orders/signature-render', sigPayload());
|
||||||
|
const bin = atob(res.data.zip_base64);
|
||||||
|
const arr = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||||
|
const url = URL.createObjectURL(new Blob([arr], { type: 'application/zip' }));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = `signaturen-${res.data.order_number || sig.order.order_number}.zip`;
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||||
|
sig.msg = 'ZIP heruntergeladen – A4-Signaturen an den Plotter geben.';
|
||||||
|
} catch (e) { sig.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Erzeugung fehlgeschlagen.'; sig.msg = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function signaturPanel() {
|
||||||
|
const sigFeld = (s, k, lbl, step) => h('div', { style: 'display:flex;flex-direction:column;gap:3px;flex:1;min-width:90px;' }, [
|
||||||
|
h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);' }, lbl),
|
||||||
|
h('input', { value: s[k], type: k === 'marker_key' ? 'text' : 'number', step: step || 'any',
|
||||||
|
style: WFELD + 'width:100%;padding:7px 9px;font-size:13px;',
|
||||||
|
onInput: (e) => { s[k] = k === 'marker_key' ? e.target.value : (parseFloat(e.target.value) || 0); sig.pdf = ''; } }),
|
||||||
|
]);
|
||||||
|
const b1 = h('div', { style: BEREICH }, [
|
||||||
|
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px;' }, [
|
||||||
|
bereichKopf('✍ Unterschriften'),
|
||||||
|
h('div', { style: 'width:280px;' }, [segToggle(sig.modus, [{ key: 'auftrag', label: 'Aus Auftrag' }, { key: 'manuell', label: 'Manuell' }], (k) => { sig.modus = k; sig.pdf = ''; })]),
|
||||||
|
]),
|
||||||
|
sig.modus === 'auftrag'
|
||||||
|
? h('div', {}, [
|
||||||
|
h('div', { style: 'display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;' }, [
|
||||||
|
h('div', { style: 'flex:1;min-width:200px;' }, [label('Auftragsnummer'), h('input', { value: sig.orderNr, placeholder: 'SK-2026-…', style: WFELD + 'width:100%;', onInput: (e) => { sig.orderNr = e.target.value; } })]),
|
||||||
|
btn(sig.laden ? 'Lädt …' : 'Auftrag laden', sigLaden, 'primary'),
|
||||||
|
]),
|
||||||
|
sig.docs.length > 1 ? h('div', { style: 'margin-top:12px;' }, [label('Dokument'), sel(sig.docId, sig.docs.map((d, i) => ({ v: d, t: `Dokument ${i + 1}` })), (e) => { sig.docId = e.target.value; sig.pdf = ''; })]) : null,
|
||||||
|
])
|
||||||
|
: h('div', {}, [
|
||||||
|
label('Dokument (PDF mit Marker §§SIG…§§)'),
|
||||||
|
h('input', { type: 'file', accept: '.pdf', onChange: (e) => sigPdfUpload(e.target.files && e.target.files[0]), style: 'font-size:13px;' }),
|
||||||
|
sig.docName ? subdued(`${sig.docName} hochgeladen.`, 'margin-top:6px;') : null,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
const b2 = h('div', { style: BEREICH }, [
|
||||||
|
bereichKopf('Unterschriften & Position'),
|
||||||
|
subdued(sig.modus === 'auftrag'
|
||||||
|
? 'Aus dem Kundenkonto vorbelegt (Marker + Feinjustierung), hier überschreibbar. Weitere Unterschrift-SVGs sind ergänzbar.'
|
||||||
|
: 'Unterschrift(en) als SVG hochladen und Marker/Position setzen.', 'margin-bottom:10px;'),
|
||||||
|
h('div', { style: 'margin-bottom:10px;' }, [
|
||||||
|
h('label', { style: `display:inline-block;cursor:pointer;color:${ACCENT};font-size:13px;` }, [
|
||||||
|
'+ Unterschrift (SVG) hinzufügen',
|
||||||
|
h('input', { type: 'file', accept: '.svg', style: 'display:none;', onChange: (e) => sigSvgUpload(e.target.files && e.target.files[0]) }),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
...sig.signaturen.map((s) => h('div', { style: 'background:#fff;border-radius:8px;padding:10px 12px;margin-bottom:8px;box-shadow:0 1px 2px rgba(0,0,0,.08);' }, [
|
||||||
|
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;margin-bottom:8px;cursor:pointer;' }, [
|
||||||
|
h('input', { type: 'checkbox', checked: s.aktiv, onChange: (e) => { s.aktiv = e.target.checked; sig.pdf = ''; }, style: 'width:16px;height:16px;' }),
|
||||||
|
s.label,
|
||||||
|
]),
|
||||||
|
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
|
||||||
|
sigFeld(s, 'marker_key', 'Marker'),
|
||||||
|
sigFeld(s, 'width_mm', 'Breite mm'),
|
||||||
|
sigFeld(s, 'offset_x_mm', 'X mm'),
|
||||||
|
sigFeld(s, 'offset_y_mm', 'Y mm'),
|
||||||
|
]),
|
||||||
|
])),
|
||||||
|
h('div', { class: 'sk-btnrow', style: 'display:flex;gap:12px;flex-wrap:wrap;margin-top:8px;' }, [
|
||||||
|
btn(sig.msg ? '…' : '👁 Vorschau', sigVorschau, 'primary'),
|
||||||
|
btn('⤓ A4-Signaturen (ZIP)', sigDownload),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
const meldung = h('div', {}, [
|
||||||
|
sig.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;margin-top:8px;' }, sig.err) : null,
|
||||||
|
sig.msg ? subdued(sig.msg, 'margin-top:8px;') : null,
|
||||||
|
]);
|
||||||
|
const links = h('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:16px;' }, [b1, b2, meldung]);
|
||||||
|
const rechts = h('div', { style: BEREICH + 'flex:0 0 420px;position:sticky;top:20px;align-self:flex-start;min-height:200px;' }, [
|
||||||
|
bereichKopf('👁 Vorschau'),
|
||||||
|
sig.pdf
|
||||||
|
? h('iframe', { src: sig.pdf, style: 'width:100%;height:70vh;border:none;border-radius:8px;background:#fff;' })
|
||||||
|
: subdued('Noch keine Vorschau. Auftrag laden, Signatur wählen, „Vorschau" klicken.'),
|
||||||
|
]);
|
||||||
|
return h('div', { style: 'display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap;' }, [links, rechts]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linke Navigationsspalte: Werkzeuge dieses Moduls.
|
||||||
|
const navItem = (label, aktiv, onClick) => h('div', {
|
||||||
|
onClick,
|
||||||
|
style: 'display:flex;align-items:center;gap:8px;border-radius:6px;padding:10px 12px;margin-bottom:3px;font-size:14px;cursor:pointer;'
|
||||||
|
+ (aktiv ? `background:${ACCENT};color:#fff;font-weight:600;` : 'color:var(--foreground-normal);'),
|
||||||
|
}, label);
|
||||||
|
|
||||||
|
return () => h(resolveComponent('private-view'), { title: ansicht.value === 'signatur' ? 'Unterschriften' : 'Neuer Auftrag' }, {
|
||||||
|
navigation: () => h('div', { style: 'padding:12px 10px;' }, [
|
||||||
|
navItem('📝 Neuer Auftrag', ansicht.value === 'neu', () => { ansicht.value = 'neu'; }),
|
||||||
|
navItem('✍ Unterschriften', ansicht.value === 'signatur', () => { ansicht.value = 'signatur'; }),
|
||||||
|
]),
|
||||||
|
default: () => [
|
||||||
|
// Mobil: Button-Reihen bleiben in einer Zeile und füllen die Breite.
|
||||||
|
h('style', {}, '@media(max-width:640px){.sk-btnrow>button{flex:1 1 auto!important;}}'),
|
||||||
|
h('div', { style: 'padding:24px 28px;background:var(--background-subdued,#f1f4f9);min-height:100%;' },
|
||||||
|
ansicht.value === 'signatur' ? signaturPanel() : (!geladen.value ? subdued('Lädt …') : formular())),
|
||||||
|
neu.lb ? h(Teleport, { to: 'body' }, [
|
||||||
|
h('div', {
|
||||||
|
onClick: () => { neu.lb = ''; },
|
||||||
|
style: 'position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:1100;display:flex;align-items:center;justify-content:center;padding:4vh 4vw;cursor:zoom-out;',
|
||||||
|
}, [h('img', { src: neu.lb, style: 'max-width:100%;max-height:90vh;object-fit:contain;background:#fff;border-radius:8px;' })]),
|
||||||
|
]) : null,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: 'skrift-neuauftrag',
|
||||||
|
name: 'Manueller Druck',
|
||||||
|
icon: 'print',
|
||||||
|
routes: [{ path: '', component: NeuAuftrag }],
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "directus-extension-skrift-neuauftrag",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Modul: Neuer Auftrag – manuelle Auftragsanlage (Format/Kuvert/Schrift, Struktur- oder 5-Zeilen-Adressen, Vorschau) über die zentralen Backend-Endpunkte.",
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "module",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "dist/index.js",
|
||||||
|
"host": "^11.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -153,7 +153,87 @@ async function paypalToken() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handler = (router, { services, getSchema, logger }) => {
|
const handler = (router, { services, getSchema, logger }) => {
|
||||||
const { ItemsService, MailService, UsersService, RolesService } = services;
|
const { ItemsService, MailService, UsersService, RolesService, AssetsService } = services;
|
||||||
|
|
||||||
|
// Signatur-Plotter-Service (intern über das npm-Netz per container_name erreichbar).
|
||||||
|
const SIGN_URL = (process.env.SKRIFT_SIGN_URL || 'http://skrift-signature-service:8000').replace(/\/$/, '');
|
||||||
|
|
||||||
|
/** Liest eine Directus-Datei (Upload) serverseitig als Buffer. */
|
||||||
|
async function dateiBytes(schema, id) {
|
||||||
|
const assets = new AssetsService({ schema, accountability: null });
|
||||||
|
const { stream } = await assets.getAsset(id, {});
|
||||||
|
const chunks = [];
|
||||||
|
for await (const c of stream) chunks.push(c);
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut die Multipart-Anfrage an den Signatur-Service (Vorschau/Render). */
|
||||||
|
async function signatureForm(schema, pdfId, sigList) {
|
||||||
|
const pdfBytes = await dateiBytes(schema, pdfId);
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('pdf', new Blob([pdfBytes], { type: 'application/pdf' }), 'dokument.pdf');
|
||||||
|
const jobs = [];
|
||||||
|
for (let i = 0; i < sigList.length; i += 1) {
|
||||||
|
const s = sigList[i] || {};
|
||||||
|
if (!s.file_id) continue;
|
||||||
|
const svgBytes = await dateiBytes(schema, s.file_id);
|
||||||
|
const svgName = `sig${i + 1}.svg`;
|
||||||
|
form.append('svgs', new Blob([svgBytes], { type: 'image/svg+xml' }), svgName);
|
||||||
|
const marker = String(s.marker_key || `SIG${i + 1}`).replace(/§/g, '').trim();
|
||||||
|
jobs.push({
|
||||||
|
token: `§§${marker}§§`,
|
||||||
|
svg_id: svgName,
|
||||||
|
position: ['genau', 'ueber', 'unter'].includes(s.position) ? s.position : 'genau',
|
||||||
|
breite_mm: Number(s.width_mm) > 0 ? Number(s.width_mm) : 45,
|
||||||
|
versatz_x_mm: Number(s.offset_x_mm) || 0,
|
||||||
|
versatz_y_mm: Number(s.offset_y_mm) || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
form.append('job', JSON.stringify(jobs));
|
||||||
|
return { form, jobCount: jobs.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gemeinsame Vorbereitung für /signature-preview und /signature-render. */
|
||||||
|
async function signatureAufruf(req, res, pfad, contentType, key) {
|
||||||
|
const acc = req.accountability;
|
||||||
|
if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
|
||||||
|
const body = req.body || {};
|
||||||
|
// Entweder aus einem Auftrag (order) ODER manuell mit direkt hochgeladenem
|
||||||
|
// Dokument (document_id) – eins von beiden muss vorhanden sein.
|
||||||
|
if (!body.order && !body.document_id) return res.status(400).json({ error: 'order oder document_id erforderlich.' });
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||||
|
let ord = null;
|
||||||
|
if (body.order) {
|
||||||
|
ord = await svc('orders').readOne(body.order, { fields: ['id', 'order_number', 'source_file', 'source_files'] });
|
||||||
|
if (!ord) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
|
||||||
|
}
|
||||||
|
const pdfId = body.document_id || (ord ? (ord.source_file || (Array.isArray(ord.source_files) ? ord.source_files[0] : null)) : null);
|
||||||
|
if (!pdfId) return res.status(400).json({ error: 'Kein Dokument (Auftrag ohne Upload bzw. keine document_id).' });
|
||||||
|
const sigList = Array.isArray(body.signatures) ? body.signatures : [];
|
||||||
|
if (!sigList.length) return res.status(400).json({ error: 'Mindestens eine Signatur wählen.' });
|
||||||
|
|
||||||
|
const { form, jobCount } = await signatureForm(schema, pdfId, sigList);
|
||||||
|
if (!jobCount) return res.status(400).json({ error: 'Keine gültige Signatur (SVG-Datei fehlt).' });
|
||||||
|
|
||||||
|
const r = await fetch(`${SIGN_URL}${pfad}`, { method: 'POST', body: form });
|
||||||
|
if (!r.ok) {
|
||||||
|
const t = await r.text();
|
||||||
|
return res.status(502).json({ error: 'Signatur-Service-Fehler.', detail: `${r.status}: ${String(t).slice(0, 400)}` });
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(await r.arrayBuffer());
|
||||||
|
return res.json({ [key]: buf.toString('base64'), content_type: contentType, order_number: ord?.order_number || 'manuell' });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[skrift-orders] ${pfad}: ${err.stack || err.message}`);
|
||||||
|
return res.status(500).json({ error: 'Signatur-Verarbeitung fehlgeschlagen.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vorschau-PDF (Dokument mit eingefügten Unterschriften).
|
||||||
|
router.post('/signature-preview', (req, res) => signatureAufruf(req, res, '/preview', 'application/pdf', 'pdf_base64'));
|
||||||
|
// A4-Signatur-SVGs als ZIP (zum Plotten in der lokalen Produktion).
|
||||||
|
router.post('/signature-render', (req, res) => signatureAufruf(req, res, '/render', 'application/zip', 'zip_base64'));
|
||||||
|
|
||||||
// Policy-konformes Zufallspasswort (Kunde meldet sich ohnehin passwortlos an).
|
// Policy-konformes Zufallspasswort (Kunde meldet sich ohnehin passwortlos an).
|
||||||
const zufallsPasswort = () =>
|
const zufallsPasswort = () =>
|
||||||
@@ -189,7 +269,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
|
|
||||||
const product = (await svc('products').readByQuery({
|
const product = (await svc('products').readByQuery({
|
||||||
filter: { key: { _eq: input.product } }, limit: 1,
|
filter: { key: { _eq: input.product } }, limit: 1,
|
||||||
fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'norm_qty', 'active'],
|
fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'norm_qty', 'price_factor_a', 'price_factor_e', 'input_mode', 'active'],
|
||||||
}))?.[0];
|
}))?.[0];
|
||||||
if (!product || !product.active) throw new Error('Unbekanntes oder inaktives Produkt.');
|
if (!product || !product.active) throw new Error('Unbekanntes oder inaktives Produkt.');
|
||||||
if (product.pricing_mode === 'auf_anfrage')
|
if (product.pricing_mode === 'auf_anfrage')
|
||||||
@@ -211,14 +291,22 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
// (B2B/B2C) editierbar. Variablen: qty, norm_b/norm_c, mind_b/mind_c, base_price.
|
// (B2B/B2C) editierbar. Variablen: qty, norm_b/norm_c, mind_b/mind_c, base_price.
|
||||||
// Eine gemeinsame Formel; die Normalpreis-Menge kommt vom Produkt.
|
// Eine gemeinsame Formel; die Normalpreis-Menge kommt vom Produkt.
|
||||||
const multFormula = settings.multiplier_formula || '1';
|
const multFormula = settings.multiplier_formula || '1';
|
||||||
const norm = Number(product.norm_qty) || 0;
|
// ACHTUNG: nicht `norm` nennen – das würde die Sanitizer-Funktion norm()
|
||||||
|
// überschatten, die weiter unten für norm(input.email) gebraucht wird.
|
||||||
|
const normMenge = Number(product.norm_qty) || 0;
|
||||||
|
// Kleinmengen-Aufschlag je Produkt: a = Deckel, e = Abkling-Exponent.
|
||||||
|
// Leer/ungültig → 1 bzw. 3 (bisheriges Verhalten). 0 bleibt gültig (kein Aufschlag).
|
||||||
|
const zahlOder = (v, d) => (v === null || v === undefined || v === '' || !Number.isFinite(Number(v))) ? d : Number(v);
|
||||||
|
const faktorA = zahlOder(product.price_factor_a, 1);
|
||||||
|
const faktorE = zahlOder(product.price_factor_e, 3);
|
||||||
const multVars = {
|
const multVars = {
|
||||||
qty: menge, menge, norm,
|
qty: menge, menge, norm: normMenge,
|
||||||
|
a: faktorA, e: faktorE,
|
||||||
base_price: Number(product.base_price) || 0,
|
base_price: Number(product.base_price) || 0,
|
||||||
};
|
};
|
||||||
// Ohne Normalpreis-Menge gibt es keinen Kleinmengen-Aufschlag (Multiplikator 1).
|
// Ohne Normalpreis-Menge gibt es keinen Kleinmengen-Aufschlag (Multiplikator 1).
|
||||||
let multiplier = 1;
|
let multiplier = 1;
|
||||||
if (norm > 0) {
|
if (normMenge > 0) {
|
||||||
try { multiplier = evalFormula(multFormula, multVars); } catch { multiplier = 1; }
|
try { multiplier = evalFormula(multFormula, multVars); } catch { multiplier = 1; }
|
||||||
}
|
}
|
||||||
if (!Number.isFinite(multiplier) || multiplier <= 0) multiplier = 1;
|
if (!Number.isFinite(multiplier) || multiplier <= 0) multiplier = 1;
|
||||||
@@ -256,7 +344,10 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
add('Porto Inland', priceOf('porto_inland') * inlandCount, true);
|
add('Porto Inland', priceOf('porto_inland') * inlandCount, true);
|
||||||
add('Porto Ausland', priceOf('porto_ausland') * auslandCount, true);
|
add('Porto Ausland', priceOf('porto_ausland') * auslandCount, true);
|
||||||
} else {
|
} else {
|
||||||
add('Sammelversand', priceOf('sammelversand'), false);
|
// Sammelversand-Staffel: bis 5 Schriftstücke günstiger (sammelversand_klein),
|
||||||
|
// ab 6 der reguläre Satz. Fallback 1,80 €, falls der kleine Satz fehlt.
|
||||||
|
const sammelKlein = priceItems['sammelversand_klein'] ? priceOf('sammelversand_klein') : 1.80;
|
||||||
|
add('Sammelversand', menge <= 5 ? sammelKlein : priceOf('sammelversand'), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Kuvert + Beschriftung – NUR bei Sammelversand.
|
// 3) Kuvert + Beschriftung – NUR bei Sammelversand.
|
||||||
@@ -394,8 +485,10 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
// Reine Zeilen-/Überlauf-Prüfung (beim „Weiter") verbraucht KEIN Kontingent
|
// Reine Zeilen-/Überlauf-Prüfung (beim „Weiter") verbraucht KEIN Kontingent
|
||||||
// und liefert keine SVGs zurück – nur has_overflow/overflow.
|
// und liefert keine SVGs zurück – nur has_overflow/overflow.
|
||||||
const nurPruefen = body.validate === true;
|
const nurPruefen = body.validate === true;
|
||||||
|
// Angemeldete Admin/App-Nutzer (Produktions-Modul) umgehen Kennung + Kontingent.
|
||||||
|
const istIntern = !!(req.accountability && (req.accountability.admin || req.accountability.app));
|
||||||
const kennung = norm(body.email) || String(body.client_id || '').slice(0, 64);
|
const kennung = norm(body.email) || String(body.client_id || '').slice(0, 64);
|
||||||
if (!kennung && !nurPruefen) return res.status(400).json({ error: 'E-Mail oder Kennung erforderlich.' });
|
if (!kennung && !nurPruefen && !istIntern) return res.status(400).json({ error: 'E-Mail oder Kennung erforderlich.' });
|
||||||
if (!body.text || !String(body.text).trim())
|
if (!body.text || !String(body.text).trim())
|
||||||
return res.status(400).json({ error: 'Kein Text für die Vorschau.' });
|
return res.status(400).json({ error: 'Kein Text für die Vorschau.' });
|
||||||
|
|
||||||
@@ -404,11 +497,11 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||||
const heute = new Date().toISOString().slice(0, 10);
|
const heute = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
const vorhanden = nurPruefen ? null : (await svc('preview_usage').readByQuery({
|
const vorhanden = (nurPruefen || istIntern) ? null : (await svc('preview_usage').readByQuery({
|
||||||
filter: { key: { _eq: kennung }, day: { _eq: heute } }, limit: 1, fields: ['id', 'count'],
|
filter: { key: { _eq: kennung }, day: { _eq: heute } }, limit: 1, fields: ['id', 'count'],
|
||||||
}))?.[0];
|
}))?.[0];
|
||||||
const verbraucht = vorhanden?.count ?? 0;
|
const verbraucht = vorhanden?.count ?? 0;
|
||||||
if (!nurPruefen && verbraucht >= PREVIEW_LIMIT) {
|
if (!nurPruefen && !istIntern && verbraucht >= PREVIEW_LIMIT) {
|
||||||
return res.status(429).json({
|
return res.status(429).json({
|
||||||
error: `Vorschau-Kontingent für heute aufgebraucht (${PREVIEW_LIMIT}).`,
|
error: `Vorschau-Kontingent für heute aufgebraucht (${PREVIEW_LIMIT}).`,
|
||||||
remaining: 0, limit: PREVIEW_LIMIT,
|
remaining: 0, limit: PREVIEW_LIMIT,
|
||||||
@@ -423,8 +516,10 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
const realistisch = body.realistic !== false;
|
const realistisch = body.realistic !== false;
|
||||||
// App-Formatschlüssel → Backend-Formate (wie im directus-controller).
|
// App-Formatschlüssel → Backend-Formate (wie im directus-controller).
|
||||||
// Ohne diese Abbildung rendert das Backend unbekannte Keys als A4.
|
// Ohne diese Abbildung rendert das Backend unbekannte Keys als A4.
|
||||||
|
// Bekannte App-Schlüssel übersetzen; eigene Format-Schlüssel (freie
|
||||||
|
// Formate) unverändert durchreichen – das Backend kennt sie aus render_formats.
|
||||||
const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
|
const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
|
||||||
const briefFormat = LETTER_FORMAT[body.format] || 'a4';
|
const briefFormat = LETTER_FORMAT[body.format] || body.format || 'a4';
|
||||||
// A4 → DIN Lang, A6 → C6
|
// A4 → DIN Lang, A6 → C6
|
||||||
const umschlagFormat = briefFormat === 'a4' ? 'din_lang' : 'c6';
|
const umschlagFormat = briefFormat === 'a4' ? 'din_lang' : 'c6';
|
||||||
|
|
||||||
@@ -450,15 +545,27 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
return clean([name, strasse, ort].filter(Boolean).join('\n'));
|
return clean([name, strasse, ort].filter(Boolean).join('\n'));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Relative Schriftgröße (manuelle Aufträge), getrennt für Brief/Kuvert. 1 = Standard.
|
||||||
|
const skala = (v) => { const n = Number(v); return Number.isFinite(n) ? Math.max(0.8, Math.min(1.5, n)) : 1; };
|
||||||
|
const fsLetter = skala(body.font_scale_letter);
|
||||||
|
const fsEnv = skala(body.font_scale_envelope);
|
||||||
|
// Signaturen (SVG je [[signaturN]]) + Feinjustierung – nur Brief, nicht Kuvert.
|
||||||
|
const sigMap = (body.signatures && typeof body.signatures === 'object') ? body.signatures : {};
|
||||||
|
const sigOff = (body.signature_offsets && typeof body.signature_offsets === 'object') ? body.signature_offsets : {};
|
||||||
|
// Mehrseitiges Schriftstück: Text fließt über mehrere Seiten (kein Überlauf-Fehler).
|
||||||
|
const mehrseitig = body.multi_page === true;
|
||||||
const basis = entries.length ? entries : [{}];
|
const basis = entries.length ? entries : [{}];
|
||||||
const letters = [];
|
const letters = [];
|
||||||
basis.forEach((e, i) => {
|
basis.forEach((e, i) => {
|
||||||
// Dateinummer 1-basiert = Briefnummer (letter_001.svg ↔ Brief 1).
|
// Dateinummer 1-basiert = Briefnummer (letter_001.svg ↔ Brief 1).
|
||||||
const nr = i + 1;
|
const nr = i + 1;
|
||||||
|
// Abweichender Text je Schriftstück (#5) hat Vorrang; sonst der Standardtext.
|
||||||
|
const briefText = (e.text && String(e.text).trim()) ? String(e.text) : String(body.text);
|
||||||
letters.push({
|
letters.push({
|
||||||
index: nr, type: 'letter', text: clean(String(body.text)),
|
index: nr, type: 'letter', text: clean(briefText),
|
||||||
format: briefFormat, font, realisticHandwriting: realistisch,
|
format: briefFormat, font, realisticHandwriting: realistisch,
|
||||||
placeholders: platzhalterVon(e, i),
|
placeholders: platzhalterVon(e, i), fontScale: fsLetter,
|
||||||
|
signatures: sigMap, signatureOffsets: sigOff, multiPage: mehrseitig,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Umschlag nur, wenn er auch beschriftet wird.
|
// Umschlag nur, wenn er auch beschriftet wird.
|
||||||
@@ -469,7 +576,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
envelopeType: istFreitext ? 'custom' : 'recipient',
|
envelopeType: istFreitext ? 'custom' : 'recipient',
|
||||||
text: istFreitext ? clean(String(body.envelope_text || '')) : adressBlock(e),
|
text: istFreitext ? clean(String(body.envelope_text || '')) : adressBlock(e),
|
||||||
format: umschlagFormat, font, realisticHandwriting: realistisch,
|
format: umschlagFormat, font, realisticHandwriting: realistisch,
|
||||||
placeholders: platzhalterVon(e, i),
|
placeholders: platzhalterVon(e, i), fontScale: fsEnv,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -513,13 +620,15 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
const r = await fetch(`${BACKEND_URL}${datei.url}`, { headers: { 'X-API-Token': BACKEND_TOKEN } });
|
const r = await fetch(`${BACKEND_URL}${datei.url}`, { headers: { 'X-API-Token': BACKEND_TOKEN } });
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
const istUmschlag = /(^|\/)envelope/.test(datei.filename || datei.url || '');
|
const istUmschlag = /(^|\/)envelope/.test(datei.filename || datei.url || '');
|
||||||
docs.push({ art: istUmschlag ? 'umschlag' : 'brief', svg: await r.text() });
|
docs.push({ art: istUmschlag ? 'umschlag' : 'brief', svg: await r.text(), page: datei.page, pageCount: datei.pageCount });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verbrauch erst nach Erfolg hochzählen.
|
// Verbrauch erst nach Erfolg hochzählen – intern (Admin) zählt nicht.
|
||||||
if (vorhanden) await svc('preview_usage').updateOne(vorhanden.id, { count: verbraucht + 1 });
|
if (!istIntern && !nurPruefen && kennung) {
|
||||||
else await svc('preview_usage').createOne({ key: kennung, day: heute, count: 1 });
|
if (vorhanden) await svc('preview_usage').updateOne(vorhanden.id, { count: verbraucht + 1 });
|
||||||
|
else await svc('preview_usage').createOne({ key: kennung, day: heute, count: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
docs, remaining: PREVIEW_LIMIT - (verbraucht + 1), limit: PREVIEW_LIMIT,
|
docs, remaining: PREVIEW_LIMIT - (verbraucht + 1), limit: PREVIEW_LIMIT,
|
||||||
@@ -534,6 +643,35 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Seitenzahl je Schriftstück (mehrseitig) – schlank, ohne Scriptalizer/SVG. */
|
||||||
|
router.post('/pagecount', async (req, res) => {
|
||||||
|
const body = req.body || {};
|
||||||
|
if (!body.text || !String(body.text).trim()) return res.status(400).json({ error: 'Text fehlt.' });
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const { settings } = await loadPricingContext(schema);
|
||||||
|
const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
|
||||||
|
const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
|
||||||
|
const briefFormat = LETTER_FORMAT[body.format] || body.format || 'a4';
|
||||||
|
const scale = Math.max(0.8, Math.min(1.5, Number(body.font_scale_letter) || 1));
|
||||||
|
const entries = (Array.isArray(body.entries) ? body.entries : []).map((e) => ({
|
||||||
|
placeholders: (e && typeof e.placeholders === 'object')
|
||||||
|
? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))]))
|
||||||
|
: {},
|
||||||
|
}));
|
||||||
|
const r = await fetch(`${BACKEND_URL}/api/order/pagecount`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
|
||||||
|
body: JSON.stringify({ text: clean(String(body.text)), format: briefFormat, fontScale: scale, entries }),
|
||||||
|
});
|
||||||
|
if (!r.ok) return res.status(502).json({ error: 'Seitenzählung fehlgeschlagen.' });
|
||||||
|
return res.json(await r.json());
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[skrift-orders] pagecount: ${err.stack || err.message}`);
|
||||||
|
return res.status(500).json({ error: 'Seitenzählung fehlgeschlagen.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/** Aktuelles Kontingent abfragen, ohne etwas zu verbrauchen. */
|
/** Aktuelles Kontingent abfragen, ohne etwas zu verbrauchen. */
|
||||||
router.get('/preview/quota', async (req, res) => {
|
router.get('/preview/quota', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -627,11 +765,47 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
fs.createReadStream(ziel).pipe(res);
|
fs.createReadStream(ziel).pipe(res);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Charge-Sammel-PDF ──────────────────────────────────────────────────────
|
||||||
|
// Führt die Brief-PDFs mehrerer API-Aufträge (Datei-Produkte) im Backend zu
|
||||||
|
// EINER PDF zusammen und reicht sie ans Admin-Board durch (Bürodrucker).
|
||||||
|
// Body: { orderIds: (number|string)[] }
|
||||||
|
router.post('/charge/pdf', async (req, res) => {
|
||||||
|
const acc = req.accountability;
|
||||||
|
if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
|
||||||
|
const orderIds = Array.isArray(req.body?.orderIds) ? req.body.orderIds.filter((v) => v != null) : [];
|
||||||
|
if (!orderIds.length) return res.status(400).json({ error: 'orderIds (Array) erforderlich.' });
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${BACKEND_URL}/api/charge/merge-pdf`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
|
||||||
|
body: JSON.stringify({ orderIds }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
let msg = 'Sammel-PDF fehlgeschlagen.';
|
||||||
|
try { msg = (await r.json())?.message || msg; } catch { /* ignore */ }
|
||||||
|
return res.status(r.status === 400 || r.status === 404 ? r.status : 502).json({ error: msg });
|
||||||
|
}
|
||||||
|
const buf = Buffer.from(await r.arrayBuffer());
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="charge.pdf"');
|
||||||
|
return res.send(buf);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`[skrift-orders] charge/pdf: ${e.message}`);
|
||||||
|
return res.status(502).json({ error: 'Backend nicht erreichbar.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Druckauftrag einreihen (manueller Trigger aus dem Admin) ───────────────
|
// ── Druckauftrag einreihen (manueller Trigger aus dem Admin) ───────────────
|
||||||
// Legt pro Typ (Schriftstück / Umschlag) einen Job mit aufgelöstem Template
|
// Legt pro Typ (Schriftstück / Umschlag) einen Job mit aufgelöstem Template
|
||||||
// für die gewählte Maschine an. Kein Statuswechsel am Auftrag (bewusst).
|
// für die gewählte Maschine an. Kein Statuswechsel am Auftrag (bewusst).
|
||||||
const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6_v2' };
|
const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6l_v1' };
|
||||||
const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
|
const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
|
||||||
|
// Lesbare Labels + Format-Schlüssel je Job-Typ. Der Schlüssel steuert die
|
||||||
|
// Format-Warteschlange (Agent druckt nur Jobs mit passendem machines.current_format).
|
||||||
|
const ENV_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
|
||||||
|
const LETTER_LABEL = { a4: 'A4', a6_hoch: 'A6 hoch', a6_quer: 'A6 quer' };
|
||||||
|
const briefFormatOf = (order) => ({ key: order.format?.key || 'a4', label: order.format?.name || LETTER_LABEL[order.format?.key] || order.format?.key || 'A4' });
|
||||||
|
const umschlagFormatOf = (order) => ({ key: order.envelope_format || 'c6', label: ENV_LABEL[order.envelope_format] || order.envelope_format || 'C6' });
|
||||||
|
|
||||||
router.post('/enqueue', async (req, res) => {
|
router.post('/enqueue', async (req, res) => {
|
||||||
const acc = req.accountability;
|
const acc = req.accountability;
|
||||||
@@ -642,7 +816,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
const schema = await getSchema();
|
const schema = await getSchema();
|
||||||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||||
const order = await svc('orders').readOne(body.order, {
|
const order = await svc('orders').readOne(body.order, {
|
||||||
fields: ['id', 'order_number', 'format.key', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
|
fields: ['id', 'order_number', 'format.key', 'format.name', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
|
||||||
});
|
});
|
||||||
if (!order) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
|
if (!order) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
|
||||||
|
|
||||||
@@ -676,8 +850,10 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
const sec = s[typ];
|
const sec = s[typ];
|
||||||
if (!sec || sec.enabled === false) continue;
|
if (!sec || sec.enabled === false) continue;
|
||||||
if (!sec.template) return res.status(400).json({ error: `Template für ${typ} fehlt.` });
|
if (!sec.template) return res.status(400).json({ error: `Template für ${typ} fehlt.` });
|
||||||
|
const fmt = typ === 'umschlag' ? umschlagFormatOf(order) : briefFormatOf(order);
|
||||||
jobs.push(await svc('jobs').createOne({
|
jobs.push(await svc('jobs').createOne({
|
||||||
order: order.id, machine: body.machine, type: typ, template: sec.template,
|
order: order.id, machine: body.machine, type: typ, template: sec.template,
|
||||||
|
format: fmt.key, format_label: fmt.label,
|
||||||
params: clampParams(sec.params), numbers: (typeof sec.numbers === 'string' && sec.numbers.trim()) ? sec.numbers.trim() : null,
|
params: clampParams(sec.params), numbers: (typeof sec.numbers === 'string' && sec.numbers.trim()) ? sec.numbers.trim() : null,
|
||||||
status: 'queued', priority: prio,
|
status: 'queued', priority: prio,
|
||||||
}));
|
}));
|
||||||
@@ -685,14 +861,18 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
if (!jobs.length) return res.status(400).json({ error: 'Kein Abschnitt zum Drucken gewählt.' });
|
if (!jobs.length) return res.status(400).json({ error: 'Kein Abschnitt zum Drucken gewählt.' });
|
||||||
} else {
|
} else {
|
||||||
// Alte, einfache Form (Auftrags-Button): Brief + ggf. Umschlag automatisch.
|
// Alte, einfache Form (Auftrags-Button): Brief + ggf. Umschlag automatisch.
|
||||||
|
const bf = briefFormatOf(order);
|
||||||
const briefTpl = await tplByKey(LETTER_TPL[order.format?.key] || 'a4_single');
|
const briefTpl = await tplByKey(LETTER_TPL[order.format?.key] || 'a4_single');
|
||||||
jobs.push(await svc('jobs').createOne({
|
jobs.push(await svc('jobs').createOne({
|
||||||
order: order.id, machine: body.machine, type: 'brief', template: briefTpl, status: 'queued', priority: prio,
|
order: order.id, machine: body.machine, type: 'brief', template: briefTpl,
|
||||||
|
format: bf.key, format_label: bf.label, status: 'queued', priority: prio,
|
||||||
}));
|
}));
|
||||||
if (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine') {
|
if (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine') {
|
||||||
|
const ef = umschlagFormatOf(order);
|
||||||
const envTpl = await tplByKey(ENVELOPE_TPL[order.envelope_format] || 'kuvert_a_v1');
|
const envTpl = await tplByKey(ENVELOPE_TPL[order.envelope_format] || 'kuvert_a_v1');
|
||||||
jobs.push(await svc('jobs').createOne({
|
jobs.push(await svc('jobs').createOne({
|
||||||
order: order.id, machine: body.machine, type: 'umschlag', template: envTpl, status: 'queued', priority: prio,
|
order: order.id, machine: body.machine, type: 'umschlag', template: envTpl,
|
||||||
|
format: ef.key, format_label: ef.label, status: 'queued', priority: prio,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -703,6 +883,57 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Sammel-Druck: mehrere Aufträge auf einmal einreihen ────────────────────
|
||||||
|
// Body: { orders:[id…], mode:'queue'|'direct',
|
||||||
|
// byFormat:{ '<formatKey>': { template, machine, label } } }
|
||||||
|
// Pro Format wird einmal Template + Maschine gewählt; hier werden daraus die
|
||||||
|
// Jobs für alle markierten Aufträge erzeugt (Brief + ggf. Umschlag je Format).
|
||||||
|
router.post('/enqueue-bulk', async (req, res) => {
|
||||||
|
const acc = req.accountability;
|
||||||
|
if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
|
||||||
|
const body = req.body || {};
|
||||||
|
const orderIds = Array.isArray(body.orders) ? body.orders : [];
|
||||||
|
const byFormat = (body.byFormat && typeof body.byFormat === 'object') ? body.byFormat : {};
|
||||||
|
if (!orderIds.length) return res.status(400).json({ error: 'Keine Aufträge gewählt.' });
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||||
|
const orders = await svc('orders').readMany(orderIds, {
|
||||||
|
fields: ['id', 'order_number', 'format.key', 'format.name', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Direktdruck bekommt hohe Priorität, außer die Zielmaschine druckt schon.
|
||||||
|
const printing = await svc('jobs').readByQuery({ filter: { status: { _eq: 'printing' } }, limit: -1, fields: ['machine'] });
|
||||||
|
const busySet = new Set((printing || []).map((j) => String(j.machine)));
|
||||||
|
const mode = body.mode === 'direct' ? 'direct' : 'queue';
|
||||||
|
const prioFor = (m) => (mode === 'direct' && !busySet.has(String(m))) ? 100 : 0;
|
||||||
|
|
||||||
|
const jobs = [];
|
||||||
|
const uebersprungen = [];
|
||||||
|
const mkJob = async (order, typ, fmt) => {
|
||||||
|
const cfg = byFormat[fmt.key];
|
||||||
|
if (!cfg || !cfg.template || !cfg.machine) {
|
||||||
|
uebersprungen.push({ order: order.order_number, grund: `Kein Template/Maschine für ${typ === 'umschlag' ? 'Kuvert' : 'Format'} ${fmt.label}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jobs.push(await svc('jobs').createOne({
|
||||||
|
order: order.id, machine: cfg.machine, type: typ, template: cfg.template,
|
||||||
|
format: fmt.key, format_label: cfg.label || fmt.label, status: 'queued', priority: prioFor(cfg.machine),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
for (const order of orders) {
|
||||||
|
await mkJob(order, 'brief', briefFormatOf(order));
|
||||||
|
if (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine') {
|
||||||
|
await mkJob(order, 'umschlag', umschlagFormatOf(order));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.json({ ok: true, jobs, mode, uebersprungen });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[skrift-orders] enqueue-bulk: ${err.stack || err.message}`);
|
||||||
|
return res.status(500).json({ error: 'Sammel-Einreihen fehlgeschlagen.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Testdruck: beliebiges Template + Text generieren und einreihen ─────────
|
// ── Testdruck: beliebiges Template + Text generieren und einreihen ─────────
|
||||||
const TEST_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
|
const TEST_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
|
||||||
router.post('/test-print', async (req, res) => {
|
router.post('/test-print', async (req, res) => {
|
||||||
@@ -742,6 +973,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
// 2) Druck-Job (ohne Auftrag, über output_ref) für die gewählte Maschine.
|
// 2) Druck-Job (ohne Auftrag, über output_ref) für die gewählte Maschine.
|
||||||
const job = await svc('jobs').createOne({
|
const job = await svc('jobs').createOne({
|
||||||
machine: tp.machine, template: tp.template, type: 'brief', status: 'queued', output_ref: ref,
|
machine: tp.machine, template: tp.template, type: 'brief', status: 'queued', output_ref: ref,
|
||||||
|
format: tp.format || 'a4', format_label: LETTER_LABEL[tp.format] || tp.format || 'A4',
|
||||||
});
|
});
|
||||||
await svc('test_prints').updateOne(id, { status: 'generiert', output_ref: ref });
|
await svc('test_prints').updateOne(id, { status: 'generiert', output_ref: ref });
|
||||||
return res.json({ ok: true, ref, job });
|
return res.json({ ok: true, ref, job });
|
||||||
@@ -793,6 +1025,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
|
|
||||||
const job = await svc('jobs').createOne({
|
const job = await svc('jobs').createOne({
|
||||||
machine: body.machine, template: body.template, type: 'brief', status: 'queued', output_ref: ref, priority: prio,
|
machine: body.machine, template: body.template, type: 'brief', status: 'queued', output_ref: ref, priority: prio,
|
||||||
|
format: body.format || 'a4', format_label: LETTER_LABEL[body.format] || body.format || 'A4',
|
||||||
});
|
});
|
||||||
if (body.offer_request) {
|
if (body.offer_request) {
|
||||||
try { await svc('offer_requests').updateOne(body.offer_request, { status: 'in_bearbeitung' }); } catch { /* nicht kritisch */ }
|
try { await svc('offer_requests').updateOne(body.offer_request, { status: 'in_bearbeitung' }); } catch { /* nicht kritisch */ }
|
||||||
@@ -885,6 +1118,52 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schlanke interne Benachrichtigung (neue Bestellung/Muster/Anfrage). Empfänger
|
||||||
|
* ist konfigurierbar (email_settings.team_email, Fallback SKRIFT_TEAM_EMAIL).
|
||||||
|
* Leer → keine Team-Mail.
|
||||||
|
*/
|
||||||
|
async function teamMail(schema, subject, html) {
|
||||||
|
try {
|
||||||
|
let to = TEAM_EMAIL;
|
||||||
|
try {
|
||||||
|
const es = await new ItemsService('email_settings', { schema, accountability: null }).readSingleton({ fields: ['team_email'] });
|
||||||
|
if (es && es.team_email !== undefined && es.team_email !== null) to = String(es.team_email).trim();
|
||||||
|
} catch { /* Feld evtl. noch nicht gebootstrapt → Fallback */ }
|
||||||
|
if (!to) return; // bewusst leer = keine Team-Mails
|
||||||
|
const mail = new MailService({ schema, accountability: null });
|
||||||
|
const von = await absender(schema);
|
||||||
|
await mail.send({ ...(von ? { from: von } : {}), to, subject, html });
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`[skrift-orders] Team-Mail nicht versendet: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sendet eine im Admin editierbare E-Mail-Vorlage (email_templates) an den
|
||||||
|
* Kunden. Platzhalter {{feld}} werden aus `vars` gefüllt. Fehlt/deaktiviert die
|
||||||
|
* Vorlage, passiert nichts (kein Fehler).
|
||||||
|
*/
|
||||||
|
async function sendeVorlage(schema, key, to, vars) {
|
||||||
|
try {
|
||||||
|
const tpl = (await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({
|
||||||
|
filter: { key: { _eq: key }, enabled: { _eq: true } }, limit: 1, fields: ['subject', 'body'],
|
||||||
|
}))?.[0];
|
||||||
|
if (!tpl) return;
|
||||||
|
const fuelle = (t) => String(t || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] ?? ''));
|
||||||
|
let signature = '';
|
||||||
|
try {
|
||||||
|
const es = await new ItemsService('email_settings', { schema, accountability: null }).readSingleton({ fields: ['signature'] });
|
||||||
|
if (es?.signature) signature = es.signature;
|
||||||
|
} catch { /* optional */ }
|
||||||
|
const mail = new MailService({ schema, accountability: null });
|
||||||
|
const von = await absender(schema);
|
||||||
|
await mail.send({ ...(von ? { from: von } : {}), to, subject: fuelle(tpl.subject), html: fuelle(tpl.body) + signature });
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`[skrift-orders] Vorlagen-Mail „${key}" nicht versendet: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Angebotsanfrage („Individuelles Angebot") ────────────────────────────
|
// ── Angebotsanfrage („Individuelles Angebot") ────────────────────────────
|
||||||
router.post('/offer-request', async (req, res) => {
|
router.post('/offer-request', async (req, res) => {
|
||||||
const body = req.body || {};
|
const body = req.body || {};
|
||||||
@@ -925,25 +1204,11 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
customer: customer?.id ?? null,
|
customer: customer?.id ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Benachrichtigung – darf den Erfolg der Anfrage nicht gefährden.
|
// Schlanke interne Benachrichtigung.
|
||||||
try {
|
const produkt = body.product ? clean(String(body.product)) : null;
|
||||||
const mail = new MailService({ schema, accountability: null });
|
await teamMail(schema, `Neue Angebotsanfrage: ${name}`,
|
||||||
const von = await absender(schema);
|
`<p>Neue Angebotsanfrage von <strong>${name}</strong> (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})`
|
||||||
// Angefragtes Produkt (z. B. Unterschriftenservice, Follow-ups) mit ausweisen.
|
+ `${produkt ? ` · ${produkt}` : ''} – im Board unter „Neu".</p>`);
|
||||||
const produkt = body.product ? clean(String(body.product)) : null;
|
|
||||||
await mail.send({
|
|
||||||
...(von ? { from: von } : {}),
|
|
||||||
to: TEAM_EMAIL,
|
|
||||||
subject: produkt ? `Angebotsanfrage: ${produkt} – ${name}` : `Angebotsanfrage von ${name}`,
|
|
||||||
html:
|
|
||||||
`<p><strong>${name}</strong> (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})</p>` +
|
|
||||||
`<p>Anfrage betrifft: <strong>${produkt || 'Individuelles Angebot'}</strong></p>` +
|
|
||||||
`<p>Menge: ${Number.isFinite(quantity) ? quantity : 'k. A.'}</p>` +
|
|
||||||
`<p style="white-space:pre-wrap">${clean(message)}</p>`,
|
|
||||||
});
|
|
||||||
} catch (mailErr) {
|
|
||||||
logger.warn(`[skrift-orders] Angebots-Mail nicht versendet: ${mailErr.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({ ok: true });
|
return res.json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -980,7 +1245,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
email,
|
email,
|
||||||
phone: clean(String(body.phone || '').trim()) || null,
|
phone: clean(String(body.phone || '').trim()) || null,
|
||||||
product: clean(String(body.product)),
|
product: clean(String(body.product)),
|
||||||
message: null,
|
message: clean(String(body.message || '').trim().slice(0, 1000)) || null,
|
||||||
address: {
|
address: {
|
||||||
street: clean(String(body.street || '')),
|
street: clean(String(body.street || '')),
|
||||||
zip: clean(String(body.zip || '')),
|
zip: clean(String(body.zip || '')),
|
||||||
@@ -991,22 +1256,11 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
customer: customer?.id ?? null,
|
customer: customer?.id ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
const musterNachricht = clean(String(body.message || '').trim().slice(0, 1000));
|
||||||
const mail = new MailService({ schema, accountability: null });
|
await teamMail(schema, `Neue Musteranfrage: ${clean(String(body.name))}`,
|
||||||
const von = await absender(schema);
|
`<p>Neue Musteranfrage von <strong>${clean(String(body.name))}</strong> (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})`
|
||||||
await mail.send({
|
+ ` · Produkt: ${clean(String(body.product))} – im Board unter „Neu".</p>`
|
||||||
...(von ? { from: von } : {}),
|
+ (musterNachricht ? `<p><strong>Nachricht:</strong><br>${musterNachricht.replace(/\n/g, '<br>')}</p>` : ''));
|
||||||
to: TEAM_EMAIL,
|
|
||||||
subject: `Musterbestellung: ${clean(String(body.product))} – ${body.name}`,
|
|
||||||
html:
|
|
||||||
`<p><strong>${clean(String(body.name))}</strong> (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})</p>` +
|
|
||||||
`<p>Produkt: <strong>${clean(String(body.product))}</strong></p>` +
|
|
||||||
`<p>${clean(String(body.street))}, ${clean(String(body.zip))} ${clean(String(body.city))}, ` +
|
|
||||||
`${clean(String(body.country || ''))}</p>`,
|
|
||||||
});
|
|
||||||
} catch (mailErr) {
|
|
||||||
logger.warn(`[skrift-orders] Muster-Mail nicht versendet: ${mailErr.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({ ok: true });
|
return res.json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1015,6 +1269,241 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Webhook: Anfragen direkt aus WordPress (JetFormBuilder) ────────────────
|
||||||
|
// POST /skrift-orders/inbound/:kind (kind = 'muster' | 'anfrage')
|
||||||
|
// Öffentlich erreichbar, daher mit Shared-Secret gesichert (SKRIFT_WEBHOOK_TOKEN,
|
||||||
|
// per ?token= oder Header X-Webhook-Token). Feldnamen = JetFormBuilder-Felder.
|
||||||
|
// JetFormBuilder sendet meist application/x-www-form-urlencoded – das parst
|
||||||
|
// Directus für Custom-Endpunkte NICHT in req.body. Daher lesen wir den
|
||||||
|
// Rohbody hier selbst und interpretieren ihn als urlencoded oder JSON.
|
||||||
|
router.use('/inbound', (req, res, next) => {
|
||||||
|
if (req.method !== 'POST') return next();
|
||||||
|
if (req.body && typeof req.body === 'object' && Object.keys(req.body).length) return next();
|
||||||
|
let raw = '';
|
||||||
|
req.setEncoding('utf8');
|
||||||
|
req.on('data', (c) => { raw += c; if (raw.length > 1000000) req.destroy(); });
|
||||||
|
req.on('end', () => {
|
||||||
|
const s = raw.trim();
|
||||||
|
if (s) {
|
||||||
|
try { req.body = JSON.parse(s); }
|
||||||
|
catch { req.body = Object.fromEntries(new URLSearchParams(s)); }
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
req.on('error', () => next());
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/inbound/:kind', async (req, res) => {
|
||||||
|
// Body ist durch die /inbound-Middleware bereits geparst (JSON/urlencoded).
|
||||||
|
let body = req.body || {};
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
try { body = JSON.parse(body); }
|
||||||
|
catch { body = Object.fromEntries(new URLSearchParams(body)); }
|
||||||
|
}
|
||||||
|
const kind = String(req.params.kind || '').toLowerCase();
|
||||||
|
// Immer protokollieren, damit im Directus-Log sichtbar ist, was ankommt.
|
||||||
|
logger.info(`[skrift-orders] inbound/${kind}: content-type=${req.get('content-type') || '—'}, `
|
||||||
|
+ `felder=[${Object.keys(body).join(',')}], token=${req.query.token ? 'query' : (req.get('x-webhook-token') ? 'header' : (body.token ? 'body' : 'keiner'))}`);
|
||||||
|
|
||||||
|
const secret = process.env.SKRIFT_WEBHOOK_TOKEN || '';
|
||||||
|
const provided = req.query.token || req.get('x-webhook-token') || body.token || '';
|
||||||
|
if (!secret) {
|
||||||
|
logger.warn('[skrift-orders] inbound: SKRIFT_WEBHOOK_TOKEN ist nicht gesetzt – Webhook abgelehnt.');
|
||||||
|
return res.status(401).json({ error: 'Webhook nicht konfiguriert (SKRIFT_WEBHOOK_TOKEN fehlt serverseitig).' });
|
||||||
|
}
|
||||||
|
if (String(provided) !== secret) {
|
||||||
|
return res.status(401).json({ error: 'Ungültiger oder fehlender Webhook-Token.' });
|
||||||
|
}
|
||||||
|
if (!['muster', 'anfrage'].includes(kind)) return res.status(404).json({ error: 'Unbekannter Hook (muster|anfrage).' });
|
||||||
|
|
||||||
|
const name = String(body.name || '').trim();
|
||||||
|
const email = norm(body.email);
|
||||||
|
if (!name) return res.status(400).json({ error: 'Name fehlt.', felder: Object.keys(body) });
|
||||||
|
if (!isEmail(email)) return res.status(400).json({ error: 'Gültige E-Mail-Adresse fehlt.', felder: Object.keys(body) });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||||
|
const { settings } = await loadPricingContext(schema);
|
||||||
|
const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
|
||||||
|
|
||||||
|
const customer = (await new UsersService({ schema, accountability: null }).readByQuery({
|
||||||
|
filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
|
||||||
|
}))?.[0];
|
||||||
|
|
||||||
|
const phone = clean(String(body.telefon || body.phone || '').trim()) || null;
|
||||||
|
|
||||||
|
let record; let subject; let mailHtml;
|
||||||
|
if (kind === 'muster') {
|
||||||
|
const produkt = body.produkt_interesse ? clean(String(body.produkt_interesse)) : null;
|
||||||
|
record = {
|
||||||
|
type: 'muster', name: clean(name), email, phone, product: produkt, message: null,
|
||||||
|
address: {
|
||||||
|
street: clean(String(body.street || '')),
|
||||||
|
zip: clean(String(body.zipcode || body.zip || '')),
|
||||||
|
city: clean(String(body.city || '')),
|
||||||
|
country: clean(String(body.country || '')),
|
||||||
|
},
|
||||||
|
status: 'neu', customer: customer?.id ?? null,
|
||||||
|
};
|
||||||
|
subject = `Neue Musteranfrage: ${name}`;
|
||||||
|
mailHtml =
|
||||||
|
`<p><strong>${clean(name)}</strong> (${email}${phone ? `, ${phone}` : ''})</p>` +
|
||||||
|
`<p>Interesse: <strong>${produkt || 'k. A.'}</strong></p>` +
|
||||||
|
`<p>${clean(String(body.street || ''))}, ${clean(String(body.zipcode || body.zip || ''))} ${clean(String(body.city || ''))}, ${clean(String(body.country || ''))}</p>`;
|
||||||
|
} else {
|
||||||
|
const unternehmen = clean(String(body.unternehmen || '').trim());
|
||||||
|
const nachricht = clean(String(body.nachricht || body.message || '').trim());
|
||||||
|
// offer_requests hat kein Firmenfeld → Unternehmen der Nachricht voranstellen.
|
||||||
|
const message = [unternehmen ? `Unternehmen: ${unternehmen}` : '', nachricht].filter(Boolean).join('\n\n') || null;
|
||||||
|
record = {
|
||||||
|
type: 'angebot', name: clean(name), email, phone, product: null, message,
|
||||||
|
status: 'neu', customer: customer?.id ?? null,
|
||||||
|
};
|
||||||
|
subject = `Neue Angebotsanfrage: ${name}`;
|
||||||
|
mailHtml =
|
||||||
|
`<p><strong>${clean(name)}</strong>${unternehmen ? ` · ${unternehmen}` : ''} (${email}${phone ? `, ${phone}` : ''})</p>` +
|
||||||
|
`<p style="white-space:pre-wrap">${nachricht || '—'}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await svc('offer_requests').createOne(record);
|
||||||
|
logger.info(`[skrift-orders] inbound/${kind}: Anfrage von ${email} gespeichert.`);
|
||||||
|
|
||||||
|
// Schlanke interne Benachrichtigung (Kunden-Autoresponder macht JetFormBuilder).
|
||||||
|
await teamMail(schema, subject, mailHtml);
|
||||||
|
|
||||||
|
return res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[skrift-orders] inbound/${kind}: ${err.stack || err.message}`);
|
||||||
|
return res.status(500).json({ error: 'Anfrage konnte nicht gespeichert werden.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Manueller Auftrag (Admin) ────────────────────────────────────────────
|
||||||
|
// Legt intern einen Auftrag an (keine Zahlung, keine Datei-Eingabe), erzeugt
|
||||||
|
// die SVGs (Vorschau 1:1) und stellt ihn als „eingegangen" ins Board. Nutzt
|
||||||
|
// dieselben Bausteine wie die öffentliche Anlage – keine Doppellogik.
|
||||||
|
router.post('/manual-order', async (req, res) => {
|
||||||
|
const acc = req.accountability;
|
||||||
|
if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
|
||||||
|
const body = req.body || {};
|
||||||
|
const entries = Array.isArray(body.entries) ? body.entries : [];
|
||||||
|
if (!body.product) return res.status(400).json({ error: 'Produkt fehlt.' });
|
||||||
|
if (!entries.length) return res.status(400).json({ error: 'Mindestens eine Empfängerzeile erforderlich.' });
|
||||||
|
try {
|
||||||
|
const schema = await getSchema();
|
||||||
|
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||||
|
const { settings } = await loadPricingContext(schema);
|
||||||
|
const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
|
||||||
|
|
||||||
|
const product = (await svc('products').readByQuery({
|
||||||
|
filter: { key: { _eq: body.product } }, limit: 1, fields: ['id', 'key', 'name', 'type'],
|
||||||
|
}))?.[0];
|
||||||
|
if (!product) return res.status(400).json({ error: 'Unbekanntes Produkt.' });
|
||||||
|
|
||||||
|
// Preis nur zur Aufzeichnung (kein Gate).
|
||||||
|
let quote = null;
|
||||||
|
try { quote = await calculatePrice(schema, { ...body, quantity: entries.length }); } catch { /* egal */ }
|
||||||
|
|
||||||
|
// Kunde optional verknüpfen (per E-Mail oder ID).
|
||||||
|
let customerId = null, customerEmail = null;
|
||||||
|
if (body.customer_email && isEmail(norm(body.customer_email))) {
|
||||||
|
const u = (await new UsersService({ schema, accountability: null }).readByQuery({
|
||||||
|
filter: { email: { _eq: norm(body.customer_email) } }, limit: 1, fields: ['id', 'email'],
|
||||||
|
}))?.[0];
|
||||||
|
if (u) { customerId = u.id; customerEmail = u.email; }
|
||||||
|
else customerEmail = norm(body.customer_email);
|
||||||
|
}
|
||||||
|
|
||||||
|
let formatId = null;
|
||||||
|
if (body.format) {
|
||||||
|
formatId = (await svc('formats').readByQuery({ filter: { key: { _eq: body.format } }, limit: 1, fields: ['id'] }))?.[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
// Kuvertformat: explizite Angabe gewinnt; Datei-Produkte (Unterschriften)
|
||||||
|
// immer DIN Lang; A6/Postkarten → C6; sonst Standard DIN Lang.
|
||||||
|
const envelopeFormat = body.needs_envelope
|
||||||
|
? (body.envelope_format
|
||||||
|
|| (quote.product?.input_mode === 'datei' ? 'dinlang'
|
||||||
|
: (String(body.format || '').startsWith('a6') ? 'c6' : 'dinlang')))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const istInland = (e) => !e.country || /^(de|deutschland|germany)$/i.test(String(e.country).trim());
|
||||||
|
const countAusland = entries.filter((e) => !istInland(e)).length;
|
||||||
|
|
||||||
|
const jt = new Date();
|
||||||
|
const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`;
|
||||||
|
const naechsteNummer = async () => {
|
||||||
|
const heutige = await svc('orders').readByQuery({ filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'] });
|
||||||
|
return `${praefix}-${String((heutige?.length ?? 0) + 1).padStart(3, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderData = {
|
||||||
|
customer: customerId, customer_email: customerEmail,
|
||||||
|
product: product.id, format: formatId,
|
||||||
|
person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
|
||||||
|
font: ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda',
|
||||||
|
realistic: body.realistic !== false,
|
||||||
|
// Relative Schriftgröße (getrennt Brief/Kuvert), 1 = Standard.
|
||||||
|
font_scale_letter: Math.max(0.8, Math.min(1.5, Number(body.font_scale_letter) || 1)),
|
||||||
|
font_scale_envelope: Math.max(0.8, Math.min(1.5, Number(body.font_scale_envelope) || 1)),
|
||||||
|
// Signatur(en) auf dem Brief (SVG je [[signaturN]]) + Feinjustierung, für die finale Generierung.
|
||||||
|
signatures_manual: (body.signatures && typeof body.signatures === 'object' && Object.keys(body.signatures).length)
|
||||||
|
? { signatures: body.signatures, offsets: (body.signature_offsets && typeof body.signature_offsets === 'object') ? body.signature_offsets : {} }
|
||||||
|
: null,
|
||||||
|
// Mehrseitiges Schriftstück (Auto-Fluss). Seiten werden bei der Generierung erzeugt.
|
||||||
|
multi_page: body.multi_page === true,
|
||||||
|
source: 'operator', status: 'in_queue', production_status: 'eingegangen',
|
||||||
|
payment_status: 'bezahlt', payment_method: 'rechnung',
|
||||||
|
shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel',
|
||||||
|
needs_envelope: !!body.needs_envelope,
|
||||||
|
envelope_labeling: body.envelope_labeling ?? null,
|
||||||
|
envelope_text: clean(body.envelope_text) ?? null,
|
||||||
|
envelope_format: envelopeFormat,
|
||||||
|
text_template: clean(body.text_template) ?? null,
|
||||||
|
preview_session: typeof body.preview_session === 'string' ? body.preview_session : null,
|
||||||
|
entries_count: entries.length,
|
||||||
|
count_inland: entries.length - countAusland, count_ausland: countAusland,
|
||||||
|
net_total: quote?.net_total ?? null, vat_amount: quote?.vat_amount ?? null, gross_total: quote?.gross_total ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let orderId, orderNumber;
|
||||||
|
for (let v = 0; v < 6; v += 1) {
|
||||||
|
orderNumber = await naechsteNummer();
|
||||||
|
try { orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); break; }
|
||||||
|
catch (e) { if (v === 5 || !/unique|duplicate|bereits|exists/i.test(String(e?.message || ''))) throw e; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const entriesSvc = svc('order_entries');
|
||||||
|
let n = 0;
|
||||||
|
for (const e of entries) {
|
||||||
|
n += 1;
|
||||||
|
await entriesSvc.createOne({
|
||||||
|
order: orderId, letter_number: n,
|
||||||
|
salutation: clean(e.salutation) ?? null, first_name: clean(e.first_name) ?? null, last_name: clean(e.last_name) ?? null,
|
||||||
|
street: clean(e.street) ?? null, house_no: clean(e.house_no) ?? null, zip: clean(e.zip) ?? null,
|
||||||
|
city: clean(e.city) ?? null, country: clean(e.country) ?? null, free_text: clean(e.free_text) ?? null,
|
||||||
|
placeholders: e.placeholders && typeof e.placeholders === 'object'
|
||||||
|
? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))])) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Manuell im Admin angelegt.' });
|
||||||
|
|
||||||
|
// Generierung IMMER anstoßen – für jedes Produkt. Das Backend entscheidet,
|
||||||
|
// was zu erzeugen ist (Schriftstücke bei Handschrift-Produkten, Kuverts immer
|
||||||
|
// wenn gewünscht; bei Datei-Produkten NUR die Kuverts). Nichts = No-Op.
|
||||||
|
fetch(`${BACKEND_URL}/api/order/from-directus`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
|
||||||
|
body: JSON.stringify({ orderId }),
|
||||||
|
}).catch((e) => logger.warn(`[skrift-orders] Manuell-Generierung nicht gestartet: ${e.message}`));
|
||||||
|
|
||||||
|
return res.json({ ok: true, order_number: orderNumber, id: orderId });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[skrift-orders] manual-order: ${err.stack || err.message}`);
|
||||||
|
return res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Auftrag anlegen ──────────────────────────────────────────────────────
|
// ── Auftrag anlegen ──────────────────────────────────────────────────────
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const body = req.body || {};
|
const body = req.body || {};
|
||||||
@@ -1039,7 +1528,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
// Kunde = Login-Konto (directus_users). Finden oder anlegen – per E-Mail.
|
// Kunde = Login-Konto (directus_users). Finden oder anlegen – per E-Mail.
|
||||||
const users = new UsersService({ schema, accountability: null });
|
const users = new UsersService({ schema, accountability: null });
|
||||||
let customer = (await users.readByQuery({
|
let customer = (await users.readByQuery({
|
||||||
filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
|
filter: { email: { _eq: email } }, limit: 1, fields: ['id', 'billing_address'],
|
||||||
}))?.[0];
|
}))?.[0];
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
const rolesSvc = new RolesService({ schema, accountability: null });
|
const rolesSvc = new RolesService({ schema, accountability: null });
|
||||||
@@ -1057,9 +1546,25 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
company: clean(body.company) ?? null,
|
company: clean(body.company) ?? null,
|
||||||
phone: clean(body.phone) ?? null,
|
phone: clean(body.phone) ?? null,
|
||||||
});
|
});
|
||||||
customer = { id };
|
customer = { id, billing_address: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rechnungsadresse automatisch ins Konto übernehmen, wenn dort noch KEINE
|
||||||
|
// hinterlegt ist (bestehende Adresse wird nicht überschrieben).
|
||||||
|
try {
|
||||||
|
const b = body.billing && typeof body.billing === 'object' ? body.billing : null;
|
||||||
|
const hatAdresse = customer.billing_address
|
||||||
|
&& (customer.billing_address.street || customer.billing_address.city);
|
||||||
|
if (b && (b.street || b.city) && !hatAdresse) {
|
||||||
|
await users.updateOne(customer.id, {
|
||||||
|
billing_address: {
|
||||||
|
street: clean(b.street) || '', zip: clean(b.zip) || '',
|
||||||
|
city: clean(b.city) || '', country: clean(b.country) || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) { logger.warn(`[skrift-orders] Konto-Adresse nicht übernommen: ${e.message}`); }
|
||||||
|
|
||||||
// Spezial-/Kontaktprodukte (z. B. Unterschriftenservice) sind nur
|
// Spezial-/Kontaktprodukte (z. B. Unterschriftenservice) sind nur
|
||||||
// bestellbar, wenn sie für dieses Konto freigeschaltet wurden.
|
// bestellbar, wenn sie für dieses Konto freigeschaltet wurden.
|
||||||
if (quote.product.type === 'contact') {
|
if (quote.product.type === 'contact') {
|
||||||
@@ -1080,9 +1585,9 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
}))?.[0]?.id ?? null;
|
}))?.[0]?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Umschlagformat automatisch: A4 → DIN Lang, A6 → C6
|
// Umschlagformat automatisch: A6/Postkarte → C6, sonst Standard DIN Lang.
|
||||||
const envelopeFormat = body.needs_envelope
|
const envelopeFormat = body.needs_envelope
|
||||||
? (String(body.format || '').startsWith('a4') ? 'dinlang' : 'c6')
|
? (String(body.format || '').startsWith('a6') ? 'c6' : 'dinlang')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Empfänger nach Inland/Ausland aufteilen (für die Auftragsübersicht).
|
// Empfänger nach Inland/Ausland aufteilen (für die Auftragsübersicht).
|
||||||
@@ -1109,16 +1614,24 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
format: formatId,
|
format: formatId,
|
||||||
person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
|
person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
|
||||||
font: ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda',
|
font: ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda',
|
||||||
source: body.source === 'operator' ? 'operator' : (body.source === 'portal' ? 'portal' : 'configurator'),
|
// Relative Schriftgröße (v.a. manuelle Aufträge), getrennt Brief/Kuvert. 1 = Standard.
|
||||||
status: 'wartet_auf_zahlung',
|
font_scale_letter: Math.max(0.8, Math.min(1.5, Number(body.font_scale_letter) || 1)),
|
||||||
|
font_scale_envelope: Math.max(0.8, Math.min(1.5, Number(body.font_scale_envelope) || 1)),
|
||||||
|
source: ['operator', 'portal', 'extern'].includes(body.source) ? body.source : 'configurator',
|
||||||
|
// Extern (z. B. Etsy) wird über die Plattform bezahlt → nicht auf Zahlung warten.
|
||||||
|
status: body.source === 'extern' ? 'bezahlt' : 'wartet_auf_zahlung',
|
||||||
shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel',
|
shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel',
|
||||||
needs_envelope: !!body.needs_envelope,
|
needs_envelope: !!body.needs_envelope,
|
||||||
envelope_labeling: body.envelope_labeling ?? null,
|
envelope_labeling: body.envelope_labeling ?? null,
|
||||||
|
envelope_text: clean(body.envelope_text) ?? null,
|
||||||
envelope_format: envelopeFormat,
|
envelope_format: envelopeFormat,
|
||||||
motif_mode: body.motif_mode ?? null,
|
motif_mode: body.motif_mode ?? null,
|
||||||
motif: Number.isFinite(Number(body.motif)) && body.motif ? Number(body.motif) : null,
|
motif: Number.isFinite(Number(body.motif)) && body.motif ? Number(body.motif) : null,
|
||||||
motif_upload: body.motif_upload || null,
|
motif_upload: body.motif_upload || null,
|
||||||
source_file: body.source_file || null,
|
source_file: body.source_file || null,
|
||||||
|
source_files: Array.isArray(body.source_files) && body.source_files.length
|
||||||
|
? body.source_files
|
||||||
|
: (body.source_file ? [body.source_file] : null),
|
||||||
billing: body.billing && typeof body.billing === 'object' ? body.billing : null,
|
billing: body.billing && typeof body.billing === 'object' ? body.billing : null,
|
||||||
shipping_address: body.shipping_address && typeof body.shipping_address === 'object' ? body.shipping_address : null,
|
shipping_address: body.shipping_address && typeof body.shipping_address === 'object' ? body.shipping_address : null,
|
||||||
own_cards: body.motif_mode === 'eigene_karten',
|
own_cards: body.motif_mode === 'eigene_karten',
|
||||||
@@ -1133,7 +1646,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
gross_total: quote.gross_total,
|
gross_total: quote.gross_total,
|
||||||
voucher: quote.voucher?.id ?? null,
|
voucher: quote.voucher?.id ?? null,
|
||||||
payment_method: paymentMethod,
|
payment_method: paymentMethod,
|
||||||
payment_status: 'offen',
|
payment_status: body.source === 'extern' ? 'bezahlt' : 'offen',
|
||||||
};
|
};
|
||||||
|
|
||||||
let orderId, orderNumber;
|
let orderId, orderNumber;
|
||||||
@@ -1168,6 +1681,8 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
placeholders: e.placeholders && typeof e.placeholders === 'object'
|
placeholders: e.placeholders && typeof e.placeholders === 'object'
|
||||||
? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))]))
|
? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))]))
|
||||||
: null,
|
: null,
|
||||||
|
// Abweichender Text nur für dieses Schriftstück (#5).
|
||||||
|
text: (e.text && String(e.text).trim()) ? clean(String(e.text)) : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1190,19 +1705,25 @@ const handler = (router, { services, getSchema, logger }) => {
|
|||||||
order: orderId, status: 'wartet_auf_zahlung', note: 'Auftrag über den Konfigurator angelegt.',
|
order: orderId, status: 'wartet_auf_zahlung', note: 'Auftrag über den Konfigurator angelegt.',
|
||||||
});
|
});
|
||||||
|
|
||||||
// SVGs automatisch erzeugen, sobald der Kunde den Auftrag abschließt.
|
// Separate, im Admin editierbare Kunden-Mail für Sonderfälle.
|
||||||
// Handschrift-Produkte werden gerendert (gezeigte Vorschau-Dokumente 1:1);
|
const kundeEmail = norm(body.email);
|
||||||
// Datei-Produkte (Upload) nicht. Feuern & vergessen – blockiert die Antwort
|
if (isEmail(kundeEmail)) {
|
||||||
// an den Kunden nicht (das Backend generiert im Hintergrund und schreibt
|
const vars = { order_number: orderNumber, name: [body.first_name, body.last_name].filter(Boolean).join(' ') };
|
||||||
// Status/artifact_path selbst zurück).
|
if (quote.product?.input_mode === 'datei') sendeVorlage(schema, 'unterschriften_eingang', kundeEmail, vars);
|
||||||
if (quote.product.type === 'letter' || quote.product.type === 'postcard') {
|
else if (body.source === 'extern') sendeVorlage(schema, 'extern_eingang', kundeEmail, vars);
|
||||||
fetch(`${BACKEND_URL}/api/order/from-directus`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
|
|
||||||
body: JSON.stringify({ orderId }),
|
|
||||||
}).catch((e) => logger.warn(`[skrift-orders] Auto-Generierung nicht gestartet: ${e.message}`));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SVGs automatisch erzeugen, sobald der Kunde den Auftrag abschließt – IMMER
|
||||||
|
// und für jedes Produkt (das Backend entscheidet, was zu erzeugen ist:
|
||||||
|
// Schriftstücke bei Handschrift-Produkten, Kuverts immer wenn gewünscht;
|
||||||
|
// bei Datei-Produkten NUR die Kuverts, nicht das hochgeladene Dokument).
|
||||||
|
// Feuern & vergessen – blockiert die Antwort an den Kunden nicht.
|
||||||
|
fetch(`${BACKEND_URL}/api/order/from-directus`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
|
||||||
|
body: JSON.stringify({ orderId }),
|
||||||
|
}).catch((e) => logger.warn(`[skrift-orders] Auto-Generierung nicht gestartet: ${e.message}`));
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
order_number: orderNumber,
|
order_number: orderNumber,
|
||||||
net_total: quote.net_total, vat_amount: quote.vat_amount, gross_total: quote.gross_total,
|
net_total: quote.net_total, vat_amount: quote.vat_amount, gross_total: quote.gross_total,
|
||||||
|
|||||||
@@ -19,14 +19,39 @@
|
|||||||
*
|
*
|
||||||
* Bewusst von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
|
* Bewusst von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
|
||||||
*/
|
*/
|
||||||
import { defineComponent, ref, reactive, computed, onMounted, h, Teleport, resolveComponent } from 'vue';
|
import { defineComponent, ref, reactive, computed, onMounted, onUnmounted, h, Teleport, resolveComponent } from 'vue';
|
||||||
import { useApi } from '@directus/extensions-sdk';
|
import { useApi } from '@directus/extensions-sdk';
|
||||||
|
|
||||||
// Auftragsformat → Standard-Template-Schlüssel (wie im Backend /enqueue).
|
// Auftragsformat → Standard-Template-Schlüssel (wie im Backend /enqueue).
|
||||||
const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6_v2' };
|
const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6l_v1' };
|
||||||
|
// v1 = Empfängeradresse (Standardvorschlag). Für individuelle Beschriftung
|
||||||
|
// wählt der Operator im Popup die Variante 2 (kuvert_a_v2 / kuvert_b_v2).
|
||||||
const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
|
const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
|
||||||
|
// Lesbare Kuvert-Formatlabels (für Sammel-Druck + Format-Warteschlange).
|
||||||
|
const ENV_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
|
||||||
const INLAND = /^(de|deutschland|germany)$/i;
|
const INLAND = /^(de|deutschland|germany)$/i;
|
||||||
|
|
||||||
|
// Versandtag → Wochentag (0=So…6=Sa). Für die Frist-Berechnung.
|
||||||
|
const WOCHENTAG = { montag: 1, dienstag: 2, mittwoch: 3, donnerstag: 4, freitag: 5, samstag: 6, sonntag: 0 };
|
||||||
|
// Garantie: kommt ein Auftrag in Woche W rein, geht er spätestens am angegebenen
|
||||||
|
// Versandtag der ÜBERNÄCHSTEN Woche (W+2) raus. Liefert das Fälligkeitsdatum.
|
||||||
|
function faelligBis(dateCreated, shippingDay) {
|
||||||
|
const wd = WOCHENTAG[String(shippingDay || '').toLowerCase()];
|
||||||
|
if (wd === undefined || !dateCreated) return null;
|
||||||
|
const d = new Date(dateCreated);
|
||||||
|
if (isNaN(d)) return null;
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
// Montag der Eingangswoche (Montag = Wochenstart).
|
||||||
|
const tag = d.getDay();
|
||||||
|
const montag = new Date(d);
|
||||||
|
montag.setDate(d.getDate() + (tag === 0 ? -6 : 1 - tag));
|
||||||
|
// Montag der übernächsten Woche + Offset bis zum Ziel-Wochentag.
|
||||||
|
const ziel = new Date(montag);
|
||||||
|
ziel.setDate(montag.getDate() + 14 + (wd === 0 ? 6 : wd - 1));
|
||||||
|
return ziel;
|
||||||
|
}
|
||||||
|
const dmY = (dt) => dt ? `${String(dt.getDate()).padStart(2, '0')}.${String(dt.getMonth() + 1).padStart(2, '0')}.${dt.getFullYear()}` : '';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'neu', label: '🆕 Neu' },
|
{ key: 'neu', label: '🆕 Neu' },
|
||||||
{ key: 'warteschlange', label: '⏳ Warteschlange' },
|
{ key: 'warteschlange', label: '⏳ Warteschlange' },
|
||||||
@@ -134,25 +159,54 @@ const Board = defineComponent({
|
|||||||
const manuell = reactive({ open: false, req: null, form: 'sie', font: 'tilda', format: 'a4', template: null, machine: null, mode: 'queue', text: '', msg: '', err: '' });
|
const 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: [],
|
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: '' });
|
info: '', laden: false, selected: null, pl: '', pe: '', pLoad: false, perr: '', lb: '', lbTitel: '' });
|
||||||
|
// Massenauswahl (Auftrags-IDs) + Sammel-Druck-Popup (pro Format Template+Maschine).
|
||||||
|
const auswahl = ref([]);
|
||||||
|
// Aufgeklappte API-Chargen (im „Neu"-Reiter), Schlüssel = Charge-ID.
|
||||||
|
const expandedCharges = ref(new Set());
|
||||||
|
const bulk = reactive({ open: false, mode: 'queue', gruppen: [], msg: '', err: '' });
|
||||||
|
// Vom Kunden hochgeladene Original-Dokumente (kein generiertes SVG).
|
||||||
|
const dateien = reactive({ open: false, order: null, items: [], laden: false, err: '' });
|
||||||
|
|
||||||
// ── Laden ──
|
// ── Laden ──
|
||||||
// Basis-Felder ohne die evtl. noch nicht gebootstrapten Felder – diese
|
// Basis-Felder ohne die evtl. noch nicht gebootstrapten Felder – diese
|
||||||
// werden separat versucht, damit ein fehlendes Feld nicht das ganze Board sperrt.
|
// 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,'
|
const ORDER_FELDER = 'id,order_number,status,payment_status,production_status,needs_envelope,envelope_format,'
|
||||||
+ 'envelope_labeling,shipping_type,entries_count,count_inland,count_ausland,brief_gedruckt,kuvert_gedruckt,'
|
+ 'envelope_labeling,shipping_type,shipping_day,keep_contacts,entries_count,count_inland,count_ausland,brief_gedruckt,kuvert_gedruckt,'
|
||||||
+ 'date_updated,date_created,format.key,format.name,product.name,product.input_mode,'
|
+ 'date_updated,date_created,format.key,format.name,product.name,product.input_mode,'
|
||||||
|
+ 'source,api_client.id,api_client.name,'
|
||||||
|
+ 'source_file,'
|
||||||
+ 'customer.first_name,customer.last_name,customer.email,customer.phone';
|
+ 'customer.first_name,customer.last_name,customer.email,customer.phone';
|
||||||
async function ladeOrders() {
|
async function ladeOrders() {
|
||||||
try {
|
try {
|
||||||
return await api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER + ',versandt_am' } });
|
// source_files/versandt_am sind ggf. noch nicht gebootstrapt → separater Versuch.
|
||||||
|
return await api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER + ',versandt_am,source_files' } });
|
||||||
} catch {
|
} catch {
|
||||||
// Feld „versandt_am" fehlt noch (Bootstrap nicht gelaufen) → ohne laden,
|
// Neues Feld fehlt noch → ohne laden (Archiv nutzt ersatzweise date_updated,
|
||||||
// Archiv nutzt ersatzweise date_updated.
|
// Dateien-Button fällt auf source_file zurück).
|
||||||
return api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER } });
|
return api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function laden() {
|
// Jobs mit Format-Feldern; fällt ohne sie zurück, falls der Bootstrap für die
|
||||||
loading.value = true; fehler.value = '';
|
// Format-Warteschlange noch nicht gelaufen ist (dann degradiert die Ansicht).
|
||||||
|
const JOB_FELDER = 'id,type,status,priority,numbers,output_ref,error,date_created,machine.id,machine.name,order.id,order.order_number';
|
||||||
|
async function ladeJobs() {
|
||||||
|
const filter = { status: { _in: ['queued', 'printing', 'failed'] } };
|
||||||
|
try {
|
||||||
|
return await api.get('/items/jobs', { params: { limit: -1, sort: '-priority,date_created', filter, fields: JOB_FELDER + ',format,format_label' } });
|
||||||
|
} catch {
|
||||||
|
return api.get('/items/jobs', { params: { limit: -1, sort: '-priority,date_created', filter, fields: JOB_FELDER } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function ladeMachines() {
|
||||||
|
try {
|
||||||
|
return await api.get('/items/machines', { params: { limit: -1, fields: 'id,name,active,status,last_seen,current_format' } });
|
||||||
|
} catch {
|
||||||
|
return api.get('/items/machines', { params: { limit: -1, fields: 'id,name,active,status,last_seen' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function laden(leise = false) {
|
||||||
|
// leise = Hintergrund-Aktualisierung: kein Spinner, kein Fehlerbanner.
|
||||||
|
if (!leise) { loading.value = true; fehler.value = ''; }
|
||||||
try {
|
try {
|
||||||
const [o, r, j, m, t, ms] = await Promise.all([
|
const [o, r, j, m, t, ms] = await Promise.all([
|
||||||
ladeOrders(),
|
ladeOrders(),
|
||||||
@@ -161,12 +215,8 @@ const Board = defineComponent({
|
|||||||
filter: { status: { _in: ['neu', 'in_bearbeitung'] } },
|
filter: { status: { _in: ['neu', 'in_bearbeitung'] } },
|
||||||
fields: 'id,type,name,email,phone,quantity,product,message,address,status,date_created',
|
fields: 'id,type,name,email,phone,quantity,product,message,address,status,date_created',
|
||||||
} }),
|
} }),
|
||||||
api.get('/items/jobs', { params: {
|
ladeJobs(),
|
||||||
limit: -1, sort: '-priority,date_created',
|
ladeMachines(),
|
||||||
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/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: '' } } })),
|
api.get('/items/muster_settings', { params: { fields: 'text_du,text_sie' } }).catch(() => ({ data: { data: { text_du: '', text_sie: '' } } })),
|
||||||
]);
|
]);
|
||||||
@@ -177,11 +227,25 @@ const Board = defineComponent({
|
|||||||
templates.value = t.data?.data || [];
|
templates.value = t.data?.data || [];
|
||||||
muster.value = ms.data?.data || { text_du: '', text_sie: '' };
|
muster.value = ms.data?.data || { text_du: '', text_sie: '' };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.';
|
if (!leise) fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.';
|
||||||
} finally { loading.value = false; }
|
} finally { if (!leise) loading.value = false; }
|
||||||
}
|
}
|
||||||
onMounted(laden);
|
onMounted(laden);
|
||||||
|
|
||||||
|
// Auto-Refresh: hält das Board aktuell, ohne dass man die Seite neu laden muss.
|
||||||
|
// Pausiert bei offenem Sammel-Dialog, im Hintergrund-Tab und während bereits
|
||||||
|
// geladen wird – damit laufende Bedienung nicht gestört wird.
|
||||||
|
let refreshTimer = null;
|
||||||
|
onMounted(() => {
|
||||||
|
refreshTimer = setInterval(() => {
|
||||||
|
// Bei offenem Dialog oder im Hintergrund-Tab nicht aktualisieren.
|
||||||
|
const dialogOffen = bulk.open || druck.open || manuell.open || empf.open || dateien.open;
|
||||||
|
if (loading.value || dialogOffen || (typeof document !== 'undefined' && document.hidden)) return;
|
||||||
|
laden(true);
|
||||||
|
}, 15000);
|
||||||
|
});
|
||||||
|
onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer); });
|
||||||
|
|
||||||
const aktiveMaschinen = computed(() => machines.value.filter((m) => m.active !== false));
|
const 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));
|
const maschineBusy = (id) => jobs.value.some((j) => j.status === 'printing' && String(j.machine?.id) === String(id));
|
||||||
|
|
||||||
@@ -238,17 +302,185 @@ const Board = defineComponent({
|
|||||||
|
|
||||||
// ── Statuswechsel eines Auftrags ──
|
// ── Statuswechsel eines Auftrags ──
|
||||||
async function setStatus(order, neu) {
|
async function setStatus(order, neu) {
|
||||||
const patch = { production_status: neu };
|
const patch = { production_status: neu, production_status_changed_at: new Date().toISOString() };
|
||||||
// Versanddatum festhalten (Basis fürs Auto-Archiv nach 14 Tagen).
|
// Versanddatum festhalten (Basis fürs Auto-Archiv nach 14 Tagen).
|
||||||
if (neu === 'versendet' && !order.versandt_am) patch.versandt_am = new Date().toISOString();
|
if (neu === 'versendet' && !order.versandt_am) patch.versandt_am = new Date().toISOString();
|
||||||
try {
|
try {
|
||||||
await api.patch(`/items/orders/${order.id}`, patch);
|
await api.patch(`/items/orders/${order.id}`, patch);
|
||||||
order.production_status = neu;
|
order.production_status = neu;
|
||||||
|
order.production_status_changed_at = patch.production_status_changed_at;
|
||||||
if (patch.versandt_am) order.versandt_am = patch.versandt_am;
|
if (patch.versandt_am) order.versandt_am = patch.versandt_am;
|
||||||
flash(`${order.order_number} → ${STATUS_LABEL[neu]}`);
|
flash(`${order.order_number} → ${STATUS_LABEL[neu]}`);
|
||||||
} catch (e) { fehler.value = e?.message || 'Statuswechsel fehlgeschlagen.'; }
|
} catch (e) { fehler.value = e?.message || 'Statuswechsel fehlgeschlagen.'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Akquise: Empfänger dieses Auftrags ausdrücklich als Kontakte/Leads
|
||||||
|
// speichern. Der Kontakte-Hook legt sie beim Setzen an (einmalig).
|
||||||
|
async function toggleKeepContacts(order) {
|
||||||
|
const neu = !order.keep_contacts;
|
||||||
|
try {
|
||||||
|
await api.patch(`/items/orders/${order.id}`, { keep_contacts: neu });
|
||||||
|
order.keep_contacts = neu;
|
||||||
|
flash(`${order.order_number}: Empfänger ${neu ? 'werden in Kontakte gespeichert' : 'werden nicht als Kontakte gespeichert'}`);
|
||||||
|
} catch (e) { fehler.value = e?.message || 'Konnte „In Kontakte speichern" nicht ändern.'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API-Chargen (Gruppierung im „Neu"-Reiter) ────────────────────────────
|
||||||
|
// Gruppenschlüssel: Kunde + Produkt/Typ + Versandtag + Eingangstag.
|
||||||
|
function bildeChargen(orders) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const o of orders) {
|
||||||
|
const kunde = o.api_client?.id ?? 'x';
|
||||||
|
const produkt = o.product?.name || '?';
|
||||||
|
const tag = o.shipping_day || '';
|
||||||
|
const eingang = String(o.date_created || '').slice(0, 10);
|
||||||
|
const key = `${kunde}|${produkt}|${tag}|${eingang}`;
|
||||||
|
let c = map.get(key);
|
||||||
|
if (!c) {
|
||||||
|
c = { key, kundeName: o.api_client?.name || 'API-Kunde', produkt, versandtag: tag, eingang, orders: [],
|
||||||
|
faellig: faelligBis(o.date_created, tag) };
|
||||||
|
map.set(key, c);
|
||||||
|
}
|
||||||
|
c.orders.push(o);
|
||||||
|
}
|
||||||
|
// Nach Fälligkeit (früheste zuerst), dann Eingang.
|
||||||
|
return [...map.values()].sort((a, b) => (a.faellig?.getTime() || 9e15) - (b.faellig?.getTime() || 9e15));
|
||||||
|
}
|
||||||
|
const chargeGewaehlt = (c) => c.orders.length > 0 && c.orders.every((o) => istGewaehlt(o.id));
|
||||||
|
function toggleCharge(c) {
|
||||||
|
const alleDrin = chargeGewaehlt(c);
|
||||||
|
const ids = c.orders.map((o) => String(o.id));
|
||||||
|
auswahl.value = alleDrin ? auswahl.value.filter((x) => !ids.includes(x))
|
||||||
|
: [...new Set([...auswahl.value, ...ids])];
|
||||||
|
}
|
||||||
|
const toggleExpand = (key) => {
|
||||||
|
const s = new Set(expandedCharges.value);
|
||||||
|
s.has(key) ? s.delete(key) : s.add(key);
|
||||||
|
expandedCharges.value = s;
|
||||||
|
};
|
||||||
|
// Ausgewählte Auftrags-IDs nach Auftragsnummer sortiert – damit PDF-Stapel
|
||||||
|
// und Kuvert-Reihenfolge im Plotter zueinander passen (Kollation).
|
||||||
|
function auswahlSortiert() {
|
||||||
|
const byId = new Map(orders.value.map((o) => [String(o.id), o]));
|
||||||
|
return auswahl.value.slice().sort((a, b) => String(byId.get(String(a))?.order_number || '')
|
||||||
|
.localeCompare(String(byId.get(String(b))?.order_number || ''), undefined, { numeric: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sammel-PDF der aktuell ausgewählten Aufträge (mehrere Chargen kombiniert).
|
||||||
|
async function chargePdf() {
|
||||||
|
const ids = auswahlSortiert();
|
||||||
|
if (!ids.length) { fehler.value = 'Keine Aufträge ausgewählt.'; return; }
|
||||||
|
meldung.value = 'Sammel-PDF wird erstellt …';
|
||||||
|
try {
|
||||||
|
const r = await api.post('/skrift-orders/charge/pdf', { orderIds: ids }, { responseType: 'blob' });
|
||||||
|
const url = URL.createObjectURL(r.data);
|
||||||
|
const a = document.createElement('a'); a.href = url; a.download = 'charge.pdf';
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
|
flash(`Sammel-PDF erstellt (${ids.length} Auftrag/Aufträge).`);
|
||||||
|
} catch (e) {
|
||||||
|
fehler.value = e?.response?.data?.error || e?.message || 'Sammel-PDF fehlgeschlagen.'; meldung.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Massenauswahl + Sammel-Druck ─────────────────────────────────────────
|
||||||
|
const istGewaehlt = (id) => auswahl.value.includes(String(id));
|
||||||
|
function toggleAuswahl(id) {
|
||||||
|
id = String(id);
|
||||||
|
auswahl.value = istGewaehlt(id) ? auswahl.value.filter((x) => x !== id) : [...auswahl.value, id];
|
||||||
|
}
|
||||||
|
const auswahlLeeren = () => { auswahl.value = []; };
|
||||||
|
// Aufträge, die im aktuellen Tab als Karten sichtbar sind (für „Alle auswählen").
|
||||||
|
function sichtbareOrders() {
|
||||||
|
switch (tab.value) {
|
||||||
|
case 'neu': return neueOrders.value;
|
||||||
|
case 'druck': return ordersByStatus('im_druck');
|
||||||
|
case 'kuvertierung': return ordersByStatus('kuvertieren');
|
||||||
|
case 'versandfertig': return ordersByStatus('versandfertig');
|
||||||
|
case 'versendet': return versendetAktiv();
|
||||||
|
case 'archiv': return archivListe();
|
||||||
|
default: return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const alleSichtbarGewaehlt = () => {
|
||||||
|
const ids = sichtbareOrders().map((o) => String(o.id));
|
||||||
|
return ids.length > 0 && ids.every((id) => auswahl.value.includes(id));
|
||||||
|
};
|
||||||
|
function alleAuswaehlen() {
|
||||||
|
const ids = sichtbareOrders().map((o) => String(o.id));
|
||||||
|
auswahl.value = alleSichtbarGewaehlt()
|
||||||
|
? auswahl.value.filter((id) => !ids.includes(id))
|
||||||
|
: [...new Set([...auswahl.value, ...ids])];
|
||||||
|
}
|
||||||
|
// Status für alle ausgewählten Aufträge auf einmal setzen.
|
||||||
|
async function bulkStatus(neuStatus) {
|
||||||
|
const sel = orders.value.filter((o) => istGewaehlt(o.id));
|
||||||
|
if (!sel.length || !neuStatus) return;
|
||||||
|
let ok = 0;
|
||||||
|
for (const o of sel) {
|
||||||
|
const patch = { production_status: neuStatus, production_status_changed_at: new Date().toISOString() };
|
||||||
|
if (neuStatus === 'versendet' && !o.versandt_am) patch.versandt_am = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
await api.patch(`/items/orders/${o.id}`, patch);
|
||||||
|
o.production_status = neuStatus;
|
||||||
|
o.production_status_changed_at = patch.production_status_changed_at;
|
||||||
|
if (patch.versandt_am) o.versandt_am = patch.versandt_am;
|
||||||
|
ok++;
|
||||||
|
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Statuswechsel fehlgeschlagen.'; }
|
||||||
|
}
|
||||||
|
flash(`${ok} Auftrag/Aufträge → ${STATUS_LABEL[neuStatus] || neuStatus}`);
|
||||||
|
auswahlLeeren();
|
||||||
|
}
|
||||||
|
// Standard-Template je Format vorbelegen (Operator kann im Popup ändern).
|
||||||
|
function stdTpl(formatKey, typ) {
|
||||||
|
const key = typ === 'umschlag' ? (ENVELOPE_TPL[formatKey] || 'kuvert_a_v1') : (LETTER_TPL[formatKey] || 'a4_single');
|
||||||
|
const t = templates.value.find((x) => x.key === key && x.type === typ) || templates.value.find((x) => x.type === typ);
|
||||||
|
return t?.id ?? null;
|
||||||
|
}
|
||||||
|
function openBulk() {
|
||||||
|
const sel = orders.value.filter((o) => istGewaehlt(o.id));
|
||||||
|
if (!sel.length) return;
|
||||||
|
const map = {};
|
||||||
|
const add = (key, label, typ) => {
|
||||||
|
if (!map[key]) map[key] = { key, label, type: typ, aktiv: true, template: stdTpl(key, typ), machine: aktiveMaschinen.value[0]?.id ?? null };
|
||||||
|
};
|
||||||
|
for (const o of sel) {
|
||||||
|
add(o.format?.key || 'a4', o.format?.name || o.format?.key || 'A4', 'brief');
|
||||||
|
if (o.needs_envelope && o.envelope_labeling && o.envelope_labeling !== 'keine') {
|
||||||
|
const ek = o.envelope_format || 'c6';
|
||||||
|
add(ek, ENV_LABEL[ek] || ek, 'umschlag');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bulk.gruppen = Object.values(map);
|
||||||
|
bulk.mode = 'queue'; bulk.msg = ''; bulk.err = ''; bulk.open = true;
|
||||||
|
}
|
||||||
|
async function submitBulk() {
|
||||||
|
const byFormat = {};
|
||||||
|
const aktive = bulk.gruppen.filter((g) => g.aktiv);
|
||||||
|
if (!aktive.length) { bulk.err = 'Mindestens ein Format auswählen.'; return; }
|
||||||
|
for (const g of aktive) {
|
||||||
|
if (!g.template || !g.machine) { bulk.err = `Für „${g.label}" bitte Template und Maschine wählen.`; return; }
|
||||||
|
byFormat[g.key] = { template: g.template, machine: g.machine, label: g.label };
|
||||||
|
}
|
||||||
|
bulk.err = ''; bulk.msg = 'Wird eingereiht …';
|
||||||
|
try {
|
||||||
|
const res = await api.post('/skrift-orders/enqueue-bulk', { orders: auswahlSortiert(), mode: bulk.mode, byFormat });
|
||||||
|
const n = res.data?.jobs?.length || 0;
|
||||||
|
const skip = res.data?.uebersprungen || [];
|
||||||
|
flash(`${n} Job(s) eingereiht${skip.length ? ` · ${skip.length} übersprungen` : ''}.`);
|
||||||
|
bulk.open = false; auswahlLeeren(); await laden();
|
||||||
|
} catch (e) { bulk.err = e?.response?.data?.error || e?.message || 'Sammel-Einreihen fehlgeschlagen.'; bulk.msg = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Format-Warteschlange: welches Format liegt an der Maschine ein? ───────
|
||||||
|
async function setMachineFormat(machine, key) {
|
||||||
|
try {
|
||||||
|
await api.patch(`/items/machines/${machine.id}`, { current_format: key || null });
|
||||||
|
machine.current_format = key || null;
|
||||||
|
flash(key ? `„${key}" als eingelegtes Format an ${machine.name} gesetzt.` : `Format an ${machine.name} geleert – Maschine wartet.`);
|
||||||
|
} catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Format konnte nicht gesetzt werden.'; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Druck-Popup ──
|
// ── 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 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 });
|
const tplToFelder = (t) => ({ tplId: t?.id ?? null, width: t?.width ?? 0, height: t?.height ?? 0, xpos: t?.xpos ?? 0, ypos: t?.ypos ?? 0, scale: t?.scale ?? 1, rotation: t?.rotation ?? 0 });
|
||||||
@@ -256,7 +488,10 @@ const Board = defineComponent({
|
|||||||
function openDruck(order) {
|
function openDruck(order) {
|
||||||
const bt = tplByKey(LETTER_TPL[order.format?.key] || 'a4_single', 'brief');
|
const bt = tplByKey(LETTER_TPL[order.format?.key] || 'a4_single', 'brief');
|
||||||
const wantsEnv = order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine';
|
const wantsEnv = order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine';
|
||||||
const et = wantsEnv ? tplByKey(ENVELOPE_TPL[order.envelope_format] || 'kuvert_a_v1', 'umschlag') : null;
|
// Bei individuellem Text die Variante 2 des Kuverts vorschlagen (v1 = Empfängeradresse).
|
||||||
|
let envKey = ENVELOPE_TPL[order.envelope_format] || 'kuvert_a_v1';
|
||||||
|
if (wantsEnv && order.envelope_labeling === 'freitext') envKey = envKey.replace('_v1', '_v2');
|
||||||
|
const et = wantsEnv ? (tplByKey(envKey, 'umschlag') || tplByKey(ENVELOPE_TPL[order.envelope_format], 'umschlag')) : null;
|
||||||
const n = Math.max(1, order.entries_count || 1);
|
const n = Math.max(1, order.entries_count || 1);
|
||||||
druck.order = order;
|
druck.order = order;
|
||||||
druck.machine = aktiveMaschinen.value[0]?.id ?? null;
|
druck.machine = aktiveMaschinen.value[0]?.id ?? null;
|
||||||
@@ -397,13 +632,44 @@ const Board = defineComponent({
|
|||||||
catch (e) { fehler.value = e?.message || 'Fehlgeschlagen.'; }
|
catch (e) { fehler.value = e?.message || 'Fehlgeschlagen.'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Vom Kunden hochgeladene Dokumente (Original, keine SVGs) ──
|
||||||
|
const orderDateiIds = (o) => (Array.isArray(o.source_files) && o.source_files.length)
|
||||||
|
? o.source_files : (o.source_file ? [o.source_file] : []);
|
||||||
|
async function openDateien(order) {
|
||||||
|
dateien.open = true; dateien.order = order; dateien.items = []; dateien.err = ''; dateien.laden = true;
|
||||||
|
try {
|
||||||
|
const items = [];
|
||||||
|
for (const id of orderDateiIds(order)) {
|
||||||
|
try {
|
||||||
|
const f = (await api.get('/files/' + id, { params: { fields: 'id,filename_download,type,filesize' } })).data?.data;
|
||||||
|
items.push({ id, name: f?.filename_download || id, type: f?.type || '' });
|
||||||
|
} catch { items.push({ id, name: id, type: '' }); }
|
||||||
|
}
|
||||||
|
dateien.items = items;
|
||||||
|
if (!items.length) dateien.err = 'Keine hochgeladenen Dateien an diesem Auftrag.';
|
||||||
|
} catch (e) { dateien.err = e?.message || 'Dateien konnten nicht geladen werden.'; }
|
||||||
|
finally { dateien.laden = false; }
|
||||||
|
}
|
||||||
|
async function dlDatei(item) {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/assets/' + item.id, { responseType: 'blob' });
|
||||||
|
const url = URL.createObjectURL(res.data);
|
||||||
|
const a = document.createElement('a'); a.href = url; a.download = item.name;
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 4000);
|
||||||
|
} catch (e) { dateien.err = 'Download fehlgeschlagen.'; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Render-Bausteine ──────────────────────────────────────────────────
|
// ── Render-Bausteine ──────────────────────────────────────────────────
|
||||||
function actionButtons(order) {
|
function actionButtons(order) {
|
||||||
// Drucken + Empfänger (Empfänger enthält die Vorschau je Dokument).
|
// Drucken + Empfänger (Empfänger enthält die Vorschau je Dokument).
|
||||||
return h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
|
return h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
|
||||||
btn('🖨 Drucken', () => openDruck(order), 'primary'),
|
btn('🖨 Drucken', () => openDruck(order), 'primary'),
|
||||||
btn('👥 Empfänger', () => openEmpf(order)),
|
btn('👥 Empfänger', () => openEmpf(order)),
|
||||||
]);
|
orderDateiIds(order).length ? btn('📎 Dateien', () => openDateien(order)) : null,
|
||||||
|
btn(order.keep_contacts ? '📇 In Kontakte gespeichert ✓' : '📇 In Kontakte speichern',
|
||||||
|
() => toggleKeepContacts(order), order.keep_contacts ? 'primary' : 'default'),
|
||||||
|
].filter(Boolean));
|
||||||
}
|
}
|
||||||
function statusSelect(order) {
|
function statusSelect(order) {
|
||||||
return h('select', {
|
return h('select', {
|
||||||
@@ -415,9 +681,13 @@ const Board = defineComponent({
|
|||||||
function orderCard(order, opts) {
|
function orderCard(order, opts) {
|
||||||
const o = order; const zeigeBadges = opts?.badges;
|
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;' }, [
|
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: 'display:flex;gap:10px;align-items:flex-start;' }, [
|
||||||
h('div', { style: 'font-weight:700;font-size:15px;' }, `#${o.order_number}`),
|
h('input', { type: 'checkbox', checked: istGewaehlt(o.id), onChange: () => toggleAuswahl(o.id),
|
||||||
subdued(`${kundeName(o)}${o.customer?.email ? ' · ' + o.customer.email : ''}${o.customer?.phone ? ' · ' + o.customer.phone : ''}`, 'margin-top:2px;'),
|
title: 'Für Sammel-Druck auswählen', style: 'width:16px;height:16px;margin-top:3px;cursor:pointer;' }),
|
||||||
|
h('div', {}, [
|
||||||
|
h('div', { style: 'font-weight:700;font-size:15px;' }, `#${o.order_number}`),
|
||||||
|
subdued(`${kundeName(o)}${o.customer?.email ? ' · ' + o.customer.email : ''}${o.customer?.phone ? ' · ' + o.customer.phone : ''}`, 'margin-top:2px;'),
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
statusSelect(o),
|
statusSelect(o),
|
||||||
]);
|
]);
|
||||||
@@ -425,6 +695,7 @@ const Board = defineComponent({
|
|||||||
chip(`${o.entries_count || 0} Briefe`),
|
chip(`${o.entries_count || 0} Briefe`),
|
||||||
chip(o.needs_envelope ? `${o.entries_count || 0} Kuverts` : 'ohne Kuvert'),
|
chip(o.needs_envelope ? `${o.entries_count || 0} Kuverts` : 'ohne Kuvert'),
|
||||||
chip(o.shipping_type === 'einzeln' ? 'Einzelversand' : 'Sammelversand'),
|
chip(o.shipping_type === 'einzeln' ? 'Einzelversand' : 'Sammelversand'),
|
||||||
|
o.shipping_day ? chip('📅 Versandtag: ' + o.shipping_day.charAt(0).toUpperCase() + o.shipping_day.slice(1), 'var(--primary-25,rgba(30,80,165,.14))') : null,
|
||||||
chip(o.product?.name || 'Produkt?'),
|
chip(o.product?.name || 'Produkt?'),
|
||||||
chip(o.format?.name || o.format?.key || 'Format?'),
|
chip(o.format?.name || o.format?.key || 'Format?'),
|
||||||
chip(o.payment_status === 'bezahlt' ? '✓ bezahlt' : (o.payment_status === 'storniert' ? 'storniert' : 'offen'),
|
chip(o.payment_status === 'bezahlt' ? '✓ bezahlt' : (o.payment_status === 'storniert' ? 'storniert' : 'offen'),
|
||||||
@@ -458,24 +729,83 @@ const Board = defineComponent({
|
|||||||
return h('div', { key: 'r' + req.id, style: CARD + `margin-bottom:14px;border-left:4px solid ${istMuster ? FARBE_MUSTER : FARBE_ANGEBOT};` }, [kopf, meta, nachricht, aktionen]);
|
return h('div', { key: 'r' + req.id, style: CARD + `margin-bottom:14px;border-left:4px solid ${istMuster ? FARBE_MUSTER : FARBE_ANGEBOT};` }, [kopf, meta, nachricht, aktionen]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Charge-Karte: fasst API-Aufträge (Kunde+Typ+Versandtag+Eingangstag) zusammen.
|
||||||
|
function chargeCard(c) {
|
||||||
|
const auf = expandedCharges.value.has(c.key);
|
||||||
|
let faelligFarbe = 'var(--background-subdued)';
|
||||||
|
if (c.faellig) {
|
||||||
|
const tage = Math.ceil((c.faellig.getTime() - Date.now()) / 864e5);
|
||||||
|
faelligFarbe = tage < 0 ? 'var(--danger-25,rgba(198,40,40,.15))'
|
||||||
|
: tage <= 7 ? 'var(--warning-25,rgba(230,160,0,.18))' : 'var(--success-25,rgba(46,125,50,.12))';
|
||||||
|
}
|
||||||
|
const kopf = h('div', { style: 'display:flex;align-items:center;gap:10px;flex-wrap:wrap;' }, [
|
||||||
|
h('input', { type: 'checkbox', checked: chargeGewaehlt(c), onChange: () => toggleCharge(c),
|
||||||
|
title: 'Ganze Charge auswählen', style: 'width:16px;height:16px;cursor:pointer;flex-shrink:0;' }),
|
||||||
|
h('button', { type: 'button', onClick: () => toggleExpand(c.key),
|
||||||
|
style: 'cursor:pointer;border:none;background:transparent;font-size:15px;color:var(--foreground-normal);', title: 'Aufklappen' }, auf ? '▾' : '▸'),
|
||||||
|
h('div', { style: 'font-weight:700;font-size:15px;' }, `📦 ${c.kundeName} · ${c.produkt}`),
|
||||||
|
chip(`${c.orders.length} Aufträge`),
|
||||||
|
c.versandtag ? chip('📅 ' + c.versandtag.charAt(0).toUpperCase() + c.versandtag.slice(1)) : null,
|
||||||
|
c.faellig ? chip('⏰ fällig bis ' + dmY(c.faellig), faelligFarbe) : null,
|
||||||
|
]);
|
||||||
|
const body = auf ? h('div', { style: 'margin-top:12px;display:flex;flex-direction:column;gap:2px;' }, c.orders.map((o) => orderCard(o))) : null;
|
||||||
|
return h('div', { key: 'c' + c.key, style: CARD + `margin-bottom:14px;border-left:4px solid ${ACCENT};` }, [kopf, body]);
|
||||||
|
}
|
||||||
|
|
||||||
function tabInhalt() {
|
function tabInhalt() {
|
||||||
if (tab.value === 'neu') {
|
if (tab.value === 'neu') {
|
||||||
const items = [...neueOrders.value.map((o) => orderCard(o)), ...neueRequests.value.map(requestCard)];
|
// API-Aufträge zu Chargen bündeln; alles andere als Einzelkarten.
|
||||||
|
const apiOrders = neueOrders.value.filter((o) => o.source === 'api');
|
||||||
|
const restOrders = neueOrders.value.filter((o) => o.source !== 'api');
|
||||||
|
const items = [
|
||||||
|
...bildeChargen(apiOrders).map(chargeCard),
|
||||||
|
...restOrders.map((o) => orderCard(o)),
|
||||||
|
...neueRequests.value.map(requestCard),
|
||||||
|
];
|
||||||
return items.length ? items : subdued('Nichts Neues.');
|
return items.length ? items : subdued('Nichts Neues.');
|
||||||
}
|
}
|
||||||
if (tab.value === 'warteschlange') {
|
if (tab.value === 'warteschlange') {
|
||||||
const gruppen = queuedByMachine.value;
|
const gruppen = queuedByMachine.value;
|
||||||
if (!gruppen.length) return subdued('Warteschlange leer.');
|
if (!gruppen.length) return subdued('Warteschlange leer.');
|
||||||
return gruppen.map((g) => h('div', { style: 'margin-bottom:18px;' }, [
|
return gruppen.map((g) => {
|
||||||
h('div', { style: 'font-weight:700;margin-bottom:8px;' }, `🖨 ${g.machine.name}${g.machine.status ? ' · ' + g.machine.status : ''}`),
|
const m = g.machine;
|
||||||
...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;' }, [
|
const aktiv = (m.current_format || '').trim();
|
||||||
h('div', {}, [
|
// Jobs je Maschine nach Format bündeln (jede Format-Queue eigenständig).
|
||||||
h('div', { style: 'font-weight:600;' }, `#${j.order?.order_number || j.output_ref || j.id} · ${j.type}`),
|
const fmt = {};
|
||||||
subdued(`${j.numbers ? 'Nr. ' + j.numbers : 'alle'}${j.priority ? ' · Direkt' : ''}`),
|
for (const j of g.jobs) {
|
||||||
|
const key = j.format || '';
|
||||||
|
(fmt[key] || (fmt[key] = { key, label: j.format_label || j.format || 'Ohne Format', jobs: [] })).jobs.push(j);
|
||||||
|
}
|
||||||
|
const formatBlock = (fg) => {
|
||||||
|
const laeuft = !!aktiv && fg.key === aktiv;
|
||||||
|
const kopf = h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:8px;' }, [
|
||||||
|
h('div', { style: 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;' }, [
|
||||||
|
h('span', { style: `font-weight:700;font-size:14px;color:${ACCENT};` }, fg.label),
|
||||||
|
chip(`${fg.jobs.length} Job(s)`),
|
||||||
|
laeuft ? chip('▶ liegt ein – wird gedruckt', 'var(--success-25,rgba(46,125,50,.15))')
|
||||||
|
: (aktiv ? chip('⏸ wartet (anderes Format liegt ein)') : null),
|
||||||
|
]),
|
||||||
|
laeuft
|
||||||
|
? btn('⏹ Stapel stoppen', () => setMachineFormat(m, null), 'danger')
|
||||||
|
: btn('▶ Dieses Format liegt ein → starten', () => setMachineFormat(m, fg.key), 'primary'),
|
||||||
|
]);
|
||||||
|
const cards = fg.jobs.map((j) => h('div', { key: 'j' + j.id, style: CARD + 'margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;' }, [
|
||||||
|
h('div', {}, [
|
||||||
|
h('div', { style: 'font-weight:600;' }, `#${j.order?.order_number || j.output_ref || j.id} · ${j.type === 'umschlag' ? '✉ Kuvert' : '📄 Brief'}`),
|
||||||
|
subdued(`${j.numbers ? 'Nr. ' + j.numbers : 'alle'}${j.priority ? ' · Direkt' : ''}`),
|
||||||
|
]),
|
||||||
|
btn('Entfernen', () => cancelJob(j), 'danger'),
|
||||||
|
]));
|
||||||
|
return h('div', { style: SECTION }, [kopf, ...cards]);
|
||||||
|
};
|
||||||
|
return h('div', { style: 'margin-bottom:22px;' }, [
|
||||||
|
h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:8px;' }, [
|
||||||
|
h('div', { style: 'font-weight:700;' }, `🖨 ${m.name}${m.status ? ' · ' + m.status : ''}`),
|
||||||
|
aktiv ? subdued(`Eingelegtes Format: ${aktiv}`) : subdued('Kein Format eingelegt – bitte einen Stapel starten.'),
|
||||||
]),
|
]),
|
||||||
btn('Entfernen', () => cancelJob(j), 'danger'),
|
...Object.values(fmt).map(formatBlock),
|
||||||
])),
|
]);
|
||||||
]));
|
});
|
||||||
}
|
}
|
||||||
if (tab.value === 'druck') {
|
if (tab.value === 'druck') {
|
||||||
const list = ordersByStatus('im_druck');
|
const list = ordersByStatus('im_druck');
|
||||||
@@ -568,6 +898,49 @@ const Board = defineComponent({
|
|||||||
]);
|
]);
|
||||||
return overlay([modalKopf(`Drucken · #${druck.order.order_number}`, () => { druck.open = false; }), body, foot], () => { druck.open = false; }, '640px');
|
return overlay([modalKopf(`Drucken · #${druck.order.order_number}`, () => { druck.open = false; }), body, foot], () => { druck.open = false; }, '640px');
|
||||||
}
|
}
|
||||||
|
function bulkModal() {
|
||||||
|
if (!bulk.open) return null;
|
||||||
|
const gruppeRow = (g) => {
|
||||||
|
const tplOpts = templates.value.filter((t) => t.type === g.type);
|
||||||
|
return h('div', { style: SECTION + (g.aktiv ? '' : 'opacity:.5;') }, [
|
||||||
|
// Format-Auswahl: abgehakt = kommt in den Sammeldruck (z. B. nur Schriftstücke,
|
||||||
|
// Kuverts abwählen). Abgewählte Formate werden komplett übersprungen.
|
||||||
|
h('label', { style: 'display:flex;align-items:center;gap:8px;font-weight:700;margin-bottom:10px;cursor:pointer;' }, [
|
||||||
|
h('input', { type: 'checkbox', checked: g.aktiv, onChange: (e) => { g.aktiv = e.target.checked; }, style: 'width:16px;height:16px;' }),
|
||||||
|
`${g.type === 'umschlag' ? '✉' : '📄'} ${g.label}`,
|
||||||
|
]),
|
||||||
|
g.aktiv ? h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px;' }, [
|
||||||
|
h('div', {}, [
|
||||||
|
h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Template'),
|
||||||
|
h('select', { style: S_SELECT + 'width:100%;', value: g.template, onChange: (e) => { g.template = e.target.value; } },
|
||||||
|
tplOpts.length ? tplOpts.map((t) => h('option', { value: t.id, selected: String(g.template) === String(t.id) }, t.name)) : [h('option', { value: '' }, 'kein Template')]),
|
||||||
|
]),
|
||||||
|
h('div', {}, [
|
||||||
|
h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Maschine'),
|
||||||
|
h('select', { style: S_SELECT + 'width:100%;', value: g.machine, onChange: (e) => { g.machine = e.target.value; } },
|
||||||
|
aktiveMaschinen.value.length ? aktiveMaschinen.value.map((mm) => h('option', { value: mm.id, selected: String(g.machine) === String(mm.id) }, mm.name)) : [h('option', { value: '' }, 'keine Maschine')]),
|
||||||
|
]),
|
||||||
|
]) : null,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
const body = h('div', { style: 'padding:18px 20px;' }, [
|
||||||
|
subdued(`${auswahl.value.length} Auftrag/Aufträge · pro Format einmal Template und Maschine wählen.`, 'margin-bottom:14px;'),
|
||||||
|
...bulk.gruppen.map(gruppeRow),
|
||||||
|
h('div', { style: 'margin:6px 0 4px;' }, [
|
||||||
|
segToggle(bulk.mode, [
|
||||||
|
{ key: 'direct', label: '⚡ Direktdruck' },
|
||||||
|
{ key: 'queue', label: '⏳ Warteschlange' },
|
||||||
|
], (k) => { bulk.mode = k; }),
|
||||||
|
]),
|
||||||
|
bulk.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;margin-top:8px;' }, bulk.err) : null,
|
||||||
|
bulk.msg ? subdued(bulk.msg, 'margin-top:8px;') : null,
|
||||||
|
]);
|
||||||
|
const foot = h('div', { style: 'display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border-subdued);' }, [
|
||||||
|
btn('Abbrechen', () => { bulk.open = false; }),
|
||||||
|
btn('In Produktion geben', submitBulk, 'primary'),
|
||||||
|
]);
|
||||||
|
return overlay([modalKopf('Sammel-Druck', () => { bulk.open = false; }), body, foot], () => { bulk.open = false; }, '640px');
|
||||||
|
}
|
||||||
function musterModal() {
|
function musterModal() {
|
||||||
if (!manuell.open) return null;
|
if (!manuell.open) return null;
|
||||||
const briefTpls = templates.value.filter((t) => t.type === 'brief');
|
const briefTpls = templates.value.filter((t) => t.type === 'brief');
|
||||||
@@ -653,6 +1026,23 @@ const Board = defineComponent({
|
|||||||
h('div', { style: 'color:rgba(255,255,255,.7);font-size:12px;' }, 'Klicken zum Schließen'),
|
h('div', { style: 'color:rgba(255,255,255,.7);font-size:12px;' }, 'Klicken zum Schließen'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
function dateienModal() {
|
||||||
|
if (!dateien.open) return null;
|
||||||
|
const body = h('div', { style: 'padding:16px 20px;' }, [
|
||||||
|
subdued('Vom Kunden hochgeladene Original-Dokumente (keine generierten SVGs).', 'margin-bottom:12px;'),
|
||||||
|
dateien.laden ? subdued('Wird geladen …')
|
||||||
|
: dateien.err ? subdued(dateien.err)
|
||||||
|
: h('div', { style: 'display:flex;flex-direction:column;gap:8px;' }, dateien.items.map((it) =>
|
||||||
|
h('div', { style: CARD + 'display:flex;justify-content:space-between;align-items:center;gap:12px;' }, [
|
||||||
|
h('div', { style: 'min-width:0;' }, [
|
||||||
|
h('div', { style: 'font-weight:600;word-break:break-all;' }, it.name),
|
||||||
|
it.type ? subdued(it.type) : null,
|
||||||
|
]),
|
||||||
|
btn('⤓ Herunterladen', () => dlDatei(it), 'primary'),
|
||||||
|
]))),
|
||||||
|
]);
|
||||||
|
return overlay([modalKopf(`Dateien · #${dateien.order?.order_number || ''}`, () => { dateien.open = false; }), body], () => { dateien.open = false; }, '620px');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Gesamt-Render ─────────────────────────────────────────────────────
|
// ── Gesamt-Render ─────────────────────────────────────────────────────
|
||||||
// In <private-view> gehängt → normaler Directus-Content-Bereich + Kopf.
|
// In <private-view> gehängt → normaler Directus-Content-Bereich + Kopf.
|
||||||
@@ -672,18 +1062,34 @@ const Board = defineComponent({
|
|||||||
const kopfzeile = h('div', { style: 'display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:18px;' }, [
|
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 …',
|
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; } }),
|
style: S_INPUT + 'flex:1;min-width:240px;', onInput: (e) => { suche.value = e.target.value; } }),
|
||||||
|
sichtbareOrders().length ? btn(alleSichtbarGewaehlt() ? '☐ Auswahl aufheben' : '☑ Alle auswählen', alleAuswaehlen) : null,
|
||||||
btn('↻ Aktualisieren', laden),
|
btn('↻ Aktualisieren', laden),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Leicht getönte Inhaltsfläche → weiße Karten heben sich klar ab (wie im Referenz-Dashboard).
|
// Leicht getönte Inhaltsfläche → weiße Karten heben sich klar ab (wie im Referenz-Dashboard).
|
||||||
|
const bulkBar = auswahl.value.length ? h('div', { style: `display:flex;align-items:center;gap:12px;flex-wrap:wrap;background:${ACCENT};color:#fff;padding:10px 16px;border-radius:8px;margin-bottom:12px;` }, [
|
||||||
|
h('span', { style: 'font-weight:600;' }, `${auswahl.value.length} Auftrag/Aufträge ausgewählt`),
|
||||||
|
h('button', { type: 'button', onClick: openBulk, style: `cursor:pointer;border:none;background:#fff;color:${ACCENT};border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;font-weight:600;` }, '🖨 In Druck geben'),
|
||||||
|
h('button', { type: 'button', onClick: chargePdf, style: `cursor:pointer;border:none;background:#fff;color:${ACCENT};border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;font-weight:600;` }, '📄 PDFs drucken'),
|
||||||
|
h('select', {
|
||||||
|
style: `cursor:pointer;border:none;background:#fff;color:${ACCENT};border-radius:7px;padding:7px 12px;font:inherit;font-size:13px;font-weight:600;`,
|
||||||
|
value: '', onChange: (e) => { if (e.target.value) { bulkStatus(e.target.value); e.target.value = ''; } },
|
||||||
|
}, [
|
||||||
|
h('option', { value: '' }, 'Status setzen …'),
|
||||||
|
...Object.entries(STATUS_LABEL).map(([k, v]) => h('option', { value: k }, v)),
|
||||||
|
]),
|
||||||
|
h('button', { type: 'button', onClick: auswahlLeeren, style: 'cursor:pointer;border:1px solid rgba(255,255,255,.6);background:transparent;color:#fff;border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;' }, 'Auswahl aufheben'),
|
||||||
|
]) : null;
|
||||||
|
|
||||||
const inhalt = h('div', { style: 'padding:24px 28px;min-height:100%;background:var(--background-subdued,#f1f4f9);' }, [
|
const inhalt = h('div', { style: 'padding:24px 28px;min-height:100%;background:var(--background-subdued,#f1f4f9);' }, [
|
||||||
kopfzeile,
|
kopfzeile,
|
||||||
|
bulkBar,
|
||||||
meldung.value ? h('div', { style: 'background:var(--success-25,rgba(46,125,50,.15));color:var(--success,#2e7d32);padding:9px 14px;border-radius:8px;margin-bottom:12px;font-size:13px;' }, meldung.value) : null,
|
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,
|
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()),
|
loading.value ? subdued('Lädt …') : h('div', { style: 'display:flex;flex-direction:column;gap:2px;' }, tabInhalt()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const modale = [druckModal(), musterModal(), empfModal(), lightboxModal()].filter(Boolean);
|
const modale = [druckModal(), bulkModal(), musterModal(), empfModal(), dateienModal(), lightboxModal()].filter(Boolean);
|
||||||
|
|
||||||
return h(resolveComponent('private-view'), { title: 'Produktion' }, {
|
return h(resolveComponent('private-view'), { title: 'Produktion' }, {
|
||||||
navigation: () => navi,
|
navigation: () => navi,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.0",
|
"express": "^4.19.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Charge-Controller – Sammel-PDF für API-Chargen.
|
||||||
|
*
|
||||||
|
* Führt die hochgeladenen Brief-PDFs mehrerer Aufträge (Datei-Produkte, z. B.
|
||||||
|
* Unterschriften-Service) zu EINER PDF zusammen, die am Bürodrucker in einem
|
||||||
|
* Rutsch gedruckt werden kann. Reihenfolge = Auftragsnummer (aufsteigend), damit
|
||||||
|
* sie zur Kuvert-Reihenfolge im Plotter passt.
|
||||||
|
*/
|
||||||
|
const { PDFDocument } = require('pdf-lib');
|
||||||
|
const directus = require('../../services/directus-service');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/charge/merge-pdf
|
||||||
|
* Body: { orderIds: (number|string)[] } → application/pdf (zusammengeführt)
|
||||||
|
*/
|
||||||
|
async function mergePdf(req, res, next) {
|
||||||
|
try {
|
||||||
|
const ids = Array.isArray(req.body?.orderIds) ? req.body.orderIds.filter((v) => v != null) : [];
|
||||||
|
if (!ids.length) {
|
||||||
|
return res.status(400).json({ error: 'Invalid request', message: 'orderIds (array) is required.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const orders = await directus.fetchOrdersMinimal(ids);
|
||||||
|
if (!orders || !orders.length) {
|
||||||
|
return res.status(404).json({ error: 'Not found', message: 'Keine Aufträge gefunden.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datei-IDs in Auftragsnummern-Reihenfolge (orders ist bereits sortiert).
|
||||||
|
const fileIds = [];
|
||||||
|
for (const o of orders) {
|
||||||
|
const sf = Array.isArray(o.source_files) ? o.source_files.filter(Boolean) : [];
|
||||||
|
if (sf.length) fileIds.push(...sf);
|
||||||
|
else if (o.source_file) fileIds.push(o.source_file);
|
||||||
|
}
|
||||||
|
if (!fileIds.length) {
|
||||||
|
return res.status(400).json({ error: 'No files', message: 'Keine Druckdateien an den Aufträgen.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = await PDFDocument.create();
|
||||||
|
let ok = 0;
|
||||||
|
const skipped = [];
|
||||||
|
for (const fid of fileIds) {
|
||||||
|
try {
|
||||||
|
const buf = await directus.fetchAssetBuffer(fid);
|
||||||
|
const src = await PDFDocument.load(buf, { ignoreEncryption: true });
|
||||||
|
const pages = await merged.copyPages(src, src.getPageIndices());
|
||||||
|
pages.forEach((p) => merged.addPage(p));
|
||||||
|
ok += 1;
|
||||||
|
} catch (e) {
|
||||||
|
skipped.push(fid);
|
||||||
|
console.warn(`[Charge] PDF ${fid} übersprungen: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
return res.status(400).json({ error: 'Merge failed', message: 'Keine gültige PDF-Datei gefunden.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await merged.save();
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="charge.pdf"');
|
||||||
|
res.setHeader('X-Skrift-Merged', String(ok));
|
||||||
|
if (skipped.length) res.setHeader('X-Skrift-Skipped', String(skipped.length));
|
||||||
|
return res.status(200).send(Buffer.from(bytes));
|
||||||
|
} catch (err) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { mergePdf };
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
const { runGeneration } = require('./order-controller');
|
const { runGeneration } = require('./order-controller');
|
||||||
const directus = require('../../services/directus-service');
|
const directus = require('../../services/directus-service');
|
||||||
|
const { countLetterPages } = require('../../lib/svg-generator');
|
||||||
|
const { replacePlaceholders } = require('../../services/placeholder-service');
|
||||||
|
|
||||||
// Directus-Formatschlüssel → Backend-Formate
|
// Directus-Formatschlüssel → Backend-Formate
|
||||||
const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
|
const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
|
||||||
@@ -45,10 +47,28 @@ function placeholdersOf(entry) {
|
|||||||
/** Übersetzt einen Directus-Auftrag in die Dokumentliste für runGeneration. */
|
/** Übersetzt einen Directus-Auftrag in die Dokumentliste für runGeneration. */
|
||||||
function buildDocuments(order, entries) {
|
function buildDocuments(order, entries) {
|
||||||
const font = order.font || 'tilda';
|
const font = order.font || 'tilda';
|
||||||
const letterFormat = LETTER_FORMAT[order.format?.key] || 'a4';
|
// Eigene Format-Schlüssel (freie Formate) unverändert durchreichen.
|
||||||
|
const letterFormat = LETTER_FORMAT[order.format?.key] || order.format?.key || 'a4';
|
||||||
const envFormat = ENVELOPE_FORMAT[order.envelope_format] || 'c6';
|
const envFormat = ENVELOPE_FORMAT[order.envelope_format] || 'c6';
|
||||||
const wantsEnvelope = !!order.needs_envelope && order.envelope_labeling !== 'keine';
|
const wantsEnvelope = !!order.needs_envelope && order.envelope_labeling !== 'keine';
|
||||||
const envelopeType = order.envelope_labeling === 'freitext' ? 'custom' : 'recipient';
|
const envelopeType = order.envelope_labeling === 'freitext' ? 'custom' : 'recipient';
|
||||||
|
// Datei-Produkte (z. B. Unterschriftenservice): das „Schriftstück" ist das vom
|
||||||
|
// Kunden hochgeladene Dokument – es wird NICHT generiert. Kuverts hingegen schon.
|
||||||
|
const istDatei = order.product?.input_mode === 'datei';
|
||||||
|
// Kuvert-only-Produkt: es wird KEIN Schriftstück generiert, nur die (adressierten)
|
||||||
|
// Kuverts. Etwaige hochgeladene PDF-Briefe liegen als source_files am Auftrag.
|
||||||
|
const istKuvertOnly = order.product?.type === 'envelope';
|
||||||
|
// Natürliche Handschrift-Variation (Default an), am Auftrag gespeichert.
|
||||||
|
const realistisch = order.realistic !== false;
|
||||||
|
// Relative Schriftgröße (getrennt Brief/Kuvert), aus dem Auftrag. 1 = Standard.
|
||||||
|
const fontScaleLetter = Number(order.font_scale_letter) || 1;
|
||||||
|
const fontScaleEnv = Number(order.font_scale_envelope) || 1;
|
||||||
|
// Signatur(en) auf dem Brief (manuelle Aufträge): SVG je [[signaturN]] + Feinjustierung.
|
||||||
|
const sigManual = order.signatures_manual && typeof order.signatures_manual === 'object' ? order.signatures_manual : null;
|
||||||
|
const sigMap = sigManual?.signatures || {};
|
||||||
|
const sigOff = sigManual?.offsets || {};
|
||||||
|
// Mehrseitiges Schriftstück (Auto-Fluss über N Seiten) – nur wenn am Auftrag gesetzt.
|
||||||
|
const istMehrseitig = !!order.multi_page;
|
||||||
|
|
||||||
const documents = [];
|
const documents = [];
|
||||||
|
|
||||||
@@ -58,24 +78,35 @@ function buildDocuments(order, entries) {
|
|||||||
// Briefnummer 1 gehört. Vorschau nummeriert identisch (siehe skrift-orders).
|
// Briefnummer 1 gehört. Vorschau nummeriert identisch (siehe skrift-orders).
|
||||||
const nr = Number(entry.letter_number) || (i + 1);
|
const nr = Number(entry.letter_number) || (i + 1);
|
||||||
|
|
||||||
documents.push({
|
if (!istDatei && !istKuvertOnly) {
|
||||||
index: nr,
|
// Abweichender Text je Schriftstück (#5) hat Vorrang; sonst Standardtext.
|
||||||
type: 'letter',
|
const briefText = (entry.text && String(entry.text).trim()) ? entry.text : (order.text_template || '');
|
||||||
text: order.text_template || '',
|
documents.push({
|
||||||
format: letterFormat,
|
index: nr,
|
||||||
font,
|
type: 'letter',
|
||||||
placeholders,
|
text: briefText,
|
||||||
});
|
format: letterFormat,
|
||||||
|
font,
|
||||||
|
placeholders,
|
||||||
|
fontScale: fontScaleLetter,
|
||||||
|
signatures: sigMap,
|
||||||
|
signatureOffsets: sigOff,
|
||||||
|
multiPage: istMehrseitig,
|
||||||
|
realisticHandwriting: realistisch,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (wantsEnvelope) {
|
if (wantsEnvelope) {
|
||||||
documents.push({
|
documents.push({
|
||||||
index: nr,
|
index: nr,
|
||||||
type: 'envelope',
|
type: 'envelope',
|
||||||
envelopeType,
|
envelopeType,
|
||||||
text: envelopeType === 'recipient' ? addressText(entry) : (order.text_template || ''),
|
text: envelopeType === 'recipient' ? addressText(entry) : (order.envelope_text || order.text_template || ''),
|
||||||
format: envFormat,
|
format: envFormat,
|
||||||
font,
|
font,
|
||||||
placeholders,
|
placeholders,
|
||||||
|
fontScale: fontScaleEnv,
|
||||||
|
realisticHandwriting: realistisch,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -115,6 +146,12 @@ async function generateFromDirectus(req, res, next) {
|
|||||||
|
|
||||||
const { documents, letterFormat, envFormat, wantsEnvelope } = buildDocuments(order, loaded.entries);
|
const { documents, letterFormat, envFormat, wantsEnvelope } = buildDocuments(order, loaded.entries);
|
||||||
|
|
||||||
|
// Nichts zu generieren (z. B. Datei-Produkt ohne Kuvert) → sauber überspringen.
|
||||||
|
if (!documents.length) {
|
||||||
|
console.log(`[Directus] ${order.order_number}: nichts zu generieren (Datei-Produkt ohne Kuvert).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[Directus] Generiere ${order.order_number}: ${documents.length} Dokumente`);
|
console.log(`[Directus] Generiere ${order.order_number}: ${documents.length} Dokumente`);
|
||||||
|
|
||||||
const result = await runGeneration({
|
const result = await runGeneration({
|
||||||
@@ -162,4 +199,27 @@ async function generateFromDirectus(req, res, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { generateFromDirectus };
|
/**
|
||||||
|
* POST /api/order/pagecount
|
||||||
|
* Body: { text, format, fontScale, entries: [{ placeholders }] }
|
||||||
|
* Liefert je Dokument die Seitenzahl (nur Umbruch-Zählung, kein Scriptalizer/SVG).
|
||||||
|
*/
|
||||||
|
function pageCounts(req, res) {
|
||||||
|
const { text, format, fontScale, entries } = req.body || {};
|
||||||
|
if (!text || !String(text).trim()) {
|
||||||
|
return res.status(400).json({ error: 'Invalid request', message: 'text is required' });
|
||||||
|
}
|
||||||
|
const fmt = format || 'a4';
|
||||||
|
const scale = Number(fontScale) || 1;
|
||||||
|
const list = Array.isArray(entries) && entries.length ? entries : [{}];
|
||||||
|
const counts = list.map((e, i) => {
|
||||||
|
const ph = { ...(e && typeof e.placeholders === 'object' ? e.placeholders : {}), Briefnummer: String(i + 1) };
|
||||||
|
let pages = 1;
|
||||||
|
try { pages = countLetterPages(replacePlaceholders(String(text), ph), fmt, { fontScale: scale }); }
|
||||||
|
catch { pages = 1; }
|
||||||
|
return { index: i + 1, pages };
|
||||||
|
});
|
||||||
|
return res.json({ counts });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generateFromDirectus, pageCounts };
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const fsSync = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const config = require('../../config');
|
const config = require('../../config');
|
||||||
const { scriptalizeBatch } = require('../../services/scriptalizer-service');
|
const { scriptalizeBatch } = require('../../services/scriptalizer-service');
|
||||||
const { generateLetterSVG, generateEnvelopeSVG } = require('../../lib/svg-generator');
|
const { generateLetterSVG, generateEnvelopeSVG, generateLetterPages } = require('../../lib/svg-generator');
|
||||||
const { replacePlaceholders, generateAllCSVs } = require('../../services/placeholder-service');
|
const { replacePlaceholders, generateAllCSVs } = require('../../services/placeholder-service');
|
||||||
const { TAB_MARKER } = require('../../lib/svg-font-engine');
|
const { TAB_MARKER } = require('../../lib/svg-font-engine');
|
||||||
|
|
||||||
@@ -188,7 +188,8 @@ async function runGeneration({ orderNumber, letters, metadata }) {
|
|||||||
index: letter.index !== undefined ? letter.index : loopIndex,
|
index: letter.index !== undefined ? letter.index : loopIndex,
|
||||||
loopIndex,
|
loopIndex,
|
||||||
processedText: text,
|
processedText: text,
|
||||||
signatures: sigPlaceholders,
|
// Signaturen aus Platzhaltern UND direkt übergebene (manuelle Aufträge).
|
||||||
|
signatures: { ...(letter.signatures || {}), ...sigPlaceholders },
|
||||||
format: letter.format || 'a4',
|
format: letter.format || 'a4',
|
||||||
font: letter.font || 'tilda',
|
font: letter.font || 'tilda',
|
||||||
type: letter.type || 'letter',
|
type: letter.type || 'letter',
|
||||||
@@ -219,7 +220,7 @@ async function runGeneration({ orderNumber, letters, metadata }) {
|
|||||||
|
|
||||||
for (const letter of fontLetters) {
|
for (const letter of fontLetters) {
|
||||||
const raw = letter.processedText;
|
const raw = letter.processedText;
|
||||||
const parts = raw.split(/(\t|\[\[signatur\d+\]\]|[&|])/gi);
|
const parts = raw.split(/(\t|\[\[signatur\d+\]\]|\[\[seitenumbruch\]\]|[&|])/gi);
|
||||||
const segments = [], markers = [];
|
const segments = [], markers = [];
|
||||||
for (let i = 0; i < parts.length; i++) {
|
for (let i = 0; i < parts.length; i++) {
|
||||||
if (i % 2 === 0) segments.push(parts[i]);
|
if (i % 2 === 0) segments.push(parts[i]);
|
||||||
@@ -271,7 +272,42 @@ async function runGeneration({ orderNumber, letters, metadata }) {
|
|||||||
const letter = processedLetters[i];
|
const letter = processedLetters[i];
|
||||||
const absoluteIndex = letter.index !== undefined ? letter.index : i;
|
const absoluteIndex = letter.index !== undefined ? letter.index : i;
|
||||||
const isEnv = letter.type === 'envelope';
|
const isEnv = letter.type === 'envelope';
|
||||||
const filename = `${isEnv ? 'envelope' : 'letter'}_${String(absoluteIndex).padStart(3, '0')}.svg`;
|
const pad = String(absoluteIndex).padStart(3, '0');
|
||||||
|
|
||||||
|
// Mehrseitiger Brief → N Seiten-Dateien letter_NNN_pM.svg (Seite 1..N).
|
||||||
|
if (!isEnv && letter.multiPage) {
|
||||||
|
const scriptalizedText = scriptalizedMap.get(letter.loopIndex) || letter.processedText;
|
||||||
|
let pages;
|
||||||
|
try {
|
||||||
|
({ pages } = generateLetterPages(scriptalizedText, letter.format, {
|
||||||
|
font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
const e = new Error(`Failed to generate SVG for letter ${i}: ${err.message}`);
|
||||||
|
e.status = 500; e.label = 'SVG generation failed'; throw e;
|
||||||
|
}
|
||||||
|
// Entweder ALLE Seiten aus der Vorschau übernehmen oder ALLE neu rendern –
|
||||||
|
// nicht mischen, sonst unterschiedliche Handschrift-Variation je Seite.
|
||||||
|
let alleInVorschau = false;
|
||||||
|
if (previewSession) {
|
||||||
|
alleInVorschau = true;
|
||||||
|
for (let p = 0; p < pages.length; p++) {
|
||||||
|
try { await fs.access(path.join(previewsRoot, previewSession, `letter_${pad}_p${p + 1}.svg`)); }
|
||||||
|
catch { alleInVorschau = false; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let p = 0; p < pages.length; p++) {
|
||||||
|
const fn = `letter_${pad}_p${p + 1}.svg`;
|
||||||
|
const fp = path.join(outputDir, fn);
|
||||||
|
if (alleInVorschau) await fs.copyFile(path.join(previewsRoot, previewSession, fn), fp);
|
||||||
|
else await fs.writeFile(fp, pages[p], 'utf8');
|
||||||
|
generatedLetters.push(fn);
|
||||||
|
}
|
||||||
|
console.log(`[Order] letter_${pad}: ${pages.length} Seiten${alleInVorschau ? ' (aus Vorschau)' : ''}.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `${isEnv ? 'envelope' : 'letter'}_${pad}.svg`;
|
||||||
const filepath = isEnv ? path.join(envelopesDir, filename) : path.join(outputDir, filename);
|
const filepath = isEnv ? path.join(envelopesDir, filename) : path.join(outputDir, filename);
|
||||||
|
|
||||||
// 1:1 aus der Vorschau übernehmen, wenn für dieses Dokument eine
|
// 1:1 aus der Vorschau übernehmen, wenn für dieses Dokument eine
|
||||||
@@ -293,12 +329,12 @@ async function runGeneration({ orderNumber, letters, metadata }) {
|
|||||||
if (isEnv) {
|
if (isEnv) {
|
||||||
svgResult = generateEnvelopeSVG(
|
svgResult = generateEnvelopeSVG(
|
||||||
scriptalizedText, letter.format, letter.envelopeType,
|
scriptalizedText, letter.format, letter.envelopeType,
|
||||||
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false }
|
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
svgResult = generateLetterSVG(
|
svgResult = generateLetterSVG(
|
||||||
scriptalizedText, letter.format,
|
scriptalizedText, letter.format,
|
||||||
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, signatures: letter.signatures || {} }
|
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, signatures: letter.signatures || {}, signatureOffsets: letter.signatureOffsets || {}, fontScale: letter.fontScale }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { scriptalizeBatch } = require("../../services/scriptalizer-service");
|
|||||||
const {
|
const {
|
||||||
generateLetterSVG,
|
generateLetterSVG,
|
||||||
generateEnvelopeSVG,
|
generateEnvelopeSVG,
|
||||||
|
generateLetterPages,
|
||||||
} = require("../../lib/svg-generator");
|
} = require("../../lib/svg-generator");
|
||||||
const {
|
const {
|
||||||
replacePlaceholders,
|
replacePlaceholders,
|
||||||
@@ -75,7 +76,8 @@ async function generateBatch(req, res, next) {
|
|||||||
...letter,
|
...letter,
|
||||||
index: letter.index !== undefined ? letter.index : loopIndex,
|
index: letter.index !== undefined ? letter.index : loopIndex,
|
||||||
processedText: text,
|
processedText: text,
|
||||||
signatures: sigPlaceholders,
|
// Signaturen aus Platzhaltern UND direkt mitgeschickte (manuelle Aufträge).
|
||||||
|
signatures: { ...(letter.signatures || {}), ...sigPlaceholders },
|
||||||
format: letter.format || "a4",
|
format: letter.format || "a4",
|
||||||
font: letter.font || "tilda",
|
font: letter.font || "tilda",
|
||||||
type: letter.type || "letter",
|
type: letter.type || "letter",
|
||||||
@@ -99,7 +101,7 @@ async function generateBatch(req, res, next) {
|
|||||||
for (const letter of processedLetters) {
|
for (const letter of processedLetters) {
|
||||||
const raw = letter.processedText;
|
const raw = letter.processedText;
|
||||||
// Splitten an \t UND [[signaturN]] (mit capture-Gruppe → Marker bleiben erhalten)
|
// Splitten an \t UND [[signaturN]] (mit capture-Gruppe → Marker bleiben erhalten)
|
||||||
const parts = raw.split(/(\t|\[\[signatur\d+\]\]|[&|])/gi);
|
const parts = raw.split(/(\t|\[\[signatur\d+\]\]|\[\[seitenumbruch\]\]|[&|])/gi);
|
||||||
const segments = [], markers = [];
|
const segments = [], markers = [];
|
||||||
for (let i = 0; i < parts.length; i++) {
|
for (let i = 0; i < parts.length; i++) {
|
||||||
if (i % 2 === 0) segments.push(parts[i]);
|
if (i % 2 === 0) segments.push(parts[i]);
|
||||||
@@ -157,63 +159,62 @@ async function generateBatch(req, res, next) {
|
|||||||
for (let i = 0; i < processedLetters.length; i++) {
|
for (let i = 0; i < processedLetters.length; i++) {
|
||||||
const letter = processedLetters[i];
|
const letter = processedLetters[i];
|
||||||
const scriptalizedText = scriptalizedTexts[i] || letter.processedText;
|
const scriptalizedText = scriptalizedTexts[i] || letter.processedText;
|
||||||
|
const absoluteIndex = letter.index !== undefined ? letter.index : i;
|
||||||
let svgResult;
|
const pad = String(absoluteIndex).padStart(3, "0");
|
||||||
|
const recipientName = letter.placeholders?.name || letter.placeholders?.vorname || `Brief ${absoluteIndex + 1}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (letter.type === "envelope") {
|
if (letter.type === "envelope") {
|
||||||
svgResult = generateEnvelopeSVG(
|
const svgResult = generateEnvelopeSVG(
|
||||||
scriptalizedText,
|
scriptalizedText, letter.format, letter.envelopeType,
|
||||||
letter.format,
|
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale }
|
||||||
letter.envelopeType,
|
|
||||||
{ font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false }
|
|
||||||
);
|
);
|
||||||
|
if (svgResult.overflow) hasOverflow = true;
|
||||||
|
const filename = `envelope_${pad}.svg`;
|
||||||
|
await fs.writeFile(path.join(sessionDir, filename), svgResult.svg, "utf8");
|
||||||
|
files.push({
|
||||||
|
index: absoluteIndex, filename, url: `/api/preview/${finalSessionId}/${filename}`,
|
||||||
|
lineCount: svgResult.lineCount, lineLimit: svgResult.lineLimit, overflow: svgResult.overflow,
|
||||||
|
recipientName: null,
|
||||||
|
});
|
||||||
|
} else if (letter.multiPage) {
|
||||||
|
// Mehrseitiger Brief: Text fließt über N Seiten – kein Überlauf-Fehler.
|
||||||
|
const { pages } = generateLetterPages(scriptalizedText, letter.format, {
|
||||||
|
font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false, fontScale: letter.fontScale,
|
||||||
|
});
|
||||||
|
for (let p = 0; p < pages.length; p++) {
|
||||||
|
const filename = `letter_${pad}_p${p + 1}.svg`;
|
||||||
|
await fs.writeFile(path.join(sessionDir, filename), pages[p], "utf8");
|
||||||
|
files.push({
|
||||||
|
index: absoluteIndex, page: p + 1, pageCount: pages.length, filename,
|
||||||
|
url: `/api/preview/${finalSessionId}/${filename}`, recipientName,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Briefe: Zeileninfo mit zurückgeben
|
const svgResult = generateLetterSVG(scriptalizedText, letter.format, {
|
||||||
svgResult = generateLetterSVG(scriptalizedText, letter.format, {
|
font: letter.font, realisticHandwriting: letter.realisticHandwriting ?? false,
|
||||||
font: letter.font,
|
signatures: letter.signatures || {}, signatureOffsets: letter.signatureOffsets || {},
|
||||||
realisticHandwriting: letter.realisticHandwriting ?? false,
|
fontScale: letter.fontScale,
|
||||||
signatures: letter.signatures || {},
|
});
|
||||||
|
if (svgResult.overflow) {
|
||||||
|
hasOverflow = true;
|
||||||
|
console.log(`[Preview] Letter ${i} has overflow: ${svgResult.lineCount}/${svgResult.lineLimit} lines`);
|
||||||
|
}
|
||||||
|
const filename = `letter_${pad}.svg`;
|
||||||
|
await fs.writeFile(path.join(sessionDir, filename), svgResult.svg, "utf8");
|
||||||
|
files.push({
|
||||||
|
index: absoluteIndex, filename, url: `/api/preview/${finalSessionId}/${filename}`,
|
||||||
|
lineCount: svgResult.lineCount, lineLimit: svgResult.lineLimit, overflow: svgResult.overflow,
|
||||||
|
recipientName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(`[Preview] SVG generation failed for letter ${i}:`, err.message);
|
||||||
`[Preview] SVG generation failed for letter ${i}:`,
|
|
||||||
err.message
|
|
||||||
);
|
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
error: "SVG generation failed",
|
error: "SVG generation failed",
|
||||||
message: `Failed to generate SVG for letter ${i}: ${err.message}`,
|
message: `Failed to generate SVG for letter ${i}: ${err.message}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track overflow
|
|
||||||
if (svgResult.overflow) {
|
|
||||||
hasOverflow = true;
|
|
||||||
console.log(`[Preview] Letter ${i} has overflow: ${svgResult.lineCount}/${svgResult.lineLimit} lines`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save SVG file - verwende den absoluten Index aus letter
|
|
||||||
// Unterscheide zwischen Briefen und Umschlägen im Dateinamen
|
|
||||||
const absoluteIndex = letter.index !== undefined ? letter.index : i;
|
|
||||||
const prefix = letter.type === 'envelope' ? 'envelope' : 'letter';
|
|
||||||
const filename = `${prefix}_${String(absoluteIndex).padStart(3, "0")}.svg`;
|
|
||||||
const filepath = path.join(sessionDir, filename);
|
|
||||||
|
|
||||||
await fs.writeFile(filepath, svgResult.svg, "utf8");
|
|
||||||
|
|
||||||
// Empfänger-Info für bessere Fehlermeldung
|
|
||||||
const recipientName = letter.placeholders?.name || letter.placeholders?.vorname || `Brief ${absoluteIndex + 1}`;
|
|
||||||
|
|
||||||
files.push({
|
|
||||||
index: absoluteIndex,
|
|
||||||
filename,
|
|
||||||
url: `/api/preview/${finalSessionId}/${filename}`,
|
|
||||||
lineCount: svgResult.lineCount,
|
|
||||||
lineLimit: svgResult.lineLimit,
|
|
||||||
overflow: svgResult.overflow,
|
|
||||||
recipientName: letter.type !== 'envelope' ? recipientName : null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Preview] Generated ${files.length} SVG files`);
|
console.log(`[Preview] Generated ${files.length} SVG files`);
|
||||||
|
|||||||
13
Docker/skrift-backend/src/api/routes/charge-routes.js
Normal file
13
Docker/skrift-backend/src/api/routes/charge-routes.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const chargeController = require('../controllers/charge-controller');
|
||||||
|
const { authenticateApiToken } = require('../middleware/auth');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/charge/merge-pdf
|
||||||
|
* Führt die Brief-PDFs mehrerer Aufträge zu einer PDF zusammen (Sammeldruck).
|
||||||
|
* Body: { orderIds: (number|string)[] }
|
||||||
|
*/
|
||||||
|
router.post('/merge-pdf', authenticateApiToken, chargeController.mergePdf);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -144,4 +144,11 @@ router.post('/register', authenticateApiToken, orderController.registerOrder);
|
|||||||
*/
|
*/
|
||||||
router.post('/from-directus', authenticateApiToken, directusController.generateFromDirectus);
|
router.post('/from-directus', authenticateApiToken, directusController.generateFromDirectus);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/order/pagecount
|
||||||
|
* Seitenzahl je Dokument (mehrseitig) – ohne Scriptalizer/SVG, nur Umbruch-Zählung.
|
||||||
|
* Body: { text, format, fontScale, entries: [{ placeholders }] }
|
||||||
|
*/
|
||||||
|
router.post('/pagecount', authenticateApiToken, directusController.pageCounts);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,70 +1,91 @@
|
|||||||
const PX_PER_MM = 3.78; // 96dpi
|
const PX_PER_MM = 3.78; // 96dpi
|
||||||
const FONT_SIZE_PX = 26;
|
const FONT_SIZE_PX = 26;
|
||||||
|
|
||||||
// Maximale Zeilenanzahl pro Format (für Textvalidierung)
|
// ── Formate: Defaults (Fallback) + datengetriebener Cache ───────────────────
|
||||||
const LINE_LIMITS = {
|
// Die Werte kommen zentral aus Directus (Collection render_formats) und werden
|
||||||
a4: 27,
|
// im Speicher gehalten (stündlich / manuell aufgefrischt via applyRenderFormats).
|
||||||
a6p: 14,
|
// Ist Directus nicht erreichbar oder ein Format nicht hinterlegt, greifen die
|
||||||
a6l: 11,
|
// eingebauten Defaults – die Generierung läuft also immer.
|
||||||
|
const DEFAULT_LINE_LIMITS = { a4: 27, a6p: 14, a6l: 11 };
|
||||||
|
const DEFAULT_FORMAT_LAYOUTS = {
|
||||||
|
a4: { marginTopMm: 25, marginLeftMm: 20, lineHeightFactor: 1.35 },
|
||||||
|
a6p: { marginTopMm: 8, marginLeftMm: 8, lineHeightFactor: 1.3 },
|
||||||
|
a6l: { marginTopMm: 8, marginLeftMm: 8, marginRightMm: 14, lineHeightFactor: 1.2 },
|
||||||
|
din_lang: { marginTopMm: 50, marginLeftMm: 10, lineHeightFactor: 1.2 },
|
||||||
|
c6: { marginTopMm: 60, marginLeftMm: 10, lineHeightFactor: 1.2 },
|
||||||
};
|
};
|
||||||
|
const DEFAULT_PAGE_FORMATS = {
|
||||||
// Layout Konfiguration pro Format + Orientation
|
|
||||||
const FORMAT_LAYOUTS = {
|
|
||||||
// A4 Hochformat
|
|
||||||
a4: {
|
|
||||||
marginTopMm: 25,
|
|
||||||
marginLeftMm: 20,
|
|
||||||
lineHeightFactor: 1.35,
|
|
||||||
},
|
|
||||||
|
|
||||||
// A6 Hochformat
|
|
||||||
a6p: {
|
|
||||||
marginTopMm: 8,
|
|
||||||
marginLeftMm: 8,
|
|
||||||
lineHeightFactor: 1.3,
|
|
||||||
},
|
|
||||||
|
|
||||||
// A6 Querformat (landscape)
|
|
||||||
a6l: {
|
|
||||||
marginTopMm: 8,
|
|
||||||
marginLeftMm: 8,
|
|
||||||
marginRightMm: 14,
|
|
||||||
lineHeightFactor: 1.2,
|
|
||||||
},
|
|
||||||
|
|
||||||
// DIN Lang Kuvert (Querformat)
|
|
||||||
din_lang: {
|
|
||||||
marginTopMm: 50,
|
|
||||||
marginLeftMm: 10, // Links unten in Ecke
|
|
||||||
lineHeightFactor: 1.2,
|
|
||||||
},
|
|
||||||
|
|
||||||
// C6 Kuvert (Querformat)
|
|
||||||
c6: {
|
|
||||||
marginTopMm: 60,
|
|
||||||
marginLeftMm: 10, // Links unten in Ecke
|
|
||||||
lineHeightFactor: 1.2,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Seiten-Dimensionen
|
|
||||||
const PAGE_FORMATS = {
|
|
||||||
a4: { widthMm: 210, heightMm: 297 },
|
a4: { widthMm: 210, heightMm: 297 },
|
||||||
a6p: { widthMm: 105, heightMm: 148 },
|
a6p: { widthMm: 105, heightMm: 148 },
|
||||||
a6l: { widthMm: 148, heightMm: 105 }, // Querformat: getauscht
|
a6l: { widthMm: 148, heightMm: 105 },
|
||||||
din_lang: { widthMm: 220, heightMm: 110 },
|
din_lang: { widthMm: 220, heightMm: 110 },
|
||||||
c6: { widthMm: 162, heightMm: 114 },
|
c6: { widthMm: 162, heightMm: 114 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Live-Cache (wird von applyRenderFormats ersetzt). Start = Defaults.
|
||||||
|
let LINE_LIMITS = { ...DEFAULT_LINE_LIMITS };
|
||||||
|
let FORMAT_LAYOUTS = { ...DEFAULT_FORMAT_LAYOUTS };
|
||||||
|
let PAGE_FORMATS = { ...DEFAULT_PAGE_FORMATS };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übernimmt Format-Definitionen aus Directus (render_formats) in den Cache.
|
||||||
|
* Baut immer auf den Defaults auf, damit Teilangaben nichts kaputt machen.
|
||||||
|
* rows: [{ key, page_width_mm, page_height_mm, margin_top_mm, margin_left_mm,
|
||||||
|
* margin_right_mm, line_height_factor, line_limit }]
|
||||||
|
*/
|
||||||
|
function applyRenderFormats(rows) {
|
||||||
|
const pf = { ...DEFAULT_PAGE_FORMATS };
|
||||||
|
const fl = { ...DEFAULT_FORMAT_LAYOUTS };
|
||||||
|
const ll = { ...DEFAULT_LINE_LIMITS };
|
||||||
|
for (const r of Array.isArray(rows) ? rows : []) {
|
||||||
|
const k = String(r.key || '').toLowerCase();
|
||||||
|
if (!k) continue;
|
||||||
|
if (r.page_width_mm && r.page_height_mm) {
|
||||||
|
pf[k] = { widthMm: Number(r.page_width_mm), heightMm: Number(r.page_height_mm) };
|
||||||
|
}
|
||||||
|
const alt = fl[k] || {};
|
||||||
|
const layout = {
|
||||||
|
marginTopMm: Number(r.margin_top_mm ?? alt.marginTopMm ?? 25),
|
||||||
|
marginLeftMm: Number(r.margin_left_mm ?? alt.marginLeftMm ?? 20),
|
||||||
|
lineHeightFactor: Number(r.line_height_factor ?? alt.lineHeightFactor ?? 1.35),
|
||||||
|
};
|
||||||
|
const mr = r.margin_right_mm ?? alt.marginRightMm;
|
||||||
|
if (mr != null && mr !== '') layout.marginRightMm = Number(mr);
|
||||||
|
fl[k] = layout;
|
||||||
|
if (r.line_limit != null && r.line_limit !== '') ll[k] = Number(r.line_limit);
|
||||||
|
}
|
||||||
|
PAGE_FORMATS = pf;
|
||||||
|
FORMAT_LAYOUTS = fl;
|
||||||
|
LINE_LIMITS = ll;
|
||||||
|
signaledMisses.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// mm in Pixel umrechnen
|
// mm in Pixel umrechnen
|
||||||
function mmToPx(mm) {
|
function mmToPx(mm) {
|
||||||
return mm * PX_PER_MM;
|
return mm * PX_PER_MM;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Refresh bei unbekanntem Format ──────────────────────────────────────────
|
||||||
|
// Wird ein Format angefragt, das (noch) nicht im Cache liegt, meldet page-layout
|
||||||
|
// das einmalig an einen Handler (den der Server auf refreshRenderFormats setzt).
|
||||||
|
// So werden neu in Directus angelegte Formate sofort nachgeladen, ohne die
|
||||||
|
// stündliche Aktualisierung abzuwarten. Für DIESEN Aufruf greift noch der
|
||||||
|
// Default – der nächste Aufruf hat das Format.
|
||||||
|
let missHandler = null;
|
||||||
|
const signaledMisses = new Set();
|
||||||
|
function setFormatMissHandler(fn) { missHandler = fn; }
|
||||||
|
function signalMiss(key) {
|
||||||
|
const k = String(key || '').toLowerCase();
|
||||||
|
if (!missHandler || !k || signaledMisses.has(k)) return;
|
||||||
|
signaledMisses.add(k);
|
||||||
|
try { missHandler(k); } catch { /* egal */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Layout für Format holen
|
// Layout für Format holen
|
||||||
function getLayoutForFormat(format) {
|
function getLayoutForFormat(format) {
|
||||||
const normalizedFormat = String(format).toLowerCase();
|
const normalizedFormat = String(format).toLowerCase();
|
||||||
const layoutConfig = FORMAT_LAYOUTS[normalizedFormat];
|
const layoutConfig = FORMAT_LAYOUTS[normalizedFormat];
|
||||||
|
if (!layoutConfig) signalMiss(normalizedFormat);
|
||||||
|
|
||||||
if (!layoutConfig) {
|
if (!layoutConfig) {
|
||||||
// Fallback auf a4
|
// Fallback auf a4
|
||||||
@@ -86,6 +107,7 @@ function getLayoutForFormat(format) {
|
|||||||
// Seiten-Dimensionen holen
|
// Seiten-Dimensionen holen
|
||||||
function getPageDimensions(format) {
|
function getPageDimensions(format) {
|
||||||
const normalizedFormat = String(format).toLowerCase();
|
const normalizedFormat = String(format).toLowerCase();
|
||||||
|
if (!PAGE_FORMATS[normalizedFormat]) signalMiss(normalizedFormat);
|
||||||
const dimensions = PAGE_FORMATS[normalizedFormat] || PAGE_FORMATS.a4;
|
const dimensions = PAGE_FORMATS[normalizedFormat] || PAGE_FORMATS.a4;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -107,18 +129,18 @@ function escapeXml(str) {
|
|||||||
// Zeilenlimit für Format holen
|
// Zeilenlimit für Format holen
|
||||||
function getLineLimit(format) {
|
function getLineLimit(format) {
|
||||||
const normalizedFormat = String(format).toLowerCase();
|
const normalizedFormat = String(format).toLowerCase();
|
||||||
|
if (!LINE_LIMITS[normalizedFormat]) signalMiss(normalizedFormat);
|
||||||
return LINE_LIMITS[normalizedFormat] || LINE_LIMITS.a4;
|
return LINE_LIMITS[normalizedFormat] || LINE_LIMITS.a4;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
PX_PER_MM,
|
PX_PER_MM,
|
||||||
FONT_SIZE_PX,
|
FONT_SIZE_PX,
|
||||||
FORMAT_LAYOUTS,
|
|
||||||
PAGE_FORMATS,
|
|
||||||
LINE_LIMITS,
|
|
||||||
mmToPx,
|
mmToPx,
|
||||||
getLayoutForFormat,
|
getLayoutForFormat,
|
||||||
getPageDimensions,
|
getPageDimensions,
|
||||||
getLineLimit,
|
getLineLimit,
|
||||||
escapeXml
|
escapeXml,
|
||||||
|
applyRenderFormats,
|
||||||
|
setFormatMissHandler,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,8 +78,10 @@ const TAB_MARKER = '';
|
|||||||
/**
|
/**
|
||||||
* Breite einer Textzeile in Pixeln messen.
|
* Breite einer Textzeile in Pixeln messen.
|
||||||
* Unterstützt TAB_MARKER: springt zur nächsten Tabstop-Position (6 × fontSizePx).
|
* Unterstützt TAB_MARKER: springt zur nächsten Tabstop-Position (6 × fontSizePx).
|
||||||
|
* maxWidthPx (optional) begrenzt den Tab-Sprung auf den rechten Rand, damit viele
|
||||||
|
* Tabs nicht über die Seite hinauslaufen. Ohne Angabe (Infinity) unverändert.
|
||||||
*/
|
*/
|
||||||
function measureTextWidthPx({ text, fontFileName, fontSizePx }) {
|
function measureTextWidthPx({ text, fontFileName, fontSizePx, maxWidthPx = Infinity }) {
|
||||||
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
|
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
|
||||||
const scale = fontSizePx / unitsPerEm;
|
const scale = fontSizePx / unitsPerEm;
|
||||||
const tabStopPx = fontSizePx * 6;
|
const tabStopPx = fontSizePx * 6;
|
||||||
@@ -88,7 +90,7 @@ function measureTextWidthPx({ text, fontFileName, fontSizePx }) {
|
|||||||
|
|
||||||
for (const ch of String(text || '')) {
|
for (const ch of String(text || '')) {
|
||||||
if (ch === TAB_MARKER) {
|
if (ch === TAB_MARKER) {
|
||||||
x = Math.ceil((x + 1) / tabStopPx) * tabStopPx;
|
x = Math.min(Math.ceil((x + 1) / tabStopPx) * tabStopPx, maxWidthPx);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +128,19 @@ function wrapTextToLinesByWidth({
|
|||||||
measureTextWidthPx({ text: t, fontFileName, fontSizePx });
|
measureTextWidthPx({ text: t, fontFileName, fontSizePx });
|
||||||
|
|
||||||
for (const para of paragraphs) {
|
for (const para of paragraphs) {
|
||||||
|
// Führende Tabs = bewusste Positionierung der Zeile (z.B. Datum nach rechts).
|
||||||
|
// Solche Zeilen NICHT nach Wortbreite umbrechen, sonst würde der Text als
|
||||||
|
// "zu breit" gewertet und die Wörter fielen nach links auf eine neue Zeile.
|
||||||
|
// Der eigentliche Versatz (bzw. Rechtsbündigkeit bei Überlauf) passiert im Layout.
|
||||||
|
const lead = para.match(new RegExp(`^${TAB_MARKER}+`));
|
||||||
|
if (lead) {
|
||||||
|
const rest = para.slice(lead[0].length);
|
||||||
|
if (!rest.includes(TAB_MARKER) && measure(rest) <= maxWidthPx) {
|
||||||
|
allLines.push(para);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tokens: Wörter und TAB_MARKER getrennt ermitteln.
|
// Tokens: Wörter und TAB_MARKER getrennt ermitteln.
|
||||||
// TAB_MARKER durch einen Platzhalter ersetzen, mit \s+ splitten, dann zurücktauschen.
|
// TAB_MARKER durch einen Platzhalter ersetzen, mit \s+ splitten, dann zurücktauschen.
|
||||||
const TAB_PLACEHOLDER = '';
|
const TAB_PLACEHOLDER = '';
|
||||||
@@ -208,6 +223,7 @@ function layoutTextToPaths({
|
|||||||
startY,
|
startY,
|
||||||
lineHeightPx,
|
lineHeightPx,
|
||||||
realisticHandwriting = false,
|
realisticHandwriting = false,
|
||||||
|
maxWidthPx = Infinity,
|
||||||
}) {
|
}) {
|
||||||
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
|
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
|
||||||
|
|
||||||
@@ -223,13 +239,32 @@ function layoutTextToPaths({
|
|||||||
|
|
||||||
const tabStopPx = fontSizePx * 6;
|
const tabStopPx = fontSizePx * 6;
|
||||||
|
|
||||||
for (let i = 0; i < line.length; i++) {
|
// Führende Tabs vorab auflösen: normalerweise an die Tab-Position rücken;
|
||||||
|
// würde der restliche Zeileninhalt dort über den rechten Rand laufen, die
|
||||||
|
// Zeile stattdessen rechtsbündig an den Rand setzen (z.B. Datum ganz rechts,
|
||||||
|
// ohne dass etwas daneben steht). Nur aktiv, wenn maxWidthPx bekannt ist.
|
||||||
|
let startIdx = 0;
|
||||||
|
if (line[0] === TAB_MARKER) {
|
||||||
|
let leadCount = 0;
|
||||||
|
while (line[leadCount] === TAB_MARKER) leadCount++;
|
||||||
|
let tx = 0;
|
||||||
|
for (let t = 0; t < leadCount; t++) {
|
||||||
|
tx = Math.min(Math.ceil((tx + 1) / tabStopPx) * tabStopPx, maxWidthPx);
|
||||||
|
}
|
||||||
|
const rest = line.slice(leadCount);
|
||||||
|
const restW = measureTextWidthPx({ text: rest, fontFileName, fontSizePx, maxWidthPx });
|
||||||
|
if (tx + restW > maxWidthPx) tx = Math.max(0, maxWidthPx - restW);
|
||||||
|
x = startX + tx;
|
||||||
|
startIdx = leadCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = startIdx; i < line.length; i++) {
|
||||||
const ch = line[i];
|
const ch = line[i];
|
||||||
|
|
||||||
// Tabulator-Marker → zur nächsten Tabstop-Position springen
|
// Tabulator-Marker → zur nächsten Tabstop-Position springen (an den Rand geclampt)
|
||||||
if (ch === TAB_MARKER) {
|
if (ch === TAB_MARKER) {
|
||||||
const relX = x - startX;
|
const relX = x - startX;
|
||||||
const nextTab = Math.ceil((relX + 1) / tabStopPx) * tabStopPx;
|
const nextTab = Math.min(Math.ceil((relX + 1) / tabStopPx) * tabStopPx, maxWidthPx);
|
||||||
x = startX + nextTab;
|
x = startX + nextTab;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ const {
|
|||||||
getLayoutForFormat,
|
getLayoutForFormat,
|
||||||
getPageDimensions,
|
getPageDimensions,
|
||||||
getLineLimit,
|
getLineLimit,
|
||||||
escapeXml
|
escapeXml,
|
||||||
|
mmToPx
|
||||||
} = require('./page-layout');
|
} = require('./page-layout');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
layoutTextToPaths,
|
layoutTextToPaths,
|
||||||
wrapTextToLinesByWidth,
|
wrapTextToLinesByWidth,
|
||||||
measureTextWidthPx
|
measureTextWidthPx,
|
||||||
|
TAB_MARKER
|
||||||
} = require('./svg-font-engine');
|
} = require('./svg-font-engine');
|
||||||
|
|
||||||
// Höhe einer Signatur in Seiten-Pixeln (Signatur-Canvas war 600×160; wir reservieren 160px * scale)
|
// Höhe einer Signatur in Seiten-Pixeln (Signatur-Canvas war 600×160; wir reservieren 160px * scale)
|
||||||
@@ -65,11 +67,22 @@ function generateLetterSVG(text, format, options = {}) {
|
|||||||
const fontKey = normalizeFontKey(options.font);
|
const fontKey = normalizeFontKey(options.font);
|
||||||
const fontConfig = SVG_FONT_CONFIG[fontKey];
|
const fontConfig = SVG_FONT_CONFIG[fontKey];
|
||||||
const signatures = options.signatures || {};
|
const signatures = options.signatures || {};
|
||||||
|
// Feinjustierung je Signatur: { signatur1: { x: mm, y: mm, scale: Faktor } }.
|
||||||
|
const signatureOffsets = options.signatureOffsets || {};
|
||||||
|
// Einseitig: Seitenumbruch-Marker gibt es hier nicht – als Zeilenumbruch behandeln,
|
||||||
|
// damit er nie wörtlich gedruckt wird (falls „Mehrseitig" nicht aktiv ist).
|
||||||
|
text = String(text || '').replace(/\[\[seitenumbruch\]\]/gi, '\n');
|
||||||
|
|
||||||
const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format);
|
const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format);
|
||||||
const { marginTopPx, marginLeftPx, marginRightPx, lineHeightPx } = getLayoutForFormat(format);
|
const { marginTopPx, marginLeftPx, marginRightPx, lineHeightPx: baseLineHeightPx } = getLayoutForFormat(format);
|
||||||
const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx);
|
const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx);
|
||||||
|
|
||||||
|
// Relative Schriftgröße (nur manuelle Aufträge setzen ≠ 1). Schrift UND Zeilenhöhe
|
||||||
|
// skalieren mit, damit sich Zeilen nicht überlappen. Default 1 = unverändert.
|
||||||
|
const fontScale = Math.max(0.8, Math.min(1.5, Number(options.fontScale) || 1));
|
||||||
|
const fontSizePx = FONT_SIZE_PX * fontScale;
|
||||||
|
const lineHeightPx = baseLineHeightPx * fontScale;
|
||||||
|
|
||||||
// ── Signatur-Marker aus Text extrahieren ──────────────────────────────────
|
// ── Signatur-Marker aus Text extrahieren ──────────────────────────────────
|
||||||
const sigMarkerRe = /\[\[(signatur\d+)\]\]/gi;
|
const sigMarkerRe = /\[\[(signatur\d+)\]\]/gi;
|
||||||
const hasSigMarkers = sigMarkerRe.test(String(text || ''));
|
const hasSigMarkers = sigMarkerRe.test(String(text || ''));
|
||||||
@@ -80,18 +93,19 @@ function generateLetterSVG(text, format, options = {}) {
|
|||||||
text: String(text || ''),
|
text: String(text || ''),
|
||||||
maxWidthPx,
|
maxWidthPx,
|
||||||
fontFileName: fontConfig.file,
|
fontFileName: fontConfig.file,
|
||||||
fontSizePx: FONT_SIZE_PX,
|
fontSizePx,
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstBaselineY = marginTopPx + FONT_SIZE_PX;
|
const firstBaselineY = marginTopPx + fontSizePx;
|
||||||
const paths = layoutTextToPaths({
|
const paths = layoutTextToPaths({
|
||||||
lines,
|
lines,
|
||||||
fontFileName: fontConfig.file,
|
fontFileName: fontConfig.file,
|
||||||
fontSizePx: FONT_SIZE_PX,
|
fontSizePx,
|
||||||
startX: marginLeftPx,
|
startX: marginLeftPx,
|
||||||
startY: firstBaselineY,
|
startY: firstBaselineY,
|
||||||
lineHeightPx,
|
lineHeightPx,
|
||||||
realisticHandwriting: options.realisticHandwriting ?? false,
|
realisticHandwriting: options.realisticHandwriting ?? false,
|
||||||
|
maxWidthPx,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pathElements = paths
|
const pathElements = paths
|
||||||
@@ -102,7 +116,8 @@ function generateLetterSVG(text, format, options = {}) {
|
|||||||
vector-effect="non-scaling-stroke"/>`)
|
vector-effect="non-scaling-stroke"/>`)
|
||||||
.join('\n ');
|
.join('\n ');
|
||||||
|
|
||||||
const lineLimit = getLineLimit(format);
|
// Größere Schrift → weniger Zeilen passen aufs Blatt (Limit gegenläufig skalieren).
|
||||||
|
const lineLimit = Math.max(1, Math.round(getLineLimit(format) / fontScale));
|
||||||
return {
|
return {
|
||||||
svg: `<svg xmlns="http://www.w3.org/2000/svg"
|
svg: `<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
width="${widthMm}mm" height="${heightMm}mm"
|
width="${widthMm}mm" height="${heightMm}mm"
|
||||||
@@ -142,9 +157,9 @@ function generateLetterSVG(text, format, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allSvgElements = [];
|
const allSvgElements = [];
|
||||||
let currentY = marginTopPx + FONT_SIZE_PX;
|
let currentY = marginTopPx + fontSizePx;
|
||||||
let totalLineCount = 0;
|
let totalLineCount = 0;
|
||||||
const lineLimit = getLineLimit(format);
|
const lineLimit = Math.max(1, Math.round(getLineLimit(format) / fontScale));
|
||||||
|
|
||||||
let si = 0;
|
let si = 0;
|
||||||
while (si < segments.length) {
|
while (si < segments.length) {
|
||||||
@@ -157,16 +172,17 @@ function generateLetterSVG(text, format, options = {}) {
|
|||||||
text: seg.content,
|
text: seg.content,
|
||||||
maxWidthPx,
|
maxWidthPx,
|
||||||
fontFileName: fontConfig.file,
|
fontFileName: fontConfig.file,
|
||||||
fontSizePx: FONT_SIZE_PX,
|
fontSizePx,
|
||||||
});
|
});
|
||||||
const paths = layoutTextToPaths({
|
const paths = layoutTextToPaths({
|
||||||
lines,
|
lines,
|
||||||
fontFileName: fontConfig.file,
|
fontFileName: fontConfig.file,
|
||||||
fontSizePx: FONT_SIZE_PX,
|
fontSizePx,
|
||||||
startX: marginLeftPx,
|
startX: marginLeftPx,
|
||||||
startY: currentY,
|
startY: currentY,
|
||||||
lineHeightPx,
|
lineHeightPx,
|
||||||
realisticHandwriting: options.realisticHandwriting ?? false,
|
realisticHandwriting: options.realisticHandwriting ?? false,
|
||||||
|
maxWidthPx,
|
||||||
});
|
});
|
||||||
paths.forEach(p => allSvgElements.push(
|
paths.forEach(p => allSvgElements.push(
|
||||||
`<path d="${escapeXml(p.d)}" transform="${p.transform}"
|
`<path d="${escapeXml(p.d)}" transform="${p.transform}"
|
||||||
@@ -188,8 +204,13 @@ function generateLetterSVG(text, format, options = {}) {
|
|||||||
sigRow.forEach((sigSeg, slotIdx) => {
|
sigRow.forEach((sigSeg, slotIdx) => {
|
||||||
const svgString = signatures[sigSeg.key] || null;
|
const svgString = signatures[sigSeg.key] || null;
|
||||||
if (svgString) {
|
if (svgString) {
|
||||||
const sigX = marginLeftPx + slotIdx * sigSlotW;
|
// Feinjustierung: Versatz in mm + Größen-Faktor relativ zur Auto-Position.
|
||||||
const sigEl = buildSignatureElement(svgString, sigX, currentY, sigScale);
|
const off = signatureOffsets[sigSeg.key] || {};
|
||||||
|
const dx = mmToPx(Number(off.x) || 0);
|
||||||
|
const dy = mmToPx(Number(off.y) || 0);
|
||||||
|
const sc = Number(off.scale) > 0 ? Number(off.scale) : 1;
|
||||||
|
const sigX = marginLeftPx + slotIdx * sigSlotW + dx;
|
||||||
|
const sigEl = buildSignatureElement(svgString, sigX, currentY + dy, sigScale * sc);
|
||||||
if (sigEl) allSvgElements.push(sigEl);
|
if (sigEl) allSvgElements.push(sigEl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -223,7 +244,12 @@ function generateEnvelopeSVG(text, format, type, options = {}) {
|
|||||||
const fontConfig = SVG_FONT_CONFIG[fontKey];
|
const fontConfig = SVG_FONT_CONFIG[fontKey];
|
||||||
|
|
||||||
const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format);
|
const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format);
|
||||||
const { marginTopPx, marginLeftPx, lineHeightPx } = getLayoutForFormat(format);
|
const { marginTopPx, marginLeftPx, lineHeightPx: baseLineHeightPx } = getLayoutForFormat(format);
|
||||||
|
|
||||||
|
// Relative Schriftgröße (getrennt vom Brief einstellbar). Default 1 = unverändert.
|
||||||
|
const fontScale = Math.max(0.8, Math.min(1.5, Number(options.fontScale) || 1));
|
||||||
|
const fontSizePx = FONT_SIZE_PX * fontScale;
|
||||||
|
const lineHeightPx = baseLineHeightPx * fontScale;
|
||||||
|
|
||||||
let lines;
|
let lines;
|
||||||
let startX;
|
let startX;
|
||||||
@@ -237,12 +263,12 @@ function generateEnvelopeSVG(text, format, type, options = {}) {
|
|||||||
text: String(text || ''),
|
text: String(text || ''),
|
||||||
maxWidthPx,
|
maxWidthPx,
|
||||||
fontFileName: fontConfig.file,
|
fontFileName: fontConfig.file,
|
||||||
fontSizePx: FONT_SIZE_PX,
|
fontSizePx,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Zentrierung berechnen
|
// Zentrierung berechnen
|
||||||
const totalTextHeight = lines.length * lineHeightPx;
|
const totalTextHeight = lines.length * lineHeightPx;
|
||||||
startY = (heightPx - totalTextHeight) / 2 + FONT_SIZE_PX;
|
startY = (heightPx - totalTextHeight) / 2 + fontSizePx;
|
||||||
|
|
||||||
// Jede Zeile einzeln zentrieren
|
// Jede Zeile einzeln zentrieren
|
||||||
// startX wird später pro Zeile berechnet
|
// startX wird später pro Zeile berechnet
|
||||||
@@ -256,11 +282,11 @@ function generateEnvelopeSVG(text, format, type, options = {}) {
|
|||||||
text: String(text || ''),
|
text: String(text || ''),
|
||||||
maxWidthPx,
|
maxWidthPx,
|
||||||
fontFileName: fontConfig.file,
|
fontFileName: fontConfig.file,
|
||||||
fontSizePx: FONT_SIZE_PX,
|
fontSizePx,
|
||||||
});
|
});
|
||||||
|
|
||||||
startX = marginLeftPx;
|
startX = marginLeftPx;
|
||||||
startY = marginTopPx + FONT_SIZE_PX;
|
startY = marginTopPx + fontSizePx;
|
||||||
}
|
}
|
||||||
|
|
||||||
let paths = [];
|
let paths = [];
|
||||||
@@ -272,14 +298,14 @@ function generateEnvelopeSVG(text, format, type, options = {}) {
|
|||||||
const lineWidth = measureTextWidthPx({
|
const lineWidth = measureTextWidthPx({
|
||||||
text: line,
|
text: line,
|
||||||
fontFileName: fontConfig.file,
|
fontFileName: fontConfig.file,
|
||||||
fontSizePx: FONT_SIZE_PX
|
fontSizePx
|
||||||
});
|
});
|
||||||
const centeredX = (widthPx - lineWidth) / 2;
|
const centeredX = (widthPx - lineWidth) / 2;
|
||||||
|
|
||||||
const linePaths = layoutTextToPaths({
|
const linePaths = layoutTextToPaths({
|
||||||
lines: [line],
|
lines: [line],
|
||||||
fontFileName: fontConfig.file,
|
fontFileName: fontConfig.file,
|
||||||
fontSizePx: FONT_SIZE_PX,
|
fontSizePx,
|
||||||
startX: centeredX,
|
startX: centeredX,
|
||||||
startY: currentY,
|
startY: currentY,
|
||||||
lineHeightPx,
|
lineHeightPx,
|
||||||
@@ -294,7 +320,7 @@ function generateEnvelopeSVG(text, format, type, options = {}) {
|
|||||||
paths = layoutTextToPaths({
|
paths = layoutTextToPaths({
|
||||||
lines,
|
lines,
|
||||||
fontFileName: fontConfig.file,
|
fontFileName: fontConfig.file,
|
||||||
fontSizePx: FONT_SIZE_PX,
|
fontSizePx,
|
||||||
startX,
|
startX,
|
||||||
startY,
|
startY,
|
||||||
lineHeightPx,
|
lineHeightPx,
|
||||||
@@ -326,16 +352,110 @@ function generateEnvelopeSVG(text, format, type, options = {}) {
|
|||||||
|
|
||||||
// Overflow-Erkennung: zu viele Zeilen für die verfügbare Fläche
|
// Overflow-Erkennung: zu viele Zeilen für die verfügbare Fläche
|
||||||
const overflowStartY = (type === 'custom')
|
const overflowStartY = (type === 'custom')
|
||||||
? (heightPx - lines.length * lineHeightPx) / 2 + FONT_SIZE_PX
|
? (heightPx - lines.length * lineHeightPx) / 2 + fontSizePx
|
||||||
: marginTopPx + FONT_SIZE_PX;
|
: marginTopPx + fontSizePx;
|
||||||
const available = heightPx - overflowStartY - FONT_SIZE_PX * 0.5;
|
const available = heightPx - overflowStartY - fontSizePx * 0.5;
|
||||||
const lineLimit = Math.max(1, Math.floor(available / lineHeightPx) + 1);
|
const lineLimit = Math.max(1, Math.floor(available / lineHeightPx) + 1);
|
||||||
const lineCount = lines.length;
|
const lineCount = lines.length;
|
||||||
|
|
||||||
return { svg: svg.trim(), overflow: lineCount > lineLimit, lineCount, lineLimit };
|
return { svg: svg.trim(), overflow: lineCount > lineLimit, lineCount, lineLimit };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mehrseitiges Schriftstück: Der Text fließt automatisch über so viele Seiten,
|
||||||
|
* wie nötig (linesPerPage = Zeilenlimit des Formats). Ein erzwungener Umbruch ist
|
||||||
|
* über den Marker [[seitenumbruch]] möglich – jeder so getrennte Block beginnt auf
|
||||||
|
* einer neuen Seite. Gibt die Seiten in natürlicher Reihenfolge (Seite 1..N) zurück.
|
||||||
|
*
|
||||||
|
* Bewusst OHNE Signatur-Unterstützung (kommt als eigener Schritt). Tab-/Rechts-
|
||||||
|
* bündig-Logik und relative Schriftgröße greifen pro Seite wie beim Einzelbrief.
|
||||||
|
*/
|
||||||
|
function generateLetterPages(text, format, options = {}) {
|
||||||
|
const fontKey = normalizeFontKey(options.font);
|
||||||
|
const fontConfig = SVG_FONT_CONFIG[fontKey];
|
||||||
|
|
||||||
|
const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format);
|
||||||
|
const { marginTopPx, marginLeftPx, marginRightPx, lineHeightPx: baseLineHeightPx } = getLayoutForFormat(format);
|
||||||
|
const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx);
|
||||||
|
|
||||||
|
const fontScale = Math.max(0.8, Math.min(1.5, Number(options.fontScale) || 1));
|
||||||
|
const fontSizePx = FONT_SIZE_PX * fontScale;
|
||||||
|
const lineHeightPx = baseLineHeightPx * fontScale;
|
||||||
|
const linesPerPage = Math.max(1, Math.round(getLineLimit(format) / fontScale));
|
||||||
|
|
||||||
|
// Erzwungene Seitenumbrüche zuerst; danach jeden Block nach Zeilen auto-fließen.
|
||||||
|
const bloecke = String(text || '').split(/\[\[seitenumbruch\]\]/gi);
|
||||||
|
const seitenZeilen = [];
|
||||||
|
for (const block of bloecke) {
|
||||||
|
const lines = wrapTextToLinesByWidth({ text: block, maxWidthPx, fontFileName: fontConfig.file, fontSizePx });
|
||||||
|
for (let i = 0; i < lines.length; i += linesPerPage) {
|
||||||
|
seitenZeilen.push(lines.slice(i, i + linesPerPage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seitenZeilen.length === 0) seitenZeilen.push(['']);
|
||||||
|
|
||||||
|
// Leere Seiten verwerfen: enthält eine Seite kein sichtbares Zeichen (nur
|
||||||
|
// Leerzeichen, Tabs, Umbrüche), wird sie weder generiert noch gedruckt.
|
||||||
|
// Bleibt nichts übrig (komplett leerer Text), eine einzige Seite behalten.
|
||||||
|
const istLeer = (lines) => lines.join('').split(TAB_MARKER).join('').replace(/\s/g, '') === '';
|
||||||
|
let seiten = seitenZeilen.filter((lines) => !istLeer(lines));
|
||||||
|
if (seiten.length === 0) seiten = [['']];
|
||||||
|
|
||||||
|
const pages = seiten.map((lines) => {
|
||||||
|
const paths = layoutTextToPaths({
|
||||||
|
lines,
|
||||||
|
fontFileName: fontConfig.file,
|
||||||
|
fontSizePx,
|
||||||
|
startX: marginLeftPx,
|
||||||
|
startY: marginTopPx + fontSizePx,
|
||||||
|
lineHeightPx,
|
||||||
|
realisticHandwriting: options.realisticHandwriting ?? false,
|
||||||
|
maxWidthPx,
|
||||||
|
});
|
||||||
|
const pathElements = paths
|
||||||
|
.map(p => `<path d="${escapeXml(p.d)}"
|
||||||
|
transform="${p.transform}"
|
||||||
|
stroke="#000000" stroke-width="0.6" fill="none"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
vector-effect="non-scaling-stroke"/>`)
|
||||||
|
.join('\n ');
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="${widthMm}mm" height="${heightMm}mm"
|
||||||
|
viewBox="0 0 ${widthPx} ${heightPx}">
|
||||||
|
<rect x="0" y="0" width="${widthPx}" height="${heightPx}" fill="#FFFFFF"/>
|
||||||
|
${pathElements}
|
||||||
|
</svg>`.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { pages, pageCount: pages.length, lineCount: seiten.reduce((a, l) => a + l.length, 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zählt, wie viele Seiten ein Text ergäbe (gleiche Logik wie generateLetterPages,
|
||||||
|
* aber ohne SVG/Scriptalizer) – für die Seiten-Übersicht je Schriftstück.
|
||||||
|
*/
|
||||||
|
function countLetterPages(text, format, options = {}) {
|
||||||
|
const fontConfig = SVG_FONT_CONFIG[normalizeFontKey(options.font)];
|
||||||
|
const { widthPx } = getPageDimensions(format);
|
||||||
|
const { marginLeftPx, marginRightPx } = getLayoutForFormat(format);
|
||||||
|
const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx);
|
||||||
|
const fontScale = Math.max(0.8, Math.min(1.5, Number(options.fontScale) || 1));
|
||||||
|
const fontSizePx = FONT_SIZE_PX * fontScale;
|
||||||
|
const linesPerPage = Math.max(1, Math.round(getLineLimit(format) / fontScale));
|
||||||
|
|
||||||
|
const seitenZeilen = [];
|
||||||
|
for (const block of String(text || '').split(/\[\[seitenumbruch\]\]/gi)) {
|
||||||
|
const lines = wrapTextToLinesByWidth({ text: block, maxWidthPx, fontFileName: fontConfig.file, fontSizePx });
|
||||||
|
for (let i = 0; i < lines.length; i += linesPerPage) seitenZeilen.push(lines.slice(i, i + linesPerPage));
|
||||||
|
}
|
||||||
|
const istLeer = (lines) => lines.join('').split(TAB_MARKER).join('').replace(/\s/g, '') === '';
|
||||||
|
const seiten = seitenZeilen.filter((lines) => !istLeer(lines));
|
||||||
|
return Math.max(1, seiten.length);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
generateLetterSVG,
|
generateLetterSVG,
|
||||||
generateEnvelopeSVG
|
generateEnvelopeSVG,
|
||||||
|
generateLetterPages,
|
||||||
|
countLetterPages
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,11 +4,37 @@ const config = require('./config');
|
|||||||
|
|
||||||
const previewRoutes = require('./api/routes/preview-routes');
|
const previewRoutes = require('./api/routes/preview-routes');
|
||||||
const orderRoutes = require('./api/routes/order-routes');
|
const orderRoutes = require('./api/routes/order-routes');
|
||||||
|
const chargeRoutes = require('./api/routes/charge-routes');
|
||||||
const healthRoutes = require('./api/routes/health-routes');
|
const healthRoutes = require('./api/routes/health-routes');
|
||||||
|
|
||||||
const errorHandler = require('./api/middleware/error-handler');
|
const errorHandler = require('./api/middleware/error-handler');
|
||||||
const requestLogger = require('./api/middleware/request-logger');
|
const requestLogger = require('./api/middleware/request-logger');
|
||||||
|
|
||||||
|
const { applyRenderFormats, setFormatMissHandler } = require('./lib/page-layout');
|
||||||
|
const { fetchRenderFormats } = require('./services/directus-service');
|
||||||
|
|
||||||
|
// Zentrale Format-Definitionen aus Directus in den Cache holen. Läuft beim Start
|
||||||
|
// und stündlich; Fehler sind unkritisch (die Defaults im page-layout greifen).
|
||||||
|
let lastFormatRefresh = 0;
|
||||||
|
async function refreshRenderFormats() {
|
||||||
|
lastFormatRefresh = Date.now();
|
||||||
|
try {
|
||||||
|
const rows = await fetchRenderFormats();
|
||||||
|
applyRenderFormats(rows);
|
||||||
|
console.log(`[Formate] ${Array.isArray(rows) ? rows.length : 0} Format(e) aus Directus geladen.`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Formate] Konnte Directus-Formate nicht laden (nutze Defaults): ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbekanntes Format angefragt → sofort nachladen (max. alle 30 s, damit ein
|
||||||
|
// dauerhaft unbekanntes Format kein Dauerfeuer auslöst).
|
||||||
|
setFormatMissHandler((key) => {
|
||||||
|
if (Date.now() - lastFormatRefresh < 30 * 1000) return;
|
||||||
|
console.log(`[Formate] Unbekanntes Format "${key}" angefragt – lade Formate nach.`);
|
||||||
|
refreshRenderFormats();
|
||||||
|
});
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
@@ -21,6 +47,17 @@ app.use(requestLogger);
|
|||||||
app.use('/health', healthRoutes);
|
app.use('/health', healthRoutes);
|
||||||
app.use('/api/preview', previewRoutes);
|
app.use('/api/preview', previewRoutes);
|
||||||
app.use('/api/order', orderRoutes);
|
app.use('/api/order', orderRoutes);
|
||||||
|
app.use('/api/charge', chargeRoutes);
|
||||||
|
|
||||||
|
// Format-Cache manuell auffrischen (Token-geschützt) – für „sofort übernehmen".
|
||||||
|
app.post('/api/formats/refresh', async (req, res) => {
|
||||||
|
const token = config.auth && config.auth.apiToken;
|
||||||
|
if (token && req.headers['x-api-token'] !== token) {
|
||||||
|
return res.status(401).json({ error: 'API token required' });
|
||||||
|
}
|
||||||
|
await refreshRenderFormats();
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
@@ -39,6 +76,9 @@ app.listen(config.port, () => {
|
|||||||
console.log('════════════════════════════════════════════════════════════');
|
console.log('════════════════════════════════════════════════════════════');
|
||||||
console.log(`Server running at http://localhost:${config.port}`);
|
console.log(`Server running at http://localhost:${config.port}`);
|
||||||
console.log('════════════════════════════════════════════════════════════\n');
|
console.log('════════════════════════════════════════════════════════════\n');
|
||||||
|
// Formate initial laden und stündlich auffrischen (zentral aus Directus).
|
||||||
|
refreshRenderFormats();
|
||||||
|
setInterval(refreshRenderFormats, 60 * 60 * 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
|
|||||||
@@ -41,9 +41,10 @@ async function directusFetch(path, options = {}) {
|
|||||||
async function fetchOrder(orderId) {
|
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_text', 'envelope_format',
|
||||||
'text_template', 'motif_mode', 'own_cards', 'entries_count', 'preview_session',
|
'text_template', 'motif_mode', 'own_cards', 'entries_count', 'preview_session',
|
||||||
'source_file', 'product.key', 'product.type', 'product.input_mode', 'format.key',
|
'source_file', 'product.key', 'product.type', 'product.input_mode', 'format.key',
|
||||||
|
'font_scale_letter', 'font_scale_envelope', 'signatures_manual', 'multi_page', 'realistic',
|
||||||
].join(',');
|
].join(',');
|
||||||
|
|
||||||
const order = await directusFetch(`/items/orders/${encodeURIComponent(orderId)}?fields=${fields}`);
|
const order = await directusFetch(`/items/orders/${encodeURIComponent(orderId)}?fields=${fields}`);
|
||||||
@@ -77,4 +78,36 @@ async function addStatusHistory(orderId, status, note) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { fetchOrder, updateOrder, addStatusHistory };
|
/** Lädt mehrere Aufträge (nur die für den Charge-Sammeldruck nötigen Felder),
|
||||||
|
* sortiert nach Auftragsnummer. */
|
||||||
|
async function fetchOrdersMinimal(ids) {
|
||||||
|
const idList = ids.map((v) => encodeURIComponent(v)).join(',');
|
||||||
|
return directusFetch(
|
||||||
|
`/items/orders?filter[id][_in]=${idList}&limit=-1&sort=order_number` +
|
||||||
|
'&fields=id,order_number,source_files,source_file',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lädt eine Directus-Datei (z. B. hochgeladenes Brief-PDF) als Buffer. */
|
||||||
|
async function fetchAssetBuffer(fileId) {
|
||||||
|
requireConfig();
|
||||||
|
const res = await fetch(config.directus.url.replace(/\/$/, '') + '/assets/' + encodeURIComponent(fileId), {
|
||||||
|
headers: { Authorization: `Bearer ${config.directus.token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = new Error(`Directus asset ${fileId}: ${res.status}`);
|
||||||
|
e.status = res.status === 404 ? 404 : 502;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zentrale Format-Definitionen (Seitengröße, Ränder, Zeilenlimit) aus Directus. */
|
||||||
|
async function fetchRenderFormats() {
|
||||||
|
return directusFetch(
|
||||||
|
'/items/render_formats?filter[active][_eq]=true&limit=-1' +
|
||||||
|
'&fields=key,page_width_mm,page_height_mm,margin_top_mm,margin_left_mm,margin_right_mm,line_height_factor,line_limit',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { fetchOrder, updateOrder, addStatusHistory, fetchRenderFormats, fetchOrdersMinimal, fetchAssetBuffer };
|
||||||
|
|||||||
@@ -46,10 +46,17 @@ class Directus:
|
|||||||
self.s.headers["Authorization"] = f"Bearer {token}"
|
self.s.headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
def get(self, path, **params):
|
def get(self, path, **params):
|
||||||
|
"""Für /items/… – Directus verpackt die Nutzdaten in `data`."""
|
||||||
r = self.s.get(self.base + path, params=params, timeout=30)
|
r = self.s.get(self.base + path, params=params, timeout=30)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json().get("data")
|
return r.json().get("data")
|
||||||
|
|
||||||
|
def get_raw(self, path, **params):
|
||||||
|
"""Für Custom-Endpunkte (z. B. /skrift-orders/files) ohne `data`-Hülle."""
|
||||||
|
r = self.s.get(self.base + path, params=params, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
def patch(self, path, body):
|
def patch(self, path, body):
|
||||||
r = self.s.patch(self.base + path, json=body, timeout=30)
|
r = self.s.patch(self.base + path, json=body, timeout=30)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
@@ -152,10 +159,29 @@ def parse_nummern(spec):
|
|||||||
return treffer or None
|
return treffer or None
|
||||||
|
|
||||||
|
|
||||||
def datei_nummer(name):
|
def seiten_reihenfolge(files):
|
||||||
"""Zahl aus einem Dateinamen ziehen (letter_003.svg → 3)."""
|
"""Seiten je Brief ABSTEIGEND (…p3, p2, p1), Briefe aufsteigend.
|
||||||
ziffern = "".join(ch for ch in os.path.splitext(os.path.basename(name))[0] if ch.isdigit())
|
|
||||||
return int(ziffern) if ziffern else None
|
Der Plotter stapelt face-up: zuletzt gedruckt liegt oben. Damit am Ende
|
||||||
|
Seite 1 obenauf liegt, muss je Brief die letzte Seite zuerst gedruckt werden.
|
||||||
|
Ergebnis: 1.3, 1.2, 1.1, 2.3, 2.2, 2.1, … Einseitige Briefe (ohne _pN) und
|
||||||
|
andere Typen (Kuvert/Signatur) bleiben unverändert.
|
||||||
|
"""
|
||||||
|
def key(f):
|
||||||
|
name = str(f.get("name", "")).lower()
|
||||||
|
brief, seite = 0, 1
|
||||||
|
if name.startswith("letter_"):
|
||||||
|
rest = name[7:]
|
||||||
|
if rest.endswith(".svg"):
|
||||||
|
rest = rest[:-4]
|
||||||
|
if "_p" in rest:
|
||||||
|
a, _, b = rest.partition("_p")
|
||||||
|
brief = int(a) if a.isdigit() else 0
|
||||||
|
seite = int(b) if b.isdigit() else 1
|
||||||
|
elif rest.isdigit():
|
||||||
|
brief = int(rest)
|
||||||
|
return (brief, -seite)
|
||||||
|
return sorted(files, key=key)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -167,29 +193,83 @@ def main():
|
|||||||
print(f"[Agent] gestartet für Maschine {machine_id} gegen {cfg['directus_url']}")
|
print(f"[Agent] gestartet für Maschine {machine_id} gegen {cfg['directus_url']}")
|
||||||
|
|
||||||
KIND = {"brief": "schriftstueck", "umschlag": "umschlag", "signatur": "signatur"}
|
KIND = {"brief": "schriftstueck", "umschlag": "umschlag", "signatur": "signatur"}
|
||||||
|
# Sicherheitsfenster: Sehen wir nach dem Senden binnen dieser Zeit kein
|
||||||
|
# „Printing" (Job war sehr kurz), gilt er als fertig – verhindert Deadlock.
|
||||||
|
start_grace = max(60, poll * 6)
|
||||||
|
|
||||||
|
# Kontext des aktuell an die Maschine gesendeten Jobs (oder None). Der
|
||||||
|
# nächste Job wird ERST gesendet, wenn die Maschine wieder „Idle" ist –
|
||||||
|
# sonst würde ein neuer write_svg (clear=1) den laufenden Druck verwerfen.
|
||||||
|
laufend = None
|
||||||
|
|
||||||
|
# Heartbeat gedrosselt: nur schreiben, wenn sich der Status ändert oder
|
||||||
|
# spätestens alle 60 s – sonst flutet der PATCH bei jedem Poll das Log.
|
||||||
|
beat_state = {"status": None, "ts": 0.0}
|
||||||
|
|
||||||
|
def beat(status):
|
||||||
|
now = time.monotonic()
|
||||||
|
if status != beat_state["status"] or now - beat_state["ts"] >= 60:
|
||||||
|
_heartbeat(dx, machine_id, status)
|
||||||
|
beat_state["status"] = status
|
||||||
|
beat_state["ts"] = now
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
maschine = dx.get(f"/items/machines/{machine_id}",
|
maschine = dx.get(f"/items/machines/{machine_id}",
|
||||||
fields="base_url,mid,username,password,active")
|
fields="base_url,mid,username,password,active,current_format")
|
||||||
if not maschine or not maschine.get("active", True):
|
if not maschine or not maschine.get("active", True):
|
||||||
_heartbeat(dx, machine_id, "offline")
|
beat("offline")
|
||||||
time.sleep(poll)
|
time.sleep(poll)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
plotter = Plotter(maschine)
|
plotter = Plotter(maschine)
|
||||||
mstatus = plotter.status()
|
st = plotter.status() or {}
|
||||||
# Bei Fehler (z. B. Papierende) automatisch fortsetzen.
|
s = str(st.get("status", "")).strip().lower()
|
||||||
if mstatus and str(mstatus.get("status", "")).lower() in ("error", "fehler"):
|
|
||||||
plotter.recover()
|
|
||||||
_heartbeat(dx, machine_id, "bereit")
|
|
||||||
|
|
||||||
job = _naechster_job(dx, machine_id)
|
# Fehler (z. B. Papierende): fortsetzen, aber nichts Neues senden.
|
||||||
if not job:
|
if s in ("error", "fehler"):
|
||||||
|
plotter.recover()
|
||||||
|
beat("fehler")
|
||||||
time.sleep(poll)
|
time.sleep(poll)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
_druck(dx, plotter, job, KIND)
|
# Maschine druckt noch → warten (auf keinen Fall einen neuen Job senden).
|
||||||
|
if s == "printing":
|
||||||
|
if laufend is not None:
|
||||||
|
laufend["gestartet"] = True
|
||||||
|
beat("druckt")
|
||||||
|
time.sleep(poll)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Nur bei sicherem „Idle" handeln. Unbekannt/leer → abwarten.
|
||||||
|
if s != "idle":
|
||||||
|
beat("bereit" if s else "offline")
|
||||||
|
time.sleep(poll)
|
||||||
|
continue
|
||||||
|
|
||||||
|
beat("bereit")
|
||||||
|
|
||||||
|
# Ein zuvor gesendeter Job ist fertig gedruckt (Idle nach Printing) →
|
||||||
|
# abschließen. Das start_grace fängt sehr kurze Jobs ab, bei denen wir
|
||||||
|
# das „Printing" nie zu Gesicht bekommen.
|
||||||
|
if laufend is not None:
|
||||||
|
if laufend.get("gestartet") or (time.monotonic() - laufend["ts"] > start_grace):
|
||||||
|
_abschluss(dx, laufend)
|
||||||
|
laufend = None
|
||||||
|
else:
|
||||||
|
time.sleep(poll)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Nächsten Job nur senden, wenn nichts (mehr) läuft.
|
||||||
|
if laufend is None:
|
||||||
|
current_format = (maschine.get("current_format") or "").strip()
|
||||||
|
# Format-Warteschlange: nur passendes Format, kein Auto-Anschluss.
|
||||||
|
if current_format:
|
||||||
|
job = _naechster_job(dx, machine_id, current_format)
|
||||||
|
if job:
|
||||||
|
laufend = _sende(dx, plotter, job, KIND)
|
||||||
|
|
||||||
|
time.sleep(poll)
|
||||||
|
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
print(f"[Agent] HTTP-Fehler: {e}")
|
print(f"[Agent] HTTP-Fehler: {e}")
|
||||||
@@ -206,12 +286,14 @@ def _heartbeat(dx, machine_id, status):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _naechster_job(dx, machine_id):
|
def _naechster_job(dx, machine_id, current_format):
|
||||||
jobs = dx.get(
|
jobs = dx.get(
|
||||||
"/items/jobs",
|
"/items/jobs",
|
||||||
**{
|
**{
|
||||||
"filter[machine][_eq]": machine_id,
|
"filter[machine][_eq]": machine_id,
|
||||||
"filter[status][_eq]": "queued",
|
"filter[status][_eq]": "queued",
|
||||||
|
# Nur Jobs des aktuell eingelegten Formats.
|
||||||
|
"filter[format][_eq]": current_format,
|
||||||
"sort": "-priority,date_created",
|
"sort": "-priority,date_created",
|
||||||
"limit": 1,
|
"limit": 1,
|
||||||
"fields": "id,type,output_ref,params,numbers,"
|
"fields": "id,type,output_ref,params,numbers,"
|
||||||
@@ -223,7 +305,13 @@ def _naechster_job(dx, machine_id):
|
|||||||
return jobs[0] if jobs else None
|
return jobs[0] if jobs else None
|
||||||
|
|
||||||
|
|
||||||
def _druck(dx, plotter, job, KIND):
|
def _sende(dx, plotter, job, KIND):
|
||||||
|
"""Sendet einen Job an die Maschine und markiert ihn als „printing".
|
||||||
|
|
||||||
|
Gibt einen Kontext zum späteren Abschluss zurück (oder None bei Fehler).
|
||||||
|
Es wird NICHT auf das Druckende gewartet – das übernimmt die Hauptschleife
|
||||||
|
(erst wenn die Maschine wieder „Idle" ist, wird der nächste Job gesendet).
|
||||||
|
"""
|
||||||
job_id = job["id"]
|
job_id = job["id"]
|
||||||
order = job.get("order") or {}
|
order = job.get("order") or {}
|
||||||
order_id = order.get("id")
|
order_id = order.get("id")
|
||||||
@@ -238,7 +326,7 @@ def _druck(dx, plotter, job, KIND):
|
|||||||
tpl[k] = params[k]
|
tpl[k] = params[k]
|
||||||
nummern = parse_nummern(job.get("numbers"))
|
nummern = parse_nummern(job.get("numbers"))
|
||||||
typ = job.get("type") or "brief"
|
typ = job.get("type") or "brief"
|
||||||
print(f"[Agent] Job {job_id}: {typ} für {order_nr}")
|
print(f"[Agent] Job {job_id}: {typ} für {order_nr} → an Maschine senden")
|
||||||
|
|
||||||
dx.patch(f"/items/jobs/{job_id}", {"status": "printing", "error": None})
|
dx.patch(f"/items/jobs/{job_id}", {"status": "printing", "error": None})
|
||||||
# Produktionsstatus fürs Kanban: sobald gedruckt wird → „im_druck".
|
# Produktionsstatus fürs Kanban: sobald gedruckt wird → „im_druck".
|
||||||
@@ -248,18 +336,25 @@ def _druck(dx, plotter, job, KIND):
|
|||||||
try:
|
try:
|
||||||
if not order_nr:
|
if not order_nr:
|
||||||
raise RuntimeError("Auftragsnummer fehlt am Job.")
|
raise RuntimeError("Auftragsnummer fehlt am Job.")
|
||||||
liste = dx.get(f"/skrift-orders/files/{order_nr}") or {}
|
liste = dx.get_raw(f"/skrift-orders/files/{order_nr}") or {}
|
||||||
gewuenscht = KIND.get(typ, "schriftstueck")
|
gewuenscht = KIND.get(typ, "schriftstueck")
|
||||||
passende = sorted(
|
passende = sorted(
|
||||||
[f for f in (liste.get("files") or []) if f.get("kind") == gewuenscht],
|
[f for f in (liste.get("files") or []) if f.get("kind") == gewuenscht],
|
||||||
key=lambda f: f.get("name", ""),
|
key=lambda f: f.get("name", ""),
|
||||||
)
|
)
|
||||||
# Nur bestimmte Briefnummern drucken, falls im Job angegeben.
|
# Nur bestimmte Briefnummern drucken, falls im Job angegeben.
|
||||||
|
# Positionsbasiert (1 = erstes Dokument), NICHT über die Zahl im
|
||||||
|
# Dateinamen: Alt-Aufträge sind 0-basiert (letter_000.svg), neue
|
||||||
|
# 1-basiert (letter_001.svg). Die Position stimmt in beiden Fällen.
|
||||||
if nummern is not None:
|
if nummern is not None:
|
||||||
passende = [f for f in passende if datei_nummer(f.get("name")) in nummern]
|
passende = [f for i, f in enumerate(passende, start=1) if i in nummern]
|
||||||
if not passende:
|
if not passende:
|
||||||
raise RuntimeError(f"Keine Dateien vom Typ '{gewuenscht}' für {order_nr} gefunden.")
|
raise RuntimeError(f"Keine Dateien vom Typ '{gewuenscht}' für {order_nr} gefunden.")
|
||||||
|
|
||||||
|
# Mehrseitige Briefe: Seiten je Brief absteigend an den Plotter (Stapel-
|
||||||
|
# Reihenfolge, damit Seite 1 am Ende oben liegt). Einseitige unverändert.
|
||||||
|
passende = seiten_reihenfolge(passende)
|
||||||
|
|
||||||
dateien = []
|
dateien = []
|
||||||
for f in passende:
|
for f in passende:
|
||||||
inhalt = dx.download(f"/skrift-orders/files/{order_nr}/download", {"rel": f["rel"]})
|
inhalt = dx.download(f"/skrift-orders/files/{order_nr}/download", {"rel": f["rel"]})
|
||||||
@@ -267,27 +362,44 @@ def _druck(dx, plotter, job, KIND):
|
|||||||
|
|
||||||
plotter.login()
|
plotter.login()
|
||||||
plotter.write(dateien, tpl)
|
plotter.write(dateien, tpl)
|
||||||
|
print(f"[Agent] Job {job_id} an Maschine übergeben ({len(dateien)} Dateien) – warte auf Fertigstellung.")
|
||||||
dx.patch(f"/items/jobs/{job_id}", {"status": "printed"})
|
return {"job_id": job_id, "order_id": order_id, "order_nr": order_nr,
|
||||||
# Indikator am Auftrag setzen (Schriftstück bzw. Umschlag gedruckt) und
|
"typ": typ, "gestartet": False, "ts": time.monotonic()}
|
||||||
# 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:
|
except Exception as e:
|
||||||
dx.patch(f"/items/jobs/{job_id}", {"status": "failed", "error": str(e)[:1000]})
|
dx.patch(f"/items/jobs/{job_id}", {"status": "failed", "error": str(e)[:1000]})
|
||||||
print(f"[Agent] Job {job_id} fehlgeschlagen: {e}")
|
print(f"[Agent] Job {job_id} fehlgeschlagen: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _abschluss(dx, ctx):
|
||||||
|
"""Schließt einen fertig gedruckten Job ab: Status, Indikatoren, Kuvertierung.
|
||||||
|
|
||||||
|
Wird erst aufgerufen, wenn die Maschine nach dem Druck wieder „Idle" meldet.
|
||||||
|
"""
|
||||||
|
job_id = ctx["job_id"]
|
||||||
|
order_id = ctx.get("order_id")
|
||||||
|
order_nr = ctx.get("order_nr")
|
||||||
|
typ = ctx.get("typ")
|
||||||
|
try:
|
||||||
|
dx.patch(f"/items/jobs/{job_id}", {"status": "printed"})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Agent] Konnte Job {job_id} nicht auf 'printed' setzen: {e}")
|
||||||
|
# Indikator am Auftrag setzen (Schriftstück bzw. Umschlag gedruckt) und
|
||||||
|
# bei Vollständigkeit automatisch in die Kuvertierung schieben.
|
||||||
|
if order_id:
|
||||||
|
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} fertig gedruckt.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -123,6 +123,17 @@ function Dialog({ title, onClose, children }: { title: string; onClose: () => vo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Konfigurator samt lokalem Cache zurücksetzen (Login bleibt erhalten). */
|
||||||
|
function konfiguratorZuruecksetzen() {
|
||||||
|
if (!window.confirm('Konfigurator zurücksetzen? Alle Eingaben und der lokale Zwischenspeicher werden gelöscht (die Anmeldung bleibt bestehen).')) return;
|
||||||
|
try {
|
||||||
|
Object.keys(localStorage)
|
||||||
|
.filter((k) => k.startsWith('skrift.') && k !== 'skrift.session')
|
||||||
|
.forEach((k) => localStorage.removeItem(k));
|
||||||
|
} catch { /* localStorage nicht verfügbar – dann reicht der Reload */ }
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
function Hilfe({ onClose }: { onClose: () => void }) {
|
function Hilfe({ onClose }: { onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Dialog title="Hilfe & Kontakt" onClose={onClose}>
|
<Dialog title="Hilfe & Kontakt" onClose={onClose}>
|
||||||
@@ -138,6 +149,14 @@ function Hilfe({ onClose }: { onClose: () => void }) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Rettungsanker bei hängengebliebenem Zustand (z. B. falscher Zeilen-Hinweis). */}
|
||||||
|
<div className="sk-help-reset">
|
||||||
|
<p className="sk-note">Zeigt der Konfigurator unerwartetes Verhalten (z. B. einen falschen Zeilen-Hinweis)? Setzen Sie ihn hier zurück:</p>
|
||||||
|
<button type="button" className="sk-btn sk-btn--ghost" onClick={konfiguratorZuruecksetzen}>
|
||||||
|
Konfigurator zurücksetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="sk-legal-links">
|
<div className="sk-legal-links">
|
||||||
<a href="https://skrift.de/agb" target="_blank" rel="noreferrer">AGB</a>
|
<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/datenschutz" target="_blank" rel="noreferrer">Datenschutz</a>
|
||||||
@@ -162,7 +181,7 @@ export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: (
|
|||||||
const [produkte, setProdukte] = useState<Product[]>([]);
|
const [produkte, setProdukte] = useState<Product[]>([]);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
product: '', name: '', email: '', phone: '',
|
product: '', name: '', email: '', phone: '',
|
||||||
street: '', zip: '', city: '', country: 'Deutschland',
|
street: '', zip: '', city: '', country: 'Deutschland', message: '',
|
||||||
});
|
});
|
||||||
const [datenschutz, setDatenschutz] = useState(false);
|
const [datenschutz, setDatenschutz] = useState(false);
|
||||||
const [status, setStatus] = useState<'formular' | 'sendet' | 'fertig'>('formular');
|
const [status, setStatus] = useState<'formular' | 'sendet' | 'fertig'>('formular');
|
||||||
@@ -184,7 +203,7 @@ export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: (
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||||
const roh = e.target.value;
|
const roh = e.target.value;
|
||||||
setForm({ ...form, [k]: k === 'phone' ? roh.replace(/[^0-9+ ]/g, '') : k === 'zip' ? roh.replace(/[^0-9]/g, '') : roh });
|
setForm({ ...form, [k]: k === 'phone' ? roh.replace(/[^0-9+ ]/g, '') : k === 'zip' ? roh.replace(/[^0-9]/g, '') : roh });
|
||||||
};
|
};
|
||||||
@@ -204,6 +223,7 @@ export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: (
|
|||||||
zip: form.zip.trim(),
|
zip: form.zip.trim(),
|
||||||
city: form.city.trim(),
|
city: form.city.trim(),
|
||||||
country: form.country,
|
country: form.country,
|
||||||
|
message: form.message.trim() || undefined,
|
||||||
});
|
});
|
||||||
setStatus('fertig');
|
setStatus('fertig');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -267,6 +287,14 @@ export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: (
|
|||||||
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
|
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="sk-field">
|
||||||
|
<label className="sk-label" htmlFor="m-message">
|
||||||
|
Nachricht <span className="sk-note" style={{ fontWeight: 400 }}>(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea id="m-message" className="sk-input" rows={3} maxLength={1000}
|
||||||
|
placeholder="Wünsche oder Fragen zum Muster?"
|
||||||
|
value={form.message} onChange={set('message')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="sk-check" style={{ marginBottom: 'var(--sk-space-4)' }}>
|
<label className="sk-check" style={{ marginBottom: 'var(--sk-space-4)' }}>
|
||||||
<input type="checkbox" checked={datenschutz} onChange={(e) => setDatenschutz(e.target.checked)} />
|
<input type="checkbox" checked={datenschutz} onChange={(e) => setDatenschutz(e.target.checked)} />
|
||||||
|
|||||||
@@ -69,6 +69,48 @@ export default function PreviewPanel({
|
|||||||
}
|
}
|
||||||
function openZoom(svg: string) { setZoomBox(messeBox()); setZoomSvg(svg); }
|
function openZoom(svg: string) { setZoomBox(messeBox()); setZoomSvg(svg); }
|
||||||
|
|
||||||
|
/** Seitenverhältnis (Breite/Höhe) aus der viewBox – sperrt die Lightbox-Abmessungen. */
|
||||||
|
function seitenverhaeltnis(svg: string): string {
|
||||||
|
const m = svg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
|
||||||
|
return m ? `${m[1]} / ${m[2]}` : '210 / 297';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Musterbild der großen Vorschau als PNG herunterladen (SVG → Canvas). */
|
||||||
|
function bildSpeichern() {
|
||||||
|
if (!zoomSvg) return;
|
||||||
|
const m = zoomSvg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
|
||||||
|
const w = Math.round(m ? Number(m[1]) : 794);
|
||||||
|
const h = Math.round(m ? Number(m[2]) : 1123);
|
||||||
|
const scale = 2; // für ein scharfes Musterbild
|
||||||
|
const url = URL.createObjectURL(new Blob([zoomSvg], { type: 'image/svg+xml;charset=utf-8' }));
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w * scale;
|
||||||
|
canvas.height = h * scale;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
canvas.toBlob((png) => {
|
||||||
|
if (png) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(png);
|
||||||
|
a.download = 'skrift-musterbild.png';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
}
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, 'image/png');
|
||||||
|
} else {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => URL.revokeObjectURL(url);
|
||||||
|
img.src = url;
|
||||||
|
}
|
||||||
|
|
||||||
// Nach einer neuen Generierung wieder vorn beginnen.
|
// Nach einer neuen Generierung wieder vorn beginnen.
|
||||||
useEffect(() => { setIndex(0); }, [docs]);
|
useEffect(() => { setIndex(0); }, [docs]);
|
||||||
|
|
||||||
@@ -152,11 +194,17 @@ export default function PreviewPanel({
|
|||||||
{zoomSvg && (
|
{zoomSvg && (
|
||||||
<div className="sk-lightbox" style={zoomBox} onClick={() => setZoomSvg(null)} role="dialog" aria-modal="true"
|
<div className="sk-lightbox" style={zoomBox} onClick={() => setZoomSvg(null)} role="dialog" aria-modal="true"
|
||||||
aria-label="Vergrößerte Vorschau">
|
aria-label="Vergrößerte Vorschau">
|
||||||
<div className="sk-lightbox-sheet" onClick={(e) => e.stopPropagation()}
|
<div className="sk-lightbox-sheet" style={{ aspectRatio: seitenverhaeltnis(zoomSvg) }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
dangerouslySetInnerHTML={{ __html: zoomSvg }} />
|
dangerouslySetInnerHTML={{ __html: zoomSvg }} />
|
||||||
<button className="sk-lightbox-close" onClick={() => setZoomSvg(null)} aria-label="Schließen">
|
<div className="sk-lightbox-actions">
|
||||||
Schließen
|
<button className="sk-lightbox-close" onClick={(e) => { e.stopPropagation(); bildSpeichern(); }}>
|
||||||
</button>
|
Bild speichern
|
||||||
|
</button>
|
||||||
|
<button className="sk-lightbox-close" onClick={() => setZoomSvg(null)} aria-label="Schließen">
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ export type CustomerAddress = {
|
|||||||
city?: string;
|
city?: string;
|
||||||
country?: string;
|
country?: string;
|
||||||
};
|
};
|
||||||
|
/** Feste Adresse ohne Bezeichnung (Rechnung bzw. Lieferung). */
|
||||||
|
export type Adresse2 = { street?: string; zip?: string; city?: string; country?: string };
|
||||||
export type Customer = {
|
export type Customer = {
|
||||||
/** Directus-User-ID (UUID) – Kunde ist das Login-Konto selbst. */
|
/** Directus-User-ID (UUID) – Kunde ist das Login-Konto selbst. */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -180,8 +182,13 @@ export type Customer = {
|
|||||||
last_name: string | null;
|
last_name: string | null;
|
||||||
company: string | null;
|
company: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
/** Mehrere hinterlegte Adressen (Rechnung/Lieferung). */
|
/** Genau zwei feste Adressen ohne Namen. */
|
||||||
addresses: CustomerAddress[] | null;
|
billing_address: Adresse2 | null;
|
||||||
|
shipping_address: Adresse2 | null;
|
||||||
|
/** Weitere Login-Mailadressen (nur Anzeige; Pflege durch den Betreiber). */
|
||||||
|
alt_emails: string[] | null;
|
||||||
|
/** Veraltet – frühere Mehrfach-Adressliste (nur für Alt-Daten). */
|
||||||
|
addresses?: CustomerAddress[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,7 +197,7 @@ export type Customer = {
|
|||||||
*/
|
*/
|
||||||
export async function fetchMyCustomer(): Promise<Customer | null> {
|
export async function fetchMyCustomer(): Promise<Customer | null> {
|
||||||
const data = await request(
|
const data = await request(
|
||||||
'/users/me?fields=id,email,person_type,first_name,last_name,company,phone,addresses',
|
'/users/me?fields=id,email,person_type,first_name,last_name,company,phone,billing_address,shipping_address,alt_emails',
|
||||||
);
|
);
|
||||||
return data?.data ?? null;
|
return data?.data ?? null;
|
||||||
}
|
}
|
||||||
@@ -204,7 +211,8 @@ export async function saveMyCustomer(c: Customer) {
|
|||||||
last_name: c.last_name,
|
last_name: c.last_name,
|
||||||
company: c.company,
|
company: c.company,
|
||||||
phone: c.phone,
|
phone: c.phone,
|
||||||
addresses: c.addresses ?? [],
|
billing_address: c.billing_address ?? null,
|
||||||
|
shipping_address: c.shipping_address ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -401,6 +409,8 @@ export type OrderInput = {
|
|||||||
text_briefing?: string;
|
text_briefing?: string;
|
||||||
/** Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF). */
|
/** Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF). */
|
||||||
source_file?: string;
|
source_file?: string;
|
||||||
|
/** Alle hochgeladenen Dokumente (Datei-Produkte, mehrere möglich). */
|
||||||
|
source_files?: string[];
|
||||||
/** Rechnungs-/Lieferadresse als Snapshot (für Bestätigung & Rechnung). */
|
/** Rechnungs-/Lieferadresse als Snapshot (für Bestätigung & Rechnung). */
|
||||||
billing?: Record<string, string>;
|
billing?: Record<string, string>;
|
||||||
shipping_address?: Record<string, string> | null;
|
shipping_address?: Record<string, string> | null;
|
||||||
@@ -511,6 +521,7 @@ export type SampleRequest = {
|
|||||||
zip: string;
|
zip: string;
|
||||||
city: string;
|
city: string;
|
||||||
country: string;
|
country: string;
|
||||||
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function sendSampleRequest(input: SampleRequest) {
|
export async function sendSampleRequest(input: SampleRequest) {
|
||||||
|
|||||||
@@ -47,14 +47,19 @@ export const VORLAGEN_SPALTEN = [
|
|||||||
/** Spalten der Freitext-Variante: fünf frei beschreibbare Zeilen. */
|
/** Spalten der Freitext-Variante: fünf frei beschreibbare Zeilen. */
|
||||||
export const FREITEXT_SPALTEN = ['Zeile 1', 'Zeile 2', 'Zeile 3', 'Zeile 4', 'Zeile 5'];
|
export const FREITEXT_SPALTEN = ['Zeile 1', 'Zeile 2', 'Zeile 3', 'Zeile 4', 'Zeile 5'];
|
||||||
|
|
||||||
/** Erzeugt die Vorlagendatei und stößt den Download an. */
|
/**
|
||||||
export async function ladeVorlageHerunter(zusatzSpalten: string[] = [], freitext = false) {
|
* Erzeugt die Vorlagendatei und stößt den Download an.
|
||||||
|
* `nurZusatz` = nur die Platzhalter-Spalten (ohne Adressspalten) – für den
|
||||||
|
* Fall, dass nur Platzhalterwerte je Dokument gebraucht werden (Sammelversand
|
||||||
|
* ohne Kuvert), nicht aber Empfängeradressen.
|
||||||
|
*/
|
||||||
|
export async function ladeVorlageHerunter(zusatzSpalten: string[] = [], freitext = false, nurZusatz = false) {
|
||||||
const XLSX = await import('xlsx');
|
const XLSX = await import('xlsx');
|
||||||
const basis = freitext ? FREITEXT_SPALTEN : VORLAGEN_SPALTEN;
|
const basis = nurZusatz ? [] : (freitext ? FREITEXT_SPALTEN : VORLAGEN_SPALTEN);
|
||||||
const kopf = [...basis, ...zusatzSpalten];
|
const kopf = [...basis, ...zusatzSpalten];
|
||||||
const beispielBasis = freitext
|
const beispielBasis = nurZusatz ? [] : (freitext
|
||||||
? ['Erika Mustermann', 'Hauptstraße 1', '10115 Berlin', '', '']
|
? ['Erika Mustermann', 'Hauptstraße 1', '10115 Berlin', '', '']
|
||||||
: ['Frau', 'Erika', 'Mustermann', 'Hauptstraße', '1', '10115', 'Berlin', 'DE'];
|
: ['Frau', 'Erika', 'Mustermann', 'Hauptstraße', '1', '10115', 'Berlin', 'DE']);
|
||||||
const beispiel = [...beispielBasis, ...zusatzSpalten.map(() => 'Beispielwert')];
|
const beispiel = [...beispielBasis, ...zusatzSpalten.map(() => 'Beispielwert')];
|
||||||
|
|
||||||
const blatt = XLSX.utils.aoa_to_sheet([kopf, beispiel]);
|
const blatt = XLSX.utils.aoa_to_sheet([kopf, beispiel]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
assetUrl,
|
assetUrl,
|
||||||
@@ -25,7 +26,6 @@ import {
|
|||||||
type Product,
|
type Product,
|
||||||
type PublicSettings,
|
type PublicSettings,
|
||||||
type Quote,
|
type Quote,
|
||||||
type CustomerAddress,
|
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
import { ladePayPal, rendereButtons } from '../lib/paypal';
|
import { ladePayPal, rendereButtons } from '../lib/paypal';
|
||||||
import { ladeVorlageHerunter, leseEmpfaengerDatei } from '../lib/excel';
|
import { ladeVorlageHerunter, leseEmpfaengerDatei } from '../lib/excel';
|
||||||
@@ -106,6 +106,8 @@ type Entry = {
|
|||||||
/** Alternative zur strukturierten Adresse. */
|
/** Alternative zur strukturierten Adresse. */
|
||||||
free_text: string;
|
free_text: string;
|
||||||
placeholders: Record<string, string>;
|
placeholders: Record<string, string>;
|
||||||
|
/** Abweichender Text NUR für dieses Schriftstück (leer/undefined = Text aus #1). */
|
||||||
|
text?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Adresse = {
|
type Adresse = {
|
||||||
@@ -270,6 +272,10 @@ const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien'
|
|||||||
|
|
||||||
export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: string }) {
|
export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: string }) {
|
||||||
const { publish, registerGoto } = useWizard();
|
const { publish, registerGoto } = useWizard();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
// Externer/Etsy-Modus (?extern=1): Bestellung ohne Preise & Zahlung,
|
||||||
|
// nur Empfänger → Text → Übersicht (siehe schritte weiter unten).
|
||||||
|
const externModus = searchParams.get('extern') === '1';
|
||||||
|
|
||||||
// Zwischengespeicherten Entwurf einmalig laden (siehe ENTWURF_KEY).
|
// Zwischengespeicherten Entwurf einmalig laden (siehe ENTWURF_KEY).
|
||||||
const entwurf = useMemo(ladeEntwurf, []);
|
const entwurf = useMemo(ladeEntwurf, []);
|
||||||
@@ -315,6 +321,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
const [textservice, setTextservice] = useState(entwurf.textservice ?? false);
|
const [textservice, setTextservice] = useState(entwurf.textservice ?? false);
|
||||||
const [textBriefing, setTextBriefing] = useState(entwurf.textBriefing ?? '');
|
const [textBriefing, setTextBriefing] = useState(entwurf.textBriefing ?? '');
|
||||||
const [textTemplate, setTextTemplate] = useState(entwurf.textTemplate ?? '');
|
const [textTemplate, setTextTemplate] = useState(entwurf.textTemplate ?? '');
|
||||||
|
/** Aktuell im Textfeld angezeigtes Schriftstück (0 = Standardtext, der für alle gilt). */
|
||||||
|
const [textDocIndex, setTextDocIndex] = useState(0);
|
||||||
|
|
||||||
const [rechnung, setRechnung] = useState<Adresse>(entwurf.rechnung ?? leereAdresse());
|
const [rechnung, setRechnung] = useState<Adresse>(entwurf.rechnung ?? leereAdresse());
|
||||||
const [lieferAbweichend, setLieferAbweichend] = useState(entwurf.lieferAbweichend ?? false);
|
const [lieferAbweichend, setLieferAbweichend] = useState(entwurf.lieferAbweichend ?? false);
|
||||||
@@ -344,7 +352,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
|
|
||||||
const [sendet, setSendet] = useState(false);
|
const [sendet, setSendet] = useState(false);
|
||||||
const [bestellFehler, setBestellFehler] = useState<string[]>([]);
|
const [bestellFehler, setBestellFehler] = useState<string[]>([]);
|
||||||
const [bestellung, setBestellung] = useState<{ nummer: string; zahlung: string; bezahlt: boolean } | null>(null);
|
const [bestellung, setBestellung] = useState<{ nummer: string; zahlung: string; bezahlt: boolean; extern?: boolean } | null>(null);
|
||||||
|
|
||||||
const [settings, setSettings] = useState<PublicSettings | null>(null);
|
const [settings, setSettings] = useState<PublicSettings | null>(null);
|
||||||
const [preisKatalog, setPreisKatalog] = useState<Record<string, PriceItem>>({});
|
const [preisKatalog, setPreisKatalog] = useState<Record<string, PriceItem>>({});
|
||||||
@@ -354,7 +362,10 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
const verbotenRe = useMemo(() => {
|
const verbotenRe = useMemo(() => {
|
||||||
const wl = settings?.char_whitelist;
|
const wl = settings?.char_whitelist;
|
||||||
if (!wl) return null;
|
if (!wl) return null;
|
||||||
try { return new RegExp(`[^${wl}]`, 'g'); } catch { return null; }
|
// Zeilenumbrüche sind im mehrzeiligen Text erlaubt; einzeilige Felder
|
||||||
|
// entfernen \n/\r ohnehin separat vor saeubere(). Tabs (\t) sind erlaubt,
|
||||||
|
// damit man z.B. das Datum an den rechten Rand rücken kann.
|
||||||
|
try { return new RegExp(`[^${wl}\\n\\r\\t]`, 'g'); } catch { return null; }
|
||||||
}, [settings?.char_whitelist]);
|
}, [settings?.char_whitelist]);
|
||||||
/** Entfernt verbotene Zeichen und meldet sie; gibt den bereinigten Wert zurück. */
|
/** Entfernt verbotene Zeichen und meldet sie; gibt den bereinigten Wert zurück. */
|
||||||
const saeubere = (v: string) => {
|
const saeubere = (v: string) => {
|
||||||
@@ -373,7 +384,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
}, [zeichenHinweis]);
|
}, [zeichenHinweis]);
|
||||||
const [motivDatei, setMotivDatei] = useState<File | null>(null);
|
const [motivDatei, setMotivDatei] = useState<File | null>(null);
|
||||||
/** Hochgeladenes Dokument bei Datei-Produkten (z. B. zu unterschreibendes PDF). */
|
/** Hochgeladenes Dokument bei Datei-Produkten (z. B. zu unterschreibendes PDF). */
|
||||||
const [quellDatei, setQuellDatei] = useState<File | null>(null);
|
/** Hochgeladene Dokumente bei Datei-Produkten (mehrere PDF/Word/Excel möglich). */
|
||||||
|
const [quellDateien, setQuellDateien] = useState<File[]>([]);
|
||||||
const paypalRef = useRef<HTMLDivElement | null>(null);
|
const paypalRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -405,17 +417,16 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
fetchMyUnlockedProducts().then(setFreigeschaltet).catch(() => setFreigeschaltet([]));
|
fetchMyUnlockedProducts().then(setFreigeschaltet).catch(() => setFreigeschaltet([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Gespeicherte Adressen des angemeldeten Kunden (für die Adressauswahl). */
|
// Angemeldete Kunden: Einstufung + die zwei festen Adressen automatisch
|
||||||
const [kundenAdressen, setKundenAdressen] = useState<CustomerAddress[]>([]);
|
// übernehmen (Rechnung → Rechnungsdaten, Lieferung → abweichende Lieferadresse).
|
||||||
|
// Kein Adress-Dropdown mehr; alles wird direkt eingesetzt.
|
||||||
// Angemeldete Kunden: Einstufung und Rechnungsdaten übernehmen.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loadSession()) return;
|
if (!loadSession()) return;
|
||||||
fetchMyCustomer()
|
fetchMyCustomer()
|
||||||
.then((c) => {
|
.then((c) => {
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
if (c.person_type) setPersonType(c.person_type);
|
if (c.person_type) setPersonType(c.person_type);
|
||||||
setKundenAdressen(c.addresses ?? []);
|
const b = c.billing_address ?? {};
|
||||||
setRechnung((r) => ({
|
setRechnung((r) => ({
|
||||||
...r,
|
...r,
|
||||||
first_name: c.first_name ?? r.first_name,
|
first_name: c.first_name ?? r.first_name,
|
||||||
@@ -423,46 +434,27 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
company: c.company ?? r.company,
|
company: c.company ?? r.company,
|
||||||
email: c.email ?? r.email,
|
email: c.email ?? r.email,
|
||||||
phone: c.phone ?? r.phone,
|
phone: c.phone ?? r.phone,
|
||||||
|
street: b.street ?? r.street,
|
||||||
|
zip: b.zip ?? r.zip,
|
||||||
|
city: b.city ?? r.city,
|
||||||
|
country: b.country ?? r.country,
|
||||||
}));
|
}));
|
||||||
|
// Lieferadresse vorbefüllen, aber „abweichende Lieferadresse" bleibt per
|
||||||
|
// Default AUS – der Kunde aktiviert sie bei Bedarf selbst.
|
||||||
|
const s = c.shipping_address ?? {};
|
||||||
|
if (s.street || s.zip || s.city) {
|
||||||
|
setLieferung((l) => ({
|
||||||
|
...l,
|
||||||
|
street: s.street ?? l.street,
|
||||||
|
zip: s.zip ?? l.zip,
|
||||||
|
city: s.city ?? l.city,
|
||||||
|
country: s.country ?? l.country,
|
||||||
|
}));
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { /* Vorausfüllen ist optional */ });
|
.catch(() => { /* Vorausfüllen ist optional */ });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Adressfelder aus einer gespeicherten Adresse füllen. */
|
|
||||||
const uebernimm = (a: CustomerAddress) => (r: Adresse): Adresse => ({
|
|
||||||
...r,
|
|
||||||
street: a.street ?? r.street,
|
|
||||||
zip: a.zip ?? r.zip,
|
|
||||||
city: a.city ?? r.city,
|
|
||||||
country: a.country ?? r.country,
|
|
||||||
});
|
|
||||||
const adresseUebernehmen = (a: CustomerAddress) => setRechnung(uebernimm(a));
|
|
||||||
const lieferungUebernehmen = (a: CustomerAddress) => setLieferung(uebernimm(a));
|
|
||||||
|
|
||||||
/** Hervorgehobene Auswahl gespeicherter Adressen (nur mit Login und Adressen). */
|
|
||||||
const adressWahl = (id: string, uebernehmen: (a: CustomerAddress) => void) =>
|
|
||||||
kundenAdressen.length === 0 ? null : (
|
|
||||||
<div className="sk-field" style={{
|
|
||||||
marginTop: 'var(--sk-space-5)',
|
|
||||||
marginBottom: 'var(--sk-space-5)',
|
|
||||||
padding: 'var(--sk-space-4)',
|
|
||||||
border: '1px solid var(--sk-accent, #1E3A6A)',
|
|
||||||
borderRadius: 'var(--sk-radius, 10px)',
|
|
||||||
background: 'rgba(30, 58, 106, 0.04)',
|
|
||||||
}}>
|
|
||||||
<label className="sk-label" htmlFor={id}>Gespeicherte Adresse übernehmen</label>
|
|
||||||
<select id={id} className="sk-input" defaultValue=""
|
|
||||||
onChange={(e) => { const i = Number(e.target.value); if (!Number.isNaN(i) && kundenAdressen[i]) uebernehmen(kundenAdressen[i]); }}>
|
|
||||||
<option value="">Bitte wählen …</option>
|
|
||||||
{kundenAdressen.map((a, i) => (
|
|
||||||
<option key={i} value={i}>
|
|
||||||
{[a.label, a.street, [a.zip, a.city].filter(Boolean).join(' ')].filter(Boolean).join(' · ') || `Adresse ${i + 1}`}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sichtbarkeit je Personentyp (im Admin über products.visibility pflegbar).
|
// Sichtbarkeit je Personentyp (im Admin über products.visibility pflegbar).
|
||||||
// Ein per Direktlink vorgewähltes Produkt bleibt immer sichtbar – sonst würde
|
// Ein per Direktlink vorgewähltes Produkt bleibt immer sichtbar – sonst würde
|
||||||
// die Anfrage abbrechen, sobald z. B. „privat" bei einem Unternehmens-Produkt gewählt wird.
|
// die Anfrage abbrechen, sobald z. B. „privat" bei einem Unternehmens-Produkt gewählt wird.
|
||||||
@@ -488,6 +480,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
// Datei-Produkt (z. B. Unterschriftenservice): im Text-Schritt lädt der Kunde
|
// Datei-Produkt (z. B. Unterschriftenservice): im Text-Schritt lädt der Kunde
|
||||||
// ein Dokument hoch, statt Text zu tippen.
|
// ein Dokument hoch, statt Text zu tippen.
|
||||||
const dateiModus = gewaehltesProdukt?.input_mode === 'datei';
|
const dateiModus = gewaehltesProdukt?.input_mode === 'datei';
|
||||||
|
// Kein Preis im Konfigurator: Extern-Modus ODER Datei-Produkt (z. B.
|
||||||
|
// Unterschriftenservice – dort wird individuell abgerechnet).
|
||||||
|
const keinePreise = externModus || dateiModus;
|
||||||
// Eigener Format-Schritt nur, wenn es überhaupt eine Wahl gibt.
|
// Eigener Format-Schritt nur, wenn es überhaupt eine Wahl gibt.
|
||||||
const brauchtFormatSchritt = produktFormate.length > 1;
|
const brauchtFormatSchritt = produktFormate.length > 1;
|
||||||
|
|
||||||
@@ -509,7 +504,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
}
|
}
|
||||||
}, [vorwahlProdukt, product, products]);
|
}, [vorwahlProdukt, product, products]);
|
||||||
|
|
||||||
// Genau ein Format → still setzen (kein Format-Schritt nötig).
|
// Genau ein Format → still setzen (kein Format-Schritt nötig). Bei mehreren
|
||||||
|
// Formaten (z. B. Postkarte hoch/quer) NICHT automatisch vorbelegen – der Kunde
|
||||||
|
// wählt im Format-Schritt (auch im Extern-Modus).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (produktFormate.length === 1 && format !== produktFormate[0].key) {
|
if (produktFormate.length === 1 && format !== produktFormate[0].key) {
|
||||||
setFormat(produktFormate[0].key);
|
setFormat(produktFormate[0].key);
|
||||||
@@ -527,6 +524,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
const brauchtAdressen =
|
const brauchtAdressen =
|
||||||
shippingType === 'einzeln' || (needsEnvelope && labeling === 'empfaenger');
|
shippingType === 'einzeln' || (needsEnvelope && labeling === 'empfaenger');
|
||||||
const umschlagFreitext = needsEnvelope && labeling === 'freitext';
|
const umschlagFreitext = needsEnvelope && labeling === 'freitext';
|
||||||
|
// Empfänger-Schritt (früh definiert, da mehrere Effekte darauf zugreifen):
|
||||||
|
// „Individueller Text" bei Freitext-Kuvert, „Empfänger" bei Adressbedarf, sonst keiner.
|
||||||
|
const empfaengerSchritt = umschlagFreitext ? 'Individueller Text' : brauchtAdressen ? 'Empfänger' : null;
|
||||||
|
|
||||||
// Ab 26 Empfängern ist die Tabelle keine Option mehr.
|
// Ab 26 Empfängern ist die Tabelle keine Option mehr.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -535,21 +535,44 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
|
|
||||||
/** Zeichengrenze richtet sich nach dem Format – wie bisher. */
|
/** Zeichengrenze richtet sich nach dem Format – wie bisher. */
|
||||||
const maxText = format?.startsWith('a4') ? MAX_TEXT.a4 : MAX_TEXT.a6;
|
const maxText = format?.startsWith('a4') ? MAX_TEXT.a4 : MAX_TEXT.a6;
|
||||||
const textZuLang = textTemplate.length > maxText;
|
|
||||||
|
// ── Text je Schriftstück (#5) ──────────────────────────────────────────────
|
||||||
|
// Pfeile im Textfeld: pro Dokument ein eigener Text möglich (Standard = #1).
|
||||||
|
// Nur im Tabellenmodus (≤ 25) und bei echtem Text (kein Textservice/Datei).
|
||||||
|
const proDokText = !textservice && !dateiModus && !nurUpload && entries.length > 1;
|
||||||
|
const textDocs = Math.min(entries.length, TABELLEN_GRENZE);
|
||||||
|
// Angezeigter Text: #1 = Standardtext (textTemplate); ab #2 der Override bzw.
|
||||||
|
// – solange nicht geändert – der Standardtext.
|
||||||
|
const aktDocText = textDocIndex === 0 ? textTemplate : (entries[textDocIndex]?.text ?? textTemplate);
|
||||||
|
const setDocText = (v: string) => {
|
||||||
|
const clean = saeubere(v);
|
||||||
|
if (textDocIndex === 0) { setTextTemplate(clean); return; }
|
||||||
|
setEntries((prev) => prev.map((e, i) => (i === textDocIndex ? { ...e, text: clean } : e)));
|
||||||
|
setPreviewSession(null);
|
||||||
|
};
|
||||||
|
const textZuLang = aktDocText.length > maxText;
|
||||||
|
|
||||||
// Ändert sich Text, Format oder Schrift, ist ein früher erkannter Überlauf
|
// Ändert sich Text, Format oder Schrift, ist ein früher erkannter Überlauf
|
||||||
// hinfällig – erst die nächste Vorschau entscheidet erneut.
|
// hinfällig – erst die nächste Vorschau entscheidet erneut.
|
||||||
useEffect(() => { setTextUeberlauf(false); }, [textTemplate, format, font]);
|
useEffect(() => { setTextUeberlauf(false); }, [textTemplate, format, font]);
|
||||||
|
|
||||||
|
// Dokument-Index nicht über die Anzahl hinaus stehen lassen.
|
||||||
|
useEffect(() => { if (textDocIndex >= textDocs) setTextDocIndex(0); }, [textDocIndex, textDocs]);
|
||||||
|
|
||||||
// Ändert sich irgendetwas, das die gerenderten Dokumente beeinflusst, wird die
|
// Ändert sich irgendetwas, das die gerenderten Dokumente beeinflusst, wird die
|
||||||
// Vorschau-Session ungültig – sonst würde bei der Bestellung veralteter Inhalt
|
// Vorschau-Session ungültig – sonst würde bei der Bestellung veralteter Inhalt
|
||||||
// 1:1 übernommen. Nur eine unveränderte Vorschau wird wiederverwendet.
|
// 1:1 übernommen. Nur eine unveränderte Vorschau wird wiederverwendet.
|
||||||
useEffect(() => { setPreviewSession(null); },
|
useEffect(() => { setPreviewSession(null); },
|
||||||
[textTemplate, font, format, realistisch, labeling, needsEnvelope, umschlagText, entries]);
|
[textTemplate, font, format, realistisch, labeling, needsEnvelope, umschlagText, entries]);
|
||||||
|
|
||||||
// Empfängerzeilen an die Menge angleichen (nur im Tabellenmodus).
|
// Empfängerzeilen an die Menge angleichen (nur im Tabellenmodus). Gilt auch im
|
||||||
|
// Extern-Modus: die (vom Betreiber vorgegebene) Menge erzeugt die passende
|
||||||
|
// Anzahl Zeilen – egal ob mit oder ohne Kuvert/Empfänger.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (nurUpload) return;
|
// Bei vielen Empfängern (Tabelle unpraktisch) wird auf Excel verwiesen und
|
||||||
|
// nicht synchronisiert – AUSSER im Extern-Modus ohne Empfänger-Schritt, wo die
|
||||||
|
// Menge einfach N Blanko-Dokumente erzeugen muss (auch über der Tabellengrenze).
|
||||||
|
if (nurUpload && (!externModus || empfaengerSchritt)) return;
|
||||||
setEntries((prev) => {
|
setEntries((prev) => {
|
||||||
if (prev.length === quantity) return prev;
|
if (prev.length === quantity) return prev;
|
||||||
if (prev.length < quantity) {
|
if (prev.length < quantity) {
|
||||||
@@ -557,38 +580,73 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
}
|
}
|
||||||
return prev.slice(0, quantity);
|
return prev.slice(0, quantity);
|
||||||
});
|
});
|
||||||
}, [quantity, nurUpload]);
|
}, [quantity, nurUpload, externModus, empfaengerSchritt]);
|
||||||
|
|
||||||
|
// Extern-Preset (vom Betreiber im Direktlink gesetzt) – der Kunde wählt nichts:
|
||||||
|
// ?extern=1&versand=sammel|einzeln&kuvert=0|1&beschriftung=empfaenger|freitext|keine&menge=N
|
||||||
|
// Die Menge ist maßgeblich (greift immer); nur ein Excel-Import passt sie an die
|
||||||
|
// tatsächlich gelesene Empfängerzahl an.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!externModus) return;
|
||||||
|
const v = searchParams.get('versand'); if (v === 'einzeln' || v === 'sammel') setShippingType(v);
|
||||||
|
const k = searchParams.get('kuvert'); if (k === '1' || k === '0') setNeedsEnvelope(k === '1');
|
||||||
|
const b = searchParams.get('beschriftung'); if (b === 'empfaenger' || b === 'freitext' || b === 'keine') setLabeling(b);
|
||||||
|
const fmt = searchParams.get('format'); if (fmt) setFormat(fmt);
|
||||||
|
const m = parseInt(searchParams.get('menge') || '', 10);
|
||||||
|
if (Number.isFinite(m) && m > 0) { setQuantity(m); setQuantityChosen(true); setMengeText(String(m)); }
|
||||||
|
// Nur einmal beim Betreten des Extern-Modus anwenden.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [externModus]);
|
||||||
|
|
||||||
|
// Abweichende Lieferadresse gibt es nur beim Sammelversand – sonst zurücksetzen.
|
||||||
|
useEffect(() => { if (shippingType !== 'sammel') setLieferAbweichend(false); }, [shippingType]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der Empfängerschritt entfällt vollständig, wenn weder Adressen noch ein
|
* Der Empfängerschritt entfällt vollständig, wenn weder Adressen noch ein
|
||||||
* Umschlagtext gebraucht werden. Bei „Individueller Text" heißt er anders,
|
* Umschlagtext gebraucht werden. Bei „Individueller Text" heißt er anders,
|
||||||
* weil dort keine Adressen, sondern der Umschlagtext erfasst wird.
|
* weil dort keine Adressen, sondern der Umschlagtext erfasst wird.
|
||||||
|
* (empfaengerSchritt ist oben – bei brauchtAdressen/umschlagFreitext – definiert.)
|
||||||
*/
|
*/
|
||||||
const empfaengerSchritt = umschlagFreitext
|
|
||||||
? 'Individueller Text'
|
|
||||||
: brauchtAdressen
|
|
||||||
? 'Empfänger'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Ist das aktuell gewählte Produkt ein „auf Anfrage"-Kontaktprodukt?
|
// Ist das aktuell gewählte Produkt ein „auf Anfrage"-Kontaktprodukt?
|
||||||
const kontaktSelektiert = kontaktProdukte.find((p) => p.key === product) ?? null;
|
const kontaktSelektiert = kontaktProdukte.find((p) => p.key === product) ?? null;
|
||||||
|
|
||||||
|
// Produkt kommt aus dem Direktlink; Extern-Modus ist oben definiert.
|
||||||
|
const angemeldet = !!loadSession();
|
||||||
|
// „Sie sind" entfällt, wenn die Einstufung schon feststeht (eingeloggt mit
|
||||||
|
// person_type) oder im Extern-Modus.
|
||||||
|
const zeigeSieSind = !externModus && !(angemeldet && !!personType);
|
||||||
|
|
||||||
// Anfrageprodukte (Unterschriftenservice, Follow-ups) haben keinen Konfigurator –
|
// Anfrageprodukte (Unterschriftenservice, Follow-ups) haben keinen Konfigurator –
|
||||||
// nach „Weiter" folgt direkt das Anfrageformular als eigene Seite.
|
// nach „Weiter" folgt direkt das Anfrageformular als eigene Seite. Freigeschaltete
|
||||||
const schritte = kontaktSelektiert
|
// Kunden landen NICHT hier (dann ist das Produkt bestellbar, s. bestellbar/kontaktProdukte).
|
||||||
// Direktlink auf ein Kontaktprodukt → direkt zum Anfrageformular (ohne „Sie sind").
|
const schritte = externModus
|
||||||
? (vorwahlProdukt ? ['Anfrage'] : ['Sie sind', 'Produkt', 'Anfrage'])
|
// Extern: der Kunde füllt NUR Empfänger + Text; Menge/Versand/Kuvert/Format
|
||||||
: [
|
// legt der Betreiber über die Direktlink-Parameter fest (s. Extern-Preset).
|
||||||
'Sie sind',
|
// Keine Preise, keine Rechnungsdaten, keine Zahlung.
|
||||||
...(vorwahlProdukt ? [] : ['Produkt']),
|
? [
|
||||||
...(brauchtFormatSchritt ? ['Format'] : []),
|
// Format wählbar, wenn es eine echte Wahl gibt (z. B. Postkarte hoch/quer)
|
||||||
'Menge & Versand',
|
// und nicht per ?format=… vorgegeben wurde.
|
||||||
...(istPostkarte ? ['Motiv'] : []),
|
...(brauchtFormatSchritt && !searchParams.get('format') ? ['Format'] : []),
|
||||||
...(empfaengerSchritt ? [empfaengerSchritt] : []),
|
...(empfaengerSchritt ? [empfaengerSchritt] : []),
|
||||||
'Text',
|
'Text',
|
||||||
'Rechnungsdaten',
|
'Rechnungsdaten',
|
||||||
'Übersicht',
|
'Übersicht',
|
||||||
];
|
]
|
||||||
|
: kontaktSelektiert
|
||||||
|
// Direktlink auf ein Kontaktprodukt → direkt zum Anfrageformular (ohne „Sie sind").
|
||||||
|
? (vorwahlProdukt ? ['Anfrage'] : ['Sie sind', 'Produkt', 'Anfrage'])
|
||||||
|
: [
|
||||||
|
...(zeigeSieSind ? ['Sie sind'] : []),
|
||||||
|
...(vorwahlProdukt ? [] : ['Produkt']),
|
||||||
|
...(brauchtFormatSchritt ? ['Format'] : []),
|
||||||
|
'Menge & Versand',
|
||||||
|
...(istPostkarte ? ['Motiv'] : []),
|
||||||
|
...(empfaengerSchritt ? [empfaengerSchritt] : []),
|
||||||
|
'Text',
|
||||||
|
'Rechnungsdaten',
|
||||||
|
'Übersicht',
|
||||||
|
];
|
||||||
const maxStep = schritte.length - 1;
|
const maxStep = schritte.length - 1;
|
||||||
const name = (i: number) => schritte[i];
|
const name = (i: number) => schritte[i];
|
||||||
|
|
||||||
@@ -626,7 +684,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
const preisMenge = quantityChosen ? quantity : REFERENZ_MENGE;
|
const preisMenge = quantityChosen ? quantity : REFERENZ_MENGE;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!product || kontaktSelektiert) return; // Kontaktprodukte haben keinen Preis
|
// Kontaktprodukte, Extern-Modus und Datei-Produkte zeigen keinen Preis.
|
||||||
|
if (!product || kontaktSelektiert || keinePreise) { setQuote(null); return; }
|
||||||
let abgebrochen = false;
|
let abgebrochen = false;
|
||||||
setQuoteFehler(null);
|
setQuoteFehler(null);
|
||||||
|
|
||||||
@@ -717,6 +776,15 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
const genutztePlatzhalter = useMemo(() => textPlatzhalter(textTemplate), [textTemplate]);
|
const genutztePlatzhalter = useMemo(() => textPlatzhalter(textTemplate), [textTemplate]);
|
||||||
/** Davon die eigenen (nicht aus der Adresse ableitbaren) – nur diese sind editierbar. */
|
/** Davon die eigenen (nicht aus der Adresse ableitbaren) – nur diese sind editierbar. */
|
||||||
const eigeneImText = genutztePlatzhalter.filter((p) => !standardSet.has(p.toLowerCase()));
|
const eigeneImText = genutztePlatzhalter.filter((p) => !standardSet.has(p.toLowerCase()));
|
||||||
|
/** Platzhalter im individuellen Umschlagtext – analog zur Text-Tabelle. */
|
||||||
|
const genutztePlatzhalterUmschlag = useMemo(() => textPlatzhalter(umschlagText), [umschlagText]);
|
||||||
|
/**
|
||||||
|
* Werden Platzhalterwerte NICHT bereits in einem Empfänger-Schritt erfasst
|
||||||
|
* (kein Adress-Schritt, kein Umschlag-Freitext)? Dann muss der Text-Schritt
|
||||||
|
* selbst Tabelle UND Excel-Upload für die Platzhalterwerte anbieten – auch
|
||||||
|
* beim Sammelversand ohne Kuvert.
|
||||||
|
*/
|
||||||
|
const platzhalterNurImText = !brauchtAdressen;
|
||||||
|
|
||||||
|
|
||||||
// PayPal-Buttons erscheinen erst nach der Bestellung – dann steht der
|
// PayPal-Buttons erscheinen erst nach der Bestellung – dann steht der
|
||||||
@@ -761,11 +829,13 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
* Der MwSt-Hinweis wird bewusst nicht angezeigt.
|
* Der MwSt-Hinweis wird bewusst nicht angezeigt.
|
||||||
*/
|
*/
|
||||||
const aufpreis = (key: string): Aufpreis => {
|
const aufpreis = (key: string): Aufpreis => {
|
||||||
|
// Ohne Preise (Datei-Produkt/Extern) an keiner Option Aufpreise zeigen.
|
||||||
|
if (keinePreise) return null;
|
||||||
const it = preisKatalog[key];
|
const it = preisKatalog[key];
|
||||||
if (!it || !it.price) return null;
|
if (!it || !it.price) return null;
|
||||||
// Nur Sammelversand wird für alle identisch (netto) angezeigt. Einzelversand
|
// Nur Sammelversand wird für alle identisch (netto) angezeigt. Einzelversand
|
||||||
// (Porto) und alles andere: Privat=brutto, Unternehmen=netto.
|
// (Porto) und alles andere: Privat=brutto, Unternehmen=netto.
|
||||||
const ohneMwStAnzeige = key === 'sammelversand';
|
const ohneMwStAnzeige = key === 'sammelversand' || key === 'sammelversand_klein';
|
||||||
const betrag = ohneMwStAnzeige || personType === 'unternehmen'
|
const betrag = ohneMwStAnzeige || personType === 'unternehmen'
|
||||||
? it.price
|
? it.price
|
||||||
: it.price * (1 + mwst / 100);
|
: it.price * (1 + mwst / 100);
|
||||||
@@ -797,16 +867,21 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
: entries.every((e) => e.last_name.trim() && e.zip.trim() && e.city.trim());
|
: entries.every((e) => e.last_name.trim() && e.zip.trim() && e.city.trim());
|
||||||
}
|
}
|
||||||
case 'Text':
|
case 'Text':
|
||||||
if (dateiModus) return !!quellDatei;
|
if (dateiModus) return quellDateien.length > 0;
|
||||||
if (textservice) return textBriefing.trim().length > 0;
|
if (textservice) return textBriefing.trim().length > 0;
|
||||||
return textTemplate.trim().length > 0 && !textZuLang && !textUeberlauf;
|
return textTemplate.trim().length > 0 && !textZuLang && !textUeberlauf;
|
||||||
case 'Rechnungsdaten':
|
case 'Rechnungsdaten':
|
||||||
// Pflichtfelder wie im bestehenden Konfigurator (Telefon optional)
|
// Pflichtfelder wie im bestehenden Konfigurator (Telefon optional).
|
||||||
|
// Bei abweichender Lieferadresse sind deren Kernfelder ebenfalls Pflicht.
|
||||||
return (
|
return (
|
||||||
!!rechnung.first_name.trim() && !!rechnung.last_name.trim() &&
|
!!rechnung.first_name.trim() && !!rechnung.last_name.trim() &&
|
||||||
istEmail(rechnung.email) &&
|
istEmail(rechnung.email) &&
|
||||||
!!rechnung.street.trim() && !!rechnung.zip.trim() && !!rechnung.city.trim() &&
|
!!rechnung.street.trim() && !!rechnung.zip.trim() && !!rechnung.city.trim() &&
|
||||||
(personType !== 'unternehmen' || !!rechnung.company.trim())
|
(personType !== 'unternehmen' || !!rechnung.company.trim()) &&
|
||||||
|
(!lieferAbweichend || (
|
||||||
|
!!lieferung.first_name.trim() && !!lieferung.last_name.trim() &&
|
||||||
|
!!lieferung.street.trim() && !!lieferung.zip.trim() && !!lieferung.city.trim()
|
||||||
|
))
|
||||||
);
|
);
|
||||||
default: return false;
|
default: return false;
|
||||||
}
|
}
|
||||||
@@ -822,7 +897,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
if (!paypalMoeglich && zahlung === 'paypal') setZahlung('rechnung');
|
if (!paypalMoeglich && zahlung === 'paypal') setZahlung('rechnung');
|
||||||
}, [paypalMoeglich, zahlung]);
|
}, [paypalMoeglich, zahlung]);
|
||||||
|
|
||||||
const bestellbereit = agb && datenschutz && final && !!quote;
|
// Ohne Preis (Extern-Modus/Datei-Produkt) gibt es keinen quote – dort reicht die Zustimmung.
|
||||||
|
const bestellbereit = agb && datenschutz && final && (!!quote || keinePreise);
|
||||||
|
|
||||||
async function weiter() {
|
async function weiter() {
|
||||||
// Beim Verlassen des Text-Schritts prüfen, ob das Schriftstück zu viele
|
// Beim Verlassen des Text-Schritts prüfen, ob das Schriftstück zu viele
|
||||||
@@ -929,25 +1005,33 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Datei-Produkte (z. B. Unterschriftenservice): Dokument hochladen.
|
// Datei-Produkte (z. B. Unterschriftenservice): ein oder mehrere
|
||||||
let quellDateiId: string | null = null;
|
// Dokumente (PDF/Word/Excel) hochladen.
|
||||||
if (dateiModus && quellDatei) {
|
let quellDateiIds: string[] = [];
|
||||||
|
if (dateiModus && quellDateien.length) {
|
||||||
try {
|
try {
|
||||||
quellDateiId = await uploadFile(quellDatei);
|
quellDateiIds = await Promise.all(quellDateien.map((f) => uploadFile(f)));
|
||||||
} catch {
|
} catch {
|
||||||
setBestellFehler(['Das Dokument konnte nicht hochgeladen werden.']);
|
setBestellFehler(['Ein Dokument konnte nicht hochgeladen werden.']);
|
||||||
setSendet(false);
|
setSendet(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extern-Modus (Etsy o. Ä.): keine Rechnungsdaten erfasst → Platzhalter,
|
||||||
|
// Bestellung ohne Zahlung, als „extern" markiert.
|
||||||
|
const bestellEmail = externModus && !rechnung.email.trim() ? 'extern@skrift.de' : rechnung.email.trim();
|
||||||
|
const bestellVorname = externModus && !rechnung.first_name.trim() ? 'Externe' : rechnung.first_name;
|
||||||
|
const bestellNachname = externModus && !rechnung.last_name.trim() ? 'Bestellung' : rechnung.last_name;
|
||||||
|
|
||||||
const ergebnis = await createOrder({
|
const ergebnis = await createOrder({
|
||||||
motif: istPostkarte && motifMode === 'katalog' ? motifId ?? undefined : undefined,
|
motif: istPostkarte && motifMode === 'katalog' ? motifId ?? undefined : undefined,
|
||||||
motif_upload: motivDateiId ?? undefined,
|
motif_upload: motivDateiId ?? undefined,
|
||||||
source_file: quellDateiId ?? undefined,
|
source_file: quellDateiIds[0] ?? undefined,
|
||||||
email: rechnung.email.trim(),
|
source_files: quellDateiIds.length ? quellDateiIds : undefined,
|
||||||
first_name: rechnung.first_name,
|
email: bestellEmail,
|
||||||
last_name: rechnung.last_name,
|
first_name: bestellVorname,
|
||||||
|
last_name: bestellNachname,
|
||||||
company: rechnung.company,
|
company: rechnung.company,
|
||||||
phone: rechnung.phone,
|
phone: rechnung.phone,
|
||||||
person_type: personType ?? 'privat',
|
person_type: personType ?? 'privat',
|
||||||
@@ -965,14 +1049,14 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
...(designservice ? ['gestaltungsservice'] : []),
|
...(designservice ? ['gestaltungsservice'] : []),
|
||||||
],
|
],
|
||||||
voucher_code: voucher.trim() || undefined,
|
voucher_code: voucher.trim() || undefined,
|
||||||
payment_method: zahlung,
|
payment_method: keinePreise ? 'rechnung' : zahlung,
|
||||||
source: loadSession() ? 'portal' : 'configurator',
|
source: externModus ? 'extern' : (loadSession() ? 'portal' : 'configurator'),
|
||||||
// Gezeigte Vorschau 1:1 übernehmen (nur wenn unverändert seit der Vorschau).
|
// Gezeigte Vorschau 1:1 übernehmen (nur wenn unverändert seit der Vorschau).
|
||||||
preview_session: previewSession ?? undefined,
|
preview_session: previewSession ?? undefined,
|
||||||
// Adress-Snapshot für Bestätigung/Rechnung.
|
// Adress-Snapshot für Bestätigung/Rechnung.
|
||||||
billing: {
|
billing: {
|
||||||
first_name: rechnung.first_name, last_name: rechnung.last_name, company: rechnung.company,
|
first_name: bestellVorname, last_name: bestellNachname, company: rechnung.company,
|
||||||
email: rechnung.email, phone: rechnung.phone, street: rechnung.street,
|
email: bestellEmail, phone: rechnung.phone, street: rechnung.street,
|
||||||
zip: rechnung.zip, city: rechnung.city, country: rechnung.country,
|
zip: rechnung.zip, city: rechnung.city, country: rechnung.country,
|
||||||
},
|
},
|
||||||
shipping_address: lieferAbweichend
|
shipping_address: lieferAbweichend
|
||||||
@@ -992,6 +1076,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
country: e.country,
|
country: e.country,
|
||||||
free_text: adressModus === 'freitext' ? e.free_text : '',
|
free_text: adressModus === 'freitext' ? e.free_text : '',
|
||||||
placeholders: e.placeholders,
|
placeholders: e.placeholders,
|
||||||
|
// Abweichender Text nur, wenn wirklich gesetzt (sonst greift der Standardtext).
|
||||||
|
text: (e.text && e.text.trim()) ? e.text : undefined,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
// Auftrag ist abgesendet – der zwischengespeicherte Entwurf wird verworfen.
|
// Auftrag ist abgesendet – der zwischengespeicherte Entwurf wird verworfen.
|
||||||
@@ -1000,6 +1086,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
nummer: ergebnis.order_number,
|
nummer: ergebnis.order_number,
|
||||||
zahlung: ergebnis.payment_method,
|
zahlung: ergebnis.payment_method,
|
||||||
bezahlt: ergebnis.payment_method !== 'paypal',
|
bezahlt: ergebnis.payment_method !== 'paypal',
|
||||||
|
extern: externModus,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) setBestellFehler(err.details.length ? err.details : [err.message]);
|
if (err instanceof ApiError) setBestellFehler(err.details.length ? err.details : [err.message]);
|
||||||
@@ -1030,9 +1117,11 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p style={{ marginTop: 'var(--sk-space-3)' }} className="sk-note">
|
<p style={{ marginTop: 'var(--sk-space-3)' }} className="sk-note">
|
||||||
{bestellung.zahlung === 'rechnung'
|
{bestellung.extern
|
||||||
? 'Die Rechnung erhalten Sie per E-Mail. Die Produktion startet nach Zahlungseingang.'
|
? 'Die Bestellung wird direkt in die Produktion gegeben – die Abrechnung läuft über die Plattform.'
|
||||||
: 'Ihre Zahlung ist eingegangen. Wir haben Ihnen eine Bestätigung per E-Mail geschickt.'}
|
: bestellung.zahlung === 'rechnung'
|
||||||
|
? 'Die Rechnung erhalten Sie per E-Mail. Die Produktion startet nach Zahlungseingang.'
|
||||||
|
: 'Ihre Zahlung ist eingegangen. Wir haben Ihnen eine Bestätigung per E-Mail geschickt.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1040,6 +1129,16 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
Den aktuellen Stand sehen Sie jederzeit unter „Aufträge" – melden Sie sich dafür mit Ihrer
|
Den aktuellen Stand sehen Sie jederzeit unter „Aufträge" – melden Sie sich dafür mit Ihrer
|
||||||
E-Mail-Adresse an.
|
E-Mail-Adresse an.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Zurück zur Hauptseite – erst wenn die Bestellung abgeschlossen ist
|
||||||
|
(bei offener PayPal-Zahlung nicht, damit niemand vorher abspringt). */}
|
||||||
|
{!offenePayPalZahlung && (
|
||||||
|
<div style={{ marginTop: 'var(--sk-space-4)' }}>
|
||||||
|
<a className="sk-btn sk-btn--primary" href="https://skrift.de">
|
||||||
|
Zurück zu skrift.de
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1058,8 +1157,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
}}>{zeichenHinweis}</div>
|
}}>{zeichenHinweis}</div>
|
||||||
)}
|
)}
|
||||||
{/* Preiszeile erst ab dem zweiten Schritt (Produkt) und nur für
|
{/* Preiszeile erst ab dem zweiten Schritt (Produkt) und nur für
|
||||||
bepreisbare Produkte – Anfrageprodukte haben keinen Preis. */}
|
bepreisbare Produkte – Anfrage-, Extern- und Datei-Produkte ohne Preis. */}
|
||||||
{step > 0 && !kontaktSelektiert && (
|
{step > 0 && !kontaktSelektiert && !keinePreise && (
|
||||||
<div className="sk-pricebar">
|
<div className="sk-pricebar">
|
||||||
<div className="sk-pricebar-inner">
|
<div className="sk-pricebar-inner">
|
||||||
<div className="sk-price-block">
|
<div className="sk-price-block">
|
||||||
@@ -1153,12 +1252,6 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
setQuantity(gueltig);
|
setQuantity(gueltig);
|
||||||
setMengeText(String(gueltig));
|
setMengeText(String(gueltig));
|
||||||
}} />
|
}} />
|
||||||
{nurUpload && (
|
|
||||||
<p className="sk-note" style={{ marginTop: 'var(--sk-space-2)' }}>
|
|
||||||
Ab {TABELLEN_GRENZE + 1} Stück laden Sie die Empfängerdaten als Excel-Datei hoch –
|
|
||||||
eine Vorlage stellen wir im Schritt „Empfänger" bereit.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -1171,7 +1264,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
preis={aufpreis('porto_inland')} />
|
preis={aufpreis('porto_inland')} />
|
||||||
<Option name="ship" selected={shippingType === 'sammel'} onSelect={() => waehleVersand('sammel')}
|
<Option name="ship" selected={shippingType === 'sammel'} onSelect={() => waehleVersand('sammel')}
|
||||||
title="Sammelversand an Sie" desc="Sie erhalten alles zur eigenen Verteilung"
|
title="Sammelversand an Sie" desc="Sie erhalten alles zur eigenen Verteilung"
|
||||||
preis={aufpreis('sammelversand')} />
|
preis={aufpreis(quantity <= 5 ? 'sammelversand_klein' : 'sammelversand')} />
|
||||||
</div>
|
</div>
|
||||||
{shippingType === 'einzeln' && (
|
{shippingType === 'einzeln' && (
|
||||||
<p className="sk-note" style={{ marginTop: 'var(--sk-space-3)' }}>
|
<p className="sk-note" style={{ marginTop: 'var(--sk-space-3)' }}>
|
||||||
@@ -1296,6 +1389,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
{/* ── Empfängerdaten ──────────────────────────────────── */}
|
{/* ── Empfängerdaten ──────────────────────────────────── */}
|
||||||
{/* ── Individueller Umschlagtext (ersetzt die Adressen) ── */}
|
{/* ── Individueller Umschlagtext (ersetzt die Adressen) ── */}
|
||||||
{aktuell === 'Individueller Text' && (
|
{aktuell === 'Individueller Text' && (
|
||||||
|
<>
|
||||||
<section className="sk-card sk-card--head">
|
<section className="sk-card sk-card--head">
|
||||||
<div className="sk-card-head">
|
<div className="sk-card-head">
|
||||||
<h2>Individueller Text</h2>
|
<h2>Individueller Text</h2>
|
||||||
@@ -1304,16 +1398,62 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
<div className="sk-card-body">
|
<div className="sk-card-body">
|
||||||
<div className="sk-field" style={{ marginBottom: 0 }}>
|
<div className="sk-field" style={{ marginBottom: 0 }}>
|
||||||
<label className="sk-label" htmlFor="umschlagtext">Text für den Umschlag</label>
|
<label className="sk-label" htmlFor="umschlagtext">Text für den Umschlag</label>
|
||||||
<input id="umschlagtext" className="sk-input" maxLength={MAX_UMSCHLAG_TEXT}
|
<textarea id="umschlagtext" className="sk-input" rows={4} maxLength={MAX_UMSCHLAG_TEXT}
|
||||||
value={umschlagText}
|
value={umschlagText}
|
||||||
placeholder="z. B. Herzliche Grüße an [[Vorname]]"
|
placeholder="z. B. Herzliche Grüße an [[Vorname]]"
|
||||||
onChange={(e) => setUmschlagText(saeubere(e.target.value.replace(/[\r\n]/g, '')))} />
|
onChange={(e) => setUmschlagText(saeubere(e.target.value))} />
|
||||||
<p className="sk-note" style={{ marginTop: 'var(--sk-space-2)' }}>
|
<p className="sk-note" style={{ marginTop: 'var(--sk-space-2)' }}>
|
||||||
{umschlagText.length}/{MAX_UMSCHLAG_TEXT} Zeichen · keine Zeilenumbrüche
|
{umschlagText.length}/{MAX_UMSCHLAG_TEXT} Zeichen
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Platzhalter-Tabelle: erscheint, sobald der Umschlagtext Platzhalter
|
||||||
|
nutzt – je Empfänger ein Wert (analog zur Text-Tabelle). */}
|
||||||
|
{genutztePlatzhalterUmschlag.length > 0 && (
|
||||||
|
<section className="sk-card sk-card--head" style={{ marginTop: 'var(--sk-space-3)' }}>
|
||||||
|
<div className="sk-card-head">
|
||||||
|
<h2>Platzhalter-Werte</h2>
|
||||||
|
<p>Füllen Sie die im Umschlagtext genutzten Platzhalter je Empfänger aus.</p>
|
||||||
|
</div>
|
||||||
|
<div className="sk-card-body">
|
||||||
|
<div className="sk-table-wrap">
|
||||||
|
<table className="sk-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
{genutztePlatzhalterUmschlag.map((p) => <th key={p}>{p}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((e, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td className="sk-table-no">{i + 1}</td>
|
||||||
|
{genutztePlatzhalterUmschlag.map((p) => {
|
||||||
|
const standard = standardSet.has(p.toLowerCase());
|
||||||
|
return (
|
||||||
|
<td key={p}>
|
||||||
|
<input
|
||||||
|
className="sk-cell"
|
||||||
|
readOnly={standard}
|
||||||
|
title={standard ? 'Kommt aus den Adressdaten' : undefined}
|
||||||
|
style={standard ? { background: 'rgba(15,23,42,0.04)', color: 'var(--sk-muted)' } : undefined}
|
||||||
|
value={standard ? standardWert(e, p) : (e.placeholders[p] ?? '')}
|
||||||
|
onChange={standard ? undefined : (ev) => setPlatzhalter(i, p, ev.target.value)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{aktuell === 'Empfänger' && (
|
{aktuell === 'Empfänger' && (
|
||||||
@@ -1342,17 +1482,18 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
|
|
||||||
<hr className="sk-group-split" />
|
<hr className="sk-group-split" />
|
||||||
|
|
||||||
{/* Art der Erfassung */}
|
{/* Art der Erfassung. Die Tabellen-Option gibt es nur bis zur
|
||||||
|
Tabellengrenze – darüber ausschließlich Excel-Upload. */}
|
||||||
<div className="sk-options">
|
<div className="sk-options">
|
||||||
<Option name="eingabe" selected={eingabeArt === 'excel'}
|
<Option name="eingabe" selected={eingabeArt === 'excel'}
|
||||||
onSelect={() => setEingabeArt('excel')}
|
onSelect={() => setEingabeArt('excel')}
|
||||||
title="Excel-Datei hochladen" />
|
title="Excel-Datei hochladen" />
|
||||||
<Option name="eingabe" selected={eingabeArt === 'tabelle'}
|
{!nurUpload && (
|
||||||
onSelect={() => !nurUpload && setEingabeArt('tabelle')}
|
<Option name="eingabe" selected={eingabeArt === 'tabelle'}
|
||||||
title="Direkt in Tabelle eingeben"
|
onSelect={() => setEingabeArt('tabelle')}
|
||||||
desc={nurUpload
|
title="Direkt in Tabelle eingeben"
|
||||||
? `Ab ${TABELLEN_GRENZE + 1} Empfängern nicht möglich – bitte Datei hochladen`
|
desc={`Bis ${TABELLEN_GRENZE} Empfänger – ohne Datei-Upload`} />
|
||||||
: `Bis ${TABELLEN_GRENZE} Empfänger – ohne Datei-Upload`} />
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{eingabeArt === 'excel' ? (
|
{eingabeArt === 'excel' ? (
|
||||||
@@ -1460,20 +1601,26 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
{dateiModus ? (
|
{dateiModus ? (
|
||||||
<section className="sk-card sk-card--head" style={{ marginTop: 'var(--sk-space-3)' }}>
|
<section className="sk-card sk-card--head" style={{ marginTop: 'var(--sk-space-3)' }}>
|
||||||
<div className="sk-card-head">
|
<div className="sk-card-head">
|
||||||
<h2>Dokument hochladen</h2>
|
<h2>Dokumente hochladen</h2>
|
||||||
<p>Laden Sie das Dokument hoch, das wir unterschreiben und versenden sollen (PDF oder Word).</p>
|
<p>Laden Sie ein oder mehrere Dokumente hoch, die wir unterschreiben und versenden sollen (PDF, Word oder Excel).</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="sk-card-body">
|
<div className="sk-card-body">
|
||||||
<label className="sk-drop">
|
<label className="sk-drop">
|
||||||
<span className="sk-drop-title">Datei hier ablegen oder klicken</span>
|
<span className="sk-drop-title">Dateien hier ablegen oder klicken</span>
|
||||||
<span className="sk-drop-sub" style={{ display: 'block' }}>.pdf, .doc oder .docx</span>
|
<span className="sk-drop-sub" style={{ display: 'block' }}>.pdf, .doc, .docx, .xls, .xlsx – mehrere möglich</span>
|
||||||
<input type="file" accept=".pdf,.doc,.docx"
|
<input type="file" multiple accept=".pdf,.doc,.docx,.xls,.xlsx"
|
||||||
onChange={(e) => setQuellDatei(e.target.files?.[0] ?? null)} />
|
onChange={(e) => setQuellDateien((prev) => [...prev, ...Array.from(e.target.files ?? [])])} />
|
||||||
</label>
|
</label>
|
||||||
{quellDatei && (
|
{quellDateien.length > 0 && (
|
||||||
<p className="sk-note" style={{ marginTop: 'var(--sk-space-3)' }}>
|
<ul className="sk-note" style={{ marginTop: 'var(--sk-space-3)', paddingLeft: '1.1em' }}>
|
||||||
{quellDatei.name} – wird mit der Bestellung übertragen.
|
{quellDateien.map((f, i) => (
|
||||||
</p>
|
<li key={i}>
|
||||||
|
{f.name}
|
||||||
|
<button type="button" className="sk-btn sk-btn--ghost" style={{ marginLeft: 'var(--sk-space-2)', padding: '2px 8px' }}
|
||||||
|
onClick={() => setQuellDateien((prev) => prev.filter((_, idx) => idx !== i))}>Entfernen</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1504,16 +1651,55 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="sk-field" style={{ marginBottom: 0 }}>
|
<div className="sk-field" style={{ marginBottom: 0 }}>
|
||||||
<label className="sk-label" htmlFor="text">
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
|
||||||
Inhalt des Schriftstücks <span className="sk-note">· max. {maxText} Zeichen</span>
|
<label className="sk-label" htmlFor="text" style={{ marginBottom: 0 }}>
|
||||||
</label>
|
Inhalt des Schriftstücks <span className="sk-note">· max. {maxText} Zeichen</span>
|
||||||
<textarea id="text" className="sk-input" rows={10} value={textTemplate}
|
</label>
|
||||||
|
{/* Pfeile: pro Schriftstück eigenen Text eingeben (Standard = #1). */}
|
||||||
|
{proDokText && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||||
|
<button type="button" className="sk-pager-btn" aria-label="Vorheriges Schriftstück"
|
||||||
|
disabled={textDocIndex === 0}
|
||||||
|
onClick={() => setTextDocIndex((i) => Math.max(0, i - 1))}>‹</button>
|
||||||
|
<span className="sk-note" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
Schriftstück {textDocIndex + 1}/{textDocs}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="sk-pager-btn" aria-label="Nächstes Schriftstück"
|
||||||
|
disabled={textDocIndex >= textDocs - 1}
|
||||||
|
onClick={() => setTextDocIndex((i) => Math.min(textDocs - 1, i + 1))}>›</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<textarea id="text" className="sk-input" rows={10} value={aktDocText}
|
||||||
maxLength={maxText}
|
maxLength={maxText}
|
||||||
onChange={(e) => setTextTemplate(saeubere(e.target.value))} />
|
onChange={(e) => setDocText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Tab-Taste fügt einen echten Tabulator ein (statt den Fokus
|
||||||
|
// zu verschieben) – für z.B. das Datum am rechten Rand.
|
||||||
|
if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
if (aktDocText.length >= maxText) return;
|
||||||
|
const ta = e.currentTarget;
|
||||||
|
const s = ta.selectionStart ?? aktDocText.length;
|
||||||
|
const en = ta.selectionEnd ?? s;
|
||||||
|
setDocText(aktDocText.slice(0, s) + '\t' + aktDocText.slice(en));
|
||||||
|
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 1; });
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
// Leerzeilen am Ende automatisch entfernen (zählen sonst als
|
||||||
|
// Zeilen mit und können das Zeilenlimit fälschlich sprengen).
|
||||||
|
const beschnitten = aktDocText.replace(/(?:\r?\n[ \t]*)+$/, '');
|
||||||
|
if (beschnitten !== aktDocText) setDocText(beschnitten);
|
||||||
|
}} />
|
||||||
<p className={textZuLang ? 'sk-error' : 'sk-note'} style={{ marginTop: 'var(--sk-space-2)' }}>
|
<p className={textZuLang ? 'sk-error' : 'sk-note'} style={{ marginTop: 'var(--sk-space-2)' }}>
|
||||||
{textTemplate.length}/{maxText} Zeichen
|
{aktDocText.length}/{maxText} Zeichen
|
||||||
{textZuLang && ' – Text ist zu lang für das gewählte Format.'}
|
{textZuLang && ' – Text ist zu lang für das gewählte Format.'}
|
||||||
</p>
|
</p>
|
||||||
|
{proDokText && textDocIndex > 0 && (
|
||||||
|
<p className="sk-error" style={{ marginTop: 'var(--sk-space-2)' }}>
|
||||||
|
Dieser Text wird nur für Schriftstück {textDocIndex + 1} verwendet (Standard = Text aus #1).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="sk-note" style={{ marginTop: '8px' }}>
|
<p className="sk-note" style={{ marginTop: '8px' }}>
|
||||||
{standardPlatzhalter.length > 0 ? (
|
{standardPlatzhalter.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
@@ -1547,15 +1733,48 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="sk-card-body">
|
<div className="sk-card-body">
|
||||||
|
{platzhalterNurImText && eigeneImText.length > 0 && (
|
||||||
|
<div className="sk-options" style={{ marginBottom: 'var(--sk-space-4)' }}>
|
||||||
|
<Option name="ph-eingabe" selected={eingabeArt === 'excel'}
|
||||||
|
onSelect={() => setEingabeArt('excel')} title="Excel-Datei hochladen" />
|
||||||
|
<Option name="ph-eingabe" selected={eingabeArt === 'tabelle'}
|
||||||
|
onSelect={() => !nurUpload && setEingabeArt('tabelle')}
|
||||||
|
title="Direkt in Tabelle eingeben"
|
||||||
|
desc={nurUpload
|
||||||
|
? `Ab ${TABELLEN_GRENZE + 1} Dokumenten nicht möglich – bitte Datei hochladen`
|
||||||
|
: `Bis ${TABELLEN_GRENZE} Dokumente – ohne Datei-Upload`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{eingabeArt === 'excel' ? (
|
{eingabeArt === 'excel' ? (
|
||||||
<p className="sk-note">
|
platzhalterNurImText ? (
|
||||||
Die Werte kommen aus Ihrer Excel-Datei.
|
<div>
|
||||||
{eigeneImText.length > 0 && (
|
<button type="button" className="sk-btn sk-btn--ghost"
|
||||||
<> Ergänzen Sie eigene Platzhalter ({eigeneImText.map((p) => `[[${p}]]`).join(', ')})
|
onClick={() => ladeVorlageHerunter(eigeneImText, false, true)}>
|
||||||
dort als zusätzliche Spalten – laden Sie die Vorlage im Schritt „Empfänger" neu,
|
Vorlage herunterladen
|
||||||
falls nötig.</>
|
</button>
|
||||||
)}
|
<label className="sk-drop" style={{ marginTop: 'var(--sk-space-4)' }}>
|
||||||
</p>
|
<span className="sk-drop-title">Ausgefüllte Datei hier ablegen oder klicken</span>
|
||||||
|
<span className="sk-drop-sub" style={{ display: 'block' }}>.xlsx oder .csv</span>
|
||||||
|
<input type="file" accept=".xlsx,.xls,.csv"
|
||||||
|
onChange={(e) => excelEinlesen(e.target.files?.[0] ?? null)} />
|
||||||
|
</label>
|
||||||
|
{excelDatei && !excelFehler && (
|
||||||
|
<p className="sk-note" style={{ marginTop: 'var(--sk-space-3)' }}>
|
||||||
|
{excelDatei.name} – {entries.length} Zeile(n) gelesen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{excelFehler && <p className="sk-error">{excelFehler}</p>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="sk-note">
|
||||||
|
Die Werte kommen aus Ihrer Excel-Datei.
|
||||||
|
{eigeneImText.length > 0 && (
|
||||||
|
<> Ergänzen Sie eigene Platzhalter ({eigeneImText.map((p) => `[[${p}]]`).join(', ')})
|
||||||
|
dort als zusätzliche Spalten – laden Sie die Vorlage im Schritt „Empfänger" neu,
|
||||||
|
falls nötig.</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="sk-table-wrap">
|
<div className="sk-table-wrap">
|
||||||
<table className="sk-table">
|
<table className="sk-table">
|
||||||
@@ -1608,8 +1827,6 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
<p>An diese Adresse stellen wir die Rechnung aus.</p>
|
<p>An diese Adresse stellen wir die Rechnung aus.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="sk-card-body">
|
<div className="sk-card-body">
|
||||||
{/* Adressauswahl für angemeldete Kunden mit gespeicherten Adressen. */}
|
|
||||||
{adressWahl('r-gespeichert', adresseUebernehmen)}
|
|
||||||
<Feld id="r-vn" label="Vorname" value={rechnung.first_name} required
|
<Feld id="r-vn" label="Vorname" value={rechnung.first_name} required
|
||||||
onChange={(v) => setRechnung({ ...rechnung, first_name: v })} />
|
onChange={(v) => setRechnung({ ...rechnung, first_name: v })} />
|
||||||
<Feld id="r-nn" label="Nachname" value={rechnung.last_name} required
|
<Feld id="r-nn" label="Nachname" value={rechnung.last_name} required
|
||||||
@@ -1640,6 +1857,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Abweichende Lieferadresse gibt es nur beim Sammelversand –
|
||||||
|
beim Einzelversand geht alles direkt an die Empfänger. */}
|
||||||
|
{shippingType === 'sammel' && (
|
||||||
<section className="sk-card" style={{ marginTop: 'var(--sk-space-3)' }}>
|
<section className="sk-card" style={{ marginTop: 'var(--sk-space-3)' }}>
|
||||||
<label className="sk-check">
|
<label className="sk-check">
|
||||||
<input type="checkbox" checked={lieferAbweichend}
|
<input type="checkbox" checked={lieferAbweichend}
|
||||||
@@ -1649,7 +1869,6 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
|
|
||||||
{lieferAbweichend && (
|
{lieferAbweichend && (
|
||||||
<div style={{ marginTop: '18px' }}>
|
<div style={{ marginTop: '18px' }}>
|
||||||
{adressWahl('l-gespeichert', lieferungUebernehmen)}
|
|
||||||
<Feld id="l-vn" label="Vorname" value={lieferung.first_name} required
|
<Feld id="l-vn" label="Vorname" value={lieferung.first_name} required
|
||||||
onChange={(v) => setLieferung({ ...lieferung, first_name: v })} />
|
onChange={(v) => setLieferung({ ...lieferung, first_name: v })} />
|
||||||
<Feld id="l-nn" label="Nachname" value={lieferung.last_name} required
|
<Feld id="l-nn" label="Nachname" value={lieferung.last_name} required
|
||||||
@@ -1670,12 +1889,23 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Übersicht ───────────────────────────────────────── */}
|
{/* ── Übersicht ───────────────────────────────────────── */}
|
||||||
{aktuell === 'Übersicht' && (
|
{aktuell === 'Übersicht' && (
|
||||||
<>
|
<>
|
||||||
|
{keinePreise ? (
|
||||||
|
<section className="sk-card">
|
||||||
|
<p className="sk-note" style={{ margin: 0 }}>
|
||||||
|
{externModus
|
||||||
|
? 'Externe Bestellung – sie wird ohne Preis und ohne Zahlung direkt zur Produktion eingereiht (Abrechnung erfolgt über die Plattform).'
|
||||||
|
: 'Der Preis wird individuell berechnet – Sie erhalten die Rechnung anschließend per E-Mail.'}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<section className="sk-card">
|
<section className="sk-card">
|
||||||
<h2>Kostenübersicht</h2>
|
<h2>Kostenübersicht</h2>
|
||||||
{quote ? (
|
{quote ? (
|
||||||
@@ -1726,6 +1956,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<section style={{ marginTop: 'var(--sk-space-3)' }}>
|
<section style={{ marginTop: 'var(--sk-space-3)' }}>
|
||||||
<label className="sk-check sk-check--card">
|
<label className="sk-check sk-check--card">
|
||||||
@@ -1774,7 +2006,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
|||||||
|
|
||||||
<button className="sk-btn sk-btn--primary sk-btn--block" style={{ marginTop: 'var(--sk-space-5)' }}
|
<button className="sk-btn sk-btn--primary sk-btn--block" style={{ marginTop: 'var(--sk-space-5)' }}
|
||||||
disabled={!bestellbereit || sendet} onClick={bestellen}>
|
disabled={!bestellbereit || sendet} onClick={bestellen}>
|
||||||
{sendet ? 'Wird gesendet …' : 'Zahlungspflichtig bestellen'}
|
{sendet ? 'Wird gesendet …' : externModus ? 'Bestellung absenden' : keinePreise ? 'Kostenpflichtig bestellen' : 'Zahlungspflichtig bestellen'}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { fetchMyCustomer, saveMyCustomer, type Customer, type CustomerAddress } from '../lib/api';
|
import { fetchMyCustomer, saveMyCustomer, type Customer, type Adresse2 } from '../lib/api';
|
||||||
|
|
||||||
const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien', 'Niederlande', 'Frankreich'];
|
const 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. */
|
/** Stammdaten des angemeldeten Kunden – füllen später den Konfigurator vor. */
|
||||||
export default function Stammdaten() {
|
export default function Stammdaten() {
|
||||||
@@ -37,13 +36,29 @@ export default function Stammdaten() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Adressen (mehrere möglich) ──
|
// ── Zwei feste Adressen (Rechnung + Lieferung, ohne Bezeichnung) ──
|
||||||
const adressen = c?.addresses ?? [];
|
const setAdrFeld = (slot: 'billing_address' | 'shipping_address', k: keyof Adresse2, v: string) =>
|
||||||
const setAdressen = (next: CustomerAddress[]) => c && setC({ ...c, addresses: next });
|
c && setC({ ...c, [slot]: { ...(c[slot] ?? {}), [k]: v } });
|
||||||
const setAdrFeld = (i: number, k: keyof CustomerAddress, v: string) =>
|
|
||||||
setAdressen(adressen.map((a, idx) => (idx === i ? { ...a, [k]: v } : a)));
|
const adressBlock = (slot: 'billing_address' | 'shipping_address', titel: string) => {
|
||||||
const addAdresse = () => setAdressen([...adressen, leereAdresse()]);
|
const a: Adresse2 = c?.[slot] ?? {};
|
||||||
const removeAdresse = (i: number) => setAdressen(adressen.filter((_, idx) => idx !== i));
|
return (
|
||||||
|
<div className="sk-check--card" style={{ padding: 'var(--sk-space-4)', marginBottom: 'var(--sk-space-3)', border: '1px solid var(--sk-border)', borderRadius: 'var(--sk-radius, 10px)' }}>
|
||||||
|
<label className="sk-label" style={{ marginBottom: 'var(--sk-space-2)', display: 'block' }}>{titel}</label>
|
||||||
|
<input className="sk-input" style={{ marginBottom: 'var(--sk-space-2)' }} placeholder="Straße & Hausnummer"
|
||||||
|
value={a.street ?? ''} onChange={(e) => setAdrFeld(slot, 'street', e.target.value)} />
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
|
||||||
|
<input className="sk-input" placeholder="PLZ" value={a.zip ?? ''}
|
||||||
|
onChange={(e) => setAdrFeld(slot, 'zip', e.target.value.replace(/[^0-9]/g, ''))} style={{ flex: '0 0 30%' }} />
|
||||||
|
<input className="sk-input" placeholder="Ort" value={a.city ?? ''}
|
||||||
|
onChange={(e) => setAdrFeld(slot, 'city', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<select className="sk-input" value={a.country ?? 'Deutschland'} onChange={(e) => setAdrFeld(slot, 'country', e.target.value)}>
|
||||||
|
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const feld = (k: keyof Customer, label: string, type = 'text') => (
|
const feld = (k: keyof Customer, label: string, type = 'text') => (
|
||||||
<div className="sk-field">
|
<div className="sk-field">
|
||||||
@@ -92,34 +107,27 @@ export default function Stammdaten() {
|
|||||||
<input className="sk-input" value={c.email} disabled />
|
<input className="sk-input" value={c.email} disabled />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Adressen – beliebig viele; im Konfigurator wählbar. */}
|
{/* Zwei feste Adressen – werden automatisch im Bestellvorgang eingesetzt. */}
|
||||||
<div className="sk-field" style={{ marginBottom: 0 }}>
|
<div className="sk-field" style={{ marginBottom: 0 }}>
|
||||||
<label className="sk-label">Adressen</label>
|
{adressBlock('billing_address', 'Rechnungsadresse')}
|
||||||
{adressen.length === 0 && <p className="sk-note">Noch keine Adresse hinterlegt.</p>}
|
{adressBlock('shipping_address', 'Lieferadresse')}
|
||||||
{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>
|
</div>
|
||||||
|
|
||||||
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-5)' }} disabled={status === 'speichert'}>
|
{/* Zusätzliche Login-Mailadressen (Pflege durch Skrift). */}
|
||||||
|
{c.alt_emails && c.alt_emails.length > 0 && (
|
||||||
|
<div className="sk-field" style={{ marginTop: 'var(--sk-space-4)', marginBottom: 0 }}>
|
||||||
|
<label className="sk-label">Weitere Login-Adressen</label>
|
||||||
|
<p className="sk-note">{c.alt_emails.join(', ')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'gespeichert' && (
|
||||||
|
<p className="sk-note" role="status" style={{
|
||||||
|
marginTop: 'var(--sk-space-4)', color: 'var(--sk-success, #2e7d32)', fontWeight: 600,
|
||||||
|
}}>✓ Stammdaten gespeichert.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-4)' }} disabled={status === 'speichert'}>
|
||||||
{status === 'speichert' ? 'Wird gespeichert …' : status === 'gespeichert' ? 'Gespeichert' : 'Speichern'}
|
{status === 'speichert' ? 'Wird gespeichert …' : status === 'gespeichert' ? 'Gespeichert' : 'Speichern'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -366,7 +366,9 @@ button { font-family: inherit; }
|
|||||||
.sk-pricebar {
|
.sk-pricebar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
/* Muss ÜBER der (ebenfalls sticky) Vorschau liegen, sonst schiebt sich die
|
||||||
|
Vorschau beim Scrollen über die Preiszeile (Kopfzeile). */
|
||||||
|
z-index: 30;
|
||||||
background: var(--sk-white);
|
background: var(--sk-white);
|
||||||
border-bottom: 1px solid var(--sk-border);
|
border-bottom: 1px solid var(--sk-border);
|
||||||
padding: var(--sk-space-3) var(--sk-space-6);
|
padding: var(--sk-space-3) var(--sk-space-6);
|
||||||
@@ -416,11 +418,15 @@ button { font-family: inherit; }
|
|||||||
|
|
||||||
.sk-preview {
|
.sk-preview {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: calc(var(--sk-space-6) + 40px);
|
/* Unterhalb der sticky Preisleiste parken, damit beide sich nicht überlagern. */
|
||||||
|
top: 84px;
|
||||||
border: 1px solid var(--sk-border);
|
border: 1px solid var(--sk-border);
|
||||||
border-radius: var(--sk-radius-lg);
|
border-radius: var(--sk-radius-lg);
|
||||||
padding: var(--sk-space-4);
|
padding: var(--sk-space-4);
|
||||||
background: var(--sk-white);
|
background: var(--sk-white);
|
||||||
|
/* Über den (sticky) Tabellenzellen, damit die Platzhalter-Tabelle nicht in
|
||||||
|
die Vorschau-Spalte hineinmalt – aber UNTER der Preisleiste (z-index 30). */
|
||||||
|
z-index: 20;
|
||||||
}
|
}
|
||||||
.sk-preview-head {
|
.sk-preview-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -625,10 +631,12 @@ button { font-family: inherit; }
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: var(--sk-card);
|
background: var(--sk-card);
|
||||||
/* bleibt beim Scrollen stehen – wie im bestehenden Konfigurator */
|
/* bleibt beim Scrollen stehen – wie im bestehenden Konfigurator.
|
||||||
|
Niedriger z-index als Vorschau/Preisleiste, damit die Tabelle nicht
|
||||||
|
über benachbarte Panels malt. */
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 3;
|
||||||
border-bottom: 2px solid rgba(15, 23, 42, 0.1);
|
border-bottom: 2px solid rgba(15, 23, 42, 0.1);
|
||||||
}
|
}
|
||||||
.sk-table td {
|
.sk-table td {
|
||||||
@@ -650,7 +658,7 @@ button { font-family: inherit; }
|
|||||||
/* bleibt beim horizontalen Scrollen sichtbar */
|
/* bleibt beim horizontalen Scrollen sichtbar */
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 5;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
.sk-cell {
|
.sk-cell {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -686,22 +694,25 @@ button { font-family: inherit; }
|
|||||||
background: var(--sk-white);
|
background: var(--sk-white);
|
||||||
border-radius: var(--sk-radius);
|
border-radius: var(--sk-radius);
|
||||||
padding: var(--sk-space-4);
|
padding: var(--sk-space-4);
|
||||||
/* Auf den Inhalt schrumpfen, damit die Seite als klar abgesetztes Blatt
|
box-sizing: border-box;
|
||||||
schwebt und nicht über die halbe Fläche „ausläuft". */
|
/* Das Seitenverhältnis wird inline aus der viewBox gesetzt (aspectRatio).
|
||||||
|
Zusammen mit beiden Grenzen skaliert das Blatt IMMER proportional in die
|
||||||
|
jeweils kleinere Grenze – nie verzerrt, egal welche Fenstergröße. */
|
||||||
max-width: min(92vw, 720px);
|
max-width: min(92vw, 720px);
|
||||||
|
max-height: 80vh;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
/* Über die Höhe begrenzen: Das SVG trägt sein A4-Seitenverhältnis (mm) selbst,
|
|
||||||
daher skaliert es proportional und die ganze Seite bleibt sichtbar – kein
|
|
||||||
Überlaufen mehr wie bei width:100%. */
|
|
||||||
.sk-lightbox-sheet svg {
|
.sk-lightbox-sheet svg {
|
||||||
display: block;
|
display: block;
|
||||||
width: auto;
|
width: 100%;
|
||||||
height: auto;
|
height: 100%;
|
||||||
max-width: 100%;
|
object-fit: contain;
|
||||||
max-height: 78vh;
|
|
||||||
}
|
}
|
||||||
|
/* Aktionszeile (Bild speichern / Schließen) unter dem Blatt. */
|
||||||
|
.sk-lightbox-actions { display: flex; gap: var(--sk-space-3); }
|
||||||
.sk-lightbox-close {
|
.sk-lightbox-close {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: var(--sk-size-sm);
|
font-size: var(--sk-size-sm);
|
||||||
@@ -1080,6 +1091,18 @@ textarea.sk-input { resize: vertical; }
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reset-Hinweis + Button: eigener, abgesetzter Block mit Luft (sonst gequetscht). */
|
||||||
|
.sk-help-reset {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--sk-space-3);
|
||||||
|
margin-top: var(--sk-space-5);
|
||||||
|
padding-top: var(--sk-space-4);
|
||||||
|
border-top: 1px solid var(--sk-border);
|
||||||
|
}
|
||||||
|
.sk-help-reset .sk-note { margin: 0; }
|
||||||
|
|
||||||
/* Kontakt-Produkt: sieht aus wie eine Option, führt aber zum Angebot. */
|
/* Kontakt-Produkt: sieht aus wie eine Option, führt aber zum Angebot. */
|
||||||
.sk-option--contact { cursor: pointer; }
|
.sk-option--contact { cursor: pointer; }
|
||||||
.sk-option--contact .sk-option-price-unit { color: var(--sk-accent); font-weight: 600; }
|
.sk-option--contact .sk-option-price-unit { color: var(--sk-accent); font-weight: 600; }
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/appshell.tsx","./src/components/icons.tsx","./src/components/offerform.tsx","./src/components/previewpanel.tsx","./src/lib/api.ts","./src/lib/dialogs.tsx","./src/lib/excel.ts","./src/lib/paypal.ts","./src/lib/status.ts","./src/lib/wizard.tsx","./src/routes/auftraege.tsx","./src/routes/configurator.tsx","./src/routes/login.tsx","./src/routes/stammdaten.tsx"],"version":"5.9.3"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/appshell.tsx","./src/components/icons.tsx","./src/components/offerform.tsx","./src/components/previewpanel.tsx","./src/lib/api.ts","./src/lib/dialogs.tsx","./src/lib/excel.ts","./src/lib/paypal.ts","./src/lib/status.ts","./src/lib/wizard.tsx","./src/routes/auftraege.tsx","./src/routes/configurator.tsx","./src/routes/login.tsx","./src/routes/muster.tsx","./src/routes/stammdaten.tsx"],"version":"5.9.3"}
|
||||||
Binary file not shown.
@@ -50,7 +50,7 @@ function skrift_preis_sync() {
|
|||||||
return $json['data'];
|
return $json['data'];
|
||||||
};
|
};
|
||||||
|
|
||||||
$produkte = $hole('/items/products?fields=key,name,base_price,norm_qty,pricing_mode,type&filter[active][_eq]=true&limit=-1');
|
$produkte = $hole('/items/products?fields=key,name,base_price,norm_qty,price_factor_a,price_factor_e,pricing_mode,type&filter[active][_eq]=true&limit=-1');
|
||||||
$settings = $hole('/items/pricing_settings?fields=*');
|
$settings = $hole('/items/pricing_settings?fields=*');
|
||||||
$items = $hole('/items/price_items?fields=key,price,unit,category&filter[active][_eq]=true&limit=-1');
|
$items = $hole('/items/price_items?fields=key,price,unit,category&filter[active][_eq]=true&limit=-1');
|
||||||
|
|
||||||
@@ -150,6 +150,9 @@ function skrift_preis_parameter($daten, $produktKey, $typ = 'privat') {
|
|||||||
|
|
||||||
// Normalpreis-Menge kommt vom Produkt; ohne Wert kein Kleinmengen-Aufschlag.
|
// Normalpreis-Menge kommt vom Produkt; ohne Wert kein Kleinmengen-Aufschlag.
|
||||||
$norm = isset($p['norm_qty']) ? (float) $p['norm_qty'] : 0.0;
|
$norm = isset($p['norm_qty']) ? (float) $p['norm_qty'] : 0.0;
|
||||||
|
// Kleinmengen-Aufschlag je Produkt (a = Deckel, e = Exponent). Leer → 1 / 3.
|
||||||
|
$a = (isset($p['price_factor_a']) && $p['price_factor_a'] !== null && $p['price_factor_a'] !== '') ? (float) $p['price_factor_a'] : 1.0;
|
||||||
|
$e = (isset($p['price_factor_e']) && $p['price_factor_e'] !== null && $p['price_factor_e'] !== '') ? (float) $p['price_factor_e'] : 3.0;
|
||||||
// Eine gemeinsame Formel.
|
// Eine gemeinsame Formel.
|
||||||
$formel = !empty($s['multiplier_formula']) ? $s['multiplier_formula'] : '';
|
$formel = !empty($s['multiplier_formula']) ? $s['multiplier_formula'] : '';
|
||||||
|
|
||||||
@@ -166,6 +169,8 @@ function skrift_preis_parameter($daten, $produktKey, $typ = 'privat') {
|
|||||||
return array(
|
return array(
|
||||||
'base' => isset($p['base_price']) ? (float) $p['base_price'] : 0.0,
|
'base' => isset($p['base_price']) ? (float) $p['base_price'] : 0.0,
|
||||||
'norm' => $norm,
|
'norm' => $norm,
|
||||||
|
'a' => $a,
|
||||||
|
'e' => $e,
|
||||||
'formel' => $formel,
|
'formel' => $formel,
|
||||||
'mwst' => isset($s['mwst_percent']) ? (float) $s['mwst_percent'] : 19.0,
|
'mwst' => isset($s['mwst_percent']) ? (float) $s['mwst_percent'] : 19.0,
|
||||||
'tiers' => $tiers,
|
'tiers' => $tiers,
|
||||||
@@ -191,6 +196,7 @@ function skrift_preis_rechne_param($par, $menge, $mitKuvert) {
|
|||||||
$mult = skrift_formel_eval($par['formel'], array(
|
$mult = skrift_formel_eval($par['formel'], array(
|
||||||
'qty' => $menge, 'menge' => $menge,
|
'qty' => $menge, 'menge' => $menge,
|
||||||
'norm' => $par['norm'], 'base_price' => $par['base'],
|
'norm' => $par['norm'], 'base_price' => $par['base'],
|
||||||
|
'a' => $par['a'], 'e' => $par['e'],
|
||||||
), 1.0);
|
), 1.0);
|
||||||
}
|
}
|
||||||
if (!is_finite($mult) || $mult <= 0) $mult = 1.0;
|
if (!is_finite($mult) || $mult <= 0) $mult = 1.0;
|
||||||
@@ -377,7 +383,7 @@ function skrift_preis_render($atts, $mitMwst) {
|
|||||||
function berechne(m, mitKuvert) {
|
function berechne(m, mitKuvert) {
|
||||||
m = Math.max(1, parseInt(m, 10) || 1);
|
m = Math.max(1, parseInt(m, 10) || 1);
|
||||||
var mult = par.norm > 0
|
var mult = par.norm > 0
|
||||||
? formelEval(par.formel, { qty: m, menge: m, norm: par.norm, base_price: par.base })
|
? formelEval(par.formel, { qty: m, menge: m, norm: par.norm, base_price: par.base, a: par.a, e: par.e })
|
||||||
: 1;
|
: 1;
|
||||||
if (!isFinite(mult) || mult <= 0) mult = 1;
|
if (!isFinite(mult) || mult <= 0) mult = 1;
|
||||||
var grund = par.base * m * mult;
|
var grund = par.base * m * mult;
|
||||||
|
|||||||
Reference in New Issue
Block a user