- Empfaenger (erster/letzter) ist die fuehrende Spalte; Ladefehler behoben (order _in statt Relationsfilter). Auftrag/Kunde sekundaer. - Je Status eine eigene Tabelle (Im Druck / Kuvertierung / Versandfertig / Eingegangen). - Neue Spalte "Aufgabe" (kuvertieren/frankieren/Versandart aus Status abgeleitet). - Versandart pro Auftrag umschaltbar (Sammel/Einzel). - Maschinen-Panel zaehlt nur WARTENDE Jobs (nicht "im Druck"). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
277 lines
18 KiB
JavaScript
277 lines
18 KiB
JavaScript
/**
|
||
* Skrift – Modul „Übersicht" (Mitarbeiter, read-only)
|
||
* ===========================================================================
|
||
* 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.
|
||
*
|
||
* 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: 'var(--background-subdued,#eef1f6)', fg: 'var(--foreground-subdued)', icon: 'inbox' },
|
||
im_druck: { bg: 'rgba(30,80,165,.12)', fg: 'var(--primary,#1e50a5)', icon: 'print' },
|
||
kuvertieren: { bg: 'rgba(106,63,208,.12)', fg: '#6a3fd0', icon: 'mail' },
|
||
versandfertig: { bg: 'rgba(230,160,0,.16)', fg: '#8a6d00', icon: 'inventory_2' },
|
||
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' };
|
||
|
||
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 orders = ref([]);
|
||
const jobs = ref([]);
|
||
const machines = ref([]);
|
||
const entriesByOrder = ref({});
|
||
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); };
|
||
|
||
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(', ');
|
||
}
|
||
|
||
async function laden(leise = false) {
|
||
if (!leise) { loading.value = true; err.value = ''; }
|
||
try {
|
||
const [o, j, m] = 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,'
|
||
+ '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: [] } })),
|
||
]);
|
||
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 = {};
|
||
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',
|
||
} }) : { data: { data: [] } };
|
||
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 };
|
||
else { idx[k].letzter = e; idx[k].anzahl += 1; }
|
||
}
|
||
} catch { /* Empfänger optional */ }
|
||
entriesByOrder.value = idx;
|
||
} catch (e) {
|
||
if (!leise) err.value = e?.response?.data?.errors?.[0]?.message || e?.message || 'Laden fehlgeschlagen.';
|
||
} finally { if (!leise) loading.value = false; }
|
||
}
|
||
onMounted(laden);
|
||
let refreshTimer = null;
|
||
onMounted(() => { refreshTimer = setInterval(() => laden(true), 20000); });
|
||
onUnmounted(() => clearInterval(refreshTimer));
|
||
|
||
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;
|
||
}
|
||
return map;
|
||
});
|
||
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)];
|
||
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 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 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.'; }
|
||
}
|
||
|
||
// ── Render-Helfer ─────────────────────────────────────────────────────────
|
||
const statusPill = (o) => {
|
||
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 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) {
|
||
case 'eingegangen': return { t: 'Wartet auf Druck', c: 'var(--foreground-subdued)', i: 'hourglass_empty' };
|
||
case 'im_druck': return { t: 'Im Druck', c: 'var(--primary,#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: '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) {
|
||
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;' }, [
|
||
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}`),
|
||
h('div', { style: 'color:var(--foreground-subdued);' }, `#${o.order_number} · ${o.entries_count || 0} Empf.`),
|
||
])),
|
||
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(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,
|
||
eingeplant ? h('span', { style: 'display:inline-flex;align-items:center;gap:3px;font-size:11.5px;font-weight:500;color:var(--primary,#1e50a5);' }, [icon('check'), 'eingeplant']) : null,
|
||
].filter(Boolean))),
|
||
td(statusPill(o)),
|
||
]);
|
||
}
|
||
|
||
function maschinenPanel() {
|
||
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),
|
||
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.')]),
|
||
]);
|
||
}
|
||
|
||
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) }),
|
||
]),
|
||
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']),
|
||
]),
|
||
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.');
|
||
})(),
|
||
]);
|
||
return h(resolveComponent('private-view'), { title: 'Übersicht' }, { default: () => inhalt });
|
||
};
|
||
},
|
||
};
|
||
|
||
export default {
|
||
id: 'skrift-uebersicht',
|
||
name: 'Übersicht',
|
||
icon: 'table_view',
|
||
routes: [{ path: '', component: Board }],
|
||
};
|