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

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