From 6a6b09d49d2cd64f7861bfedbfffb5b514b82a1f Mon Sep 17 00:00:00 2001 From: s4luorth Date: Thu, 1 Oct 2026 11:21:49 +0200 Subject: [PATCH] Uebersicht: Sidebar Suche/Uebersicht, API-Gruppierung, Versanddatum, 3-Uhr-Mail uebersicht: - Secondary-Sidebar: "Uebersicht" + "Suche". Suche wie frueher (Empfaenger suchen -> Schriftstueckart -> In Warteschlange, fuer Nachdruck). - API-Auftraege als aufklappbare Charge-Gruppe (Kopf mit Summen: Schriftstuecke, Kuverts, Format, Versand). Versandfertig unterteilt: schnellstmoeglich / Datum. - Versanddatum je Auftrag editierbar (leer = schnellstmoeglich). bootstrap: orders.versanddatum (date). produktion: Charge-Kopf zeigt Summen (Schriftstuecke/Kuverts/Format/Versand). mailer: 03:00-Mail listet zusaetzlich "heute zu versenden" (faelliges Versanddatum). Co-Authored-By: Claude Opus 4.8 --- bootstrap/bootstrap.mjs | 2 + .../dist/index.js | 17 +- .../dist/index.js | 13 +- .../dist/index.js | 322 +++++++++++++----- 4 files changed, 259 insertions(+), 95 deletions(-) diff --git a/bootstrap/bootstrap.mjs b/bootstrap/bootstrap.mjs index 7b9a3c6..e2d4600 100644 --- a/bootstrap/bootstrap.mjs +++ b/bootstrap/bootstrap.mjs @@ -318,6 +318,8 @@ const collections = [ // (Einzelversand = ja, Sammelversand = nein). Bei Sammelversand umstellbar. f('frankiert', 'boolean', { interface: 'boolean', note: 'Von uns frankiert → Briefmarke wird abgebucht. Leer = automatisch (Einzel ja / Sammel nein).' }), dropdown('porto_klasse', ['auto', 'standard', 'grossbrief', 'maxibrief'], { default: 'auto', note: 'Markentyp-Override. auto = Inland 0,95 € / Ausland 1,25 €.' }), + // Gewünschtes Versanddatum. Leer = schnellstmöglich. Bei Fälligkeit 03:00-Mail. + f('versanddatum', 'date', { interface: 'datetime', options: { includeSeconds: false }, note: 'Gewünschtes Versanddatum (leer = schnellstmöglich).' }), f('entries_count', 'integer', { interface: 'input', default: 0 }), f('count_inland', 'integer', { interface: 'input', default: 0, note: 'Empfängerzeilen Deutschland.' }), f('count_ausland', 'integer', { interface: 'input', default: 0, note: 'Empfängerzeilen Ausland.' }), diff --git a/extensions/directus-extension-skrift-mailer/dist/index.js b/extensions/directus-extension-skrift-mailer/dist/index.js index 652c605..87027bc 100644 --- a/extensions/directus-extension-skrift-mailer/dist/index.js +++ b/extensions/directus-extension-skrift-mailer/dist/index.js @@ -309,7 +309,22 @@ export default (register, { services, getSchema, logger }) => { + `${gesamtA}` + `${gesamtM}` + ''; - const nachricht = { to, subject: `Auftragsübersicht ${heute} – ${gesamtA} Aufträge`, html, text: htmlZuText(html), replyTo: REPLY_TO }; + // Heute zu versenden: versandfertige Aufträge mit fälligem (≤ heute) Versanddatum. + let faelligHtml = ''; let faelligN = 0; + try { + const heuteStr = new Date().toISOString().slice(0, 10); + const faellig = await new ItemsService('orders', { schema, accountability: null }).readByQuery({ + filter: { _and: [{ production_status: { _eq: 'versandfertig' } }, { versanddatum: { _nnull: true } }, { versanddatum: { _lte: heuteStr } }] }, + fields: ['order_number', 'order_name', 'versanddatum', 'entries_count'], limit: -1, sort: ['versanddatum'], + }); + faelligN = (faellig || []).length; + if (faelligN) { + const z = faellig.map((o) => `
  • ${esc(o.order_name || o.order_number)} – ${o.entries_count || 0} Stk., Versand ${esc(o.versanddatum)}
  • `).join(''); + faelligHtml = `

    Heute zu versenden (${faelligN}):

    `; + } + } catch (e) { logger.warn(`[skrift-mailer] Fälligkeits-Check: ${e.message}`); } + const htmlFull = html + faelligHtml; + const nachricht = { to, subject: `Auftragsübersicht ${heute} – ${gesamtA} Aufträge${faelligN ? ` · ${faelligN} heute versenden` : ''}`, html: htmlFull, text: htmlZuText(htmlFull), replyTo: REPLY_TO }; if (settings.sender_email) nachricht.from = settings.sender_name ? `${settings.sender_name} <${settings.sender_email}>` : settings.sender_email; await new MailService({ schema, accountability: null }).send(nachricht); logger.info(`[skrift-mailer] Auftragsübersicht verschickt: ${gesamtA} Aufträge in ${Object.keys(agg).length} Status`); diff --git a/extensions/directus-extension-skrift-produktion/dist/index.js b/extensions/directus-extension-skrift-produktion/dist/index.js index fe9801d..13fc96f 100644 --- a/extensions/directus-extension-skrift-produktion/dist/index.js +++ b/extensions/directus-extension-skrift-produktion/dist/index.js @@ -1547,9 +1547,20 @@ const Board = defineComponent({ c.druckZeit ? chip('gedruckt ' + dmYhm(c.druckZeit), 'rgba(30,80,165,.1)', 'var(--primary,#1e50a5)') : null, c.faellig ? chip('fällig bis ' + dmY(c.faellig), faelligFarbe) : null, ]); + // Summen der Charge (Schriftstücke · Kuverts · Format · Versand). + const sumSchrift = c.orders.reduce((a, o) => a + (Number(o.entries_count) || 0), 0); + const sumKuvert = c.orders.reduce((a, o) => a + (o.needs_envelope ? (Number(o.entries_count) || 0) : 0), 0); + const fmts = [...new Set(c.orders.map((o) => o.format?.name || o.format?.key).filter(Boolean))]; + const versands = [...new Set(c.orders.map((o) => o.shipping_type === 'einzeln' ? 'Einzel' : 'Sammel'))]; + const cTrenn = () => h('span', { style: 'color:var(--border-normal);', 'aria-hidden': 'true' }, '·'); + const chargeSpecs = h('div', { style: 'display:flex;gap:11px;align-items:center;flex-wrap:wrap;margin-top:10px;color:var(--foreground-subdued);font-size:13.5px;' }, [ + metaItem(`${sumSchrift} Schriftstücke`), cTrenn(), metaItem(`${sumKuvert} Kuverts`), cTrenn(), + metaItem('Format: ' + (fmts.length ? fmts.join(', ') : '—')), cTrenn(), + metaItem('Versand: ' + (versands.length === 1 ? versands[0] : versands.join('/'))), + ]); const body = auf ? h('div', { style: 'margin-top:12px;display:flex;flex-direction:column;gap:2px;' }, c.orders.map((o) => orderCard(o, opts))) : null; const chargeNotiz = notizFeld(chargeNotizen.value[c.key], (v) => setChargeNotiz(c.key, v), 'Notiz zur Charge …'); - return h('div', { key: 'c' + c.key, style: 'border:1px solid var(--border-normal);border-radius:12px;background:var(--background-page,#fff);padding:16px 18px;margin-bottom:14px;' }, [kopf, chargeNotiz, body]); + return h('div', { key: 'c' + c.key, style: 'border:1px solid var(--border-normal);border-radius:12px;background:var(--background-page,#fff);padding:16px 18px;margin-bottom:14px;' }, [kopf, chargeSpecs, chargeNotiz, body]); } // Aufträge einer Ansicht rendern: bei aktiver Suche als EINZELaufträge (damit diff --git a/extensions/directus-extension-skrift-uebersicht/dist/index.js b/extensions/directus-extension-skrift-uebersicht/dist/index.js index 2963f7d..4e054bf 100644 --- a/extensions/directus-extension-skrift-uebersicht/dist/index.js +++ b/extensions/directus-extension-skrift-uebersicht/dist/index.js @@ -1,11 +1,13 @@ /** - * Skrift – Modul „Übersicht" (Mitarbeiter, read-only) + * Skrift – Modul „Übersicht" (Mitarbeiter) * =========================================================================== - * Kompakte Tabelle aller offenen Aufträge – ohne Blättern direkt ersichtlich: - * Auftrag (Name/Nummer) · erster+letzter Empfänger · Format · fällig · - * Status · Druck (Brief/Kuvert getrennt) · eingeplant. - * Mitarbeiter planen NICHT, dürfen aber: Status setzen, „Format liegt ein → - * Warteschlange starten" (setzt das Maschinenformat) und suchen/filtern. + * Secondary-Sidebar mit zwei Ansichten: + * • Übersicht – kompakte, nach Status getrennte Tabellen (API-Aufträge + * gruppiert). Je Auftrag: Empfänger (erster/letzter), Format, Aufgabe, + * Versandart (umschaltbar), Versanddatum (editierbar), Druck Brief/Kuvert, + * eingeplant, Status. Maschinen-Panel: „Format einlegen → starten". + * • Suche – Empfänger suchen, Schriftstückart wählen, direkt in die + * Warteschlange (Nachdruck nach Fehlern). * * Von Hand als ESM (kein Build) – Vue + SDK stellt Directus bereit. */ @@ -24,9 +26,11 @@ const STATUS_META = { versendet: { bg: 'rgba(46,125,50,.14)', fg: 'var(--success,#2e7d32)', icon: 'local_shipping' }, storniert: { bg: 'rgba(198,40,40,.12)', fg: 'var(--danger,#c62828)', icon: 'cancel' }, }; -// Aktiv = noch nicht abgeschlossen. const AKTIV_STATUS = ['eingegangen', 'im_druck', 'kuvertieren', 'versandfertig']; const FORMAT_LABEL = { a4: 'A4', a5p: 'A5 hoch', a5l: 'A5 quer', dinlang_karte: 'DIN lang', a6_hoch: 'A6 hoch', a6_quer: 'A6 quer', dinlang: 'DIN Lang', c6: 'C6', c4: 'C4' }; +// Format → Standard-Template-Schlüssel (Fallback, wie im Board). +const LETTER_TPL = { a4: 'a4_single', a5p: 'a5_v1', a5l: 'a5l_v1', dinlang_karte: 'dl_v1', a6_hoch: 'a6_v1', a6_quer: 'a6l_v1' }; +const ENVELOPE_TPL = { dinlang: 'kuvert_a_v1', c6: 'kuvert_b_v1', c4: 'kuvert_c4_v1' }; const WOCHENTAG = { montag: 1, dienstag: 2, mittwoch: 3, donnerstag: 4, freitag: 5, samstag: 6, sonntag: 0 }; function faelligBis(dateCreated, shippingDay) { @@ -46,60 +50,79 @@ const Board = { const api = useApi(); const icon = (name, extra = {}) => h(resolveComponent('v-icon'), { name, small: true, ...extra }); + const view = ref('uebersicht'); // 'uebersicht' | 'suche' const orders = ref([]); const jobs = ref([]); const machines = ref([]); - const entriesByOrder = ref({}); + const templates = ref([]); + const entriesByOrder = ref({}); // { orderId: {erster, letzter, anzahl} } + const sucheEintraege = ref([]); // flache Empfängerliste für die Suche + const expandedCharges = reactive({}); const loading = ref(false); const msg = ref(''); const err = ref(''); - const sucheQuery = ref(''); - let sucheTimer = null; - const sucheSetzen = (v) => { clearTimeout(sucheTimer); sucheTimer = setTimeout(() => { sucheQuery.value = v; }, 250); }; + const sucheQuery = ref(''); let sucheTimer = null; + const sucheSetzen = (v) => { clearTimeout(sucheTimer); sucheTimer = setTimeout(() => { sucheQuery.value = v; }, 220); }; + const sucheTyp = ref('brief'); - function kundeName(o) { - return [o.customer?.company, [o.customer?.first_name, o.customer?.last_name].filter(Boolean).join(' ')].filter(Boolean).join(' · '); - } + const aktiveMaschinen = computed(() => machines.value.filter((m) => m.active !== false)); + function kundeName(o) { return [o.customer?.company, [o.customer?.first_name, o.customer?.last_name].filter(Boolean).join(' ')].filter(Boolean).join(' · '); } function empfKurz(e) { if (!e) return ''; const name = e.company || [e.first_name, e.last_name].filter(Boolean).join(' ') || (e.free_text ? String(e.free_text).split('\n').map((z) => z.trim()).filter(Boolean)[0] : ''); return [name, e.city].filter(Boolean).join(', '); } + function entryName(e) { + return [e.salutation, e.first_name, e.last_name].filter(Boolean).join(' ') + || e.company || (e.free_text ? String(e.free_text).split('\n')[0].trim() : '') || '—'; + } async function laden(leise = false) { if (!leise) { loading.value = true; err.value = ''; } try { - const [o, j, m] = await Promise.all([ + const [o, j, m, t] = await Promise.all([ api.get('/items/orders', { params: { limit: -1, sort: 'date_created', filter: { production_status: { _in: AKTIV_STATUS } }, fields: 'id,order_number,order_name,production_status,needs_envelope,brief_gedruckt,kuvert_gedruckt,' - + 'format.key,format.name,shipping_day,date_created,entries_count,' + + 'format.key,format.name,shipping_day,shipping_type,versanddatum,date_created,entries_count,source,' + + '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' } }).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/format_templates', { params: { limit: -1, fields: 'id,key,name,type,default_format,search_default' } }).catch(() => ({ data: { data: [] } })), ]); orders.value = o.data?.data || []; jobs.value = j.data?.data || []; machines.value = m.data?.data || []; - // Erster/letzter Empfänger je Auftrag (für die Adressspalte). - const idx = {}; + templates.value = t.data?.data || []; + // Empfänger (erster/letzter + flache Suchliste) aus order_entries. + const idx = {}; const flach = []; const ids = orders.value.map((x) => x.id); try { const ent = ids.length ? await api.get('/items/order_entries', { params: { limit: -1, sort: 'letter_number', filter: { order: { _in: ids } }, - fields: 'order,first_name,last_name,company,city,letter_number,free_text', + fields: 'order,letter_number,salutation,first_name,last_name,company,street,house_no,zip,city,country,free_text', } }) : { data: { data: [] } }; for (const e of (ent.data?.data || [])) { - const k = String(e.order?.id ?? e.order); - if (!k || k === 'null') continue; + const k = String(e.order?.id ?? e.order); if (!k || k === 'null') continue; if (!idx[k]) idx[k] = { erster: e, letzter: e, anzahl: 1 }; else { idx[k].letzter = e; idx[k].anzahl += 1; } + const adr = []; + const person = [e.salutation, e.first_name, e.last_name].filter(Boolean).join(' '); + if (person) adr.push(person); else if (e.company) adr.push(e.company); + const strasse = [e.street, e.house_no].filter(Boolean).join(' '); if (strasse) adr.push(strasse); + const ortZeile = [e.zip, e.city].filter(Boolean).join(' '); if (ortZeile) adr.push(ortZeile); + if (e.country && !/^(de|d|deu|deutschland|germany)$/i.test(String(e.country).trim())) adr.push(e.country); + if (!adr.length && e.free_text) adr.push(...String(e.free_text).split('\n').map((z) => z.trim()).filter(Boolean)); + const hay = [e.salutation, e.first_name, e.last_name, e.company, e.street, e.house_no, e.zip, e.city, e.country, e.free_text].filter(Boolean).join(' ').toLowerCase(); + flach.push({ orderId: k, nr: Number(e.letter_number) || (idx[k].anzahl), name: entryName(e), adr, hay }); } } catch { /* Empfänger optional */ } entriesByOrder.value = idx; + sucheEintraege.value = flach; } catch (e) { if (!leise) err.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.'; } finally { if (!leise) loading.value = false; } @@ -109,15 +132,11 @@ const Board = { onMounted(() => { refreshTimer = setInterval(() => laden(true), 20000); }); onUnmounted(() => clearInterval(refreshTimer)); + 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; // nur wartende Jobs (nicht „im Druck") - const f = j.format || '?'; - if (!map[f]) map[f] = { n: 0, label: j.format_label || FORMAT_LABEL[f] || f }; - map[f].n += 1; - } + 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(() => { @@ -125,42 +144,75 @@ const Board = { let list = orders.value; if (q) list = list.filter((o) => { const e = entriesByOrder.value[String(o.id)]; - const hay = [o.order_number, o.order_name, kundeName(o), empfKurz(e?.erster), empfKurz(e?.letzter), o.format?.name].filter(Boolean).join(' ').toLowerCase(); - return hay.includes(q); + return [o.order_number, o.order_name, kundeName(o), empfKurz(e?.erster), empfKurz(e?.letzter), o.format?.name].filter(Boolean).join(' ').toLowerCase().includes(q); }); return list; }); function flash(t) { msg.value = t; setTimeout(() => { if (msg.value === t) msg.value = ''; }, 4000); } - async function setStatus(o, v) { - try { await api.patch('/items/orders/' + o.id, { production_status: v }); o.production_status = v; } - catch (e) { err.value = 'Status konnte nicht gesetzt werden.'; } - } + async function patchOrder(o, felder) { try { await api.patch('/items/orders/' + o.id, felder); Object.assign(o, felder); } catch (e) { err.value = 'Speichern fehlgeschlagen.'; } } + const setStatus = (o, v) => patchOrder(o, { production_status: v }); + const setVersand = (o, v) => patchOrder(o, { shipping_type: v }); + const setVersanddatum = (o, v) => patchOrder(o, { versanddatum: v || null }); 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 }); 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.'; } } + // ── Charge-Bildung (API-Aufträge gruppieren) ────────────────────────────── + function bildeChargen(list) { + const map = new Map(); + for (const o of list) { + const key = `${o.api_client?.id ?? 'x'}|${o.product?.name || '?'}|${o.shipping_day || ''}|${String(o.date_created || '').slice(0, 10)}`; + let c = map.get(key); + if (!c) { c = { key, kundeName: o.api_client?.name || 'API-Kunde', produkt: o.product?.name || '?', versandtag: o.shipping_day || '', orders: [] }; map.set(key, c); } + c.orders.push(o); + } + return [...map.values()]; + } + + // ── Reprint (Suche) ─────────────────────────────────────────────────────── + const tplSuchStandard = (typ) => templates.value.find((t) => t.type === typ && t.search_default) || null; + const tplByKey = (key, typ) => templates.value.find((t) => t.key === key && t.type === typ) || templates.value.find((t) => t.type === typ) || null; + const tplForFormat = (fmt, typ) => (fmt && templates.value.find((t) => t.type === typ && t.default_format && t.default_format === fmt)) || null; + async function schnelldruck(o, nr, typ) { + const machine = aktiveMaschinen.value[0]?.id || null; + if (!machine) { err.value = 'Keine aktive Maschine verfügbar.'; return; } + const tpl = typ === 'umschlag' + ? (tplSuchStandard('umschlag') || tplForFormat(o.envelope_format, 'umschlag') || tplByKey(ENVELOPE_TPL[o.envelope_format] || 'kuvert_a_v1', 'umschlag')) + : (tplSuchStandard('brief') || tplForFormat(o.format?.key, 'brief') || tplByKey(LETTER_TPL[o.format?.key] || 'a4_single', 'brief')); + if (!tpl?.id) { err.value = `Kein Standard-Template für ${typ === 'umschlag' ? 'Kuvert' : 'Schriftstück'} – bitte in den Einstellungen zuordnen.`; return; } + try { + const sections = {}; sections[typ === 'umschlag' ? 'umschlag' : 'brief'] = { enabled: true, template: tpl.id, 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(true); + } catch (e) { err.value = e?.response?.data?.error || e?.message || 'Einreihen fehlgeschlagen.'; } + } + const sucheTreffer = computed(() => { + const q = sucheQuery.value.trim().toLowerCase(); if (!q) return []; + const res = []; + for (const e of sucheEintraege.value) { + const o = orderById.value[e.orderId]; if (!o) continue; + if (!(e.hay.includes(q) || String(o.order_number).toLowerCase().includes(q))) continue; + res.push({ e, o }); if (res.length >= 150) break; + } + return res; + }); + // ── Render-Helfer ───────────────────────────────────────────────────────── const statusPill = (o) => { - const k = o.production_status || 'eingegangen'; - const m = STATUS_META[k] || STATUS_META.eingegangen; + const k = o.production_status || 'eingegangen'; const m = STATUS_META[k] || STATUS_META.eingegangen; return h('div', { style: `display:inline-flex;align-items:center;gap:5px;border-radius:8px;padding:5px 8px 5px 10px;background:${m.bg};color:${m.fg};font-weight:500;font-size:13px;` }, [ icon(m.icon), h('select', { style: `border:none;background:transparent;color:${m.fg};font:inherit;font-size:13px;font-weight:500;cursor:pointer;`, value: k, onChange: (e) => setStatus(o, e.target.value) }, Object.entries(STATUS_LABEL).map(([kk, v]) => h('option', { value: kk, selected: k === kk }, v))), ]); }; - const druckBadge = (ok, text) => h('span', { style: `display:inline-flex;align-items:center;gap:4px;font-size:12.5px;font-weight:500;padding:2px 8px;border-radius:999px;background:${ok ? 'rgba(46,125,50,.14)' : 'rgba(198,40,40,.12)'};color:${ok ? 'var(--success,#2e7d32)' : 'var(--danger,#c62828)'};` }, - [icon(ok ? 'check' : 'close'), text]); - + const druckBadge = (ok, text) => h('span', { style: `display:inline-flex;align-items:center;gap:4px;font-size:12.5px;font-weight:500;padding:2px 8px;border-radius:999px;background:${ok ? 'rgba(46,125,50,.14)' : 'rgba(198,40,40,.12)'};color:${ok ? 'var(--success,#2e7d32)' : 'var(--danger,#c62828)'};` }, [icon(ok ? 'check' : 'close'), text]); const th = (t, extra) => h('th', { style: `text-align:left;font-size:12px;font-weight:500;color:var(--foreground-subdued);text-transform:uppercase;letter-spacing:.03em;padding:10px 12px;border-bottom:1px solid var(--border-normal);white-space:nowrap;${extra || ''}` }, t); const td = (kids, extra) => h('td', { style: `padding:11px 12px;border-bottom:1px solid var(--border-subdued);font-size:14px;vertical-align:top;${extra || ''}` }, kids); - // Nächste konkrete Aufgabe für den Mitarbeiter (aus Status + Kuvert + Versandart). function aufgabe(o) { const einzeln = o.shipping_type === 'einzeln'; switch (o.production_status) { @@ -171,28 +223,27 @@ const Board = { default: return { t: STATUS_LABEL[o.production_status] || '', c: 'var(--foreground-subdued)', i: 'info' }; } } - async function setVersand(o, v) { - try { await api.patch('/items/orders/' + o.id, { shipping_type: v }); o.shipping_type = v; } - catch (e) { err.value = 'Versandart konnte nicht gesetzt werden.'; } - } function versandToggle(o) { const einzeln = o.shipping_type === 'einzeln'; return h('button', { type: 'button', title: 'Umschalten Sammel/Einzel', onClick: () => setVersand(o, einzeln ? 'sammel' : 'einzeln'), style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-page,#fff);border-radius:999px;padding:4px 11px;font:inherit;font-size:12.5px;font-weight:500;display:inline-flex;align-items:center;gap:5px;' }, [icon(einzeln ? 'person' : 'groups'), einzeln ? 'Einzeln' : 'Sammel']); } - function zeile(o) { + function datumFeld(o) { + return h('input', { type: 'date', value: o.versanddatum || '', title: 'Versanddatum (leer = schnellstmöglich)', + style: 'border:1px solid var(--border-normal);border-radius:8px;padding:6px 8px;font:inherit;font-size:13px;background:var(--background-page);color:var(--foreground-normal);' + + (o.versanddatum && o.versanddatum <= new Date().toISOString().slice(0, 10) ? 'border-color:var(--danger,#c62828);color:var(--danger,#c62828);' : ''), + onChange: (e) => setVersanddatum(o, e.target.value) }); + } + function zeile(o, indent) { const e = entriesByOrder.value[String(o.id)]; - const faellig = faelligBis(o.date_created, o.shipping_day); const ag = aufgabe(o); const eingeplant = eingeplantSet.value.has(String(o.id)); return h('tr', { key: 'o' + o.id }, [ - // Empfänger = Hauptinfo. - td(h('div', { style: 'line-height:1.45;' }, [ + td(h('div', { style: `line-height:1.45;${indent ? 'padding-left:18px;border-left:2px solid var(--border-subdued);' : ''}` }, [ h('div', { style: 'font-weight:500;' }, empfKurz(e?.erster) || '—'), (e && e.anzahl > 1) ? h('div', { style: 'font-size:12.5px;color:var(--foreground-subdued);' }, '… ' + empfKurz(e.letzter)) : null, ])), - // Auftrag = sekundär. td(h('div', { style: 'line-height:1.4;font-size:13px;' }, [ o.order_name ? h('div', { style: 'font-weight:500;color:var(--primary,#1e50a5);' }, o.order_name) : null, h('div', {}, kundeName(o) || `#${o.order_number}`), @@ -201,7 +252,7 @@ const Board = { td(h('span', {}, o.format?.name || FORMAT_LABEL[o.format?.key] || o.format?.key || '—')), td(h('span', { style: `display:inline-flex;align-items:center;gap:5px;font-weight:500;color:${ag.c};` }, [icon(ag.i), ag.t])), td(versandToggle(o)), - td(h('span', { style: faellig && faellig.getTime() < Date.now() ? 'color:var(--danger,#c62828);font-weight:500;' : '' }, dmY(faellig))), + td(datumFeld(o)), td(h('div', { style: 'display:flex;flex-direction:column;gap:4px;align-items:flex-start;' }, [ druckBadge(!!o.brief_gedruckt, 'Brief'), o.needs_envelope ? druckBadge(!!o.kuvert_gedruckt, 'Kuvert') : null, @@ -210,60 +261,145 @@ const Board = { td(statusPill(o)), ]); } + function chargeHeaderRow(c) { + const auf = !!expandedCharges[c.key]; + const sumS = c.orders.reduce((a, o) => a + (Number(o.entries_count) || 0), 0); + const sumK = c.orders.reduce((a, o) => a + (o.needs_envelope ? (Number(o.entries_count) || 0) : 0), 0); + const fmts = [...new Set(c.orders.map((o) => o.format?.name || o.format?.key).filter(Boolean))]; + const versands = [...new Set(c.orders.map((o) => o.shipping_type === 'einzeln' ? 'Einzel' : 'Sammel'))]; + return h('tr', { key: 'c' + c.key, style: 'background:var(--background-subdued,#f6f8fb);cursor:pointer;', onClick: () => { expandedCharges[c.key] = !auf; } }, [ + h('td', { colspan: 8, style: 'padding:10px 12px;border-bottom:1px solid var(--border-normal);' }, [ + h('div', { style: 'display:flex;align-items:center;gap:10px;flex-wrap:wrap;' }, [ + icon(auf ? 'expand_more' : 'chevron_right'), + h('span', { style: 'font-weight:500;font-size:15px;display:inline-flex;align-items:center;gap:6px;' }, [icon('inbox'), `${c.kundeName} · ${c.produkt}`]), + h('span', { style: 'font-size:13px;color:var(--foreground-subdued);' }, `${c.orders.length} Aufträge · ${sumS} Schriftstücke · ${sumK} Kuverts · ${fmts.join(', ') || '—'} · ${versands.length === 1 ? versands[0] : versands.join('/')}`), + ]), + ]), + ]); + } + // Zeilen einer Status-Gruppe: API gruppiert (Charge-Kopf + bei Aufklappen Mitglieder), Rest einzeln. + function gruppenZeilen(list) { + const api = list.filter((o) => o.source === 'api' || o.api_client); + const rest = list.filter((o) => !(o.source === 'api' || o.api_client)); + const rows = []; + for (const c of bildeChargen(api)) { rows.push(chargeHeaderRow(c)); if (expandedCharges[c.key]) for (const o of c.orders) rows.push(zeile(o, true)); } + for (const o of rest) rows.push(zeile(o)); + return rows; + } + const kopfRow = () => h('thead', {}, h('tr', {}, [th('Empfänger (erster · letzter)'), th('Auftrag'), th('Format'), th('Aufgabe'), th('Versand'), th('Versanddatum'), th('Stand'), th('Status')])); + const tabelle = (list) => h('div', { style: 'border:1px solid var(--border-normal);border-radius:12px;overflow:auto;background:var(--background-page,#fff);' }, + [h('table', { style: 'width:100%;border-collapse:collapse;' }, [kopfRow(), h('tbody', {}, gruppenZeilen(list))])]); + const sektionKopf = (label, m, n) => h('div', { style: 'display:flex;align-items:center;gap:10px;margin-bottom:10px;' }, [ + h('span', { style: `display:inline-flex;align-items:center;gap:6px;font-size:16px;font-weight:500;color:${m.fg};` }, [icon(m.icon), label]), + h('span', { style: 'font-size:13px;color:var(--foreground-subdued);' }, `${n} Auftrag/Aufträge`), + ]); function maschinenPanel() { - const qf = queuedByFormat.value; - const formate = Object.keys(qf); + const qf = queuedByFormat.value; const formate = Object.keys(qf); return h('div', { style: 'border:1px solid var(--border-normal);border-radius:12px;background:var(--background-page,#fff);margin-bottom:18px;overflow:hidden;' }, [ h('div', { style: 'display:flex;align-items:center;gap:6px;font-size:13px;font-weight:500;color:var(--foreground-subdued);padding:10px 14px;border-bottom:1px solid var(--border-subdued);background:var(--background-subdued,#f6f8fb);' }, [icon('print'), 'Maschinen · Format einlegen und starten']), h('div', { style: 'padding:14px;display:flex;flex-direction:column;gap:14px;' }, - machines.value.length ? machines.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, m.current_format ? h('span', { style: 'color:var(--foreground-subdued);font-weight:400;font-size:13px;' }, ` · eingelegt: ${FORMAT_LABEL[m.current_format] || m.current_format}`) : h('span', { style: 'color:var(--foreground-subdued);font-weight:400;font-size:13px;' }, ' · kein Format eingelegt')]), - ...(formate.length ? formate.map((f) => h('button', { - type: 'button', onClick: () => formatStarten(m, f), + 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:var(--foreground-subdued);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:var(--primary,#1e50a5);background:var(--primary,#1e50a5);color:#fff;' : 'border-color:var(--border-normal);background:var(--background-page,#fff);color:var(--foreground-normal);'}`, }, [icon('play_arrow'), `${qf[f].label} (${qf[f].n})`])) : [h('span', { style: 'color:var(--foreground-subdued);font-size:13px;' }, 'Keine Jobs in der Warteschlange.')]), - ])) - : [h('span', { style: 'color:var(--foreground-subdued);font-size:13px;' }, 'Keine aktive Maschine.')]), + ])) : [h('span', { style: 'color:var(--foreground-subdued);font-size:13px;' }, 'Keine aktive Maschine.')]), ]); } - return () => { - const inhalt = h('div', { style: 'padding:24px 28px;' }, [ - h('div', { style: 'display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:18px;' }, [ - h('div', { style: 'position:relative;flex:1;min-width:240px;' }, [ - h('input', { type: 'search', placeholder: 'Auftrag, Kunde, Empfänger, Format …', - style: 'width:100%;box-sizing:border-box;border:1px solid var(--border-normal);border-radius:10px;padding:11px 14px;font:inherit;font-size:15px;background:var(--background-page);color:var(--foreground-normal);', - onInput: (e) => sucheSetzen(e.target.value) }), + function uebersichtInhalt() { + if (loading.value) return h('div', { style: 'color:var(--foreground-subdued);' }, 'Lädt …'); + const GRUPPEN = [['im_druck', 'Im Druck'], ['kuvertieren', 'Kuvertierung'], ['eingegangen', 'Eingegangen']]; + const sek = []; + for (const [st, label] of GRUPPEN) { + const list = gefiltert.value.filter((o) => (o.production_status || 'eingegangen') === st); + if (!list.length) continue; + sek.push(h('div', { key: 'sec' + st, style: 'margin-bottom:22px;' }, [sektionKopf(label, STATUS_META[st], list.length), tabelle(list)])); + } + // Versandfertig: unterteilt in „schnellstmöglich" (ohne Datum) und „mit Datum". + const vf = gefiltert.value.filter((o) => o.production_status === 'versandfertig'); + if (vf.length) { + const ohne = vf.filter((o) => !o.versanddatum); + const mit = vf.filter((o) => o.versanddatum).sort((a, b) => String(a.versanddatum).localeCompare(String(b.versanddatum))); + sek.push(h('div', { key: 'secvf', style: 'margin-bottom:22px;' }, [ + sektionKopf('Versandfertig', STATUS_META.versandfertig, vf.length), + ohne.length ? h('div', { style: 'margin-bottom:12px;' }, [h('div', { style: 'font-size:13px;font-weight:500;color:var(--foreground-subdued);margin-bottom:6px;' }, 'Schnellstmöglich (' + ohne.length + ')'), tabelle(ohne)]) : null, + mit.length ? h('div', {}, [h('div', { style: 'font-size:13px;font-weight:500;color:var(--foreground-subdued);margin-bottom:6px;' }, 'Mit Versanddatum (' + mit.length + ')'), tabelle(mit)]) : null, + ].filter(Boolean))); + } + return sek.length ? h('div', {}, [maschinenPanel(), ...sek]) : h('div', {}, [maschinenPanel(), h('div', { style: 'padding:28px;text-align:center;color:var(--foreground-subdued);border:1px solid var(--border-normal);border-radius:12px;background:var(--background-page,#fff);' }, 'Keine offenen Aufträge.')]); + } + + function sucheInhalt() { + const TYPEN = [['brief', 'Schriftstück', 'description'], ['umschlag', 'Kuvert', 'mail'], ['objekt', 'Druckobjekt', 'attach_file']]; + const typWahl = h('div', {}, [ + h('div', { style: 'font-size:14px;font-weight:500;margin-bottom:8px;' }, '1. Was möchten Sie?'), + h('div', { style: 'display:flex;gap:10px;flex-wrap:wrap;' }, TYPEN.map(([k, l, ic]) => { + const aktiv = sucheTyp.value === k; + return h('button', { type: 'button', onClick: () => { sucheTyp.value = k; }, + style: `cursor:pointer;border-radius:10px;padding:13px 20px;font:inherit;font-size:16px;font-weight:500;display:inline-flex;align-items:center;gap:8px;border:2px solid;${aktiv ? 'background:var(--primary,#1e50a5);color:#fff;border-color:var(--primary,#1e50a5);' : 'background:var(--background-page,#fff);color:var(--foreground-normal);border-color:var(--border-normal);'}` }, [icon(ic), l]); + })), + ]); + const feld = h('div', { style: 'margin-top:18px;' }, [ + h('div', { style: 'font-size:14px;font-weight:500;margin-bottom:8px;' }, '2. Empfänger suchen'), + h('input', { type: 'search', placeholder: 'Name, Straße, Ort oder Auftragsnummer …', + style: 'width:100%;box-sizing:border-box;background:var(--background-page);color:var(--foreground-normal);border:2px solid var(--border-normal);border-radius:12px;font:inherit;font-size:22px;padding:16px 20px;outline:none;', + onInput: (e) => sucheSetzen(e.target.value) }), + ]); + const rahmen = (kinder) => h('div', {}, [ + h('div', { style: 'background:var(--background-page,#fff);border:1px solid var(--border-normal);border-radius:12px;padding:20px 22px;margin-bottom:16px;' }, [typWahl, feld]), + kinder, + ]); + if (!sucheQuery.value.trim()) return rahmen(h('div', { style: 'color:var(--foreground-subdued);' }, 'Suchbegriff eingeben – durchsucht die offenen Aufträge.')); + const treffer = sucheTreffer.value; + if (!treffer.length) return rahmen(h('div', { style: 'color:var(--foreground-subdued);' }, 'Keine Treffer.')); + const trefferZeile = ({ e, o }) => { + const gesamt = entriesByOrder.value[String(o.id)]?.anzahl || 1; + const adrLines = (e.adr && e.adr.length) ? e.adr : [e.name || '—']; + const kannKuvert = !!(o.needs_envelope && o.envelope_labeling && o.envelope_labeling !== 'keine'); + const aktion = sucheTyp.value === 'objekt' + ? h('a', { href: `/admin/content/orders/${o.id}`, target: '_blank', rel: 'noopener', style: 'display:inline-flex;align-items:center;gap:6px;background:var(--primary,#1e50a5);color:#fff;border-radius:8px;padding:9px 16px;font-size:14px;font-weight:500;text-decoration:none;' }, [icon('open_in_new'), 'Auftrag öffnen']) + : (sucheTyp.value === 'umschlag' && !kannKuvert) + ? h('span', { style: 'color:var(--foreground-subdued);font-size:14px;' }, 'Kein Kuvert') + : h('button', { type: 'button', onClick: () => schnelldruck(o, e.nr, sucheTyp.value), + style: 'cursor:pointer;display:inline-flex;align-items:center;gap:6px;background:var(--primary,#1e50a5);color:#fff;border:none;border-radius:8px;padding:10px 18px;font:inherit;font-size:15px;font-weight:500;' }, [icon('print'), 'In Warteschlange']); + return h('div', { key: `s${o.id}-${e.nr}`, style: 'border:1px solid var(--border-normal);border-radius:12px;background:var(--background-page,#fff);padding:14px 16px;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;' }, [ + h('div', { style: 'min-width:0;' }, [ + ...adrLines.map((z, i) => h('div', { style: i === 0 ? 'font-weight:500;font-size:16px;' : 'font-size:14px;color:var(--foreground-normal);' }, z)), + h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;margin-top:5px;align-items:center;font-size:13px;color:var(--foreground-subdued);' }, [ + h('span', {}, `Auftrag #${o.order_number}`), + gesamt > 1 ? h('span', { style: 'background:var(--background-subdued);border-radius:999px;padding:2px 9px;' }, `Nr. ${e.nr} von ${gesamt}`) : null, + h('span', { style: `display:inline-flex;align-items:center;gap:4px;color:${(STATUS_META[o.production_status] || STATUS_META.eingegangen).fg};` }, [icon((STATUS_META[o.production_status] || STATUS_META.eingegangen).icon), STATUS_LABEL[o.production_status] || '']), + ]), ]), + aktion, + ]); + }; + return rahmen(h('div', {}, treffer.map(trefferZeile))); + } + + const navItem = (key, label, ic) => h('button', { type: 'button', onClick: () => { clearTimeout(sucheTimer); sucheQuery.value = ''; view.value = key; }, + style: 'display:flex;align-items:center;gap:10px;width:100%;text-align:left;cursor:pointer;border:none;border-radius:8px;padding:11px 13px;margin-bottom:3px;font:inherit;font-size:14px;' + + (view.value === key ? 'background:var(--primary,#1e50a5);color:#fff;font-weight:500;' : 'background:transparent;color:var(--foreground-normal);'), + }, [icon(ic), label]); + + return () => { + const navi = h('div', { style: 'padding:14px 12px;' }, [ + navItem('uebersicht', 'Übersicht', 'table_view'), + navItem('suche', 'Suche', 'search'), + ]); + const inhalt = h('div', { style: 'padding:24px 28px;' }, [ + view.value === 'uebersicht' ? h('div', { style: 'display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:18px;' }, [ + h('input', { type: 'search', placeholder: 'Auftrag, Kunde, Empfänger, Format …', style: 'flex:1;min-width:240px;box-sizing:border-box;border:1px solid var(--border-normal);border-radius:10px;padding:11px 14px;font:inherit;font-size:15px;background:var(--background-page);color:var(--foreground-normal);', onInput: (e) => sucheSetzen(e.target.value) }), h('button', { type: 'button', onClick: () => laden(), style: 'cursor:pointer;border:1px solid var(--border-normal);background:var(--background-page,#fff);border-radius:8px;padding:9px 16px;font:inherit;font-size:14px;font-weight:500;display:inline-flex;align-items:center;gap:6px;' }, [icon('refresh'), 'Aktualisieren']), - ]), + ]) : null, msg.value ? h('div', { style: 'background:var(--success-25,rgba(46,125,50,.14));color:var(--success,#2e7d32);padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:14px;' }, msg.value) : null, err.value ? h('div', { style: 'background:var(--danger-25,rgba(198,40,40,.12));color:var(--danger,#c62828);padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:14px;' }, err.value) : null, - maschinenPanel(), - loading.value - ? h('div', { style: 'color:var(--foreground-subdued);' }, 'Lädt …') - : (() => { - const GRUPPEN = [['im_druck', 'Im Druck'], ['kuvertieren', 'Kuvertierung'], ['versandfertig', 'Versandfertig'], ['eingegangen', 'Eingegangen']]; - const kopf = () => h('thead', {}, h('tr', {}, [th('Empfänger (erster · letzter)'), th('Auftrag'), th('Format'), th('Aufgabe'), th('Versand'), th('Fällig'), th('Stand'), th('Status')])); - const sek = GRUPPEN.map(([st, label]) => { - const list = gefiltert.value.filter((o) => (o.production_status || 'eingegangen') === st); - if (!list.length) return null; - const m = STATUS_META[st]; - return h('div', { key: 'sec' + st, style: 'margin-bottom:22px;' }, [ - h('div', { style: 'display:flex;align-items:center;gap:10px;margin-bottom:10px;' }, [ - h('span', { style: `display:inline-flex;align-items:center;gap:6px;font-size:16px;font-weight:500;color:${m.fg};` }, [icon(m.icon), label]), - h('span', { style: 'font-size:13px;color:var(--foreground-subdued);' }, `${list.length} Auftrag/Aufträge`), - ]), - h('div', { style: 'border:1px solid var(--border-normal);border-radius:12px;overflow:auto;background:var(--background-page,#fff);' }, - [h('table', { style: 'width:100%;border-collapse:collapse;' }, [kopf(), h('tbody', {}, list.map(zeile))])]), - ]); - }).filter(Boolean); - return sek.length ? h('div', {}, sek) : h('div', { style: 'padding:28px;text-align:center;color:var(--foreground-subdued);border:1px solid var(--border-normal);border-radius:12px;background:var(--background-page,#fff);' }, 'Keine offenen Aufträge.'); - })(), + view.value === 'suche' ? sucheInhalt() : uebersichtInhalt(), ]); - return h(resolveComponent('private-view'), { title: 'Übersicht' }, { default: () => inhalt }); + return h(resolveComponent('private-view'), { title: view.value === 'suche' ? 'Suche' : 'Übersicht' }, { navigation: () => navi, default: () => inhalt }); }; }, };