diff --git a/extensions/directus-extension-skrift-orders/dist/index.js b/extensions/directus-extension-skrift-orders/dist/index.js index e463f3b..8f2d6af 100644 --- a/extensions/directus-extension-skrift-orders/dist/index.js +++ b/extensions/directus-extension-skrift-orders/dist/index.js @@ -1267,6 +1267,17 @@ const handler = (router, { services, getSchema, logger }) => { const mode = body.mode === 'direct' ? 'direct' : 'queue'; 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 uebersprungen = []; const mkJob = async (order, typ, fmt) => { @@ -1277,7 +1288,8 @@ const handler = (router, { services, getSchema, logger }) => { } 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), + format: fmt.key, format_label: cfg.label || fmt.label, params: clampParams(cfg.params), + status: 'queued', priority: prioFor(cfg.machine), })); }; for (const order of orders) { diff --git a/extensions/directus-extension-skrift-produktion/dist/index.js b/extensions/directus-extension-skrift-produktion/dist/index.js index 5280e43..cb04175 100644 --- a/extensions/directus-extension-skrift-produktion/dist/index.js +++ b/extensions/directus-extension-skrift-produktion/dist/index.js @@ -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 TABS = [ + { key: 'suche', label: '🔎 Suche' }, { key: 'neu', label: '🆕 Neu' }, { key: 'warteschlange', label: '⏳ Warteschlange' }, { key: 'druck', label: '🖨 Im Druck' }, @@ -169,6 +170,12 @@ const Board = defineComponent({ const api = useApi(); const tab = ref('neu'); 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 fehler = ref(''); const meldung = ref(''); @@ -253,6 +260,7 @@ const Board = defineComponent({ } }); const idx = {}; const namen = {}; + const flach = []; for (const e of r.data?.data || []) { const oid = String(e.order?.id ?? e.order ?? ''); if (!oid) continue; @@ -262,9 +270,12 @@ const Board = defineComponent({ const nm = entryName(e); if (!namen[oid]) namen[oid] = { erste: nm, letzte: nm, 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; empfNamen.value = namen; + sucheEintraege.value = flach; } catch { /* Suche läuft dann ohne Empfänger weiter */ } } // Sende-Index über ALLE Jobs (auch bereits gedruckte): orderId → @@ -390,6 +401,7 @@ const Board = defineComponent({ } finally { if (!leise) loading.value = false; } } onMounted(laden); + onMounted(sucheDefaultsLaden); // 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 @@ -441,6 +453,56 @@ const Board = defineComponent({ 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))); + // ── 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 groups = {}; 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); }); const tabCount = (key) => { + if (key === 'suche') return sucheTreffer.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 === 'druck') return ordersByStatus('im_druck').length; @@ -612,7 +675,11 @@ const Board = defineComponent({ if (!sel.length) return; const map = {}; 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) { 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.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() { const byFormat = {}; const aktive = bulk.gruppen.filter((g) => g.aktiv); if (!aktive.length) { bulk.err = 'Mindestens ein Format auswählen.'; return; } for (const g of aktive) { 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 …'; try { @@ -656,6 +731,9 @@ const Board = defineComponent({ // 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 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) { // 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() { + if (tab.value === 'suche') return sucheInhalt(); if (tab.value === 'neu') { const items = [ ...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;' }), `${g.type === 'umschlag' ? '✉' : '📄'} ${g.label}`, ]), - g.aktiv ? 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')]), + g.aktiv ? h('div', {}, [ + 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) => 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('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, ]); };