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:
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;' }, [
|
||||
|
||||
Reference in New Issue
Block a user