diff --git a/Docker/directus/.env.example b/Docker/directus/.env.example index 9f2de30..1c88e7d 100644 --- a/Docker/directus/.env.example +++ b/Docker/directus/.env.example @@ -46,6 +46,8 @@ SKRIFT_TEAM_EMAIL=hello@skrift.de # Frei wählbarer langer Zufallswert; muss identisch als ?token=... in der # Webhook-URL stehen. Ohne diesen Wert lehnt der Webhook alle Anfragen ab. SKRIFT_WEBHOOK_TOKEN=CHANGE-ME-langer-zufallswert +# Signatur-Plotter-Service (intern über das npm-Netz). Default passt zum container_name. +SKRIFT_SIGN_URL=http://skrift-signature-service:8000 # ── PayPal (server-seitige Zahlungsprüfung) ───────────────── # Client-ID ist öffentlich (auch im Frontend), das Secret bleibt NUR hier. diff --git a/Docker/directus/bootstrap/bootstrap.mjs b/Docker/directus/bootstrap/bootstrap.mjs index a77a60a..49d4f20 100644 --- a/Docker/directus/bootstrap/bootstrap.mjs +++ b/Docker/directus/bootstrap/bootstrap.mjs @@ -130,8 +130,16 @@ const USER_FIELDS = [ dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat', note: 'Einstufung – nur Betreiber ändert sie.' }), f('company', 'string', { interface: 'input', note: 'Firmenname (bei Unternehmen).' }), f('phone', 'string', { interface: 'input' }), - f('addresses', 'json', { interface: 'list', note: 'Adressen des Kunden', options: { fields: ADDRESS_SUBFIELDS } }), + // Genau zwei feste Adressen je Konto (ohne Namen/Bezeichnung): Rechnung + Lieferung. + // Werden automatisch an der passenden Stelle im Bestellvorgang eingesetzt. + f('billing_address', 'json', { interface: 'input-code', note: 'Rechnungsadresse {street,zip,city,country}.' }), + f('shipping_address', 'json', { interface: 'input-code', note: 'Lieferadresse {street,zip,city,country}.' }), + // Veraltet (frühere Mehrfach-Adressliste) – bleibt für Alt-Daten erhalten. + f('addresses', 'json', { interface: 'list', hidden: true, note: 'Veraltet – ersetzt durch billing_address/shipping_address.', options: { fields: ADDRESS_SUBFIELDS } }), f('notes', 'text', { interface: 'input-multiline', note: 'Interne Notizen (für den Kunden nicht sichtbar).' }), + // Zusätzliche Login-Mailadressen: Wer sich mit einer dieser Mails per Code + // anmeldet, landet im SELBEN Konto (voller Zugriff). Liste von E-Mails. + f('alt_emails', 'json', { interface: 'tags', note: 'Weitere Login-Mailadressen (gleiches Konto).' }), { field: 'signatures', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Hinterlegte Unterschriften des Kunden.' }, schema: null }, { field: 'discounts', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Kundenspezifische Rabatte.' }, schema: null }, { field: 'unlocked_products', type: 'alias', meta: { interface: 'list-o2m', special: ['o2m'], note: 'Individuell freigeschaltete Produkte.' }, schema: null }, @@ -241,7 +249,7 @@ const collections = [ m2o('format', 'formats'), dropdown('person_type', ['privat', 'unternehmen'], { default: 'privat' }), dropdown('font', ['tilda', 'alva', 'ellie']), - dropdown('source', ['configurator', 'portal', 'operator'], { default: 'configurator' }), + dropdown('source', ['configurator', 'portal', 'operator', 'extern'], { default: 'configurator', note: '„extern" = Bestellung über eine andere Plattform (z. B. Etsy), ohne Preis/Zahlung.' }), dropdown('status', ['entwurf', 'vorbereitet', 'wartet_auf_zahlung', 'bezahlt', 'material_erwartet', 'material_eingegangen', 'in_queue', 'in_produktion', 'gedruckt', 'versendet', 'abgeschlossen', 'storniert'], { default: 'entwurf' }), @@ -266,8 +274,10 @@ const collections = [ f('preview_session', 'string', { interface: 'input', note: 'Vorschau-Session – gezeigte Dokumente werden 1:1 übernommen.' }), // Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF/Word). { field: 'source_file', type: 'uuid', - meta: { interface: 'file', special: ['file'], note: 'Hochgeladenes Dokument (Datei-Produkte).' }, + meta: { interface: 'file', special: ['file'], note: 'Hochgeladenes Dokument (Datei-Produkte) – erstes/primäres.' }, schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' }, + // Alle hochgeladenen Dokumente (mehrere PDF/Word/Excel möglich) als Datei-ID-Liste. + f('source_files', 'json', { interface: 'tags', note: 'Alle hochgeladenen Dokumente (Datei-IDs).' }), // Rechnungs-/Lieferadresse als Snapshot (für Bestätigung, Rechnung, Versand). f('billing', 'json', { interface: 'input-code', note: 'Rechnungsadresse (Snapshot bei Bestellung).' }), f('shipping_address', 'json', { interface: 'input-code', note: 'Abweichende Lieferadresse (falls vorhanden).' }), @@ -435,6 +445,12 @@ const collections = [ { field: 'file', type: 'uuid', meta: { interface: 'file', special: ['file'], note: 'Signatur als SVG-Datei (Upload).' }, schema: { is_nullable: true }, __related: 'directus_files', __onDelete: 'SET NULL' }, + // Voreingestellte Platzierung relativ zum Marker im PDF (§§SIG1§§ …). + // Wird im Signatur-Modul als Vorgabe geladen, dort manuell überschreibbar. + f('marker_key', 'string', { interface: 'input', default: 'SIG1', note: 'Marker-Token im PDF (ohne §§), z. B. SIG1.' }), + f('offset_x_mm', 'float', { interface: 'input', default: 0, note: 'Versatz X zum Marker (mm).' }), + f('offset_y_mm', 'float', { interface: 'input', default: 0, note: 'Versatz Y zum Marker (mm).' }), + f('width_mm', 'float', { interface: 'input', default: 40, note: 'Breite der Signatur (mm).' }), { field: 'date_created', type: 'timestamp', meta: { interface: 'datetime', readonly: true, special: ['date-created'] }, schema: {} }, ] }, @@ -472,6 +488,7 @@ const collections = [ { collection: 'email_settings', meta: { icon: 'alternate_email', singleton: true, note: 'Absender, Signatur, Portal-Link, AGB + Footer für alle Mails.' }, fields: [ f('sender_name', 'string', { interface: 'input', note: 'Absendername (erscheint als Absender, z. B. „Skrift").' }), f('sender_email', 'string', { interface: 'input', note: 'Absenderadresse (muss vom SMTP-Server erlaubt sein).' }), + f('team_email', 'string', { interface: 'input', note: 'Interne Benachrichtigungen (neue Bestellung/Muster/Anfrage). Leer = keine Team-Mails.' }), f('signature', 'text', { interface: 'input-rich-text-html', note: 'Signatur unter Mails ohne eigenen Footer (WYSIWYG/HTML).' }), f('portal_url', 'string', { interface: 'input', note: 'Basis-URL des Kundenportals (Platzhalter {{link}}).' }), f('company_footer', 'text', { interface: 'input-rich-text-html', note: 'Footer-Adresszeile (Platzhalter {{footer}}).' }), @@ -622,6 +639,21 @@ async function applySeed() { ]; const productByKey = {}; for (const p of products) productByKey[p.key] = await seedItem('products', 'key', p); + // Unterschriftenservice muss im Datei-Modus laufen (Text-Schritt = Upload) UND + // bepreisbar sein (formel), damit freigeschaltete Kunden ihn regulär bestellen + // können. Auch für bestehende Datensätze erzwingen (seedItem aktualisiert nicht). + try { + const us = productByKey.unterschriftenservice; + if (us?.id) { + const patch = {}; + if (us.input_mode !== 'datei') patch.input_mode = 'datei'; + if (us.pricing_mode !== 'formel') patch.pricing_mode = 'formel'; + if (Object.keys(patch).length) { + await api('PATCH', `/items/products/${us.id}`, patch); + log('unterschriftenservice →', JSON.stringify(patch)); + } + } + } catch { /* nicht kritisch */ } const formats = [ { product: productByKey.briefe.id, key: 'a4', name: 'A4', active: true }, @@ -733,6 +765,11 @@ async function applySeed() { body: `

${G}

Ihr Auftrag {{order_id}} ist abgeschlossen. Wir freuen uns auf Ihre nächste Bestellung.

` }, { key: 'storniert', label: 'Status: storniert', subject: '{{order_id}} wurde storniert', append_signature: true, attach_agb: false, body: `

${G}

Ihr Auftrag {{order_id}} wurde storniert. Bei Fragen sind wir gerne für Sie da.

{{reason}}` }, + // 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: `

${G}

vielen Dank – Ihr Auftrag {{order_number}} für den Unterschriftenservice ist bei uns eingegangen. Wir prüfen Ihr Dokument und melden uns mit den nächsten Schritten.

` }, + { key: 'extern_eingang', label: 'Externe Bestellung: Eingang', subject: 'Ihre Bestellung {{order_number}} ist eingegangen', append_signature: true, attach_agb: false, + body: `

${G}

vielen Dank – Ihre Bestellung {{order_number}} ist bei uns eingegangen und geht direkt in die Produktion.

` }, ]; for (const t of templates) await seedItem('email_templates', 'key', { enabled: true, ...t }); @@ -775,6 +812,7 @@ async function applySeed() { await api('PATCH', '/items/email_settings', { sender_name: 'Skrift', sender_email: (process.env.EMAIL_FROM || 'noreply@skrift.de').trim(), + team_email: (process.env.SKRIFT_TEAM_EMAIL || 'hello@skrift.de').trim(), signature: '

Mit freundlichen Grüßen
Ihr Skrift-Team

', portal_url: (process.env.CORS_ORIGIN || 'https://app.skrift.de').split(',')[0].trim(), company_footer: 'Skrift | Hundscheiderweg 4 | 66679 Losheim am See | www.skrift.de', @@ -883,12 +921,12 @@ async function applyPermissions() { await ensureAccess(custRole.id, custPol.id); // Kunde = eigenes Login-Konto: eigenes Profil lesen … await ensurePermission(custPol.id, 'directus_users', 'read', - ['id', 'email', 'first_name', 'last_name', 'company', 'phone', 'person_type', 'addresses'], + ['id', 'email', 'first_name', 'last_name', 'company', 'phone', 'person_type', 'addresses', 'billing_address', 'shipping_address', 'alt_emails'], { id: { _eq: '$CURRENT_USER' } }); // … und begrenzt selbst pflegen. person_type bewusst NICHT enthalten – die // Einstufung privat/Unternehmen legt der Betreiber fest, nicht der Kunde. await ensurePermission(custPol.id, 'directus_users', 'update', - ['first_name', 'last_name', 'company', 'phone', 'addresses'], + ['first_name', 'last_name', 'company', 'phone', 'addresses', 'billing_address', 'shipping_address'], { id: { _eq: '$CURRENT_USER' } }); for (const act of ['create', 'read', 'update']) await ensurePermission(custPol.id, 'orders', act, ['*'], OWN_ORDER); for (const col of ['order_entries', 'order_addons']) for (const act of ['create', 'read', 'update']) await ensurePermission(custPol.id, col, act, ['*'], OWN_VIA_ORDER); @@ -899,6 +937,9 @@ async function applyPermissions() { await ensurePermission(custPol.id, 'customer_products', 'read', ['*'], { customer: { _eq: '$CURRENT_USER' } }); for (const col of ['products', 'formats', 'motifs', 'price_items', 'pricing_settings']) await ensurePermission(custPol.id, col, 'read'); + // Datei-Upload: Motiv-Bilder und hochzuladende Dokumente (Unterschriftenservice). + await ensurePermission(custPol.id, 'directus_files', 'create'); + await ensurePermission(custPol.id, 'directus_files', 'read', ['*'], { uploaded_by: { _eq: '$CURRENT_USER' } }); // Public (Gast): nur aktive Kataloge lesen const pubPol = await ensurePolicy('Public (Skrift)', { app: false }); diff --git a/Docker/directus/docker-compose.yml b/Docker/directus/docker-compose.yml index e5b57e1..45fd84c 100644 --- a/Docker/directus/docker-compose.yml +++ b/Docker/directus/docker-compose.yml @@ -84,6 +84,9 @@ services: # Muss identisch als ?token=... in der Webhook-URL stehen. SKRIFT_WEBHOOK_TOKEN: ${SKRIFT_WEBHOOK_TOKEN} + # Signatur-Plotter-Service (Vorschau/Render im Unterschriftenservice-Modul). + SKRIFT_SIGN_URL: ${SKRIFT_SIGN_URL:-http://skrift-signature-service:8000} + # PayPal – server-seitige Zahlungsprüfung (Secret liegt NUR hier, nie im Frontend) PAYPAL_CLIENT_ID: ${PAYPAL_CLIENT_ID} PAYPAL_CLIENT_SECRET: ${PAYPAL_CLIENT_SECRET} diff --git a/Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js index b5930b4..bf1c9bd 100644 --- a/Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-auth/dist/index.js @@ -43,14 +43,26 @@ const handler = (router, { services, getSchema, env, logger }) => { const ctx = async () => ({ schema: await getSchema(), accountability: null }); + // Findet das Konto per primärer E-Mail ODER per zusätzlicher Login-Mail + // (alt_emails). So melden sich Zweit-Mails im SELBEN Konto an. async function findUserByEmail(email, schema) { const users = new UsersService({ schema, accountability: null }); - const found = await users.readByQuery({ - filter: { email: { _eq: email } }, - fields: ['id', 'email', 'status'], - limit: 1, - }); - return found?.[0] || null; + const felder = ['id', 'email', 'status', 'alt_emails']; + const primaer = await users.readByQuery({ filter: { email: { _eq: email } }, fields: felder, limit: 1 }); + if (primaer?.[0]) return primaer[0]; + // Zusätzliche Login-Mails: JSON-Feld ist DB-abhängig schwer filterbar, daher + // die (wenigen) Konten mit alt_emails laden und exakt in JS prüfen. + try { + const cand = await users.readByQuery({ filter: { alt_emails: { _nnull: true } }, fields: felder, limit: -1 }); + const treffer = (cand || []).find((u) => { + const liste = Array.isArray(u.alt_emails) + ? u.alt_emails + : (typeof u.alt_emails === 'string' ? (() => { try { return JSON.parse(u.alt_emails); } catch { return []; } })() : []); + return Array.isArray(liste) && liste.map((x) => norm(x)).includes(email); + }); + if (treffer) return treffer; + } catch { /* alt_emails evtl. noch nicht vorhanden → ignorieren */ } + return null; } async function sendCodeMail(email, code) { @@ -173,6 +185,11 @@ const handler = (router, { services, getSchema, env, logger }) => { if (!user || user.status !== 'active') return res.status(401).json({ error: 'Kein aktives Konto zu dieser Adresse.' }); + // WICHTIG: Directus kennt nur die PRIMÄRE E-Mail als Login. Wurde der Code + // an eine Zweit-Mail geschickt, melden wir dennoch mit der primären an – + // dadurch landet die Zweit-Mail im selben Konto (voller Zugriff). + const loginEmail = norm(user.email); + // Offizieller Login-Pfad mit rotierendem Zufallspasswort (s. Kopfkommentar). const users = new UsersService({ schema, accountability: null }); const tempPassword = tempPasswort(); @@ -183,9 +200,9 @@ const handler = (router, { services, getSchema, env, logger }) => { // curl funktioniert – daher zuerst diese Variante, dann Fallbacks. const auth = new AuthenticationService({ schema, accountability: null }); const versuche = [ - () => auth.login('default', { email, password: tempPassword }), - () => auth.login({ email, password: tempPassword }), - () => auth.login({ email, password: tempPassword }, {}), + () => auth.login('default', { email: loginEmail, password: tempPassword }), + () => auth.login({ email: loginEmail, password: tempPassword }), + () => auth.login({ email: loginEmail, password: tempPassword }, {}), ]; let result, letzterFehler; for (const versuch of versuche) { diff --git a/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js index 0e90621..2ad593f 100644 --- a/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js @@ -57,10 +57,34 @@ export default (register, { services, getSchema, logger }) => { async function loadSettings(schema) { try { return await new ItemsService('email_settings', { schema, accountability: null }) - .readSingleton({ fields: ['signature', 'portal_url', 'company_footer', 'agb_file', 'sender_name', 'sender_email'] }); + .readSingleton({ fields: ['signature', 'portal_url', 'company_footer', 'agb_file', 'sender_name', 'sender_email', 'team_email'] }); } catch { return {}; } } + /** Schlanke interne Benachrichtigung „Neue Bestellung …" (Empfänger konfigurierbar). */ + async function teamNotify(orderId) { + try { + const schema = await getSchema(); + const settings = await loadSettings(schema); + const to = (settings?.team_email || '').trim(); + if (!to) return; + const order = await new ItemsService('orders', { schema, accountability: null }) + .readOne(orderId, { fields: ['order_number', 'product.name'] }); + if (!order) return; + const nachricht = { + to, + subject: `Neue Bestellung ${order.order_number}`, + html: `

Neue Bestellung ${order.order_number}${order.product?.name ? ` (${order.product.name})` : ''} – im Produktions-Board unter „Neu".

`, + }; + if (settings.sender_email) { + nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email; + } + await new MailService({ schema, accountability: null }).send(nachricht); + } catch (e) { + logger.warn(`[skrift-mailer] Team-Benachrichtigung: ${e.message}`); + } + } + /** AGB-PDF (aus email_settings.agb_file) als Nodemailer-Anhang, direkt von Platte. */ async function agbAnhang(schema, settings) { if (!settings?.agb_file) return null; @@ -87,10 +111,13 @@ export default (register, { services, getSchema, logger }) => { 'needs_envelope', 'envelope_labeling', 'envelope_format', 'net_total', 'vat_amount', 'gross_total', 'billing', 'shipping_address', 'customer.first_name', 'customer.last_name', 'customer.company', - 'product.name', 'format.name', + 'product.name', 'product.input_mode', 'format.name', ], }); if (!order?.customer_email) return; + // Datei-Produkte (Unterschriftenservice) erhalten KEINE Standard-Bestell- + // bestätigung – dafür geht eine eigene Vorlage aus dem Bestell-Endpunkt raus. + if (key === 'order_created' && order.product?.input_mode === 'datei') return; const settings = await loadSettings(schema); const name = [order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(' ') @@ -145,6 +172,8 @@ export default (register, { services, getSchema, logger }) => { if (payload?.status === 'entwurf') return; try { await sendForOrder(key, 'order_created'); } catch (e) { logger.warn(`[skrift-mailer] order_created: ${e.message}`); } + // Schlanke interne Benachrichtigung „Neue Bestellung …" an das Team. + await teamNotify(key); }); register.action('orders.items.update', async ({ keys, payload }) => { diff --git a/Docker/directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js index a2489ec..dbf1a37 100644 --- a/Docker/directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js @@ -47,6 +47,22 @@ const NeuAuftrag = defineComponent({ const verbotenRe = ref(null); const musterTexte = ref({ text_du: '', text_sie: '' }); 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({ @@ -213,7 +229,7 @@ const NeuAuftrag = defineComponent({ ]); }; // Weißes, einzeiliges Zellenfeld (feste Breite → Zeile scrollt bei Bedarf). - const feld = (lbl, val, on, ph) => h('div', { style: 'display:flex;flex-direction:column;gap:3px;flex:0 0 150px;' }, [ + const feld = (lbl, val, on, ph) => 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 }), ]); @@ -315,23 +331,170 @@ const NeuAuftrag = defineComponent({ return h('div', { style: 'display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap;' }, [links, rechts]); } - // Linke Navigationsspalte: aktuelle Werkzeuge dieses Moduls. Der - // Unterschriftenservice folgt als weiterer Eintrag. - const navItem = (label, aktiv, disabled) => h('div', { - style: 'display:flex;align-items:center;gap:8px;border-radius:6px;padding:10px 12px;margin-bottom:3px;font-size:14px;' - + (aktiv ? `background:${ACCENT};color:#fff;font-weight:600;` : disabled ? 'color:var(--foreground-subdued);opacity:.7;' : 'color:var(--foreground-normal);'), + // ── 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: 'Neuer Auftrag' }, { + return () => h(resolveComponent('private-view'), { title: ansicht.value === 'signatur' ? 'Unterschriften' : 'Neuer Auftrag' }, { navigation: () => h('div', { style: 'padding:12px 10px;' }, [ - navItem('📝 Neuer Auftrag', true, false), - navItem('✍ Unterschriftenservice (folgt)', false, true), + 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%;' }, - !geladen.value ? subdued('Lädt …') : formular()), + ansicht.value === 'signatur' ? signaturPanel() : (!geladen.value ? subdued('Lädt …') : formular())), neu.lb ? h(Teleport, { to: 'body' }, [ h('div', { onClick: () => { neu.lb = ''; }, diff --git a/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js index a728b68..7a4311f 100644 --- a/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js @@ -153,7 +153,87 @@ async function paypalToken() { } const handler = (router, { services, getSchema, logger }) => { - const { ItemsService, MailService, UsersService, RolesService } = services; + const { ItemsService, MailService, UsersService, RolesService, AssetsService } = services; + + // Signatur-Plotter-Service (intern über das npm-Netz per container_name erreichbar). + const SIGN_URL = (process.env.SKRIFT_SIGN_URL || 'http://skrift-signature-service:8000').replace(/\/$/, ''); + + /** Liest eine Directus-Datei (Upload) serverseitig als Buffer. */ + async function dateiBytes(schema, id) { + const assets = new AssetsService({ schema, accountability: null }); + const { stream } = await assets.getAsset(id, {}); + const chunks = []; + for await (const c of stream) chunks.push(c); + return Buffer.concat(chunks); + } + + /** Baut die Multipart-Anfrage an den Signatur-Service (Vorschau/Render). */ + async function signatureForm(schema, pdfId, sigList) { + const pdfBytes = await dateiBytes(schema, pdfId); + const form = new FormData(); + form.append('pdf', new Blob([pdfBytes], { type: 'application/pdf' }), 'dokument.pdf'); + const jobs = []; + for (let i = 0; i < sigList.length; i += 1) { + const s = sigList[i] || {}; + if (!s.file_id) continue; + const svgBytes = await dateiBytes(schema, s.file_id); + const svgName = `sig${i + 1}.svg`; + form.append('svgs', new Blob([svgBytes], { type: 'image/svg+xml' }), svgName); + const marker = String(s.marker_key || `SIG${i + 1}`).replace(/§/g, '').trim(); + jobs.push({ + token: `§§${marker}§§`, + svg_id: svgName, + position: ['genau', 'ueber', 'unter'].includes(s.position) ? s.position : 'genau', + breite_mm: Number(s.width_mm) > 0 ? Number(s.width_mm) : 45, + versatz_x_mm: Number(s.offset_x_mm) || 0, + versatz_y_mm: Number(s.offset_y_mm) || 0, + }); + } + form.append('job', JSON.stringify(jobs)); + return { form, jobCount: jobs.length }; + } + + /** Gemeinsame Vorbereitung für /signature-preview und /signature-render. */ + async function signatureAufruf(req, res, pfad, contentType, key) { + const acc = req.accountability; + if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' }); + const body = req.body || {}; + // Entweder aus einem Auftrag (order) ODER manuell mit direkt hochgeladenem + // Dokument (document_id) – eins von beiden muss vorhanden sein. + if (!body.order && !body.document_id) return res.status(400).json({ error: 'order oder document_id erforderlich.' }); + try { + const schema = await getSchema(); + const svc = (c) => new ItemsService(c, { schema, accountability: null }); + let ord = null; + if (body.order) { + ord = await svc('orders').readOne(body.order, { fields: ['id', 'order_number', 'source_file', 'source_files'] }); + if (!ord) return res.status(404).json({ error: 'Auftrag nicht gefunden.' }); + } + const pdfId = body.document_id || (ord ? (ord.source_file || (Array.isArray(ord.source_files) ? ord.source_files[0] : null)) : null); + if (!pdfId) return res.status(400).json({ error: 'Kein Dokument (Auftrag ohne Upload bzw. keine document_id).' }); + const sigList = Array.isArray(body.signatures) ? body.signatures : []; + if (!sigList.length) return res.status(400).json({ error: 'Mindestens eine Signatur wählen.' }); + + const { form, jobCount } = await signatureForm(schema, pdfId, sigList); + if (!jobCount) return res.status(400).json({ error: 'Keine gültige Signatur (SVG-Datei fehlt).' }); + + const r = await fetch(`${SIGN_URL}${pfad}`, { method: 'POST', body: form }); + if (!r.ok) { + const t = await r.text(); + return res.status(502).json({ error: 'Signatur-Service-Fehler.', detail: `${r.status}: ${String(t).slice(0, 400)}` }); + } + const buf = Buffer.from(await r.arrayBuffer()); + return res.json({ [key]: buf.toString('base64'), content_type: contentType, order_number: ord?.order_number || 'manuell' }); + } catch (err) { + logger.error(`[skrift-orders] ${pfad}: ${err.stack || err.message}`); + return res.status(500).json({ error: 'Signatur-Verarbeitung fehlgeschlagen.' }); + } + } + + // Vorschau-PDF (Dokument mit eingefügten Unterschriften). + router.post('/signature-preview', (req, res) => signatureAufruf(req, res, '/preview', 'application/pdf', 'pdf_base64')); + // A4-Signatur-SVGs als ZIP (zum Plotten in der lokalen Produktion). + router.post('/signature-render', (req, res) => signatureAufruf(req, res, '/render', 'application/zip', 'zip_base64')); // Policy-konformes Zufallspasswort (Kunde meldet sich ohnehin passwortlos an). const zufallsPasswort = () => @@ -189,7 +269,7 @@ const handler = (router, { services, getSchema, logger }) => { const product = (await svc('products').readByQuery({ filter: { key: { _eq: input.product } }, limit: 1, - fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'norm_qty', 'price_factor_a', 'price_factor_e', 'active'], + fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'norm_qty', 'price_factor_a', 'price_factor_e', 'input_mode', 'active'], }))?.[0]; if (!product || !product.active) throw new Error('Unbekanntes oder inaktives Produkt.'); if (product.pricing_mode === 'auf_anfrage') @@ -964,6 +1044,52 @@ const handler = (router, { services, getSchema, logger }) => { return null; } + /** + * Schlanke interne Benachrichtigung (neue Bestellung/Muster/Anfrage). Empfänger + * ist konfigurierbar (email_settings.team_email, Fallback SKRIFT_TEAM_EMAIL). + * Leer → keine Team-Mail. + */ + async function teamMail(schema, subject, html) { + try { + let to = TEAM_EMAIL; + try { + const es = await new ItemsService('email_settings', { schema, accountability: null }).readSingleton({ fields: ['team_email'] }); + if (es && es.team_email !== undefined && es.team_email !== null) to = String(es.team_email).trim(); + } catch { /* Feld evtl. noch nicht gebootstrapt → Fallback */ } + if (!to) return; // bewusst leer = keine Team-Mails + const mail = new MailService({ schema, accountability: null }); + const von = await absender(schema); + await mail.send({ ...(von ? { from: von } : {}), to, subject, html }); + } catch (e) { + logger.warn(`[skrift-orders] Team-Mail nicht versendet: ${e.message}`); + } + } + + /** + * Sendet eine im Admin editierbare E-Mail-Vorlage (email_templates) an den + * Kunden. Platzhalter {{feld}} werden aus `vars` gefüllt. Fehlt/deaktiviert die + * Vorlage, passiert nichts (kein Fehler). + */ + async function sendeVorlage(schema, key, to, vars) { + try { + const tpl = (await new ItemsService('email_templates', { schema, accountability: null }).readByQuery({ + filter: { key: { _eq: key }, enabled: { _eq: true } }, limit: 1, fields: ['subject', 'body'], + }))?.[0]; + if (!tpl) return; + const fuelle = (t) => String(t || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] ?? '')); + let signature = ''; + try { + const es = await new ItemsService('email_settings', { schema, accountability: null }).readSingleton({ fields: ['signature'] }); + if (es?.signature) signature = es.signature; + } catch { /* optional */ } + const mail = new MailService({ schema, accountability: null }); + const von = await absender(schema); + await mail.send({ ...(von ? { from: von } : {}), to, subject: fuelle(tpl.subject), html: fuelle(tpl.body) + signature }); + } catch (e) { + logger.warn(`[skrift-orders] Vorlagen-Mail „${key}" nicht versendet: ${e.message}`); + } + } + // ── Angebotsanfrage („Individuelles Angebot") ──────────────────────────── router.post('/offer-request', async (req, res) => { const body = req.body || {}; @@ -1004,25 +1130,11 @@ const handler = (router, { services, getSchema, logger }) => { customer: customer?.id ?? null, }); - // Benachrichtigung – darf den Erfolg der Anfrage nicht gefährden. - try { - const mail = new MailService({ schema, accountability: null }); - const von = await absender(schema); - // Angefragtes Produkt (z. B. Unterschriftenservice, Follow-ups) mit ausweisen. - const produkt = body.product ? clean(String(body.product)) : null; - await mail.send({ - ...(von ? { from: von } : {}), - to: TEAM_EMAIL, - subject: produkt ? `Angebotsanfrage: ${produkt} – ${name}` : `Angebotsanfrage von ${name}`, - html: - `

${name} (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})

` + - `

Anfrage betrifft: ${produkt || 'Individuelles Angebot'}

` + - `

Menge: ${Number.isFinite(quantity) ? quantity : 'k. A.'}

` + - `

${clean(message)}

`, - }); - } catch (mailErr) { - logger.warn(`[skrift-orders] Angebots-Mail nicht versendet: ${mailErr.message}`); - } + // Schlanke interne Benachrichtigung. + const produkt = body.product ? clean(String(body.product)) : null; + await teamMail(schema, `Neue Angebotsanfrage: ${name}`, + `

Neue Angebotsanfrage von ${name} (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})` + + `${produkt ? ` · ${produkt}` : ''} – im Board unter „Neu".

`); return res.json({ ok: true }); } catch (err) { @@ -1070,22 +1182,9 @@ const handler = (router, { services, getSchema, logger }) => { customer: customer?.id ?? null, }); - try { - const mail = new MailService({ schema, accountability: null }); - const von = await absender(schema); - await mail.send({ - ...(von ? { from: von } : {}), - to: TEAM_EMAIL, - subject: `Musterbestellung: ${clean(String(body.product))} – ${body.name}`, - html: - `

${clean(String(body.name))} (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})

` + - `

Produkt: ${clean(String(body.product))}

` + - `

${clean(String(body.street))}, ${clean(String(body.zip))} ${clean(String(body.city))}, ` + - `${clean(String(body.country || ''))}

`, - }); - } catch (mailErr) { - logger.warn(`[skrift-orders] Muster-Mail nicht versendet: ${mailErr.message}`); - } + await teamMail(schema, `Neue Musteranfrage: ${clean(String(body.name))}`, + `

Neue Musteranfrage von ${clean(String(body.name))} (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})` + + ` · Produkt: ${clean(String(body.product))} – im Board unter „Neu".

`); return res.json({ ok: true }); } catch (err) { @@ -1171,7 +1270,7 @@ const handler = (router, { services, getSchema, logger }) => { }, status: 'neu', customer: customer?.id ?? null, }; - subject = `Musteranfrage (Web): ${produkt || '—'} – ${name}`; + subject = `Neue Musteranfrage: ${name}`; mailHtml = `

${clean(name)} (${email}${phone ? `, ${phone}` : ''})

` + `

Interesse: ${produkt || 'k. A.'}

` + @@ -1185,7 +1284,7 @@ const handler = (router, { services, getSchema, logger }) => { type: 'angebot', name: clean(name), email, phone, product: null, message, status: 'neu', customer: customer?.id ?? null, }; - subject = `Angebotsanfrage (Web) von ${name}`; + subject = `Neue Angebotsanfrage: ${name}`; mailHtml = `

${clean(name)}${unternehmen ? ` · ${unternehmen}` : ''} (${email}${phone ? `, ${phone}` : ''})

` + `

${nachricht || '—'}

`; @@ -1194,14 +1293,8 @@ const handler = (router, { services, getSchema, logger }) => { await svc('offer_requests').createOne(record); logger.info(`[skrift-orders] inbound/${kind}: Anfrage von ${email} gespeichert.`); - // Interne Team-Benachrichtigung (Kunden-Autoresponder macht JetFormBuilder). - try { - const mail = new MailService({ schema, accountability: null }); - const von = await absender(schema); - await mail.send({ ...(von ? { from: von } : {}), to: TEAM_EMAIL, subject, html: mailHtml }); - } catch (mailErr) { - logger.warn(`[skrift-orders] Webhook-Mail nicht versendet: ${mailErr.message}`); - } + // Schlanke interne Benachrichtigung (Kunden-Autoresponder macht JetFormBuilder). + await teamMail(schema, subject, mailHtml); return res.json({ ok: true }); } catch (err) { @@ -1250,8 +1343,12 @@ const handler = (router, { services, getSchema, logger }) => { 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 || (String(body.format || '').startsWith('a4') ? 'dinlang' : 'c6')) + ? (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()); @@ -1344,7 +1441,7 @@ const handler = (router, { services, getSchema, logger }) => { // Kunde = Login-Konto (directus_users). Finden oder anlegen – per E-Mail. const users = new UsersService({ schema, accountability: null }); let customer = (await users.readByQuery({ - filter: { email: { _eq: email } }, limit: 1, fields: ['id'], + filter: { email: { _eq: email } }, limit: 1, fields: ['id', 'billing_address'], }))?.[0]; if (!customer) { const rolesSvc = new RolesService({ schema, accountability: null }); @@ -1362,9 +1459,25 @@ const handler = (router, { services, getSchema, logger }) => { company: clean(body.company) ?? null, phone: clean(body.phone) ?? null, }); - customer = { id }; + customer = { id, billing_address: null }; } + // Rechnungsadresse automatisch ins Konto übernehmen, wenn dort noch KEINE + // hinterlegt ist (bestehende Adresse wird nicht überschrieben). + try { + const b = body.billing && typeof body.billing === 'object' ? body.billing : null; + const hatAdresse = customer.billing_address + && (customer.billing_address.street || customer.billing_address.city); + if (b && (b.street || b.city) && !hatAdresse) { + await users.updateOne(customer.id, { + billing_address: { + street: clean(b.street) || '', zip: clean(b.zip) || '', + city: clean(b.city) || '', country: clean(b.country) || '', + }, + }); + } + } catch (e) { logger.warn(`[skrift-orders] Konto-Adresse nicht übernommen: ${e.message}`); } + // Spezial-/Kontaktprodukte (z. B. Unterschriftenservice) sind nur // bestellbar, wenn sie für dieses Konto freigeschaltet wurden. if (quote.product.type === 'contact') { @@ -1385,9 +1498,9 @@ const handler = (router, { services, getSchema, logger }) => { }))?.[0]?.id ?? null; } - // Umschlagformat automatisch: A4 → DIN Lang, A6 → C6 + // Umschlagformat automatisch: A6/Postkarte → C6, sonst Standard DIN Lang. const envelopeFormat = body.needs_envelope - ? (String(body.format || '').startsWith('a4') ? 'dinlang' : 'c6') + ? (String(body.format || '').startsWith('a6') ? 'c6' : 'dinlang') : null; // Empfänger nach Inland/Ausland aufteilen (für die Auftragsübersicht). @@ -1414,8 +1527,9 @@ const handler = (router, { services, getSchema, logger }) => { format: formatId, person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat', font: ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda', - source: body.source === 'operator' ? 'operator' : (body.source === 'portal' ? 'portal' : 'configurator'), - status: 'wartet_auf_zahlung', + source: ['operator', 'portal', 'extern'].includes(body.source) ? body.source : 'configurator', + // Extern (z. B. Etsy) wird über die Plattform bezahlt → nicht auf Zahlung warten. + status: body.source === 'extern' ? 'bezahlt' : 'wartet_auf_zahlung', shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel', needs_envelope: !!body.needs_envelope, envelope_labeling: body.envelope_labeling ?? null, @@ -1425,6 +1539,9 @@ const handler = (router, { services, getSchema, logger }) => { motif: Number.isFinite(Number(body.motif)) && body.motif ? Number(body.motif) : null, motif_upload: body.motif_upload || null, source_file: body.source_file || null, + source_files: Array.isArray(body.source_files) && body.source_files.length + ? body.source_files + : (body.source_file ? [body.source_file] : null), billing: body.billing && typeof body.billing === 'object' ? body.billing : null, shipping_address: body.shipping_address && typeof body.shipping_address === 'object' ? body.shipping_address : null, own_cards: body.motif_mode === 'eigene_karten', @@ -1439,7 +1556,7 @@ const handler = (router, { services, getSchema, logger }) => { gross_total: quote.gross_total, voucher: quote.voucher?.id ?? null, payment_method: paymentMethod, - payment_status: 'offen', + payment_status: body.source === 'extern' ? 'bezahlt' : 'offen', }; let orderId, orderNumber; @@ -1496,19 +1613,25 @@ const handler = (router, { services, getSchema, logger }) => { order: orderId, status: 'wartet_auf_zahlung', note: 'Auftrag über den Konfigurator angelegt.', }); - // SVGs automatisch erzeugen, sobald der Kunde den Auftrag abschließt. - // Handschrift-Produkte werden gerendert (gezeigte Vorschau-Dokumente 1:1); - // Datei-Produkte (Upload) nicht. Feuern & vergessen – blockiert die Antwort - // an den Kunden nicht (das Backend generiert im Hintergrund und schreibt - // Status/artifact_path selbst zurück). - if (quote.product.type === 'letter' || quote.product.type === 'postcard') { - fetch(`${BACKEND_URL}/api/order/from-directus`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN }, - body: JSON.stringify({ orderId }), - }).catch((e) => logger.warn(`[skrift-orders] Auto-Generierung nicht gestartet: ${e.message}`)); + // Separate, im Admin editierbare Kunden-Mail für Sonderfälle. + const kundeEmail = norm(body.email); + if (isEmail(kundeEmail)) { + const vars = { order_number: orderNumber, name: [body.first_name, body.last_name].filter(Boolean).join(' ') }; + if (quote.product?.input_mode === 'datei') sendeVorlage(schema, 'unterschriften_eingang', kundeEmail, vars); + else if (body.source === 'extern') sendeVorlage(schema, 'extern_eingang', kundeEmail, vars); } + // SVGs automatisch erzeugen, sobald der Kunde den Auftrag abschließt – IMMER + // und für jedes Produkt (das Backend entscheidet, was zu erzeugen ist: + // Schriftstücke bei Handschrift-Produkten, Kuverts immer wenn gewünscht; + // bei Datei-Produkten NUR die Kuverts, nicht das hochgeladene Dokument). + // Feuern & vergessen – blockiert die Antwort an den Kunden nicht. + fetch(`${BACKEND_URL}/api/order/from-directus`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN }, + body: JSON.stringify({ orderId }), + }).catch((e) => logger.warn(`[skrift-orders] Auto-Generierung nicht gestartet: ${e.message}`)); + return res.json({ order_number: orderNumber, net_total: quote.net_total, vat_amount: quote.vat_amount, gross_total: quote.gross_total, diff --git a/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js index 9170bef..4ec68c3 100644 --- a/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js @@ -141,6 +141,8 @@ const Board = defineComponent({ // Massenauswahl (Auftrags-IDs) + Sammel-Druck-Popup (pro Format Template+Maschine). const auswahl = ref([]); const bulk = reactive({ open: false, mode: 'queue', gruppen: [], msg: '', err: '' }); + // Vom Kunden hochgeladene Original-Dokumente (kein generiertes SVG). + const dateien = reactive({ open: false, order: null, items: [], laden: false, err: '' }); // ── Laden ── // Basis-Felder ohne die evtl. noch nicht gebootstrapten Felder – diese @@ -148,13 +150,15 @@ const Board = defineComponent({ const ORDER_FELDER = 'id,order_number,status,payment_status,production_status,needs_envelope,envelope_format,' + 'envelope_labeling,shipping_type,entries_count,count_inland,count_ausland,brief_gedruckt,kuvert_gedruckt,' + 'date_updated,date_created,format.key,format.name,product.name,product.input_mode,' + + 'source_file,' + 'customer.first_name,customer.last_name,customer.email,customer.phone'; async function ladeOrders() { try { - return await api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER + ',versandt_am' } }); + // source_files/versandt_am sind ggf. noch nicht gebootstrapt → separater Versuch. + return await api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER + ',versandt_am,source_files' } }); } catch { - // Feld „versandt_am" fehlt noch (Bootstrap nicht gelaufen) → ohne laden, - // Archiv nutzt ersatzweise date_updated. + // Neues Feld fehlt noch → ohne laden (Archiv nutzt ersatzweise date_updated, + // Dateien-Button fällt auf source_file zurück). return api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER } }); } } @@ -516,13 +520,42 @@ const Board = defineComponent({ catch (e) { fehler.value = e?.message || 'Fehlgeschlagen.'; } } + // ── Vom Kunden hochgeladene Dokumente (Original, keine SVGs) ── + const orderDateiIds = (o) => (Array.isArray(o.source_files) && o.source_files.length) + ? o.source_files : (o.source_file ? [o.source_file] : []); + async function openDateien(order) { + dateien.open = true; dateien.order = order; dateien.items = []; dateien.err = ''; dateien.laden = true; + try { + const items = []; + for (const id of orderDateiIds(order)) { + try { + const f = (await api.get('/files/' + id, { params: { fields: 'id,filename_download,type,filesize' } })).data?.data; + items.push({ id, name: f?.filename_download || id, type: f?.type || '' }); + } catch { items.push({ id, name: id, type: '' }); } + } + dateien.items = items; + if (!items.length) dateien.err = 'Keine hochgeladenen Dateien an diesem Auftrag.'; + } catch (e) { dateien.err = e?.message || 'Dateien konnten nicht geladen werden.'; } + finally { dateien.laden = false; } + } + async function dlDatei(item) { + try { + const res = await api.get('/assets/' + item.id, { responseType: 'blob' }); + const url = URL.createObjectURL(res.data); + const a = document.createElement('a'); a.href = url; a.download = item.name; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 4000); + } catch (e) { dateien.err = 'Download fehlgeschlagen.'; } + } + // ── Render-Bausteine ────────────────────────────────────────────────── function actionButtons(order) { // Drucken + Empfänger (Empfänger enthält die Vorschau je Dokument). return h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [ btn('🖨 Drucken', () => openDruck(order), 'primary'), btn('👥 Empfänger', () => openEmpf(order)), - ]); + orderDateiIds(order).length ? btn('📎 Dateien', () => openDateien(order)) : null, + ].filter(Boolean)); } function statusSelect(order) { return h('select', { @@ -843,6 +876,23 @@ const Board = defineComponent({ h('div', { style: 'color:rgba(255,255,255,.7);font-size:12px;' }, 'Klicken zum Schließen'), ]); } + function dateienModal() { + if (!dateien.open) return null; + const body = h('div', { style: 'padding:16px 20px;' }, [ + subdued('Vom Kunden hochgeladene Original-Dokumente (keine generierten SVGs).', 'margin-bottom:12px;'), + dateien.laden ? subdued('Wird geladen …') + : dateien.err ? subdued(dateien.err) + : h('div', { style: 'display:flex;flex-direction:column;gap:8px;' }, dateien.items.map((it) => + h('div', { style: CARD + 'display:flex;justify-content:space-between;align-items:center;gap:12px;' }, [ + h('div', { style: 'min-width:0;' }, [ + h('div', { style: 'font-weight:600;word-break:break-all;' }, it.name), + it.type ? subdued(it.type) : null, + ]), + btn('⤓ Herunterladen', () => dlDatei(it), 'primary'), + ]))), + ]); + return overlay([modalKopf(`Dateien · #${dateien.order?.order_number || ''}`, () => { dateien.open = false; }), body], () => { dateien.open = false; }, '620px'); + } // ── Gesamt-Render ───────────────────────────────────────────────────── // In gehängt → normaler Directus-Content-Bereich + Kopf. @@ -888,7 +938,7 @@ const Board = defineComponent({ loading.value ? subdued('Lädt …') : h('div', { style: 'display:flex;flex-direction:column;gap:2px;' }, tabInhalt()), ]); - const modale = [druckModal(), bulkModal(), musterModal(), empfModal(), lightboxModal()].filter(Boolean); + const modale = [druckModal(), bulkModal(), musterModal(), empfModal(), dateienModal(), lightboxModal()].filter(Boolean); return h(resolveComponent('private-view'), { title: 'Produktion' }, { navigation: () => navi, diff --git a/Docker/skrift-backend/src/api/controllers/directus-controller.js b/Docker/skrift-backend/src/api/controllers/directus-controller.js index 584829a..a7681f2 100644 --- a/Docker/skrift-backend/src/api/controllers/directus-controller.js +++ b/Docker/skrift-backend/src/api/controllers/directus-controller.js @@ -50,6 +50,9 @@ function buildDocuments(order, entries) { const envFormat = ENVELOPE_FORMAT[order.envelope_format] || 'c6'; const wantsEnvelope = !!order.needs_envelope && order.envelope_labeling !== 'keine'; const envelopeType = order.envelope_labeling === 'freitext' ? 'custom' : 'recipient'; + // Datei-Produkte (z. B. Unterschriftenservice): das „Schriftstück" ist das vom + // Kunden hochgeladene Dokument – es wird NICHT generiert. Kuverts hingegen schon. + const istDatei = order.product?.input_mode === 'datei'; const documents = []; @@ -59,14 +62,16 @@ function buildDocuments(order, entries) { // Briefnummer 1 gehört. Vorschau nummeriert identisch (siehe skrift-orders). const nr = Number(entry.letter_number) || (i + 1); - documents.push({ - index: nr, - type: 'letter', - text: order.text_template || '', - format: letterFormat, - font, - placeholders, - }); + if (!istDatei) { + documents.push({ + index: nr, + type: 'letter', + text: order.text_template || '', + format: letterFormat, + font, + placeholders, + }); + } if (wantsEnvelope) { documents.push({ @@ -116,6 +121,12 @@ async function generateFromDirectus(req, res, next) { const { documents, letterFormat, envFormat, wantsEnvelope } = buildDocuments(order, loaded.entries); + // Nichts zu generieren (z. B. Datei-Produkt ohne Kuvert) → sauber überspringen. + if (!documents.length) { + console.log(`[Directus] ${order.order_number}: nichts zu generieren (Datei-Produkt ohne Kuvert).`); + return; + } + console.log(`[Directus] Generiere ${order.order_number}: ${documents.length} Dokumente`); const result = await runGeneration({ diff --git a/Webseite/skrift-app/src/lib/api.ts b/Webseite/skrift-app/src/lib/api.ts index 1a6f1fe..4b5bf40 100644 --- a/Webseite/skrift-app/src/lib/api.ts +++ b/Webseite/skrift-app/src/lib/api.ts @@ -171,6 +171,8 @@ export type CustomerAddress = { city?: string; country?: string; }; +/** Feste Adresse ohne Bezeichnung (Rechnung bzw. Lieferung). */ +export type Adresse2 = { street?: string; zip?: string; city?: string; country?: string }; export type Customer = { /** Directus-User-ID (UUID) – Kunde ist das Login-Konto selbst. */ id: string; @@ -180,8 +182,13 @@ export type Customer = { last_name: string | null; company: string | null; phone: string | null; - /** Mehrere hinterlegte Adressen (Rechnung/Lieferung). */ - addresses: CustomerAddress[] | null; + /** Genau zwei feste Adressen ohne Namen. */ + billing_address: Adresse2 | null; + shipping_address: Adresse2 | null; + /** Weitere Login-Mailadressen (nur Anzeige; Pflege durch den Betreiber). */ + alt_emails: string[] | null; + /** Veraltet – frühere Mehrfach-Adressliste (nur für Alt-Daten). */ + addresses?: CustomerAddress[] | null; }; /** @@ -190,7 +197,7 @@ export type Customer = { */ export async function fetchMyCustomer(): Promise { const data = await request( - '/users/me?fields=id,email,person_type,first_name,last_name,company,phone,addresses', + '/users/me?fields=id,email,person_type,first_name,last_name,company,phone,billing_address,shipping_address,alt_emails', ); return data?.data ?? null; } @@ -204,7 +211,8 @@ export async function saveMyCustomer(c: Customer) { last_name: c.last_name, company: c.company, phone: c.phone, - addresses: c.addresses ?? [], + billing_address: c.billing_address ?? null, + shipping_address: c.shipping_address ?? null, }, }); } @@ -401,6 +409,8 @@ export type OrderInput = { text_briefing?: string; /** Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF). */ source_file?: string; + /** Alle hochgeladenen Dokumente (Datei-Produkte, mehrere möglich). */ + source_files?: string[]; /** Rechnungs-/Lieferadresse als Snapshot (für Bestätigung & Rechnung). */ billing?: Record; shipping_address?: Record | null; diff --git a/Webseite/skrift-app/src/routes/Configurator.tsx b/Webseite/skrift-app/src/routes/Configurator.tsx index 76d50dc..976276b 100644 --- a/Webseite/skrift-app/src/routes/Configurator.tsx +++ b/Webseite/skrift-app/src/routes/Configurator.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { ApiError, assetUrl, @@ -25,7 +26,6 @@ import { type Product, type PublicSettings, type Quote, - type CustomerAddress, } from '../lib/api'; import { ladePayPal, rendereButtons } from '../lib/paypal'; import { ladeVorlageHerunter, leseEmpfaengerDatei } from '../lib/excel'; @@ -270,6 +270,10 @@ const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien' export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: string }) { const { publish, registerGoto } = useWizard(); + const [searchParams] = useSearchParams(); + // Externer/Etsy-Modus (?extern=1): Bestellung ohne Preise & Zahlung, + // nur Empfänger → Text → Übersicht (siehe schritte weiter unten). + const externModus = searchParams.get('extern') === '1'; // Zwischengespeicherten Entwurf einmalig laden (siehe ENTWURF_KEY). const entwurf = useMemo(ladeEntwurf, []); @@ -344,7 +348,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri const [sendet, setSendet] = useState(false); const [bestellFehler, setBestellFehler] = useState([]); - 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(null); const [preisKatalog, setPreisKatalog] = useState>({}); @@ -375,7 +379,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri }, [zeichenHinweis]); const [motivDatei, setMotivDatei] = useState(null); /** Hochgeladenes Dokument bei Datei-Produkten (z. B. zu unterschreibendes PDF). */ - const [quellDatei, setQuellDatei] = useState(null); + /** Hochgeladene Dokumente bei Datei-Produkten (mehrere PDF/Word/Excel möglich). */ + const [quellDateien, setQuellDateien] = useState([]); const paypalRef = useRef(null); useEffect(() => { @@ -407,17 +412,16 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri fetchMyUnlockedProducts().then(setFreigeschaltet).catch(() => setFreigeschaltet([])); }, []); - /** Gespeicherte Adressen des angemeldeten Kunden (für die Adressauswahl). */ - const [kundenAdressen, setKundenAdressen] = useState([]); - - // Angemeldete Kunden: Einstufung und Rechnungsdaten übernehmen. + // Angemeldete Kunden: Einstufung + die zwei festen Adressen automatisch + // übernehmen (Rechnung → Rechnungsdaten, Lieferung → abweichende Lieferadresse). + // Kein Adress-Dropdown mehr; alles wird direkt eingesetzt. useEffect(() => { if (!loadSession()) return; fetchMyCustomer() .then((c) => { if (!c) return; if (c.person_type) setPersonType(c.person_type); - setKundenAdressen(c.addresses ?? []); + const b = c.billing_address ?? {}; setRechnung((r) => ({ ...r, first_name: c.first_name ?? r.first_name, @@ -425,46 +429,27 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri company: c.company ?? r.company, email: c.email ?? r.email, phone: c.phone ?? r.phone, + street: b.street ?? r.street, + zip: b.zip ?? r.zip, + city: b.city ?? r.city, + country: b.country ?? r.country, })); + // Lieferadresse vorbefüllen, aber „abweichende Lieferadresse" bleibt per + // Default AUS – der Kunde aktiviert sie bei Bedarf selbst. + const s = c.shipping_address ?? {}; + if (s.street || s.zip || s.city) { + setLieferung((l) => ({ + ...l, + street: s.street ?? l.street, + zip: s.zip ?? l.zip, + city: s.city ?? l.city, + country: s.country ?? l.country, + })); + } }) .catch(() => { /* Vorausfüllen ist optional */ }); }, []); - /** Adressfelder aus einer gespeicherten Adresse füllen. */ - const uebernimm = (a: CustomerAddress) => (r: Adresse): Adresse => ({ - ...r, - street: a.street ?? r.street, - zip: a.zip ?? r.zip, - city: a.city ?? r.city, - country: a.country ?? r.country, - }); - const adresseUebernehmen = (a: CustomerAddress) => setRechnung(uebernimm(a)); - const lieferungUebernehmen = (a: CustomerAddress) => setLieferung(uebernimm(a)); - - /** Hervorgehobene Auswahl gespeicherter Adressen (nur mit Login und Adressen). */ - const adressWahl = (id: string, uebernehmen: (a: CustomerAddress) => void) => - kundenAdressen.length === 0 ? null : ( -
- - -
- ); - // Sichtbarkeit je Personentyp (im Admin über products.visibility pflegbar). // Ein per Direktlink vorgewähltes Produkt bleibt immer sichtbar – sonst würde // die Anfrage abbrechen, sobald z. B. „privat" bei einem Unternehmens-Produkt gewählt wird. @@ -490,6 +475,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri // Datei-Produkt (z. B. Unterschriftenservice): im Text-Schritt lädt der Kunde // ein Dokument hoch, statt Text zu tippen. const dateiModus = gewaehltesProdukt?.input_mode === 'datei'; + // Kein Preis im Konfigurator: Extern-Modus ODER Datei-Produkt (z. B. + // Unterschriftenservice – dort wird individuell abgerechnet). + const keinePreise = externModus || dateiModus; // Eigener Format-Schritt nur, wenn es überhaupt eine Wahl gibt. const brauchtFormatSchritt = produktFormate.length > 1; @@ -511,12 +499,14 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri } }, [vorwahlProdukt, product, products]); - // Genau ein Format → still setzen (kein Format-Schritt nötig). + // Genau ein Format → still setzen (kein Format-Schritt nötig). Im Extern-Modus + // gibt es keinen Format-Schritt → dort immer das erste Format vorbelegen. useEffect(() => { - if (produktFormate.length === 1 && format !== produktFormate[0].key) { + if (!produktFormate.length) return; + if ((produktFormate.length === 1 || (externModus && !format)) && format !== produktFormate[0].key) { setFormat(produktFormate[0].key); } - }, [produktFormate, format]); + }, [produktFormate, format, externModus]); const nurUpload = quantity > TABELLEN_GRENZE; @@ -529,6 +519,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri const brauchtAdressen = shippingType === 'einzeln' || (needsEnvelope && labeling === 'empfaenger'); const umschlagFreitext = needsEnvelope && labeling === 'freitext'; + // Empfänger-Schritt (früh definiert, da mehrere Effekte darauf zugreifen): + // „Individueller Text" bei Freitext-Kuvert, „Empfänger" bei Adressbedarf, sonst keiner. + const empfaengerSchritt = umschlagFreitext ? 'Individueller Text' : brauchtAdressen ? 'Empfänger' : null; // Ab 26 Empfängern ist die Tabelle keine Option mehr. useEffect(() => { @@ -549,9 +542,14 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri useEffect(() => { setPreviewSession(null); }, [textTemplate, font, format, realistisch, labeling, needsEnvelope, umschlagText, entries]); - // Empfängerzeilen an die Menge angleichen (nur im Tabellenmodus). + // Empfängerzeilen an die Menge angleichen (nur im Tabellenmodus). Gilt auch im + // Extern-Modus: die (vom Betreiber vorgegebene) Menge erzeugt die passende + // Anzahl Zeilen – egal ob mit oder ohne Kuvert/Empfänger. useEffect(() => { - if (nurUpload) return; + // Bei vielen Empfängern (Tabelle unpraktisch) wird auf Excel verwiesen und + // nicht synchronisiert – AUSSER im Extern-Modus ohne Empfänger-Schritt, wo die + // Menge einfach N Blanko-Dokumente erzeugen muss (auch über der Tabellengrenze). + if (nurUpload && (!externModus || empfaengerSchritt)) return; setEntries((prev) => { if (prev.length === quantity) return prev; if (prev.length < quantity) { @@ -559,38 +557,69 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri } return prev.slice(0, quantity); }); - }, [quantity, nurUpload]); + }, [quantity, nurUpload, externModus, empfaengerSchritt]); + + // Extern-Preset (vom Betreiber im Direktlink gesetzt) – der Kunde wählt nichts: + // ?extern=1&versand=sammel|einzeln&kuvert=0|1&beschriftung=empfaenger|freitext|keine&menge=N + // Die Menge ist maßgeblich (greift immer); nur ein Excel-Import passt sie an die + // tatsächlich gelesene Empfängerzahl an. + useEffect(() => { + if (!externModus) return; + const v = searchParams.get('versand'); if (v === 'einzeln' || v === 'sammel') setShippingType(v); + const k = searchParams.get('kuvert'); if (k === '1' || k === '0') setNeedsEnvelope(k === '1'); + const b = searchParams.get('beschriftung'); if (b === 'empfaenger' || b === 'freitext' || b === 'keine') setLabeling(b); + const m = parseInt(searchParams.get('menge') || '', 10); + if (Number.isFinite(m) && m > 0) { setQuantity(m); setQuantityChosen(true); setMengeText(String(m)); } + // Nur einmal beim Betreten des Extern-Modus anwenden. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [externModus]); + + // Abweichende Lieferadresse gibt es nur beim Sammelversand – sonst zurücksetzen. + useEffect(() => { if (shippingType !== 'sammel') setLieferAbweichend(false); }, [shippingType]); /** * Der Empfängerschritt entfällt vollständig, wenn weder Adressen noch ein * Umschlagtext gebraucht werden. Bei „Individueller Text" heißt er anders, * weil dort keine Adressen, sondern der Umschlagtext erfasst wird. + * (empfaengerSchritt ist oben – bei brauchtAdressen/umschlagFreitext – definiert.) */ - const empfaengerSchritt = umschlagFreitext - ? 'Individueller Text' - : brauchtAdressen - ? 'Empfänger' - : null; // Ist das aktuell gewählte Produkt ein „auf Anfrage"-Kontaktprodukt? const kontaktSelektiert = kontaktProdukte.find((p) => p.key === product) ?? null; + // Produkt kommt aus dem Direktlink; Extern-Modus ist oben definiert. + const angemeldet = !!loadSession(); + // „Sie sind" entfällt, wenn die Einstufung schon feststeht (eingeloggt mit + // person_type) oder im Extern-Modus. + const zeigeSieSind = !externModus && !(angemeldet && !!personType); + // Anfrageprodukte (Unterschriftenservice, Follow-ups) haben keinen Konfigurator – - // nach „Weiter" folgt direkt das Anfrageformular als eigene Seite. - const schritte = kontaktSelektiert - // Direktlink auf ein Kontaktprodukt → direkt zum Anfrageformular (ohne „Sie sind"). - ? (vorwahlProdukt ? ['Anfrage'] : ['Sie sind', 'Produkt', 'Anfrage']) - : [ - 'Sie sind', - ...(vorwahlProdukt ? [] : ['Produkt']), - ...(brauchtFormatSchritt ? ['Format'] : []), - 'Menge & Versand', - ...(istPostkarte ? ['Motiv'] : []), + // nach „Weiter" folgt direkt das Anfrageformular als eigene Seite. Freigeschaltete + // Kunden landen NICHT hier (dann ist das Produkt bestellbar, s. bestellbar/kontaktProdukte). + const schritte = externModus + // Extern: der Kunde füllt NUR Empfänger + Text; Menge/Versand/Kuvert/Format + // legt der Betreiber über die Direktlink-Parameter fest (s. Extern-Preset). + // Keine Preise, keine Rechnungsdaten, keine Zahlung. + ? [ ...(empfaengerSchritt ? [empfaengerSchritt] : []), 'Text', 'Rechnungsdaten', 'Ü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 name = (i: number) => schritte[i]; @@ -628,7 +657,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri const preisMenge = quantityChosen ? quantity : REFERENZ_MENGE; useEffect(() => { - if (!product || kontaktSelektiert) return; // Kontaktprodukte haben keinen Preis + // Kontaktprodukte, Extern-Modus und Datei-Produkte zeigen keinen Preis. + if (!product || kontaktSelektiert || keinePreise) { setQuote(null); return; } let abgebrochen = false; setQuoteFehler(null); @@ -772,6 +802,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri * Der MwSt-Hinweis wird bewusst nicht angezeigt. */ const aufpreis = (key: string): Aufpreis => { + // Ohne Preise (Datei-Produkt/Extern) an keiner Option Aufpreise zeigen. + if (keinePreise) return null; const it = preisKatalog[key]; if (!it || !it.price) return null; // Nur Sammelversand wird für alle identisch (netto) angezeigt. Einzelversand @@ -808,16 +840,21 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri : entries.every((e) => e.last_name.trim() && e.zip.trim() && e.city.trim()); } case 'Text': - if (dateiModus) return !!quellDatei; + if (dateiModus) return quellDateien.length > 0; if (textservice) return textBriefing.trim().length > 0; return textTemplate.trim().length > 0 && !textZuLang && !textUeberlauf; case 'Rechnungsdaten': - // Pflichtfelder wie im bestehenden Konfigurator (Telefon optional) + // Pflichtfelder wie im bestehenden Konfigurator (Telefon optional). + // Bei abweichender Lieferadresse sind deren Kernfelder ebenfalls Pflicht. return ( !!rechnung.first_name.trim() && !!rechnung.last_name.trim() && istEmail(rechnung.email) && !!rechnung.street.trim() && !!rechnung.zip.trim() && !!rechnung.city.trim() && - (personType !== 'unternehmen' || !!rechnung.company.trim()) + (personType !== 'unternehmen' || !!rechnung.company.trim()) && + (!lieferAbweichend || ( + !!lieferung.first_name.trim() && !!lieferung.last_name.trim() && + !!lieferung.street.trim() && !!lieferung.zip.trim() && !!lieferung.city.trim() + )) ); default: return false; } @@ -833,7 +870,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri if (!paypalMoeglich && zahlung === 'paypal') setZahlung('rechnung'); }, [paypalMoeglich, zahlung]); - const bestellbereit = agb && datenschutz && final && !!quote; + // Ohne Preis (Extern-Modus/Datei-Produkt) gibt es keinen quote – dort reicht die Zustimmung. + const bestellbereit = agb && datenschutz && final && (!!quote || keinePreise); async function weiter() { // Beim Verlassen des Text-Schritts prüfen, ob das Schriftstück zu viele @@ -940,25 +978,33 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri } } - // Datei-Produkte (z. B. Unterschriftenservice): Dokument hochladen. - let quellDateiId: string | null = null; - if (dateiModus && quellDatei) { + // Datei-Produkte (z. B. Unterschriftenservice): ein oder mehrere + // Dokumente (PDF/Word/Excel) hochladen. + let quellDateiIds: string[] = []; + if (dateiModus && quellDateien.length) { try { - quellDateiId = await uploadFile(quellDatei); + quellDateiIds = await Promise.all(quellDateien.map((f) => uploadFile(f))); } catch { - setBestellFehler(['Das Dokument konnte nicht hochgeladen werden.']); + setBestellFehler(['Ein Dokument konnte nicht hochgeladen werden.']); setSendet(false); return; } } + // Extern-Modus (Etsy o. Ä.): keine Rechnungsdaten erfasst → Platzhalter, + // Bestellung ohne Zahlung, als „extern" markiert. + const bestellEmail = externModus && !rechnung.email.trim() ? 'extern@skrift.de' : rechnung.email.trim(); + const bestellVorname = externModus && !rechnung.first_name.trim() ? 'Externe' : rechnung.first_name; + const bestellNachname = externModus && !rechnung.last_name.trim() ? 'Bestellung' : rechnung.last_name; + const ergebnis = await createOrder({ motif: istPostkarte && motifMode === 'katalog' ? motifId ?? undefined : undefined, motif_upload: motivDateiId ?? undefined, - source_file: quellDateiId ?? undefined, - email: rechnung.email.trim(), - first_name: rechnung.first_name, - last_name: rechnung.last_name, + source_file: quellDateiIds[0] ?? undefined, + source_files: quellDateiIds.length ? quellDateiIds : undefined, + email: bestellEmail, + first_name: bestellVorname, + last_name: bestellNachname, company: rechnung.company, phone: rechnung.phone, person_type: personType ?? 'privat', @@ -976,14 +1022,14 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri ...(designservice ? ['gestaltungsservice'] : []), ], voucher_code: voucher.trim() || undefined, - payment_method: zahlung, - source: loadSession() ? 'portal' : 'configurator', + payment_method: keinePreise ? 'rechnung' : zahlung, + source: externModus ? 'extern' : (loadSession() ? 'portal' : 'configurator'), // Gezeigte Vorschau 1:1 übernehmen (nur wenn unverändert seit der Vorschau). preview_session: previewSession ?? undefined, // Adress-Snapshot für Bestätigung/Rechnung. billing: { - first_name: rechnung.first_name, last_name: rechnung.last_name, company: rechnung.company, - email: rechnung.email, phone: rechnung.phone, street: rechnung.street, + first_name: bestellVorname, last_name: bestellNachname, company: rechnung.company, + email: bestellEmail, phone: rechnung.phone, street: rechnung.street, zip: rechnung.zip, city: rechnung.city, country: rechnung.country, }, shipping_address: lieferAbweichend @@ -1011,6 +1057,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri nummer: ergebnis.order_number, zahlung: ergebnis.payment_method, bezahlt: ergebnis.payment_method !== 'paypal', + extern: externModus, }); } catch (err) { if (err instanceof ApiError) setBestellFehler(err.details.length ? err.details : [err.message]); @@ -1041,9 +1088,11 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri ) : (

- {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.'} + {bestellung.extern + ? 'Die Bestellung wird direkt in die Produktion gegeben – die Abrechnung läuft über die Plattform.' + : bestellung.zahlung === 'rechnung' + ? 'Die Rechnung erhalten Sie per E-Mail. Die Produktion startet nach Zahlungseingang.' + : 'Ihre Zahlung ist eingegangen. Wir haben Ihnen eine Bestätigung per E-Mail geschickt.'}

)} @@ -1079,8 +1128,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri }}>{zeichenHinweis} )} {/* Preiszeile erst ab dem zweiten Schritt (Produkt) und nur für - bepreisbare Produkte – Anfrageprodukte haben keinen Preis. */} - {step > 0 && !kontaktSelektiert && ( + bepreisbare Produkte – Anfrage-, Extern- und Datei-Produkte ohne Preis. */} + {step > 0 && !kontaktSelektiert && !keinePreise && (
@@ -1174,12 +1223,6 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri setQuantity(gueltig); setMengeText(String(gueltig)); }} /> - {nurUpload && ( -

- Ab {TABELLEN_GRENZE + 1} Stück laden Sie die Empfängerdaten als Excel-Datei hoch – - eine Vorlage stellen wir im Schritt „Empfänger" bereit. -

- )}
@@ -1410,17 +1453,18 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
- {/* Art der Erfassung */} + {/* Art der Erfassung. Die Tabellen-Option gibt es nur bis zur + Tabellengrenze – darüber ausschließlich Excel-Upload. */}
{eingabeArt === 'excel' ? ( @@ -1528,20 +1572,26 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri {dateiModus ? (
-

Dokument hochladen

-

Laden Sie das Dokument hoch, das wir unterschreiben und versenden sollen (PDF oder Word).

+

Dokumente hochladen

+

Laden Sie ein oder mehrere Dokumente hoch, die wir unterschreiben und versenden sollen (PDF, Word oder Excel).

- {quellDatei && ( -

- {quellDatei.name} – wird mit der Bestellung übertragen. -

+ {quellDateien.length > 0 && ( +
    + {quellDateien.map((f, i) => ( +
  • + {f.name} + +
  • + ))} +
)}
@@ -1709,8 +1759,6 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri

An diese Adresse stellen wir die Rechnung aus.

- {/* Adressauswahl für angemeldete Kunden mit gespeicherten Adressen. */} - {adressWahl('r-gespeichert', adresseUebernehmen)} setRechnung({ ...rechnung, first_name: v })} /> + {/* Abweichende Lieferadresse gibt es nur beim Sammelversand – + beim Einzelversand geht alles direkt an die Empfänger. */} + {shippingType === 'sammel' && (
+ )} )} {/* ── Übersicht ───────────────────────────────────────── */} {aktuell === 'Übersicht' && ( <> + {keinePreise ? ( +
+

+ {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.'} +

+
+ ) : ( + <>

Kostenübersicht

{quote ? ( @@ -1827,6 +1888,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri

)}
+ + )}
diff --git a/Webseite/skrift-app/src/routes/Stammdaten.tsx b/Webseite/skrift-app/src/routes/Stammdaten.tsx index 0db9799..0f25479 100644 --- a/Webseite/skrift-app/src/routes/Stammdaten.tsx +++ b/Webseite/skrift-app/src/routes/Stammdaten.tsx @@ -1,8 +1,7 @@ import { useEffect, useState } from 'react'; -import { fetchMyCustomer, saveMyCustomer, type Customer, type CustomerAddress } from '../lib/api'; +import { fetchMyCustomer, saveMyCustomer, type Customer, type Adresse2 } from '../lib/api'; const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien', 'Niederlande', 'Frankreich']; -const leereAdresse = (): CustomerAddress => ({ label: '', street: '', zip: '', city: '', country: 'Deutschland' }); /** Stammdaten des angemeldeten Kunden – füllen später den Konfigurator vor. */ export default function Stammdaten() { @@ -37,13 +36,29 @@ export default function Stammdaten() { } } - // ── Adressen (mehrere möglich) ── - const adressen = c?.addresses ?? []; - const setAdressen = (next: CustomerAddress[]) => c && setC({ ...c, addresses: next }); - const setAdrFeld = (i: number, k: keyof CustomerAddress, v: string) => - setAdressen(adressen.map((a, idx) => (idx === i ? { ...a, [k]: v } : a))); - const addAdresse = () => setAdressen([...adressen, leereAdresse()]); - const removeAdresse = (i: number) => setAdressen(adressen.filter((_, idx) => idx !== i)); + // ── Zwei feste Adressen (Rechnung + Lieferung, ohne Bezeichnung) ── + const setAdrFeld = (slot: 'billing_address' | 'shipping_address', k: keyof Adresse2, v: string) => + c && setC({ ...c, [slot]: { ...(c[slot] ?? {}), [k]: v } }); + + const adressBlock = (slot: 'billing_address' | 'shipping_address', titel: string) => { + const a: Adresse2 = c?.[slot] ?? {}; + return ( +
+ + setAdrFeld(slot, 'street', e.target.value)} /> +
+ setAdrFeld(slot, 'zip', e.target.value.replace(/[^0-9]/g, ''))} style={{ flex: '0 0 30%' }} /> + setAdrFeld(slot, 'city', e.target.value)} /> +
+ +
+ ); + }; const feld = (k: keyof Customer, label: string, type = 'text') => (
@@ -92,34 +107,27 @@ export default function Stammdaten() {
- {/* Adressen – beliebig viele; im Konfigurator wählbar. */} + {/* Zwei feste Adressen – werden automatisch im Bestellvorgang eingesetzt. */}
- - {adressen.length === 0 &&

Noch keine Adresse hinterlegt.

} - {adressen.map((a, i) => ( -
-
- setAdrFeld(i, 'label', e.target.value)} /> - -
- setAdrFeld(i, 'street', e.target.value)} /> -
- setAdrFeld(i, 'zip', e.target.value.replace(/[^0-9]/g, ''))} style={{ flex: '0 0 30%' }} /> - setAdrFeld(i, 'city', e.target.value)} /> -
- -
- ))} - + {adressBlock('billing_address', 'Rechnungsadresse')} + {adressBlock('shipping_address', 'Lieferadresse')}
-