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:
@@ -42,6 +42,10 @@ SKRIFT_BACKEND_TOKEN=CHANGE-ME-gleich-wie-backend-API_TOKEN
|
||||
SKRIFT_PREVIEW_LIMIT=10
|
||||
# Zieladresse für Angebots-/Musteranfragen
|
||||
SKRIFT_TEAM_EMAIL=hello@skrift.de
|
||||
# Shared Secret für den WordPress-Webhook (JetFormBuilder → /skrift-orders/inbound/*).
|
||||
# Frei wählbarer langer Zufallswert; muss identisch als ?token=... in der
|
||||
# Webhook-URL stehen. Ohne diesen Wert lehnt der Webhook alle Anfragen ab.
|
||||
SKRIFT_WEBHOOK_TOKEN=CHANGE-ME-langer-zufallswert
|
||||
|
||||
# ── PayPal (server-seitige Zahlungsprüfung) ─────────────────
|
||||
# Client-ID ist öffentlich (auch im Frontend), das Secret bleibt NUR hier.
|
||||
|
||||
@@ -156,6 +156,12 @@ const collections = [
|
||||
// Ab dieser Menge gilt der Normalpreis (Multiplikator 1) – je Produkt.
|
||||
// Leer = Rückfall auf die globale Norm-Menge in den Preis-Einstellungen.
|
||||
f('norm_qty', 'float', { interface: 'input', note: 'Menge, ab der der Normalpreis gilt (Multiplikator 1). Leer = globaler Wert.' }),
|
||||
// Kleinmengen-Aufschlag je Produkt (fließt als a/e in die Multiplikator-Formel):
|
||||
// a = Deckel (bei Menge→0 ist der Multiplikator 1 + a), e = Abklingform
|
||||
// (großes e = Aufschlag fällt schnell; kleines e = bleibt über mittlere Mengen hoch).
|
||||
// Leer → a = 1, e = 3 (bisheriges Verhalten). Postkarten z. B. a = 1.5, e = 2.
|
||||
f('price_factor_a', 'float', { interface: 'input', default: 1, note: 'Kleinmengen-Aufschlag: Deckel a (Multiplikator bei Menge→0 = 1 + a). Leer = 1.' }),
|
||||
f('price_factor_e', 'float', { interface: 'input', default: 3, note: 'Kleinmengen-Aufschlag: Exponent e (größer = fällt schneller ab). Leer = 3.' }),
|
||||
// Eingabeart im Konfigurator: freier Text oder Datei-Upload (z. B. ein zu
|
||||
// unterschreibendes PDF beim Unterschriftenservice).
|
||||
dropdown('input_mode', ['text', 'datei'], { default: 'text', note: 'Text-Eingabe oder Datei-Upload.' }),
|
||||
@@ -195,7 +201,7 @@ const collections = [
|
||||
// Beispiel: (qty >= norm_b) ? 1 : (2 - sqrt(qty / norm_b))
|
||||
// Eine gemeinsame Formel für Privat und Unternehmen. `norm` ist die
|
||||
// Normalpreis-Menge des jeweiligen Produkts (products.norm_qty).
|
||||
f('multiplier_formula', 'text', { interface: 'input-multiline', note: 'Mengen-Multiplikator (gilt für Privat und Unternehmen). Variablen: qty, norm, base_price. Funktionen: sqrt, pow, min, max, round …' }),
|
||||
f('multiplier_formula', 'text', { interface: 'input-multiline', note: 'Mengen-Multiplikator (gilt für Privat und Unternehmen). Variablen: qty, norm, base_price, a, e (a/e je Produkt). Funktionen: sqrt, pow, min, max, round …' }),
|
||||
f('mwst_percent', 'float', { interface: 'input', default: 19 }),
|
||||
// Mengenrabatt-Staffel: ab min_qty Stück gilt percent % Rabatt auf den
|
||||
// Grundpreis. Es greift die höchste passende Stufe. Frei in Directus pflegbar.
|
||||
@@ -249,6 +255,7 @@ const collections = [
|
||||
dropdown('shipping_type', ['einzeln', 'sammel'], { default: 'sammel' }),
|
||||
f('needs_envelope', 'boolean', { interface: 'boolean', default: false }),
|
||||
dropdown('envelope_labeling', ['empfaenger', 'freitext', 'keine']),
|
||||
f('envelope_text', 'text', { interface: 'input-multiline', note: 'Individueller Kuverttext (bei envelope_labeling = freitext).' }),
|
||||
dropdown('envelope_format', ['c6', 'dinlang'], { note: 'Automatisch aus Format.' }),
|
||||
dropdown('motif_mode', ['katalog', 'upload', 'ohne', 'eigene_karten']),
|
||||
m2o('motif', 'motifs'),
|
||||
@@ -334,6 +341,10 @@ const collections = [
|
||||
f('active', 'boolean', { interface: 'boolean', default: true }),
|
||||
dropdown('status', ['unbekannt', 'bereit', 'druckt', 'fehler', 'offline'], { default: 'unbekannt', note: 'Vom Agent gemeldet.' }),
|
||||
{ field: 'last_seen', type: 'timestamp', meta: { interface: 'datetime', note: 'Letzte Rückmeldung des Agents.' }, schema: {} },
|
||||
// Aktuell eingelegtes Format: Der Agent druckt NUR Jobs mit passendem
|
||||
// format-Schlüssel und springt nie selbstständig auf ein anderes Format.
|
||||
// Leer = Maschine wartet, bis im Board ein Format als eingelegt gewählt wird.
|
||||
f('current_format', 'string', { interface: 'input', note: 'Aktuell eingelegtes Format (Agent druckt nur passende Jobs). Leer = wartet auf Formatwahl.' }),
|
||||
f('notes', 'text', { interface: 'input-multiline' }),
|
||||
] },
|
||||
|
||||
@@ -391,6 +402,11 @@ const collections = [
|
||||
m2o('machine', 'machines'),
|
||||
dropdown('type', ['brief', 'umschlag', 'signatur'], { default: 'brief', note: 'Was gedruckt wird.' }),
|
||||
m2o('template', 'format_templates', { note: 'Aufgelöstes Druck-Template.' }),
|
||||
// Format-Schlüssel des Jobs (Brief: a4/a6…, Kuvert: dinlang/c6). Grundlage
|
||||
// für die Format-Warteschlange: der Agent druckt nur Jobs, deren Format dem
|
||||
// an der Maschine eingelegten Format entspricht.
|
||||
f('format', 'string', { interface: 'input', note: 'Format-Schlüssel (Brief: a4/a6…, Kuvert: dinlang/c6). Grundlage der Format-Warteschlange.' }),
|
||||
f('format_label', 'string', { interface: 'input', note: 'Lesbare Formatbezeichnung für die Warteschlangen-Ansicht.' }),
|
||||
dropdown('status', ['queued', 'printing', 'printed', 'failed', 'shipped'], { default: 'queued' }),
|
||||
f('priority', 'integer', { interface: 'input', default: 0, note: 'Höher = zuerst. Direktdruck bekommt hohe Priorität.' }),
|
||||
// Pro-Druck-Übersteuerung der Template-Maße (Breite/Höhe/X/Y/Scale/Rot).
|
||||
@@ -593,8 +609,10 @@ async function seedItem(collection, uniqueField, item) {
|
||||
async function applySeed() {
|
||||
console.log('› Seed');
|
||||
const products = [
|
||||
{ key: 'briefe', name: 'Briefe', type: 'letter', visibility: 'alle', pricing_mode: 'formel', base_price: 4.0, norm_qty: 100, active: true, sort: 1 },
|
||||
{ key: 'postkarten', name: 'Postkarten', type: 'postcard', visibility: 'alle', pricing_mode: 'formel', base_price: 1.3, norm_qty: 100, active: true, sort: 2 },
|
||||
{ key: 'briefe', name: 'Briefe', type: 'letter', visibility: 'alle', pricing_mode: 'formel', base_price: 4.0, norm_qty: 100, price_factor_a: 1, price_factor_e: 3, active: true, sort: 1 },
|
||||
// Postkarten: höherer Deckel + sanfterer Exponent, damit kleine/mittlere
|
||||
// Mengen nicht zu günstig werden (Fixaufwand relativ größer als bei Briefen).
|
||||
{ key: 'postkarten', name: 'Postkarten', type: 'postcard', visibility: 'alle', pricing_mode: 'formel', base_price: 1.3, norm_qty: 100, price_factor_a: 1.5, price_factor_e: 2, active: true, sort: 2 },
|
||||
{ key: 'muster', name: 'Muster', type: 'sample', visibility: 'alle', pricing_mode: 'formel', base_price: 0, active: true, sort: 3 },
|
||||
// Datei-Produkt: pro Kunde freischaltbar, Kunde lädt ein PDF/Word hoch.
|
||||
// pricing_mode „formel" + base_price, damit es für freigeschaltete Kunden
|
||||
@@ -641,12 +659,30 @@ async function applySeed() {
|
||||
log('seed singleton pricing_settings');
|
||||
}
|
||||
// Gemeinsame Mengen-Multiplikator-Formel: nur setzen, wenn leer (Edits bleiben
|
||||
// erhalten). `norm` kommt je Produkt aus products.norm_qty.
|
||||
// erhalten). `norm` kommt je Produkt aus products.norm_qty, a/e je Produkt.
|
||||
const CANON_MULT = '(qty >= norm) ? 1 : (1 + a * pow(1 - qty / norm, e))';
|
||||
if (!ps || !ps.multiplier_formula) {
|
||||
await api('PATCH', '/items/pricing_settings', {
|
||||
multiplier_formula: '(qty >= norm) ? 1 : (1 + 2 * pow(1 - qty / norm, 3))',
|
||||
});
|
||||
await api('PATCH', '/items/pricing_settings', { multiplier_formula: CANON_MULT });
|
||||
log('seed multiplier-formel');
|
||||
} else {
|
||||
// Migration: eine bestehende Formel mit fest verdrahtetem Koeffizient/Exponent
|
||||
// (1 + N * pow(1 - qty / norm, M)) auf die a/e-Variante umstellen und die
|
||||
// Altwerte in die Produkte übernehmen – Verhalten bleibt exakt erhalten.
|
||||
const m = String(ps.multiplier_formula).match(
|
||||
/^\(\s*qty\s*>=\s*norm\s*\)\s*\?\s*1\s*:\s*\(\s*1\s*\+\s*([\d.]+)\s*\*\s*pow\(\s*1\s*-\s*qty\s*\/\s*norm\s*,\s*([\d.]+)\s*\)\s*\)$/,
|
||||
);
|
||||
if (m) {
|
||||
const aOld = Number(m[1]); const eOld = Number(m[2]);
|
||||
await api('PATCH', '/items/pricing_settings', { multiplier_formula: CANON_MULT });
|
||||
const prods = await api('GET', '/items/products?fields=id,price_factor_a,price_factor_e&limit=-1');
|
||||
for (const p of (prods || [])) {
|
||||
const patch = {};
|
||||
if (p.price_factor_a == null) patch.price_factor_a = aOld;
|
||||
if (p.price_factor_e == null) patch.price_factor_e = eOld;
|
||||
if (Object.keys(patch).length) await api('PATCH', `/items/products/${p.id}`, patch);
|
||||
}
|
||||
log(`migriere multiplier-formel → a/e (Altwerte a=${aOld}, e=${eOld} in Produkte übernommen)`);
|
||||
}
|
||||
}
|
||||
|
||||
// E-Mail-Vorlagen (nur anlegen, wenn noch nicht vorhanden – Edits bleiben erhalten).
|
||||
|
||||
@@ -80,6 +80,10 @@ services:
|
||||
SKRIFT_PREVIEW_LIMIT: ${SKRIFT_PREVIEW_LIMIT:-10}
|
||||
SKRIFT_TEAM_EMAIL: ${SKRIFT_TEAM_EMAIL:-hello@skrift.de}
|
||||
|
||||
# Shared Secret für den WordPress-Webhook (JetFormBuilder → /skrift-orders/inbound/*).
|
||||
# Muss identisch als ?token=... in der Webhook-URL stehen.
|
||||
SKRIFT_WEBHOOK_TOKEN: ${SKRIFT_WEBHOOK_TOKEN}
|
||||
|
||||
# PayPal – server-seitige Zahlungsprüfung (Secret liegt NUR hier, nie im Frontend)
|
||||
PAYPAL_CLIENT_ID: ${PAYPAL_CLIENT_ID}
|
||||
PAYPAL_CLIENT_SECRET: ${PAYPAL_CLIENT_SECRET}
|
||||
|
||||
351
Docker/directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js
vendored
Normal file
351
Docker/directus/extensions/directus-extension-skrift-neuauftrag/dist/index.js
vendored
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Skrift – Modul „Neuer Auftrag"
|
||||
* ===========================================================================
|
||||
* Eigenständige, manuelle Auftragsanlage im Admin (eigenes Modul, kein Teil des
|
||||
* Produktions-Boards). Nutzt ausschließlich die zentralen Backend-Endpunkte:
|
||||
* POST /skrift-orders/preview – Vorschau (Admin ohne Kontingent)
|
||||
* POST /skrift-orders/manual-order – Auftrag intern anlegen (keine Zahlung)
|
||||
* Der Auftrag erscheint danach als „eingegangen" im Produktions-Board.
|
||||
*
|
||||
* Von Hand als ESM (kein Build-Schritt) – Vue + SDK stellt Directus bereit.
|
||||
*/
|
||||
import { defineComponent, ref, reactive, onMounted, h, resolveComponent, Teleport } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const ACCENT = '#1E50A5';
|
||||
// Weißes, randloses Eingabefeld – hebt sich durch Kontrast + leichten Schatten
|
||||
// vom hellblauen Bereich ab (kein Rahmen).
|
||||
const WFELD = 'background:#fff;border:none;border-radius:8px;padding:9px 12px;font:inherit;font-size:14px;color:#1A1A1A;outline:none;box-shadow:0 1px 2px rgba(0,0,0,.08);';
|
||||
const WSELECT = WFELD + 'cursor:pointer;';
|
||||
// Hellblauer Bereich (kein Rahmen).
|
||||
const BEREICH = 'background:rgba(30,80,165,.06);border-radius:12px;padding:18px 20px;';
|
||||
const STANDARD_PLATZHALTER = ['Anrede', 'Vorname', 'Nachname', 'Strasse', 'PLZ', 'Ort', 'Land', 'Briefnummer'];
|
||||
|
||||
function btn(label, onClick, kind) {
|
||||
const base = 'cursor:pointer;border-radius:8px;padding:8px 16px;font:inherit;font-size:14px;border:none;';
|
||||
const style = kind === 'primary' ? base + `background:${ACCENT};color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.15);`
|
||||
: base + `background:#fff;color:${ACCENT};box-shadow:0 1px 2px rgba(0,0,0,.1);`;
|
||||
return h('button', { type: 'button', onClick, style }, label);
|
||||
}
|
||||
function subdued(t, extra) { return h('div', { style: `color:var(--foreground-subdued);font-size:13px;${extra || ''}` }, t); }
|
||||
function segToggle(value, opts, onSel) {
|
||||
return h('div', { style: 'display:flex;width:100%;border-radius:8px;overflow:hidden;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.08);' },
|
||||
opts.map((o) => h('button', {
|
||||
type: 'button', onClick: () => onSel(o.key),
|
||||
style: `flex:1;padding:10px 8px;font:inherit;font-size:13px;text-align:center;cursor:pointer;border:none;`
|
||||
+ (value === o.key ? `background:${ACCENT};color:#fff;font-weight:700;` : `background:transparent;color:${ACCENT};`),
|
||||
}, o.label)));
|
||||
}
|
||||
function bereichKopf(txt) { return h('div', { style: `font-size:15px;font-weight:800;color:${ACCENT};margin-bottom:14px;` }, txt); }
|
||||
function label(txt) { return h('label', { style: 'display:block;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);margin-bottom:4px;' }, txt); }
|
||||
|
||||
const NeuAuftrag = defineComponent({
|
||||
setup() {
|
||||
const api = useApi();
|
||||
const produkte = ref([]);
|
||||
const formate = ref([]);
|
||||
const verbotenRe = ref(null);
|
||||
const musterTexte = ref({ text_du: '', text_sie: '' });
|
||||
const geladen = ref(false);
|
||||
|
||||
const leereZeile = () => ({ free_text: '', placeholders: {} });
|
||||
const neu = reactive({
|
||||
product: '', format: '', font: 'tilda',
|
||||
// Kuvert-Adresse je Empfänger: '5zeilen' (5 Zeilen) oder 'individuell' (einzeilig).
|
||||
adressModus: '5zeilen', kuverts: false, realistic: true, customer_email: '', text: '', rows: [leereZeile()],
|
||||
importOpen: false, importText: '',
|
||||
docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '',
|
||||
});
|
||||
// Kuverts nur generieren, wenn angehakt (Beschriftung aus der Empfängeradresse).
|
||||
const kuvertPayload = () => (neu.kuverts
|
||||
? { needs_envelope: true, envelope_labeling: 'empfaenger', envelope_text: '' }
|
||||
: { needs_envelope: false, envelope_labeling: 'keine', envelope_text: '' });
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
const [p, f, ps, mt] = await Promise.all([
|
||||
api.get('/items/products', { params: { limit: -1, filter: { active: { _eq: true }, type: { _in: ['letter', 'postcard'] } }, fields: 'key,name,type' } }),
|
||||
api.get('/items/formats', { params: { limit: -1, filter: { active: { _eq: true } }, fields: 'key,name,product.key' } }),
|
||||
api.get('/items/pricing_settings', { params: { fields: 'char_whitelist' } }).catch(() => ({ data: { data: {} } })),
|
||||
api.get('/items/muster_settings', { params: { fields: 'text_du,text_sie' } }).catch(() => ({ data: { data: {} } })),
|
||||
]);
|
||||
produkte.value = p.data?.data || [];
|
||||
formate.value = f.data?.data || [];
|
||||
musterTexte.value = mt.data?.data || { text_du: '', text_sie: '' };
|
||||
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; }
|
||||
neu.product = produkte.value[0]?.key || 'briefe';
|
||||
neu.format = neuFormate()[0]?.key || '';
|
||||
} catch (e) {
|
||||
neu.err = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.';
|
||||
} finally { geladen.value = true; }
|
||||
}
|
||||
onMounted(laden);
|
||||
|
||||
const saeubere = (v) => {
|
||||
if (!verbotenRe.value) return v;
|
||||
const treffer = String(v).match(verbotenRe.value);
|
||||
if (!treffer || !treffer.length) return v;
|
||||
const einmalig = [...new Set(treffer)].map((c) => (c === ' ' ? '␣' : c)).join(' ');
|
||||
neu.hinweis = `Zeichen ${einmalig} ist nicht erlaubt und wurde entfernt.`;
|
||||
setTimeout(() => { if (neu.hinweis.includes(einmalig)) neu.hinweis = ''; }, 4000);
|
||||
return String(v).replace(verbotenRe.value, '');
|
||||
};
|
||||
// Alle aktiven Formate zur Auswahl (nicht aufs Produkt eingeschränkt) –
|
||||
// so lassen sich auch freie/eigene Formate direkt wählen.
|
||||
const neuFormate = () => formate.value;
|
||||
// Wie viele Adresszeilen je Empfänger: 5 („5 Zeilen") bzw. 1 („Individueller Text").
|
||||
const zeilenAnzahl = () => (neu.adressModus === 'individuell' ? 1 : 5);
|
||||
const freitext = (e, z) => (String(e.free_text || '').split('\n')[z] ?? '');
|
||||
const setFreitext = (i, z, v) => {
|
||||
const max = zeilenAnzahl();
|
||||
const zeilen = String(neu.rows[i].free_text || '').split('\n');
|
||||
while (zeilen.length < max) zeilen.push('');
|
||||
zeilen[z] = saeubere(String(v).replace(/[\r\n]/g, ''));
|
||||
neu.rows[i].free_text = zeilen.slice(0, max).join('\n').replace(/\n+$/, '');
|
||||
neu.previewSession = null;
|
||||
};
|
||||
// Individuelle Platzhalter je Empfänger (z. B. [[Gutschein]]).
|
||||
const setPlatzhalter = (i, name, v) => {
|
||||
neu.rows[i].placeholders = { ...(neu.rows[i].placeholders || {}), [name]: saeubere(v) };
|
||||
neu.previewSession = null;
|
||||
};
|
||||
// Platzhalter aus dem Brieftext ziehen (ohne den automatischen [[Briefnummer]]).
|
||||
const customPlaceholders = () => {
|
||||
const set = new Set();
|
||||
for (const m of String(neu.text || '').matchAll(/\[\[([^\]]+)\]\]/g)) {
|
||||
const name = m[1].trim();
|
||||
if (name && name.toLowerCase() !== 'briefnummer') set.add(name);
|
||||
}
|
||||
return [...set];
|
||||
};
|
||||
const addZeile = () => neu.rows.push(leereZeile());
|
||||
const delZeile = (i) => { neu.rows.splice(i, 1); if (!neu.rows.length) neu.rows.push(leereZeile()); };
|
||||
// Empfänger zählt, wenn eine Adresszeile ODER ein Platzhalterwert gefüllt ist.
|
||||
const entries = () => neu.rows.filter((e) => String(e.free_text || '').trim()
|
||||
|| (e.placeholders && Object.values(e.placeholders).some((v) => String(v || '').trim())));
|
||||
|
||||
// Aus Excel/CSV eingefügte Zeilen übernehmen. Erst die individuellen
|
||||
// Platzhalter-Spalten, dann die Adresszeilen (Tab/Semikolon/Komma-getrennt).
|
||||
function importUebernehmen() {
|
||||
const ph = customPlaceholders();
|
||||
const max = zeilenAnzahl();
|
||||
const zeilen = String(neu.importText || '').split(/\r?\n/).map((z) => z.trim()).filter(Boolean);
|
||||
if (!zeilen.length) { neu.importOpen = false; return; }
|
||||
const rows = zeilen.map((z) => {
|
||||
const zellen = z.split(/\t|;|,/).map((c) => saeubere(c.trim()));
|
||||
const row = leereZeile();
|
||||
ph.forEach((name, i) => { row.placeholders = { ...row.placeholders, [name]: zellen[i] || '' }; });
|
||||
row.free_text = zellen.slice(ph.length, ph.length + max).join('\n');
|
||||
return row;
|
||||
});
|
||||
neu.rows = rows;
|
||||
neu.importText = ''; neu.importOpen = false; neu.previewSession = null;
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
// Vorschau ohne Empfänger erlaubt – das Backend nutzt dann eine Blankozeile.
|
||||
if (!neu.text.trim()) { neu.err = 'Bitte zuerst einen Text eingeben.'; return; }
|
||||
neu.pLaedt = true; neu.err = ''; neu.msg = '';
|
||||
try {
|
||||
const kv = kuvertPayload();
|
||||
const res = await api.post('/skrift-orders/preview', {
|
||||
text: neu.text, font: neu.font, format: neu.format, realistic: neu.realistic,
|
||||
envelope_labeling: kv.envelope_labeling, envelope_text: kv.envelope_text,
|
||||
entries: entries().slice(0, 3),
|
||||
});
|
||||
const d = res.data || {};
|
||||
if (d.has_overflow) { neu.err = 'Text ist zu lang für das Format – bitte kürzen.'; neu.docs = []; neu.previewSession = null; }
|
||||
else { neu.docs = d.docs || d.previews || []; neu.previewSession = d.session || d.sessionId || null; }
|
||||
} catch (e) { neu.err = e?.response?.data?.detail || e?.response?.data?.error || e?.message || 'Vorschau fehlgeschlagen.'; }
|
||||
finally { neu.pLaedt = false; }
|
||||
}
|
||||
async function absenden() {
|
||||
// Empfänger sind optional – ohne Empfänger genau ein Schriftstück (Blankozeile).
|
||||
const ent = entries();
|
||||
const payloadEntries = ent.length ? ent : [leereZeile()];
|
||||
neu.err = ''; neu.msg = 'Wird angelegt …';
|
||||
try {
|
||||
const kv = kuvertPayload();
|
||||
const res = await api.post('/skrift-orders/manual-order', {
|
||||
product: neu.product, format: neu.format, font: neu.font,
|
||||
needs_envelope: kv.needs_envelope, envelope_labeling: kv.envelope_labeling, envelope_text: kv.envelope_text,
|
||||
shipping_type: 'sammel',
|
||||
customer_email: neu.customer_email.trim() || undefined,
|
||||
text_template: neu.text, preview_session: neu.previewSession || undefined,
|
||||
entries: payloadEntries,
|
||||
});
|
||||
const nr = res.data?.order_number || '';
|
||||
// Felder bewusst NICHT zurücksetzen – der letzte Auftrag bleibt stehen,
|
||||
// damit er sich als Vorlage für den nächsten leicht anpassen lässt.
|
||||
neu.msg = `✓ Auftrag ${nr} angelegt – liegt im Produktions-Board unter „Neu". Felder bleiben stehen; mit „Formular leeren" zurücksetzen.`;
|
||||
} catch (e) { neu.err = e?.response?.data?.error || e?.message || 'Anlegen fehlgeschlagen.'; neu.msg = ''; }
|
||||
}
|
||||
function zuruecksetzen() {
|
||||
Object.assign(neu, {
|
||||
product: produkte.value[0]?.key || 'briefe', format: neuFormate()[0]?.key || '', font: 'tilda',
|
||||
adressModus: '5zeilen', kuverts: false, realistic: true, customer_email: '', text: '', rows: [leereZeile()],
|
||||
importOpen: false, importText: '',
|
||||
docs: [], previewSession: null, pLaedt: false, msg: '', err: '', hinweis: '', lb: '',
|
||||
});
|
||||
}
|
||||
const musterEinfuegen = (form) => {
|
||||
const t = form === 'du' ? musterTexte.value.text_du : musterTexte.value.text_sie;
|
||||
if (!t) return;
|
||||
neu.text = neu.text.trim() ? `${neu.text}\n\n${t}` : t;
|
||||
neu.previewSession = null;
|
||||
};
|
||||
|
||||
// Weißes Dropdown / Eingabefeld.
|
||||
const sel = (val, opts, on, extra) => h('select', { style: WSELECT + 'width:100%;' + (extra || ''), value: val, onChange: on },
|
||||
opts.map((o) => h('option', { value: o.v, selected: String(val) === String(o.v) }, o.t)));
|
||||
const svgUri = (svg) => { try { return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg))); } catch { return null; } };
|
||||
const docBild = (d) => {
|
||||
const svg = d && typeof d.svg === 'string' ? d.svg : null;
|
||||
const src = svg ? svgUri(svg) : (typeof d === 'string' ? d : (d?.dataUrl || d?.url || d?.image || null));
|
||||
const txt = (d?.art === 'umschlag' ? '✉ Kuvert' : '📄 Schriftstück');
|
||||
return h('div', { style: 'display:flex;flex-direction:column;align-items:center;gap:4px;' }, [
|
||||
src ? h('img', { src, onClick: () => { neu.lb = src; }, style: 'width:100%;max-height:260px;object-fit:contain;border-radius:6px;background:#fff;cursor:zoom-in;box-shadow:0 1px 3px rgba(0,0,0,.12);' })
|
||||
: h('div', { style: 'width:100%;height:200px;border-radius:6px;display:flex;align-items:center;justify-content:center;background:#fff;color:var(--foreground-subdued);' }, 'Dok.'),
|
||||
h('span', { style: 'font-size:11px;color:var(--foreground-subdued);' }, txt),
|
||||
]);
|
||||
};
|
||||
// Weißes, einzeiliges Zellenfeld (feste Breite → Zeile scrollt bei Bedarf).
|
||||
const feld = (lbl, val, on, ph) => h('div', { style: 'display:flex;flex-direction:column;gap:3px;flex:0 0 150px;' }, [
|
||||
h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.04em;color:var(--foreground-subdued);' }, lbl),
|
||||
h('input', { value: val, placeholder: ph || '', style: WFELD + 'width:100%;padding:7px 9px;font-size:13px;', onInput: on }),
|
||||
]);
|
||||
|
||||
function formular() {
|
||||
// Empfängerzeile: erst individuelle Platzhalter, dann die Adresszeilen.
|
||||
const zeile = (e, i) => [
|
||||
...customPlaceholders().map((name) => feld(name, e.placeholders?.[name] || '', (ev) => setPlatzhalter(i, name, ev.target.value))),
|
||||
...Array.from({ length: zeilenAnzahl() }, (_, z) => feld('Zeile ' + (z + 1), freitext(e, z), (ev) => setFreitext(i, z, ev.target.value))),
|
||||
];
|
||||
|
||||
// Bereich 1 – Produkt
|
||||
const b1 = h('div', { style: BEREICH }, [
|
||||
bereichKopf('📄 Produkt'),
|
||||
h('div', { style: 'display:grid;grid-template-columns:repeat(3,1fr);gap:12px;' }, [
|
||||
h('div', {}, [label('Produkt'), sel(neu.product, produkte.value.map((p) => ({ v: p.key, t: p.name })), (e) => { neu.product = e.target.value; neu.format = neuFormate()[0]?.key || ''; neu.previewSession = null; })]),
|
||||
h('div', {}, [label('Format'), sel(neu.format, neuFormate().map((f) => ({ v: f.key, t: f.name })), (e) => { neu.format = e.target.value; neu.previewSession = null; })]),
|
||||
h('div', {}, [label('Schrift'), sel(neu.font, [['tilda', 'Tilda'], ['alva', 'Alva'], ['ellie', 'Ellie']].map(([v, t]) => ({ v, t })), (e) => { neu.font = e.target.value; neu.previewSession = null; })]),
|
||||
]),
|
||||
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;margin-top:12px;cursor:pointer;' }, [
|
||||
h('input', { type: 'checkbox', checked: neu.realistic, onChange: (e) => { neu.realistic = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
|
||||
'Natürliche Handschrift (leichte Variation)',
|
||||
]),
|
||||
]);
|
||||
|
||||
// Bereich 2 – Kuvert (Empfängertabelle)
|
||||
const b2 = h('div', { style: BEREICH }, [
|
||||
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;' }, [
|
||||
bereichKopf('✉ Kuvert'),
|
||||
h('div', { style: 'width:300px;' }, [segToggle(neu.adressModus, [{ key: '5zeilen', label: '5 Zeilen' }, { key: 'individuell', label: 'Individueller Text' }], (k) => { neu.adressModus = k; neu.previewSession = null; })]),
|
||||
]),
|
||||
h('div', { style: 'display:flex;flex-direction:column;gap:8px;max-height:34vh;overflow:auto;' },
|
||||
neu.rows.map((e, i) => h('div', { style: 'display:flex;gap:8px;align-items:center;' }, [
|
||||
h('span', { style: `min-width:22px;font-weight:700;color:${ACCENT};` }, `#${i + 1}`),
|
||||
h('div', { style: 'flex:1;min-width:0;display:flex;gap:6px;flex-wrap:nowrap;overflow-x:auto;padding-bottom:2px;' }, zeile(e, i)),
|
||||
h('button', { type: 'button', onClick: () => delZeile(i), style: 'cursor:pointer;border:none;background:transparent;color:var(--danger,#c62828);font-size:18px;padding:2px 6px;' }, '×'),
|
||||
]))),
|
||||
h('div', { style: 'margin-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:12px;' }, [
|
||||
btn('+ Empfänger', addZeile),
|
||||
btn(neu.importOpen ? 'Import schließen' : '⇪ Aus Excel einfügen', () => { neu.importOpen = !neu.importOpen; }),
|
||||
]),
|
||||
h('label', { style: 'display:flex;align-items:center;gap:8px;font-size:14px;cursor:pointer;margin-top:12px;' }, [
|
||||
h('input', { type: 'checkbox', checked: neu.kuverts, onChange: (e) => { neu.kuverts = e.target.checked; neu.previewSession = null; }, style: 'width:16px;height:16px;' }),
|
||||
'Kuverts generieren',
|
||||
]),
|
||||
neu.importOpen ? h('div', { style: 'margin-top:12px;' }, [
|
||||
subdued('Aus Excel kopieren und einfügen – eine Zeile pro Empfänger, Spalten mit Tab/Semikolon/Komma. '
|
||||
+ 'Reihenfolge: erst individuelle Platzhalter, dann ' + (neu.adressModus === 'individuell' ? 'die eine Adresszeile.' : 'Zeile 1–5.'), 'margin-bottom:6px;'),
|
||||
h('textarea', { value: neu.importText, rows: 5, style: WFELD + 'width:100%;font-family:var(--family-monospace,monospace);', onInput: (e) => { neu.importText = e.target.value; } }),
|
||||
h('div', { style: 'margin-top:6px;' }, [btn('Übernehmen', importUebernehmen, 'primary')]),
|
||||
]) : null,
|
||||
]);
|
||||
|
||||
// Bereich 3 – Text (Mustertext-Dropdown direkt darüber)
|
||||
const b3 = h('div', { style: BEREICH }, [
|
||||
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px;' }, [
|
||||
bereichKopf('✍ Text'),
|
||||
h('select', { style: WSELECT + 'font-size:13px;padding:7px 10px;width:auto;', onChange: (e) => { if (e.target.value) { musterEinfuegen(e.target.value); e.target.value = ''; } } }, [
|
||||
h('option', { value: '' }, 'Mustertext einfügen …'),
|
||||
h('option', { value: 'sie' }, 'Sie-Form'),
|
||||
h('option', { value: 'du' }, 'Du-Form'),
|
||||
]),
|
||||
]),
|
||||
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; } }),
|
||||
]);
|
||||
|
||||
// Bereich 4 – Kunde
|
||||
const b4 = h('div', { style: BEREICH }, [
|
||||
bereichKopf('👤 Kunde'),
|
||||
h('div', { style: 'max-width:420px;' }, [
|
||||
label('Kunde verknüpfen (E-Mail, optional)'),
|
||||
h('input', { type: 'email', value: neu.customer_email, placeholder: 'kunde@example.de', style: WFELD + 'width:100%;', onInput: (e) => { neu.customer_email = e.target.value; } }),
|
||||
]),
|
||||
]);
|
||||
|
||||
const aktionen = h('div', { class: 'sk-btnrow', style: 'display:flex;gap:8px;flex-wrap:wrap;' }, [
|
||||
neu.hinweis ? h('div', { style: `color:var(--danger,#c62828);font-size:13px;width:100%;` }, neu.hinweis) : null,
|
||||
neu.err ? h('div', { style: 'color:var(--danger,#c62828);font-size:13px;width:100%;' }, neu.err) : null,
|
||||
neu.msg ? h('div', { style: 'width:100%;' }, subdued(neu.msg)) : null,
|
||||
btn(neu.pLaedt ? 'Vorschau …' : '👁 Vorschau', preview),
|
||||
btn('In Druck geben', absenden, 'primary'),
|
||||
btn('Formular leeren', zuruecksetzen),
|
||||
]);
|
||||
|
||||
const links = h('div', { style: 'flex:1;min-width:0;display:flex;flex-direction:column;gap:16px;' }, [b1, b2, b3, b4, aktionen]);
|
||||
|
||||
// Vorschau rechts daneben (eigener hellblauer Bereich).
|
||||
const rechts = h('div', { style: BEREICH + 'flex:0 0 340px;position:sticky;top:20px;align-self:flex-start;' }, [
|
||||
h('div', { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;' }, [
|
||||
bereichKopf('👁 Vorschau'),
|
||||
btn(neu.pLaedt ? '…' : 'Aktualisieren', preview),
|
||||
]),
|
||||
neu.pLaedt ? subdued('Wird erzeugt …')
|
||||
: neu.docs.length ? h('div', { style: 'display:flex;flex-direction:column;gap:12px;' }, neu.docs.slice(0, 4).map(docBild))
|
||||
: subdued('Noch keine Vorschau. „Aktualisieren" klicken (Klick aufs Bild = groß).'),
|
||||
]);
|
||||
|
||||
return h('div', { style: 'display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap;' }, [links, rechts]);
|
||||
}
|
||||
|
||||
// Linke Navigationsspalte: aktuelle Werkzeuge dieses Moduls. Der
|
||||
// Unterschriftenservice folgt als weiterer Eintrag.
|
||||
const navItem = (label, aktiv, disabled) => h('div', {
|
||||
style: 'display:flex;align-items:center;gap:8px;border-radius:6px;padding:10px 12px;margin-bottom:3px;font-size:14px;'
|
||||
+ (aktiv ? `background:${ACCENT};color:#fff;font-weight:600;` : disabled ? 'color:var(--foreground-subdued);opacity:.7;' : 'color:var(--foreground-normal);'),
|
||||
}, label);
|
||||
|
||||
return () => h(resolveComponent('private-view'), { title: 'Neuer Auftrag' }, {
|
||||
navigation: () => h('div', { style: 'padding:12px 10px;' }, [
|
||||
navItem('📝 Neuer Auftrag', true, false),
|
||||
navItem('✍ Unterschriftenservice (folgt)', false, true),
|
||||
]),
|
||||
default: () => [
|
||||
// Mobil: Button-Reihen bleiben in einer Zeile und füllen die Breite.
|
||||
h('style', {}, '@media(max-width:640px){.sk-btnrow>button{flex:1 1 auto!important;}}'),
|
||||
h('div', { style: 'padding:24px 28px;background:var(--background-subdued,#f1f4f9);min-height:100%;' },
|
||||
!geladen.value ? subdued('Lädt …') : formular()),
|
||||
neu.lb ? h(Teleport, { to: 'body' }, [
|
||||
h('div', {
|
||||
onClick: () => { neu.lb = ''; },
|
||||
style: 'position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:1100;display:flex;align-items:center;justify-content:center;padding:4vh 4vw;cursor:zoom-out;',
|
||||
}, [h('img', { src: neu.lb, style: 'max-width:100%;max-height:90vh;object-fit:contain;background:#fff;border-radius:8px;' })]),
|
||||
]) : null,
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
id: 'skrift-neuauftrag',
|
||||
name: 'Manueller Druck',
|
||||
icon: 'print',
|
||||
routes: [{ path: '', component: NeuAuftrag }],
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "directus-extension-skrift-neuauftrag",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Modul: Neuer Auftrag – manuelle Auftragsanlage (Format/Kuvert/Schrift, Struktur- oder 5-Zeilen-Adressen, Vorschau) über die zentralen Backend-Endpunkte.",
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "dist/index.js",
|
||||
"source": "dist/index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
|
||||
const product = (await svc('products').readByQuery({
|
||||
filter: { key: { _eq: input.product } }, limit: 1,
|
||||
fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'norm_qty', 'active'],
|
||||
fields: ['id', 'key', 'name', 'type', 'pricing_mode', 'base_price', 'norm_qty', 'price_factor_a', 'price_factor_e', 'active'],
|
||||
}))?.[0];
|
||||
if (!product || !product.active) throw new Error('Unbekanntes oder inaktives Produkt.');
|
||||
if (product.pricing_mode === 'auf_anfrage')
|
||||
@@ -211,14 +211,22 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
// (B2B/B2C) editierbar. Variablen: qty, norm_b/norm_c, mind_b/mind_c, base_price.
|
||||
// Eine gemeinsame Formel; die Normalpreis-Menge kommt vom Produkt.
|
||||
const multFormula = settings.multiplier_formula || '1';
|
||||
const norm = Number(product.norm_qty) || 0;
|
||||
// ACHTUNG: nicht `norm` nennen – das würde die Sanitizer-Funktion norm()
|
||||
// überschatten, die weiter unten für norm(input.email) gebraucht wird.
|
||||
const normMenge = Number(product.norm_qty) || 0;
|
||||
// Kleinmengen-Aufschlag je Produkt: a = Deckel, e = Abkling-Exponent.
|
||||
// Leer/ungültig → 1 bzw. 3 (bisheriges Verhalten). 0 bleibt gültig (kein Aufschlag).
|
||||
const zahlOder = (v, d) => (v === null || v === undefined || v === '' || !Number.isFinite(Number(v))) ? d : Number(v);
|
||||
const faktorA = zahlOder(product.price_factor_a, 1);
|
||||
const faktorE = zahlOder(product.price_factor_e, 3);
|
||||
const multVars = {
|
||||
qty: menge, menge, norm,
|
||||
qty: menge, menge, norm: normMenge,
|
||||
a: faktorA, e: faktorE,
|
||||
base_price: Number(product.base_price) || 0,
|
||||
};
|
||||
// Ohne Normalpreis-Menge gibt es keinen Kleinmengen-Aufschlag (Multiplikator 1).
|
||||
let multiplier = 1;
|
||||
if (norm > 0) {
|
||||
if (normMenge > 0) {
|
||||
try { multiplier = evalFormula(multFormula, multVars); } catch { multiplier = 1; }
|
||||
}
|
||||
if (!Number.isFinite(multiplier) || multiplier <= 0) multiplier = 1;
|
||||
@@ -394,8 +402,10 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
// Reine Zeilen-/Überlauf-Prüfung (beim „Weiter") verbraucht KEIN Kontingent
|
||||
// und liefert keine SVGs zurück – nur has_overflow/overflow.
|
||||
const nurPruefen = body.validate === true;
|
||||
// Angemeldete Admin/App-Nutzer (Produktions-Modul) umgehen Kennung + Kontingent.
|
||||
const istIntern = !!(req.accountability && (req.accountability.admin || req.accountability.app));
|
||||
const kennung = norm(body.email) || String(body.client_id || '').slice(0, 64);
|
||||
if (!kennung && !nurPruefen) return res.status(400).json({ error: 'E-Mail oder Kennung erforderlich.' });
|
||||
if (!kennung && !nurPruefen && !istIntern) return res.status(400).json({ error: 'E-Mail oder Kennung erforderlich.' });
|
||||
if (!body.text || !String(body.text).trim())
|
||||
return res.status(400).json({ error: 'Kein Text für die Vorschau.' });
|
||||
|
||||
@@ -404,11 +414,11 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||
const heute = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const vorhanden = nurPruefen ? null : (await svc('preview_usage').readByQuery({
|
||||
const vorhanden = (nurPruefen || istIntern) ? null : (await svc('preview_usage').readByQuery({
|
||||
filter: { key: { _eq: kennung }, day: { _eq: heute } }, limit: 1, fields: ['id', 'count'],
|
||||
}))?.[0];
|
||||
const verbraucht = vorhanden?.count ?? 0;
|
||||
if (!nurPruefen && verbraucht >= PREVIEW_LIMIT) {
|
||||
if (!nurPruefen && !istIntern && verbraucht >= PREVIEW_LIMIT) {
|
||||
return res.status(429).json({
|
||||
error: `Vorschau-Kontingent für heute aufgebraucht (${PREVIEW_LIMIT}).`,
|
||||
remaining: 0, limit: PREVIEW_LIMIT,
|
||||
@@ -423,8 +433,10 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
const realistisch = body.realistic !== false;
|
||||
// App-Formatschlüssel → Backend-Formate (wie im directus-controller).
|
||||
// Ohne diese Abbildung rendert das Backend unbekannte Keys als A4.
|
||||
// Bekannte App-Schlüssel übersetzen; eigene Format-Schlüssel (freie
|
||||
// Formate) unverändert durchreichen – das Backend kennt sie aus render_formats.
|
||||
const LETTER_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
|
||||
const briefFormat = LETTER_FORMAT[body.format] || 'a4';
|
||||
const briefFormat = LETTER_FORMAT[body.format] || body.format || 'a4';
|
||||
// A4 → DIN Lang, A6 → C6
|
||||
const umschlagFormat = briefFormat === 'a4' ? 'din_lang' : 'c6';
|
||||
|
||||
@@ -517,9 +529,11 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Verbrauch erst nach Erfolg hochzählen.
|
||||
// Verbrauch erst nach Erfolg hochzählen – intern (Admin) zählt nicht.
|
||||
if (!istIntern && !nurPruefen && kennung) {
|
||||
if (vorhanden) await svc('preview_usage').updateOne(vorhanden.id, { count: verbraucht + 1 });
|
||||
else await svc('preview_usage').createOne({ key: kennung, day: heute, count: 1 });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
docs, remaining: PREVIEW_LIMIT - (verbraucht + 1), limit: PREVIEW_LIMIT,
|
||||
@@ -632,6 +646,12 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
// für die gewählte Maschine an. Kein Statuswechsel am Auftrag (bewusst).
|
||||
const LETTER_TPL = { a4: 'a4_single', a6_hoch: 'a6_v1', a6_quer: 'a6l_v1' };
|
||||
const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1' };
|
||||
// Lesbare Labels + Format-Schlüssel je Job-Typ. Der Schlüssel steuert die
|
||||
// Format-Warteschlange (Agent druckt nur Jobs mit passendem machines.current_format).
|
||||
const ENV_LABEL = { dinlang: 'DIN Lang', c6: 'C6' };
|
||||
const LETTER_LABEL = { a4: 'A4', a6_hoch: 'A6 hoch', a6_quer: 'A6 quer' };
|
||||
const briefFormatOf = (order) => ({ key: order.format?.key || 'a4', label: order.format?.name || LETTER_LABEL[order.format?.key] || order.format?.key || 'A4' });
|
||||
const umschlagFormatOf = (order) => ({ key: order.envelope_format || 'c6', label: ENV_LABEL[order.envelope_format] || order.envelope_format || 'C6' });
|
||||
|
||||
router.post('/enqueue', async (req, res) => {
|
||||
const acc = req.accountability;
|
||||
@@ -642,7 +662,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
const schema = await getSchema();
|
||||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||
const order = await svc('orders').readOne(body.order, {
|
||||
fields: ['id', 'order_number', 'format.key', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
|
||||
fields: ['id', 'order_number', 'format.key', 'format.name', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
|
||||
});
|
||||
if (!order) return res.status(404).json({ error: 'Auftrag nicht gefunden.' });
|
||||
|
||||
@@ -676,8 +696,10 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
const sec = s[typ];
|
||||
if (!sec || sec.enabled === false) continue;
|
||||
if (!sec.template) return res.status(400).json({ error: `Template für ${typ} fehlt.` });
|
||||
const fmt = typ === 'umschlag' ? umschlagFormatOf(order) : briefFormatOf(order);
|
||||
jobs.push(await svc('jobs').createOne({
|
||||
order: order.id, machine: body.machine, type: typ, template: sec.template,
|
||||
format: fmt.key, format_label: fmt.label,
|
||||
params: clampParams(sec.params), numbers: (typeof sec.numbers === 'string' && sec.numbers.trim()) ? sec.numbers.trim() : null,
|
||||
status: 'queued', priority: prio,
|
||||
}));
|
||||
@@ -685,14 +707,18 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
if (!jobs.length) return res.status(400).json({ error: 'Kein Abschnitt zum Drucken gewählt.' });
|
||||
} else {
|
||||
// Alte, einfache Form (Auftrags-Button): Brief + ggf. Umschlag automatisch.
|
||||
const bf = briefFormatOf(order);
|
||||
const briefTpl = await tplByKey(LETTER_TPL[order.format?.key] || 'a4_single');
|
||||
jobs.push(await svc('jobs').createOne({
|
||||
order: order.id, machine: body.machine, type: 'brief', template: briefTpl, status: 'queued', priority: prio,
|
||||
order: order.id, machine: body.machine, type: 'brief', template: briefTpl,
|
||||
format: bf.key, format_label: bf.label, status: 'queued', priority: prio,
|
||||
}));
|
||||
if (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine') {
|
||||
const ef = umschlagFormatOf(order);
|
||||
const envTpl = await tplByKey(ENVELOPE_TPL[order.envelope_format] || 'kuvert_a_v1');
|
||||
jobs.push(await svc('jobs').createOne({
|
||||
order: order.id, machine: body.machine, type: 'umschlag', template: envTpl, status: 'queued', priority: prio,
|
||||
order: order.id, machine: body.machine, type: 'umschlag', template: envTpl,
|
||||
format: ef.key, format_label: ef.label, status: 'queued', priority: prio,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -703,6 +729,57 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Sammel-Druck: mehrere Aufträge auf einmal einreihen ────────────────────
|
||||
// Body: { orders:[id…], mode:'queue'|'direct',
|
||||
// byFormat:{ '<formatKey>': { template, machine, label } } }
|
||||
// Pro Format wird einmal Template + Maschine gewählt; hier werden daraus die
|
||||
// Jobs für alle markierten Aufträge erzeugt (Brief + ggf. Umschlag je Format).
|
||||
router.post('/enqueue-bulk', 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 orderIds = Array.isArray(body.orders) ? body.orders : [];
|
||||
const byFormat = (body.byFormat && typeof body.byFormat === 'object') ? body.byFormat : {};
|
||||
if (!orderIds.length) return res.status(400).json({ error: 'Keine Aufträge gewählt.' });
|
||||
try {
|
||||
const schema = await getSchema();
|
||||
const svc = (c) => new ItemsService(c, { schema, accountability: null });
|
||||
const orders = await svc('orders').readMany(orderIds, {
|
||||
fields: ['id', 'order_number', 'format.key', 'format.name', 'needs_envelope', 'envelope_labeling', 'envelope_format'],
|
||||
});
|
||||
|
||||
// Direktdruck bekommt hohe Priorität, außer die Zielmaschine druckt schon.
|
||||
const printing = await svc('jobs').readByQuery({ filter: { status: { _eq: 'printing' } }, limit: -1, fields: ['machine'] });
|
||||
const busySet = new Set((printing || []).map((j) => String(j.machine)));
|
||||
const mode = body.mode === 'direct' ? 'direct' : 'queue';
|
||||
const prioFor = (m) => (mode === 'direct' && !busySet.has(String(m))) ? 100 : 0;
|
||||
|
||||
const jobs = [];
|
||||
const uebersprungen = [];
|
||||
const mkJob = async (order, typ, fmt) => {
|
||||
const cfg = byFormat[fmt.key];
|
||||
if (!cfg || !cfg.template || !cfg.machine) {
|
||||
uebersprungen.push({ order: order.order_number, grund: `Kein Template/Maschine für ${typ === 'umschlag' ? 'Kuvert' : 'Format'} ${fmt.label}` });
|
||||
return;
|
||||
}
|
||||
jobs.push(await svc('jobs').createOne({
|
||||
order: order.id, machine: cfg.machine, type: typ, template: cfg.template,
|
||||
format: fmt.key, format_label: cfg.label || fmt.label, status: 'queued', priority: prioFor(cfg.machine),
|
||||
}));
|
||||
};
|
||||
for (const order of orders) {
|
||||
await mkJob(order, 'brief', briefFormatOf(order));
|
||||
if (order.needs_envelope && order.envelope_labeling && order.envelope_labeling !== 'keine') {
|
||||
await mkJob(order, 'umschlag', umschlagFormatOf(order));
|
||||
}
|
||||
}
|
||||
return res.json({ ok: true, jobs, mode, uebersprungen });
|
||||
} catch (err) {
|
||||
logger.error(`[skrift-orders] enqueue-bulk: ${err.stack || err.message}`);
|
||||
return res.status(500).json({ error: 'Sammel-Einreihen fehlgeschlagen.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Testdruck: beliebiges Template + Text generieren und einreihen ─────────
|
||||
const TEST_FORMAT = { a4: 'a4', a6_hoch: 'a6p', a6_quer: 'a6l' };
|
||||
router.post('/test-print', async (req, res) => {
|
||||
@@ -742,6 +819,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
// 2) Druck-Job (ohne Auftrag, über output_ref) für die gewählte Maschine.
|
||||
const job = await svc('jobs').createOne({
|
||||
machine: tp.machine, template: tp.template, type: 'brief', status: 'queued', output_ref: ref,
|
||||
format: tp.format || 'a4', format_label: LETTER_LABEL[tp.format] || tp.format || 'A4',
|
||||
});
|
||||
await svc('test_prints').updateOne(id, { status: 'generiert', output_ref: ref });
|
||||
return res.json({ ok: true, ref, job });
|
||||
@@ -793,6 +871,7 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
|
||||
const job = await svc('jobs').createOne({
|
||||
machine: body.machine, template: body.template, type: 'brief', status: 'queued', output_ref: ref, priority: prio,
|
||||
format: body.format || 'a4', format_label: LETTER_LABEL[body.format] || body.format || 'A4',
|
||||
});
|
||||
if (body.offer_request) {
|
||||
try { await svc('offer_requests').updateOne(body.offer_request, { status: 'in_bearbeitung' }); } catch { /* nicht kritisch */ }
|
||||
@@ -1015,6 +1094,232 @@ const handler = (router, { services, getSchema, logger }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Webhook: Anfragen direkt aus WordPress (JetFormBuilder) ────────────────
|
||||
// POST /skrift-orders/inbound/:kind (kind = 'muster' | 'anfrage')
|
||||
// Öffentlich erreichbar, daher mit Shared-Secret gesichert (SKRIFT_WEBHOOK_TOKEN,
|
||||
// per ?token= oder Header X-Webhook-Token). Feldnamen = JetFormBuilder-Felder.
|
||||
// JetFormBuilder sendet meist application/x-www-form-urlencoded – das parst
|
||||
// Directus für Custom-Endpunkte NICHT in req.body. Daher lesen wir den
|
||||
// Rohbody hier selbst und interpretieren ihn als urlencoded oder JSON.
|
||||
router.use('/inbound', (req, res, next) => {
|
||||
if (req.method !== 'POST') return next();
|
||||
if (req.body && typeof req.body === 'object' && Object.keys(req.body).length) return next();
|
||||
let raw = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (c) => { raw += c; if (raw.length > 1000000) req.destroy(); });
|
||||
req.on('end', () => {
|
||||
const s = raw.trim();
|
||||
if (s) {
|
||||
try { req.body = JSON.parse(s); }
|
||||
catch { req.body = Object.fromEntries(new URLSearchParams(s)); }
|
||||
}
|
||||
next();
|
||||
});
|
||||
req.on('error', () => next());
|
||||
});
|
||||
|
||||
router.post('/inbound/:kind', async (req, res) => {
|
||||
// Body ist durch die /inbound-Middleware bereits geparst (JSON/urlencoded).
|
||||
let body = req.body || {};
|
||||
if (typeof body === 'string') {
|
||||
try { body = JSON.parse(body); }
|
||||
catch { body = Object.fromEntries(new URLSearchParams(body)); }
|
||||
}
|
||||
const kind = String(req.params.kind || '').toLowerCase();
|
||||
// Immer protokollieren, damit im Directus-Log sichtbar ist, was ankommt.
|
||||
logger.info(`[skrift-orders] inbound/${kind}: content-type=${req.get('content-type') || '—'}, `
|
||||
+ `felder=[${Object.keys(body).join(',')}], token=${req.query.token ? 'query' : (req.get('x-webhook-token') ? 'header' : (body.token ? 'body' : 'keiner'))}`);
|
||||
|
||||
const secret = process.env.SKRIFT_WEBHOOK_TOKEN || '';
|
||||
const provided = req.query.token || req.get('x-webhook-token') || body.token || '';
|
||||
if (!secret) {
|
||||
logger.warn('[skrift-orders] inbound: SKRIFT_WEBHOOK_TOKEN ist nicht gesetzt – Webhook abgelehnt.');
|
||||
return res.status(401).json({ error: 'Webhook nicht konfiguriert (SKRIFT_WEBHOOK_TOKEN fehlt serverseitig).' });
|
||||
}
|
||||
if (String(provided) !== secret) {
|
||||
return res.status(401).json({ error: 'Ungültiger oder fehlender Webhook-Token.' });
|
||||
}
|
||||
if (!['muster', 'anfrage'].includes(kind)) return res.status(404).json({ error: 'Unbekannter Hook (muster|anfrage).' });
|
||||
|
||||
const name = String(body.name || '').trim();
|
||||
const email = norm(body.email);
|
||||
if (!name) return res.status(400).json({ error: 'Name fehlt.', felder: Object.keys(body) });
|
||||
if (!isEmail(email)) return res.status(400).json({ error: 'Gültige E-Mail-Adresse fehlt.', felder: Object.keys(body) });
|
||||
|
||||
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 customer = (await new UsersService({ schema, accountability: null }).readByQuery({
|
||||
filter: { email: { _eq: email } }, limit: 1, fields: ['id'],
|
||||
}))?.[0];
|
||||
|
||||
const phone = clean(String(body.telefon || body.phone || '').trim()) || null;
|
||||
|
||||
let record; let subject; let mailHtml;
|
||||
if (kind === 'muster') {
|
||||
const produkt = body.produkt_interesse ? clean(String(body.produkt_interesse)) : null;
|
||||
record = {
|
||||
type: 'muster', name: clean(name), email, phone, product: produkt, message: null,
|
||||
address: {
|
||||
street: clean(String(body.street || '')),
|
||||
zip: clean(String(body.zipcode || body.zip || '')),
|
||||
city: clean(String(body.city || '')),
|
||||
country: clean(String(body.country || '')),
|
||||
},
|
||||
status: 'neu', customer: customer?.id ?? null,
|
||||
};
|
||||
subject = `Musteranfrage (Web): ${produkt || '—'} – ${name}`;
|
||||
mailHtml =
|
||||
`<p><strong>${clean(name)}</strong> (${email}${phone ? `, ${phone}` : ''})</p>` +
|
||||
`<p>Interesse: <strong>${produkt || 'k. A.'}</strong></p>` +
|
||||
`<p>${clean(String(body.street || ''))}, ${clean(String(body.zipcode || body.zip || ''))} ${clean(String(body.city || ''))}, ${clean(String(body.country || ''))}</p>`;
|
||||
} else {
|
||||
const unternehmen = clean(String(body.unternehmen || '').trim());
|
||||
const nachricht = clean(String(body.nachricht || body.message || '').trim());
|
||||
// offer_requests hat kein Firmenfeld → Unternehmen der Nachricht voranstellen.
|
||||
const message = [unternehmen ? `Unternehmen: ${unternehmen}` : '', nachricht].filter(Boolean).join('\n\n') || null;
|
||||
record = {
|
||||
type: 'angebot', name: clean(name), email, phone, product: null, message,
|
||||
status: 'neu', customer: customer?.id ?? null,
|
||||
};
|
||||
subject = `Angebotsanfrage (Web) von ${name}`;
|
||||
mailHtml =
|
||||
`<p><strong>${clean(name)}</strong>${unternehmen ? ` · ${unternehmen}` : ''} (${email}${phone ? `, ${phone}` : ''})</p>` +
|
||||
`<p style="white-space:pre-wrap">${nachricht || '—'}</p>`;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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,10 +534,14 @@ 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', { 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),
|
||||
]);
|
||||
const meta = h('div', { style: 'display:flex;gap:6px;flex-wrap:wrap;margin:10px 0;' }, [
|
||||
@@ -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;' }, [
|
||||
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}`),
|
||||
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.'),
|
||||
]),
|
||||
...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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(',');
|
||||
|
||||
@@ -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,8 +333,28 @@ def _druck(dx, plotter, job, KIND):
|
||||
|
||||
plotter.login()
|
||||
plotter.write(dateien, tpl)
|
||||
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:
|
||||
@@ -288,10 +370,7 @@ def _druck(dx, plotter, job, KIND):
|
||||
print(f"[Agent] Auftrag {order_nr} vollständig gedruckt → Kuvertierung.")
|
||||
except Exception:
|
||||
pass
|
||||
print(f"[Agent] Job {job_id} gedruckt ({len(dateien)} Dateien).")
|
||||
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}")
|
||||
print(f"[Agent] Job {job_id} fertig gedruckt.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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,7 +1615,39 @@ 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' ? (
|
||||
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 && (
|
||||
@@ -1556,6 +1656,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
|
||||
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