Produktion: Suche-Reiter + Sammel-Druck mit editierbaren Maßen

Suche-Reiter (ganz oben, vor Neu): großes Suchfeld über alle aktiven
Stufen (Neu, Warteschlange, Im Druck, Kuvertierung, Versandfertig),
empfängergenau – Einzelaufträge zeigen den Namen, Mehrfachaufträge den
Empfänger an der richtigen Briefnummer. Über dem Feld ein Typ-Umschalter
(Schriftstück / Kuvert / Druckobjekte) mit je eigenem, im Reiter
einstellbarem Standard-Template + Maschine (lokal gespeichert). Der
Druck-Button gibt genau diesen Empfänger mit dem Standard-Template in die
Warteschlange; Druckobjekte öffnet die Dateien zum Herunterladen.

Sammel-Druck (Mass-Select): pro Format nicht nur Template + Maschine,
sondern auch Maße/Position (Breite, Höhe, X, Y, Scale, Rotation)
editierbar – übersteuert die Template-Werte wie beim Einzeldruck
(enqueue-bulk reicht die params jetzt an die Jobs durch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-09-21 17:07:44 +02:00
parent af63fd66be
commit a364f07035
2 changed files with 176 additions and 13 deletions

View File

@@ -1267,6 +1267,17 @@ const handler = (router, { services, getSchema, logger }) => {
const mode = body.mode === 'direct' ? 'direct' : 'queue'; const mode = body.mode === 'direct' ? 'direct' : 'queue';
const prioFor = (m) => (mode === 'direct' && !busySet.has(String(m))) ? 100 : 0; const prioFor = (m) => (mode === 'direct' && !busySet.has(String(m))) ? 100 : 0;
// Reinigt eine Maß-Übersteuerung auf erlaubte Zahlenfelder (oder null) –
// wie in /enqueue, damit der Sammel-Druck Template-Werte übersteuern kann.
const clampParams = (p) => {
if (!p || typeof p !== 'object') return null;
const out = {};
for (const k of ['width', 'height', 'xpos', 'ypos', 'scale', 'rotation']) {
if (p[k] !== undefined && p[k] !== null && p[k] !== '' && !isNaN(Number(p[k]))) out[k] = Number(p[k]);
}
return Object.keys(out).length ? out : null;
};
const jobs = []; const jobs = [];
const uebersprungen = []; const uebersprungen = [];
const mkJob = async (order, typ, fmt) => { const mkJob = async (order, typ, fmt) => {
@@ -1277,7 +1288,8 @@ const handler = (router, { services, getSchema, logger }) => {
} }
jobs.push(await svc('jobs').createOne({ jobs.push(await svc('jobs').createOne({
order: order.id, machine: cfg.machine, type: typ, template: cfg.template, 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), format: fmt.key, format_label: cfg.label || fmt.label, params: clampParams(cfg.params),
status: 'queued', priority: prioFor(cfg.machine),
})); }));
}; };
for (const order of orders) { for (const order of orders) {

View File

@@ -53,6 +53,7 @@ function faelligBis(dateCreated, shippingDay) {
const dmY = (dt) => dt ? `${String(dt.getDate()).padStart(2, '0')}.${String(dt.getMonth() + 1).padStart(2, '0')}.${dt.getFullYear()}` : ''; const dmY = (dt) => dt ? `${String(dt.getDate()).padStart(2, '0')}.${String(dt.getMonth() + 1).padStart(2, '0')}.${dt.getFullYear()}` : '';
const TABS = [ const TABS = [
{ key: 'suche', label: '🔎 Suche' },
{ key: 'neu', label: '🆕 Neu' }, { key: 'neu', label: '🆕 Neu' },
{ key: 'warteschlange', label: '⏳ Warteschlange' }, { key: 'warteschlange', label: '⏳ Warteschlange' },
{ key: 'druck', label: '🖨 Im Druck' }, { key: 'druck', label: '🖨 Im Druck' },
@@ -169,6 +170,12 @@ const Board = defineComponent({
const api = useApi(); const api = useApi();
const tab = ref('neu'); const tab = ref('neu');
const suche = ref(''); const suche = ref('');
// Suche-Reiter: Objekttyp (Schriftstück/Kuvert/Druckobjekt), Standard-Templates
// je Typ + Zielmaschine (im Reiter einstellbar, lokal gespeichert), und ein
// flacher Empfänger-Index (eine Zeile je Empfänger, mit Briefnummer).
const sucheTyp = ref('brief');
const sucheTpl = reactive({ brief: null, umschlag: null, machine: null });
const sucheEintraege = ref([]);
const loading = ref(false); const loading = ref(false);
const fehler = ref(''); const fehler = ref('');
const meldung = ref(''); const meldung = ref('');
@@ -253,6 +260,7 @@ const Board = defineComponent({
} }); } });
const idx = {}; const idx = {};
const namen = {}; const namen = {};
const flach = [];
for (const e of r.data?.data || []) { for (const e of r.data?.data || []) {
const oid = String(e.order?.id ?? e.order ?? ''); const oid = String(e.order?.id ?? e.order ?? '');
if (!oid) continue; if (!oid) continue;
@@ -262,9 +270,12 @@ const Board = defineComponent({
const nm = entryName(e); const nm = entryName(e);
if (!namen[oid]) namen[oid] = { erste: nm, letzte: nm, anzahl: 1 }; if (!namen[oid]) namen[oid] = { erste: nm, letzte: nm, anzahl: 1 };
else { namen[oid].letzte = nm; namen[oid].anzahl += 1; } else { namen[oid].letzte = nm; namen[oid].anzahl += 1; }
// Eine durchsuchbare Zeile je Empfänger (mit Briefnummer) für den Suche-Reiter.
flach.push({ orderId: oid, nr: Number(e.letter_number) || flach.filter((x) => x.orderId === oid).length + 1, name: nm, hay: txt });
} }
empfIndex.value = idx; empfIndex.value = idx;
empfNamen.value = namen; empfNamen.value = namen;
sucheEintraege.value = flach;
} catch { /* Suche läuft dann ohne Empfänger weiter */ } } catch { /* Suche läuft dann ohne Empfänger weiter */ }
} }
// Sende-Index über ALLE Jobs (auch bereits gedruckte): orderId → // Sende-Index über ALLE Jobs (auch bereits gedruckte): orderId →
@@ -390,6 +401,7 @@ const Board = defineComponent({
} finally { if (!leise) loading.value = false; } } finally { if (!leise) loading.value = false; }
} }
onMounted(laden); onMounted(laden);
onMounted(sucheDefaultsLaden);
// Auto-Refresh: hält das Board aktuell, ohne dass man die Seite neu laden muss. // Auto-Refresh: hält das Board aktuell, ohne dass man die Seite neu laden muss.
// Pausiert bei offenem Sammel-Dialog, im Hintergrund-Tab und während bereits // Pausiert bei offenem Sammel-Dialog, im Hintergrund-Tab und während bereits
@@ -441,6 +453,56 @@ const Board = defineComponent({
const versendetAktiv = () => orders.value.filter((o) => o.production_status === 'versendet' && !istArchiviert(o) && trifftSuche(orderHay(o))); const versendetAktiv = () => orders.value.filter((o) => o.production_status === 'versendet' && !istArchiviert(o) && trifftSuche(orderHay(o)));
const archivListe = () => orders.value.filter((o) => istArchiviert(o) && trifftSuche(orderHay(o))); const archivListe = () => orders.value.filter((o) => istArchiviert(o) && trifftSuche(orderHay(o)));
// ── Suche-Reiter: Empfänger-genaue Treffer über alle aktiven Stufen ────────
const SUCHE_AKTIV = ['eingegangen', 'im_druck', 'kuvertieren', 'versandfertig'];
const orderById = computed(() => {
const m = {};
for (const o of orders.value) m[String(o.id)] = o;
return m;
});
const sucheTreffer = computed(() => {
const q = suche.value.trim();
if (!q) return [];
const res = [];
for (const e of sucheEintraege.value) {
const o = orderById.value[e.orderId];
if (!o || !SUCHE_AKTIV.includes(o.production_status || 'eingegangen')) continue;
if (!trifftSuche([e.hay, o.order_number, o.product?.name].filter(Boolean).join(' '))) continue;
res.push({ e, o });
if (res.length >= 200) break;
}
return res;
});
// Standard-Templates/Maschine für den Schnelldruck lokal sichern/laden.
const SUCHE_KEY = 'skrift_suche_defaults';
function sucheDefaultsLaden() {
try {
const s = JSON.parse(localStorage.getItem(SUCHE_KEY) || '{}');
if (s.brief) sucheTpl.brief = s.brief;
if (s.umschlag) sucheTpl.umschlag = s.umschlag;
if (s.machine) sucheTpl.machine = s.machine;
} catch { /* ohne gespeicherte Defaults weiter */ }
}
function sucheDefaultsSpeichern() {
try { localStorage.setItem(SUCHE_KEY, JSON.stringify({ brief: sucheTpl.brief, umschlag: sucheTpl.umschlag, machine: sucheTpl.machine })); }
catch { /* egal */ }
}
// Schnelldruck: NUR den gewählten Empfänger (Briefnummer) des Typs mit dem im
// Reiter gesetzten Standard-Template in die Warteschlange geben.
async function schnelldruck(o, nr, typ) {
const tplId = typ === 'umschlag' ? sucheTpl.umschlag : sucheTpl.brief;
const machine = sucheTpl.machine || aktiveMaschinen.value[0]?.id || null;
if (!tplId) { fehler.value = `Bitte oben ein Standard-Template für ${typ === 'umschlag' ? 'Kuvert' : 'Schriftstück'} wählen.`; return; }
if (!machine) { fehler.value = 'Keine aktive Maschine verfügbar.'; return; }
try {
const sections = {};
sections[typ === 'umschlag' ? 'umschlag' : 'brief'] = { enabled: true, template: tplId, numbers: nr ? String(nr) : '' };
await api.post('/skrift-orders/enqueue', { order: o.id, machine, mode: 'queue', sections });
flash(`#${o.order_number} · ${typ === 'umschlag' ? '✉ Kuvert' : '📄 Schriftstück'}${nr ? ` Nr. ${nr}` : ''} → Warteschlange.`);
await laden();
} catch (e) { fehler.value = e?.response?.data?.error || e?.message || 'Einreihen fehlgeschlagen.'; }
}
const queuedByMachine = computed(() => { const queuedByMachine = computed(() => {
const groups = {}; const groups = {};
for (const m of aktiveMaschinen.value) groups[m.id] = { machine: m, jobs: [] }; for (const m of aktiveMaschinen.value) groups[m.id] = { machine: m, jobs: [] };
@@ -453,6 +515,7 @@ const Board = defineComponent({
return Object.values(groups).filter((g) => g.jobs.length); return Object.values(groups).filter((g) => g.jobs.length);
}); });
const tabCount = (key) => { const tabCount = (key) => {
if (key === 'suche') return sucheTreffer.value.length;
if (key === 'neu') return neueOrders.value.length + neueRequests.value.length; if (key === 'neu') return neueOrders.value.length + neueRequests.value.length;
if (key === 'warteschlange') return jobs.value.filter((j) => j.status === 'queued').length; if (key === 'warteschlange') return jobs.value.filter((j) => j.status === 'queued').length;
if (key === 'druck') return ordersByStatus('im_druck').length; if (key === 'druck') return ordersByStatus('im_druck').length;
@@ -612,7 +675,11 @@ const Board = defineComponent({
if (!sel.length) return; if (!sel.length) return;
const map = {}; const map = {};
const add = (key, label, typ) => { const add = (key, label, typ) => {
if (!map[key]) map[key] = { key, label, type: typ, aktiv: true, template: stdTpl(key, typ), machine: aktiveMaschinen.value[0]?.id ?? null }; if (!map[key]) {
const tid = stdTpl(key, typ);
const t = templates.value.find((x) => String(x.id) === String(tid));
map[key] = { key, label, type: typ, aktiv: true, template: tid, machine: aktiveMaschinen.value[0]?.id ?? null, ...paramFelder(t) };
}
}; };
for (const o of sel) { for (const o of sel) {
add(o.format?.key || 'a4', o.format?.name || o.format?.key || 'A4', 'brief'); add(o.format?.key || 'a4', o.format?.name || o.format?.key || 'A4', 'brief');
@@ -624,13 +691,21 @@ const Board = defineComponent({
bulk.gruppen = Object.values(map); bulk.gruppen = Object.values(map);
bulk.mode = 'queue'; bulk.msg = ''; bulk.err = ''; bulk.open = true; bulk.mode = 'queue'; bulk.msg = ''; bulk.err = ''; bulk.open = true;
} }
// Template im Sammel-Druck wechseln → Maße/Position aus dem neuen Template
// vorbelegen (der Operator kann sie danach je Format übersteuern).
function bulkTplWechsel(g, tplId) {
g.template = tplId;
const t = templates.value.find((x) => String(x.id) === String(tplId));
if (t) Object.assign(g, paramFelder(t));
}
async function submitBulk() { async function submitBulk() {
const byFormat = {}; const byFormat = {};
const aktive = bulk.gruppen.filter((g) => g.aktiv); const aktive = bulk.gruppen.filter((g) => g.aktiv);
if (!aktive.length) { bulk.err = 'Mindestens ein Format auswählen.'; return; } if (!aktive.length) { bulk.err = 'Mindestens ein Format auswählen.'; return; }
for (const g of aktive) { for (const g of aktive) {
if (!g.template || !g.machine) { bulk.err = `Für „${g.label}" bitte Template und Maschine wählen.`; return; } 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 }; byFormat[g.key] = { template: g.template, machine: g.machine, label: g.label,
params: { width: g.width, height: g.height, xpos: g.xpos, ypos: g.ypos, scale: g.scale, rotation: g.rotation } };
} }
bulk.err = ''; bulk.msg = 'Wird eingereiht …'; bulk.err = ''; bulk.msg = 'Wird eingereiht …';
try { try {
@@ -656,6 +731,9 @@ const Board = defineComponent({
// Bevorzugt das als Standard fürs Format markierte Template (default_format), sonst Fallback. // Bevorzugt das als Standard fürs Format markierte Template (default_format), sonst Fallback.
const tplForFormat = (fmt, typ) => (fmt && templates.value.find((t) => t.type === typ && t.default_format && t.default_format === fmt)) || null; const tplForFormat = (fmt, typ) => (fmt && templates.value.find((t) => t.type === typ && t.default_format && t.default_format === fmt)) || 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 }); 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 });
// Nur die editierbaren Maß-/Positionsfelder eines Templates (ohne tplId) – für
// den Sammel-Druck, wo das Template über g.template getrennt geführt wird.
const paramFelder = (t) => ({ width: t?.width ?? 0, height: t?.height ?? 0, xpos: t?.xpos ?? 0, ypos: t?.ypos ?? 0, scale: t?.scale ?? 1, rotation: t?.rotation ?? 0 });
function openDruck(order) { function openDruck(order) {
// Standard-Template fürs Format (default_format) hat Vorrang, sonst alte Zuordnung. // Standard-Template fürs Format (default_format) hat Vorrang, sonst alte Zuordnung.
@@ -1190,7 +1268,71 @@ const Board = defineComponent({
]; ];
} }
// ── Suche-Reiter ──────────────────────────────────────────────────────────
function sucheInhalt() {
const briefTpls = templates.value.filter((t) => t.type === 'brief');
const umschlagTpls = templates.value.filter((t) => t.type === 'umschlag');
const tplSelect = (typ) => {
const opts = typ === 'umschlag' ? umschlagTpls : briefTpls;
const val = typ === 'umschlag' ? sucheTpl.umschlag : sucheTpl.brief;
return h('select', { style: S_SELECT + 'width:100%;', value: val ?? '',
onChange: (e) => { const v = e.target.value || null; if (typ === 'umschlag') sucheTpl.umschlag = v; else sucheTpl.brief = v; sucheDefaultsSpeichern(); } },
[h('option', { value: '' }, '– Standard-Template –')]
.concat(opts.map((t) => h('option', { value: t.id, selected: String(val) === String(t.id) }, t.name))));
};
// Kopf: Typ-Umschalter + Standard-Templates/Maschine (im Reiter einstellbar).
const kopf = h('div', { style: SECTION }, [
h('div', { style: 'max-width:560px;margin-bottom:14px;' }, [
segToggle(sucheTyp.value, [
{ key: 'brief', label: '📄 Schriftstück' },
{ key: 'umschlag', label: '✉ Kuvert' },
{ key: 'objekt', label: '📎 Druckobjekte' },
], (k) => { sucheTyp.value = k; }),
]),
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;' }, [
h('div', {}, [h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Standard-Template Schriftstück'), tplSelect('brief')]),
h('div', {}, [h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Standard-Template Kuvert'), tplSelect('umschlag')]),
h('div', {}, [h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Maschine'),
h('select', { style: S_SELECT + 'width:100%;', value: sucheTpl.machine ?? '',
onChange: (e) => { sucheTpl.machine = e.target.value || null; sucheDefaultsSpeichern(); } },
[h('option', { value: '' }, aktiveMaschinen.value[0] ? `Automatisch (${aktiveMaschinen.value[0].name})` : '– keine –')]
.concat(aktiveMaschinen.value.map((m) => h('option', { value: m.id, selected: String(sucheTpl.machine) === String(m.id) }, m.name)))),
]),
]),
subdued(sucheTyp.value === 'objekt'
? 'Druckobjekte: fertige Dateien des Auftrags zum manuellen Herunterladen.'
: 'Klick auf „In Warteschlange" gibt genau diesen Empfänger mit dem gewählten Standard-Template in die Queue.', 'margin-top:10px;'),
]);
// Großes Suchfeld (nutzt dasselbe Such-Feld wie oben).
const feld = h('input', { type: 'search', value: suche.value, placeholder: '🔎 Name, Straße, Ort, Auftragsnummer …',
style: S_INPUT + 'width:100%;font-size:20px;padding:14px 18px;margin:14px 0;', onInput: (e) => { suche.value = e.target.value; } });
if (!suche.value.trim()) return [kopf, feld, subdued('Suchbegriff eingeben – durchsucht Neu, Warteschlange, Im Druck, Kuvertierung und Versandfertig.')];
const treffer = sucheTreffer.value;
if (!treffer.length) return [kopf, feld, subdued('Keine Treffer in den aktiven Aufträgen.')];
const zeile = ({ e, o }) => {
const gesamt = empfNamen.value[String(o.id)]?.anzahl || 1;
const pos = gesamt > 1 ? `Nr. ${e.nr} von ${gesamt}` : 'Einzelauftrag';
const statusLbl = STATUS_LABEL[o.production_status || 'eingegangen'] || o.production_status || '—';
const aktion = sucheTyp.value === 'objekt'
? btn('📎 Dateien', () => openEmpf(o), 'primary')
: btn('🖨 In Warteschlange', () => schnelldruck(o, e.nr, sucheTyp.value), 'primary');
return h('div', { key: `s${o.id}-${e.nr}`, style: CARD + 'margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;' }, [
h('div', { style: 'min-width:0;' }, [
h('div', { style: 'font-weight:700;font-size:15px;' }, e.name || '—'),
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;margin-top:3px;align-items:center;' }, [
subdued(`#${o.order_number}`), chip(pos), chip(statusLbl), o.product?.name ? subdued(o.product.name) : null,
]),
]),
aktion,
]);
};
return [kopf, feld, h('div', { style: 'display:flex;flex-direction:column;gap:2px;' }, treffer.map(zeile))];
}
function tabInhalt() { function tabInhalt() {
if (tab.value === 'suche') return sucheInhalt();
if (tab.value === 'neu') { if (tab.value === 'neu') {
const items = [ const items = [
...renderGruppiert(neueOrders.value), ...renderGruppiert(neueOrders.value),
@@ -1348,17 +1490,26 @@ const Board = defineComponent({
h('input', { type: 'checkbox', checked: g.aktiv, onChange: (e) => { g.aktiv = e.target.checked; }, style: 'width:16px;height:16px;' }), h('input', { type: 'checkbox', checked: g.aktiv, onChange: (e) => { g.aktiv = e.target.checked; }, style: 'width:16px;height:16px;' }),
`${g.type === 'umschlag' ? '✉' : '📄'} ${g.label}`, `${g.type === 'umschlag' ? '✉' : '📄'} ${g.label}`,
]), ]),
g.aktiv ? h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px;' }, [ g.aktiv ? h('div', {}, [
h('div', {}, [ h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:10px;' }, [
h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Template'), h('div', {}, [
h('select', { style: S_SELECT + 'width:100%;', value: g.template, onChange: (e) => { g.template = e.target.value; } }, h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Template'),
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('select', { style: S_SELECT + 'width:100%;', value: g.template, onChange: (e) => bulkTplWechsel(g, 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('div', {}, [
h('select', { style: S_SELECT + 'width:100%;', value: g.machine, onChange: (e) => { g.machine = e.target.value; } }, h('label', { style: 'font-size:11px;color:var(--foreground-subdued);' }, 'Maschine'),
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')]), 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')]),
]),
]), ]),
// Maße/Position wie beim normalen Druck editierbar (übersteuert das Template).
h('div', { style: 'display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-top:10px;' },
PARAM_FELDER.map(([k, lbl]) => h('div', {}, [
h('label', { style: 'font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--foreground-subdued);' }, lbl),
h('input', { type: 'number', step: '0.01', value: g[k], style: S_INPUT + 'width:100%;',
onInput: (e) => { g[k] = e.target.value; } }),
]))),
]) : null, ]) : null,
]); ]);
}; };