V0.1
This commit is contained in:
@@ -19,16 +19,14 @@ async function parseError(res, text) {
|
||||
}
|
||||
|
||||
function redirectToLogin() {
|
||||
if (
|
||||
!location.hash.startsWith('#/login') &&
|
||||
!location.hash.startsWith('#/bootstrap')
|
||||
) {
|
||||
location.hash = '#/login';
|
||||
const p = location.pathname;
|
||||
if (p !== '/login.html' && p !== '/bootstrap.html') {
|
||||
location.href = '/login.html';
|
||||
}
|
||||
}
|
||||
|
||||
/** Geschützte REST-API liegt unter /api (Root-URLs bleiben für die SPA frei). */
|
||||
function apiUrl(path) {
|
||||
export function apiUrl(path) {
|
||||
if (path.startsWith('/auth/')) return path;
|
||||
return `/api${path.startsWith('/') ? path : `/${path}`}`;
|
||||
}
|
||||
@@ -105,6 +103,23 @@ export async function apiDelete(path) {
|
||||
return apiRequest('DELETE', path);
|
||||
}
|
||||
|
||||
/** multipart/form-data (kein Content-Type setzen — Boundary setzt der Browser) */
|
||||
export async function apiPostForm(path, formData) {
|
||||
const url = apiUrl(path);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (res.status === 401) {
|
||||
onUnauthorized(path);
|
||||
throw authRedirectError();
|
||||
}
|
||||
if (!res.ok) throw new Error(await parseError(res, text));
|
||||
return parseJsonBody(text);
|
||||
}
|
||||
|
||||
/** Öffentlich: keine Session nötig, kein Redirect bei 401 */
|
||||
export async function authFetchStatus() {
|
||||
const res = await fetch('/auth/status', { credentials: 'include' });
|
||||
|
||||
1502
public/js/app.js
1502
public/js/app.js
File diff suppressed because it is too large
Load Diff
165
public/js/core/attachment-preview.js
Normal file
165
public/js/core/attachment-preview.js
Normal file
@@ -0,0 +1,165 @@
|
||||
/** Download-URL (href der API) → dieselbe URL mit ?inline=1 für Anzeige */
|
||||
export function hrefToInlineView(href) {
|
||||
if (!href) return href;
|
||||
const u = new URL(href, window.location.origin);
|
||||
u.searchParams.set('inline', '1');
|
||||
return u.pathname + u.search + u.hash;
|
||||
}
|
||||
|
||||
const TEXT_PREVIEW_MAX = 512 * 1024;
|
||||
|
||||
/** @param {string} mime @param {string} fileName */
|
||||
export function attachmentPreviewKind(mime, fileName) {
|
||||
const m = (mime || '').toLowerCase().trim();
|
||||
const ext = (fileName || '').split('.').pop()?.toLowerCase() || '';
|
||||
if (m.startsWith('image/')) return 'image';
|
||||
if (m === 'application/pdf' || ext === 'pdf') return 'pdf';
|
||||
if (m.startsWith('video/')) return 'video';
|
||||
if (m.startsWith('audio/')) return 'audio';
|
||||
if (
|
||||
m.startsWith('text/') ||
|
||||
m === 'application/json' ||
|
||||
m === 'application/xml' ||
|
||||
['csv', 'json', 'xml', 'txt', 'log', 'md', 'svg'].includes(ext)
|
||||
) {
|
||||
return 'text';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
let dialogEl;
|
||||
let bodyEl;
|
||||
let titleEl;
|
||||
let downloadLink;
|
||||
|
||||
function setPageScrollLocked(locked) {
|
||||
if (locked) {
|
||||
document.documentElement.style.overflow = 'hidden';
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.documentElement.style.overflow = '';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDialog() {
|
||||
if (dialogEl) return;
|
||||
dialogEl = document.createElement('dialog');
|
||||
dialogEl.className = 'attachment-preview-dialog';
|
||||
dialogEl.setAttribute('aria-modal', 'true');
|
||||
dialogEl.innerHTML = `
|
||||
<div class="attachment-preview-inner">
|
||||
<header class="attachment-preview-header">
|
||||
<h3 class="attachment-preview-title"></h3>
|
||||
<button type="button" class="attachment-preview-close" aria-label="Schließen">×</button>
|
||||
</header>
|
||||
<div class="attachment-preview-body"></div>
|
||||
<footer class="attachment-preview-footer">
|
||||
<a class="button secondary attachment-preview-download" href="#" download>Herunterladen</a>
|
||||
</footer>
|
||||
</div>`;
|
||||
document.body.appendChild(dialogEl);
|
||||
bodyEl = dialogEl.querySelector('.attachment-preview-body');
|
||||
titleEl = dialogEl.querySelector('.attachment-preview-title');
|
||||
downloadLink = dialogEl.querySelector('.attachment-preview-download');
|
||||
dialogEl.querySelector('.attachment-preview-close').addEventListener('click', () => {
|
||||
dialogEl.close();
|
||||
});
|
||||
dialogEl.addEventListener('click', (e) => {
|
||||
if (e.target === dialogEl) dialogEl.close();
|
||||
});
|
||||
dialogEl.addEventListener('close', () => {
|
||||
if (bodyEl) bodyEl.innerHTML = '';
|
||||
setPageScrollLocked(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Klick-Delegation: Links mit .js-attachment-preview öffnen das Modal.
|
||||
*/
|
||||
export function bindAttachmentPreview(root = document.body) {
|
||||
ensureDialog();
|
||||
root.addEventListener('click', (e) => {
|
||||
const a = e.target.closest('a.js-attachment-preview');
|
||||
if (!a) return;
|
||||
e.preventDefault();
|
||||
openAttachmentPreview(a);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLAnchorElement} a
|
||||
*/
|
||||
export async function openAttachmentPreview(a) {
|
||||
ensureDialog();
|
||||
const name = a.getAttribute('data-name') || a.textContent?.trim() || 'Datei';
|
||||
const mime = a.getAttribute('data-mime') || '';
|
||||
const rawHref = a.getAttribute('href') || '';
|
||||
const viewUrl = hrefToInlineView(rawHref);
|
||||
const kind = attachmentPreviewKind(mime, name);
|
||||
|
||||
titleEl.textContent = name;
|
||||
downloadLink.href = rawHref;
|
||||
downloadLink.setAttribute('download', name);
|
||||
|
||||
bodyEl.className = 'attachment-preview-body attachment-preview-body--scroll';
|
||||
bodyEl.innerHTML = '<p class="muted attachment-preview-loading">Lade Vorschau …</p>';
|
||||
|
||||
try {
|
||||
if (kind === 'image') {
|
||||
bodyEl.innerHTML = '';
|
||||
const img = document.createElement('img');
|
||||
img.className = 'attachment-preview-img';
|
||||
img.alt = name;
|
||||
img.src = viewUrl;
|
||||
img.referrerPolicy = 'same-origin';
|
||||
bodyEl.appendChild(img);
|
||||
} else if (kind === 'pdf') {
|
||||
bodyEl.className = 'attachment-preview-body attachment-preview-body--embed';
|
||||
bodyEl.innerHTML = '';
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.className = 'attachment-preview-iframe';
|
||||
iframe.title = name;
|
||||
iframe.src = viewUrl;
|
||||
bodyEl.appendChild(iframe);
|
||||
} else if (kind === 'video') {
|
||||
bodyEl.innerHTML = '';
|
||||
const v = document.createElement('video');
|
||||
v.className = 'attachment-preview-video';
|
||||
v.controls = true;
|
||||
v.playsInline = true;
|
||||
v.src = viewUrl;
|
||||
bodyEl.appendChild(v);
|
||||
} else if (kind === 'audio') {
|
||||
bodyEl.innerHTML = '';
|
||||
const v = document.createElement('audio');
|
||||
v.className = 'attachment-preview-audio';
|
||||
v.controls = true;
|
||||
v.src = viewUrl;
|
||||
bodyEl.appendChild(v);
|
||||
} else if (kind === 'text') {
|
||||
const res = await fetch(viewUrl, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error('Laden fehlgeschlagen');
|
||||
let text = await res.text();
|
||||
if (text.length > TEXT_PREVIEW_MAX) {
|
||||
text = `${text.slice(0, TEXT_PREVIEW_MAX)}\n\n… (gekürzt)`;
|
||||
}
|
||||
bodyEl.innerHTML = '';
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'attachment-preview-text';
|
||||
pre.textContent = text;
|
||||
bodyEl.appendChild(pre);
|
||||
} else {
|
||||
bodyEl.innerHTML =
|
||||
'<p class="muted">Für diesen Dateityp gibt es keine eingebaute Vorschau. Nutzen Sie „Herunterladen“.</p>';
|
||||
}
|
||||
} catch (err) {
|
||||
bodyEl.className = 'attachment-preview-body attachment-preview-body--scroll';
|
||||
bodyEl.innerHTML = `<p class="error">Vorschau konnte nicht geladen werden: ${err.message || err}</p>`;
|
||||
}
|
||||
|
||||
if (typeof dialogEl.showModal === 'function') {
|
||||
setPageScrollLocked(true);
|
||||
dialogEl.showModal();
|
||||
}
|
||||
}
|
||||
49
public/js/core/auth-guard.js
Normal file
49
public/js/core/auth-guard.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { authFetchStatus, isAuthRedirectError } from '../api.js';
|
||||
import { updateNav } from './layout.js';
|
||||
|
||||
window.addEventListener('unhandledrejection', (ev) => {
|
||||
if (isAuthRedirectError(ev.reason)) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {{ needsAdmin?: boolean, activeNav?: string }} opts
|
||||
* @returns {Promise<object|null>} Session-Status oder null bei Redirect
|
||||
*/
|
||||
export async function guard(opts = {}) {
|
||||
const { needsAdmin = false, activeNav = '' } = opts;
|
||||
let st;
|
||||
try {
|
||||
st = await authFetchStatus();
|
||||
} catch {
|
||||
st = { needsBootstrap: false, loggedIn: false, user: null };
|
||||
}
|
||||
if (st.needsBootstrap) {
|
||||
if (!location.pathname.endsWith('/bootstrap.html')) {
|
||||
location.href = '/bootstrap.html';
|
||||
return null;
|
||||
}
|
||||
return st;
|
||||
}
|
||||
if (!st.loggedIn) {
|
||||
if (!location.pathname.endsWith('/login.html')) {
|
||||
location.href = '/login.html';
|
||||
return null;
|
||||
}
|
||||
return st;
|
||||
}
|
||||
if (
|
||||
location.pathname.endsWith('/login.html') ||
|
||||
location.pathname.endsWith('/bootstrap.html')
|
||||
) {
|
||||
location.href = '/start.html';
|
||||
return null;
|
||||
}
|
||||
if (needsAdmin && st.user?.role !== 'admin') {
|
||||
location.href = '/start.html';
|
||||
return null;
|
||||
}
|
||||
updateNav(st, activeNav);
|
||||
return st;
|
||||
}
|
||||
64
public/js/core/constants.js
Normal file
64
public/js/core/constants.js
Normal file
@@ -0,0 +1,64 @@
|
||||
export const ticketStatusLabel = {
|
||||
OPEN: 'Offen',
|
||||
WAITING: 'Warte auf Rückmeldung',
|
||||
DONE: 'Erledigt',
|
||||
};
|
||||
|
||||
export const ticketPriorityLabel = {
|
||||
LOW: 'Niedrig',
|
||||
MEDIUM: 'Mittel',
|
||||
HIGH: 'Hoch',
|
||||
};
|
||||
|
||||
export const eventTypeLabel = {
|
||||
NOTE: 'Notiz',
|
||||
CALL: 'Anruf',
|
||||
REMOTE: 'Remote',
|
||||
PART: 'Ersatzteil benötigt',
|
||||
ATTACHMENT: 'Anhang',
|
||||
SYSTEM: 'System',
|
||||
};
|
||||
|
||||
export const eventTypeBadgeClass = {
|
||||
NOTE: 'event-type-note',
|
||||
CALL: 'event-type-call',
|
||||
REMOTE: 'event-type-remote',
|
||||
PART: 'event-type-part',
|
||||
ATTACHMENT: 'event-type-attachment',
|
||||
SYSTEM: 'event-type-system',
|
||||
};
|
||||
|
||||
export const statusBadgeClass = {
|
||||
OPEN: 'badge-open',
|
||||
WAITING: 'badge-waiting',
|
||||
DONE: 'badge-done',
|
||||
};
|
||||
|
||||
export const priorityBadgeClass = {
|
||||
HIGH: 'badge-high',
|
||||
MEDIUM: 'badge-medium',
|
||||
LOW: 'badge-low',
|
||||
};
|
||||
|
||||
/** Einheitlicher Platzhalter, wenn noch keine Gruppenzeile gesetzt war */
|
||||
export const ANLAGEN_GRUPPE_PLACEHOLDER = '\u2014';
|
||||
|
||||
/** Listen-Status Maschinen (Anlagenliste) — Codes wie in der API/DB */
|
||||
export const machineListStatusLabel = {
|
||||
'': '',
|
||||
PRUEFEN: 'Prüfen!',
|
||||
VERSCHROTTET: 'Verschrottet',
|
||||
SN_GEAENDERT: 'SN geändert',
|
||||
IN_BEARBEITUNG: 'In Bearbeitung',
|
||||
UPDATE_RAUS: 'Update raus',
|
||||
};
|
||||
|
||||
/** Zeilenklasse in der Maschinenliste (volle Zeilenfarbe) */
|
||||
export const machineListStatusRowClass = {
|
||||
'': 'machine-row--none',
|
||||
PRUEFEN: 'machine-row--pruefen',
|
||||
VERSCHROTTET: 'machine-row--verschrottet',
|
||||
SN_GEAENDERT: 'machine-row--sn',
|
||||
IN_BEARBEITUNG: 'machine-row--bearbeitung',
|
||||
UPDATE_RAUS: 'machine-row--update',
|
||||
};
|
||||
39
public/js/core/layout.js
Normal file
39
public/js/core/layout.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { apiPost } from '../api.js';
|
||||
import { esc } from './utils.js';
|
||||
|
||||
/**
|
||||
* @param {string} [activeNav] 'start' | 'machines' | 'tickets' | 'options' | 'users'
|
||||
*/
|
||||
export function updateNav(st, activeNav = '') {
|
||||
const nav = document.getElementById('main-nav');
|
||||
if (!nav) return;
|
||||
if (!st.loggedIn) {
|
||||
nav.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const isAdmin = st.user?.role === 'admin';
|
||||
const na = (key) => (activeNav === key ? 'nav-active' : '');
|
||||
nav.innerHTML = `
|
||||
<a href="/start.html" class="${na('start')}">Start</a>
|
||||
<a href="/machines.html" class="${na('machines')}">Maschinen</a>
|
||||
<a href="/tickets.html" class="${na('tickets')}">Tickets</a>
|
||||
${
|
||||
isAdmin
|
||||
? `<a href="/options.html" class="${na('options')}">Optionen</a><a href="/users.html" class="${na('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.href = '/login.html';
|
||||
};
|
||||
}
|
||||
}
|
||||
247
public/js/core/machine-extras.js
Normal file
247
public/js/core/machine-extras.js
Normal file
@@ -0,0 +1,247 @@
|
||||
import { esc } from './utils.js';
|
||||
import { ANLAGEN_GRUPPE_PLACEHOLDER } from './constants.js';
|
||||
|
||||
/** Baut das extras-Objekt aus dem Maschinen-Bearbeitungsformular (Anlagenliste). */
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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>`;
|
||||
}
|
||||
|
||||
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</h3>
|
||||
<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>`;
|
||||
}
|
||||
220
public/js/core/ticket-events.js
Normal file
220
public/js/core/ticket-events.js
Normal file
@@ -0,0 +1,220 @@
|
||||
import { apiGet, apiUrl } from '../api.js';
|
||||
import { esc, formatDateTime, formatRemoteDurationDe, telHref } from './utils.js';
|
||||
|
||||
function formatFileSizeDe(n) {
|
||||
if (n == null || typeof n !== 'number' || n < 0) return '';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Zwischenspeicher für GET /integrations/teamviewer/connections */
|
||||
let tvSessionsCache = null;
|
||||
|
||||
/** HTML für die Inhaltsspalte (nur server-/formularbekannte Typen) */
|
||||
export 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) {
|
||||
const th = telHref(ev.callbackNumber);
|
||||
const numHtml = th
|
||||
? `<a href="${esc(th)}">${esc(ev.callbackNumber)}</a>`
|
||||
: esc(ev.callbackNumber);
|
||||
h += `<p class="event-inhalt-meta"><strong>Rückrufnummer:</strong> ${numHtml}</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>`;
|
||||
}
|
||||
if (ev.teamviewerNotes && String(ev.teamviewerNotes).trim()) {
|
||||
h += `<p class="event-inhalt-meta"><strong>Notizen:</strong> <span class="event-tv-notes" style="white-space:pre-wrap">${esc(String(ev.teamviewerNotes).trim())}</span></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>`;
|
||||
}
|
||||
if (t === 'ATTACHMENT') {
|
||||
let h = '';
|
||||
if (ev.description && String(ev.description).trim()) {
|
||||
h += `<div class="event-inhalt-block"><p class="event-inhalt-label">Beschreibung</p><div class="event-inhalt-text">${esc(ev.description)}</div></div>`;
|
||||
}
|
||||
const atts = ev.attachments || [];
|
||||
if (atts.length === 0) {
|
||||
h += '<p class="muted">Keine Dateien.</p>';
|
||||
} else {
|
||||
h += '<ul class="event-attachment-list">';
|
||||
for (const a of atts) {
|
||||
const href = apiUrl(a.url);
|
||||
const timeParen =
|
||||
a.createdAt != null
|
||||
? ` <span class="muted">(${esc(formatDateTime(a.createdAt))})</span>`
|
||||
: '';
|
||||
h += `<li><a href="${esc(href)}" class="js-attachment-preview" data-mime="${esc(a.mimeType || '')}" data-name="${esc(a.originalName)}">${esc(a.originalName)}</a>${timeParen}`;
|
||||
if (a.sizeBytes != null) {
|
||||
h += ` <span class="muted">· ${esc(formatFileSizeDe(a.sizeBytes))}</span>`;
|
||||
}
|
||||
h += '</li>';
|
||||
}
|
||||
h += '</ul>';
|
||||
}
|
||||
return h;
|
||||
}
|
||||
return `<div class="event-inhalt-text">${esc(ev.description)}</div>`;
|
||||
}
|
||||
|
||||
export 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 || '')}" data-notes="${esc(d.notes || '')}">${esc(d.label)}</option>`,
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
export 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';
|
||||
}
|
||||
}
|
||||
|
||||
export 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') req = true;
|
||||
if (v === 'REMOTE' && name === 'description_remote') req = false;
|
||||
if (v === 'PART' && name === 'articleNumber') req = true;
|
||||
if (v === 'ATTACHMENT') req = false;
|
||||
}
|
||||
inp.required = req;
|
||||
});
|
||||
});
|
||||
if (v === 'REMOTE') {
|
||||
loadTeamViewerConnectionsIntoSelect();
|
||||
}
|
||||
}
|
||||
|
||||
/** Neueste zuerst; ATTACHMENT-Blöcke bleiben unten (innerhalb ebenfalls neuer über älter). */
|
||||
export function sortEventsChronologicalWithAttachmentsLast(events) {
|
||||
const non = events
|
||||
.filter((e) => e.type !== 'ATTACHMENT')
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
const att = events
|
||||
.filter((e) => e.type === 'ATTACHMENT')
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
return [...non, ...att];
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
60
public/js/core/utils.js
Normal file
60
public/js/core/utils.js
Normal file
@@ -0,0 +1,60 @@
|
||||
export function esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** Rufnummer für href="tel:…" (Ziffern, höchstens ein führendes +). */
|
||||
export function telHref(raw) {
|
||||
const t = String(raw ?? '').trim();
|
||||
if (!t) return '';
|
||||
const digitsPlus = t.replace(/[^\d+]/g, '');
|
||||
if (!digitsPlus) return '';
|
||||
const normalized = digitsPlus.startsWith('+')
|
||||
? '+' + digitsPlus.slice(1).replace(/\+/g, '')
|
||||
: digitsPlus.replace(/\+/g, '');
|
||||
return `tel:${normalized}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(iso) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString('de-DE');
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
export 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 '—';
|
||||
}
|
||||
}
|
||||
|
||||
export function extrasName(m) {
|
||||
const x = m?.extras;
|
||||
if (!x || typeof x !== 'object') return '';
|
||||
return String(x.Name || '').trim();
|
||||
}
|
||||
|
||||
/** Anzeige Dauer aus gespeicherten Sekunden (TeamViewer start/end). */
|
||||
export 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.`;
|
||||
}
|
||||
32
public/js/pages/bootstrap.js
vendored
Normal file
32
public/js/pages/bootstrap.js
vendored
Normal 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
44
public/js/pages/login.js
Normal 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();
|
||||
170
public/js/pages/machine-detail.js
Normal file
170
public/js/pages/machine-detail.js
Normal 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
108
public/js/pages/machines.js
Normal 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
137
public/js/pages/options.js
Normal 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
157
public/js/pages/start.js
Normal 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)}` : ''} · <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();
|
||||
283
public/js/pages/ticket-detail.js
Normal file
283
public/js/pages/ticket-detail.js
Normal 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
122
public/js/pages/tickets.js
Normal 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
93
public/js/pages/users.js
Normal 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();
|
||||
Reference in New Issue
Block a user