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

@@ -106,6 +106,8 @@ type Entry = {
/** Alternative zur strukturierten Adresse. */
free_text: string;
placeholders: Record<string, string>;
/** 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<Adresse>(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
</div>
) : (
<div className="sk-field" style={{ marginBottom: 0 }}>
<label className="sk-label" htmlFor="text">
Inhalt des Schriftstücks <span className="sk-note">· max. {maxText} Zeichen</span>
</label>
<textarea id="text" className="sk-input" rows={10} value={textTemplate}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
<label className="sk-label" htmlFor="text" style={{ marginBottom: 0 }}>
Inhalt des Schriftstücks <span className="sk-note">· max. {maxText} Zeichen</span>
</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}
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)' }}>
{textTemplate.length}/{maxText} Zeichen
{aktDocText.length}/{maxText} Zeichen
{textZuLang && ' – Text ist zu lang für das gewählte Format.'}
</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' }}>
{standardPlatzhalter.length > 0 ? (
<>