Files
skrift-directus/extensions/directus-extension-skrift-uebersicht/dist/index.js
s4luorth 5196e30343 Mitarbeiter-Uebersicht: Empfaenger ausklappbar + Kommentar; Auftragsnr nur 1x; Board: Versanddatum setzbar
Uebersicht:
- Empfaenger-Zelle: erster Empfaenger + Umschalter "alle N Empfaenger" -> klappt eine
  Detailzeile mit der VOLLEN Empfaengerliste aus (kein gesondertes erster/letzter mehr)
- gleiche Detailzeile hat ein Kommentarfeld je Auftrag (internal_note)
- Auftragsnummer wird nur noch EINMAL angezeigt (keine Doppelung mehr)
- volle Empfaengerliste + internal_note werden mitgeladen

Produktionsboard:
- Versanddatum je Auftrag unter "Weiteres -> Aufgaben umstellen" setzbar (Datum, leer = schnellstmoeglich).
  Mitarbeiter-Uebersicht bleibt bei Versandart/-datum weiterhin nur lesbar (wie gewuenscht).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-01 18:03:02 +02:00

589 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Skrift – Modul „Übersicht" (Mitarbeiter)
* ===========================================================================
* 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.
*/
import { ref, reactive, computed, onMounted, onUnmounted, h, resolveComponent } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const STATUS_LABEL = {
eingegangen: 'Eingegangen', im_druck: 'Im Druck', kuvertieren: 'Kuvertierung',
versandfertig: 'Versandfertig', versendet: 'Versendet', storniert: 'Storniert',
};
const STATUS_META = {
eingegangen: { bg: '#eef2f8', fg: '#566070', bd: '#d2dae6', icon: 'inbox' },
im_druck: { bg: '#e3ecfa', fg: '#1E50A5', bd: '#bcd0ef', icon: 'print' },
kuvertieren: { bg: '#ece4fb', fg: '#5b32b8', bd: '#d3c4f2', icon: 'mail' },
versandfertig: { bg: '#fbeccb', fg: '#8a5a00', bd: '#f0d79a', icon: 'inventory_2' },
versendet: { bg: '#e3f4e6', fg: '#1b6b2e', bd: '#bfe2c6', icon: 'local_shipping' },
storniert: { bg: '#fbe3e0', fg: '#c0392b', bd: '#f3c6bf', icon: 'cancel' },
};
const AKTIV_STATUS = ['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) {
const wd = WOCHENTAG[String(shippingDay || '').toLowerCase()];
if (wd === undefined || !dateCreated) return null;
const d = new Date(dateCreated); if (isNaN(d)) return null;
d.setHours(0, 0, 0, 0);
const tag = d.getDay();
const montag = new Date(d); montag.setDate(d.getDate() + (tag === 0 ? -6 : 1 - tag));
const ziel = new Date(montag); ziel.setDate(montag.getDate() + 14 + (wd === 0 ? 6 : wd - 1));
return ziel;
}
const dmY = (dt) => dt ? `${String(dt.getDate()).padStart(2, '0')}.${String(dt.getMonth() + 1).padStart(2, '0')}.${dt.getFullYear()}` : '—';
const Board = {
setup() {
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 templates = ref([]);
const entriesByOrder = ref({}); // { orderId: {erster, letzter, anzahl} }
const sucheEintraege = ref([]); // flache Empfängerliste für die Suche
const expandedCharges = reactive({});
const expandedEmpf = reactive({}); // je Auftrag: volle Empfängerliste + Kommentar ausklappen
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; }, 220); };
const sucheTyp = ref('brief');
const aktiveMaschinen = computed(() => machines.value.filter((m) => m.active !== false));
// Mitarbeiter-Ansicht liest keine directus_users-Felder (Rechte) → Name aus
// API-Kunde; manuelle Aufträge zeigen sonst Auftragsname/-nummer.
function kundeName(o) { return o.api_client?.name || ''; }
// „Seite 1" o. Ä. = Seiten eines mehrseitigen Briefs, kein Empfänger.
const istSeitenmarker = (z) => /^seite\s*\d+$/i.test(String(z || '').trim());
function freitextName(ft) {
const z = ft ? String(ft).split('\n').map((s) => s.trim()).filter(Boolean)[0] : '';
return istSeitenmarker(z) ? '' : z;
}
function empfKurz(e) {
if (!e) return '';
const name = [e.first_name, e.last_name].filter(Boolean).join(' ') || freitextName(e.free_text);
return [name, e.city].filter(Boolean).join(', ');
}
function entryName(e) {
return [e.salutation, e.first_name, e.last_name].filter(Boolean).join(' ')
|| freitextName(e.free_text) || '—';
}
async function laden(leise = false) {
if (!leise) { loading.value = true; err.value = ''; }
try {
const [o, j, m, t] = await Promise.all([
(async () => {
// Sichere Basisfelder (alle auf orders/formats/products – von der
// Mitarbeiter-Rolle lesbar). api_client (API-Kundenname) und kuvertieren
// sind optional: fehlende Rechte/Felder dürfen die Ansicht NICHT blockieren.
const basis = 'id,order_number,order_name,production_status,needs_envelope,brief_gedruckt,kuvert_gedruckt,frankiert,internal_note,'
+ 'format.key,format.name,shipping_day,shipping_type,versanddatum,date_created,entries_count,source,'
+ 'envelope_labeling,envelope_format,product.name';
const mitClient = basis + ',api_client.id,api_client.name';
const p = (fields) => ({ limit: -1, sort: 'date_created', filter: { production_status: { _in: AKTIV_STATUS } }, fields });
try { return await api.get('/items/orders', { params: p(mitClient + ',kuvertieren') }); }
catch {
try { return await api.get('/items/orders', { params: p(mitClient) }); }
catch { return await api.get('/items/orders', { params: p(basis) }); }
}
})(),
(async () => {
try { return await 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 { try { return await api.get('/items/jobs', { params: { limit: -1, filter: { status: { _in: ['queued', 'printing'] } }, fields: 'id,type,status,format,format_label,machine.id,order.id' } }); } catch { return { data: { data: [] } }; } }
})(),
(async () => {
try { return await api.get('/items/machines', { params: { limit: -1, filter: { active: { _eq: true } }, fields: 'id,name,current_format,pending_format,status,active' } }); }
catch { try { return await api.get('/items/machines', { params: { limit: -1, filter: { active: { _eq: true } }, fields: 'id,name,current_format,status,active' } }); } catch { return { 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;
orders.value = o.data?.data || [];
jobs.value = j.data?.data || [];
machines.value = m.data?.data || [];
templates.value = t.data?.data || [];
// Empfänger (erster/letzter) separat & schlank laden – nur beim vollen
// Laden oder wenn sich die Auftragsmenge geändert hat (sonst unnötige
// DB-Last beim 20-Sekunden-Refresh).
if (!leise || orders.value.length !== vorherAnzahl) await ladeEmpfaenger();
} catch (e) {
if (!leise) err.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.';
} finally { if (!leise) loading.value = false; }
}
// Schlanke Empfänger-Indexierung (nur Name/Ort) für erster/letzter + Filter.
async function ladeEmpfaenger() {
try {
const ent = await api.get('/items/order_entries', { params: {
limit: -1, sort: ['order', 'letter_number'],
filter: { order: { production_status: { _in: AKTIV_STATUS } } },
fields: 'order,letter_number,first_name,last_name,city,free_text',
} });
const idx = {};
for (const e of (ent.data?.data || [])) {
const k = String(e.order?.id ?? e.order); if (!k || k === 'null') continue;
if (!idx[k]) idx[k] = { erster: e, letzter: e, anzahl: 1, alle: [e] };
else { idx[k].letzter = e; idx[k].anzahl += 1; if (idx[k].alle.length < 2000) idx[k].alle.push(e); }
}
entriesByOrder.value = idx;
} catch { /* Empfänger optional */ }
}
// Volle Empfängerliste (mit Adresse) für die Suche – erst bei Bedarf.
let sucheGeladen = false;
async function ladeSuche() {
if (sucheGeladen) return;
try {
const ent = await api.get('/items/order_entries', { params: {
limit: -1, sort: ['order', 'letter_number'],
filter: { order: { production_status: { _in: AKTIV_STATUS } } },
fields: 'order,letter_number,salutation,first_name,last_name,street,house_no,zip,city,country,free_text',
} });
const flach = []; const cnt = {};
for (const e of (ent.data?.data || [])) {
const k = String(e.order?.id ?? e.order); if (!k || k === 'null') continue;
cnt[k] = (cnt[k] || 0) + 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) || cnt[k], name: entryName(e), adr, hay });
}
sucheEintraege.value = flach; sucheGeladen = true;
} catch { /* optional */ }
}
onMounted(laden);
let refreshTimer = null;
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 gefiltert = computed(() => {
const q = sucheQuery.value.trim().toLowerCase();
let list = orders.value;
if (q) list = list.filter((o) => {
const e = entriesByOrder.value[String(o.id)];
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 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 });
const setNote = (o, v) => patchOrder(o, { internal_note: v });
// Status für einen ganzen Sammelauftrag (alle Teil-Aufträge) auf einmal.
async function setChargeStatus(c, v) {
try {
await api.patch('/items/orders', { keys: c.orders.map((o) => o.id), data: { production_status: v } });
for (const o of c.orders) o.production_status = v;
await laden(true);
} catch (e) { err.value = 'Speichern fehlgeschlagen.'; }
}
async function formatStarten(machine, fmt) {
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) ──────────────────────────────
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 statusPillCtrl = (k0, onCh) => {
const k = k0 || '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};border:1px solid ${m.bd || '#d2dae6'};font-weight:600;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) => onCh(e.target.value) },
Object.entries(STATUS_LABEL).map(([kk, v]) => h('option', { value: kk, selected: k === kk }, v))),
]);
};
const statusPill = (o) => statusPillCtrl(o.production_status, (v) => setStatus(o, 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 ? '#1b6b2e' : '#c0392b'};` }, [icon(ok ? 'check' : 'close'), text]);
const th = (t, extra) => h('th', { style: `text-align:left;font-size:12px;font-weight:500;color:#566070;text-transform:uppercase;letter-spacing:.03em;padding:10px 12px;border-bottom:1px solid #b4c0d2;white-space:nowrap;${extra || ''}` }, t);
const td = (kids, extra) => h('td', { style: `padding:11px 12px;border-bottom:1px solid #d2dae6;font-size:14px;vertical-align:top;${extra || ''}` }, kids);
function aufgabe(o) {
const einzeln = o.shipping_type === 'einzeln';
switch (o.production_status) {
case 'eingegangen': return { t: 'Wartet auf Druck', c: '#566070', i: 'hourglass_empty' };
case 'im_druck': return { t: 'Im Druck', c: '#1E50A5', i: 'print' };
case 'kuvertieren': return { t: (o.needs_envelope ? 'Kuvertieren' : 'Vorbereiten') + ' + frankieren', c: '#6a3fd0', i: 'mail' };
case 'versandfertig': return { t: einzeln ? 'Einzelversand' : 'Sammelversand', c: '#8a6d00', i: 'local_shipping' };
default: return { t: STATUS_LABEL[o.production_status] || '', c: '#566070', i: 'info' };
}
}
// Nur-Lese-Anzeige für Mitarbeiter: Versandart darf hier nicht geändert werden.
function versandToggle(o) {
const einzeln = o.shipping_type === 'einzeln';
return h('span', { title: 'Versandart (nur Ansicht)',
style: 'border:1px solid #d2dae6;background:#eef2f8;border-radius:999px;padding:4px 11px;font-size:12.5px;font-weight:500;display:inline-flex;align-items:center;gap:5px;color:#1b2430;' },
[icon(einzeln ? 'person' : 'groups'), einzeln ? 'Einzeln' : 'Sammel']);
}
// Nur-Lese-Anzeige für Mitarbeiter: Versanddatum darf hier nicht geändert werden.
function datumFeld(o) {
const faellig = o.versanddatum && o.versanddatum <= new Date().toISOString().slice(0, 10);
const txt = o.versanddatum ? o.versanddatum : 'schnellstmöglich';
return h('span', { title: 'Versanddatum (nur Ansicht)',
style: 'display:inline-flex;align-items:center;gap:5px;font-size:13px;color:' + (faellig ? '#c0392b' : '#1b2430') + ';font-weight:' + (faellig ? '600' : '400') + ';' },
[icon('event'), txt]);
}
// Ein Ablauf-Schritt: nicht nötig (✗, grau) · erledigt (✓, grün) · offen (amber).
function stepPill(labelTxt, benoetigt, erledigt) {
let bg, fg, bd, ic, zustand;
if (!benoetigt) { bg = '#eef2f8'; fg = '#566070'; bd = '#d2dae6'; ic = 'close'; zustand = 'nicht nötig'; }
else if (erledigt) { bg = '#e3f4e6'; fg = '#1b6b2e'; bd = '#bfe2c6'; ic = 'check'; zustand = 'erledigt'; }
else { bg = '#fbeccb'; fg = '#8a5a00'; bd = '#f0d79a'; ic = 'radio_button_unchecked'; zustand = 'offen'; }
return h('span', { title: `${labelTxt}: ${zustand}`, style: `display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:600;padding:3px 9px;border-radius:999px;background:${bg};color:${fg};border:1px solid ${bd};white-space:nowrap;` }, [icon(ic), labelTxt]);
}
function ablaufStrip(o) {
const fertig = ['versandfertig', 'versendet'].includes(o.production_status);
const versendet = o.production_status === 'versendet';
const frankiert = (typeof o.frankiert === 'boolean') ? o.frankiert : (o.shipping_type !== 'sammel');
const kuvertieren = !!o.needs_envelope && o.kuvertieren !== false; // nur Kuvert ohne Blatt → kein Kuvertieren
const steps = [
['Druck Schriftstück', true, !!o.brief_gedruckt],
['Druck Kuvert', !!o.needs_envelope, !!o.kuvert_gedruckt],
['Kuvertieren', kuvertieren, fertig],
['Frankieren', !!frankiert, fertig],
['Versand', true, versendet],
];
return h('div', { style: 'display:flex;gap:5px;flex-wrap:wrap;' }, steps.map(([l, b, e]) => stepPill(l, b, e)));
}
// Gibt ein Zeilen-ARRAY zurück: Hauptzeile + (bei Ausklappen) Detailzeile mit
// voller Empfängerliste und Kommentarfeld.
function zeile(o, indent) {
const id = String(o.id);
const e = entriesByOrder.value[id];
const auf = !!expandedEmpf[id];
const anzahl = e ? e.anzahl : (o.entries_count || 0);
// Empfänger: erster + Umschalter „alle anzeigen" (kein gesondertes letzter mehr).
const empfZelle = h('div', { style: `line-height:1.45;${indent ? 'padding-left:18px;border-left:2px solid #d2dae6;' : ''}` }, [
h('div', { style: 'font-weight:500;' }, empfKurz(e?.erster) || '—'),
(anzahl > 1) ? h('button', { type: 'button', onClick: () => { expandedEmpf[id] = !auf; },
style: 'cursor:pointer;border:none;background:transparent;padding:2px 0;margin-top:2px;color:#1E50A5;font:inherit;font-size:12.5px;font-weight:600;display:inline-flex;align-items:center;gap:3px;' },
[icon(auf ? 'expand_less' : 'expand_more'), auf ? 'Empfänger ausblenden' : `alle ${anzahl} Empfänger`]) : null,
].filter(Boolean));
// Auftrag: Name/Kunde + Nummer NUR EINMAL.
const name = o.order_name || kundeName(o);
const auftragZelle = h('div', { style: 'line-height:1.4;font-size:13px;' }, [
name ? h('div', { style: 'font-weight:500;' + (o.order_name ? 'color:#1E50A5;' : '') }, name) : null,
h('div', { style: 'color:#566070;' }, `#${o.order_number} · ${o.entries_count || 0} Empf.`),
].filter(Boolean));
const haupt = h('tr', { key: 'o' + o.id }, [
td(empfZelle),
td(auftragZelle),
td(h('span', {}, o.format?.name || FORMAT_LABEL[o.format?.key] || o.format?.key || '—')),
td(ablaufStrip(o)),
td(versandToggle(o)),
td(datumFeld(o)),
td(statusPill(o)),
]);
if (!auf) return [haupt];
const liste = (e && e.alle) ? e.alle : [];
const detail = h('tr', { key: 'd' + o.id }, [
h('td', { colspan: 7, style: `padding:0 12px 14px;border-bottom:1px solid #d2dae6;${indent ? 'padding-left:30px;' : ''}` }, [
h('div', { style: 'display:grid;grid-template-columns:1fr 1fr;gap:16px;background:#f7f9fc;border:1px solid #d2dae6;border-radius:10px;padding:12px 14px;' }, [
h('div', {}, [
h('div', { style: 'font-size:11.5px;font-weight:600;color:#566070;text-transform:uppercase;letter-spacing:.03em;margin-bottom:6px;' }, `Empfänger (${liste.length || anzahl})`),
h('div', { style: 'max-height:220px;overflow:auto;display:flex;flex-direction:column;gap:3px;' },
liste.length ? liste.map((x, i) => h('div', { key: i, style: 'font-size:13px;display:flex;gap:8px;' }, [
h('span', { style: 'color:#566070;min-width:28px;' }, (i + 1) + '.'),
h('span', {}, empfKurz(x) || '—'),
])) : [h('span', { style: 'color:#566070;font-size:13px;' }, 'Keine Empfängerdaten geladen.')]),
]),
h('div', {}, [
h('div', { style: 'font-size:11.5px;font-weight:600;color:#566070;text-transform:uppercase;letter-spacing:.03em;margin-bottom:6px;' }, 'Kommentar'),
h('textarea', { rows: 4, placeholder: 'Kommentar zum Auftrag …',
style: 'width:100%;box-sizing:border-box;border:1px solid #b4c0d2;border-radius:8px;padding:9px 11px;font:inherit;font-size:13px;background:#ffffff;color:#1b2430;resize:vertical;',
value: o.internal_note || '', onChange: (ev) => setNote(o, ev.target.value) }),
]),
]),
]),
]);
return [haupt, detail];
}
// Aggregierter Ablauf über alle Aufträge einer Charge.
function ablaufStripAgg(orders) {
const needEnv = orders.filter((o) => o.needs_envelope);
const needKuv = orders.filter((o) => o.needs_envelope && o.kuvertieren !== false);
const needFrank = orders.filter((o) => (typeof o.frankiert === 'boolean') ? o.frankiert : (o.shipping_type !== 'sammel'));
const alle = (arr, fn) => arr.length > 0 && arr.every(fn);
const fertigOrder = (o) => ['versandfertig', 'versendet'].includes(o.production_status);
const steps = [
['Druck Schriftstück', true, alle(orders, (o) => !!o.brief_gedruckt)],
['Druck Kuvert', needEnv.length > 0, alle(needEnv, (o) => !!o.kuvert_gedruckt)],
['Kuvertieren', needKuv.length > 0, alle(needKuv, fertigOrder)],
['Frankieren', needFrank.length > 0, alle(needFrank, fertigOrder)],
['Versand', true, alle(orders, (o) => o.production_status === 'versendet')],
];
return h('div', { style: 'display:flex;gap:5px;flex-wrap:wrap;' }, steps.map(([l, b, e]) => stepPill(l, b, e)));
}
// Sammelauftrag (Charge) = EINE normale Zeile, optisch wie jeder andere
// Auftrag. Empfänger = erster der Charge · letzter der Charge. Status
// schaltet den ganzen Sammelauftrag um. Unterelemente optional aufklappbar.
function chargeZeile(c) {
const auf = !!expandedCharges[c.key];
const sumEmpf = c.orders.reduce((a, o) => a + (Number(o.entries_count) || 0), 0);
const fmts = [...new Set(c.orders.map((o) => o.format?.name || FORMAT_LABEL[o.format?.key] || o.format?.key).filter(Boolean))];
const first = entriesByOrder.value[String(c.orders[0]?.id)];
const last = entriesByOrder.value[String(c.orders[c.orders.length - 1]?.id)];
const einzelN = c.orders.filter((o) => o.shipping_type === 'einzeln').length;
const vTxt = einzelN === 0 ? 'Sammel' : (einzelN === c.orders.length ? 'Einzeln' : 'gemischt');
const vIc = einzelN === c.orders.length ? 'person' : 'groups';
const daten = [...new Set(c.orders.map((o) => o.versanddatum || '').filter(Boolean))];
const dTxt = daten.length === 0 ? 'schnellstmöglich' : (daten.length === 1 ? daten[0] : 'mehrere');
const dFaellig = daten.length === 1 && daten[0] <= new Date().toISOString().slice(0, 10);
const stat = c.orders[0]?.production_status;
return h('tr', { key: 'c' + c.key }, [
td(h('div', { style: 'line-height:1.45;' }, [
h('div', { style: 'font-weight:500;' }, empfKurz(first?.erster) || '—'),
sumEmpf > 1 ? h('div', { style: 'font-size:12.5px;color:#566070;' }, '… ' + (empfKurz(last?.letzter) || '—')) : null,
])),
td(h('div', { style: 'line-height:1.4;font-size:13px;' }, [
h('div', { style: 'font-weight:500;display:inline-flex;align-items:center;gap:6px;cursor:pointer;', title: auf ? 'Zuklappen' : 'Teil-Aufträge zeigen', onClick: () => { expandedCharges[c.key] = !auf; } },
[icon(auf ? 'expand_more' : 'chevron_right'), icon('inbox'), c.kundeName]),
h('div', { style: 'color:#566070;' }, `${c.produkt} · ${c.orders.length} Aufträge · ${sumEmpf} Empf.`),
])),
td(h('span', {}, fmts.join(', ') || '—')),
td(ablaufStripAgg(c.orders)),
td(h('span', { title: 'Versandart (nur Ansicht)', style: 'border:1px solid #d2dae6;background:#eef2f8;border-radius:999px;padding:4px 11px;font-size:12.5px;font-weight:500;display:inline-flex;align-items:center;gap:5px;color:#1b2430;' }, [icon(vIc), vTxt])),
td(h('span', { title: 'Versanddatum (nur Ansicht)', style: 'display:inline-flex;align-items:center;gap:5px;font-size:13px;color:' + (dFaellig ? '#c0392b' : '#1b2430') + ';font-weight:' + (dFaellig ? '600' : '400') + ';' }, [icon('event'), dTxt])),
td(statusPillCtrl(stat, (v) => setChargeStatus(c, v))),
]);
}
// Zeilen einer Status-Gruppe: API-Aufträge als Sammelzeile (optional
// aufklappbar), 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(chargeZeile(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('Ablauf'), th('Versand'), th('Versanddatum'), th('Status')]));
const tabelle = (list) => h('div', { style: 'border:1px solid #b4c0d2;border-radius:12px;overflow:auto;background:#ffffff;' },
[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:#566070;' }, `${n} Auftrag/Aufträge`),
]);
function maschinenPanel() {
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 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.')]),
]);
}
function uebersichtInhalt() {
if (loading.value) return h('div', { style: 'color:#566070;' }, 'Lädt …');
const GRUPPEN = [['im_druck', 'Im Druck'], ['kuvertieren', 'Kuvertierung']];
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:#566070;margin-bottom:6px;' }, 'Schnellstmöglich (' + ohne.length + ')'), tabelle(ohne)]) : null,
mit.length ? h('div', {}, [h('div', { style: 'font-size:13px;font-weight:500;color:#566070;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:#566070;border:1px solid #b4c0d2;border-radius:12px;background:#ffffff;' }, '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:#1E50A5;color:#fff;border-color:#1E50A5;' : 'background:#ffffff;color:#1b2430;border-color:#b4c0d2;'}` }, [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:#ffffff;color:#1b2430;border:2px solid #b4c0d2;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:#ffffff;border:1px solid #b4c0d2;border-radius:12px;padding:20px 22px;margin-bottom:16px;' }, [typWahl, feld]),
kinder,
]);
if (!sucheQuery.value.trim()) return rahmen(h('div', { style: 'color:#566070;' }, 'Suchbegriff eingeben – durchsucht die offenen Aufträge.'));
const treffer = sucheTreffer.value;
if (!treffer.length) return rahmen(h('div', { style: 'color:#566070;' }, '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:#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:#566070;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:#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 #b4c0d2;border-radius:12px;background:#ffffff;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:#1b2430;' }, z)),
h('div', { style: 'display:flex;gap:8px;flex-wrap:wrap;margin-top:5px;align-items:center;font-size:13px;color:#566070;' }, [
h('span', {}, `Auftrag #${o.order_number}`),
gesamt > 1 ? h('span', { style: 'background:#eef2f8;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; if (key === 'suche') ladeSuche(); },
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:#1E50A5;color:#fff;font-weight:500;' : 'background:transparent;color:#1b2430;'),
}, [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 #b4c0d2;border-radius:10px;padding:11px 14px;font:inherit;font-size:15px;background:#ffffff;color:#1b2430;', onInput: (e) => sucheSetzen(e.target.value) }),
h('button', { type: 'button', onClick: () => laden(), style: 'cursor:pointer;border:1px solid #b4c0d2;background:#ffffff;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:#dff2e2;color:#1b6b2e;padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:14px;' }, msg.value) : null,
err.value ? h('div', { style: 'background:#fbe3e0;color:#c0392b;padding:10px 14px;border-radius:8px;margin-bottom:12px;font-size:14px;' }, err.value) : null,
view.value === 'suche' ? sucheInhalt() : uebersichtInhalt(),
]);
return h(resolveComponent('private-view'), { title: view.value === 'suche' ? 'Suche' : 'Übersicht' }, { navigation: () => navi, default: () => inhalt });
};
},
};
export default {
id: 'skrift-uebersicht',
name: 'Übersicht',
icon: 'table_view',
routes: [{ path: '', component: Board }],
};