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('country', 'string', { interface: 'input' }),
f('free_text', 'text', { interface: 'input-multiline', note: 'Alternativ zur strukturierten Adresse.' }), f('free_text', 'text', { interface: 'input-multiline', note: 'Alternativ zur strukturierten Adresse.' }),
f('placeholders', 'json', { interface: 'list', note: 'Zusätzliche Platzhalterwerte.' }), f('placeholders', 'json', { interface: 'list', note: 'Zusätzliche Platzhalterwerte.' }),
f('text', 'text', { interface: 'input-multiline', note: 'Abweichender Brieftext NUR für dieses Schriftstück (leer = Standardtext des Auftrags).' }),
] }, ] },
{ collection: 'order_addons', meta: { icon: 'add_shopping_cart', note: 'Junction Auftrag ↔ Zusatzleistung.', hidden: true }, fields: [ { collection: 'order_addons', meta: { icon: 'add_shopping_cart', note: 'Junction Auftrag ↔ Zusatzleistung.', hidden: true }, fields: [

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 LABELING_LABEL = { empfaenger: 'Empfängeradresse', freitext: 'Individueller Text', keine: 'Keine' };
const UPLOAD_ROOT = process.env.STORAGE_LOCAL_ROOT || '/directus/uploads'; const UPLOAD_ROOT = process.env.STORAGE_LOCAL_ROOT || '/directus/uploads';
// Eingebaute Fallback-Vorlagen: greifen NUR, wenn in `email_templates` keine
// aktive Vorlage mit passendem Schlüssel existiert. So gehen Bestätigung und
// die kundenrelevanten Status-Mails auch ohne konfigurierte Vorlage sicher raus
// (analog zum Login-Fallback in skrift-auth). Interne Status (in_queue, gedruckt …)
// haben bewusst KEINEN Fallback → für die braucht es eine aktive Vorlage.
const FALLBACK_TEMPLATES = {
order_created: {
subject: 'Bestellbestätigung {{order_id}}',
body: `<p>Hallo {{name}},</p>
<p>vielen Dank für Ihre Bestellung <strong>{{order_id}}</strong>. Wir haben Ihren Auftrag erhalten und melden uns mit den nächsten Schritten.</p>`,
},
bezahlt: {
subject: 'Zahlung erhalten – Bestellung {{order_id}}',
body: `<p>Hallo {{name}},</p>
<p>Ihre Zahlung zur Bestellung <strong>{{order_id}}</strong> ist bei uns eingegangen. Wir starten nun mit der Bearbeitung.</p>`,
},
versendet: {
subject: 'Ihre Bestellung {{order_id}} ist unterwegs',
body: `<p>Hallo {{name}},</p>
<p>Ihre Bestellung <strong>{{order_id}}</strong> wurde versendet.</p>`,
},
abgeschlossen: {
subject: 'Bestellung {{order_id}} abgeschlossen',
body: `<p>Hallo {{name}},</p>
<p>Ihre Bestellung <strong>{{order_id}}</strong> ist abgeschlossen. Vielen Dank für Ihr Vertrauen!</p>`,
},
storniert: {
subject: 'Bestellung {{order_id}} storniert',
body: `<p>Hallo {{name}},</p>
<p>Ihre Bestellung <strong>{{order_id}}</strong> wurde storniert. Bei Fragen antworten Sie einfach auf diese E-Mail.</p>`,
},
};
const render = (str, vars) => const render = (str, vars) =>
String(str || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : '')); String(str || '').replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : ''));
const eur = (n) => (Number(n) || 0).toFixed(2).replace('.', ','); const eur = (n) => (Number(n) || 0).toFixed(2).replace('.', ',');
@@ -57,17 +90,27 @@ export default (register, { services, getSchema, logger }) => {
async function loadSettings(schema) { async function loadSettings(schema) {
try { try {
return await new ItemsService('email_settings', { schema, accountability: null }) 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 {}; } } 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). */ /** Schlanke interne Benachrichtigung „Neue Bestellung …" (Empfänger konfigurierbar). */
async function teamNotify(orderId) { async function teamNotify(orderId) {
try { try {
const schema = await getSchema(); const schema = await getSchema();
const settings = await loadSettings(schema); const to = await teamEmpfaenger(schema);
const to = (settings?.team_email || '').trim();
if (!to) return; if (!to) return;
const settings = await loadSettings(schema);
const order = await new ItemsService('orders', { schema, accountability: null }) const order = await new ItemsService('orders', { schema, accountability: null })
.readOne(orderId, { fields: ['order_number', 'product.name'] }); .readOne(orderId, { fields: ['order_number', 'product.name'] });
if (!order) return; if (!order) return;
@@ -102,8 +145,8 @@ export default (register, { services, getSchema, logger }) => {
async function sendForOrder(orderId, key, extraVars = {}) { async function sendForOrder(orderId, key, extraVars = {}) {
const schema = await getSchema(); const schema = await getSchema();
const tpl = await loadTemplate(key, schema); const tpl = await loadTemplate(key, schema) || FALLBACK_TEMPLATES[key];
if (!tpl) return; // keine (aktive) Vorlage → keine Mail if (!tpl) return; // weder aktive Vorlage noch Fallback → keine Mail
const order = await new ItemsService('orders', { schema, accountability: null }).readOne(orderId, { const order = await new ItemsService('orders', { schema, accountability: null }).readOne(orderId, {
fields: [ fields: [

View File

@@ -94,8 +94,9 @@ const NeuAuftrag = defineComponent({
musterListe.value = mtx.data?.data || []; musterListe.value = mtx.data?.data || [];
const wl = ps.data?.data?.char_whitelist; const wl = ps.data?.data?.char_whitelist;
// Zeilenumbrüche immer erlauben (Brieftext ist mehrzeilig). Einzeilige // Zeilenumbrüche immer erlauben (Brieftext ist mehrzeilig). Einzeilige
// Felder entfernen \n/\r ohnehin separat, daher hier gefahrlos. // Felder entfernen \n/\r ohnehin separat, daher hier gefahrlos. Tabs (\t)
try { verbotenRe.value = wl ? new RegExp(`[^${wl}\\n\\r]`, 'g') : null; } catch { verbotenRe.value = null; } // 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.product = produkte.value[0]?.key || 'briefe';
neu.format = neuFormate()[0]?.key || ''; neu.format = neuFormate()[0]?.key || '';
} catch (e) { } catch (e) {
@@ -302,7 +303,15 @@ const NeuAuftrag = defineComponent({
)), )),
]), ]),
label(`Brieftext · ${neu.text.length} Zeichen · [[Platzhalter]] erlaubt`), 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 // Bereich 4 – Kunde

View File

@@ -550,8 +550,10 @@ const handler = (router, { services, getSchema, logger }) => {
basis.forEach((e, i) => { basis.forEach((e, i) => {
// Dateinummer 1-basiert = Briefnummer (letter_001.svg ↔ Brief 1). // Dateinummer 1-basiert = Briefnummer (letter_001.svg ↔ Brief 1).
const nr = i + 1; const nr = i + 1;
// Abweichender Text je Schriftstück (#5) hat Vorrang; sonst der Standardtext.
const briefText = (e.text && String(e.text).trim()) ? String(e.text) : String(body.text);
letters.push({ letters.push({
index: nr, type: 'letter', text: clean(String(body.text)), index: nr, type: 'letter', text: clean(briefText),
format: briefFormat, font, realisticHandwriting: realistisch, format: briefFormat, font, realisticHandwriting: realistisch,
placeholders: platzhalterVon(e, i), placeholders: platzhalterVon(e, i),
}); });
@@ -1594,6 +1596,8 @@ const handler = (router, { services, getSchema, logger }) => {
placeholders: e.placeholders && typeof e.placeholders === 'object' placeholders: e.placeholders && typeof e.placeholders === 'object'
? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))])) ? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))]))
: null, : null,
// Abweichender Text nur für dieses Schriftstück (#5).
text: (e.text && String(e.text).trim()) ? clean(String(e.text)) : null,
}); });
} }

View File

@@ -332,7 +332,7 @@ const Board = defineComponent({
if (!sel.length) return; if (!sel.length) return;
const map = {}; const map = {};
const add = (key, label, typ) => { 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) { for (const o of sel) {
add(o.format?.key || 'a4', o.format?.name || o.format?.key || 'A4', 'brief'); add(o.format?.key || 'a4', o.format?.name || o.format?.key || 'A4', 'brief');
@@ -346,7 +346,9 @@ const Board = defineComponent({
} }
async function submitBulk() { async function submitBulk() {
const byFormat = {}; 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; } 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 }; byFormat[g.key] = { template: g.template, machine: g.machine, label: g.label };
} }
@@ -757,9 +759,14 @@ const Board = defineComponent({
if (!bulk.open) return null; if (!bulk.open) return null;
const gruppeRow = (g) => { const gruppeRow = (g) => {
const tplOpts = templates.value.filter((t) => t.type === g.type); const tplOpts = templates.value.filter((t) => t.type === g.type);
return h('div', { style: SECTION }, [ return h('div', { style: SECTION + (g.aktiv ? '' : 'opacity:.5;') }, [
h('div', { style: 'font-weight:700;margin-bottom:10px;' }, `${g.type === 'umschlag' ? '✉' : '📄'} ${g.label}`), // Format-Auswahl: abgehakt = kommt in den Sammeldruck (z. B. nur Schriftstücke,
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px;' }, [ // 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('div', {}, [
h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Template'), 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; } }, 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; } }, 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')]), 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;' }, [ 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); const nr = Number(entry.letter_number) || (i + 1);
if (!istDatei) { 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({ documents.push({
index: nr, index: nr,
type: 'letter', type: 'letter',
text: order.text_template || '', text: briefText,
format: letterFormat, format: letterFormat,
font, font,
placeholders, placeholders,

View File

@@ -78,8 +78,10 @@ const TAB_MARKER = '';
/** /**
* Breite einer Textzeile in Pixeln messen. * Breite einer Textzeile in Pixeln messen.
* Unterstützt TAB_MARKER: springt zur nächsten Tabstop-Position (6 × fontSizePx). * Unterstützt TAB_MARKER: springt zur nächsten Tabstop-Position (6 × fontSizePx).
* maxWidthPx (optional) begrenzt den Tab-Sprung auf den rechten Rand, damit viele
* Tabs nicht über die Seite hinauslaufen. Ohne Angabe (Infinity) unverändert.
*/ */
function measureTextWidthPx({ text, fontFileName, fontSizePx }) { function measureTextWidthPx({ text, fontFileName, fontSizePx, maxWidthPx = Infinity }) {
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName); const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
const scale = fontSizePx / unitsPerEm; const scale = fontSizePx / unitsPerEm;
const tabStopPx = fontSizePx * 6; const tabStopPx = fontSizePx * 6;
@@ -88,7 +90,7 @@ function measureTextWidthPx({ text, fontFileName, fontSizePx }) {
for (const ch of String(text || '')) { for (const ch of String(text || '')) {
if (ch === TAB_MARKER) { if (ch === TAB_MARKER) {
x = Math.ceil((x + 1) / tabStopPx) * tabStopPx; x = Math.min(Math.ceil((x + 1) / tabStopPx) * tabStopPx, maxWidthPx);
continue; continue;
} }
@@ -126,6 +128,19 @@ function wrapTextToLinesByWidth({
measureTextWidthPx({ text: t, fontFileName, fontSizePx }); measureTextWidthPx({ text: t, fontFileName, fontSizePx });
for (const para of paragraphs) { for (const para of paragraphs) {
// Führende Tabs = bewusste Positionierung der Zeile (z.B. Datum nach rechts).
// Solche Zeilen NICHT nach Wortbreite umbrechen, sonst würde der Text als
// "zu breit" gewertet und die Wörter fielen nach links auf eine neue Zeile.
// Der eigentliche Versatz (bzw. Rechtsbündigkeit bei Überlauf) passiert im Layout.
const lead = para.match(new RegExp(`^${TAB_MARKER}+`));
if (lead) {
const rest = para.slice(lead[0].length);
if (!rest.includes(TAB_MARKER) && measure(rest) <= maxWidthPx) {
allLines.push(para);
continue;
}
}
// Tokens: Wörter und TAB_MARKER getrennt ermitteln. // Tokens: Wörter und TAB_MARKER getrennt ermitteln.
// TAB_MARKER durch einen Platzhalter ersetzen, mit \s+ splitten, dann zurücktauschen. // TAB_MARKER durch einen Platzhalter ersetzen, mit \s+ splitten, dann zurücktauschen.
const TAB_PLACEHOLDER = ''; const TAB_PLACEHOLDER = '';
@@ -208,6 +223,7 @@ function layoutTextToPaths({
startY, startY,
lineHeightPx, lineHeightPx,
realisticHandwriting = false, realisticHandwriting = false,
maxWidthPx = Infinity,
}) { }) {
const { unitsPerEm, glyphs } = parseSvgFont(fontFileName); const { unitsPerEm, glyphs } = parseSvgFont(fontFileName);
@@ -223,13 +239,32 @@ function layoutTextToPaths({
const tabStopPx = fontSizePx * 6; const tabStopPx = fontSizePx * 6;
for (let i = 0; i < line.length; i++) { // Führende Tabs vorab auflösen: normalerweise an die Tab-Position rücken;
// würde der restliche Zeileninhalt dort über den rechten Rand laufen, die
// Zeile stattdessen rechtsbündig an den Rand setzen (z.B. Datum ganz rechts,
// ohne dass etwas daneben steht). Nur aktiv, wenn maxWidthPx bekannt ist.
let startIdx = 0;
if (line[0] === TAB_MARKER) {
let leadCount = 0;
while (line[leadCount] === TAB_MARKER) leadCount++;
let tx = 0;
for (let t = 0; t < leadCount; t++) {
tx = Math.min(Math.ceil((tx + 1) / tabStopPx) * tabStopPx, maxWidthPx);
}
const rest = line.slice(leadCount);
const restW = measureTextWidthPx({ text: rest, fontFileName, fontSizePx, maxWidthPx });
if (tx + restW > maxWidthPx) tx = Math.max(0, maxWidthPx - restW);
x = startX + tx;
startIdx = leadCount;
}
for (let i = startIdx; i < line.length; i++) {
const ch = line[i]; const ch = line[i];
// Tabulator-Marker → zur nächsten Tabstop-Position springen // Tabulator-Marker → zur nächsten Tabstop-Position springen (an den Rand geclampt)
if (ch === TAB_MARKER) { if (ch === TAB_MARKER) {
const relX = x - startX; const relX = x - startX;
const nextTab = Math.ceil((relX + 1) / tabStopPx) * tabStopPx; const nextTab = Math.min(Math.ceil((relX + 1) / tabStopPx) * tabStopPx, maxWidthPx);
x = startX + nextTab; x = startX + nextTab;
continue; continue;
} }

View File

@@ -92,6 +92,7 @@ function generateLetterSVG(text, format, options = {}) {
startY: firstBaselineY, startY: firstBaselineY,
lineHeightPx, lineHeightPx,
realisticHandwriting: options.realisticHandwriting ?? false, realisticHandwriting: options.realisticHandwriting ?? false,
maxWidthPx,
}); });
const pathElements = paths const pathElements = paths
@@ -167,6 +168,7 @@ function generateLetterSVG(text, format, options = {}) {
startY: currentY, startY: currentY,
lineHeightPx, lineHeightPx,
realisticHandwriting: options.realisticHandwriting ?? false, realisticHandwriting: options.realisticHandwriting ?? false,
maxWidthPx,
}); });
paths.forEach(p => allSvgElements.push( paths.forEach(p => allSvgElements.push(
`<path d="${escapeXml(p.d)}" transform="${p.transform}" `<path d="${escapeXml(p.d)}" transform="${p.transform}"

View File

@@ -106,6 +106,8 @@ type Entry = {
/** Alternative zur strukturierten Adresse. */ /** Alternative zur strukturierten Adresse. */
free_text: string; free_text: string;
placeholders: Record<string, string>; placeholders: Record<string, string>;
/** Abweichender Text NUR für dieses Schriftstück (leer/undefined = Text aus #1). */
text?: string;
}; };
type Adresse = { type Adresse = {
@@ -319,6 +321,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const [textservice, setTextservice] = useState(entwurf.textservice ?? false); const [textservice, setTextservice] = useState(entwurf.textservice ?? false);
const [textBriefing, setTextBriefing] = useState(entwurf.textBriefing ?? ''); const [textBriefing, setTextBriefing] = useState(entwurf.textBriefing ?? '');
const [textTemplate, setTextTemplate] = useState(entwurf.textTemplate ?? ''); const [textTemplate, setTextTemplate] = useState(entwurf.textTemplate ?? '');
/** Aktuell im Textfeld angezeigtes Schriftstück (0 = Standardtext, der für alle gilt). */
const [textDocIndex, setTextDocIndex] = useState(0);
const [rechnung, setRechnung] = useState<Adresse>(entwurf.rechnung ?? leereAdresse()); const [rechnung, setRechnung] = useState<Adresse>(entwurf.rechnung ?? leereAdresse());
const [lieferAbweichend, setLieferAbweichend] = useState(entwurf.lieferAbweichend ?? false); const [lieferAbweichend, setLieferAbweichend] = useState(entwurf.lieferAbweichend ?? false);
@@ -359,8 +363,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const wl = settings?.char_whitelist; const wl = settings?.char_whitelist;
if (!wl) return null; if (!wl) return null;
// Zeilenumbrüche sind im mehrzeiligen Text erlaubt; einzeilige Felder // Zeilenumbrüche sind im mehrzeiligen Text erlaubt; einzeilige Felder
// entfernen \n/\r ohnehin separat vor saeubere(). // entfernen \n/\r ohnehin separat vor saeubere(). Tabs (\t) sind erlaubt,
try { return new RegExp(`[^${wl}\\n\\r]`, 'g'); } catch { return null; } // damit man z.B. das Datum an den rechten Rand rücken kann.
try { return new RegExp(`[^${wl}\\n\\r\\t]`, 'g'); } catch { return null; }
}, [settings?.char_whitelist]); }, [settings?.char_whitelist]);
/** Entfernt verbotene Zeichen und meldet sie; gibt den bereinigten Wert zurück. */ /** Entfernt verbotene Zeichen und meldet sie; gibt den bereinigten Wert zurück. */
const saeubere = (v: string) => { const saeubere = (v: string) => {
@@ -530,12 +535,30 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
/** Zeichengrenze richtet sich nach dem Format – wie bisher. */ /** Zeichengrenze richtet sich nach dem Format – wie bisher. */
const maxText = format?.startsWith('a4') ? MAX_TEXT.a4 : MAX_TEXT.a6; const maxText = format?.startsWith('a4') ? MAX_TEXT.a4 : MAX_TEXT.a6;
const textZuLang = textTemplate.length > maxText;
// ── Text je Schriftstück (#5) ──────────────────────────────────────────────
// Pfeile im Textfeld: pro Dokument ein eigener Text möglich (Standard = #1).
// Nur im Tabellenmodus (≤ 25) und bei echtem Text (kein Textservice/Datei).
const proDokText = !textservice && !dateiModus && !nurUpload && entries.length > 1;
const textDocs = Math.min(entries.length, TABELLEN_GRENZE);
// Angezeigter Text: #1 = Standardtext (textTemplate); ab #2 der Override bzw.
// – solange nicht geändert – der Standardtext.
const aktDocText = textDocIndex === 0 ? textTemplate : (entries[textDocIndex]?.text ?? textTemplate);
const setDocText = (v: string) => {
const clean = saeubere(v);
if (textDocIndex === 0) { setTextTemplate(clean); return; }
setEntries((prev) => prev.map((e, i) => (i === textDocIndex ? { ...e, text: clean } : e)));
setPreviewSession(null);
};
const textZuLang = aktDocText.length > maxText;
// Ändert sich Text, Format oder Schrift, ist ein früher erkannter Überlauf // Ändert sich Text, Format oder Schrift, ist ein früher erkannter Überlauf
// hinfällig – erst die nächste Vorschau entscheidet erneut. // hinfällig – erst die nächste Vorschau entscheidet erneut.
useEffect(() => { setTextUeberlauf(false); }, [textTemplate, format, font]); useEffect(() => { setTextUeberlauf(false); }, [textTemplate, format, font]);
// Dokument-Index nicht über die Anzahl hinaus stehen lassen.
useEffect(() => { if (textDocIndex >= textDocs) setTextDocIndex(0); }, [textDocIndex, textDocs]);
// Ändert sich irgendetwas, das die gerenderten Dokumente beeinflusst, wird die // Ändert sich irgendetwas, das die gerenderten Dokumente beeinflusst, wird die
// Vorschau-Session ungültig – sonst würde bei der Bestellung veralteter Inhalt // Vorschau-Session ungültig – sonst würde bei der Bestellung veralteter Inhalt
// 1:1 übernommen. Nur eine unveränderte Vorschau wird wiederverwendet. // 1:1 übernommen. Nur eine unveränderte Vorschau wird wiederverwendet.
@@ -1053,6 +1076,8 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
country: e.country, country: e.country,
free_text: adressModus === 'freitext' ? e.free_text : '', free_text: adressModus === 'freitext' ? e.free_text : '',
placeholders: e.placeholders, placeholders: e.placeholders,
// Abweichender Text nur, wenn wirklich gesetzt (sonst greift der Standardtext).
text: (e.text && e.text.trim()) ? e.text : undefined,
})), })),
}); });
// Auftrag ist abgesendet – der zwischengespeicherte Entwurf wird verworfen. // Auftrag ist abgesendet – der zwischengespeicherte Entwurf wird verworfen.
@@ -1626,16 +1651,49 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
</div> </div>
) : ( ) : (
<div className="sk-field" style={{ marginBottom: 0 }}> <div className="sk-field" style={{ marginBottom: 0 }}>
<label className="sk-label" htmlFor="text"> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
Inhalt des Schriftstücks <span className="sk-note">· max. {maxText} Zeichen</span> <label className="sk-label" htmlFor="text" style={{ marginBottom: 0 }}>
</label> Inhalt des Schriftstücks <span className="sk-note">· max. {maxText} Zeichen</span>
<textarea id="text" className="sk-input" rows={10} value={textTemplate} </label>
{/* Pfeile: pro Schriftstück eigenen Text eingeben (Standard = #1). */}
{proDokText && (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<button type="button" className="sk-pager-btn" aria-label="Vorheriges Schriftstück"
disabled={textDocIndex === 0}
onClick={() => setTextDocIndex((i) => Math.max(0, i - 1))}>‹</button>
<span className="sk-note" style={{ whiteSpace: 'nowrap' }}>
Schriftstück {textDocIndex + 1}/{textDocs}
</span>
<button type="button" className="sk-pager-btn" aria-label="Nächstes Schriftstück"
disabled={textDocIndex >= textDocs - 1}
onClick={() => setTextDocIndex((i) => Math.min(textDocs - 1, i + 1))}>›</button>
</div>
)}
</div>
<textarea id="text" className="sk-input" rows={10} value={aktDocText}
maxLength={maxText} maxLength={maxText}
onChange={(e) => setTextTemplate(saeubere(e.target.value))} /> onChange={(e) => setDocText(e.target.value)}
onKeyDown={(e) => {
// Tab-Taste fügt einen echten Tabulator ein (statt den Fokus
// zu verschieben) – für z.B. das Datum am rechten Rand.
if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
e.preventDefault();
if (aktDocText.length >= maxText) return;
const ta = e.currentTarget;
const s = ta.selectionStart ?? aktDocText.length;
const en = ta.selectionEnd ?? s;
setDocText(aktDocText.slice(0, s) + '\t' + aktDocText.slice(en));
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 1; });
}} />
<p className={textZuLang ? 'sk-error' : 'sk-note'} style={{ marginTop: 'var(--sk-space-2)' }}> <p className={textZuLang ? 'sk-error' : 'sk-note'} style={{ marginTop: 'var(--sk-space-2)' }}>
{textTemplate.length}/{maxText} Zeichen {aktDocText.length}/{maxText} Zeichen
{textZuLang && ' – Text ist zu lang für das gewählte Format.'} {textZuLang && ' – Text ist zu lang für das gewählte Format.'}
</p> </p>
{proDokText && textDocIndex > 0 && (
<p className="sk-error" style={{ marginTop: 'var(--sk-space-2)' }}>
Dieser Text wird nur für Schriftstück {textDocIndex + 1} verwendet (Standard = Text aus #1).
</p>
)}
<p className="sk-note" style={{ marginTop: '8px' }}> <p className="sk-note" style={{ marginTop: '8px' }}>
{standardPlatzhalter.length > 0 ? ( {standardPlatzhalter.length > 0 ? (
<> <>