`;
+ }
+
+ await svc('offer_requests').createOne(record);
+ logger.info(`[skrift-orders] inbound/${kind}: Anfrage von ${email} gespeichert.`);
+
+ // Interne Team-Benachrichtigung (Kunden-Autoresponder macht JetFormBuilder).
+ try {
+ const mail = new MailService({ schema, accountability: null });
+ const von = await absender(schema);
+ await mail.send({ ...(von ? { from: von } : {}), to: TEAM_EMAIL, subject, html: mailHtml });
+ } catch (mailErr) {
+ logger.warn(`[skrift-orders] Webhook-Mail nicht versendet: ${mailErr.message}`);
+ }
+
+ return res.json({ ok: true });
+ } catch (err) {
+ logger.error(`[skrift-orders] inbound/${kind}: ${err.stack || err.message}`);
+ return res.status(500).json({ error: 'Anfrage konnte nicht gespeichert werden.' });
+ }
+ });
+
+ // ── Manueller Auftrag (Admin) ────────────────────────────────────────────
+ // Legt intern einen Auftrag an (keine Zahlung, keine Datei-Eingabe), erzeugt
+ // die SVGs (Vorschau 1:1) und stellt ihn als „eingegangen" ins Board. Nutzt
+ // dieselben Bausteine wie die öffentliche Anlage – keine Doppellogik.
+ router.post('/manual-order', async (req, res) => {
+ const acc = req.accountability;
+ if (!acc || !acc.user || !(acc.admin || acc.app)) return res.status(403).json({ error: 'Kein Zugriff.' });
+ const body = req.body || {};
+ const entries = Array.isArray(body.entries) ? body.entries : [];
+ if (!body.product) return res.status(400).json({ error: 'Produkt fehlt.' });
+ if (!entries.length) return res.status(400).json({ error: 'Mindestens eine Empfängerzeile erforderlich.' });
+ try {
+ const schema = await getSchema();
+ const svc = (c) => new ItemsService(c, { schema, accountability: null });
+ const { settings } = await loadPricingContext(schema);
+ const clean = makeSanitizer(settings.char_whitelist || '\\x20-\\x7E');
+
+ const product = (await svc('products').readByQuery({
+ filter: { key: { _eq: body.product } }, limit: 1, fields: ['id', 'key', 'name', 'type'],
+ }))?.[0];
+ if (!product) return res.status(400).json({ error: 'Unbekanntes Produkt.' });
+
+ // Preis nur zur Aufzeichnung (kein Gate).
+ let quote = null;
+ try { quote = await calculatePrice(schema, { ...body, quantity: entries.length }); } catch { /* egal */ }
+
+ // Kunde optional verknüpfen (per E-Mail oder ID).
+ let customerId = null, customerEmail = null;
+ if (body.customer_email && isEmail(norm(body.customer_email))) {
+ const u = (await new UsersService({ schema, accountability: null }).readByQuery({
+ filter: { email: { _eq: norm(body.customer_email) } }, limit: 1, fields: ['id', 'email'],
+ }))?.[0];
+ if (u) { customerId = u.id; customerEmail = u.email; }
+ else customerEmail = norm(body.customer_email);
+ }
+
+ let formatId = null;
+ if (body.format) {
+ formatId = (await svc('formats').readByQuery({ filter: { key: { _eq: body.format } }, limit: 1, fields: ['id'] }))?.[0]?.id ?? null;
+ }
+ const envelopeFormat = body.needs_envelope
+ ? (body.envelope_format || (String(body.format || '').startsWith('a4') ? 'dinlang' : 'c6'))
+ : null;
+
+ const istInland = (e) => !e.country || /^(de|deutschland|germany)$/i.test(String(e.country).trim());
+ const countAusland = entries.filter((e) => !istInland(e)).length;
+
+ const jt = new Date();
+ const praefix = `${String(jt.getDate()).padStart(2, '0')}-${String(jt.getMonth() + 1).padStart(2, '0')}-${String(jt.getFullYear()).slice(2)}`;
+ const naechsteNummer = async () => {
+ const heutige = await svc('orders').readByQuery({ filter: { order_number: { _starts_with: `${praefix}-` } }, limit: -1, fields: ['id'] });
+ return `${praefix}-${String((heutige?.length ?? 0) + 1).padStart(3, '0')}`;
+ };
+
+ const orderData = {
+ customer: customerId, customer_email: customerEmail,
+ product: product.id, format: formatId,
+ person_type: body.person_type === 'unternehmen' ? 'unternehmen' : 'privat',
+ font: ['tilda', 'alva', 'ellie'].includes(body.font) ? body.font : 'tilda',
+ source: 'operator', status: 'in_queue', production_status: 'eingegangen',
+ payment_status: 'bezahlt', payment_method: 'rechnung',
+ shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel',
+ needs_envelope: !!body.needs_envelope,
+ envelope_labeling: body.envelope_labeling ?? null,
+ envelope_text: clean(body.envelope_text) ?? null,
+ envelope_format: envelopeFormat,
+ text_template: clean(body.text_template) ?? null,
+ preview_session: typeof body.preview_session === 'string' ? body.preview_session : null,
+ entries_count: entries.length,
+ count_inland: entries.length - countAusland, count_ausland: countAusland,
+ net_total: quote?.net_total ?? null, vat_amount: quote?.vat_amount ?? null, gross_total: quote?.gross_total ?? null,
+ };
+
+ let orderId, orderNumber;
+ for (let v = 0; v < 6; v += 1) {
+ orderNumber = await naechsteNummer();
+ try { orderId = await svc('orders').createOne({ order_number: orderNumber, ...orderData }); break; }
+ catch (e) { if (v === 5 || !/unique|duplicate|bereits|exists/i.test(String(e?.message || ''))) throw e; }
+ }
+
+ const entriesSvc = svc('order_entries');
+ let n = 0;
+ for (const e of entries) {
+ n += 1;
+ await entriesSvc.createOne({
+ order: orderId, letter_number: n,
+ salutation: clean(e.salutation) ?? null, first_name: clean(e.first_name) ?? null, last_name: clean(e.last_name) ?? null,
+ street: clean(e.street) ?? null, house_no: clean(e.house_no) ?? null, zip: clean(e.zip) ?? null,
+ city: clean(e.city) ?? null, country: clean(e.country) ?? null, free_text: clean(e.free_text) ?? null,
+ placeholders: e.placeholders && typeof e.placeholders === 'object'
+ ? Object.fromEntries(Object.entries(e.placeholders).map(([k, v]) => [k, clean(String(v))])) : null,
+ });
+ }
+
+ await svc('status_history').createOne({ order: orderId, status: 'eingegangen', note: 'Manuell im Admin angelegt.' });
+
+ if (product.type === 'letter' || product.type === 'postcard') {
+ fetch(`${BACKEND_URL}/api/order/from-directus`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Token': BACKEND_TOKEN },
+ body: JSON.stringify({ orderId }),
+ }).catch((e) => logger.warn(`[skrift-orders] Manuell-Generierung nicht gestartet: ${e.message}`));
+ }
+
+ return res.json({ ok: true, order_number: orderNumber, id: orderId });
+ } catch (err) {
+ logger.error(`[skrift-orders] manual-order: ${err.stack || err.message}`);
+ return res.status(400).json({ error: err.message });
+ }
+ });
+
// ── Auftrag anlegen ──────────────────────────────────────────────────────
router.post('/', async (req, res) => {
const body = req.body || {};
@@ -1114,6 +1419,7 @@ const handler = (router, { services, getSchema, logger }) => {
shipping_type: body.shipping_type === 'einzeln' ? 'einzeln' : 'sammel',
needs_envelope: !!body.needs_envelope,
envelope_labeling: body.envelope_labeling ?? null,
+ envelope_text: clean(body.envelope_text) ?? null,
envelope_format: envelopeFormat,
motif_mode: body.motif_mode ?? null,
motif: Number.isFinite(Number(body.motif)) && body.motif ? Number(body.motif) : 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 b637741..9170bef 100644
--- a/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js
+++ b/Docker/directus/extensions/directus-extension-skrift-produktion/dist/index.js
@@ -27,6 +27,8 @@ const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6l_v1' };
// v1 = Empfängeradresse (Standardvorschlag). Für individuelle Beschriftung
// wählt der Operator im Popup die Variante 2 (kuvert_a_v2 / kuvert_b_v2).
const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
+// Lesbare Kuvert-Formatlabels (für Sammel-Druck + Format-Warteschlange).
+const ENV_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
const INLAND = /^(de|deutschland|germany)$/i;
const TABS = [
@@ -136,6 +138,9 @@ const Board = defineComponent({
const manuell = reactive({ open: false, req: null, form: 'sie', font: 'tilda', format: 'a4', template: null, machine: null, mode: 'queue', text: '', msg: '', err: '' });
const empf = reactive({ open: false, mode: 'order', titel: '', order: null, suche: '', rows: [], files: [],
info: '', laden: false, selected: null, pl: '', pe: '', pLoad: false, perr: '', lb: '', lbTitel: '' });
+ // Massenauswahl (Auftrags-IDs) + Sammel-Druck-Popup (pro Format Template+Maschine).
+ const auswahl = ref([]);
+ const bulk = reactive({ open: false, mode: 'queue', gruppen: [], msg: '', err: '' });
// ── Laden ──
// Basis-Felder ohne die evtl. noch nicht gebootstrapten Felder – diese
@@ -153,6 +158,24 @@ const Board = defineComponent({
return api.get('/items/orders', { params: { limit: -1, sort: '-date_created', fields: ORDER_FELDER } });
}
}
+ // Jobs mit Format-Feldern; fällt ohne sie zurück, falls der Bootstrap für die
+ // Format-Warteschlange noch nicht gelaufen ist (dann degradiert die Ansicht).
+ const JOB_FELDER = 'id,type,status,priority,numbers,output_ref,error,date_created,machine.id,machine.name,order.id,order.order_number';
+ async function ladeJobs() {
+ const filter = { status: { _in: ['queued', 'printing', 'failed'] } };
+ try {
+ return await api.get('/items/jobs', { params: { limit: -1, sort: '-priority,date_created', filter, fields: JOB_FELDER + ',format,format_label' } });
+ } catch {
+ return api.get('/items/jobs', { params: { limit: -1, sort: '-priority,date_created', filter, fields: JOB_FELDER } });
+ }
+ }
+ async function ladeMachines() {
+ try {
+ return await api.get('/items/machines', { params: { limit: -1, fields: 'id,name,active,status,last_seen,current_format' } });
+ } catch {
+ return api.get('/items/machines', { params: { limit: -1, fields: 'id,name,active,status,last_seen' } });
+ }
+ }
async function laden() {
loading.value = true; fehler.value = '';
try {
@@ -163,12 +186,8 @@ const Board = defineComponent({
filter: { status: { _in: ['neu', 'in_bearbeitung'] } },
fields: 'id,type,name,email,phone,quantity,product,message,address,status,date_created',
} }),
- api.get('/items/jobs', { params: {
- limit: -1, sort: '-priority,date_created',
- filter: { status: { _in: ['queued', 'printing', 'failed'] } },
- fields: 'id,type,status,priority,numbers,output_ref,error,date_created,machine.id,machine.name,order.id,order.order_number',
- } }),
- api.get('/items/machines', { params: { limit: -1, fields: 'id,name,active,status,last_seen' } }),
+ ladeJobs(),
+ ladeMachines(),
api.get('/items/format_templates', { params: { limit: -1, fields: 'id,key,name,type,width,height,xpos,ypos,scale,rotation' } }),
api.get('/items/muster_settings', { params: { fields: 'text_du,text_sie' } }).catch(() => ({ data: { data: { text_du: '', text_sie: '' } } })),
]);
@@ -251,6 +270,101 @@ const Board = defineComponent({
} catch (e) { fehler.value = e?.message || 'Statuswechsel fehlgeschlagen.'; }
}
+ // ── Massenauswahl + Sammel-Druck ─────────────────────────────────────────
+ const istGewaehlt = (id) => auswahl.value.includes(String(id));
+ function toggleAuswahl(id) {
+ id = String(id);
+ auswahl.value = istGewaehlt(id) ? auswahl.value.filter((x) => x !== id) : [...auswahl.value, id];
+ }
+ const auswahlLeeren = () => { auswahl.value = []; };
+ // Aufträge, die im aktuellen Tab als Karten sichtbar sind (für „Alle auswählen").
+ function sichtbareOrders() {
+ switch (tab.value) {
+ case 'neu': return neueOrders.value;
+ case 'druck': return ordersByStatus('im_druck');
+ case 'kuvertierung': return ordersByStatus('kuvertieren');
+ case 'versandfertig': return ordersByStatus('versandfertig');
+ case 'versendet': return versendetAktiv();
+ case 'archiv': return archivListe();
+ default: return [];
+ }
+ }
+ const alleSichtbarGewaehlt = () => {
+ const ids = sichtbareOrders().map((o) => String(o.id));
+ return ids.length > 0 && ids.every((id) => auswahl.value.includes(id));
+ };
+ function alleAuswaehlen() {
+ const ids = sichtbareOrders().map((o) => String(o.id));
+ auswahl.value = alleSichtbarGewaehlt()
+ ? auswahl.value.filter((id) => !ids.includes(id))
+ : [...new Set([...auswahl.value, ...ids])];
+ }
+ // Status für alle ausgewählten Aufträge auf einmal setzen.
+ async function bulkStatus(neuStatus) {
+ const sel = orders.value.filter((o) => istGewaehlt(o.id));
+ if (!sel.length || !neuStatus) return;
+ let ok = 0;
+ for (const o of sel) {
+ const patch = { production_status: neuStatus };
+ if (neuStatus === 'versendet' && !o.versandt_am) patch.versandt_am = new Date().toISOString();
+ try {
+ await api.patch(`/items/orders/${o.id}`, patch);
+ o.production_status = neuStatus;
+ if (patch.versandt_am) o.versandt_am = patch.versandt_am;
+ ok++;
+ } catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Statuswechsel fehlgeschlagen.'; }
+ }
+ flash(`${ok} Auftrag/Aufträge → ${STATUS_LABEL[neuStatus] || neuStatus}`);
+ auswahlLeeren();
+ }
+ // Standard-Template je Format vorbelegen (Operator kann im Popup ändern).
+ function stdTpl(formatKey, typ) {
+ const key = typ === 'umschlag' ? (ENVELOPE_TPL[formatKey] || 'kuvert_a_v1') : (LETTER_TPL[formatKey] || 'a4_single');
+ const t = templates.value.find((x) => x.key === key && x.type === typ) || templates.value.find((x) => x.type === typ);
+ return t?.id ?? null;
+ }
+ function openBulk() {
+ const sel = orders.value.filter((o) => istGewaehlt(o.id));
+ 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 };
+ };
+ for (const o of sel) {
+ add(o.format?.key || 'a4', o.format?.name || o.format?.key || 'A4', 'brief');
+ if (o.needs_envelope && o.envelope_labeling && o.envelope_labeling !== 'keine') {
+ const ek = o.envelope_format || 'c6';
+ add(ek, ENV_LABEL[ek] || ek, 'umschlag');
+ }
+ }
+ bulk.gruppen = Object.values(map);
+ bulk.mode = 'queue'; bulk.msg = ''; bulk.err = ''; bulk.open = true;
+ }
+ async function submitBulk() {
+ const byFormat = {};
+ for (const g of bulk.gruppen) {
+ 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 };
+ }
+ bulk.err = ''; bulk.msg = 'Wird eingereiht …';
+ try {
+ const res = await api.post('/skrift-orders/enqueue-bulk', { orders: auswahl.value, mode: bulk.mode, byFormat });
+ const n = res.data?.jobs?.length || 0;
+ const skip = res.data?.uebersprungen || [];
+ flash(`${n} Job(s) eingereiht${skip.length ? ` · ${skip.length} übersprungen` : ''}.`);
+ bulk.open = false; auswahlLeeren(); await laden();
+ } catch (e) { bulk.err = e?.response?.data?.error || e?.message || 'Sammel-Einreihen fehlgeschlagen.'; bulk.msg = ''; }
+ }
+
+ // ── Format-Warteschlange: welches Format liegt an der Maschine ein? ───────
+ async function setMachineFormat(machine, key) {
+ try {
+ await api.patch(`/items/machines/${machine.id}`, { current_format: key || null });
+ machine.current_format = key || null;
+ flash(key ? `„${key}" als eingelegtes Format an ${machine.name} gesetzt.` : `Format an ${machine.name} geleert – Maschine wartet.`);
+ } catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Format konnte nicht gesetzt werden.'; }
+ }
+
// ── Druck-Popup ──
const tplByKey = (key, typ) => templates.value.find((t) => t.key === key && t.type === typ) || templates.value.find((t) => t.type === typ) || null;
const tplToFelder = (t) => ({ tplId: t?.id ?? null, width: t?.width ?? 0, height: t?.height ?? 0, xpos: t?.xpos ?? 0, ypos: t?.ypos ?? 0, scale: t?.scale ?? 1, rotation: t?.rotation ?? 0 });
@@ -420,9 +534,13 @@ const Board = defineComponent({
function orderCard(order, opts) {
const o = order; const zeigeBadges = opts?.badges;
const kopf = h('div', { style: 'display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;' }, [
- h('div', {}, [
- h('div', { style: 'font-weight:700;font-size:15px;' }, `#${o.order_number}`),
- subdued(`${kundeName(o)}${o.customer?.email ? ' · ' + o.customer.email : ''}${o.customer?.phone ? ' · ' + o.customer.phone : ''}`, 'margin-top:2px;'),
+ h('div', { style: 'display:flex;gap:10px;align-items:flex-start;' }, [
+ h('input', { type: 'checkbox', checked: istGewaehlt(o.id), onChange: () => toggleAuswahl(o.id),
+ title: 'Für Sammel-Druck auswählen', style: 'width:16px;height:16px;margin-top:3px;cursor:pointer;' }),
+ h('div', {}, [
+ h('div', { style: 'font-weight:700;font-size:15px;' }, `#${o.order_number}`),
+ subdued(`${kundeName(o)}${o.customer?.email ? ' · ' + o.customer.email : ''}${o.customer?.phone ? ' · ' + o.customer.phone : ''}`, 'margin-top:2px;'),
+ ]),
]),
statusSelect(o),
]);
@@ -471,16 +589,45 @@ const Board = defineComponent({
if (tab.value === 'warteschlange') {
const gruppen = queuedByMachine.value;
if (!gruppen.length) return subdued('Warteschlange leer.');
- return gruppen.map((g) => h('div', { style: 'margin-bottom:18px;' }, [
- h('div', { style: 'font-weight:700;margin-bottom:8px;' }, `🖨 ${g.machine.name}${g.machine.status ? ' · ' + g.machine.status : ''}`),
- ...g.jobs.map((j) => h('div', { key: 'j' + j.id, style: CARD + 'margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;' }, [
- h('div', {}, [
- h('div', { style: 'font-weight:600;' }, `#${j.order?.order_number || j.output_ref || j.id} · ${j.type}`),
- subdued(`${j.numbers ? 'Nr. ' + j.numbers : 'alle'}${j.priority ? ' · Direkt' : ''}`),
+ return gruppen.map((g) => {
+ const m = g.machine;
+ const aktiv = (m.current_format || '').trim();
+ // Jobs je Maschine nach Format bündeln (jede Format-Queue eigenständig).
+ const fmt = {};
+ for (const j of g.jobs) {
+ const key = j.format || '';
+ (fmt[key] || (fmt[key] = { key, label: j.format_label || j.format || 'Ohne Format', jobs: [] })).jobs.push(j);
+ }
+ const formatBlock = (fg) => {
+ const laeuft = !!aktiv && fg.key === aktiv;
+ const kopf = h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:8px;' }, [
+ h('div', { style: 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;' }, [
+ h('span', { style: `font-weight:700;font-size:14px;color:${ACCENT};` }, fg.label),
+ chip(`${fg.jobs.length} Job(s)`),
+ laeuft ? chip('▶ liegt ein – wird gedruckt', 'var(--success-25,rgba(46,125,50,.15))')
+ : (aktiv ? chip('⏸ wartet (anderes Format liegt ein)') : null),
+ ]),
+ laeuft
+ ? btn('⏹ Stapel stoppen', () => setMachineFormat(m, null), 'danger')
+ : btn('▶ Dieses Format liegt ein → starten', () => setMachineFormat(m, fg.key), 'primary'),
+ ]);
+ const cards = fg.jobs.map((j) => h('div', { key: 'j' + j.id, style: CARD + 'margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;' }, [
+ h('div', {}, [
+ h('div', { style: 'font-weight:600;' }, `#${j.order?.order_number || j.output_ref || j.id} · ${j.type === 'umschlag' ? '✉ Kuvert' : '📄 Brief'}`),
+ subdued(`${j.numbers ? 'Nr. ' + j.numbers : 'alle'}${j.priority ? ' · Direkt' : ''}`),
+ ]),
+ btn('Entfernen', () => cancelJob(j), 'danger'),
+ ]));
+ return h('div', { style: SECTION }, [kopf, ...cards]);
+ };
+ return h('div', { style: 'margin-bottom:22px;' }, [
+ h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:8px;' }, [
+ h('div', { style: 'font-weight:700;' }, `🖨 ${m.name}${m.status ? ' · ' + m.status : ''}`),
+ aktiv ? subdued(`Eingelegtes Format: ${aktiv}`) : subdued('Kein Format eingelegt – bitte einen Stapel starten.'),
]),
- btn('Entfernen', () => cancelJob(j), 'danger'),
- ])),
- ]));
+ ...Object.values(fmt).map(formatBlock),
+ ]);
+ });
}
if (tab.value === 'druck') {
const list = ordersByStatus('im_druck');
@@ -573,6 +720,44 @@ const Board = defineComponent({
]);
return overlay([modalKopf(`Drucken · #${druck.order.order_number}`, () => { druck.open = false; }), body, foot], () => { druck.open = false; }, '640px');
}
+ function bulkModal() {
+ 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;' }, [
+ 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; } },
+ tplOpts.length ? tplOpts.map((t) => h('option', { value: t.id, selected: String(g.template) === String(t.id) }, t.name)) : [h('option', { value: '' }, 'kein Template')]),
+ ]),
+ h('div', {}, [
+ h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Maschine'),
+ 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')]),
+ ]),
+ ]),
+ ]);
+ };
+ const body = h('div', { style: 'padding:18px 20px;' }, [
+ subdued(`${auswahl.value.length} Auftrag/Aufträge · pro Format einmal Template und Maschine wählen.`, 'margin-bottom:14px;'),
+ ...bulk.gruppen.map(gruppeRow),
+ h('div', { style: 'margin:6px 0 4px;' }, [
+ segToggle(bulk.mode, [
+ { key: 'direct', label: '⚡ Direktdruck' },
+ { key: 'queue', label: '⏳ Warteschlange' },
+ ], (k) => { bulk.mode = k; }),
+ ]),
+ bulk.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;margin-top:8px;' }, bulk.err) : null,
+ bulk.msg ? subdued(bulk.msg, 'margin-top:8px;') : null,
+ ]);
+ const foot = h('div', { style: 'display:flex;justify-content:flex-end;gap:8px;padding:14px 20px;border-top:1px solid var(--border-subdued);' }, [
+ btn('Abbrechen', () => { bulk.open = false; }),
+ btn('In Produktion geben', submitBulk, 'primary'),
+ ]);
+ return overlay([modalKopf('Sammel-Druck', () => { bulk.open = false; }), body, foot], () => { bulk.open = false; }, '640px');
+ }
function musterModal() {
if (!manuell.open) return null;
const briefTpls = templates.value.filter((t) => t.type === 'brief');
@@ -677,18 +862,33 @@ const Board = defineComponent({
const kopfzeile = h('div', { style: 'display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:18px;' }, [
h('input', { type: 'search', value: suche.value, placeholder: '🔎 Auftrag, Kunde, E-Mail, Produkt …',
style: S_INPUT + 'flex:1;min-width:240px;', onInput: (e) => { suche.value = e.target.value; } }),
+ sichtbareOrders().length ? btn(alleSichtbarGewaehlt() ? '☐ Auswahl aufheben' : '☑ Alle auswählen', alleAuswaehlen) : null,
btn('↻ Aktualisieren', laden),
]);
// Leicht getönte Inhaltsfläche → weiße Karten heben sich klar ab (wie im Referenz-Dashboard).
+ const bulkBar = auswahl.value.length ? h('div', { style: `display:flex;align-items:center;gap:12px;flex-wrap:wrap;background:${ACCENT};color:#fff;padding:10px 16px;border-radius:8px;margin-bottom:12px;` }, [
+ h('span', { style: 'font-weight:600;' }, `${auswahl.value.length} Auftrag/Aufträge ausgewählt`),
+ h('button', { type: 'button', onClick: openBulk, style: `cursor:pointer;border:none;background:#fff;color:${ACCENT};border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;font-weight:600;` }, '🖨 In Druck geben'),
+ h('select', {
+ style: `cursor:pointer;border:none;background:#fff;color:${ACCENT};border-radius:7px;padding:7px 12px;font:inherit;font-size:13px;font-weight:600;`,
+ value: '', onChange: (e) => { if (e.target.value) { bulkStatus(e.target.value); e.target.value = ''; } },
+ }, [
+ h('option', { value: '' }, 'Status setzen …'),
+ ...Object.entries(STATUS_LABEL).map(([k, v]) => h('option', { value: k }, v)),
+ ]),
+ h('button', { type: 'button', onClick: auswahlLeeren, style: 'cursor:pointer;border:1px solid rgba(255,255,255,.6);background:transparent;color:#fff;border-radius:7px;padding:6px 14px;font:inherit;font-size:13px;' }, 'Auswahl aufheben'),
+ ]) : null;
+
const inhalt = h('div', { style: 'padding:24px 28px;min-height:100%;background:var(--background-subdued,#f1f4f9);' }, [
kopfzeile,
+ bulkBar,
meldung.value ? h('div', { style: 'background:var(--success-25,rgba(46,125,50,.15));color:var(--success,#2e7d32);padding:9px 14px;border-radius:8px;margin-bottom:12px;font-size:13px;' }, meldung.value) : null,
fehler.value ? h('div', { style: 'background:var(--danger-25,rgba(198,40,40,.12));color:var(--danger,#c62828);padding:9px 14px;border-radius:8px;margin-bottom:12px;font-size:13px;' }, fehler.value) : null,
loading.value ? subdued('Lädt …') : h('div', { style: 'display:flex;flex-direction:column;gap:2px;' }, tabInhalt()),
]);
- const modale = [druckModal(), musterModal(), empfModal(), lightboxModal()].filter(Boolean);
+ const modale = [druckModal(), bulkModal(), musterModal(), empfModal(), lightboxModal()].filter(Boolean);
return h(resolveComponent('private-view'), { title: 'Produktion' }, {
navigation: () => navi,
diff --git a/Docker/skrift-backend/src/api/controllers/directus-controller.js b/Docker/skrift-backend/src/api/controllers/directus-controller.js
index ff9c26c..584829a 100644
--- a/Docker/skrift-backend/src/api/controllers/directus-controller.js
+++ b/Docker/skrift-backend/src/api/controllers/directus-controller.js
@@ -45,7 +45,8 @@ function placeholdersOf(entry) {
/** Übersetzt einen Directus-Auftrag in die Dokumentliste für runGeneration. */
function buildDocuments(order, entries) {
const font = order.font || 'tilda';
- const letterFormat = LETTER_FORMAT[order.format?.key] || 'a4';
+ // Eigene Format-Schlüssel (freie Formate) unverändert durchreichen.
+ const letterFormat = LETTER_FORMAT[order.format?.key] || order.format?.key || 'a4';
const envFormat = ENVELOPE_FORMAT[order.envelope_format] || 'c6';
const wantsEnvelope = !!order.needs_envelope && order.envelope_labeling !== 'keine';
const envelopeType = order.envelope_labeling === 'freitext' ? 'custom' : 'recipient';
@@ -72,7 +73,7 @@ function buildDocuments(order, entries) {
index: nr,
type: 'envelope',
envelopeType,
- text: envelopeType === 'recipient' ? addressText(entry) : (order.text_template || ''),
+ text: envelopeType === 'recipient' ? addressText(entry) : (order.envelope_text || order.text_template || ''),
format: envFormat,
font,
placeholders,
diff --git a/Docker/skrift-backend/src/lib/page-layout.js b/Docker/skrift-backend/src/lib/page-layout.js
index da2648c..328ee60 100644
--- a/Docker/skrift-backend/src/lib/page-layout.js
+++ b/Docker/skrift-backend/src/lib/page-layout.js
@@ -57,6 +57,7 @@ function applyRenderFormats(rows) {
PAGE_FORMATS = pf;
FORMAT_LAYOUTS = fl;
LINE_LIMITS = ll;
+ signaledMisses.clear();
}
// mm in Pixel umrechnen
@@ -64,10 +65,27 @@ function mmToPx(mm) {
return mm * PX_PER_MM;
}
+// ── Refresh bei unbekanntem Format ──────────────────────────────────────────
+// Wird ein Format angefragt, das (noch) nicht im Cache liegt, meldet page-layout
+// das einmalig an einen Handler (den der Server auf refreshRenderFormats setzt).
+// So werden neu in Directus angelegte Formate sofort nachgeladen, ohne die
+// stündliche Aktualisierung abzuwarten. Für DIESEN Aufruf greift noch der
+// Default – der nächste Aufruf hat das Format.
+let missHandler = null;
+const signaledMisses = new Set();
+function setFormatMissHandler(fn) { missHandler = fn; }
+function signalMiss(key) {
+ const k = String(key || '').toLowerCase();
+ if (!missHandler || !k || signaledMisses.has(k)) return;
+ signaledMisses.add(k);
+ try { missHandler(k); } catch { /* egal */ }
+}
+
// Layout für Format holen
function getLayoutForFormat(format) {
const normalizedFormat = String(format).toLowerCase();
const layoutConfig = FORMAT_LAYOUTS[normalizedFormat];
+ if (!layoutConfig) signalMiss(normalizedFormat);
if (!layoutConfig) {
// Fallback auf a4
@@ -89,6 +107,7 @@ function getLayoutForFormat(format) {
// Seiten-Dimensionen holen
function getPageDimensions(format) {
const normalizedFormat = String(format).toLowerCase();
+ if (!PAGE_FORMATS[normalizedFormat]) signalMiss(normalizedFormat);
const dimensions = PAGE_FORMATS[normalizedFormat] || PAGE_FORMATS.a4;
return {
@@ -110,6 +129,7 @@ function escapeXml(str) {
// Zeilenlimit für Format holen
function getLineLimit(format) {
const normalizedFormat = String(format).toLowerCase();
+ if (!LINE_LIMITS[normalizedFormat]) signalMiss(normalizedFormat);
return LINE_LIMITS[normalizedFormat] || LINE_LIMITS.a4;
}
@@ -122,4 +142,5 @@ module.exports = {
getLineLimit,
escapeXml,
applyRenderFormats,
+ setFormatMissHandler,
};
diff --git a/Docker/skrift-backend/src/server.js b/Docker/skrift-backend/src/server.js
index 8ca4bd9..0df1b3f 100644
--- a/Docker/skrift-backend/src/server.js
+++ b/Docker/skrift-backend/src/server.js
@@ -9,12 +9,14 @@ const healthRoutes = require('./api/routes/health-routes');
const errorHandler = require('./api/middleware/error-handler');
const requestLogger = require('./api/middleware/request-logger');
-const { applyRenderFormats } = require('./lib/page-layout');
+const { applyRenderFormats, setFormatMissHandler } = require('./lib/page-layout');
const { fetchRenderFormats } = require('./services/directus-service');
// Zentrale Format-Definitionen aus Directus in den Cache holen. Läuft beim Start
// und stündlich; Fehler sind unkritisch (die Defaults im page-layout greifen).
+let lastFormatRefresh = 0;
async function refreshRenderFormats() {
+ lastFormatRefresh = Date.now();
try {
const rows = await fetchRenderFormats();
applyRenderFormats(rows);
@@ -24,6 +26,14 @@ async function refreshRenderFormats() {
}
}
+// Unbekanntes Format angefragt → sofort nachladen (max. alle 30 s, damit ein
+// dauerhaft unbekanntes Format kein Dauerfeuer auslöst).
+setFormatMissHandler((key) => {
+ if (Date.now() - lastFormatRefresh < 30 * 1000) return;
+ console.log(`[Formate] Unbekanntes Format "${key}" angefragt – lade Formate nach.`);
+ refreshRenderFormats();
+});
+
const app = express();
// Middleware
diff --git a/Docker/skrift-backend/src/services/directus-service.js b/Docker/skrift-backend/src/services/directus-service.js
index d1bf8d9..1d9af99 100644
--- a/Docker/skrift-backend/src/services/directus-service.js
+++ b/Docker/skrift-backend/src/services/directus-service.js
@@ -41,7 +41,7 @@ async function directusFetch(path, options = {}) {
async function fetchOrder(orderId) {
const fields = [
'id', 'order_number', 'customer_email', 'font', 'status',
- 'needs_envelope', 'envelope_labeling', 'envelope_format',
+ 'needs_envelope', 'envelope_labeling', 'envelope_text', 'envelope_format',
'text_template', 'motif_mode', 'own_cards', 'entries_count', 'preview_session',
'source_file', 'product.key', 'product.type', 'product.input_mode', 'format.key',
].join(',');
diff --git a/Produktion/skrift-agent/agent.py b/Produktion/skrift-agent/agent.py
index ecdd88f..d0e911e 100644
--- a/Produktion/skrift-agent/agent.py
+++ b/Produktion/skrift-agent/agent.py
@@ -168,29 +168,83 @@ def main():
print(f"[Agent] gestartet für Maschine {machine_id} gegen {cfg['directus_url']}")
KIND = {"brief": "schriftstueck", "umschlag": "umschlag", "signatur": "signatur"}
+ # Sicherheitsfenster: Sehen wir nach dem Senden binnen dieser Zeit kein
+ # „Printing" (Job war sehr kurz), gilt er als fertig – verhindert Deadlock.
+ start_grace = max(60, poll * 6)
+
+ # Kontext des aktuell an die Maschine gesendeten Jobs (oder None). Der
+ # nächste Job wird ERST gesendet, wenn die Maschine wieder „Idle" ist –
+ # sonst würde ein neuer write_svg (clear=1) den laufenden Druck verwerfen.
+ laufend = None
+
+ # Heartbeat gedrosselt: nur schreiben, wenn sich der Status ändert oder
+ # spätestens alle 60 s – sonst flutet der PATCH bei jedem Poll das Log.
+ beat_state = {"status": None, "ts": 0.0}
+
+ def beat(status):
+ now = time.monotonic()
+ if status != beat_state["status"] or now - beat_state["ts"] >= 60:
+ _heartbeat(dx, machine_id, status)
+ beat_state["status"] = status
+ beat_state["ts"] = now
while True:
try:
maschine = dx.get(f"/items/machines/{machine_id}",
- fields="base_url,mid,username,password,active")
+ fields="base_url,mid,username,password,active,current_format")
if not maschine or not maschine.get("active", True):
- _heartbeat(dx, machine_id, "offline")
+ beat("offline")
time.sleep(poll)
continue
plotter = Plotter(maschine)
- mstatus = plotter.status()
- # Bei Fehler (z. B. Papierende) automatisch fortsetzen.
- if mstatus and str(mstatus.get("status", "")).lower() in ("error", "fehler"):
- plotter.recover()
- _heartbeat(dx, machine_id, "bereit")
+ st = plotter.status() or {}
+ s = str(st.get("status", "")).strip().lower()
- job = _naechster_job(dx, machine_id)
- if not job:
+ # Fehler (z. B. Papierende): fortsetzen, aber nichts Neues senden.
+ if s in ("error", "fehler"):
+ plotter.recover()
+ beat("fehler")
time.sleep(poll)
continue
- _druck(dx, plotter, job, KIND)
+ # Maschine druckt noch → warten (auf keinen Fall einen neuen Job senden).
+ if s == "printing":
+ if laufend is not None:
+ laufend["gestartet"] = True
+ beat("druckt")
+ time.sleep(poll)
+ continue
+
+ # Nur bei sicherem „Idle" handeln. Unbekannt/leer → abwarten.
+ if s != "idle":
+ beat("bereit" if s else "offline")
+ time.sleep(poll)
+ continue
+
+ beat("bereit")
+
+ # Ein zuvor gesendeter Job ist fertig gedruckt (Idle nach Printing) →
+ # abschließen. Das start_grace fängt sehr kurze Jobs ab, bei denen wir
+ # das „Printing" nie zu Gesicht bekommen.
+ if laufend is not None:
+ if laufend.get("gestartet") or (time.monotonic() - laufend["ts"] > start_grace):
+ _abschluss(dx, laufend)
+ laufend = None
+ else:
+ time.sleep(poll)
+ continue
+
+ # Nächsten Job nur senden, wenn nichts (mehr) läuft.
+ if laufend is None:
+ current_format = (maschine.get("current_format") or "").strip()
+ # Format-Warteschlange: nur passendes Format, kein Auto-Anschluss.
+ if current_format:
+ job = _naechster_job(dx, machine_id, current_format)
+ if job:
+ laufend = _sende(dx, plotter, job, KIND)
+
+ time.sleep(poll)
except requests.HTTPError as e:
print(f"[Agent] HTTP-Fehler: {e}")
@@ -207,12 +261,14 @@ def _heartbeat(dx, machine_id, status):
pass
-def _naechster_job(dx, machine_id):
+def _naechster_job(dx, machine_id, current_format):
jobs = dx.get(
"/items/jobs",
**{
"filter[machine][_eq]": machine_id,
"filter[status][_eq]": "queued",
+ # Nur Jobs des aktuell eingelegten Formats.
+ "filter[format][_eq]": current_format,
"sort": "-priority,date_created",
"limit": 1,
"fields": "id,type,output_ref,params,numbers,"
@@ -224,7 +280,13 @@ def _naechster_job(dx, machine_id):
return jobs[0] if jobs else None
-def _druck(dx, plotter, job, KIND):
+def _sende(dx, plotter, job, KIND):
+ """Sendet einen Job an die Maschine und markiert ihn als „printing".
+
+ Gibt einen Kontext zum späteren Abschluss zurück (oder None bei Fehler).
+ Es wird NICHT auf das Druckende gewartet – das übernimmt die Hauptschleife
+ (erst wenn die Maschine wieder „Idle" ist, wird der nächste Job gesendet).
+ """
job_id = job["id"]
order = job.get("order") or {}
order_id = order.get("id")
@@ -239,7 +301,7 @@ def _druck(dx, plotter, job, KIND):
tpl[k] = params[k]
nummern = parse_nummern(job.get("numbers"))
typ = job.get("type") or "brief"
- print(f"[Agent] Job {job_id}: {typ} für {order_nr}")
+ print(f"[Agent] Job {job_id}: {typ} für {order_nr} → an Maschine senden")
dx.patch(f"/items/jobs/{job_id}", {"status": "printing", "error": None})
# Produktionsstatus fürs Kanban: sobald gedruckt wird → „im_druck".
@@ -271,27 +333,44 @@ def _druck(dx, plotter, job, KIND):
plotter.login()
plotter.write(dateien, tpl)
-
- dx.patch(f"/items/jobs/{job_id}", {"status": "printed"})
- # Indikator am Auftrag setzen (Schriftstück bzw. Umschlag gedruckt) und
- # bei Vollständigkeit automatisch in die Kuvertierung schieben.
- if order_id:
- feld = "kuvert_gedruckt" if typ == "umschlag" else "brief_gedruckt"
- try:
- dx.patch(f"/items/orders/{order_id}", {feld: True})
- stand = dx.get(f"/items/orders/{order_id}",
- fields="brief_gedruckt,kuvert_gedruckt,needs_envelope,production_status") or {}
- braucht_kuvert = bool(stand.get("needs_envelope"))
- fertig = stand.get("brief_gedruckt") and (stand.get("kuvert_gedruckt") or not braucht_kuvert)
- if fertig and stand.get("production_status") == "im_druck":
- dx.patch(f"/items/orders/{order_id}", {"production_status": "kuvertieren"})
- print(f"[Agent] Auftrag {order_nr} vollständig gedruckt → Kuvertierung.")
- except Exception:
- pass
- print(f"[Agent] Job {job_id} gedruckt ({len(dateien)} Dateien).")
+ print(f"[Agent] Job {job_id} an Maschine übergeben ({len(dateien)} Dateien) – warte auf Fertigstellung.")
+ return {"job_id": job_id, "order_id": order_id, "order_nr": order_nr,
+ "typ": typ, "gestartet": False, "ts": time.monotonic()}
except Exception as e:
dx.patch(f"/items/jobs/{job_id}", {"status": "failed", "error": str(e)[:1000]})
print(f"[Agent] Job {job_id} fehlgeschlagen: {e}")
+ return None
+
+
+def _abschluss(dx, ctx):
+ """Schließt einen fertig gedruckten Job ab: Status, Indikatoren, Kuvertierung.
+
+ Wird erst aufgerufen, wenn die Maschine nach dem Druck wieder „Idle" meldet.
+ """
+ job_id = ctx["job_id"]
+ order_id = ctx.get("order_id")
+ order_nr = ctx.get("order_nr")
+ typ = ctx.get("typ")
+ try:
+ dx.patch(f"/items/jobs/{job_id}", {"status": "printed"})
+ except Exception as e:
+ print(f"[Agent] Konnte Job {job_id} nicht auf 'printed' setzen: {e}")
+ # Indikator am Auftrag setzen (Schriftstück bzw. Umschlag gedruckt) und
+ # bei Vollständigkeit automatisch in die Kuvertierung schieben.
+ if order_id:
+ feld = "kuvert_gedruckt" if typ == "umschlag" else "brief_gedruckt"
+ try:
+ dx.patch(f"/items/orders/{order_id}", {feld: True})
+ stand = dx.get(f"/items/orders/{order_id}",
+ fields="brief_gedruckt,kuvert_gedruckt,needs_envelope,production_status") or {}
+ braucht_kuvert = bool(stand.get("needs_envelope"))
+ fertig = stand.get("brief_gedruckt") and (stand.get("kuvert_gedruckt") or not braucht_kuvert)
+ if fertig and stand.get("production_status") == "im_druck":
+ dx.patch(f"/items/orders/{order_id}", {"production_status": "kuvertieren"})
+ print(f"[Agent] Auftrag {order_nr} vollständig gedruckt → Kuvertierung.")
+ except Exception:
+ pass
+ print(f"[Agent] Job {job_id} fertig gedruckt.")
if __name__ == "__main__":
diff --git a/Webseite/skrift-app/src/lib/excel.ts b/Webseite/skrift-app/src/lib/excel.ts
index 50307c3..dd5fd45 100644
--- a/Webseite/skrift-app/src/lib/excel.ts
+++ b/Webseite/skrift-app/src/lib/excel.ts
@@ -47,14 +47,19 @@ export const VORLAGEN_SPALTEN = [
/** Spalten der Freitext-Variante: fünf frei beschreibbare Zeilen. */
export const FREITEXT_SPALTEN = ['Zeile 1', 'Zeile 2', 'Zeile 3', 'Zeile 4', 'Zeile 5'];
-/** Erzeugt die Vorlagendatei und stößt den Download an. */
-export async function ladeVorlageHerunter(zusatzSpalten: string[] = [], freitext = false) {
+/**
+ * Erzeugt die Vorlagendatei und stößt den Download an.
+ * `nurZusatz` = nur die Platzhalter-Spalten (ohne Adressspalten) – für den
+ * Fall, dass nur Platzhalterwerte je Dokument gebraucht werden (Sammelversand
+ * ohne Kuvert), nicht aber Empfängeradressen.
+ */
+export async function ladeVorlageHerunter(zusatzSpalten: string[] = [], freitext = false, nurZusatz = false) {
const XLSX = await import('xlsx');
- const basis = freitext ? FREITEXT_SPALTEN : VORLAGEN_SPALTEN;
+ const basis = nurZusatz ? [] : (freitext ? FREITEXT_SPALTEN : VORLAGEN_SPALTEN);
const kopf = [...basis, ...zusatzSpalten];
- const beispielBasis = freitext
+ const beispielBasis = nurZusatz ? [] : (freitext
? ['Erika Mustermann', 'Hauptstraße 1', '10115 Berlin', '', '']
- : ['Frau', 'Erika', 'Mustermann', 'Hauptstraße', '1', '10115', 'Berlin', 'DE'];
+ : ['Frau', 'Erika', 'Mustermann', 'Hauptstraße', '1', '10115', 'Berlin', 'DE']);
const beispiel = [...beispielBasis, ...zusatzSpalten.map(() => 'Beispielwert')];
const blatt = XLSX.utils.aoa_to_sheet([kopf, beispiel]);
diff --git a/Webseite/skrift-app/src/routes/Configurator.tsx b/Webseite/skrift-app/src/routes/Configurator.tsx
index 5f207b8..76d50dc 100644
--- a/Webseite/skrift-app/src/routes/Configurator.tsx
+++ b/Webseite/skrift-app/src/routes/Configurator.tsx
@@ -354,7 +354,9 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const verbotenRe = useMemo(() => {
const wl = settings?.char_whitelist;
if (!wl) return null;
- try { return new RegExp(`[^${wl}]`, 'g'); } catch { 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; }
}, [settings?.char_whitelist]);
/** Entfernt verbotene Zeichen und meldet sie; gibt den bereinigten Wert zurück. */
const saeubere = (v: string) => {
@@ -717,6 +719,15 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const genutztePlatzhalter = useMemo(() => textPlatzhalter(textTemplate), [textTemplate]);
/** Davon die eigenen (nicht aus der Adresse ableitbaren) – nur diese sind editierbar. */
const eigeneImText = genutztePlatzhalter.filter((p) => !standardSet.has(p.toLowerCase()));
+ /** Platzhalter im individuellen Umschlagtext – analog zur Text-Tabelle. */
+ const genutztePlatzhalterUmschlag = useMemo(() => textPlatzhalter(umschlagText), [umschlagText]);
+ /**
+ * Werden Platzhalterwerte NICHT bereits in einem Empfänger-Schritt erfasst
+ * (kein Adress-Schritt, kein Umschlag-Freitext)? Dann muss der Text-Schritt
+ * selbst Tabelle UND Excel-Upload für die Platzhalterwerte anbieten – auch
+ * beim Sammelversand ohne Kuvert.
+ */
+ const platzhalterNurImText = !brauchtAdressen;
// PayPal-Buttons erscheinen erst nach der Bestellung – dann steht der
@@ -1040,6 +1051,16 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
Den aktuellen Stand sehen Sie jederzeit unter „Aufträge" – melden Sie sich dafür mit Ihrer
E-Mail-Adresse an.
+
+ {/* Zurück zur Hauptseite – erst wenn die Bestellung abgeschlossen ist
+ (bei offener PayPal-Zahlung nicht, damit niemand vorher abspringt). */}
+ {!offenePayPalZahlung && (
+
- Die Werte kommen aus Ihrer Excel-Datei.
- {eigeneImText.length > 0 && (
- <> Ergänzen Sie eigene Platzhalter ({eigeneImText.map((p) => `[[${p}]]`).join(', ')})
- dort als zusätzliche Spalten – laden Sie die Vorlage im Schritt „Empfänger" neu,
- falls nötig.>
- )}
-
+ Die Werte kommen aus Ihrer Excel-Datei.
+ {eigeneImText.length > 0 && (
+ <> Ergänzen Sie eigene Platzhalter ({eigeneImText.map((p) => `[[${p}]]`).join(', ')})
+ dort als zusätzliche Spalten – laden Sie die Vorlage im Schritt „Empfänger" neu,
+ falls nötig.>
+ )}
+
+ )
) : (
diff --git a/Webseite/skrift-app/src/styles/global.css b/Webseite/skrift-app/src/styles/global.css
index 0f728d1..1e6786f 100644
--- a/Webseite/skrift-app/src/styles/global.css
+++ b/Webseite/skrift-app/src/styles/global.css
@@ -421,6 +421,9 @@ button { font-family: inherit; }
border-radius: var(--sk-radius-lg);
padding: var(--sk-space-4);
background: var(--sk-white);
+ /* Über den (sticky) Tabellenzellen, damit die Platzhalter-Tabelle nicht in
+ die Vorschau-Spalte hineinmalt. */
+ z-index: 20;
}
.sk-preview-head {
display: flex;
@@ -625,10 +628,12 @@ button { font-family: inherit; }
padding: 10px 12px;
white-space: nowrap;
background: var(--sk-card);
- /* bleibt beim Scrollen stehen – wie im bestehenden Konfigurator */
+ /* bleibt beim Scrollen stehen – wie im bestehenden Konfigurator.
+ Niedriger z-index als Vorschau/Preisleiste, damit die Tabelle nicht
+ über benachbarte Panels malt. */
position: sticky;
top: 0;
- z-index: 10;
+ z-index: 3;
border-bottom: 2px solid rgba(15, 23, 42, 0.1);
}
.sk-table td {
@@ -650,7 +655,7 @@ button { font-family: inherit; }
/* bleibt beim horizontalen Scrollen sichtbar */
position: sticky;
left: 0;
- z-index: 5;
+ z-index: 2;
}
.sk-cell {
width: 100%;
diff --git a/Webseite/skrift-preisrechner.zip b/Webseite/skrift-preisrechner.zip
index 9cc4689..7acbca5 100644
Binary files a/Webseite/skrift-preisrechner.zip and b/Webseite/skrift-preisrechner.zip differ
diff --git a/Webseite/skrift-preisrechner/skrift-preisrechner.php b/Webseite/skrift-preisrechner/skrift-preisrechner.php
index e563c8e..a1a7791 100644
--- a/Webseite/skrift-preisrechner/skrift-preisrechner.php
+++ b/Webseite/skrift-preisrechner/skrift-preisrechner.php
@@ -50,7 +50,7 @@ function skrift_preis_sync() {
return $json['data'];
};
- $produkte = $hole('/items/products?fields=key,name,base_price,norm_qty,pricing_mode,type&filter[active][_eq]=true&limit=-1');
+ $produkte = $hole('/items/products?fields=key,name,base_price,norm_qty,price_factor_a,price_factor_e,pricing_mode,type&filter[active][_eq]=true&limit=-1');
$settings = $hole('/items/pricing_settings?fields=*');
$items = $hole('/items/price_items?fields=key,price,unit,category&filter[active][_eq]=true&limit=-1');
@@ -150,6 +150,9 @@ function skrift_preis_parameter($daten, $produktKey, $typ = 'privat') {
// Normalpreis-Menge kommt vom Produkt; ohne Wert kein Kleinmengen-Aufschlag.
$norm = isset($p['norm_qty']) ? (float) $p['norm_qty'] : 0.0;
+ // Kleinmengen-Aufschlag je Produkt (a = Deckel, e = Exponent). Leer → 1 / 3.
+ $a = (isset($p['price_factor_a']) && $p['price_factor_a'] !== null && $p['price_factor_a'] !== '') ? (float) $p['price_factor_a'] : 1.0;
+ $e = (isset($p['price_factor_e']) && $p['price_factor_e'] !== null && $p['price_factor_e'] !== '') ? (float) $p['price_factor_e'] : 3.0;
// Eine gemeinsame Formel.
$formel = !empty($s['multiplier_formula']) ? $s['multiplier_formula'] : '';
@@ -166,6 +169,8 @@ function skrift_preis_parameter($daten, $produktKey, $typ = 'privat') {
return array(
'base' => isset($p['base_price']) ? (float) $p['base_price'] : 0.0,
'norm' => $norm,
+ 'a' => $a,
+ 'e' => $e,
'formel' => $formel,
'mwst' => isset($s['mwst_percent']) ? (float) $s['mwst_percent'] : 19.0,
'tiers' => $tiers,
@@ -191,6 +196,7 @@ function skrift_preis_rechne_param($par, $menge, $mitKuvert) {
$mult = skrift_formel_eval($par['formel'], array(
'qty' => $menge, 'menge' => $menge,
'norm' => $par['norm'], 'base_price' => $par['base'],
+ 'a' => $par['a'], 'e' => $par['e'],
), 1.0);
}
if (!is_finite($mult) || $mult <= 0) $mult = 1.0;
@@ -377,7 +383,7 @@ function skrift_preis_render($atts, $mitMwst) {
function berechne(m, mitKuvert) {
m = Math.max(1, parseInt(m, 10) || 1);
var mult = par.norm > 0
- ? formelEval(par.formel, { qty: m, menge: m, norm: par.norm, base_price: par.base })
+ ? formelEval(par.formel, { qty: m, menge: m, norm: par.norm, base_price: par.base, a: par.a, e: par.e })
: 1;
if (!isFinite(mult) || mult <= 0) mult = 1;
var grund = par.base * m * mult;