diff --git a/Docker/directus/bootstrap/bootstrap.mjs b/Docker/directus/bootstrap/bootstrap.mjs index cd9c8ac..b2923c1 100644 --- a/Docker/directus/bootstrap/bootstrap.mjs +++ b/Docker/directus/bootstrap/bootstrap.mjs @@ -333,6 +333,7 @@ const collections = [ f('country', 'string', { interface: 'input' }), f('free_text', 'text', { interface: 'input-multiline', note: 'Alternativ zur strukturierten Adresse.' }), f('placeholders', 'json', { interface: 'list', note: 'Zusätzliche Platzhalterwerte.' }), + f('text', 'text', { interface: 'input-multiline', note: 'Abweichender Brieftext NUR für dieses Schriftstück (leer = Standardtext des Auftrags).' }), ] }, { collection: 'order_addons', meta: { icon: 'add_shopping_cart', note: 'Junction Auftrag ↔ Zusatzleistung.', hidden: true }, fields: [ diff --git a/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js index 2ad593f..e64cc3d 100644 --- a/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-mailer/dist/index.js @@ -23,6 +23,39 @@ const ENVELOPE_LABEL = { dinlang: 'DIN Lang', c6: 'C6' }; const LABELING_LABEL = { empfaenger: 'Empfängeradresse', freitext: 'Individueller Text', keine: 'Keine' }; const UPLOAD_ROOT = process.env.STORAGE_LOCAL_ROOT || '/directus/uploads'; +// Eingebaute Fallback-Vorlagen: greifen NUR, wenn in `email_templates` keine +// aktive Vorlage mit passendem Schlüssel existiert. So gehen Bestätigung und +// die kundenrelevanten Status-Mails auch ohne konfigurierte Vorlage sicher raus +// (analog zum Login-Fallback in skrift-auth). Interne Status (in_queue, gedruckt …) +// haben bewusst KEINEN Fallback → für die braucht es eine aktive Vorlage. +const FALLBACK_TEMPLATES = { + order_created: { + subject: 'Bestellbestätigung {{order_id}}', + body: `

Hallo {{name}},

+

vielen Dank für Ihre Bestellung {{order_id}}. Wir haben Ihren Auftrag erhalten und melden uns mit den nächsten Schritten.

`, + }, + bezahlt: { + subject: 'Zahlung erhalten – Bestellung {{order_id}}', + body: `

Hallo {{name}},

+

Ihre Zahlung zur Bestellung {{order_id}} ist bei uns eingegangen. Wir starten nun mit der Bearbeitung.

`, + }, + versendet: { + subject: 'Ihre Bestellung {{order_id}} ist unterwegs', + body: `

Hallo {{name}},

+

Ihre Bestellung {{order_id}} wurde versendet.

`, + }, + abgeschlossen: { + subject: 'Bestellung {{order_id}} abgeschlossen', + body: `

Hallo {{name}},

+

Ihre Bestellung {{order_id}} ist abgeschlossen. Vielen Dank für Ihr Vertrauen!

`, + }, + storniert: { + subject: 'Bestellung {{order_id}} storniert', + body: `

Hallo {{name}},

+

Ihre Bestellung {{order_id}} wurde storniert. Bei Fragen antworten Sie einfach auf diese E-Mail.

`, + }, +}; + const render = (str, vars) => String(str || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : '')); const eur = (n) => (Number(n) || 0).toFixed(2).replace('.', ','); @@ -57,17 +90,27 @@ 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', 'team_email'] }); + .readSingleton({ fields: ['signature', 'portal_url', 'company_footer', 'agb_file', 'sender_name', 'sender_email'] }); } catch { return {}; } } + /** Konfigurierter Team-Empfänger (separat gelesen, damit ein evtl. noch nicht + * gebootstraptes Feld die Kundenmails nicht beeinträchtigt). */ + async function teamEmpfaenger(schema) { + try { + const es = await new ItemsService('email_settings', { schema, accountability: null }) + .readSingleton({ fields: ['team_email'] }); + return (es?.team_email || '').trim(); + } catch { return ''; } + } + /** Schlanke interne Benachrichtigung „Neue Bestellung …" (Empfänger konfigurierbar). */ async function teamNotify(orderId) { try { const schema = await getSchema(); - const settings = await loadSettings(schema); - const to = (settings?.team_email || '').trim(); + const to = await teamEmpfaenger(schema); if (!to) return; + const settings = await loadSettings(schema); const order = await new ItemsService('orders', { schema, accountability: null }) .readOne(orderId, { fields: ['order_number', 'product.name'] }); if (!order) return; @@ -102,8 +145,8 @@ export default (register, { services, getSchema, logger }) => { async function sendForOrder(orderId, key, extraVars = {}) { const schema = await getSchema(); - const tpl = await loadTemplate(key, schema); - if (!tpl) return; // keine (aktive) Vorlage → keine Mail + const tpl = await loadTemplate(key, schema) || FALLBACK_TEMPLATES[key]; + if (!tpl) return; // weder aktive Vorlage noch Fallback → keine Mail const order = await new ItemsService('orders', { schema, accountability: null }).readOne(orderId, { fields: [ diff --git a/Docker/directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js index 58ec20a..f7aeedd 100644 --- a/Docker/directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js @@ -94,8 +94,9 @@ const NeuAuftrag = defineComponent({ musterListe.value = mtx.data?.data || []; const wl = ps.data?.data?.char_whitelist; // Zeilenumbrüche immer erlauben (Brieftext ist mehrzeilig). Einzeilige - // Felder entfernen \n/\r ohnehin separat, daher hier gefahrlos. - try { verbotenRe.value = wl ? new RegExp(`[^${wl}\\n\\r]`, 'g') : null; } catch { verbotenRe.value = null; } + // Felder entfernen \n/\r ohnehin separat, daher hier gefahrlos. Tabs (\t) + // sind erlaubt, damit man z.B. das Datum an den rechten Rand rücken kann. + try { verbotenRe.value = wl ? new RegExp(`[^${wl}\\n\\r\\t]`, 'g') : null; } catch { verbotenRe.value = null; } neu.product = produkte.value[0]?.key || 'briefe'; neu.format = neuFormate()[0]?.key || ''; } catch (e) { @@ -302,7 +303,15 @@ const NeuAuftrag = defineComponent({ )), ]), label(`Brieftext · ${neu.text.length} Zeichen · [[Platzhalter]] erlaubt`), - h('textarea', { value: neu.text, rows: 8, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.text = saeubere(e.target.value); neu.previewSession = null; } }), + h('textarea', { value: neu.text, rows: 8, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.text = saeubere(e.target.value); neu.previewSession = null; }, onKeydown: (e) => { + // Tab-Taste fügt einen echten Tabulator ein (statt den Fokus zu verschieben). + if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return; + e.preventDefault(); + const ta = e.target, s = ta.selectionStart, en = ta.selectionEnd; + neu.text = saeubere(neu.text.slice(0, s) + '\t' + neu.text.slice(en)); + neu.previewSession = null; + requestAnimationFrame(() => { try { ta.selectionStart = ta.selectionEnd = s + 1; } catch {} }); + } }), ]); // Bereich 4 – Kunde diff --git a/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js index df054e7..5988fc4 100644 --- a/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-orders/dist/index.js @@ -550,8 +550,10 @@ const handler = (router, { services, getSchema, logger }) => { basis.forEach((e, i) => { // Dateinummer 1-basiert = Briefnummer (letter_001.svg ↔ Brief 1). const nr = i + 1; + // Abweichender Text je Schriftstück (#5) hat Vorrang; sonst der Standardtext. + const briefText = (e.text && String(e.text).trim()) ? String(e.text) : String(body.text); letters.push({ - index: nr, type: 'letter', text: clean(String(body.text)), + index: nr, type: 'letter', text: clean(briefText), format: briefFormat, font, realisticHandwriting: realistisch, placeholders: platzhalterVon(e, i), }); @@ -1594,6 +1596,8 @@ const handler = (router, { services, getSchema, logger }) => { placeholders: e.placeholders && typeof e.placeholders === 'object' ? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))])) : null, + // Abweichender Text nur für dieses Schriftstück (#5). + text: (e.text && String(e.text).trim()) ? clean(String(e.text)) : null, }); } diff --git a/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js b/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js index 4ec68c3..2feebdf 100644 --- a/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js +++ b/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js @@ -332,7 +332,7 @@ const Board = defineComponent({ if (!sel.length) return; const map = {}; const add = (key, label, typ) => { - if (!map[key]) map[key] = { key, label, type: typ, template: stdTpl(key, typ), machine: aktiveMaschinen.value[0]?.id ?? null }; + if (!map[key]) map[key] = { key, label, type: typ, aktiv: true, template: stdTpl(key, typ), machine: aktiveMaschinen.value[0]?.id ?? null }; }; for (const o of sel) { add(o.format?.key || 'a4', o.format?.name || o.format?.key || 'A4', 'brief'); @@ -346,7 +346,9 @@ const Board = defineComponent({ } async function submitBulk() { const byFormat = {}; - for (const g of bulk.gruppen) { + const aktive = bulk.gruppen.filter((g) => g.aktiv); + if (!aktive.length) { bulk.err = 'Mindestens ein Format auswählen.'; return; } + for (const g of aktive) { if (!g.template || !g.machine) { bulk.err = `Für „${g.label}" bitte Template und Maschine wählen.`; return; } byFormat[g.key] = { template: g.template, machine: g.machine, label: g.label }; } @@ -757,9 +759,14 @@ const Board = defineComponent({ if (!bulk.open) return null; const gruppeRow = (g) => { const tplOpts = templates.value.filter((t) => t.type === g.type); - return h('div', { style: SECTION }, [ - h('div', { style: 'font-weight:700;margin-bottom:10px;' }, `${g.type === 'umschlag' ? '✉' : '📄'} ${g.label}`), - h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px;' }, [ + return h('div', { style: SECTION + (g.aktiv ? '' : 'opacity:.5;') }, [ + // Format-Auswahl: abgehakt = kommt in den Sammeldruck (z. B. nur Schriftstücke, + // Kuverts abwählen). Abgewählte Formate werden komplett übersprungen. + h('label', { style: 'display:flex;align-items:center;gap:8px;font-weight:700;margin-bottom:10px;cursor:pointer;' }, [ + h('input', { type: 'checkbox', checked: g.aktiv, onChange: (e) => { g.aktiv = e.target.checked; }, style: 'width:16px;height:16px;' }), + `${g.type === 'umschlag' ? '✉' : '📄'} ${g.label}`, + ]), + g.aktiv ? h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px;' }, [ h('div', {}, [ h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Template'), h('select', { style: S_SELECT + 'width:100%;', value: g.template, onChange: (e) => { g.template = e.target.value; } }, @@ -770,7 +777,7 @@ const Board = defineComponent({ h('select', { style: S_SELECT + 'width:100%;', value: g.machine, onChange: (e) => { g.machine = e.target.value; } }, aktiveMaschinen.value.length ? aktiveMaschinen.value.map((mm) => h('option', { value: mm.id, selected: String(g.machine) === String(mm.id) }, mm.name)) : [h('option', { value: '' }, 'keine Maschine')]), ]), - ]), + ]) : null, ]); }; const body = h('div', { style: 'padding:18px 20px;' }, [ diff --git a/Docker/skrift-backend/src/api/controllers/directus-controller.js b/Docker/skrift-backend/src/api/controllers/directus-controller.js index a7681f2..c072ce9 100644 --- a/Docker/skrift-backend/src/api/controllers/directus-controller.js +++ b/Docker/skrift-backend/src/api/controllers/directus-controller.js @@ -63,10 +63,12 @@ function buildDocuments(order, entries) { const nr = Number(entry.letter_number) || (i + 1); if (!istDatei) { + // Abweichender Text je Schriftstück (#5) hat Vorrang; sonst Standardtext. + const briefText = (entry.text && String(entry.text).trim()) ? entry.text : (order.text_template || ''); documents.push({ index: nr, type: 'letter', - text: order.text_template || '', + text: briefText, format: letterFormat, font, placeholders, diff --git a/Docker/skrift-backend/src/lib/svg-font-engine.js b/Docker/skrift-backend/src/lib/svg-font-engine.js index 3bc69e7..432365c 100644 --- a/Docker/skrift-backend/src/lib/svg-font-engine.js +++ b/Docker/skrift-backend/src/lib/svg-font-engine.js @@ -78,8 +78,10 @@ const TAB_MARKER = ''; /** * Breite einer Textzeile in Pixeln messen. * Unterstützt TAB_MARKER: springt zur nächsten Tabstop-Position (6 × fontSizePx). + * maxWidthPx (optional) begrenzt den Tab-Sprung auf den rechten Rand, damit viele + * Tabs nicht über die Seite hinauslaufen. Ohne Angabe (Infinity) unverändert. */ -function measureTextWidthPx({ text, fontFileName, fontSizePx }) { +function measureTextWidthPx({ text, fontFileName, fontSizePx, maxWidthPx = Infinity }) { const { unitsPerEm, glyphs } = parseSvgFont(fontFileName); const scale = fontSizePx / unitsPerEm; const tabStopPx = fontSizePx * 6; @@ -88,7 +90,7 @@ function measureTextWidthPx({ text, fontFileName, fontSizePx }) { for (const ch of String(text || '')) { if (ch === TAB_MARKER) { - x = Math.ceil((x + 1) / tabStopPx) * tabStopPx; + x = Math.min(Math.ceil((x + 1) / tabStopPx) * tabStopPx, maxWidthPx); continue; } @@ -126,6 +128,19 @@ function wrapTextToLinesByWidth({ measureTextWidthPx({ text: t, fontFileName, fontSizePx }); for (const para of paragraphs) { + // Führende Tabs = bewusste Positionierung der Zeile (z.B. Datum nach rechts). + // Solche Zeilen NICHT nach Wortbreite umbrechen, sonst würde der Text als + // "zu breit" gewertet und die Wörter fielen nach links auf eine neue Zeile. + // Der eigentliche Versatz (bzw. Rechtsbündigkeit bei Überlauf) passiert im Layout. + const lead = para.match(new RegExp(`^${TAB_MARKER}+`)); + if (lead) { + const rest = para.slice(lead[0].length); + if (!rest.includes(TAB_MARKER) && measure(rest) <= maxWidthPx) { + allLines.push(para); + continue; + } + } + // Tokens: Wörter und TAB_MARKER getrennt ermitteln. // TAB_MARKER durch einen Platzhalter ersetzen, mit \s+ splitten, dann zurücktauschen. const TAB_PLACEHOLDER = ''; @@ -208,6 +223,7 @@ function layoutTextToPaths({ startY, lineHeightPx, realisticHandwriting = false, + maxWidthPx = Infinity, }) { const { unitsPerEm, glyphs } = parseSvgFont(fontFileName); @@ -223,13 +239,32 @@ function layoutTextToPaths({ const tabStopPx = fontSizePx * 6; - for (let i = 0; i < line.length; i++) { + // Führende Tabs vorab auflösen: normalerweise an die Tab-Position rücken; + // würde der restliche Zeileninhalt dort über den rechten Rand laufen, die + // Zeile stattdessen rechtsbündig an den Rand setzen (z.B. Datum ganz rechts, + // ohne dass etwas daneben steht). Nur aktiv, wenn maxWidthPx bekannt ist. + let startIdx = 0; + if (line[0] === TAB_MARKER) { + let leadCount = 0; + while (line[leadCount] === TAB_MARKER) leadCount++; + let tx = 0; + for (let t = 0; t < leadCount; t++) { + tx = Math.min(Math.ceil((tx + 1) / tabStopPx) * tabStopPx, maxWidthPx); + } + const rest = line.slice(leadCount); + const restW = measureTextWidthPx({ text: rest, fontFileName, fontSizePx, maxWidthPx }); + if (tx + restW > maxWidthPx) tx = Math.max(0, maxWidthPx - restW); + x = startX + tx; + startIdx = leadCount; + } + + for (let i = startIdx; i < line.length; i++) { const ch = line[i]; - // Tabulator-Marker → zur nächsten Tabstop-Position springen + // Tabulator-Marker → zur nächsten Tabstop-Position springen (an den Rand geclampt) if (ch === TAB_MARKER) { const relX = x - startX; - const nextTab = Math.ceil((relX + 1) / tabStopPx) * tabStopPx; + const nextTab = Math.min(Math.ceil((relX + 1) / tabStopPx) * tabStopPx, maxWidthPx); x = startX + nextTab; continue; } diff --git a/Docker/skrift-backend/src/lib/svg-generator.js b/Docker/skrift-backend/src/lib/svg-generator.js index 490988e..a6afb73 100644 --- a/Docker/skrift-backend/src/lib/svg-generator.js +++ b/Docker/skrift-backend/src/lib/svg-generator.js @@ -92,6 +92,7 @@ function generateLetterSVG(text, format, options = {}) { startY: firstBaselineY, lineHeightPx, realisticHandwriting: options.realisticHandwriting ?? false, + maxWidthPx, }); const pathElements = paths @@ -167,6 +168,7 @@ function generateLetterSVG(text, format, options = {}) { startY: currentY, lineHeightPx, realisticHandwriting: options.realisticHandwriting ?? false, + maxWidthPx, }); paths.forEach(p => allSvgElements.push( `; + /** Abweichender Text NUR für dieses Schriftstück (leer/undefined = Text aus #1). */ + text?: string; }; type Adresse = { @@ -319,6 +321,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri const [textservice, setTextservice] = useState(entwurf.textservice ?? false); const [textBriefing, setTextBriefing] = useState(entwurf.textBriefing ?? ''); const [textTemplate, setTextTemplate] = useState(entwurf.textTemplate ?? ''); + /** Aktuell im Textfeld angezeigtes Schriftstück (0 = Standardtext, der für alle gilt). */ + const [textDocIndex, setTextDocIndex] = useState(0); const [rechnung, setRechnung] = useState(entwurf.rechnung ?? leereAdresse()); const [lieferAbweichend, setLieferAbweichend] = useState(entwurf.lieferAbweichend ?? false); @@ -359,8 +363,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri const wl = settings?.char_whitelist; if (!wl) return null; // Zeilenumbrüche sind im mehrzeiligen Text erlaubt; einzeilige Felder - // entfernen \n/\r ohnehin separat vor saeubere(). - try { return new RegExp(`[^${wl}\\n\\r]`, 'g'); } catch { return null; } + // entfernen \n/\r ohnehin separat vor saeubere(). Tabs (\t) sind erlaubt, + // damit man z.B. das Datum an den rechten Rand rücken kann. + try { return new RegExp(`[^${wl}\\n\\r\\t]`, 'g'); } catch { return null; } }, [settings?.char_whitelist]); /** Entfernt verbotene Zeichen und meldet sie; gibt den bereinigten Wert zurück. */ const saeubere = (v: string) => { @@ -530,12 +535,30 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri /** Zeichengrenze richtet sich nach dem Format – wie bisher. */ const maxText = format?.startsWith('a4') ? MAX_TEXT.a4 : MAX_TEXT.a6; - const textZuLang = textTemplate.length > maxText; + + // ── Text je Schriftstück (#5) ────────────────────────────────────────────── + // Pfeile im Textfeld: pro Dokument ein eigener Text möglich (Standard = #1). + // Nur im Tabellenmodus (≤ 25) und bei echtem Text (kein Textservice/Datei). + const proDokText = !textservice && !dateiModus && !nurUpload && entries.length > 1; + const textDocs = Math.min(entries.length, TABELLEN_GRENZE); + // Angezeigter Text: #1 = Standardtext (textTemplate); ab #2 der Override bzw. + // – solange nicht geändert – der Standardtext. + const aktDocText = textDocIndex === 0 ? textTemplate : (entries[textDocIndex]?.text ?? textTemplate); + const setDocText = (v: string) => { + const clean = saeubere(v); + if (textDocIndex === 0) { setTextTemplate(clean); return; } + setEntries((prev) => prev.map((e, i) => (i === textDocIndex ? { ...e, text: clean } : e))); + setPreviewSession(null); + }; + const textZuLang = aktDocText.length > maxText; // Ändert sich Text, Format oder Schrift, ist ein früher erkannter Überlauf // hinfällig – erst die nächste Vorschau entscheidet erneut. useEffect(() => { setTextUeberlauf(false); }, [textTemplate, format, font]); + // Dokument-Index nicht über die Anzahl hinaus stehen lassen. + useEffect(() => { if (textDocIndex >= textDocs) setTextDocIndex(0); }, [textDocIndex, textDocs]); + // Ändert sich irgendetwas, das die gerenderten Dokumente beeinflusst, wird die // Vorschau-Session ungültig – sonst würde bei der Bestellung veralteter Inhalt // 1:1 übernommen. Nur eine unveränderte Vorschau wird wiederverwendet. @@ -1053,6 +1076,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri country: e.country, free_text: adressModus === 'freitext' ? e.free_text : '', placeholders: e.placeholders, + // Abweichender Text nur, wenn wirklich gesetzt (sonst greift der Standardtext). + text: (e.text && e.text.trim()) ? e.text : undefined, })), }); // Auftrag ist abgesendet – der zwischengespeicherte Entwurf wird verworfen. @@ -1626,16 +1651,49 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri ) : (
- -