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

33
public/bootstrap.html Normal file
View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Erster Administrator — SDS CRM</title>
<link rel="stylesheet" href="/css/style.css" />
<link rel="stylesheet" href="/css/pages/auth.css" />
</head>
<body>
<header class="header">
<h1><a href="/start.html">SDS CRM</a></h1>
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
</header>
<main id="app" class="main">
<p id="page-loading" class="muted">Lade …</p>
<div id="bootstrap-panel" class="stack auth-panel" hidden>
<h2>Erster Administrator</h2>
<p class="muted">
Es ist noch kein Benutzer angelegt. Legen Sie das erste Admin-Konto an (min. 8 Zeichen Passwort).
</p>
<div class="card">
<form id="form-boot" class="stack">
<label>Benutzername <input name="username" required autocomplete="username" /></label>
<label>Passwort <input name="password" type="password" required minlength="8" autocomplete="new-password" /></label>
<button type="submit">Konto anlegen</button>
</form>
</div>
</div>
</main>
<script type="module" src="/js/pages/bootstrap.js"></script>
</body>
</html>

View File

@@ -0,0 +1 @@
/* Login / Bootstrap bei Bedarf seiten-spezifische Styles */

View File

@@ -0,0 +1 @@
/* Maschinen- und Ticket-Detail bei Bedarf seiten-spezifische Styles */

View File

@@ -0,0 +1,127 @@
/* Farben zentral — gleiche Werte für Zeilen und Legende */
.machines-overview {
--mrow-none: rgba(240, 243, 246, 0.12);
--mrow-pruefen: rgba(229, 57, 53, 0.45);
--mrow-verschrottet: rgba(206, 147, 216, 0.45);
--mrow-sn: rgba(102, 187, 106, 0.4);
--mrow-bearbeitung: rgba(255, 235, 59, 0.35);
--mrow-update: rgba(38, 198, 218, 0.4);
}
.machines-overview-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem 1.5rem;
}
.machines-overview-title h2 {
margin-bottom: 0.35rem;
}
/* Legende oben rechts */
.machine-legend {
flex-shrink: 0;
max-width: min(100%, 16rem);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.55rem 0.75rem 0.6rem;
background: var(--bg-card);
font-size: 0.82rem;
line-height: 1.35;
}
.machine-legend-title {
font-weight: 600;
color: var(--text-sub);
margin-bottom: 0.4rem;
font-size: 0.78rem;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.machine-legend-list {
list-style: none;
margin: 0;
padding: 0;
}
.machine-legend-list li {
display: flex;
align-items: center;
gap: 0.45rem;
margin: 0.15rem 0;
color: var(--text);
}
.machine-legend-swatch {
flex-shrink: 0;
width: 1rem;
height: 0.75rem;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
}
.machine-legend-swatch.machine-row--none {
background: var(--mrow-none);
}
.machine-legend-swatch.machine-row--pruefen {
background: var(--mrow-pruefen);
}
.machine-legend-swatch.machine-row--verschrottet {
background: var(--mrow-verschrottet);
}
.machine-legend-swatch.machine-row--sn {
background: var(--mrow-sn);
}
.machine-legend-swatch.machine-row--bearbeitung {
background: var(--mrow-bearbeitung);
}
.machine-legend-swatch.machine-row--update {
background: var(--mrow-update);
}
/* Maschinenliste: ganze Zeile gemäß Listen-Status */
#machine-table tbody tr.machine-row--none {
background: var(--mrow-none);
}
#machine-table tbody tr.machine-row--pruefen {
background: var(--mrow-pruefen);
}
#machine-table tbody tr.machine-row--verschrottet {
background: var(--mrow-verschrottet);
}
#machine-table tbody tr.machine-row--sn {
background: var(--mrow-sn);
}
#machine-table tbody tr.machine-row--bearbeitung {
background: var(--mrow-bearbeitung);
}
#machine-table tbody tr.machine-row--update {
background: var(--mrow-update);
}
#machine-table tbody tr.machine-row--pruefen a,
#machine-table tbody tr.machine-row--verschrottet a,
#machine-table tbody tr.machine-row--sn a,
#machine-table tbody tr.machine-row--bearbeitung a,
#machine-table tbody tr.machine-row--update a {
color: inherit;
font-weight: 600;
}
#machine-table tbody tr.machine-row--bearbeitung a {
color: var(--accent-hi);
}

View File

@@ -0,0 +1 @@
/* Optionen bei Bedarf seiten-spezifische Styles */

View File

@@ -0,0 +1 @@
/* Start bei Bedarf seiten-spezifische Styles */

View File

@@ -0,0 +1,37 @@
/* Tickets Neues Ticket: volle Breite für Titel & Beschreibung */
.ticket-new-form .ticket-form-machine label {
max-width: 36rem;
}
.ticket-new-form .ticket-form-machine select {
width: 100%;
min-width: 0;
}
.ticket-new-form .ticket-field-full {
width: 100%;
}
.ticket-new-form .ticket-field-full input,
.ticket-new-form .ticket-field-full textarea {
width: 100%;
min-width: 0;
max-width: 100%;
}
.ticket-new-form textarea.ticket-desc {
resize: vertical;
min-height: 8rem;
display: block;
}
.ticket-row-overdue {
background-color: rgba(180, 40, 40, 0.12);
box-shadow: inset 3px 0 0 0 #b42828;
}
.ticket-row-overdue a {
color: #8b1e1e;
font-weight: 600;
}

View File

@@ -0,0 +1 @@
/* Benutzer bei Bedarf seiten-spezifische Styles */

File diff suppressed because it is too large Load Diff

View File

@@ -4,14 +4,12 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SDS CRM</title>
<link rel="stylesheet" href="/css/style.css" />
<meta http-equiv="refresh" content="0;url=/start.html" />
<script>
location.replace('/start.html');
</script>
</head>
<body>
<header class="header">
<h1><a href="#/home">SDS CRM</a></h1>
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
</header>
<main id="app" class="main"></main>
<script type="module" src="/js/app.js"></script>
<p><a href="/start.html">Weiter zur Startseite</a></p>
</body>
</html>

View File

@@ -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' });

File diff suppressed because it is too large Load Diff

View 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();
}
}

View 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;
}

View 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
View 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';
};
}
}

View 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>`;
}

View 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
View File

@@ -0,0 +1,60 @@
export function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 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
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();

30
public/login.html Normal file
View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Anmelden — SDS CRM</title>
<link rel="stylesheet" href="/css/style.css" />
<link rel="stylesheet" href="/css/pages/auth.css" />
</head>
<body>
<header class="header">
<h1><a href="/start.html">SDS CRM</a></h1>
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
</header>
<main id="app" class="main">
<p id="page-loading" class="muted">Lade …</p>
<div id="login-panel" class="stack auth-panel" hidden>
<h2>Anmelden</h2>
<div class="card">
<form id="form-login" class="stack">
<label>Benutzername <input name="username" required autocomplete="username" /></label>
<label>Passwort <input name="password" type="password" required autocomplete="current-password" /></label>
<button type="submit">Anmelden</button>
</form>
</div>
</div>
</main>
<script type="module" src="/js/pages/login.js"></script>
</body>
</html>

85
public/machine.html Normal file
View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Maschine — SDS CRM</title>
<link rel="stylesheet" href="/css/style.css" />
<link rel="stylesheet" href="/css/pages/detail.css" />
</head>
<body>
<header class="header">
<h1><a href="/start.html">SDS CRM</a></h1>
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
</header>
<main id="app" class="main">
<p id="page-loading" class="muted">Lade …</p>
<div id="machine-bad-id" hidden>
<p class="error">Ungültige oder fehlende Maschinen-ID.</p>
<p><a href="/machines.html">← Zur Übersicht</a></p>
</div>
<p id="page-error" class="error" hidden></p>
<div id="page-main" class="stack" hidden>
<p><a href="/machines.html">← Alle Maschinen</a></p>
<h2 id="machine-title"></h2>
<div id="panel-machine-view">
<div class="card" id="mach-view">
<p><strong>Typ:</strong> <span id="m-typ"></span></p>
<p><strong>Seriennummer:</strong> <span id="m-serial"></span></p>
<p><strong>Standort:</strong> <span id="m-standort"></span></p>
<p id="m-extras-name-row" hidden>
<strong>Name (Anlagenliste):</strong> <span id="m-extras-name"></span>
</p>
<div class="row">
<button type="button" id="btn-m-edit">Bearbeiten</button>
<button type="button" class="secondary" id="btn-m-dup">Duplizieren</button>
<a
class="badge"
id="m-link-tickets"
href="#"
style="align-self: center"
>Tickets dieser Maschine</a
>
<button type="button" class="danger" id="btn-m-del">Löschen</button>
</div>
</div>
<div id="machine-extras-view"></div>
</div>
<form id="form-m" class="stack" hidden>
<div class="card">
<h3 class="machine-detail-card-title">Stammdaten</h3>
<div class="row machine-detail-actions">
<button type="submit">Speichern</button>
<button type="button" class="secondary" id="m-cancel">Abbrechen</button>
<a class="badge" id="m-link-tickets-edit" href="#">Tickets dieser Maschine</a>
<button type="button" class="danger" id="btn-m-del-edit">Löschen</button>
</div>
<div class="row">
<label>Name <input name="name" id="input-m-name" required /></label>
<label>Typ <input name="typ" id="input-m-typ" required /></label>
</div>
<div class="row">
<label>Seriennummer <input name="seriennummer" id="input-m-serial" required /></label>
<label>Standort <input name="standort" id="input-m-standort" required /></label>
</div>
<label
>Status (Liste)
<select name="listStatus" id="input-m-list-status">
<option value="">— keiner —</option>
<option value="PRUEFEN">Prüfen!</option>
<option value="VERSCHROTTET">Verschrottet</option>
<option value="SN_GEAENDERT">SN geändert</option>
<option value="IN_BEARBEITUNG">In Bearbeitung</option>
<option value="UPDATE_RAUS">Update raus</option>
</select>
</label>
</div>
<div id="machine-extras-edit"></div>
</form>
</div>
</main>
<script type="module" src="/js/pages/machine-detail.js"></script>
</body>
</html>

132
public/machines.html Normal file
View File

@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Maschinen — SDS CRM</title>
<link rel="stylesheet" href="/css/style.css" />
<link rel="stylesheet" href="/css/pages/machines.css" />
</head>
<body>
<header class="header">
<h1><a href="/start.html">SDS CRM</a></h1>
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
</header>
<main id="app" class="main">
<p id="page-loading" class="muted">Lade …</p>
<p id="page-error" class="error" hidden></p>
<div id="page-main" class="stack machines-overview" hidden>
<div class="machines-overview-header">
<div class="machines-overview-title">
<h2>Maschinen (Anlagenliste)</h2>
<p class="muted">
<span id="machine-count">0</span>
</p>
</div>
<aside class="machine-legend" aria-label="Legende Zeilenfarben">
<div class="machine-legend-title">Farben (Listen-Status)</div>
<ul class="machine-legend-list">
<li>
<span class="machine-legend-swatch machine-row--none" aria-hidden="true"></span>
<span>kein Status</span>
</li>
<li>
<span class="machine-legend-swatch machine-row--pruefen" aria-hidden="true"></span>
<span>Prüfen!</span>
</li>
<li>
<span class="machine-legend-swatch machine-row--verschrottet" aria-hidden="true"></span>
<span>Verschrottet</span>
</li>
<li>
<span class="machine-legend-swatch machine-row--sn" aria-hidden="true"></span>
<span>SN geändert</span>
</li>
<li>
<span class="machine-legend-swatch machine-row--bearbeitung" aria-hidden="true"></span>
<span>In Bearbeitung</span>
</li>
<li>
<span class="machine-legend-swatch machine-row--update" aria-hidden="true"></span>
<span>Update raus</span>
</li>
</ul>
</aside>
</div>
<div class="card">
<label
>Suche
<input
type="search"
id="machine-filter"
placeholder="Seriennummer, Typ, Ort, …"
style="min-width: min(100%, 22rem)"
/></label>
</div>
<div class="card table-wrap">
<table id="machine-table">
<thead>
<tr>
<th>Seriennr.</th>
<th>Typ</th>
<th>Konzern</th>
<th>Name (Excel)</th>
<th>Stadt</th>
<th>Land</th>
<th>Jahr</th>
</tr>
</thead>
<tbody id="machine-table-body"></tbody>
</table>
</div>
<div class="card options-section ldap-section machines-new-machine">
<button
type="button"
class="ldap-section-toggle"
id="new-machine-toggle"
aria-expanded="false"
aria-controls="new-machine-section-body"
>
<span class="ldap-section-heading">Neue Maschine anlegen</span>
<span class="ldap-chevron" aria-hidden="true"></span>
</button>
<div class="ldap-section-body" id="new-machine-section-body" hidden>
<form id="form-new-machine" class="stack">
<div class="row">
<label
>Name
<input
name="name"
required
autocomplete="organization"
placeholder="z.B. Anlage Standort A"
/></label>
<label>Typ <input name="typ" required placeholder="Maschinentyp" /></label>
</div>
<div class="row">
<label
>Seriennummer
<input name="seriennummer" required autocomplete="off" placeholder="eindeutig"
/></label>
<label>Standort <input name="standort" required placeholder="Ort / Adresse" /></label>
</div>
<label
>Status (Liste)
<select name="listStatus" id="new-machine-status">
<option value="">— keiner —</option>
<option value="PRUEFEN">Prüfen!</option>
<option value="VERSCHROTTET">Verschrottet</option>
<option value="SN_GEAENDERT">SN geändert</option>
<option value="IN_BEARBEITUNG">In Bearbeitung</option>
<option value="UPDATE_RAUS">Update raus</option>
</select>
</label>
<button type="submit">Maschine anlegen</button>
</form>
</div>
</div>
</div>
</main>
<script type="module" src="/js/pages/machines.js"></script>
</body>
</html>

157
public/options.html Normal file
View File

@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Optionen — SDS CRM</title>
<link rel="stylesheet" href="/css/style.css" />
<link rel="stylesheet" href="/css/pages/options.css" />
</head>
<body>
<header class="header">
<h1><a href="/start.html">SDS CRM</a></h1>
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
</header>
<main id="app" class="main">
<p id="page-loading" class="muted">Lade …</p>
<p id="page-error" class="error" hidden></p>
<div id="page-main" class="stack options-page" hidden>
<p><a href="/start.html">← Start</a></p>
<h2>Optionen</h2>
<p class="muted">Integrationen für das CRM. Werte werden in der Datenbank gespeichert.</p>
<form id="form-opt" class="stack">
<div class="card options-section ldap-section">
<button
type="button"
class="ldap-section-toggle"
id="ldap-toggle"
aria-expanded="true"
aria-controls="ldap-section-body"
>
<span class="ldap-section-heading">LDAP-Synchronisation</span>
<span class="ldap-chevron" aria-hidden="true"></span>
</button>
<div class="ldap-section-body" id="ldap-section-body">
<h4 class="ldap-subtitle">LDAP-Konfiguration</h4>
<label class="row-inline ldap-sync-check">
<input type="checkbox" name="ldap_syncEnabled" id="ldap_syncEnabled" value="on" />
LDAP-Synchronisation aktivieren
</label>
<div class="form-grid-2">
<label
>LDAP-Server URL
<input
name="ldap_serverUrl"
id="ldap_serverUrl"
type="text"
placeholder="ldap://…"
autocomplete="off"
/></label>
<label
>Base DN
<input name="ldap_searchBase" id="ldap_searchBase" placeholder="DC=…" autocomplete="off"
/></label>
<label>Bind DN (optional) <input name="ldap_bindDn" id="ldap_bindDn" autocomplete="off" /></label>
<label
>Bind Passwort (optional)
<input
name="ldap_bindPassword"
id="ldap_bindPassword"
type="password"
value=""
autocomplete="new-password"
placeholder="Leer lassen um nicht zu ändern"
/></label>
</div>
<label class="full-width"
>User Search Filter
<textarea
name="ldap_userSearchFilter"
id="ldap_userSearchFilter"
rows="4"
spellcheck="false"
class="ldap-filter-ta"
></textarea>
</label>
<div class="form-grid-ldap-attr">
<label
>Username-Attribut
<input
name="ldap_usernameAttribute"
id="ldap_usernameAttribute"
placeholder="sAMAccountName"
/></label>
<label
>Vorname-Attribut
<input name="ldap_firstNameAttribute" id="ldap_firstNameAttribute" placeholder="givenName"
/></label>
<label
>Nachname-Attribut
<input name="ldap_lastNameAttribute" id="ldap_lastNameAttribute" placeholder="sn"
/></label>
</div>
<label class="full-width"
>Sync-Intervall (Minuten)
<input
name="ldap_syncIntervalMinutes"
id="ldap_syncIntervalMinutes"
type="number"
min="0"
step="1"
value="1440"
/></label>
<p class="muted ldap-hint">0 = nur manuelle Synchronisation</p>
</div>
</div>
<div class="card options-section sync-panel">
<h3 class="options-section-title">Synchronisation</h3>
<div class="sync-actions">
<button type="button" class="btn-ldap-sync-now" id="btn-ldap-sync-now">
Synchronisation jetzt starten
</button>
</div>
<p class="muted sync-last-line" id="ldap-last-sync">Letzte Synchronisation: —</p>
<h4 class="sync-log-title">Sync-Log (letzte 10 Einträge)</h4>
<div class="table-wrap sync-log-table-wrap">
<table class="sync-log-table">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Typ</th>
<th>Status</th>
<th>Benutzer synchronisiert</th>
<th>Fehlermeldung</th>
</tr>
</thead>
<tbody id="sync-log-body"></tbody>
</table>
</div>
</div>
<div class="card options-section">
<h3 class="options-section-title">TeamViewer</h3>
<p class="muted">
API-Aufrufe nutzen den Header
<code>Authorization: Bearer &lt;token&gt;</code> (nur das Token ohne das Wort „Bearer“
eintragen).
</p>
<label
>Bearer-Token
<input
name="tv_bearerToken"
id="tv_bearerToken"
type="password"
autocomplete="off"
placeholder="your_token"
spellcheck="false"
/></label>
<label>Hinweise <textarea name="tv_notes" id="tv_notes" rows="2"></textarea></label>
</div>
<div class="options-actions">
<button type="submit" class="btn-config-save">Konfiguration speichern</button>
</div>
</form>
</div>
</main>
<script type="module" src="/js/pages/options.js"></script>
</body>
</html>

75
public/start.html Normal file
View File

@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Start — SDS CRM</title>
<link rel="stylesheet" href="/css/style.css" />
<link rel="stylesheet" href="/css/pages/start.css" />
</head>
<body>
<header class="header">
<h1><a href="/start.html">SDS CRM</a></h1>
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
</header>
<main id="app" class="main">
<p id="page-loading" class="muted">Lade …</p>
<p id="page-error" class="error" hidden></p>
<div id="page-main" class="stack home-open-tickets" hidden>
<div class="home-kpi-bar">
<h2>Offene Tickets</h2>
<div class="home-kpi-pills">
<span class="kpi-pill"
><span id="kpi-open" class="badge badge-open">0 Offen</span></span
>
<span class="kpi-pill"
><span id="kpi-waiting" class="badge badge-waiting">0 Wartend</span></span
>
<span id="kpi-total" class="kpi-pill muted">gesamt: 0</span>
</div>
</div>
<div id="home-ticket-list" class="home-ticket-list"></div>
<p id="home-empty" class="muted" hidden>Keine offenen Tickets.</p>
</div>
</main>
<template id="tpl-home-ticket">
<article class="card home-ticket-card">
<div class="home-ticket-head">
<button
type="button"
class="home-ticket-collapse-btn"
aria-expanded="false"
title="Beschreibung und Ereignisse ein- oder ausblenden"
>
<span class="home-ticket-chevron" aria-hidden="true"></span>
</button>
<div class="home-ticket-head-main">
<div class="home-ticket-titleline">
<a class="js-ticket-link" href="#">Titel</a>
<span class="badge js-status"></span>
<span class="badge js-priority"></span>
</div>
<div class="home-ticket-meta-row">
<span class="js-meta-machine"></span>
<span><span class="muted">Standort:</span> <span class="js-meta-standort"></span></span>
<span><span class="muted">Erstellt:</span> <span class="js-meta-created"></span></span>
<span><span class="muted">Aktualisiert:</span> <span class="js-meta-updated"></span></span>
</div>
</div>
<a class="home-ticket-open js-ticket-open" href="#">Ticket öffnen →</a>
</div>
<div class="home-ticket-collapsible" hidden>
<div class="home-ticket-context">
<p class="home-ticket-context-h"><strong>Beschreibung</strong></p>
<p class="home-ticket-desc js-desc"></p>
<div class="js-machine-block"></div>
</div>
<div class="home-ticket-events js-events"></div>
</div>
</article>
</template>
<script type="module" src="/js/pages/start.js"></script>
</body>
</html>

182
public/ticket.html Normal file
View File

@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ticket — SDS CRM</title>
<link rel="stylesheet" href="/css/style.css" />
<link rel="stylesheet" href="/css/pages/detail.css" />
</head>
<body>
<header class="header">
<h1><a href="/start.html">SDS CRM</a></h1>
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
</header>
<main id="app" class="main">
<p id="page-loading" class="muted">Lade …</p>
<div id="ticket-bad-id" hidden>
<p class="error">Ungültige oder fehlende Ticket-ID.</p>
<p><a href="/tickets.html">← Zur Übersicht</a></p>
</div>
<p id="page-error" class="error" hidden></p>
<div id="page-main" class="stack" hidden>
<p><a href="/tickets.html">← Zurück</a></p>
<h2 id="ticket-title"></h2>
<div id="panel-ticket-view" class="card">
<p>
<strong>Status:</strong>
<span id="t-status-badge" class="badge"></span>
</p>
<p><strong>Priorität:</strong> <span id="t-priority-label"></span></p>
<p>
<label for="t-sla-days"><strong>Fälligkeit (Bearbeitungszeit)</strong></label>
<select id="t-sla-days" aria-label="Fälligkeit in Tagen">
<option value="">Standard (2 Tage)</option>
<option value="1">1 Tag</option>
<option value="2">2 Tage</option>
<option value="3">3 Tage</option>
<option value="4">4 Tage</option>
<option value="5">5 Tage</option>
</select>
</p>
<p><strong>Beschreibung:</strong></p>
<p id="t-description" style="white-space: pre-wrap"></p>
<p id="t-machine-row" hidden>
<strong>Maschine:</strong>
<a id="t-machine-link" href="#"></a><span id="t-machine-suffix"></span>
</p>
<button type="button" id="btn-t-edit">Bearbeiten</button>
</div>
<form id="panel-ticket-edit" class="card stack" hidden>
<label>Titel <input name="title" id="tu-title" required /></label>
<label
>Beschreibung <textarea name="description" id="tu-desc" required></textarea></label
>
<div class="row">
<label
>Status
<select name="status" id="tu-status">
<option value="OPEN">Offen</option>
<option value="WAITING">Warte auf Rückmeldung</option>
<option value="DONE">Erledigt</option>
</select>
</label>
<label
>Priorität
<select name="priority" id="tu-priority">
<option value="LOW">Niedrig</option>
<option value="MEDIUM">Mittel</option>
<option value="HIGH">Hoch</option>
</select>
</label>
</div>
<div class="row">
<button type="submit">Speichern</button>
<button type="button" class="secondary" id="tu-cancel">Abbrechen</button>
</div>
</form>
<h3>Historie</h3>
<div class="card">
<h4>Event hinzufügen</h4>
<form id="form-ev" class="stack">
<label
>Art
<select id="ev-type-sel" name="type" required>
<option value="NOTE">Notiz</option>
<option value="CALL">Anruf</option>
<option value="REMOTE">Remote</option>
<option value="PART">Ersatzteil benötigt</option>
<option value="ATTACHMENT">Anhang / Datei(en)</option>
</select>
</label>
<div class="ev-field-group" data-ev-type="NOTE">
<label>Beschreibung <textarea name="description_note" rows="3" required></textarea></label>
</div>
<div class="ev-field-group" data-ev-type="CALL" hidden>
<label>Beschreibung <textarea name="description_call" rows="3"></textarea></label>
<label>Rückrufnummer <span class="muted">(optional)</span>
<input name="callbackNumber" type="tel" autocomplete="tel" /></label>
</div>
<div class="ev-field-group" data-ev-type="REMOTE" hidden>
<label
>Beschreibung
<textarea
name="description_remote"
rows="3"
placeholder="Was wurde remote erledigt?"
></textarea>
</label>
<div class="tv-device-row">
<label
>Benutzer (TeamViewer)
<select id="tv-user-select" aria-label="TeamViewer Benutzer">
<option value="">— zuerst Art „Remote“ wählen —</option>
</select>
</label>
<label
>Gerät / Session
<select name="teamviewerDevice" id="tv-conn-select" disabled aria-label="TeamViewer Gerät">
<option value="">— zuerst Benutzer wählen —</option>
</select>
</label>
<button type="button" class="secondary" id="btn-tv-reload">Liste aktualisieren</button>
</div>
<p class="muted tv-conn-hint" id="tv-conn-hint"></p>
</div>
<div class="ev-field-group" data-ev-type="PART" hidden>
<label>Artikelnummer <input name="articleNumber" /></label>
<label>Bemerkung <span class="muted">(optional)</span>
<textarea name="description_part" rows="2"></textarea>
</label>
</div>
<div class="ev-field-group" data-ev-type="ATTACHMENT" hidden>
<label>Beschreibung <span class="muted">(optional)</span>
<textarea name="description_attachment" rows="2" placeholder="Kurzbeschreibung zu den Dateien"></textarea>
</label>
<label>Dateien
<input
type="file"
id="ev-attachment-files"
name="files"
multiple
accept="*/*"
/>
</label>
</div>
<button type="submit">Event speichern</button>
</form>
</div>
<div class="card table-wrap">
<table class="events-table">
<thead>
<tr>
<th scope="col">Zeitpunkt</th>
<th scope="col">Art</th>
<th scope="col">Inhalt</th>
</tr>
</thead>
<tbody id="events-table-body"></tbody>
</table>
</div>
<div id="sect-second-ticket" hidden>
<h3>Weiteres Ticket für diese Maschine</h3>
<p class="muted">Optional.</p>
<div class="card">
<form id="form-t2" class="stack">
<div class="row">
<label>Titel <input name="title" required /></label>
<label>Beschreibung <textarea name="description" required></textarea></label>
</div>
<button type="submit">Ticket erstellen</button>
</form>
</div>
</div>
</div>
</main>
<script type="module" src="/js/pages/ticket-detail.js"></script>
</body>
</html>

96
public/tickets.html Normal file
View File

@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tickets — SDS CRM</title>
<link rel="stylesheet" href="/css/style.css" />
<link rel="stylesheet" href="/css/pages/tickets.css" />
</head>
<body>
<header class="header">
<h1><a href="/start.html">SDS CRM</a></h1>
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
</header>
<main id="app" class="main">
<p id="page-loading" class="muted">Lade …</p>
<p id="page-error" class="error" hidden></p>
<div id="page-main" class="stack" hidden>
<h2>Tickets</h2>
<div class="card">
<h3>Neues Ticket</h3>
<form id="form-new-ticket" class="stack ticket-new-form">
<div class="ticket-form-machine">
<label
>Maschine
<select name="machineId" id="sel-nm" required>
<option value="">— wählen —</option>
</select>
</label>
</div>
<label class="ticket-field-full">
Titel
<input name="title" type="text" required autocomplete="off" />
</label>
<label class="ticket-field-full">
Beschreibung
<textarea
name="description"
class="ticket-desc"
required
rows="6"
></textarea>
</label>
<button type="submit">Ticket erstellen</button>
</form>
</div>
<div class="card">
<h3>Filter</h3>
<form id="form-filter" class="stack">
<div class="row">
<label
>Status
<select name="status">
<option value="">Alle</option>
<option value="OPEN">Offen</option>
<option value="WAITING">Warte auf Rückmeldung</option>
<option value="DONE">Erledigt</option>
</select>
</label>
<label
>Priorität
<select name="priority">
<option value="">Alle</option>
<option value="LOW">Niedrig</option>
<option value="MEDIUM">Mittel</option>
<option value="HIGH">Hoch</option>
</select>
</label>
<label
>Maschine
<select name="machineId" id="sel-fm">
<option value="">Alle</option>
</select>
</label>
</div>
<button type="submit">Filter anwenden</button>
</form>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Titel</th>
<th>Status</th>
<th>Priorität</th>
<th>Maschine</th>
</tr>
</thead>
<tbody id="tickets-table-body"></tbody>
</table>
</div>
</div>
</main>
<script type="module" src="/js/pages/tickets.js"></script>
</body>
</html>

59
public/users.html Normal file
View File

@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Benutzer — SDS CRM</title>
<link rel="stylesheet" href="/css/style.css" />
<link rel="stylesheet" href="/css/pages/users.css" />
</head>
<body>
<header class="header">
<h1><a href="/start.html">SDS CRM</a></h1>
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
</header>
<main id="app" class="main">
<p id="page-loading" class="muted">Lade …</p>
<p id="page-error" class="error" hidden></p>
<div id="page-main" class="stack" hidden>
<p><a href="/start.html">← Start</a></p>
<h2>Benutzer</h2>
<div class="card">
<h3>Neuer Benutzer</h3>
<form id="form-new-user" class="stack">
<div class="row">
<label>Benutzername <input name="username" required autocomplete="off" /></label>
<label
>Passwort
<input name="password" type="password" required minlength="8" autocomplete="new-password"
/></label>
<label
>Rolle
<select name="role">
<option value="user">Benutzer</option>
<option value="admin">Administrator</option>
</select>
</label>
<button type="submit">Anlegen</button>
</div>
</form>
</div>
<div class="card table-wrap">
<table class="users-table">
<thead>
<tr>
<th>Benutzer</th>
<th>Rolle</th>
<th>Quelle</th>
<th>Aktiv</th>
<th></th>
</tr>
</thead>
<tbody id="users-table-body"></tbody>
</table>
</div>
</div>
</main>
<script type="module" src="/js/pages/users.js"></script>
</body>
</html>