/**
* Timer App - Frontend
*
* Die Timer selbst leben auf dem Server. Der Browser zaehlt zwischen zwei
* Server-Abgleichen nur lokal weiter, damit die Anzeige fluessig laeuft.
* Gerechnet wird lokal ausschliesslich fuer die Anzeige - der verbindliche
* Betrag entsteht beim Beenden auf dem Server.
*/
const state = {
timers: [], // {id, customerName, isRunning, baseMs, syncedAt}
rate: { rateAmount: 10, rateInterval: 10 },
printerConfigured: false, // steuert, ob die Bon-Knoepfe erscheinen
printerDevice: '', // gewaehltes Geraet am Pi, leer = automatisch
printAutoOnStop: true, // Bon beim Beenden automatisch drucken
tickHandle: null,
syncHandle: null,
setupWatch: null, // wartet auf die Anmeldung des Drucker-Pi
setupCommand: '', // der Befehl, der auf dem Pi ausgefuehrt wird
history: { offset: 0, limit: 50, reachedEnd: false }
};
const SYNC_INTERVAL_MS = 15000; // Abgleich mit dem Server
const TICK_INTERVAL_MS = 250; // Anzeige-Aktualisierung
let oidcConfigured = false; // ob Authentik erreichbar ist
let loggedInUser = null; // {username, displayName, email} nach SSO-Login
// ---------------------------------------------------------------------------
// Hilfsfunktionen
// ---------------------------------------------------------------------------
function formatTime(ms) {
const total = Math.floor(Math.max(0, ms) / 1000);
const p = n => String(n).padStart(2, '0');
return `${p(Math.floor(total / 3600))}:${p(Math.floor((total % 3600) / 60))}:${p(total % 60)}`;
}
function formatEuro(value) {
return '€' + Number(value || 0).toLocaleString('de-DE', {
minimumFractionDigits: Number.isInteger(Number(value)) ? 0 : 2,
maximumFractionDigits: 2
});
}
function calcAmount(ms) {
if (!ms || ms <= 0) return 0;
const minutes = Math.ceil(ms / 60000);
const units = Math.ceil(minutes / state.rate.rateInterval);
return Math.round(units * state.rate.rateAmount * 100) / 100;
}
/** Aktuelle Zeit eines Timers - lokal fortgeschrieben seit dem letzten Sync. */
function currentMs(timer) {
if (!timer.isRunning) return timer.baseMs;
return timer.baseMs + (Date.now() - timer.syncedAt);
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str == null ? '' : String(str);
return div.innerHTML;
}
let toastTimeout = null;
function toast(message, kind = 'info') {
const el = document.getElementById('toast');
el.textContent = message;
el.className = 'toast show ' + kind;
clearTimeout(toastTimeout);
// Fehler laenger stehen lassen: die Meldung "Bon nicht gedruckt" enthaelt
// den Grund und den Hinweis auf den Nachdruck - das will gelesen werden.
toastTimeout = setTimeout(() => { el.className = 'toast'; }, kind === 'error' ? 8000 : 4000);
}
async function api(url, options = {}) {
const response = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...options
});
if (response.status === 401) {
stopLoops();
showLogin();
throw new Error('Nicht angemeldet');
}
if (!response.ok) {
let msg = 'Serverfehler';
let daten = null;
try { daten = await response.json(); msg = daten.error || msg; } catch (e) { /* egal */ }
const fehler = new Error(msg);
// Manche Endpunkte liefern im Fehlerfall Zusatzinfos mit (etwa das
// Protokoll der Druckereinrichtung). Die waeren sonst verloren.
if (daten && daten.steps) fehler.steps = daten.steps;
throw fehler;
}
return response.status === 204 ? null : response.json();
}
// ---------------------------------------------------------------------------
// Ansichten
// ---------------------------------------------------------------------------
function switchView(id) {
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
document.getElementById(id).classList.add('active');
}
function showLogin() {
document.getElementById('header').style.display = 'none';
switchView('loginView');
applyLoginMode();
}
// Anmeldung ausschliesslich ueber Authentik. Ist Authentik nicht erreichbar,
// wird der Hinweis unter dem Button eingeblendet.
function applyLoginMode() {
const err = document.getElementById('loginError');
if (err) err.classList.toggle('show', !oidcConfigured);
}
function showTimer() {
switchView('timerView');
}
async function showHistory() {
switchView('historyView');
state.history.offset = 0;
state.history.reachedEnd = false;
await loadHistory(true);
}
async function showSettings() {
switchView('settingsView');
await loadSettings();
}
// ---------------------------------------------------------------------------
// Anmeldung
// ---------------------------------------------------------------------------
async function checkAuth() {
try {
const data = await api('/api/auth/status');
oidcConfigured = !!data.oidcConfigured;
loggedInUser = data.user || null;
if (data.authenticated) {
await enterApp();
} else {
showLogin();
}
} catch (e) {
showLogin();
}
}
async function enterApp() {
document.getElementById('header').style.display = 'flex';
renderCurrentUser();
showTimer();
await syncTimers();
startLoops();
}
// Zeigt den angemeldeten Authentik-Nutzer: im Benutzer-Menue der
// Portal-Kopfleiste, ohne Leiste oben rechts im eigenen Header.
function renderCurrentUser() {
const u = loggedInUser;
const name = u ? (u.displayName || u.username || '') : '';
const el = document.getElementById('currentUser');
el.textContent = name;
el.title = u && u.email ? u.email : 'Angemeldet';
renderTopbarSession(name);
}
function confirmLogout() {
const running = state.timers.filter(t => t.isRunning).length;
return running === 0 || confirm(
`${running} Timer ${running === 1 ? 'läuft' : 'laufen'} noch.\n\n` +
`Sie laufen serverseitig weiter und sind nach dem nächsten Anmelden wieder da. Trotzdem abmelden?`
);
}
// Eigener Abmelden-Knopf, nur sichtbar ohne Portal-Kopfleiste
async function logout() {
if (!confirmLogout()) return;
stopLoops();
state.timers = [];
// GET-Navigation: beendet lokale Sitzung und – bei Authentik-Login – auch die SSO-Sitzung
window.location.href = '/auth/logout';
}
// ---------------------------------------------------------------------------
// Portal-Kopfleiste
// ---------------------------------------------------------------------------
// topbar.js laeuft per defer erst nach diesem Skript, die Leiste steht also
// fruehestens ab DOMContentLoaded im DOM. Die Sitzungsanzeige wird einmal
// eingehaengt, sobald die Anmeldung steht, danach aendert sich nur ihr Text.
const topbarSession = document.createElement('div');
topbarSession.slot = 'benutzer';
topbarSession.className = 'topbar-session';
topbarSession.append(Object.assign(document.createElement('span'), { textContent: 'Timer – angemeldet als' }),
document.createElement('strong'));
let topbarSessionName = null; // null = (noch) nicht angemeldet
function renderTopbarSession(name) {
topbarSessionName = name || 'Angemeldet';
topbarSession.lastChild.textContent = topbarSessionName;
if (!topbarSession.isConnected) document.querySelector('hi-topbar')?.append(topbarSession);
}
document.addEventListener('DOMContentLoaded', () => {
if (topbarSessionName !== null) renderTopbarSession(topbarSessionName);
});
// "Abmelden" in der Leiste: erst die eigene Sitzung beenden, danach meldet
// die Leiste bei Portal und Authentik ab. Abbrechen bei laufenden Timern
// verhindert das ganze Abmelden.
document.addEventListener('hi-abmelden', (e) => {
if (!confirmLogout()) {
e.preventDefault();
return;
}
stopLoops();
state.timers = [];
e.detail.warten(fetch('/api/logout', { method: 'POST', credentials: 'same-origin' }));
});
// ---------------------------------------------------------------------------
// Timer-Synchronisation
// ---------------------------------------------------------------------------
async function syncTimers() {
const data = await api('/api/timers');
state.rate = data.rate;
state.printerConfigured = !!data.printerConfigured;
state.printAutoOnStop = data.printAutoOnStop !== false;
const now = Date.now();
state.timers = data.timers.map(t => ({
id: t.id,
customerName: t.customerName,
isRunning: t.isRunning,
baseMs: t.elapsedMs,
syncedAt: now,
createdAt: t.createdAt
}));
renderTimers();
}
function startLoops() {
stopLoops();
state.tickHandle = setInterval(tick, TICK_INTERVAL_MS);
state.syncHandle = setInterval(() => { syncTimers().catch(() => {}); }, SYNC_INTERVAL_MS);
}
function stopLoops() {
clearInterval(state.tickHandle);
clearInterval(state.syncHandle);
state.tickHandle = null;
state.syncHandle = null;
}
// Nach Ruhezustand/Tabwechsel sofort abgleichen statt bis zum naechsten Sync warten
document.addEventListener('visibilitychange', () => {
if (!document.hidden && state.syncHandle) syncTimers().catch(() => {});
});
/** Aktualisiert nur die Zahlen - baut das DOM nicht neu (sonst springt der Fokus). */
function tick() {
for (const timer of state.timers) {
const ms = currentMs(timer);
const timeEl = document.getElementById(`time-${timer.id}`);
const amountEl = document.getElementById(`amount-${timer.id}`);
if (timeEl) timeEl.textContent = formatTime(ms);
if (amountEl) amountEl.textContent = formatEuro(calcAmount(ms));
}
}
// ---------------------------------------------------------------------------
// Timer-Darstellung
// ---------------------------------------------------------------------------
function renderTimers() {
const list = document.getElementById('timerList');
const empty = document.getElementById('timerEmpty');
document.getElementById('rateHint').textContent =
`Tarif: ${formatEuro(state.rate.rateAmount)} pro angefangene ${state.rate.rateInterval} Minuten`;
if (state.timers.length === 0) {
list.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
list.innerHTML = state.timers.map(timer => {
const ms = currentMs(timer);
const since = new Date(timer.createdAt).toLocaleTimeString('de-DE',
{ hour: '2-digit', minute: '2-digit' });
return `
${timer.isRunning ? '● läuft' : '❚❚ pausiert'}
${formatTime(ms)}
${formatEuro(calcAmount(ms))}
gestartet um ${since} Uhr
${timer.isRunning
? ``
: ``}
${state.printerConfigured
? ``
: ''}
`;
}).join('');
}
// ---------------------------------------------------------------------------
// Timer-Aktionen
// ---------------------------------------------------------------------------
async function addTimer() {
const field = document.getElementById('newTimerName');
const name = field.value.trim();
try {
await api('/api/timers', { method: 'POST', body: JSON.stringify({ customerName: name }) });
field.value = '';
await syncTimers();
toast(name ? `Timer für „${name}" gestartet` : 'Timer gestartet', 'success');
} catch (e) {
toast('Timer konnte nicht gestartet werden: ' + e.message, 'error');
}
}
async function renameTimer(id, value) {
try {
await api(`/api/timers/${id}`, {
method: 'PATCH',
body: JSON.stringify({ customerName: value.trim() })
});
const timer = state.timers.find(t => t.id === id);
if (timer) timer.customerName = value.trim();
} catch (e) {
toast('Name konnte nicht gespeichert werden: ' + e.message, 'error');
}
}
async function pauseTimer(id) {
try {
await api(`/api/timers/${id}/pause`, { method: 'POST' });
await syncTimers();
} catch (e) {
toast('Pause fehlgeschlagen: ' + e.message, 'error');
}
}
async function resumeTimer(id) {
try {
await api(`/api/timers/${id}/resume`, { method: 'POST' });
await syncTimers();
} catch (e) {
toast('Fortsetzen fehlgeschlagen: ' + e.message, 'error');
}
}
/**
* Zwischenbon: der Kunde geht jetzt zur Kasse, der Platz bleibt aber belegt.
* Der Timer wird dabei bewusst NICHT angefasst.
*/
async function printInterim(id) {
const timer = state.timers.find(t => t.id === id);
if (!timer) return;
try {
toast('Bon wird gedruckt …', 'info');
const res = await api(`/api/timers/${id}/receipt`, { method: 'POST' });
toast(`Bon gedruckt – ${formatEuro(res.amount)} (Timer läuft weiter)`, 'success');
} catch (e) {
toast('Bon konnte nicht gedruckt werden: ' + e.message, 'error');
}
}
async function stopTimer(id) {
const timer = state.timers.find(t => t.id === id);
if (!timer) return;
const ms = currentMs(timer);
const label = timer.customerName ? `„${timer.customerName}"` : 'Timer';
const bonHinweis = state.printerConfigured && state.printAutoOnStop
? '\n\nDer Bon für die Kasse wird gedruckt.' : '';
if (!confirm(`${label} beenden?\n\nZeit: ${formatTime(ms)}\nBetrag: ${formatEuro(calcAmount(ms))}\n\nDer Eintrag wird in der Historie gespeichert.${bonHinweis}`)) return;
try {
const result = await api(`/api/timers/${id}/stop`, { method: 'POST' });
await syncTimers();
if (!result.saved) {
return toast(result.reason || 'Nichts gespeichert.', 'info');
}
const kern = `Gespeichert: ${formatTime(result.duration)} – ${formatEuro(result.amount)}`;
if (result.printed) {
toast(kern + ' · Bon gedruckt', 'success');
} else if (result.printError) {
// Bewusst als Fehler und mit Nennung des Nachdrucks: der Vorgang ist
// gespeichert, es fehlt nur das Papier.
toast(`${kern}\nBon NICHT gedruckt (${result.printError}) – Nachdruck über die Historie.`, 'error');
} else {
toast(kern, 'success');
}
} catch (e) {
toast('Beenden fehlgeschlagen: ' + e.message, 'error');
}
}
async function discardTimer(id) {
const timer = state.timers.find(t => t.id === id);
if (!timer) return;
const label = timer.customerName ? `„${timer.customerName}"` : 'Timer';
if (!confirm(`${label} verwerfen?\n\nDie erfasste Zeit (${formatTime(currentMs(timer))}) wird NICHT gespeichert.`)) return;
try {
await api(`/api/timers/${id}`, { method: 'DELETE' });
await syncTimers();
toast('Timer verworfen - nichts gespeichert.', 'info');
} catch (e) {
toast('Verwerfen fehlgeschlagen: ' + e.message, 'error');
}
}
document.getElementById('newTimerForm').addEventListener('submit', (e) => {
e.preventDefault();
addTimer();
});
// ---------------------------------------------------------------------------
// Historie
// ---------------------------------------------------------------------------
function historyQuery() {
const params = new URLSearchParams();
const from = document.getElementById('filterFrom').value;
const to = document.getElementById('filterTo').value;
const q = document.getElementById('filterName').value.trim();
if (from) params.set('from', from);
if (to) params.set('to', to);
if (q) params.set('q', q);
return params;
}
async function loadHistory(reset = false) {
if (reset) {
state.history.offset = 0;
state.history.reachedEnd = false;
}
const params = historyQuery();
params.set('limit', state.history.limit);
params.set('offset', state.history.offset);
try {
const data = await api('/api/sessions?' + params.toString());
renderHistory(data, reset);
} catch (e) {
document.getElementById('historyList').innerHTML =
`⚠️
Fehler beim Laden: ${escapeHtml(e.message)}
`;
}
}
function renderHistory(data, reset) {
const listEl = document.getElementById('historyList');
const statsEl = document.getElementById('historyStats');
const moreBtn = document.getElementById('historyMore');
statsEl.innerHTML = `
Einträge
${data.totals.count}
Gesamtzeit
${formatTime(data.totals.duration)}
Gesamtbetrag
${formatEuro(data.totals.amount)}
`;
if (data.totals.count === 0) {
listEl.innerHTML = '📭
Keine Einträge für diesen Zeitraum
';
moreBtn.style.display = 'none';
return;
}
const rows = data.sessions.map(s => {
const date = new Date(s.created_at * 1000).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
const name = s.customer_name
? `${escapeHtml(s.customer_name)}`
: 'ohne Namen';
return `
${name}
${date} Uhr
${formatTime(s.duration)}
${formatEuro(s.amount)}
${state.printerConfigured
? `
`
: ''}
`;
}).join('');
if (reset) {
listEl.innerHTML = rows;
} else {
listEl.insertAdjacentHTML('beforeend', rows);
}
const shown = state.history.offset + data.sessions.length;
state.history.reachedEnd = shown >= data.totals.count;
moreBtn.style.display = state.history.reachedEnd ? 'none' : 'inline-block';
moreBtn.textContent = `Weitere laden (${shown} von ${data.totals.count})`;
}
async function loadMoreHistory() {
state.history.offset += state.history.limit;
await loadHistory(false);
}
/** Nachdruck aus der Historie - Papier war leer, Bon verlegt, Kunde will eine Kopie. */
async function reprintSession(id) {
try {
toast('Bon wird gedruckt …', 'info');
await api(`/api/sessions/${id}/receipt`, { method: 'POST' });
toast('Bon nachgedruckt', 'success');
} catch (e) {
toast('Nachdruck fehlgeschlagen: ' + e.message, 'error');
}
}
async function deleteSession(id) {
if (!confirm('Diesen Eintrag endgültig aus der Historie löschen?')) return;
try {
await api(`/api/sessions/${id}`, { method: 'DELETE' });
await loadHistory(true);
toast('Eintrag gelöscht', 'info');
} catch (e) {
toast('Löschen fehlgeschlagen: ' + e.message, 'error');
}
}
function exportCsv() {
const params = historyQuery();
window.location.href = '/api/sessions/export.csv?' + params.toString();
}
function resetFilter() {
document.getElementById('filterFrom').value = '';
document.getElementById('filterTo').value = '';
document.getElementById('filterName').value = '';
loadHistory(true);
}
function filterToday() {
const today = new Date().toISOString().slice(0, 10);
document.getElementById('filterFrom').value = today;
document.getElementById('filterTo').value = today;
loadHistory(true);
}
function filterThisMonth() {
const now = new Date();
const first = new Date(now.getFullYear(), now.getMonth(), 1);
const pad = n => String(n).padStart(2, '0');
document.getElementById('filterFrom').value =
`${first.getFullYear()}-${pad(first.getMonth() + 1)}-01`;
document.getElementById('filterTo').value =
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
loadHistory(true);
}
['filterFrom', 'filterTo'].forEach(id =>
document.getElementById(id).addEventListener('change', () => loadHistory(true)));
document.getElementById('filterName').addEventListener('input', (() => {
let handle = null;
return () => { clearTimeout(handle); handle = setTimeout(() => loadHistory(true), 350); };
})());
// ---------------------------------------------------------------------------
// Einstellungen (serverseitig - gelten fuer alle Geraete)
// ---------------------------------------------------------------------------
async function loadSettings() {
try {
const settings = await api('/api/settings');
state.rate = settings;
state.printerConfigured = !!(settings.printer && settings.printer.configured);
state.printerDevice = (settings.printer && settings.printer.device) || '';
state.printAutoOnStop = settings.printAutoOnStop !== false;
document.getElementById('rateAmount').value = settings.rateAmount;
document.getElementById('rateInterval').value = settings.rateInterval;
document.getElementById('receiptHeader').value = settings.receiptHeader || '';
document.getElementById('receiptFooter').value = settings.receiptFooter || '';
document.getElementById('printAutoOnStop').checked = state.printAutoOnStop;
document.getElementById('receiptBarcodeEnabled').checked = settings.receiptBarcodeEnabled !== false;
document.getElementById('receiptBarcodeArticle').value = settings.receiptBarcodeArticle || '';
toggleBarcodeField();
updateSettingsPreview();
renderPrinterState(settings.printer);
refreshPrinterStatus();
loadDevices();
} catch (e) {
toast('Einstellungen konnten nicht geladen werden: ' + e.message, 'error');
}
}
async function saveSettings() {
const rateAmount = parseFloat(document.getElementById('rateAmount').value);
const rateInterval = parseInt(document.getElementById('rateInterval').value);
const receiptHeader = document.getElementById('receiptHeader').value;
const receiptFooter = document.getElementById('receiptFooter').value;
const printAutoOnStop = document.getElementById('printAutoOnStop').checked;
const receiptBarcodeEnabled = document.getElementById('receiptBarcodeEnabled').checked;
const receiptBarcodeArticle = document.getElementById('receiptBarcodeArticle').value;
try {
const rate = await api('/api/settings', {
method: 'PUT',
body: JSON.stringify({
rateAmount, rateInterval, receiptHeader, receiptFooter, printAutoOnStop,
receiptBarcodeEnabled, receiptBarcodeArticle
})
});
state.rate = rate;
state.printAutoOnStop = rate.printAutoOnStop !== false;
renderTimers();
toast('Einstellungen gespeichert - gilt für alle Geräte', 'success');
showTimer();
} catch (e) {
toast('Speichern fehlgeschlagen: ' + e.message, 'error');
}
}
/** Der Artikelname interessiert nur, solange der Code auch gedruckt wird. */
function toggleBarcodeField() {
const an = document.getElementById('receiptBarcodeEnabled').checked;
document.getElementById('barcodeArticleGroup').style.opacity = an ? '1' : '0.45';
document.getElementById('receiptBarcodeArticle').disabled = !an;
}
async function resetSettings() {
if (!confirm('Tarif auf Standard zurücksetzen (€10 pro 10 Minuten)?')) return;
document.getElementById('rateAmount').value = 10;
document.getElementById('rateInterval').value = 10;
await saveSettings();
}
// ---------------------------------------------------------------------------
// Bondrucker
// ---------------------------------------------------------------------------
/** Zeigt in den Einstellungen, ob und wo ein Drucker haengt. */
function renderPrinterState(info, live) {
const el = document.getElementById('printerState');
const testBtn = document.getElementById('printerTestBtn');
if (!el) return;
if (!info || !info.configured) {
el.className = 'printer-state off';
el.textContent = 'Kein Bondrucker eingerichtet – es wird nichts gedruckt, '
+ 'und die Bon-Knöpfe an den Timern und in der Historie sind ausgeblendet. '
+ 'Über „🔧 Drucker einrichten" lässt sich einer hinzufügen.';
if (testBtn) testBtn.disabled = true;
return;
}
if (testBtn) testBtn.disabled = false;
// Ohne bekannte Adresse (Statusabfrage selbst fehlgeschlagen) nicht
// "undefined:undefined" anzeigen.
const ziel = info.host ? `${info.host}:${info.port}` : 'Drucker';
if (live === undefined) {
el.className = 'printer-state';
el.textContent = `Drucker eingerichtet: ${ziel} – Zustand wird geprüft …`;
} else if (live.ok) {
el.className = 'printer-state ok';
const geraet = live.printer && live.printer.device ? ` (${live.printer.device})` : '';
el.textContent = `Drucker bereit: ${ziel}${geraet}`;
} else {
el.className = 'printer-state error';
el.textContent = `Drucker nicht erreichbar: ${ziel} – ${live.error || 'unbekannter Fehler'}`;
}
}
async function refreshPrinterStatus() {
try {
const status = await api('/api/printer/status');
renderPrinterState({ configured: status.configured, host: status.host, port: status.port }, status);
} catch (e) {
renderPrinterState({ configured: state.printerConfigured }, { ok: false, error: e.message });
}
}
/**
* Die Druckerliste vom Pi holen.
*
* Sie kommt bewusst live und nicht aus dem Zwischenspeicher: wer hier auf
* "Suchen" tippt, hat gerade ein Kabel umgesteckt und will genau das sehen.
*
* @param {boolean} lautstark Rueckmeldung geben - beim Knopfdruck ja, beim
* stillen Laden der Einstellungen nein.
*/
async function loadDevices(lautstark = false) {
const feld = document.getElementById('printerDevice');
const gruppe = document.getElementById('printerDeviceGroup');
const hinweis = document.getElementById('printerDeviceHint');
if (!feld) return;
// Ohne eingerichteten Drucker gibt es nichts auszuwaehlen.
gruppe.style.display = state.printerConfigured ? '' : 'none';
if (!state.printerConfigured) return;
const gewaehlt = state.printerDevice || '';
try {
const res = await api('/api/printer/devices');
state.printerDevice = res.selected || '';
const automatisch = res.fallback
? `automatisch (zurzeit ${res.fallback})`
: 'automatisch';
feld.innerHTML = `` +
res.devices.map(g => {
const beschriftung = g.name ? `${g.path} — ${g.name}` : g.path;
return ``;
}).join('');
// Der eingestellte Drucker haengt gerade nicht dran. Er darf trotzdem
// nicht aus der Liste fallen, sonst stellt ein Speichern ihn unbemerkt
// auf "automatisch" um.
const eingestellt = res.selected || '';
if (eingestellt && !res.devices.some(g => g.path === eingestellt)) {
feld.innerHTML += ``;
}
feld.value = eingestellt;
feld.disabled = false;
hinweis.textContent = res.devices.length
? 'Welches Gerät am Raspberry Pi bedruckt wird.'
: 'Am Pi hängt gerade kein Drucker. Kabel prüfen, dann erneut suchen.';
if (lautstark) {
toast(res.devices.length
? `${res.devices.length} Drucker gefunden`
: 'Am Pi hängt gerade kein Drucker', res.devices.length ? 'success' : 'error');
}
} catch (e) {
feld.innerHTML = ``;
feld.disabled = true;
hinweis.textContent = 'Liste nicht abrufbar: ' + e.message;
if (lautstark) toast('Drucker konnten nicht abgerufen werden: ' + e.message, 'error');
}
}
/** Die Auswahl sofort speichern - ein eigener Speichern-Knopf waere hier
* eine Falle, weil das Feld weit ueber dem Knopf steht. */
async function saveDevice() {
const feld = document.getElementById('printerDevice');
const vorher = state.printerDevice || '';
const neu = feld.value;
try {
const antwort = await api('/api/settings', {
method: 'PUT',
body: JSON.stringify({
rateAmount: state.rate.rateAmount,
rateInterval: state.rate.rateInterval,
printerDevice: neu
})
});
state.printerDevice = (antwort.printer && antwort.printer.device) || '';
toast(neu ? 'Drucker gewählt: ' + neu : 'Drucker wird automatisch gewählt', 'success');
await refreshPrinterStatus();
} catch (e) {
feld.value = vorher;
toast('Auswahl nicht gespeichert: ' + e.message, 'error');
}
}
async function testPrint() {
const btn = document.getElementById('printerTestBtn');
if (btn) btn.disabled = true;
try {
await api('/api/printer/test', { method: 'POST' });
toast('Probebon gedruckt', 'success');
} catch (e) {
toast('Probedruck fehlgeschlagen: ' + e.message, 'error');
} finally {
if (btn) btn.disabled = false;
refreshPrinterStatus();
}
}
// ---------------------------------------------------------------------------
// Einrichtung eines Drucker-Pi
// ---------------------------------------------------------------------------
function toggleSetup() {
const box = document.getElementById('setupBox');
const offen = box.style.display !== 'none';
box.style.display = offen ? 'none' : 'block';
document.getElementById('setupToggleBtn').textContent =
offen ? '🔧 Drucker einrichten' : '✕ Einrichtung schließen';
if (offen) stopSetupWatch();
}
/** Protokoll der Einrichtung anzeigen - ein Kasten je Schritt. */
function renderSetupLog(steps, laufend) {
const el = document.getElementById('setupLog');
el.classList.add('show');
const zeilen = (steps || []).map(s => `
${s.ok ? '✅' : '❌'}
${escapeHtml(s.name)}
${s.detail ? `
${escapeHtml(s.detail)}
` : ''}
`).join('');
el.innerHTML = zeilen + (laufend
? ``
: '');
}
/** Einrichtungslink erzeugen und danach auf die Rueckmeldung des Pi warten. */
async function startSetup() {
const btn = document.getElementById('setupStartBtn');
btn.disabled = true;
try {
const res = await api('/api/printer/setup-code', { method: 'POST' });
state.setupCommand = res.command;
document.getElementById('setupCommand').textContent = res.command;
document.getElementById('setupLink').style.display = 'block';
const bis = new Date(res.expiresAt);
document.getElementById('setupExpiry').textContent =
`Der Link gilt einmal und läuft um ${bis.getHours()}:` +
`${String(bis.getMinutes()).padStart(2, '0')} Uhr ab. ` +
'Danach hier einfach einen neuen erzeugen.';
renderSetupLog([{ name: 'Link erzeugt', ok: true, detail: res.url }], true);
startSetupWatch();
toast('Link erzeugt. Jetzt am Pi ausführen.', 'success');
} catch (e) {
toast('Link konnte nicht erzeugt werden: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = '🔗 Neuen Link erzeugen';
}
}
function copySetupCommand() {
const text = state.setupCommand || '';
if (!text) return;
// navigator.clipboard gibt es nur auf HTTPS oder localhost. Im Laden laeuft
// das hier auch mal ueber schlichtes HTTP - dann faellt es auf den alten
// Weg zurueck, statt wortlos nichts zu tun.
const alterWeg = () => {
const feld = document.createElement('textarea');
feld.value = text;
feld.style.position = 'fixed';
feld.style.opacity = '0';
document.body.appendChild(feld);
feld.select();
try { document.execCommand('copy'); } catch (e) { /* dann eben abtippen */ }
document.body.removeChild(feld);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(
() => toast('Befehl kopiert', 'success'),
() => { alterWeg(); toast('Befehl kopiert', 'success'); }
);
} else {
alterWeg();
toast('Befehl kopiert', 'success');
}
}
/**
* Auf die Anmeldung des Pi warten.
*
* Der Pi meldet sich bei der App, nicht umgekehrt - die App kann also nur
* nachsehen. Alle drei Sekunden ist oft genug, um sich wie eine Rueckmeldung
* anzufuehlen, und selten genug, um niemandem wehzutun.
*/
function startSetupWatch() {
stopSetupWatch();
state.setupWatch = setInterval(async () => {
let z;
try {
z = await api('/api/printer/setup-status');
} catch (e) {
return; // Netz weg, Anmeldung abgelaufen - beim naechsten Mal wieder
}
if (z.state === 'done') {
stopSetupWatch();
state.printerConfigured = true;
renderSetupLog([
{ name: 'Pi angemeldet', ok: true,
detail: `${z.host}${z.device ? ' · Drucker ' + z.device : ''}` },
{ name: 'Übernommen', ok: true,
detail: 'Die App druckt ab jetzt auf diesen Pi. Ein Probedruck zeigt, ob Papier drin ist.' }
], false);
toast('Drucker eingerichtet: ' + z.host + ' — jetzt oben den Drucker auswählen.', 'success');
await refreshPrinterStatus();
await loadDevices();
await syncTimers().catch(() => {});
} else if (z.state === 'expired') {
stopSetupWatch();
renderSetupLog([{ name: 'Link abgelaufen', ok: false,
detail: 'Es hat sich kein Pi gemeldet. Bitte einen neuen Link erzeugen.' }], false);
}
}, 3000);
}
function stopSetupWatch() {
if (state.setupWatch) {
clearInterval(state.setupWatch);
state.setupWatch = null;
}
}
function updateSettingsPreview() {
const amount = parseFloat(document.getElementById('rateAmount').value) || 0;
const interval = parseInt(document.getElementById('rateInterval').value) || 1;
document.getElementById('settingsPreview').textContent =
`${formatEuro(amount)} pro angefangene ${interval} Minuten`;
const examples = [Math.max(1, interval - 5), interval + 5, interval * 2, 60]
.filter((v, i, a) => a.indexOf(v) === i)
.map(mins => `${mins} Min = ${formatEuro(Math.ceil(mins / interval) * amount)}`);
document.getElementById('previewExamples').innerHTML =
`Beispiele:
${examples.join(' | ')}`;
}
document.getElementById('settingsForm').addEventListener('submit', (e) => {
e.preventDefault();
saveSettings();
});
document.getElementById('rateAmount').addEventListener('input', updateSettingsPreview);
document.getElementById('rateInterval').addEventListener('input', updateSettingsPreview);
// ---------------------------------------------------------------------------
checkAuth();