diff --git a/bootstrap/bootstrap.mjs b/bootstrap/bootstrap.mjs index 516d716..bb0a3ff 100644 --- a/bootstrap/bootstrap.mjs +++ b/bootstrap/bootstrap.mjs @@ -404,6 +404,10 @@ const collections = [ // 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.' }), + // Vom Agent angefordertes NÄCHSTES Format (lineare Warteschlange): der nächste + // geplante Job braucht ein anderes Format → Agent pausiert und setzt dies, das + // Board zeigt „Format einlegen → bestätigen" (setzt current_format, leert dies). + f('pending_format', 'string', { interface: 'input', note: 'Angefordertes nächstes Format (wartet auf Bestätigung im Board).' }), f('notes', 'text', { interface: 'input-multiline' }), ] }, @@ -471,6 +475,9 @@ const collections = [ 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.' }), + // Explizite lineare Reihenfolge der Plan-Warteschlange (klein = zuerst). Ein + // Format darf mehrfach an verschiedenen Positionen stehen; im Board umsortierbar. + f('position', 'float', { interface: 'input', default: 0, note: 'Lineare Position in der Warteschlange (klein = zuerst).' }), // Pro-Druck-Übersteuerung der Template-Maße (Breite/Höhe/X/Y/Scale/Rot). // Vom Druck-Popup gesetzt; leer = Template-Werte verwenden. f('params', 'json', { interface: 'input-code', note: 'Maß-Übersteuerung {width,height,xpos,ypos,scale,rotation} (leer = Template).' }), @@ -1296,9 +1303,9 @@ async function applyPermissions() { ['status', 'artifact_path', 'payment_status', 'payment_ref', 'production_status', 'brief_gedruckt', 'kuvert_gedruckt']); await ensurePermission(svcPol.id, 'jobs', 'create'); // Agent meldet Druckfortschritt zurück. - await ensurePermission(svcPol.id, 'jobs', 'update', ['status', 'priority', 'error']); - // Agent meldet Maschinenzustand (Heartbeat). - await ensurePermission(svcPol.id, 'machines', 'update', ['status', 'last_seen']); + await ensurePermission(svcPol.id, 'jobs', 'update', ['status', 'priority', 'error', 'position']); + // Agent meldet Maschinenzustand (Heartbeat) + fordert Formatwechsel an. + await ensurePermission(svcPol.id, 'machines', 'update', ['status', 'last_seen', 'pending_format']); await ensurePermission(svcPol.id, 'status_history', 'create'); // Hochgeladene Druckdateien lesen (Backend führt PDFs für den Charge-Sammeldruck // zusammen und lädt sie dafür über /assets). diff --git a/extensions/directus-extension-skrift-orders/dist/index.js b/extensions/directus-extension-skrift-orders/dist/index.js index 8e1355a..a5a17e2 100644 --- a/extensions/directus-extension-skrift-orders/dist/index.js +++ b/extensions/directus-extension-skrift-orders/dist/index.js @@ -1249,6 +1249,15 @@ const handler = (router, { services, getSchema, logger }) => { const modus = (body.mode === 'direct' && !busy) ? 'direct' : 'queue'; const prio = modus === 'direct' ? 100 : 0; + // Nächste freie Position in der linearen Warteschlange dieser Maschine + // (neue Jobs hängen hinten an; im Board umsortierbar). + const letzte = await svc('jobs').readByQuery({ + filter: { machine: { _eq: body.machine }, status: { _in: ['queued', 'printing'] } }, + sort: ['-position'], limit: 1, fields: ['position'], + }); + let pos = (letzte && letzte.length && Number.isFinite(Number(letzte[0].position))) ? Number(letzte[0].position) : 0; + const nextPos = () => (pos += 1); + // Reinigt eine Maß-Übersteuerung auf erlaubte Zahlenfelder (oder null). const clampParams = (p) => { if (!p || typeof p !== 'object') return null; @@ -1274,7 +1283,7 @@ const handler = (router, { services, getSchema, logger }) => { params: clampParams(sec.params), numbers: (typeof sec.numbers === 'string' && sec.numbers.trim()) ? sec.numbers.trim() : null, // Rollenschonende Zeichenreihenfolge (A6 quer) – nur fürs Schriftstück sinnvoll. spaltenweise: !!(sec.spaltenweise || body.spaltenweise), - status: 'queued', priority: prio, + status: 'queued', priority: prio, position: nextPos(), })); } if (!jobs.length) return res.status(400).json({ error: 'Kein Abschnitt zum Drucken gewählt.' }); @@ -1284,14 +1293,14 @@ const handler = (router, { services, getSchema, logger }) => { 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, - format: bf.key, format_label: bf.label, status: 'queued', priority: prio, + format: bf.key, format_label: bf.label, status: 'queued', priority: prio, position: nextPos(), })); 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, - format: ef.key, format_label: ef.label, status: 'queued', priority: prio, + format: ef.key, format_label: ef.label, status: 'queued', priority: prio, position: nextPos(), })); } } @@ -1345,6 +1354,22 @@ const handler = (router, { services, getSchema, logger }) => { return Object.keys(out).length ? out : null; }; + // Lineare Position je Maschine: Startwert = höchste vorhandene Position der + // Maschine, dann fortlaufend. So hängen die Sammel-Jobs hinten an und behalten + // die (nach Auftragsnummer sortierte) Reihenfolge – im Board umsortierbar. + const posByMachine = {}; + const nextPos = async (machine) => { + const key = String(machine); + if (posByMachine[key] === undefined) { + const letzte = await svc('jobs').readByQuery({ + filter: { machine: { _eq: machine }, status: { _in: ['queued', 'printing'] } }, + sort: ['-position'], limit: 1, fields: ['position'], + }); + posByMachine[key] = (letzte && letzte.length && Number.isFinite(Number(letzte[0].position))) ? Number(letzte[0].position) : 0; + } + return (posByMachine[key] += 1); + }; + const jobs = []; const uebersprungen = []; const mkJob = async (order, typ, fmt) => { @@ -1356,7 +1381,7 @@ 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, params: clampParams(cfg.params), - status: 'queued', priority: prioFor(cfg.machine), + status: 'queued', priority: prioFor(cfg.machine), position: await nextPos(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 202572c..0a8c12d 100644 --- a/extensions/directus-extension-skrift-produktion/dist/index.js +++ b/extensions/directus-extension-skrift-produktion/dist/index.js @@ -284,14 +284,14 @@ const Board = defineComponent({ 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' } }); + return await api.get('/items/jobs', { params: { limit: -1, sort: '-priority,position,date_created', filter, fields: JOB_FELDER + ',format,format_label,position' } }); } 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' } }); + return await api.get('/items/machines', { params: { limit: -1, fields: 'id,name,active,status,last_seen,current_format,pending_format' } }); } catch { return api.get('/items/machines', { params: { limit: -1, fields: 'id,name,active,status,last_seen' } }); } @@ -578,17 +578,6 @@ const Board = defineComponent({ } 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: [] }; - groups.__ohne = { machine: { name: 'Ohne Maschine' }, jobs: [] }; - for (const j of jobs.value) { - if (j.status !== 'queued') continue; - if (!trifftSuche([j.order?.order_number, j.output_ref, j.type].filter(Boolean).join(' '))) continue; - (groups[j.machine?.id] || groups.__ohne).jobs.push(j); - } - 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; @@ -1292,6 +1281,31 @@ const Board = defineComponent({ try { await api.delete(`/items/jobs/${job.id}`); flash('Job entfernt.'); await laden(); } catch (e) { fehler.value = e?.message || 'Job konnte nicht entfernt werden.'; } } + // Lineare Reihenfolge: Positionen 1..n in Listenreihenfolge schreiben (robust, + // auch wenn Altbestand alle Position 0 hatte). + async function setzeReihenfolge(liste) { + try { + for (let i = 0; i < liste.length; i++) { + if (Number(liste[i].position) !== i + 1) await api.patch(`/items/jobs/${liste[i].id}`, { position: i + 1 }); + } + await laden(); + } catch (e) { fehler.value = e?.message || 'Umsortieren fehlgeschlagen.'; } + } + function listeUmstellen(liste, idx, ziel) { + if (ziel < 0 || ziel >= liste.length) return liste; + const arr = liste.slice(); const [x] = arr.splice(idx, 1); arr.splice(ziel, 0, x); return arr; + } + // Agent hat einen Formatwechsel angefordert (pending_format) → bestätigen: das + // angeforderte Format wird „eingelegt", die Anforderung gelöscht, Agent druckt weiter. + async function bestaetigeFormat(machine) { + try { + const f = (machine.pending_format || '').trim(); + await api.patch(`/items/machines/${machine.id}`, { current_format: f || null, pending_format: null }); + machine.current_format = f || null; machine.pending_format = null; + flash(f ? `Format „${f}" eingelegt – Druck läuft weiter.` : 'Bestätigt.'); + await laden(); + } catch (e) { fehler.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Bestätigen fehlgeschlagen.'; } + } async function setRequestStatus(req, st) { try { await api.patch(`/items/offer_requests/${req.id}`, { status: st }); flash('Anfrage aktualisiert.'); await laden(); } catch (e) { fehler.value = e?.message || 'Fehlgeschlagen.'; } @@ -1797,47 +1811,80 @@ const Board = defineComponent({ return items.length ? items : subdued('Nichts Neues.'); } if (tab.value === 'warteschlange') { - const gruppen = queuedByMachine.value; - if (!gruppen.length) return subdued('Warteschlange leer.'); - return gruppen.map((g) => { - const m = g.machine; + const queuedFor = (mid) => jobs.value.filter((j) => j.status === 'queued' && String(j.machine?.id) === String(mid)); + const printingFor = (mid) => jobs.value.filter((j) => j.status === 'printing' && String(j.machine?.id) === String(mid)); + const fmtLabel = (j) => j && (j.format_label || j.format || 'Ohne Format'); + const zeileStil = 'border:1px solid #d2dae6;border-radius:10px;padding:9px 11px;background:#ffffff;margin-bottom:6px;display:flex;align-items:center;gap:12px;flex-wrap:wrap;'; + const ordnBtn = (ic, titel, onClick, disabled) => h('button', { type: 'button', disabled, title: titel, onClick, + style: `cursor:${disabled ? 'not-allowed' : 'pointer'};border:1px solid #b4c0d2;background:#ffffff;border-radius:7px;width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;color:${disabled ? '#c7d0df' : '#1b2430'};flex-shrink:0;` }, icon(ic)); + + const maschinenPanel = (m) => { + const qlist = queuedFor(m.id); + const plist = printingFor(m.id); + if (!qlist.length && !plist.length) return null; 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', '#dff2e2') - : (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 === '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), + const pend = (m.pending_format || '').trim(); + const labelVon = (key) => (qlist.find((j) => j.format === key)?.format_label || key); + const erste = qlist[0]; + + const kopf = h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;margin-bottom:12px;' }, [ + h('div', { style: 'display:flex;align-items:center;gap:8px;font-weight:700;font-size:15px;' }, [icon('print'), m.name, m.status ? subdued('· ' + m.status) : null]), + h('div', { style: 'display:flex;align-items:center;gap:6px;font-size:13px;' }, [subdued('Eingelegt:'), + h('span', { style: `font-weight:600;color:${aktiv ? ACCENT : '#566070'};` }, aktiv ? labelVon(aktiv) : 'keins')]), ]); - }); + + // Formatwechsel-Anforderung des Agents → bestätigen; sonst Start-Hinweis. + const banner = pend + ? h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;background:#fbeccb;border:1px solid #f0d79a;border-radius:10px;padding:12px 14px;margin-bottom:12px;' }, [ + h('div', { style: 'display:flex;align-items:center;gap:8px;font-weight:600;color:#8a5a00;' }, [icon('swap_horiz'), `Formatwechsel: bitte „${labelVon(pend)}" einlegen`]), + btn('Eingelegt → bestätigen', () => bestaetigeFormat(m), 'primary', 'check'), + ]) + : (!aktiv && erste) + ? h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;background:#eef2f8;border:1px solid #b4c0d2;border-radius:10px;padding:12px 14px;margin-bottom:12px;' }, [ + subdued('Kein Format eingelegt.'), + btn(`Format „${fmtLabel(erste)}" einlegen → starten`, () => setMachineFormat(m, erste.format), 'primary', 'play_arrow'), + ]) + : null; + + const laufende = plist.map((j) => h('div', { key: 'p' + j.id, style: zeileStil + 'background:#e3f4e6;border-color:#bfe2c6;' }, [ + icon('print'), h('span', { style: 'font-weight:600;color:#1b6b2e;' }, `wird gedruckt: #${j.order?.order_number || j.output_ref || j.id} · ${j.type === 'umschlag' ? 'Kuvert' : 'Brief'} (${fmtLabel(j)})`), + ])); + + const zeilen = []; + qlist.forEach((j, i) => { + if (i > 0 && (qlist[i - 1].format || '') !== (j.format || '')) { + zeilen.push(h('div', { key: 'sep' + j.id, style: 'display:flex;align-items:center;gap:6px;margin:8px 0 6px 6px;font-size:12.5px;font-weight:600;color:#8a5a00;' }, [icon('swap_horiz'), `Formatwechsel → ${fmtLabel(j)}`])); + } + const istAktiv = aktiv && j.format === aktiv; + zeilen.push(h('div', { key: 'q' + j.id, style: zeileStil + (istAktiv ? 'border-color:#bcd0ef;background:#f2f6fd;' : '') }, [ + h('div', { style: 'width:26px;height:26px;border-radius:7px;background:#eef2f8;border:1px solid #b4c0d2;display:inline-flex;align-items:center;justify-content:center;font-size:12.5px;font-weight:700;color:#566070;flex-shrink:0;' }, String(i + 1)), + h('div', { style: 'flex:1;min-width:140px;' }, [ + h('div', { style: 'display:flex;align-items:center;gap:8px;flex-wrap:wrap;' }, [ + h('span', { style: `font-size:12px;font-weight:700;padding:2px 8px;border-radius:999px;background:#e3ecfa;color:${ACCENT};border:1px solid #bcd0ef;` }, fmtLabel(j)), + h('span', { style: 'font-weight:600;' }, `#${j.order?.order_number || j.output_ref || j.id} · ${j.type === 'umschlag' ? 'Kuvert' : 'Brief'}`), + j.priority ? chip('Direkt', '#fbeccb', '#8a5a00') : null, + ]), + subdued(j.numbers ? 'Nr. ' + j.numbers : 'alle'), + ]), + h('div', { style: 'display:flex;align-items:center;gap:4px;flex-shrink:0;' }, [ + ordnBtn('keyboard_double_arrow_up', 'An den Anfang', () => setzeReihenfolge(listeUmstellen(qlist, i, 0)), i === 0), + ordnBtn('keyboard_arrow_up', 'Nach oben', () => setzeReihenfolge(listeUmstellen(qlist, i, i - 1)), i === 0), + ordnBtn('keyboard_arrow_down', 'Nach unten', () => setzeReihenfolge(listeUmstellen(qlist, i, i + 1)), i === qlist.length - 1), + ordnBtn('keyboard_double_arrow_down', 'Ans Ende', () => setzeReihenfolge(listeUmstellen(qlist, i, qlist.length - 1)), i === qlist.length - 1), + ordnBtn('close', 'Entfernen', () => cancelJob(j), false), + ]), + ])); + }); + + return h('div', { key: 'wm' + m.id, style: SECTION + 'margin-bottom:18px;' }, [ + kopf, banner, ...laufende, + qlist.length ? h('div', { style: 'font-size:11.5px;font-weight:600;color:#566070;text-transform:uppercase;letter-spacing:.03em;margin:4px 0 8px;' }, `Geplante Reihenfolge (${qlist.length})`) : null, + ...zeilen, + ].filter(Boolean)); + }; + + const panels = aktiveMaschinen.value.map(maschinenPanel).filter(Boolean); + return panels.length ? panels : subdued('Warteschlange leer.'); } if (tab.value === 'druck') { const list = ordersByStatus('im_druck'); diff --git a/extensions/directus-extension-skrift-uebersicht/dist/index.js b/extensions/directus-extension-skrift-uebersicht/dist/index.js index dea2e34..c1cd01e 100644 --- a/extensions/directus-extension-skrift-uebersicht/dist/index.js +++ b/extensions/directus-extension-skrift-uebersicht/dist/index.js @@ -94,8 +94,8 @@ const Board = { + 'envelope_labeling,envelope_format,api_client.id,api_client.name,product.name,' + 'customer.first_name,customer.last_name,customer.company', } }), - api.get('/items/jobs', { params: { limit: -1, filter: { status: { _in: ['queued', 'printing'] } }, fields: 'id,type,status,format,format_label,order.id' } }).catch(() => ({ data: { data: [] } })), - api.get('/items/machines', { params: { limit: -1, filter: { active: { _eq: true } }, fields: 'id,name,current_format,status,active' } }).catch(() => ({ data: { data: [] } })), + api.get('/items/jobs', { params: { limit: -1, sort: '-priority,position,date_created', filter: { status: { _in: ['queued', 'printing'] } }, fields: 'id,type,status,format,format_label,position,machine.id,order.id,order.order_number' } }).catch(() => ({ data: { data: [] } })), + api.get('/items/machines', { params: { limit: -1, filter: { active: { _eq: true } }, fields: 'id,name,current_format,pending_format,status,active' } }).catch(() => ({ data: { data: [] } })), api.get('/items/format_templates', { params: { limit: -1, fields: 'id,key,name,type,default_format,search_default' } }).catch(() => ({ data: { data: [] } })), ]); const vorherAnzahl = orders.value.length; @@ -162,11 +162,6 @@ const Board = { const orderById = computed(() => { const m = {}; for (const o of orders.value) m[String(o.id)] = o; return m; }); const eingeplantSet = computed(() => new Set(jobs.value.map((j) => String(j.order?.id)).filter((x) => x && x !== 'null'))); - const queuedByFormat = computed(() => { - const map = {}; - for (const j of jobs.value) { if (j.status !== 'queued') continue; const f = j.format || '?'; if (!map[f]) map[f] = { n: 0, label: j.format_label || FORMAT_LABEL[f] || f }; map[f].n += 1; } - return map; - }); const gefiltert = computed(() => { const q = sucheQuery.value.trim().toLowerCase(); let list = orders.value; @@ -191,8 +186,25 @@ const Board = { } catch (e) { err.value = 'Speichern fehlgeschlagen.'; } } async function formatStarten(machine, fmt) { - try { await api.patch('/items/machines/' + machine.id, { current_format: fmt }); machine.current_format = fmt; flash(`${machine.name}: Format „${FORMAT_LABEL[fmt] || fmt}" eingelegt → Warteschlange läuft.`); } - catch (e) { err.value = 'Format konnte nicht gestartet werden.'; } + try { + await api.patch('/items/machines/' + machine.id, { current_format: fmt, pending_format: null }); + machine.current_format = fmt; machine.pending_format = null; + flash(`${machine.name}: Format „${FORMAT_LABEL[fmt] || fmt}" eingelegt → Warteschlange läuft.`); + await laden(true); + } catch (e) { err.value = 'Format konnte nicht gestartet werden.'; } + } + // Queued-Jobs einer Maschine in Plan-Reihenfolge (jobs.value ist bereits sortiert). + const jobsFuerMaschine = (mid) => jobs.value.filter((j) => j.status === 'queued' && String(j.machine?.id) === String(mid)); + // Aufeinanderfolgende gleiche Formate zu Segmenten zusammenfassen. + function formatSegmente(list) { + const seg = []; + for (const j of list) { + const key = j.format || '?'; + const last = seg[seg.length - 1]; + if (last && last.key === key) last.n += 1; + else seg.push({ key, label: j.format_label || FORMAT_LABEL[key] || key, n: 1 }); + } + return seg; } // ── Charge-Bildung (API-Aufträge gruppieren) ────────────────────────────── @@ -384,17 +396,41 @@ const Board = { ]); function maschinenPanel() { - const qf = queuedByFormat.value; const formate = Object.keys(qf); + const startBtn = (label, onClick, primär) => h('button', { type: 'button', onClick, + style: `cursor:pointer;border-radius:8px;padding:9px 15px;font:inherit;font-size:14px;font-weight:600;display:inline-flex;align-items:center;gap:6px;border:1px solid;${primär ? 'border-color:#1E50A5;background:#1E50A5;color:#fff;' : 'border-color:#b4c0d2;background:#ffffff;color:#1b2430;'}` }, + [icon(primär ? 'check' : 'play_arrow'), label]); + const maschinenZeile = (m) => { + const q = jobsFuerMaschine(m.id); + const aktiv = (m.current_format || '').trim(); + const pend = (m.pending_format || '').trim(); + const segs = formatSegmente(q); + const labelVon = (k) => (q.find((j) => j.format === k)?.format_label || FORMAT_LABEL[k] || k); + // Aktion: Formatwechsel bestätigen > erstes Format starten > läuft. + let aktion; + if (pend) aktion = startBtn(`Format „${labelVon(pend)}" eingelegt → bestätigen`, () => formatStarten(m, pend), true); + else if (!aktiv && segs.length) aktion = startBtn(`Format „${segs[0].label}" einlegen → starten`, () => formatStarten(m, segs[0].key), true); + else if (aktiv) aktion = h('span', { style: 'display:inline-flex;align-items:center;gap:5px;font-size:13px;color:#1b6b2e;font-weight:600;' }, [icon('play_arrow'), `läuft: ${labelVon(aktiv)}`]); + else aktion = h('span', { style: 'color:#566070;font-size:13px;' }, 'Keine Jobs in der Warteschlange.'); + // Vorschau der kommenden Format-Segmente. + const plan = segs.length ? h('div', { style: 'display:flex;align-items:center;gap:6px;flex-wrap:wrap;margin-top:8px;' }, [ + h('span', { style: 'font-size:12px;color:#566070;' }, 'Als Nächstes:'), + ...segs.slice(0, 8).flatMap((s, i) => { + const chip = h('span', { style: `font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;background:${s.key === aktiv ? '#e3ecfa' : '#eef2f8'};color:${s.key === aktiv ? '#1E50A5' : '#566070'};border:1px solid ${s.key === aktiv ? '#bcd0ef' : '#d2dae6'};` }, `${s.label} (${s.n})`); + return i === 0 ? [chip] : [h('span', { style: 'color:#b4c0d2;', 'aria-hidden': 'true' }, '›'), chip]; + }), + ]) : null; + return h('div', { key: 'm' + m.id, style: 'padding:12px 14px;border:1px solid #d2dae6;border-radius:10px;background:#ffffff;' + (pend ? 'border-color:#f0d79a;background:#fdf6e6;' : '') }, [ + h('div', { style: 'display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;' }, [ + h('div', { style: 'font-weight:600;' }, [m.name, h('span', { style: 'color:#566070;font-weight:400;font-size:13px;' }, aktiv ? ` · eingelegt: ${labelVon(aktiv)}` : ' · kein Format eingelegt')]), + aktion, + ]), + plan, + ].filter(Boolean)); + }; return h('div', { style: 'border:1px solid #b4c0d2;border-radius:12px;background:#ffffff;margin-bottom:18px;overflow:hidden;' }, [ - h('div', { style: 'display:flex;align-items:center;gap:6px;font-size:13px;font-weight:500;color:#566070;padding:10px 14px;border-bottom:1px solid #d2dae6;background:#eef2f8;' }, [icon('print'), 'Maschinen · Format einlegen und starten']), - h('div', { style: 'padding:14px;display:flex;flex-direction:column;gap:14px;' }, - aktiveMaschinen.value.length ? aktiveMaschinen.value.map((m) => h('div', { key: 'm' + m.id, style: 'display:flex;align-items:center;gap:12px;flex-wrap:wrap;' }, [ - h('div', { style: 'font-weight:500;min-width:150px;' }, [m.name, h('span', { style: 'color:#566070;font-weight:400;font-size:13px;' }, m.current_format ? ` · eingelegt: ${FORMAT_LABEL[m.current_format] || m.current_format}` : ' · kein Format eingelegt')]), - ...(formate.length ? formate.map((f) => h('button', { type: 'button', onClick: () => formatStarten(m, f), - style: `cursor:pointer;border-radius:8px;padding:8px 14px;font:inherit;font-size:14px;font-weight:500;display:inline-flex;align-items:center;gap:6px;border:1px solid;${m.current_format === f ? 'border-color:#1E50A5;background:#1E50A5;color:#fff;' : 'border-color:#b4c0d2;background:#ffffff;color:#1b2430;'}`, - }, [icon('play_arrow'), `${qf[f].label} (${qf[f].n})`])) - : [h('span', { style: 'color:#566070;font-size:13px;' }, 'Keine Jobs in der Warteschlange.')]), - ])) : [h('span', { style: 'color:#566070;font-size:13px;' }, 'Keine aktive Maschine.')]), + h('div', { style: 'display:flex;align-items:center;gap:6px;font-size:13px;font-weight:500;color:#566070;padding:10px 14px;border-bottom:1px solid #d2dae6;background:#eef2f8;' }, [icon('print'), 'Maschinen · Format einlegen und bestätigen']), + h('div', { style: 'padding:14px;display:flex;flex-direction:column;gap:12px;' }, + aktiveMaschinen.value.length ? aktiveMaschinen.value.map(maschinenZeile) : [h('span', { style: 'color:#566070;font-size:13px;' }, 'Keine aktive Maschine.')]), ]); }