Files
SDS-CRM/public/js/app.js
2026-03-22 19:26:35 +01:00

1503 lines
55 KiB
JavaScript

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 = `<div class="event-inhalt-block"><p class="event-inhalt-label">Beschreibung</p><div class="event-inhalt-text">${esc(ev.description)}</div>`;
if (ev.callbackNumber) {
h += `<p class="event-inhalt-meta"><strong>Rückrufnummer:</strong> ${esc(ev.callbackNumber)}</p>`;
}
return `${h}</div>`;
}
if (t === 'REMOTE') {
let h = `<div class="event-inhalt-block"><p class="event-inhalt-label">Beschreibung</p><div class="event-inhalt-text">${esc(ev.description)}</div>`;
if (ev.teamviewerId) {
h += `<p class="event-inhalt-meta"><strong>Gerät-ID (TeamViewer):</strong> <code>${esc(ev.teamviewerId)}</code></p>`;
}
if (ev.remoteDurationSeconds != null) {
h += `<p class="event-inhalt-meta"><strong>Remote-Dauer:</strong> ${esc(formatRemoteDurationDe(ev.remoteDurationSeconds))}</p>`;
}
return `${h}</div>`;
}
if (t === 'PART') {
let h = `<div class="event-inhalt-block"><p class="event-inhalt-meta"><strong>Artikelnummer:</strong> <code class="event-artnr">${esc(ev.articleNumber || '')}</code></p>`;
if (ev.description && String(ev.description).trim()) {
h += `<p class="event-inhalt-label">Bemerkung</p><div class="event-inhalt-text">${esc(ev.description)}</div>`;
}
return `${h}</div>`;
}
return `<div class="event-inhalt-text">${esc(ev.description)}</div>`;
}
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 =
'<option value="">— Gerät / Session wählen —</option>';
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) =>
`<option value="${esc(d.deviceid)}" data-devicename="${esc(d.label)}" data-start-date="${esc(d.startDate || '')}" data-end-date="${esc(d.endDate || '')}">${esc(d.label)}</option>`,
)
.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 = '<option value="">… lädt …</option>';
devSel.innerHTML = '<option value="">…</option>';
devSel.disabled = true;
if (hint) hint.textContent = '';
try {
const data = await apiGet('/integrations/teamviewer/connections');
tvSessionsCache = data;
const users = data.users || [];
userSel.innerHTML =
'<option value="">— Benutzer wählen (letzte 7 Tage) —</option>' +
users
.map((u) => {
const label =
u.username && u.username !== '_unbekannt'
? u.username
: u.userid
? `Benutzer ${u.userid}`
: 'Unbekannt (Benutzer)';
return `<option value="${esc(u.userKey)}">${esc(label)}</option>`;
})
.join('');
devSel.innerHTML =
'<option value="">— zuerst Benutzer wählen —</option>';
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 = '<option value="">— Laden fehlgeschlagen —</option>';
devSel.innerHTML = '<option value="">—</option>';
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 = `
<a href="#/home">Start</a>
<a href="#/machines">Maschinen</a>
<a href="#/tickets">Tickets</a>
${isAdmin ? '<a href="#/options">Optionen</a><a href="#/users">Benutzer</a>' : ''}
<span class="nav-user muted">${esc(st.user.username)}</span>
<button type="button" class="secondary btn-nav-logout" id="btn-logout">Abmelden</button>`;
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 = `
<div class="stack auth-panel">
<h2>Erster Administrator</h2>
<p class="muted">Es ist noch kein Benutzer angelegt. Legen Sie das erste Admin-Konto an (min. 8 Zeichen Passwort).</p>
<div class="card">
<form id="form-boot" class="stack">
<label>Benutzername <input name="username" required autocomplete="username" /></label>
<label>Passwort <input name="password" type="password" required minlength="8" autocomplete="new-password" /></label>
<button type="submit">Konto anlegen</button>
</form>
</div>
</div>`;
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 = `
<div class="stack auth-panel">
<h2>Anmelden</h2>
<div class="card">
<form id="form-login" class="stack">
<label>Benutzername <input name="username" required autocomplete="username" /></label>
<label>Passwort <input name="password" type="password" required autocomplete="current-password" /></label>
<button type="submit">Anmelden</button>
</form>
</div>
</div>`;
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) => `
<tr>
<td>${esc(formatDeSyncDateTime(e.finishedAt))}</td>
<td>${e.triggerType === 'automatic' ? 'Automatisch' : 'Manuell'}</td>
<td><span class="sync-status-badge ${e.status === 'success' ? 'sync-status-ok' : 'sync-status-err'}">${e.status === 'success' ? 'Erfolg' : 'Fehler'}</span></td>
<td class="num">${esc(String(e.usersSynced ?? 0))}</td>
<td>${e.errorMessage ? esc(e.errorMessage) : '—'}</td>
</tr>`,
)
.join('')
: `<tr><td colspan="5" class="muted">Noch keine Einträge.</td></tr>`;
appEl.innerHTML = `
<div class="stack options-page">
<p><a href="#/home">← Start</a></p>
<h2>Optionen</h2>
<p class="muted">Integrationen für das CRM. Werte werden in der Datenbank gespeichert.</p>
<form id="form-opt" class="stack">
<div class="card options-section ldap-section">
<button type="button" class="ldap-section-toggle" id="ldap-toggle" aria-expanded="true" aria-controls="ldap-section-body">
<span class="ldap-section-heading">LDAP-Synchronisation</span>
<span class="ldap-chevron" aria-hidden="true">▲</span>
</button>
<div class="ldap-section-body" id="ldap-section-body">
<h4 class="ldap-subtitle">LDAP-Konfiguration</h4>
<label class="row-inline ldap-sync-check">
<input type="checkbox" name="ldap_syncEnabled" ${ldap.syncEnabled ? 'checked' : ''} />
LDAP-Synchronisation aktivieren
</label>
<div class="form-grid-2">
<label>LDAP-Server URL
<input name="ldap_serverUrl" value="${L('serverUrl')}" placeholder="ldap://…" autocomplete="off" />
</label>
<label>Base DN
<input name="ldap_searchBase" value="${L('searchBase')}" placeholder="DC=…" autocomplete="off" />
</label>
<label>Bind DN (optional)
<input name="ldap_bindDn" value="${L('bindDn')}" autocomplete="off" />
</label>
<label>Bind Passwort (optional)
<input name="ldap_bindPassword" type="password" value="" autocomplete="new-password" placeholder="Leer lassen um nicht zu ändern" />
</label>
</div>
<label class="full-width">User Search Filter
<textarea name="ldap_userSearchFilter" rows="4" spellcheck="false" class="ldap-filter-ta">${userFilterVal}</textarea>
</label>
<div class="form-grid-ldap-attr">
<label>Username-Attribut
<input name="ldap_usernameAttribute" value="${L('usernameAttribute')}" placeholder="sAMAccountName" />
</label>
<label>Vorname-Attribut
<input name="ldap_firstNameAttribute" value="${L('firstNameAttribute')}" placeholder="givenName" />
</label>
<label>Nachname-Attribut
<input name="ldap_lastNameAttribute" value="${L('lastNameAttribute')}" placeholder="sn" />
</label>
</div>
<label class="full-width">Sync-Intervall (Minuten)
<input name="ldap_syncIntervalMinutes" type="number" min="0" step="1" value="${esc(String(syncMin))}" />
</label>
<p class="muted ldap-hint">0 = nur manuelle Synchronisation</p>
</div>
</div>
<div class="card options-section sync-panel">
<h3 class="options-section-title">Synchronisation</h3>
<div class="sync-actions">
<button type="button" class="btn-ldap-sync-now" id="btn-ldap-sync-now">Synchronisation jetzt starten</button>
</div>
<p class="muted sync-last-line" id="ldap-last-sync">Letzte Synchronisation: ${esc(lastSyncLabel)}</p>
<h4 class="sync-log-title">Sync-Log (letzte 10 Einträge)</h4>
<div class="table-wrap sync-log-table-wrap">
<table class="sync-log-table">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Typ</th>
<th>Status</th>
<th>Benutzer synchronisiert</th>
<th>Fehlermeldung</th>
</tr>
</thead>
<tbody>${logRows}</tbody>
</table>
</div>
</div>
<div class="card options-section">
<h3 class="options-section-title">TeamViewer</h3>
<p class="muted">API-Aufrufe nutzen den Header <code>Authorization: Bearer &lt;token&gt;</code> (nur das Token ohne das Wort „Bearer“ eintragen).</p>
<label>Bearer-Token
<input name="tv_bearerToken" type="password" value="${bearer}" autocomplete="off" placeholder="your_token" spellcheck="false" />
</label>
<label>Hinweise <textarea name="tv_notes" rows="2">${tvNotes}</textarea></label>
</div>
<div class="options-actions">
<button type="submit" class="btn-config-save">Konfiguration speichern</button>
</div>
</form>
</div>`;
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 = `
<div class="stack">
<p><a href="#/home">← Start</a></p>
<h2>Benutzer</h2>
<div class="card">
<h3>Neuer Benutzer</h3>
<form id="form-new-user" class="stack">
<div class="row">
<label>Benutzername <input name="username" required autocomplete="off" /></label>
<label>Passwort <input name="password" type="password" required minlength="8" autocomplete="new-password" /></label>
<label>Rolle
<select name="role">
<option value="user">Benutzer</option>
<option value="admin">Administrator</option>
</select>
</label>
<button type="submit">Anlegen</button>
</div>
</form>
</div>
<div class="card table-wrap">
<table class="users-table">
<thead>
<tr>
<th>Benutzer</th>
<th>Rolle</th>
<th>Quelle</th>
<th>Aktiv</th>
<th></th>
</tr>
</thead>
<tbody>
${users
.map(
(u) => `
<tr data-id="${esc(u.id)}">
<td>${esc(u.username)}</td>
<td><span class="badge">${u.role === 'admin' ? 'Admin' : 'Benutzer'}</span></td>
<td class="muted">${u.source === 'ldap' ? 'LDAP' : 'Lokal'}</td>
<td>${u.active ? 'Ja' : 'Nein'}</td>
<td class="users-actions">
${u.source === 'local' ? `<button type="button" class="secondary btn-pw" data-id="${esc(u.id)}">Passwort</button>` : ''}
<button type="button" class="secondary btn-toggle" data-id="${esc(u.id)}" data-active="${u.active ? '1' : '0'}">${u.active ? 'Deaktivieren' : 'Aktivieren'}</button>
<button type="button" class="danger btn-del-user" data-id="${esc(u.id)}">Löschen</button>
</td>
</tr>`,
)
.join('')}
</tbody>
</table>
</div>
</div>`;
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 = '<p class="muted">Lade …</p>';
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 = `<p class="error">${esc(e.message)}</p>`;
}
}
/** 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 `
<div class="card">
<h3>Anlagenliste (ITT)</h3>
<p class="muted">Keine Anlagendaten gespeichert — nichts zu bearbeiten.</p>
</div>`;
}
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 `<tr>
<td class="muted">${esc(grp)}</td>
<th>${esc(String(label || `Spalte ${i + 1}`))}</th>
<td><input type="text" name="extra_wert_${i}" value="${esc(String(v ?? ''))}" class="extras-cell-input" autocomplete="off" /></td>
</tr>`;
})
.join('');
return `
<div class="card">
<h3>Anlagenliste (bearbeiten)</h3>
<p class="muted">Nur die Spalte „Wert“ ist bearbeitbar. Gruppe und Beschreibung bleiben wie importiert.</p>
<div class="table-wrap">
<table class="extras-table anlagen-voll">
<colgroup>
<col class="anlagen-col-gruppe" />
<col class="anlagen-col-beschr" />
<col class="anlagen-col-wert" />
</colgroup>
<thead>
<tr>
<th>Gruppe (Z. 7)</th>
<th>Beschreibung (Z. 9)</th>
<th>Wert</th>
</tr>
</thead>
<tbody>${body}</tbody>
</table>
</div>
</div>`;
}
/** 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
? `<tbody class="anlagen-gruppe-spacer"><tr><td colspan="2"></td></tr></tbody>`
: '';
const titel = gruppenTitel(seg.key);
const kopf = `<tr class="anlagen-gruppe-kopf"><td colspan="2">${esc(titel)}</td></tr>`;
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(
`<tr data-empty="${show ? '0' : '1'}"><th>${esc(label)}</th><td>${esc(String(v ?? ''))}</td></tr>`,
);
}
return `${spacer}<tbody class="anlagen-gruppe">${kopf}${zeilen.join('')}</tbody>`;
})
.join('');
return `
<div class="card">
<h3>Anlagenliste (alle Spalten · Zeile 9 = Beschreibung)</h3>
<p class="muted">Gruppe = Zeile 7 im Blatt „Anlagen“, Beschreibung = Zeile 9. Zusammenhängende Zeilen mit gleicher Gruppe sind zusammengefasst.</p>
<div class="table-wrap">
<table class="extras-table anlagen-voll gruppiert">
<colgroup>
<col class="anlagen-col-beschr" />
<col class="anlagen-col-wert" />
</colgroup>
<thead>
<tr>
<th>Beschreibung (Z. 9)</th>
<th>Wert</th>
</tr>
</thead>
${tbodies}
</table>
</div>
</div>`;
}
const body = beschr
.map((label, i) => {
const v = werte[i];
const show = v !== '' && v != null;
const grp = '';
return `<tr data-empty="${show ? '0' : '1'}">
<td class="muted">${esc(grp)}</td>
<th>${esc(String(label || `Spalte ${i + 1}`))}</th>
<td>${esc(String(v ?? ''))}</td>
</tr>`;
})
.join('');
return `
<div class="card">
<h3>Anlagenliste (alle Spalten · Zeile 9 = Beschreibung)</h3>
<p class="muted">Gruppe = Zeile 7 im Blatt „Anlagen“, Beschreibung = Zeile 9.</p>
<div class="table-wrap">
<table class="extras-table anlagen-voll">
<colgroup>
<col class="anlagen-col-gruppe" />
<col class="anlagen-col-beschr" />
<col class="anlagen-col-wert" />
</colgroup>
<thead>
<tr>
<th>Gruppe (Z. 7)</th>
<th>Beschreibung (Z. 9)</th>
<th>Wert</th>
</tr>
</thead>
<tbody>${body}</tbody>
</table>
</div>
</div>`;
}
const kvEntries = Object.entries(extras).filter(([k]) => !k.startsWith('_'));
if (editable) {
if (kvEntries.length === 0) {
return `
<div class="card">
<h3>Daten aus Anlagenliste (ITT)</h3>
<p class="muted">Keine zusätzlichen Felder — nichts zu bearbeiten.</p>
</div>`;
}
const rows = kvEntries
.map(
([k, v], i) =>
`<tr>
<th>${esc(k)}</th>
<td><input type="text" name="extras_kv_val_${i}" value="${esc(String(v))}" class="extras-cell-input" autocomplete="off" /></td>
</tr>`,
)
.join('');
return `
<div class="card">
<h3>Daten aus Anlagenliste (ITT)</h3>
<p class="muted">Nur die Werte sind bearbeitbar; Feldnamen sind fest.</p>
<div class="table-wrap">
<table class="extras-table">
<tbody>${rows}</tbody>
</table>
</div>
</div>`;
}
const rows = kvEntries
.filter(([, v]) => v !== '' && v != null)
.map(
([k, v]) =>
`<tr><th>${esc(k)}</th><td>${esc(String(v))}</td></tr>`,
)
.join('');
if (!rows) return '';
return `
<div class="card">
<h3>Daten aus Anlagenliste (ITT)</h3>
<div class="table-wrap">
<table class="extras-table">
<tbody>${rows}</tbody>
</table>
</div>
</div>`;
}
async function viewMachineList() {
const machines = await apiGet('/machines');
appEl.innerHTML = `
<div class="stack machines-overview">
<h2>Maschinen (Anlagenliste)</h2>
<p class="muted">${machines.length} Einträge · Import: <code>npm run import:anlagen</code></p>
<div class="card">
<label>Suche <input type="search" id="machine-filter" placeholder="Seriennummer, Typ, Ort, …" style="min-width:min(100%, 22rem)" /></label>
</div>
<div class="card table-wrap">
<table id="machine-table">
<thead>
<tr>
<th>Seriennr.</th>
<th>Typ</th>
<th>Konzern</th>
<th>Name (Excel)</th>
<th>Stadt</th>
<th>Land</th>
<th>Jahr</th>
</tr>
</thead>
<tbody>
${machines
.map((m) => {
const x = m.extras || {};
return `
<tr>
<td><a href="#/machines/${m.id}">${esc(m.seriennummer)}</a></td>
<td>${esc(m.typ)}</td>
<td>${esc(x.Konzern || '')}</td>
<td>${esc(x.Name || '')}</td>
<td>${esc(x.Stadt || '')}</td>
<td>${esc(x.Land || '')}</td>
<td>${esc(x.Jahr || '')}</td>
</tr>`;
})
.join('')}
</tbody>
</table>
</div>
</div>`;
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 = `
<div class="stack">
<p><a href="#/machines">← Alle Maschinen</a></p>
<h2>${esc(m.name)}</h2>
<div class="card" id="mach-view">
<p><strong>Typ:</strong> ${esc(m.typ)}</p>
<p><strong>Seriennummer:</strong> ${esc(m.seriennummer)}</p>
<p><strong>Standort:</strong> ${esc(m.standort)}</p>
${ortName ? `<p><strong>Name (Anlagenliste):</strong> ${esc(ortName)}</p>` : ''}
<div class="row">
<button type="button" id="btn-m-edit">Bearbeiten</button>
<a class="badge" href="#/tickets?machineId=${esc(m.id)}" style="align-self:center">Tickets dieser Maschine</a>
<button type="button" class="danger" id="btn-m-del">Löschen</button>
</div>
</div>
${extrasTableHtml(m.extras)}
</div>`;
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 = `
<div class="stack">
<p><a href="#/machines">← Alle Maschinen</a></p>
<h2>${esc(m.name)}</h2>
<form id="form-m" class="stack">
<div class="card">
<h3 class="machine-detail-card-title">Stammdaten</h3>
<div class="row">
<label>Name <input name="name" value="${esc(m.name)}" required /></label>
<label>Typ <input name="typ" value="${esc(m.typ)}" required /></label>
</div>
<div class="row">
<label>Seriennummer <input name="seriennummer" value="${esc(m.seriennummer)}" required /></label>
<label>Standort <input name="standort" value="${esc(m.standort)}" required /></label>
</div>
</div>
${extrasTableHtml(m.extras, { editable: true })}
<div class="row machine-detail-actions">
<button type="submit">Speichern</button>
<button type="button" class="secondary" id="m-cancel">Abbrechen</button>
<a class="badge" href="#/tickets?machineId=${esc(m.id)}">Tickets dieser Maschine</a>
<button type="button" class="danger" id="btn-m-del-edit">Löschen</button>
</div>
</form>
</div>`;
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
? '<p class="muted">Keine offenen Tickets.</p>'
: `<div class="home-ticket-list">${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
? `<p class="home-ticket-machine"><strong>Maschine:</strong> <a href="#/machines/${esc(t.machine.id)}">${esc(t.machine.seriennummer)}</a>${mn ? ` · ${esc(mn)}` : ''} &nbsp;·&nbsp; <strong>Typ:</strong> ${esc(t.machine.typ)}</p>`
: '';
const evChrono = [...events].reverse();
const eventBoxes =
evChrono.length === 0
? '<p class="muted home-ticket-no-events">Noch keine Ereignisse in der Historie.</p>'
: evChrono
.map(
(ev) => `
<div class="home-event-box">
<div class="home-event-box-header">
<span class="badge event-type-badge ${eventTypeBadgeClass[ev.type] || ''}">${esc(eventTypeLabel[ev.type] || ev.type)}</span>
<time class="home-event-box-time" datetime="${esc(ev.createdAt)}">${esc(formatDateTime(ev.createdAt))}</time>
</div>
<div class="home-event-box-body">${eventInhaltHtml(ev)}</div>
</div>`,
)
.join('');
return `
<article class="card home-ticket-card" data-ticket-id="${esc(t.id)}">
<div class="home-ticket-top">
<div class="home-ticket-top-inner">
<div class="home-ticket-titleline">
<a href="#/tickets/${esc(t.id)}">${esc(t.title)}</a>
<span class="badge ${statusBadgeClass[t.status] || ''}">${esc(ticketStatusLabel[t.status])}</span>
<span class="badge ${priorityBadgeClass[t.priority] || ''}">${esc(ticketPriorityLabel[t.priority])}</span>
</div>
<div class="home-ticket-meta-row">
<span>${t.machine ? `<span class="muted">Maschine:</span> ${machineLabel}` : '<span class="muted">Keine Maschine</span>'}</span>
<span><span class="muted">Standort:</span> ${standort}</span>
<span><span class="muted">Erstellt:</span> ${esc(formatDateTime(t.createdAt))}</span>
<span><span class="muted">Aktualisiert:</span> ${esc(formatDateTime(t.updatedAt))}</span>
</div>
</div>
<a class="home-ticket-open" href="#/tickets/${esc(t.id)}">Ticket öffnen →</a>
</div>
<div class="home-ticket-context">
<p class="home-ticket-context-h"><strong>Beschreibung</strong></p>
<p class="home-ticket-desc">${esc(t.description)}</p>
${mBlock}
</div>
<div class="home-ticket-events">${eventBoxes}</div>
</article>`;
})
.join('')}</div>`;
appEl.innerHTML = `
<div class="stack home-open-tickets">
<div class="home-kpi-bar">
<h2>Offene Tickets</h2>
<div class="home-kpi-pills">
<span class="kpi-pill"><span class="badge badge-open">${openCount} Offen</span></span>
<span class="kpi-pill"><span class="badge badge-waiting">${waitingCount} Wartend</span></span>
<span class="kpi-pill muted">gesamt: ${tickets.length}</span>
</div>
</div>
${listHtml}
</div>`;
}
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 = `
<div class="stack">
<h2>Tickets</h2>
<div class="card">
<h3>Neues Ticket</h3>
<form id="form-new-ticket" class="stack">
<div class="row">
<label>Maschine
<select name="machineId" id="sel-nm" required>
<option value="">— wählen —</option>
${allMachines
.map(
(m) =>
`<option value="${esc(m.id)}" ${m.id === mid ? 'selected' : ''}>${esc(m.seriennummer)}${extrasName(m) ? ` · ${esc(extrasName(m))}` : ''}</option>`,
)
.join('')}
</select>
</label>
</div>
<div class="row">
<label>Titel <input name="title" required /></label>
<label>Beschreibung <textarea name="description" required></textarea></label>
</div>
<button type="submit">Ticket erstellen</button>
</form>
</div>
<div class="card">
<h3>Filter</h3>
<form id="form-filter" class="stack">
<div class="row">
<label>Status
<select name="status">
<option value="">Alle</option>
<option value="OPEN">${ticketStatusLabel.OPEN}</option>
<option value="WAITING">${ticketStatusLabel.WAITING}</option>
<option value="DONE">${ticketStatusLabel.DONE}</option>
</select>
</label>
<label>Priorität
<select name="priority">
<option value="">Alle</option>
<option value="LOW">${ticketPriorityLabel.LOW}</option>
<option value="MEDIUM">${ticketPriorityLabel.MEDIUM}</option>
<option value="HIGH">${ticketPriorityLabel.HIGH}</option>
</select>
</label>
<label>Maschine
<select name="machineId" id="sel-fm">
<option value="">Alle</option>
${allMachines
.map(
(m) =>
`<option value="${esc(m.id)}">${esc(m.seriennummer)}</option>`,
)
.join('')}
</select>
</label>
</div>
<button type="submit">Filter anwenden</button>
</form>
</div>
<div class="card">
<table>
<thead><tr><th>Titel</th><th>Status</th><th>Priorität</th><th>Maschine</th></tr></thead>
<tbody>
${tickets
.map(
(t) => `
<tr>
<td><a href="#/tickets/${t.id}">${esc(t.title)}</a></td>
<td><span class="badge ${statusBadgeClass[t.status] || ''}">${esc(ticketStatusLabel[t.status])}</span></td>
<td><span class="badge ${priorityBadgeClass[t.priority] || ''}">${esc(ticketPriorityLabel[t.priority])}</span></td>
<td class="muted">${t.machine ? esc(t.machine.seriennummer) : ''}${t.machine ? (extrasName(t.machine) ? ` · ${esc(extrasName(t.machine))}` : '') : ''}</td>
</tr>`,
)
.join('')}
</tbody>
</table>
</div>
</div>`;
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 = `
<div class="stack">
<p><a href="#/tickets">← Zurück</a></p>
<h2>${esc(ticket.title)}</h2>
<div class="card" id="tick-view">
<p><strong>Status:</strong> <span class="badge ${statusBadgeClass[ticket.status] || ''}">${esc(ticketStatusLabel[ticket.status])}</span></p>
<p><strong>Priorität:</strong> ${esc(ticketPriorityLabel[ticket.priority])}</p>
<p><strong>Beschreibung:</strong></p>
<p style="white-space:pre-wrap">${esc(ticket.description)}</p>
${ticket.machine ? `<p><strong>Maschine:</strong> <a href="#/machines/${ticket.machine.id}">${esc(ticket.machine.seriennummer)}</a>${mn ? ` · ${esc(mn)}` : ''}</p>` : ''}
<button type="button" id="btn-t-edit">Bearbeiten</button>
</div>
<h3>Historie</h3>
<div class="card">
<h4>Event hinzufügen</h4>
<form id="form-ev" class="stack">
<label>Art
<select id="ev-type-sel" name="type" required>
<option value="NOTE">${eventTypeLabel.NOTE}</option>
<option value="CALL">${eventTypeLabel.CALL}</option>
<option value="REMOTE">${eventTypeLabel.REMOTE}</option>
<option value="PART">${eventTypeLabel.PART}</option>
</select>
</label>
<div class="ev-field-group" data-ev-type="NOTE">
<label>Beschreibung <textarea name="description_note" rows="3" required></textarea></label>
</div>
<div class="ev-field-group" data-ev-type="CALL" hidden>
<label>Beschreibung <textarea name="description_call" rows="3"></textarea></label>
<label>Rückrufnummer <input name="callbackNumber" type="tel" autocomplete="tel" /></label>
</div>
<div class="ev-field-group" data-ev-type="REMOTE" hidden>
<label>Beschreibung <textarea name="description_remote" rows="3" placeholder="Was wurde remote erledigt?"></textarea></label>
<div class="tv-device-row">
<label>Benutzer (TeamViewer)
<select id="tv-user-select" aria-label="TeamViewer Benutzer">
<option value="">— zuerst Art „Remote“ wählen —</option>
</select>
</label>
<label>Gerät / Session
<select name="teamviewerDevice" id="tv-conn-select" disabled aria-label="TeamViewer Gerät">
<option value="">— zuerst Benutzer wählen —</option>
</select>
</label>
<button type="button" class="secondary" id="btn-tv-reload">Liste aktualisieren</button>
</div>
<p class="muted tv-conn-hint" id="tv-conn-hint"></p>
</div>
<div class="ev-field-group" data-ev-type="PART" hidden>
<label>Artikelnummer <input name="articleNumber" /></label>
<label>Bemerkung <span class="muted">(optional)</span>
<textarea name="description_part" rows="2"></textarea>
</label>
</div>
<button type="submit">Event speichern</button>
</form>
</div>
<div class="card table-wrap">
<table class="events-table">
<thead>
<tr>
<th scope="col">Zeitpunkt</th>
<th scope="col">Art</th>
<th scope="col">Inhalt</th>
</tr>
</thead>
<tbody>
${
events.length === 0
? `<tr><td colspan="3" class="muted">Noch keine Ereignisse.</td></tr>`
: events
.map(
(ev) => `
<tr>
<td class="events-table-time">${esc(formatDateTime(ev.createdAt))}</td>
<td><span class="badge event-type-badge ${eventTypeBadgeClass[ev.type] || ''}">${esc(eventTypeLabel[ev.type] || ev.type)}</span></td>
<td class="events-table-desc">${eventInhaltHtml(ev)}</td>
</tr>`,
)
.join('')
}
</tbody>
</table>
</div>
<h3>Weiteres Ticket für diese Maschine</h3>
<p class="muted">Optional.</p>
<div class="card">
<form id="form-t2" class="stack">
<div class="row">
<label>Titel <input name="title" required /></label>
<label>Beschreibung <textarea name="description" required></textarea></label>
</div>
<button type="submit">Ticket erstellen</button>
</form>
</div>
</div>`;
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',
'<p class="error tv-form-err">Beschreibung oder Gerät auswählen.</p>',
);
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 = `
<form id="form-tu" class="stack">
<label>Titel <input name="title" value="${esc(ticket.title)}" required /></label>
<label>Beschreibung <textarea name="description" required>${esc(ticket.description)}</textarea></label>
<div class="row">
<label>Status
<select name="status">
<option value="OPEN" ${ticket.status === 'OPEN' ? 'selected' : ''}>${ticketStatusLabel.OPEN}</option>
<option value="WAITING" ${ticket.status === 'WAITING' ? 'selected' : ''}>${ticketStatusLabel.WAITING}</option>
<option value="DONE" ${ticket.status === 'DONE' ? 'selected' : ''}>${ticketStatusLabel.DONE}</option>
</select>
</label>
<label>Priorität
<select name="priority">
<option value="LOW" ${ticket.priority === 'LOW' ? 'selected' : ''}>${ticketPriorityLabel.LOW}</option>
<option value="MEDIUM" ${ticket.priority === 'MEDIUM' ? 'selected' : ''}>${ticketPriorityLabel.MEDIUM}</option>
<option value="HIGH" ${ticket.priority === 'HIGH' ? 'selected' : ''}>${ticketPriorityLabel.HIGH}</option>
</select>
</label>
</div>
<div class="row">
<button type="submit">Speichern</button>
<button type="button" class="secondary" id="tu-cancel">Abbrechen</button>
</div>
</form>`;
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();