Unterschriftenservice, Extern-Modus, Adressmodell, Alt-Mail, Team-Mails
skrift-app (Konfigurator): - Datei-Produkte (Unterschriftenservice): Mehrfach-Upload (PDF/Word/Excel), komplett preisfrei (keine Preisleiste/Aufpreise/Kostenübersicht/Zahlung) - Extern-/Etsy-Modus (?extern=1&versand&kuvert&beschriftung&menge): vom Betreiber vorkonfiguriert, Kunde füllt nur Empfänger/Text/Rechnungsdaten/Übersicht, keine Preise/Zahlung; Menge greift immer (auch >25 / ohne Empfänger); eigener Danke-Text - Adressmodell: zwei feste Adressen (Rechnung/Lieferung) ohne Namen statt Dropdown; Auto-Vorbefüllung; Lieferadresse nur bei Sammelversand; abweichende Lieferadresse per Default aus; Pflichtfelder erzwungen - Platzhalter-Tabelle immer im Text-Schritt (auch Sammelversand ohne Kuvert) + Excel; Tabellen-Eingabe nur bis 25, „Ab 26"-Hinweis entfernt - Stammdaten: zwei Adressblöcke + Erfolgsmeldung Directus/Backend: - Signatur-Endpunkte /signature-preview + /signature-render (Proxy zum signature-service, Marker + Konto-Offsets), Modul-Reiter „Unterschriften" (aus Auftrag oder manuell), Tabelle über volle Breite - Generierung läuft bei jeder Bestellung; Kuverts immer (Datei-Produkt: nur Kuvert, DIN Lang als Default); source_files am Auftrag - Produktions-Board: „Dateien"-Button (hochgeladene Originale, keine SVGs) - Alt-Mail-Login: Zweit-Mails melden im selben Konto an (skrift-auth) - Team-Mails schlank + konfigurierbar (email_settings.team_email) für Bestellung/Muster/Anfrage; keine Bestellbestätigung bei Datei-Produkten, separate Vorlagen unterschriften_eingang/extern_eingang - Rechnungsadresse wird ins Konto übernommen, wenn dort noch keine steht - Kunden-Policy: directus_files create/read (Upload); Bootstrap: users billing_address/shipping_address/alt_emails, orders.source_files/source=extern, signatures marker/offsets, unterschriftenservice input_mode=datei/pricing_mode=formel Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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: `<p>${G}</p><p>Ihr Auftrag <strong>{{order_id}}</strong> ist abgeschlossen. Wir freuen uns auf Ihre nächste Bestellung.</p>` },
|
||||
{ key: 'storniert', label: 'Status: storniert', subject: '{{order_id}} wurde storniert', append_signature: true, attach_agb: false,
|
||||
body: `<p>${G}</p><p>Ihr Auftrag <strong>{{order_id}}</strong> wurde storniert. Bei Fragen sind wir gerne für Sie da.</p>{{reason}}` },
|
||||
// Separate, frei editierbare Mails für Unterschriftenservice- und Extern-Bestellungen.
|
||||
{ key: 'unterschriften_eingang', label: 'Unterschriften: Eingang', subject: 'Ihr Unterschriften-Auftrag {{order_number}} ist eingegangen', append_signature: true, attach_agb: false,
|
||||
body: `<p>${G}</p><p>vielen Dank – Ihr Auftrag <strong>{{order_number}}</strong> für den Unterschriftenservice ist bei uns eingegangen. Wir prüfen Ihr Dokument und melden uns mit den nächsten Schritten.</p>` },
|
||||
{ key: 'extern_eingang', label: 'Externe Bestellung: Eingang', subject: 'Ihre Bestellung {{order_number}} ist eingegangen', append_signature: true, attach_agb: false,
|
||||
body: `<p>${G}</p><p>vielen Dank – Ihre Bestellung <strong>{{order_number}}</strong> ist bei uns eingegangen und geht direkt in die Produktion.</p>` },
|
||||
];
|
||||
for (const t of templates) await seedItem('email_templates', 'key', { enabled: true, ...t });
|
||||
|
||||
@@ -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: '<p>Mit freundlichen Grüßen<br>Ihr Skrift-Team</p>',
|
||||
portal_url: (process.env.CORS_ORIGIN || 'https://app.skrift.de').split(',')[0].trim(),
|
||||
company_footer: 'Skrift | Hundscheiderweg 4 | 66679 Losheim am See | www.skrift.de',
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: `<p>Neue Bestellung <strong>${order.order_number}</strong>${order.product?.name ? ` (${order.product.name})` : ''} – im Produktions-Board unter „Neu".</p>`,
|
||||
};
|
||||
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 }) => {
|
||||
|
||||
@@ -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 = ''; },
|
||||
|
||||
@@ -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:
|
||||
`<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}`);
|
||||
}
|
||||
// Schlanke interne Benachrichtigung.
|
||||
const produkt = body.product ? clean(String(body.product)) : null;
|
||||
await teamMail(schema, `Neue Angebotsanfrage: ${name}`,
|
||||
`<p>Neue Angebotsanfrage von <strong>${name}</strong> (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})`
|
||||
+ `${produkt ? ` · ${produkt}` : ''} – im Board unter „Neu".</p>`);
|
||||
|
||||
return res.json({ ok: true });
|
||||
} catch (err) {
|
||||
@@ -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:
|
||||
`<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}`);
|
||||
}
|
||||
await teamMail(schema, `Neue Musteranfrage: ${clean(String(body.name))}`,
|
||||
`<p>Neue Musteranfrage von <strong>${clean(String(body.name))}</strong> (${email}${body.phone ? `, ${clean(String(body.phone))}` : ''})`
|
||||
+ ` · Produkt: ${clean(String(body.product))} – im Board unter „Neu".</p>`);
|
||||
|
||||
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 =
|
||||
`<p><strong>${clean(name)}</strong> (${email}${phone ? `, ${phone}` : ''})</p>` +
|
||||
`<p>Interesse: <strong>${produkt || 'k. A.'}</strong></p>` +
|
||||
@@ -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 =
|
||||
`<p><strong>${clean(name)}</strong>${unternehmen ? ` · ${unternehmen}` : ''} (${email}${phone ? `, ${phone}` : ''})</p>` +
|
||||
`<p style="white-space:pre-wrap">${nachricht || '—'}</p>`;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <private-view> 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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -171,6 +171,8 @@ export type CustomerAddress = {
|
||||
city?: string;
|
||||
country?: string;
|
||||
};
|
||||
/** Feste Adresse ohne Bezeichnung (Rechnung bzw. Lieferung). */
|
||||
export type Adresse2 = { street?: string; zip?: string; city?: string; country?: string };
|
||||
export type Customer = {
|
||||
/** Directus-User-ID (UUID) – Kunde ist das Login-Konto selbst. */
|
||||
id: string;
|
||||
@@ -180,8 +182,13 @@ export type Customer = {
|
||||
last_name: string | null;
|
||||
company: string | null;
|
||||
phone: string | null;
|
||||
/** Mehrere hinterlegte Adressen (Rechnung/Lieferung). */
|
||||
addresses: CustomerAddress[] | null;
|
||||
/** Genau zwei feste Adressen ohne Namen. */
|
||||
billing_address: Adresse2 | null;
|
||||
shipping_address: Adresse2 | null;
|
||||
/** Weitere Login-Mailadressen (nur Anzeige; Pflege durch den Betreiber). */
|
||||
alt_emails: string[] | null;
|
||||
/** Veraltet – frühere Mehrfach-Adressliste (nur für Alt-Daten). */
|
||||
addresses?: CustomerAddress[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -190,7 +197,7 @@ export type Customer = {
|
||||
*/
|
||||
export async function fetchMyCustomer(): Promise<Customer | null> {
|
||||
const data = await request(
|
||||
'/users/me?fields=id,email,person_type,first_name,last_name,company,phone,addresses',
|
||||
'/users/me?fields=id,email,person_type,first_name,last_name,company,phone,billing_address,shipping_address,alt_emails',
|
||||
);
|
||||
return data?.data ?? null;
|
||||
}
|
||||
@@ -204,7 +211,8 @@ export async function saveMyCustomer(c: Customer) {
|
||||
last_name: c.last_name,
|
||||
company: c.company,
|
||||
phone: c.phone,
|
||||
addresses: c.addresses ?? [],
|
||||
billing_address: c.billing_address ?? null,
|
||||
shipping_address: c.shipping_address ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -401,6 +409,8 @@ export type OrderInput = {
|
||||
text_briefing?: string;
|
||||
/** Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF). */
|
||||
source_file?: string;
|
||||
/** Alle hochgeladenen Dokumente (Datei-Produkte, mehrere möglich). */
|
||||
source_files?: string[];
|
||||
/** Rechnungs-/Lieferadresse als Snapshot (für Bestätigung & Rechnung). */
|
||||
billing?: Record<string, string>;
|
||||
shipping_address?: Record<string, string> | null;
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [bestellung, setBestellung] = useState<{ nummer: string; zahlung: string; bezahlt: boolean } | null>(null);
|
||||
const [bestellung, setBestellung] = useState<{ nummer: string; zahlung: string; bezahlt: boolean; extern?: boolean } | null>(null);
|
||||
|
||||
const [settings, setSettings] = useState<PublicSettings | null>(null);
|
||||
const [preisKatalog, setPreisKatalog] = useState<Record<string, PriceItem>>({});
|
||||
@@ -375,7 +379,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
}, [zeichenHinweis]);
|
||||
const [motivDatei, setMotivDatei] = useState<File | null>(null);
|
||||
/** Hochgeladenes Dokument bei Datei-Produkten (z. B. zu unterschreibendes PDF). */
|
||||
const [quellDatei, setQuellDatei] = useState<File | null>(null);
|
||||
/** Hochgeladene Dokumente bei Datei-Produkten (mehrere PDF/Word/Excel möglich). */
|
||||
const [quellDateien, setQuellDateien] = useState<File[]>([]);
|
||||
const paypalRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -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<CustomerAddress[]>([]);
|
||||
|
||||
// Angemeldete Kunden: Einstufung und Rechnungsdaten übernehmen.
|
||||
// Angemeldete Kunden: Einstufung + die zwei festen Adressen automatisch
|
||||
// übernehmen (Rechnung → Rechnungsdaten, Lieferung → abweichende Lieferadresse).
|
||||
// Kein Adress-Dropdown mehr; alles wird direkt eingesetzt.
|
||||
useEffect(() => {
|
||||
if (!loadSession()) return;
|
||||
fetchMyCustomer()
|
||||
.then((c) => {
|
||||
if (!c) return;
|
||||
if (c.person_type) setPersonType(c.person_type);
|
||||
setKundenAdressen(c.addresses ?? []);
|
||||
const b = c.billing_address ?? {};
|
||||
setRechnung((r) => ({
|
||||
...r,
|
||||
first_name: c.first_name ?? r.first_name,
|
||||
@@ -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 : (
|
||||
<div className="sk-field" style={{
|
||||
marginTop: 'var(--sk-space-5)',
|
||||
marginBottom: 'var(--sk-space-5)',
|
||||
padding: 'var(--sk-space-4)',
|
||||
border: '1px solid var(--sk-accent, #1E3A6A)',
|
||||
borderRadius: 'var(--sk-radius, 10px)',
|
||||
background: 'rgba(30, 58, 106, 0.04)',
|
||||
}}>
|
||||
<label className="sk-label" htmlFor={id}>Gespeicherte Adresse übernehmen</label>
|
||||
<select id={id} className="sk-input" defaultValue=""
|
||||
onChange={(e) => { const i = Number(e.target.value); if (!Number.isNaN(i) && kundenAdressen[i]) uebernehmen(kundenAdressen[i]); }}>
|
||||
<option value="">Bitte wählen …</option>
|
||||
{kundenAdressen.map((a, i) => (
|
||||
<option key={i} value={i}>
|
||||
{[a.label, a.street, [a.zip, a.city].filter(Boolean).join(' ')].filter(Boolean).join(' · ') || `Adresse ${i + 1}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Sichtbarkeit je Personentyp (im Admin über products.visibility pflegbar).
|
||||
// Ein per Direktlink vorgewähltes Produkt bleibt immer sichtbar – sonst würde
|
||||
// die Anfrage abbrechen, sobald z. B. „privat" bei einem Unternehmens-Produkt gewählt wird.
|
||||
@@ -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
|
||||
</>
|
||||
) : (
|
||||
<p style={{ marginTop: 'var(--sk-space-3)' }} className="sk-note">
|
||||
{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.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1079,8 +1128,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
}}>{zeichenHinweis}</div>
|
||||
)}
|
||||
{/* Preiszeile erst ab dem zweiten Schritt (Produkt) und nur für
|
||||
bepreisbare Produkte – Anfrageprodukte haben keinen Preis. */}
|
||||
{step > 0 && !kontaktSelektiert && (
|
||||
bepreisbare Produkte – Anfrage-, Extern- und Datei-Produkte ohne Preis. */}
|
||||
{step > 0 && !kontaktSelektiert && !keinePreise && (
|
||||
<div className="sk-pricebar">
|
||||
<div className="sk-pricebar-inner">
|
||||
<div className="sk-price-block">
|
||||
@@ -1174,12 +1223,6 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
setQuantity(gueltig);
|
||||
setMengeText(String(gueltig));
|
||||
}} />
|
||||
{nurUpload && (
|
||||
<p className="sk-note" style={{ marginTop: 'var(--sk-space-2)' }}>
|
||||
Ab {TABELLEN_GRENZE + 1} Stück laden Sie die Empfängerdaten als Excel-Datei hoch –
|
||||
eine Vorlage stellen wir im Schritt „Empfänger" bereit.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1410,17 +1453,18 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
|
||||
<hr className="sk-group-split" />
|
||||
|
||||
{/* Art der Erfassung */}
|
||||
{/* Art der Erfassung. Die Tabellen-Option gibt es nur bis zur
|
||||
Tabellengrenze – darüber ausschließlich Excel-Upload. */}
|
||||
<div className="sk-options">
|
||||
<Option name="eingabe" selected={eingabeArt === 'excel'}
|
||||
onSelect={() => setEingabeArt('excel')}
|
||||
title="Excel-Datei hochladen" />
|
||||
<Option name="eingabe" selected={eingabeArt === 'tabelle'}
|
||||
onSelect={() => !nurUpload && setEingabeArt('tabelle')}
|
||||
title="Direkt in Tabelle eingeben"
|
||||
desc={nurUpload
|
||||
? `Ab ${TABELLEN_GRENZE + 1} Empfängern nicht möglich – bitte Datei hochladen`
|
||||
: `Bis ${TABELLEN_GRENZE} Empfänger – ohne Datei-Upload`} />
|
||||
{!nurUpload && (
|
||||
<Option name="eingabe" selected={eingabeArt === 'tabelle'}
|
||||
onSelect={() => setEingabeArt('tabelle')}
|
||||
title="Direkt in Tabelle eingeben"
|
||||
desc={`Bis ${TABELLEN_GRENZE} Empfänger – ohne Datei-Upload`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{eingabeArt === 'excel' ? (
|
||||
@@ -1528,20 +1572,26 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
{dateiModus ? (
|
||||
<section className="sk-card sk-card--head" style={{ marginTop: 'var(--sk-space-3)' }}>
|
||||
<div className="sk-card-head">
|
||||
<h2>Dokument hochladen</h2>
|
||||
<p>Laden Sie das Dokument hoch, das wir unterschreiben und versenden sollen (PDF oder Word).</p>
|
||||
<h2>Dokumente hochladen</h2>
|
||||
<p>Laden Sie ein oder mehrere Dokumente hoch, die wir unterschreiben und versenden sollen (PDF, Word oder Excel).</p>
|
||||
</div>
|
||||
<div className="sk-card-body">
|
||||
<label className="sk-drop">
|
||||
<span className="sk-drop-title">Datei hier ablegen oder klicken</span>
|
||||
<span className="sk-drop-sub" style={{ display: 'block' }}>.pdf, .doc oder .docx</span>
|
||||
<input type="file" accept=".pdf,.doc,.docx"
|
||||
onChange={(e) => setQuellDatei(e.target.files?.[0] ?? null)} />
|
||||
<span className="sk-drop-title">Dateien hier ablegen oder klicken</span>
|
||||
<span className="sk-drop-sub" style={{ display: 'block' }}>.pdf, .doc, .docx, .xls, .xlsx – mehrere möglich</span>
|
||||
<input type="file" multiple accept=".pdf,.doc,.docx,.xls,.xlsx"
|
||||
onChange={(e) => setQuellDateien((prev) => [...prev, ...Array.from(e.target.files ?? [])])} />
|
||||
</label>
|
||||
{quellDatei && (
|
||||
<p className="sk-note" style={{ marginTop: 'var(--sk-space-3)' }}>
|
||||
{quellDatei.name} – wird mit der Bestellung übertragen.
|
||||
</p>
|
||||
{quellDateien.length > 0 && (
|
||||
<ul className="sk-note" style={{ marginTop: 'var(--sk-space-3)', paddingLeft: '1.1em' }}>
|
||||
{quellDateien.map((f, i) => (
|
||||
<li key={i}>
|
||||
{f.name}
|
||||
<button type="button" className="sk-btn sk-btn--ghost" style={{ marginLeft: 'var(--sk-space-2)', padding: '2px 8px' }}
|
||||
onClick={() => setQuellDateien((prev) => prev.filter((_, idx) => idx !== i))}>Entfernen</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1709,8 +1759,6 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
<p>An diese Adresse stellen wir die Rechnung aus.</p>
|
||||
</div>
|
||||
<div className="sk-card-body">
|
||||
{/* Adressauswahl für angemeldete Kunden mit gespeicherten Adressen. */}
|
||||
{adressWahl('r-gespeichert', adresseUebernehmen)}
|
||||
<Feld id="r-vn" label="Vorname" value={rechnung.first_name} required
|
||||
onChange={(v) => setRechnung({ ...rechnung, first_name: v })} />
|
||||
<Feld id="r-nn" label="Nachname" value={rechnung.last_name} required
|
||||
@@ -1741,6 +1789,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Abweichende Lieferadresse gibt es nur beim Sammelversand –
|
||||
beim Einzelversand geht alles direkt an die Empfänger. */}
|
||||
{shippingType === 'sammel' && (
|
||||
<section className="sk-card" style={{ marginTop: 'var(--sk-space-3)' }}>
|
||||
<label className="sk-check">
|
||||
<input type="checkbox" checked={lieferAbweichend}
|
||||
@@ -1750,7 +1801,6 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
|
||||
{lieferAbweichend && (
|
||||
<div style={{ marginTop: '18px' }}>
|
||||
{adressWahl('l-gespeichert', lieferungUebernehmen)}
|
||||
<Feld id="l-vn" label="Vorname" value={lieferung.first_name} required
|
||||
onChange={(v) => setLieferung({ ...lieferung, first_name: v })} />
|
||||
<Feld id="l-nn" label="Nachname" value={lieferung.last_name} required
|
||||
@@ -1771,12 +1821,23 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Übersicht ───────────────────────────────────────── */}
|
||||
{aktuell === 'Übersicht' && (
|
||||
<>
|
||||
{keinePreise ? (
|
||||
<section className="sk-card">
|
||||
<p className="sk-note" style={{ margin: 0 }}>
|
||||
{externModus
|
||||
? 'Externe Bestellung – sie wird ohne Preis und ohne Zahlung direkt zur Produktion eingereiht (Abrechnung erfolgt über die Plattform).'
|
||||
: 'Der Preis wird individuell berechnet – Sie erhalten die Rechnung anschließend per E-Mail.'}
|
||||
</p>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className="sk-card">
|
||||
<h2>Kostenübersicht</h2>
|
||||
{quote ? (
|
||||
@@ -1827,6 +1888,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<section style={{ marginTop: 'var(--sk-space-3)' }}>
|
||||
<label className="sk-check sk-check--card">
|
||||
@@ -1875,7 +1938,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
|
||||
<button className="sk-btn sk-btn--primary sk-btn--block" style={{ marginTop: 'var(--sk-space-5)' }}
|
||||
disabled={!bestellbereit || sendet} onClick={bestellen}>
|
||||
{sendet ? 'Wird gesendet …' : 'Zahlungspflichtig bestellen'}
|
||||
{sendet ? 'Wird gesendet …' : externModus ? 'Bestellung absenden' : keinePreise ? 'Kostenpflichtig bestellen' : 'Zahlungspflichtig bestellen'}
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchMyCustomer, saveMyCustomer, type Customer, type CustomerAddress } from '../lib/api';
|
||||
import { fetchMyCustomer, saveMyCustomer, type Customer, type Adresse2 } from '../lib/api';
|
||||
|
||||
const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien', 'Niederlande', 'Frankreich'];
|
||||
const leereAdresse = (): CustomerAddress => ({ label: '', street: '', zip: '', city: '', country: 'Deutschland' });
|
||||
|
||||
/** Stammdaten des angemeldeten Kunden – füllen später den Konfigurator vor. */
|
||||
export default function Stammdaten() {
|
||||
@@ -37,13 +36,29 @@ export default function Stammdaten() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Adressen (mehrere möglich) ──
|
||||
const adressen = c?.addresses ?? [];
|
||||
const setAdressen = (next: CustomerAddress[]) => c && setC({ ...c, addresses: next });
|
||||
const setAdrFeld = (i: number, k: keyof CustomerAddress, v: string) =>
|
||||
setAdressen(adressen.map((a, idx) => (idx === i ? { ...a, [k]: v } : a)));
|
||||
const addAdresse = () => setAdressen([...adressen, leereAdresse()]);
|
||||
const removeAdresse = (i: number) => setAdressen(adressen.filter((_, idx) => idx !== i));
|
||||
// ── Zwei feste Adressen (Rechnung + Lieferung, ohne Bezeichnung) ──
|
||||
const setAdrFeld = (slot: 'billing_address' | 'shipping_address', k: keyof Adresse2, v: string) =>
|
||||
c && setC({ ...c, [slot]: { ...(c[slot] ?? {}), [k]: v } });
|
||||
|
||||
const adressBlock = (slot: 'billing_address' | 'shipping_address', titel: string) => {
|
||||
const a: Adresse2 = c?.[slot] ?? {};
|
||||
return (
|
||||
<div className="sk-check--card" style={{ padding: 'var(--sk-space-4)', marginBottom: 'var(--sk-space-3)', border: '1px solid var(--sk-border)', borderRadius: 'var(--sk-radius, 10px)' }}>
|
||||
<label className="sk-label" style={{ marginBottom: 'var(--sk-space-2)', display: 'block' }}>{titel}</label>
|
||||
<input className="sk-input" style={{ marginBottom: 'var(--sk-space-2)' }} placeholder="Straße & Hausnummer"
|
||||
value={a.street ?? ''} onChange={(e) => setAdrFeld(slot, 'street', e.target.value)} />
|
||||
<div style={{ display: 'flex', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
|
||||
<input className="sk-input" placeholder="PLZ" value={a.zip ?? ''}
|
||||
onChange={(e) => setAdrFeld(slot, 'zip', e.target.value.replace(/[^0-9]/g, ''))} style={{ flex: '0 0 30%' }} />
|
||||
<input className="sk-input" placeholder="Ort" value={a.city ?? ''}
|
||||
onChange={(e) => setAdrFeld(slot, 'city', e.target.value)} />
|
||||
</div>
|
||||
<select className="sk-input" value={a.country ?? 'Deutschland'} onChange={(e) => setAdrFeld(slot, 'country', e.target.value)}>
|
||||
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const feld = (k: keyof Customer, label: string, type = 'text') => (
|
||||
<div className="sk-field">
|
||||
@@ -92,34 +107,27 @@ export default function Stammdaten() {
|
||||
<input className="sk-input" value={c.email} disabled />
|
||||
</div>
|
||||
|
||||
{/* Adressen – beliebig viele; im Konfigurator wählbar. */}
|
||||
{/* Zwei feste Adressen – werden automatisch im Bestellvorgang eingesetzt. */}
|
||||
<div className="sk-field" style={{ marginBottom: 0 }}>
|
||||
<label className="sk-label">Adressen</label>
|
||||
{adressen.length === 0 && <p className="sk-note">Noch keine Adresse hinterlegt.</p>}
|
||||
{adressen.map((a, i) => (
|
||||
<div key={i} className="sk-check--card" style={{ padding: 'var(--sk-space-4)', marginBottom: 'var(--sk-space-3)', border: '1px solid var(--sk-border)', borderRadius: 'var(--sk-radius, 10px)' }}>
|
||||
<div style={{ display: 'flex', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
|
||||
<input className="sk-input" placeholder="Bezeichnung (z. B. Rechnung, Lieferung)"
|
||||
value={a.label ?? ''} onChange={(e) => setAdrFeld(i, 'label', e.target.value)} />
|
||||
<button type="button" className="sk-btn sk-btn--ghost" onClick={() => removeAdresse(i)}>Entfernen</button>
|
||||
</div>
|
||||
<input className="sk-input" style={{ marginBottom: 'var(--sk-space-2)' }} placeholder="Straße & Hausnummer"
|
||||
value={a.street ?? ''} onChange={(e) => setAdrFeld(i, 'street', e.target.value)} />
|
||||
<div style={{ display: 'flex', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
|
||||
<input className="sk-input" placeholder="PLZ" value={a.zip ?? ''}
|
||||
onChange={(e) => setAdrFeld(i, 'zip', e.target.value.replace(/[^0-9]/g, ''))} style={{ flex: '0 0 30%' }} />
|
||||
<input className="sk-input" placeholder="Ort" value={a.city ?? ''}
|
||||
onChange={(e) => setAdrFeld(i, 'city', e.target.value)} />
|
||||
</div>
|
||||
<select className="sk-input" value={a.country ?? 'Deutschland'} onChange={(e) => setAdrFeld(i, 'country', e.target.value)}>
|
||||
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="sk-btn sk-btn--ghost" onClick={addAdresse}>+ Adresse hinzufügen</button>
|
||||
{adressBlock('billing_address', 'Rechnungsadresse')}
|
||||
{adressBlock('shipping_address', 'Lieferadresse')}
|
||||
</div>
|
||||
|
||||
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-5)' }} disabled={status === 'speichert'}>
|
||||
{/* Zusätzliche Login-Mailadressen (Pflege durch Skrift). */}
|
||||
{c.alt_emails && c.alt_emails.length > 0 && (
|
||||
<div className="sk-field" style={{ marginTop: 'var(--sk-space-4)', marginBottom: 0 }}>
|
||||
<label className="sk-label">Weitere Login-Adressen</label>
|
||||
<p className="sk-note">{c.alt_emails.join(', ')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'gespeichert' && (
|
||||
<p className="sk-note" role="status" style={{
|
||||
marginTop: 'var(--sk-space-4)', color: 'var(--sk-success, #2e7d32)', fontWeight: 600,
|
||||
}}>✓ Stammdaten gespeichert.</p>
|
||||
)}
|
||||
|
||||
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-4)' }} disabled={status === 'speichert'}>
|
||||
{status === 'speichert' ? 'Wird gespeichert …' : status === 'gespeichert' ? 'Gespeichert' : 'Speichern'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user