B: Explizite lineare Druck-Warteschlange + Formatwechsel-Bestaetigung

Schema (bootstrap):
- jobs.position (float) = explizite lineare Reihenfolge; machines.pending_format = vom Agent angefordertes naechstes Format
- Service-Rechte: jobs.update +position, machines.update +pending_format

Einreihen (skrift-orders): enqueue/enqueue-bulk vergeben position (ans Ende je Maschine)

Board (produktion) Warteschlange: EINE lineare Liste je Maschine (nicht mehr nach
Format gebuendelt), Hoch/Runter/Anfang/Ende-Umsortieren, Formatwechsel-Marker,
laufende Jobs, Banner "Format einlegen -> bestaetigen" (setzt current_format, leert pending)

Uebersicht (Mitarbeiter): Maschinen-Panel mit Bestaetigen/Starten + Vorschau der
kommenden Format-Segmente; jobs nach position sortiert

ACHTUNG Deploy: Bootstrap VOR Directus-Neustart UND vor Agent-Update (neue Felder/Rechte).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
s4luorth
2026-10-01 13:41:12 +02:00
parent a1a2a615cc
commit 0f7f9c3bd9
4 changed files with 193 additions and 78 deletions

View File

@@ -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).

View File

@@ -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) {

View File

@@ -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');

View File

@@ -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.')]),
]);
}