import { apiDelete, apiGet, apiPost, apiPut, authFetchStatus, isAuthRedirectError, } from './api.js'; /** Zwischenspeicher für GET /integrations/teamviewer/connections (Benutzer → Geräte). */ let tvSessionsCache = null; const ticketStatusLabel = { OPEN: 'Offen', WAITING: 'Warte auf Rückmeldung', DONE: 'Erledigt', }; const ticketPriorityLabel = { LOW: 'Niedrig', MEDIUM: 'Mittel', HIGH: 'Hoch', }; const eventTypeLabel = { NOTE: 'Notiz', CALL: 'Anruf', REMOTE: 'Remote', PART: 'Ersatzteil benötigt', SYSTEM: 'System', }; const eventTypeBadgeClass = { NOTE: 'event-type-note', CALL: 'event-type-call', REMOTE: 'event-type-remote', PART: 'event-type-part', SYSTEM: 'event-type-system', }; /** Anzeige Dauer aus gespeicherten Sekunden (TeamViewer start/end). */ function formatRemoteDurationDe(totalSec) { if (totalSec == null || totalSec < 0) return ''; const n = Math.floor(Number(totalSec)); const m = Math.floor(n / 60); const s = n % 60; if (m === 0) return `${s} Sek.`; if (s === 0) return `${m} Min.`; return `${m} Min. ${s} Sek.`; } /** HTML für die Inhaltsspalte (nur server-/formularbekannte Typen) */ function eventInhaltHtml(ev) { const t = ev.type; if (t === 'CALL') { let h = `

Beschreibung

${esc(ev.description)}
`; if (ev.callbackNumber) { h += `

Rückrufnummer: ${esc(ev.callbackNumber)}

`; } return `${h}
`; } if (t === 'REMOTE') { let h = `

Beschreibung

${esc(ev.description)}
`; if (ev.teamviewerId) { h += `

Gerät-ID (TeamViewer): ${esc(ev.teamviewerId)}

`; } if (ev.remoteDurationSeconds != null) { h += `

Remote-Dauer: ${esc(formatRemoteDurationDe(ev.remoteDurationSeconds))}

`; } return `${h}
`; } if (t === 'PART') { let h = `

Artikelnummer: ${esc(ev.articleNumber || '')}

`; if (ev.description && String(ev.description).trim()) { h += `

Bemerkung

${esc(ev.description)}
`; } return `${h}
`; } return `
${esc(ev.description)}
`; } function fillTvDeviceSelect() { const userSel = document.getElementById('tv-user-select'); const devSel = document.getElementById('tv-conn-select'); if (!devSel) return; const ukey = userSel?.value ?? ''; devSel.innerHTML = ''; if (!ukey || !tvSessionsCache) { devSel.disabled = true; return; } const u = (tvSessionsCache.users || []).find((x) => x.userKey === ukey); const devices = u?.devices || []; if (devices.length === 0) { devSel.disabled = true; return; } devSel.disabled = false; devSel.innerHTML += devices .map( (d) => ``, ) .join(''); } async function loadTeamViewerConnectionsIntoSelect() { const userSel = document.getElementById('tv-user-select'); const devSel = document.getElementById('tv-conn-select'); const hint = document.getElementById('tv-conn-hint'); if (!userSel || !devSel) return; userSel.innerHTML = ''; devSel.innerHTML = ''; devSel.disabled = true; if (hint) hint.textContent = ''; try { const data = await apiGet('/integrations/teamviewer/connections'); tvSessionsCache = data; const users = data.users || []; userSel.innerHTML = '' + users .map((u) => { const label = u.username && u.username !== '_unbekannt' ? u.username : u.userid ? `Benutzer ${u.userid}` : 'Unbekannt (Benutzer)'; return ``; }) .join(''); devSel.innerHTML = ''; devSel.disabled = true; userSel.onchange = () => fillTvDeviceSelect(); if (hint) { const ndev = users.reduce((n, u) => n + (u.devices?.length || 0), 0); if (users.length) { hint.textContent = `${users.length} Benutzer, ${ndev} Gerät(e)/Session(s).`; } else { hint.textContent = data.meta?.recordCount === 0 ? 'Keine Verbindungen in den letzten 7 Tagen.' : 'Keine gruppierten Einträge (TeamViewer-Antwort prüfen).'; } } } catch (e) { tvSessionsCache = null; userSel.innerHTML = ''; devSel.innerHTML = ''; devSel.disabled = true; if (hint) hint.textContent = e.message || 'Fehler'; } } function syncEventFormFieldGroups(form) { const sel = form.querySelector('#ev-type-sel'); if (!sel) return; const v = sel.value; form.querySelectorAll('.ev-field-group').forEach((el) => { const show = el.getAttribute('data-ev-type') === v; el.hidden = !show; el.querySelectorAll('input, textarea').forEach((inp) => { const name = inp.getAttribute('name'); let req = false; if (show) { if (v === 'NOTE' && name === 'description_note') req = true; if (v === 'CALL' && (name === 'description_call' || name === 'callbackNumber')) req = true; if (v === 'REMOTE' && name === 'description_remote') req = false; if (v === 'PART' && name === 'articleNumber') req = true; } inp.required = req; }); }); if (v === 'REMOTE') { loadTeamViewerConnectionsIntoSelect(); } } function buildEventPostBody(ticketId, fd) { const type = fd.get('type'); const base = { ticketId, type }; if (type === 'NOTE') { return { ...base, description: fd.get('description_note') }; } if (type === 'CALL') { return { ...base, description: fd.get('description_call'), callbackNumber: fd.get('callbackNumber'), }; } if (type === 'REMOTE') { const body = { ...base, description: fd.get('description_remote'), }; const tv = fd.get('teamviewerDevice'); if (tv && String(tv).trim()) body.teamviewerId = String(tv).trim(); return body; } if (type === 'PART') { return { ...base, articleNumber: fd.get('articleNumber'), description: fd.get('description_part') || '', }; } return base; } function esc(s) { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function extrasName(m) { const x = m?.extras; if (!x || typeof x !== 'object') return ''; return String(x.Name || '').trim(); } function parseRoute() { const pathOnly = (location.hash.slice(1) || '/home').split('?')[0]; const raw = pathOnly.replace(/^\/+/, ''); const parts = raw.split('/').filter(Boolean); return { parts }; } function formatDateTime(iso) { try { return new Date(iso).toLocaleString('de-DE'); } catch { return '—'; } } const appEl = document.getElementById('app'); function updateNav(st) { const nav = document.getElementById('main-nav'); if (!nav) return; if (!st.loggedIn) { nav.innerHTML = ''; return; } const isAdmin = st.user?.role === 'admin'; nav.innerHTML = ` Start Maschinen Tickets ${isAdmin ? 'OptionenBenutzer' : ''} ${esc(st.user.username)} `; const btn = document.getElementById('btn-logout'); if (btn) { btn.onclick = async () => { try { await apiPost('/auth/logout', {}); } catch { /* ignore */ } updateNav({ loggedIn: false }); location.hash = '#/login'; }; } } async function viewBootstrap() { appEl.innerHTML = `

Erster Administrator

Es ist noch kein Benutzer angelegt. Legen Sie das erste Admin-Konto an (min. 8 Zeichen Passwort).

`; document.getElementById('form-boot').onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(e.target); await apiPost('/auth/bootstrap', { username: fd.get('username'), password: fd.get('password'), }); location.hash = '#/home'; }; } async function viewLogin() { appEl.innerHTML = `

Anmelden

`; document.getElementById('form-login').onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(e.target); try { await apiPost('/auth/login', { username: fd.get('username'), password: fd.get('password'), }); location.hash = '#/home'; } catch (err) { appEl.querySelector('.auth-err')?.remove(); const p = document.createElement('p'); p.className = 'error auth-err'; p.textContent = err.message || 'Anmeldung fehlgeschlagen'; document.getElementById('form-login').prepend(p); } }; } function formatDeSyncDateTime(iso) { if (!iso) return '—'; try { return new Date(iso).toLocaleString('de-DE', { day: 'numeric', month: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', }); } catch { return '—'; } } async function viewOptions() { const data = await apiGet('/settings/integrations'); let syncStatus = { lastSyncAt: null, entries: [] }; try { syncStatus = await apiGet('/ldap/sync-status'); } catch { /* z. B. ältere Server ohne Route */ } const ldap = data.ldap || {}; const L = (k) => esc(ldap[k] ?? ''); const userFilterVal = esc(ldap.userSearchFilter || ldap.userFilter || ''); const syncMin = ldap.syncIntervalMinutes ?? 1440; const tv = data.teamviewer || {}; const bearer = esc(tv.bearerToken || tv.apiToken || ''); const tvNotes = esc(tv.notes ?? tv.apiNotes ?? ''); const lastSyncLabel = formatDeSyncDateTime(syncStatus.lastSyncAt); const logEntries = Array.isArray(syncStatus.entries) ? syncStatus.entries : []; const logRows = logEntries.length > 0 ? logEntries .map( (e) => ` ${esc(formatDeSyncDateTime(e.finishedAt))} ${e.triggerType === 'automatic' ? 'Automatisch' : 'Manuell'} ${e.status === 'success' ? 'Erfolg' : 'Fehler'} ${esc(String(e.usersSynced ?? 0))} ${e.errorMessage ? esc(e.errorMessage) : '—'} `, ) .join('') : `Noch keine Einträge.`; appEl.innerHTML = `

← Start

Optionen

Integrationen für das CRM. Werte werden in der Datenbank gespeichert.

LDAP-Konfiguration

0 = nur manuelle Synchronisation

Synchronisation

Letzte Synchronisation: ${esc(lastSyncLabel)}

Sync-Log (letzte 10 Einträge)

${logRows}
Zeitpunkt Typ Status Benutzer synchronisiert Fehlermeldung

TeamViewer

API-Aufrufe nutzen den Header Authorization: Bearer <token> (nur das Token ohne das Wort „Bearer“ eintragen).

`; const ldapBody = document.getElementById('ldap-section-body'); const ldapToggle = document.getElementById('ldap-toggle'); const chev = ldapToggle.querySelector('.ldap-chevron'); function setLdapOpen(open) { ldapBody.hidden = !open; ldapToggle.setAttribute('aria-expanded', String(open)); chev.textContent = open ? '▲' : '▼'; } ldapToggle.onclick = () => setLdapOpen(ldapBody.hidden); document.getElementById('btn-ldap-sync-now').onclick = async () => { const btn = document.getElementById('btn-ldap-sync-now'); btn.disabled = true; try { await apiPost('/ldap/sync', {}); await route(); } catch (err) { alert(err.message || String(err)); } finally { btn.disabled = false; } }; document.getElementById('form-opt').onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(e.target); await apiPut('/settings/integrations', { ldap: { serverUrl: fd.get('ldap_serverUrl'), bindDn: fd.get('ldap_bindDn'), bindPassword: fd.get('ldap_bindPassword'), searchBase: fd.get('ldap_searchBase'), userSearchFilter: fd.get('ldap_userSearchFilter'), usernameAttribute: fd.get('ldap_usernameAttribute'), firstNameAttribute: fd.get('ldap_firstNameAttribute'), lastNameAttribute: fd.get('ldap_lastNameAttribute'), syncIntervalMinutes: fd.get('ldap_syncIntervalMinutes'), syncEnabled: fd.get('ldap_syncEnabled') === 'on', }, teamviewer: { bearerToken: fd.get('tv_bearerToken'), notes: fd.get('tv_notes'), }, }); route(); }; } async function viewUsers() { const users = await apiGet('/users'); appEl.innerHTML = `

← Start

Benutzer

Neuer Benutzer

${users .map( (u) => ` `, ) .join('')}
Benutzer Rolle Quelle Aktiv
${esc(u.username)} ${u.role === 'admin' ? 'Admin' : 'Benutzer'} ${u.source === 'ldap' ? 'LDAP' : 'Lokal'} ${u.active ? 'Ja' : 'Nein'} ${u.source === 'local' ? `` : ''}
`; document.getElementById('form-new-user').onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(e.target); await apiPost('/users', { username: fd.get('username'), password: fd.get('password'), role: fd.get('role'), }); e.target.reset(); route(); }; appEl.querySelectorAll('.btn-pw').forEach((btn) => { btn.onclick = async () => { const uid = btn.getAttribute('data-id'); const pw = window.prompt('Neues Passwort (min. 8 Zeichen):'); if (!pw || pw.length < 8) return; await apiPut(`/users/${uid}`, { password: pw }); route(); }; }); appEl.querySelectorAll('.btn-toggle').forEach((btn) => { btn.onclick = async () => { const uid = btn.getAttribute('data-id'); const active = btn.getAttribute('data-active') === '1'; await apiPut(`/users/${uid}`, { active: !active }); route(); }; }); appEl.querySelectorAll('.btn-del-user').forEach((btn) => { btn.onclick = async () => { if (!window.confirm('Benutzer wirklich löschen?')) return; const uid = btn.getAttribute('data-id'); await apiDelete(`/users/${uid}`); route(); }; }); } async function route() { const { parts } = parseRoute(); appEl.innerHTML = '

Lade …

'; let st; try { st = await authFetchStatus(); } catch { st = { needsBootstrap: false, loggedIn: false, user: null }; } updateNav(st); try { if (st.needsBootstrap) { if (parts[0] !== 'bootstrap') { location.hash = '#/bootstrap'; return; } await viewBootstrap(); return; } if (!st.loggedIn) { if (parts[0] !== 'login') { location.hash = '#/login'; return; } await viewLogin(); return; } if (parts[0] === 'login' || parts[0] === 'bootstrap') { location.hash = '#/home'; return; } const isAdmin = st.user?.role === 'admin'; if (parts[0] === 'options' && parts.length === 1) { if (!isAdmin) { location.hash = '#/home'; return; } await viewOptions(); return; } if (parts[0] === 'users' && parts.length === 1) { if (!isAdmin) { location.hash = '#/home'; return; } await viewUsers(); return; } if (parts[0] === 'home' && parts.length === 1) { await viewHome(); return; } if (parts[0] === 'machines' && parts.length === 1) { await viewMachineList(); return; } if (parts[0] === 'machines' && parts.length === 2) { await viewMachineDetail(parts[1]); return; } if (parts[0] === 'tickets' && parts.length === 1) { await viewTicketList(); return; } if (parts[0] === 'tickets' && parts.length === 2) { await viewTicketDetail(parts[1]); return; } location.hash = '#/home'; } catch (e) { if (isAuthRedirectError(e)) { updateNav({ loggedIn: false, user: null, needsBootstrap: false }); return; } appEl.innerHTML = `

${esc(e.message)}

`; } } /** Einheitlicher Platzhalter, wenn noch keine Gruppenzeile gesetzt war */ const ANLAGEN_GRUPPE_PLACEHOLDER = '\u2014'; /** Baut das extras-Objekt aus dem Maschinen-Bearbeitungsformular (Anlagenliste). */ function collectExtrasFromMachineForm(form, machine) { const prev = machine.extras && typeof machine.extras === 'object' ? JSON.parse(JSON.stringify(machine.extras)) : {}; const beschr = prev._beschriftungZeile9; const werte = prev._werteAlsListe; const gruppe = prev._gruppeZeile7; if ( Array.isArray(beschr) && Array.isArray(werte) && beschr.length === werte.length ) { const n = beschr.length; const newWerte = []; for (let i = 0; i < n; i++) { newWerte.push(String(form.elements[`extra_wert_${i}`]?.value ?? '')); } prev._werteAlsListe = newWerte; return prev; } const out = {}; for (const k of Object.keys(prev)) { if (k.startsWith('_')) out[k] = prev[k]; } const kvKeys = Object.keys(prev).filter((k) => !k.startsWith('_')); for (let i = 0; i < kvKeys.length; i++) { const key = kvKeys[i]; out[key] = String(form.elements[`extras_kv_val_${i}`]?.value ?? ''); } return out; } function extrasTableHtml(extras, opts) { const editable = opts && opts.editable === true; if (!extras || typeof extras !== 'object') { if (editable) { return `

Anlagenliste (ITT)

Keine Anlagendaten gespeichert — nichts zu bearbeiten.

`; } return ''; } const beschr = extras._beschriftungZeile9; const gruppe = extras._gruppeZeile7; const werte = extras._werteAlsListe; if ( Array.isArray(beschr) && Array.isArray(werte) && beschr.length === werte.length ) { const g = Array.isArray(gruppe) && gruppe.length === beschr.length ? gruppe : null; if (editable) { const body = beschr .map((label, i) => { const v = werte[i]; const grp = g ? String(g[i] ?? '') : ''; return ` ${esc(grp)} ${esc(String(label || `Spalte ${i + 1}`))} `; }) .join(''); return `

Anlagenliste (bearbeiten)

Nur die Spalte „Wert“ ist bearbeitbar. Gruppe und Beschreibung bleiben wie importiert.

${body}
Gruppe (Z. 7) Beschreibung (Z. 9) Wert
`; } /** Zeilen mit gleicher laufender Gruppe; Titelzeile nur bei Wechsel */ if (g) { const n = beschr.length; const groupForRow = []; let cur = ''; for (let i = 0; i < n; i++) { const raw = g[i] != null ? String(g[i]).trim() : ''; if (raw) cur = raw; groupForRow[i] = cur || ANLAGEN_GRUPPE_PLACEHOLDER; } const segments = []; let segStart = 0; for (let i = 1; i <= n; i++) { if (i === n || groupForRow[i] !== groupForRow[i - 1]) { segments.push({ from: segStart, to: i, key: groupForRow[segStart] }); segStart = i; } } const gruppenTitel = (key) => key === ANLAGEN_GRUPPE_PLACEHOLDER ? 'Sonstiges' : key; const tbodies = segments .map((seg, idx) => { const spacer = idx > 0 ? `` : ''; const titel = gruppenTitel(seg.key); const kopf = `${esc(titel)}`; const zeilen = []; for (let i = seg.from; i < seg.to; i++) { const v = werte[i]; const show = v !== '' && v != null; const label = String(beschr[i] || `Spalte ${i + 1}`); zeilen.push( `${esc(label)}${esc(String(v ?? ''))}`, ); } return `${spacer}${kopf}${zeilen.join('')}`; }) .join(''); return `

Anlagenliste (alle Spalten · Zeile 9 = Beschreibung)

Gruppe = Zeile 7 im Blatt „Anlagen“, Beschreibung = Zeile 9. Zusammenhängende Zeilen mit gleicher Gruppe sind zusammengefasst.

${tbodies}
Beschreibung (Z. 9) Wert
`; } const body = beschr .map((label, i) => { const v = werte[i]; const show = v !== '' && v != null; const grp = ''; return ` ${esc(grp)} ${esc(String(label || `Spalte ${i + 1}`))} ${esc(String(v ?? ''))} `; }) .join(''); return `

Anlagenliste (alle Spalten · Zeile 9 = Beschreibung)

Gruppe = Zeile 7 im Blatt „Anlagen“, Beschreibung = Zeile 9.

${body}
Gruppe (Z. 7) Beschreibung (Z. 9) Wert
`; } const kvEntries = Object.entries(extras).filter(([k]) => !k.startsWith('_')); if (editable) { if (kvEntries.length === 0) { return `

Daten aus Anlagenliste (ITT)

Keine zusätzlichen Felder — nichts zu bearbeiten.

`; } const rows = kvEntries .map( ([k, v], i) => ` ${esc(k)} `, ) .join(''); return `

Daten aus Anlagenliste (ITT)

Nur die Werte sind bearbeitbar; Feldnamen sind fest.

${rows}
`; } const rows = kvEntries .filter(([, v]) => v !== '' && v != null) .map( ([k, v]) => `${esc(k)}${esc(String(v))}`, ) .join(''); if (!rows) return ''; return `

Daten aus Anlagenliste (ITT)

${rows}
`; } async function viewMachineList() { const machines = await apiGet('/machines'); appEl.innerHTML = `

Maschinen (Anlagenliste)

${machines.length} Einträge · Import: npm run import:anlagen

${machines .map((m) => { const x = m.extras || {}; return ` `; }) .join('')}
Seriennr. Typ Konzern Name (Excel) Stadt Land Jahr
${esc(m.seriennummer)} ${esc(m.typ)} ${esc(x.Konzern || '')} ${esc(x.Name || '')} ${esc(x.Stadt || '')} ${esc(x.Land || '')} ${esc(x.Jahr || '')}
`; const inp = document.getElementById('machine-filter'); const tbody = document.querySelector('#machine-table tbody'); inp.addEventListener('input', () => { const q = inp.value.toLowerCase().trim(); tbody.querySelectorAll('tr').forEach((tr) => { tr.hidden = !!(q && !tr.textContent.toLowerCase().includes(q)); }); }); } async function viewMachineDetail(id) { const m = await apiGet(`/machines/${id}`); const ortName = extrasName(m); const renderView = () => { appEl.innerHTML = `

← Alle Maschinen

${esc(m.name)}

Typ: ${esc(m.typ)}

Seriennummer: ${esc(m.seriennummer)}

Standort: ${esc(m.standort)}

${ortName ? `

Name (Anlagenliste): ${esc(ortName)}

` : ''}
Tickets dieser Maschine
${extrasTableHtml(m.extras)}
`; document.getElementById('btn-m-del').onclick = async () => { if (!confirm('Maschine wirklich löschen?')) return; await apiDelete(`/machines/${id}`); location.hash = '#/machines'; }; document.getElementById('btn-m-edit').onclick = () => renderEdit(); }; const renderEdit = () => { appEl.innerHTML = `

← Alle Maschinen

${esc(m.name)}

Stammdaten

${extrasTableHtml(m.extras, { editable: true })}
Tickets dieser Maschine
`; const form = document.getElementById('form-m'); form.onsubmit = async (e) => { e.preventDefault(); const body = { name: form.elements.name.value, typ: form.elements.typ.value, seriennummer: form.elements.seriennummer.value, standort: form.elements.standort.value, extras: collectExtrasFromMachineForm(form, m), }; await apiPut(`/machines/${id}`, body); route(); }; document.getElementById('m-cancel').onclick = () => renderView(); document.getElementById('btn-m-del-edit').onclick = async () => { if (!confirm('Maschine wirklich löschen?')) return; await apiDelete(`/machines/${id}`); location.hash = '#/machines'; }; }; renderView(); } const statusBadgeClass = { OPEN: 'badge-open', WAITING: 'badge-waiting', DONE: 'badge-done' }; const priorityBadgeClass = { HIGH: 'badge-high', MEDIUM: 'badge-medium', LOW: 'badge-low' }; async function viewHome() { const tickets = await apiGet('/tickets?open=1'); const eventsLists = tickets.length === 0 ? [] : await Promise.all(tickets.map((t) => apiGet(`/tickets/${t.id}/events`))); const openCount = tickets.filter((t) => t.status === 'OPEN').length; const waitingCount = tickets.filter((t) => t.status === 'WAITING').length; const listHtml = tickets.length === 0 ? '

Keine offenen Tickets.

' : `
${tickets .map((t, i) => { const events = eventsLists[i] || []; const mn = t.machine ? extrasName(t.machine) : ''; const machineLabel = t.machine ? `${esc(t.machine.seriennummer)}${mn ? ` · ${esc(mn)}` : ''}` : ''; const standort = t.machine ? esc(t.machine.standort) : '—'; const mBlock = t.machine ? `

Maschine: ${esc(t.machine.seriennummer)}${mn ? ` · ${esc(mn)}` : ''}  ·  Typ: ${esc(t.machine.typ)}

` : ''; const evChrono = [...events].reverse(); const eventBoxes = evChrono.length === 0 ? '

Noch keine Ereignisse in der Historie.

' : evChrono .map( (ev) => `
${esc(eventTypeLabel[ev.type] || ev.type)}
${eventInhaltHtml(ev)}
`, ) .join(''); return `
${esc(t.title)} ${esc(ticketStatusLabel[t.status])} ${esc(ticketPriorityLabel[t.priority])}
${t.machine ? `Maschine: ${machineLabel}` : 'Keine Maschine'} Standort: ${standort} Erstellt: ${esc(formatDateTime(t.createdAt))} Aktualisiert: ${esc(formatDateTime(t.updatedAt))}
Ticket öffnen →

Beschreibung

${esc(t.description)}

${mBlock}
${eventBoxes}
`; }) .join('')}
`; appEl.innerHTML = `

Offene Tickets

${openCount} Offen ${waitingCount} Wartend gesamt: ${tickets.length}
${listHtml}
`; } function ticketListQuery() { const q = new URLSearchParams(location.hash.split('?')[1] || ''); const p = new URLSearchParams(); if (q.get('status')) p.set('status', q.get('status')); if (q.get('priority')) p.set('priority', q.get('priority')); if (q.get('machineId')) p.set('machineId', q.get('machineId')); const s = p.toString(); return s ? `?${s}` : ''; } async function viewTicketList() { const qs = ticketListQuery(); const hashParams = new URLSearchParams(location.hash.split('?')[1] || ''); const [tickets, allMachines] = await Promise.all([ apiGet(`/tickets${qs}`), apiGet('/machines'), ]); const mid = hashParams.get('machineId') || ''; appEl.innerHTML = `

Tickets

Neues Ticket

Filter

${tickets .map( (t) => ` `, ) .join('')}
TitelStatusPrioritätMaschine
${esc(t.title)} ${esc(ticketStatusLabel[t.status])} ${esc(ticketPriorityLabel[t.priority])} ${t.machine ? esc(t.machine.seriennummer) : ''}${t.machine ? (extrasName(t.machine) ? ` · ${esc(extrasName(t.machine))}` : '') : ''}
`; const selFm = document.getElementById('sel-fm'); if (mid) selFm.value = mid; document.getElementById('form-new-ticket').onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(e.target); await apiPost('/tickets', { machineId: fd.get('machineId'), title: fd.get('title'), description: fd.get('description'), }); e.target.reset(); route(); }; document.getElementById('form-filter').onsubmit = (e) => { e.preventDefault(); const fd = new FormData(e.target); const p = new URLSearchParams(); if (fd.get('status')) p.set('status', fd.get('status')); if (fd.get('priority')) p.set('priority', fd.get('priority')); if (fd.get('machineId')) p.set('machineId', fd.get('machineId')); const q = p.toString(); location.hash = `#/tickets${q ? `?${q}` : ''}`; }; } async function viewTicketDetail(id) { const [ticket, events] = await Promise.all([ apiGet(`/tickets/${id}`), apiGet(`/tickets/${id}/events`), ]); const mn = ticket.machine ? extrasName(ticket.machine) : ''; appEl.innerHTML = `

← Zurück

${esc(ticket.title)}

Status: ${esc(ticketStatusLabel[ticket.status])}

Priorität: ${esc(ticketPriorityLabel[ticket.priority])}

Beschreibung:

${esc(ticket.description)}

${ticket.machine ? `

Maschine: ${esc(ticket.machine.seriennummer)}${mn ? ` · ${esc(mn)}` : ''}

` : ''}

Historie

Event hinzufügen

${ events.length === 0 ? `` : events .map( (ev) => ` `, ) .join('') }
Zeitpunkt Art Inhalt
Noch keine Ereignisse.
${esc(formatDateTime(ev.createdAt))} ${esc(eventTypeLabel[ev.type] || ev.type)} ${eventInhaltHtml(ev)}

Weiteres Ticket für diese Maschine

Optional.

`; const formEv = document.getElementById('form-ev'); syncEventFormFieldGroups(formEv); formEv.querySelector('#ev-type-sel').onchange = () => syncEventFormFieldGroups(formEv); document.getElementById('btn-tv-reload').onclick = () => loadTeamViewerConnectionsIntoSelect(); formEv.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(e.target); let body = buildEventPostBody(id, fd); if (body.type === 'REMOTE') { const sel = document.getElementById('tv-conn-select'); const opt = sel?.selectedOptions?.[0]; if (opt?.value) { body.teamviewerId = opt.value; const sd = opt.getAttribute('data-start-date'); const ed = opt.getAttribute('data-end-date'); if (sd) body.teamviewerStartDate = sd; if (ed) body.teamviewerEndDate = ed; const dn = opt.getAttribute('data-devicename') || ''; const u = String(fd.get('description_remote') ?? '').trim(); if (dn) { body.description = u ? `${u}\n\nTeamViewer-Gerät: ${dn}` : `TeamViewer-Gerät: ${dn}`; } else if (u) { body.description = u; } else { body.description = 'Remote-Session (TeamViewer)'; } } else { body.description = String(body.description ?? '').trim(); if (!body.description) { formEv.insertAdjacentHTML( 'afterbegin', '

Beschreibung oder Gerät auswählen.

', ); setTimeout(() => { document.querySelector('.tv-form-err')?.remove(); }, 4000); return; } } } await apiPost('/events', body); e.target.reset(); syncEventFormFieldGroups(formEv); route(); }; document.getElementById('form-t2').onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(e.target); await apiPost('/tickets', { machineId: ticket.machineId, title: fd.get('title'), description: fd.get('description'), }); e.target.reset(); route(); }; document.getElementById('btn-t-edit').onclick = () => { document.getElementById('tick-view').innerHTML = `
`; document.getElementById('form-tu').onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(e.target); await apiPut(`/tickets/${id}`, Object.fromEntries(fd.entries())); route(); }; document.getElementById('tu-cancel').onclick = () => route(); }; } window.addEventListener('unhandledrejection', (ev) => { if (isAuthRedirectError(ev.reason)) { ev.preventDefault(); } }); window.addEventListener('hashchange', route); if (!location.hash) location.hash = '#/home'; route();