Tabulator-Support in Brieftexten + ausstehende Batches

Tabs (Eingabe + Rendering):
- Tab-Taste fügt echten Tabulator ein (App-Konfigurator + Directus
  manueller Druck); \t in beiden Zeichen-Whitelists erlaubt.
- Renderer: Zeilen mit führenden Tabs werden nicht mehr nach Wortbreite
  umgebrochen; Tab-Spruenge an den rechten Rand geclampt; bei Ueberlauf
  rechtsbuendig (Datum ganz rechts, ohne Absturz nach links). Tab-loser
  Text bleibt bit-identisch.

Mailer:
- loadSettings bricht nicht mehr ab, wenn team_email fehlt (Team-
  Empfaenger separat gelesen).
- Eingebaute Fallback-Vorlagen fuer order_created + Status-Mails, damit
  Bestaetigung/Status auch ohne DB-Vorlage zugestellt werden.

Weitere ausstehende Arbeit:
- Abweichender Text je Schriftstueck (#5) in Generierung/Konfigurator.
- Produktions-Board: Format-Sammelgruppen (Bulk) + Bootstrap-Feld.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-08-04 16:32:11 +02:00
parent 0b2f2ba50a
commit db419bf8eb
9 changed files with 191 additions and 30 deletions

View File

@@ -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: [

View File

@@ -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: `<p>Hallo {{name}},</p>
<p>vielen Dank für Ihre Bestellung <strong>{{order_id}}</strong>. Wir haben Ihren Auftrag erhalten und melden uns mit den nächsten Schritten.</p>`,
},
bezahlt: {
subject: 'Zahlung erhalten – Bestellung {{order_id}}',
body: `<p>Hallo {{name}},</p>
<p>Ihre Zahlung zur Bestellung <strong>{{order_id}}</strong> ist bei uns eingegangen. Wir starten nun mit der Bearbeitung.</p>`,
},
versendet: {
subject: 'Ihre Bestellung {{order_id}} ist unterwegs',
body: `<p>Hallo {{name}},</p>
<p>Ihre Bestellung <strong>{{order_id}}</strong> wurde versendet.</p>`,
},
abgeschlossen: {
subject: 'Bestellung {{order_id}} abgeschlossen',
body: `<p>Hallo {{name}},</p>
<p>Ihre Bestellung <strong>{{order_id}}</strong> ist abgeschlossen. Vielen Dank für Ihr Vertrauen!</p>`,
},
storniert: {
subject: 'Bestellung {{order_id}} storniert',
body: `<p>Hallo {{name}},</p>
<p>Ihre Bestellung <strong>{{order_id}}</strong> wurde storniert. Bei Fragen antworten Sie einfach auf diese E-Mail.</p>`,
},
};
const render = (str, vars) =>
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: [

View File

@@ -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

View File

@@ -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,
});
}

View File

@@ -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;' }, [

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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(
`<path d="${escapeXml(p.d)}" transform="${p.transform}"