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:
@@ -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]);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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%;
|
||||
|
||||
Binary file not shown.
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user