This commit is contained in:
2026-03-23 02:09:14 +01:00
parent 705329d3c2
commit d8d46ed8e9
61 changed files with 6054 additions and 3116 deletions

32
public/js/pages/bootstrap.js vendored Normal file
View File

@@ -0,0 +1,32 @@
import { apiPost, authFetchStatus } from '../api.js';
const loadingEl = document.getElementById('page-loading');
const panel = document.getElementById('bootstrap-panel');
async function init() {
let st;
try {
st = await authFetchStatus();
} catch {
st = { needsBootstrap: false, loggedIn: false };
}
if (!st.needsBootstrap) {
location.href = st.loggedIn ? '/start.html' : '/login.html';
return;
}
loadingEl.hidden = true;
panel.hidden = false;
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.href = '/start.html';
};
}
init();

44
public/js/pages/login.js Normal file
View File

@@ -0,0 +1,44 @@
import { apiPost, authFetchStatus } from '../api.js';
const loadingEl = document.getElementById('page-loading');
const panel = document.getElementById('login-panel');
async function init() {
let st;
try {
st = await authFetchStatus();
} catch {
st = { needsBootstrap: false, loggedIn: false };
}
if (st.needsBootstrap) {
location.href = '/bootstrap.html';
return;
}
if (st.loggedIn) {
location.href = '/start.html';
return;
}
loadingEl.hidden = true;
panel.hidden = false;
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.href = '/start.html';
} catch (err) {
document.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);
}
};
}
init();

View File

@@ -0,0 +1,170 @@
import {
apiDelete,
apiGet,
apiPost,
apiPut,
isAuthRedirectError,
} from '../api.js';
import { guard } from '../core/auth-guard.js';
import {
collectExtrasFromMachineForm,
extrasTableHtml,
} from '../core/machine-extras.js';
import { extrasName } from '../core/utils.js';
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const loadingEl = document.getElementById('page-loading');
const badIdEl = document.getElementById('machine-bad-id');
const errEl = document.getElementById('page-error');
const mainEl = document.getElementById('page-main');
const panelView = document.getElementById('panel-machine-view');
const formM = document.getElementById('form-m');
function showError(msg) {
loadingEl.hidden = true;
badIdEl.hidden = true;
mainEl.hidden = true;
errEl.hidden = false;
errEl.textContent = msg;
}
function fillView(m) {
document.getElementById('machine-title').textContent = m.name;
document.getElementById('m-typ').textContent = m.typ;
document.getElementById('m-serial').textContent = m.seriennummer;
document.getElementById('m-standort').textContent = m.standort;
const ort = extrasName(m);
const row = document.getElementById('m-extras-name-row');
if (ort) {
row.hidden = false;
document.getElementById('m-extras-name').textContent = ort;
} else {
row.hidden = true;
}
const tid = `/tickets.html?machineId=${encodeURIComponent(m.id)}`;
document.getElementById('m-link-tickets').href = tid;
document.getElementById('m-link-tickets-edit').href = tid;
document.getElementById('machine-extras-view').innerHTML = extrasTableHtml(m.extras);
}
function fillEdit(m) {
document.getElementById('input-m-name').value = m.name;
document.getElementById('input-m-typ').value = m.typ;
document.getElementById('input-m-serial').value = m.seriennummer;
document.getElementById('input-m-standort').value = m.standort;
document.getElementById('input-m-list-status').value = m.listStatus || '';
document.getElementById('machine-extras-edit').innerHTML = extrasTableHtml(m.extras, {
editable: true,
});
}
function showViewMode() {
panelView.hidden = false;
formM.hidden = true;
}
function showEditMode() {
panelView.hidden = true;
formM.hidden = false;
}
async function viewMachineDetail(id, options = {}) {
const { startInEditMode = false } = options;
const m = await apiGet(`/machines/${id}`);
fillView(m);
if (startInEditMode) {
fillEdit(m);
showEditMode();
} else {
document.getElementById('machine-extras-edit').innerHTML = '';
showViewMode();
}
document.getElementById('btn-m-del').onclick = async () => {
if (!confirm('Maschine wirklich löschen?')) return;
await apiDelete(`/machines/${id}`);
location.href = '/machines.html';
};
document.getElementById('btn-m-edit').onclick = () => {
fillEdit(m);
showEditMode();
};
document.getElementById('btn-m-dup').onclick = async () => {
const body = {
name: `${m.name} (Kopie)`,
typ: m.typ,
seriennummer: `${m.seriennummer} - Kopie`,
standort: m.standort,
listStatus: m.listStatus || '',
};
if (m.extras && typeof m.extras === 'object') {
body.extras = JSON.parse(JSON.stringify(m.extras));
}
try {
const created = await apiPost('/machines', body);
location.href = `/machine.html?id=${encodeURIComponent(created.id)}&edit=1`;
} catch (err) {
alert(err.message || 'Duplizieren fehlgeschlagen.');
}
};
formM.onsubmit = async (e) => {
e.preventDefault();
const body = {
name: formM.elements.name.value,
typ: formM.elements.typ.value,
seriennummer: formM.elements.seriennummer.value,
standort: formM.elements.standort.value,
listStatus: formM.elements.listStatus.value || '',
extras: collectExtrasFromMachineForm(formM, m),
};
await apiPut(`/machines/${id}`, body);
location.reload();
};
document.getElementById('m-cancel').onclick = () => {
fillView(m);
document.getElementById('machine-extras-edit').innerHTML = '';
showViewMode();
};
document.getElementById('btn-m-del-edit').onclick = async () => {
if (!confirm('Maschine wirklich löschen?')) return;
await apiDelete(`/machines/${id}`);
location.href = '/machines.html';
};
}
async function init() {
const st = await guard({ activeNav: 'machines' });
if (!st) return;
const params = new URLSearchParams(location.search);
const id = params.get('id');
const startInEditMode = params.get('edit') === '1';
if (!id || !UUID.test(id)) {
loadingEl.hidden = true;
badIdEl.hidden = false;
return;
}
loadingEl.hidden = true;
mainEl.hidden = false;
try {
await viewMachineDetail(id, { startInEditMode });
if (startInEditMode) {
history.replaceState(null, '', `/machine.html?id=${encodeURIComponent(id)}`);
}
} catch (e) {
if (isAuthRedirectError(e)) return;
showError(e.message || 'Fehler');
}
}
init();

108
public/js/pages/machines.js Normal file
View File

@@ -0,0 +1,108 @@
import { apiGet, apiPost, isAuthRedirectError } from '../api.js';
import { machineListStatusRowClass } from '../core/constants.js';
import { guard } from '../core/auth-guard.js';
import { esc } from '../core/utils.js';
function rowClassForListStatus(listStatus) {
const code =
listStatus && machineListStatusRowClass[listStatus] !== undefined
? listStatus
: '';
return machineListStatusRowClass[code];
}
const loadingEl = document.getElementById('page-loading');
const mainEl = document.getElementById('page-main');
const errEl = document.getElementById('page-error');
function showError(msg) {
loadingEl.hidden = true;
mainEl.hidden = true;
errEl.hidden = false;
errEl.textContent = msg;
}
function renderRows(machines) {
const tbody = document.getElementById('machine-table-body');
tbody.innerHTML = machines
.map((m) => {
const x = m.extras || {};
const rc = rowClassForListStatus(m.listStatus);
return `<tr class="${esc(rc)}">
<td><a href="/machine.html?id=${esc(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('');
}
function initNewMachineCollapse() {
const body = document.getElementById('new-machine-section-body');
const toggle = document.getElementById('new-machine-toggle');
const chev = toggle.querySelector('.ldap-chevron');
function setOpen(open) {
body.hidden = !open;
toggle.setAttribute('aria-expanded', String(open));
chev.textContent = open ? '▲' : '▼';
}
toggle.onclick = () => setOpen(body.hidden);
}
async function run() {
const machines = await apiGet('/machines');
document.getElementById('machine-count').textContent = String(machines.length);
renderRows(machines);
initNewMachineCollapse();
const formNew = document.getElementById('form-new-machine');
formNew.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(formNew);
const btn = formNew.querySelector('button[type="submit"]');
btn.disabled = true;
try {
const body = Object.fromEntries(fd.entries());
const created = await apiPost('/machines', {
name: body.name,
typ: body.typ,
seriennummer: body.seriennummer,
standort: body.standort,
listStatus: body.listStatus || '',
});
location.href = `/machine.html?id=${encodeURIComponent(created.id)}`;
} catch (err) {
alert(err.message || 'Anlegen fehlgeschlagen.');
btn.disabled = false;
}
});
const inp = document.getElementById('machine-filter');
const tbody = document.getElementById('machine-table-body');
inp.addEventListener('input', () => {
const q = inp.value.toLowerCase().trim();
tbody.querySelectorAll('tr').forEach((tr) => {
tr.hidden = !!(q && !tr.textContent.toLowerCase().includes(q));
});
});
}
async function init() {
const st = await guard({ activeNav: 'machines' });
if (!st) return;
loadingEl.hidden = true;
mainEl.hidden = false;
try {
await run();
} catch (e) {
if (isAuthRedirectError(e)) return;
showError(e.message || 'Fehler');
}
}
init();

137
public/js/pages/options.js Normal file
View File

@@ -0,0 +1,137 @@
import { apiGet, apiPost, apiPut, isAuthRedirectError } from '../api.js';
import { guard } from '../core/auth-guard.js';
import { esc, formatDeSyncDateTime } from '../core/utils.js';
const loadingEl = document.getElementById('page-loading');
const mainEl = document.getElementById('page-main');
const errEl = document.getElementById('page-error');
function showError(msg) {
loadingEl.hidden = true;
mainEl.hidden = true;
errEl.hidden = false;
errEl.textContent = msg;
}
function renderSyncLog(entries) {
const tbody = document.getElementById('sync-log-body');
if (!entries.length) {
tbody.innerHTML =
'<tr><td colspan="5" class="muted">Noch keine Einträge.</td></tr>';
return;
}
tbody.innerHTML = entries
.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('');
}
function applyIntegrationForm(data) {
const ldap = data.ldap || {};
document.getElementById('ldap_syncEnabled').checked = Boolean(ldap.syncEnabled);
document.getElementById('ldap_serverUrl').value = ldap.serverUrl ?? '';
document.getElementById('ldap_searchBase').value = ldap.searchBase ?? '';
document.getElementById('ldap_bindDn').value = ldap.bindDn ?? '';
document.getElementById('ldap_bindPassword').value = '';
document.getElementById('ldap_userSearchFilter').value =
ldap.userSearchFilter || ldap.userFilter || '';
document.getElementById('ldap_usernameAttribute').value =
ldap.usernameAttribute ?? '';
document.getElementById('ldap_firstNameAttribute').value =
ldap.firstNameAttribute ?? '';
document.getElementById('ldap_lastNameAttribute').value =
ldap.lastNameAttribute ?? '';
document.getElementById('ldap_syncIntervalMinutes').value = String(
ldap.syncIntervalMinutes ?? 1440,
);
const tv = data.teamviewer || {};
document.getElementById('tv_bearerToken').value =
tv.bearerToken || tv.apiToken || '';
document.getElementById('tv_notes').value = tv.notes ?? tv.apiNotes ?? '';
}
async function run() {
const data = await apiGet('/settings/integrations');
let syncStatus = { lastSyncAt: null, entries: [] };
try {
syncStatus = await apiGet('/ldap/sync-status');
} catch {
/* ältere Server */
}
applyIntegrationForm(data);
document.getElementById('ldap-last-sync').textContent =
`Letzte Synchronisation: ${formatDeSyncDateTime(syncStatus.lastSyncAt)}`;
renderSyncLog(Array.isArray(syncStatus.entries) ? syncStatus.entries : []);
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', {});
location.reload();
} 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'),
},
});
location.reload();
};
}
async function init() {
const st = await guard({ needsAdmin: true, activeNav: 'options' });
if (!st) return;
loadingEl.hidden = true;
mainEl.hidden = false;
try {
await run();
} catch (e) {
if (isAuthRedirectError(e)) return;
showError(e.message || 'Fehler');
}
}
init();

157
public/js/pages/start.js Normal file
View File

@@ -0,0 +1,157 @@
import { apiGet, isAuthRedirectError } from '../api.js';
import { guard } from '../core/auth-guard.js';
import {
ticketStatusLabel,
ticketPriorityLabel,
eventTypeLabel,
eventTypeBadgeClass,
statusBadgeClass,
priorityBadgeClass,
} from '../core/constants.js';
import { esc, formatDateTime, extrasName } from '../core/utils.js';
import { bindAttachmentPreview } from '../core/attachment-preview.js';
import {
eventInhaltHtml,
sortEventsChronologicalWithAttachmentsLast,
} from '../core/ticket-events.js';
const loadingEl = document.getElementById('page-loading');
const mainEl = document.getElementById('page-main');
const errEl = document.getElementById('page-error');
const listEl = document.getElementById('home-ticket-list');
const emptyEl = document.getElementById('home-empty');
const tpl = document.getElementById('tpl-home-ticket');
function showError(msg) {
loadingEl.hidden = true;
mainEl.hidden = true;
errEl.hidden = false;
errEl.textContent = msg;
}
function renderEventBoxes(events) {
const evChrono = sortEventsChronologicalWithAttachmentsLast(events);
if (evChrono.length === 0) {
return '<p class="muted home-ticket-no-events">Noch keine Ereignisse in der Historie.</p>';
}
return 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('');
}
function fillTicketCard(node, t, events) {
const id = t.id;
const detailId = `home-ticket-detail-${id}`;
node.dataset.ticketId = id;
const btn = node.querySelector('.home-ticket-collapse-btn');
const panel = node.querySelector('.home-ticket-collapsible');
panel.id = detailId;
btn.setAttribute('aria-controls', detailId);
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 headEl = node.querySelector('.home-ticket-head');
headEl.classList.toggle('home-ticket-head-overdue', Boolean(t.isOverdue));
const titleA = node.querySelector('.js-ticket-link');
titleA.href = `/ticket.html?id=${encodeURIComponent(id)}`;
titleA.textContent = t.title;
const st = node.querySelector('.js-status');
st.textContent = ticketStatusLabel[t.status];
st.className = `badge js-status ${statusBadgeClass[t.status] || ''}`;
const pr = node.querySelector('.js-priority');
pr.textContent = ticketPriorityLabel[t.priority];
pr.className = `badge js-priority ${priorityBadgeClass[t.priority] || ''}`;
const metaM = node.querySelector('.js-meta-machine');
metaM.innerHTML = t.machine
? `<span class="muted">Maschine:</span> ${machineLabel}`
: '<span class="muted">Keine Maschine</span>';
node.querySelector('.js-meta-standort').textContent = standort;
node.querySelector('.js-meta-created').textContent = formatDateTime(t.createdAt);
node.querySelector('.js-meta-updated').textContent = formatDateTime(t.updatedAt);
const openA = node.querySelector('.js-ticket-open');
openA.href = `/ticket.html?id=${encodeURIComponent(id)}`;
node.querySelector('.js-desc').textContent = t.description;
const mBlock = node.querySelector('.js-machine-block');
if (t.machine) {
const m = t.machine;
mBlock.innerHTML = `<p class="home-ticket-machine"><strong>Maschine:</strong> <a href="/machine.html?id=${esc(m.id)}">${esc(m.seriennummer)}</a>${mn ? ` · ${esc(mn)}` : ''} &nbsp;·&nbsp; <strong>Typ:</strong> ${esc(m.typ)}</p>`;
} else {
mBlock.innerHTML = '';
}
node.querySelector('.js-events').innerHTML = renderEventBoxes(events);
const chev = btn.querySelector('.home-ticket-chevron');
btn.onclick = () => {
const willShow = panel.hidden;
panel.hidden = !willShow;
btn.setAttribute('aria-expanded', String(willShow));
chev.textContent = willShow ? '▲' : '▼';
};
}
async function run() {
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;
document.getElementById('kpi-open').textContent = `${openCount} Offen`;
document.getElementById('kpi-waiting').textContent = `${waitingCount} Wartend`;
document.getElementById('kpi-total').textContent = `gesamt: ${tickets.length}`;
listEl.innerHTML = '';
if (tickets.length === 0) {
emptyEl.hidden = false;
} else {
emptyEl.hidden = true;
tickets.forEach((t, i) => {
const frag = tpl.content.cloneNode(true);
const article = frag.querySelector('.home-ticket-card');
fillTicketCard(article, t, eventsLists[i] || []);
listEl.appendChild(article);
});
}
}
async function init() {
const st = await guard({ activeNav: 'start' });
if (!st) return;
loadingEl.hidden = true;
mainEl.hidden = false;
bindAttachmentPreview(document.body);
try {
await run();
} catch (e) {
if (isAuthRedirectError(e)) return;
showError(e.message || 'Fehler');
}
}
init();

View File

@@ -0,0 +1,283 @@
import { apiGet, apiPost, apiPostForm, apiPut, isAuthRedirectError } from '../api.js';
import { guard } from '../core/auth-guard.js';
import {
ticketStatusLabel,
ticketPriorityLabel,
eventTypeLabel,
eventTypeBadgeClass,
statusBadgeClass,
priorityBadgeClass,
} from '../core/constants.js';
import { esc, formatDateTime, extrasName } from '../core/utils.js';
import { bindAttachmentPreview } from '../core/attachment-preview.js';
import {
eventInhaltHtml,
syncEventFormFieldGroups,
buildEventPostBody,
loadTeamViewerConnectionsIntoSelect,
sortEventsChronologicalWithAttachmentsLast,
} from '../core/ticket-events.js';
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const loadingEl = document.getElementById('page-loading');
const badIdEl = document.getElementById('ticket-bad-id');
const errEl = document.getElementById('page-error');
const mainEl = document.getElementById('page-main');
const panelView = document.getElementById('panel-ticket-view');
const panelEdit = document.getElementById('panel-ticket-edit');
function showError(msg) {
loadingEl.hidden = true;
badIdEl.hidden = true;
mainEl.hidden = true;
errEl.hidden = false;
errEl.textContent = msg;
}
function fillTicketView(ticket) {
document.getElementById('ticket-title').textContent = ticket.title;
const stBadge = document.getElementById('t-status-badge');
stBadge.textContent = ticketStatusLabel[ticket.status];
stBadge.className = `badge ${statusBadgeClass[ticket.status] || ''}`;
document.getElementById('t-priority-label').textContent =
ticketPriorityLabel[ticket.priority];
const slaSel = document.getElementById('t-sla-days');
if (slaSel) {
slaSel.value =
ticket.slaDays != null && ticket.slaDays !== ''
? String(ticket.slaDays)
: '';
}
document.getElementById('t-description').textContent = ticket.description;
const mrow = document.getElementById('t-machine-row');
if (ticket.machine) {
mrow.hidden = false;
const mn = extrasName(ticket.machine);
const link = document.getElementById('t-machine-link');
link.href = `/machine.html?id=${encodeURIComponent(ticket.machine.id)}`;
link.textContent = ticket.machine.seriennummer;
document.getElementById('t-machine-suffix').textContent = mn
? ` · ${mn}`
: '';
} else {
mrow.hidden = true;
}
}
function fillEditForm(ticket) {
document.getElementById('tu-title').value = ticket.title;
document.getElementById('tu-desc').value = ticket.description;
document.getElementById('tu-status').value = ticket.status;
document.getElementById('tu-priority').value = ticket.priority;
}
function renderEvents(events) {
const tbody = document.getElementById('events-table-body');
if (events.length === 0) {
tbody.innerHTML =
'<tr><td colspan="3" class="muted">Noch keine Ereignisse.</td></tr>';
return;
}
tbody.innerHTML = 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('');
}
function showViewMode() {
panelView.hidden = false;
panelEdit.hidden = true;
}
function showEditMode() {
panelView.hidden = true;
panelEdit.hidden = false;
}
async function viewTicketDetail(id) {
const [ticket, events] = await Promise.all([
apiGet(`/tickets/${id}`),
apiGet(`/tickets/${id}/events`),
]);
let currentTicket = ticket;
fillTicketView(currentTicket);
fillEditForm(currentTicket);
showViewMode();
renderEvents(sortEventsChronologicalWithAttachmentsLast(events));
const sect2 = document.getElementById('sect-second-ticket');
if (currentTicket.machineId) {
sect2.hidden = false;
} else {
sect2.hidden = true;
}
const slaSel = document.getElementById('t-sla-days');
if (slaSel) {
slaSel.onchange = async () => {
const v = slaSel.value;
const slaDays = v === '' ? null : Number(v);
try {
const updated = await apiPut(`/tickets/${id}`, { slaDays });
const evs = await apiGet(`/tickets/${id}/events`);
currentTicket = updated;
fillTicketView(updated);
fillEditForm(updated);
renderEvents(sortEventsChronologicalWithAttachmentsLast(evs));
} catch (err) {
slaSel.value =
currentTicket.slaDays != null
? String(currentTicket.slaDays)
: '';
if (isAuthRedirectError(err)) return;
window.alert(err.message || 'Fehler beim Speichern der Fälligkeit.');
}
};
}
document.getElementById('btn-t-edit').onclick = () => {
fillEditForm(currentTicket);
showEditMode();
};
panelEdit.onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(panelEdit);
await apiPut(`/tickets/${id}`, Object.fromEntries(fd.entries()));
location.reload();
};
document.getElementById('tu-cancel').onclick = () => {
fillTicketView(currentTicket);
showViewMode();
};
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);
const evType = fd.get('type');
if (evType === 'ATTACHMENT') {
const fileInput = document.getElementById('ev-attachment-files');
const files = fileInput?.files;
if (!files || files.length === 0) {
formEv.insertAdjacentHTML(
'afterbegin',
'<p class="error tv-form-err">Mindestens eine Datei auswählen.</p>',
);
setTimeout(() => {
document.querySelector('.tv-form-err')?.remove();
}, 4000);
return;
}
const formData = new FormData();
formData.append(
'description',
String(fd.get('description_attachment') ?? '').trim(),
);
for (let i = 0; i < files.length; i += 1) {
formData.append('files', files[i]);
}
await apiPostForm(`/tickets/${id}/events/attachments`, formData);
e.target.reset();
syncEventFormFieldGroups(formEv);
location.reload();
return;
}
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 n = opt.getAttribute('data-notes');
if (n && String(n).trim()) body.teamviewerNotes = String(n).trim();
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);
location.reload();
};
document.getElementById('form-t2').onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
await apiPost('/tickets', {
machineId: currentTicket.machineId,
title: fd.get('title'),
description: fd.get('description'),
});
e.target.reset();
location.reload();
};
}
async function init() {
const st = await guard({ activeNav: 'tickets' });
if (!st) return;
const id = new URLSearchParams(location.search).get('id');
if (!id || !UUID.test(id)) {
loadingEl.hidden = true;
badIdEl.hidden = false;
return;
}
loadingEl.hidden = true;
mainEl.hidden = false;
bindAttachmentPreview(document.body);
try {
await viewTicketDetail(id);
} catch (e) {
if (isAuthRedirectError(e)) return;
showError(e.message || 'Fehler');
}
}
init();

122
public/js/pages/tickets.js Normal file
View File

@@ -0,0 +1,122 @@
import { apiGet, apiPost, isAuthRedirectError } from '../api.js';
import { guard } from '../core/auth-guard.js';
import {
ticketStatusLabel,
ticketPriorityLabel,
statusBadgeClass,
priorityBadgeClass,
} from '../core/constants.js';
import { esc, extrasName } from '../core/utils.js';
const loadingEl = document.getElementById('page-loading');
const mainEl = document.getElementById('page-main');
const errEl = document.getElementById('page-error');
function showError(msg) {
loadingEl.hidden = true;
mainEl.hidden = true;
errEl.hidden = false;
errEl.textContent = msg;
}
function ticketListQuery() {
const q = new URLSearchParams(location.search);
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}` : '';
}
function fillMachineSelects(allMachines, selectedMid) {
const selNm = document.getElementById('sel-nm');
const selFm = document.getElementById('sel-fm');
const opts = allMachines
.map(
(m) =>
`<option value="${esc(m.id)}">${esc(m.seriennummer)}${extrasName(m) ? ` · ${esc(extrasName(m))}` : ''}</option>`,
)
.join('');
selNm.innerHTML = `<option value="">— wählen —</option>${opts}`;
selFm.innerHTML = `<option value="">Alle</option>${opts}`;
if (selectedMid) {
selNm.value = selectedMid;
selFm.value = selectedMid;
}
}
function renderTicketRows(tickets) {
const tbody = document.getElementById('tickets-table-body');
tbody.innerHTML = tickets
.map(
(t) => `
<tr${t.isOverdue ? ' class="ticket-row-overdue"' : ''}>
<td><a href="/ticket.html?id=${esc(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('');
}
async function run() {
const qs = ticketListQuery();
const urlParams = new URLSearchParams(location.search);
const [tickets, allMachines] = await Promise.all([
apiGet(`/tickets${qs}`),
apiGet('/machines'),
]);
const mid = urlParams.get('machineId') || '';
fillMachineSelects(allMachines, mid);
const filterStatus = urlParams.get('status') || '';
const filterPriority = urlParams.get('priority') || '';
const formFilter = document.getElementById('form-filter');
formFilter.elements.status.value = filterStatus;
formFilter.elements.priority.value = filterPriority;
renderTicketRows(tickets);
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();
fillMachineSelects(allMachines, mid);
location.reload();
};
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.href = `/tickets.html${q ? `?${q}` : ''}`;
};
}
async function init() {
const st = await guard({ activeNav: 'tickets' });
if (!st) return;
loadingEl.hidden = true;
mainEl.hidden = false;
try {
await run();
} catch (e) {
if (isAuthRedirectError(e)) return;
showError(e.message || 'Fehler');
}
}
init();

93
public/js/pages/users.js Normal file
View File

@@ -0,0 +1,93 @@
import { apiDelete, apiGet, apiPost, apiPut, isAuthRedirectError } from '../api.js';
import { guard } from '../core/auth-guard.js';
import { esc } from '../core/utils.js';
const loadingEl = document.getElementById('page-loading');
const mainEl = document.getElementById('page-main');
const errEl = document.getElementById('page-error');
function showError(msg) {
loadingEl.hidden = true;
mainEl.hidden = true;
errEl.hidden = false;
errEl.textContent = msg;
}
function renderRows(users) {
const tbody = document.getElementById('users-table-body');
tbody.innerHTML = 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('');
}
async function run() {
const users = await apiGet('/users');
renderRows(users);
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();
location.reload();
};
const root = document.getElementById('page-main');
root.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 });
location.reload();
};
});
root.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 });
location.reload();
};
});
root.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}`);
location.reload();
};
});
}
async function init() {
const st = await guard({ needsAdmin: true, activeNav: 'users' });
if (!st) return;
loadingEl.hidden = true;
mainEl.hidden = false;
try {
await run();
} catch (e) {
if (isAuthRedirectError(e)) return;
showError(e.message || 'Fehler');
}
}
init();