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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user