Produktion: Format-Warteschlangen, Sammeldruck, Webhook, Preis-a/e

Produktions-Board:
- Massenauswahl (+ "Alle auswählen") mit Sammel-Druck und Sammel-Status
- Warteschlange je Maschine nach Format gebündelt; Formatabfrage
  (machines.current_format) statt automatischem Anschluss
- resiliente Ladeabfragen (Board bleibt ohne neue Felder nutzbar)

Agent (skrift-agent):
- druckt strikt ein Dokument nach dem anderen (Idle-Gate) -> behebt
  Verlust von Folgejobs während eines laufenden Drucks
- druckt nur Jobs des eingelegten Formats (current_format), kein Auto-Anschluss
- Heartbeat gedrosselt (nur bei Statuswechsel / alle 60 s)

Neues Modul "Manueller Druck" (skrift-neuauftrag):
- interner Auftrag ohne Zahlung, Vorschau, Platzhalter/Excel-Import,
  Felder bleiben nach dem Absenden stehen

Backend/Endpunkte (skrift-orders):
- jobs tragen format/format_label; /enqueue-bulk für Sammeldruck
- Webhook /inbound/:kind (JetFormBuilder) mit Token + tolerantem Body-Parser
- Kleinmengen-Aufschlag je Produkt über Faktoren a (Deckel) und e (Exponent)
- Fix: norm()-Shadowing in calculatePrice (norm is not a function)

Bootstrap:
- jobs.format/format_label, machines.current_format
- products.price_factor_a/e; Multiplikator-Formel auf a/e umgestellt inkl.
  verhaltensneutraler Migration bestehender Formel/Produkte

skrift-app (Konfigurator):
- Zeilenumbrüche im Textfeld erlaubt; Umschlag-Individualtext als Textfeld
- Platzhalter-Tabelle im Text-Schritt immer verfügbar (auch Sammelversand
  ohne Kuvert) inkl. Excel-Upload nur für Platzhalterwerte
- Danke-Seite: Button zurück zu skrift.de
- z-index-Fix: Platzhalter-Tabelle überlagert die Vorschau nicht mehr

WordPress-Preisrechner: a/e-Faktoren in PHP- und JS-Rechnung
Directus: SKRIFT_WEBHOOK_TOKEN in compose/.env.example

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-07-26 11:51:24 +02:00
parent f77ddb6a4c
commit 6232cecdb1
17 changed files with 1239 additions and 97 deletions

View File

@@ -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]);

View File

@@ -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.
</p>
{/* Zurück zur Hauptseite – erst wenn die Bestellung abgeschlossen ist
(bei offener PayPal-Zahlung nicht, damit niemand vorher abspringt). */}
{!offenePayPalZahlung && (
<div style={{ marginTop: 'var(--sk-space-4)' }}>
<a className="sk-btn sk-btn--primary" href="https://skrift.de">
Zurück zu skrift.de
</a>
</div>
)}
</section>
</div>
);
@@ -1296,6 +1317,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
{/* ── Empfängerdaten ──────────────────────────────────── */}
{/* ── Individueller Umschlagtext (ersetzt die Adressen) ── */}
{aktuell === 'Individueller Text' && (
<>
<section className="sk-card sk-card--head">
<div className="sk-card-head">
<h2>Individueller Text</h2>
@@ -1304,16 +1326,62 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
<div className="sk-card-body">
<div className="sk-field" style={{ marginBottom: 0 }}>
<label className="sk-label" htmlFor="umschlagtext">Text für den Umschlag</label>
<input id="umschlagtext" className="sk-input" maxLength={MAX_UMSCHLAG_TEXT}
<textarea id="umschlagtext" className="sk-input" rows={4} maxLength={MAX_UMSCHLAG_TEXT}
value={umschlagText}
placeholder="z. B. Herzliche Grüße an [[Vorname]]"
onChange={(e) => setUmschlagText(saeubere(e.target.value.replace(/[\r\n]/g, '')))} />
onChange={(e) => setUmschlagText(saeubere(e.target.value))} />
<p className="sk-note" style={{ marginTop: 'var(--sk-space-2)' }}>
{umschlagText.length}/{MAX_UMSCHLAG_TEXT} Zeichen · keine Zeilenumbrüche
{umschlagText.length}/{MAX_UMSCHLAG_TEXT} Zeichen
</p>
</div>
</div>
</section>
{/* Platzhalter-Tabelle: erscheint, sobald der Umschlagtext Platzhalter
nutzt – je Empfänger ein Wert (analog zur Text-Tabelle). */}
{genutztePlatzhalterUmschlag.length > 0 && (
<section className="sk-card sk-card--head" style={{ marginTop: 'var(--sk-space-3)' }}>
<div className="sk-card-head">
<h2>Platzhalter-Werte</h2>
<p>Füllen Sie die im Umschlagtext genutzten Platzhalter je Empfänger aus.</p>
</div>
<div className="sk-card-body">
<div className="sk-table-wrap">
<table className="sk-table">
<thead>
<tr>
<th>#</th>
{genutztePlatzhalterUmschlag.map((p) => <th key={p}>{p}</th>)}
</tr>
</thead>
<tbody>
{entries.map((e, i) => (
<tr key={i}>
<td className="sk-table-no">{i + 1}</td>
{genutztePlatzhalterUmschlag.map((p) => {
const standard = standardSet.has(p.toLowerCase());
return (
<td key={p}>
<input
className="sk-cell"
readOnly={standard}
title={standard ? 'Kommt aus den Adressdaten' : undefined}
style={standard ? { background: 'rgba(15,23,42,0.04)', color: 'var(--sk-muted)' } : undefined}
value={standard ? standardWert(e, p) : (e.placeholders[p] ?? '')}
onChange={standard ? undefined : (ev) => setPlatzhalter(i, p, ev.target.value)}
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
)}
</>
)}
{aktuell === 'Empfänger' && (
@@ -1547,15 +1615,48 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
</p>
</div>
<div className="sk-card-body">
{platzhalterNurImText && eigeneImText.length > 0 && (
<div className="sk-options" style={{ marginBottom: 'var(--sk-space-4)' }}>
<Option name="ph-eingabe" selected={eingabeArt === 'excel'}
onSelect={() => setEingabeArt('excel')} title="Excel-Datei hochladen" />
<Option name="ph-eingabe" selected={eingabeArt === 'tabelle'}
onSelect={() => !nurUpload && setEingabeArt('tabelle')}
title="Direkt in Tabelle eingeben"
desc={nurUpload
? `Ab ${TABELLEN_GRENZE + 1} Dokumenten nicht möglich – bitte Datei hochladen`
: `Bis ${TABELLEN_GRENZE} Dokumente – ohne Datei-Upload`} />
</div>
)}
{eingabeArt === 'excel' ? (
<p className="sk-note">
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.</>
)}
</p>
platzhalterNurImText ? (
<div>
<button type="button" className="sk-btn sk-btn--ghost"
onClick={() => ladeVorlageHerunter(eigeneImText, false, true)}>
Vorlage herunterladen
</button>
<label className="sk-drop" style={{ marginTop: 'var(--sk-space-4)' }}>
<span className="sk-drop-title">Ausgefüllte Datei hier ablegen oder klicken</span>
<span className="sk-drop-sub" style={{ display: 'block' }}>.xlsx oder .csv</span>
<input type="file" accept=".xlsx,.xls,.csv"
onChange={(e) => excelEinlesen(e.target.files?.[0] ?? null)} />
</label>
{excelDatei && !excelFehler && (
<p className="sk-note" style={{ marginTop: 'var(--sk-space-3)' }}>
{excelDatei.name} – {entries.length} Zeile(n) gelesen.
</p>
)}
{excelFehler && <p className="sk-error">{excelFehler}</p>}
</div>
) : (
<p className="sk-note">
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.</>
)}
</p>
)
) : (
<div className="sk-table-wrap">
<table className="sk-table">

View File

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