1503 lines
55 KiB
JavaScript
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, '&')
|
|
.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 = `
|
|
<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 <token></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)}` : ''} · <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();
|