/** * 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 printAutoOnStop: true, // Bon beim Beenden automatisch drucken tickHandle: null, syncHandle: null, 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 oben rechts im Header. function renderCurrentUser() { const el = document.getElementById('currentUser'); if (!el) return; const u = loggedInUser; el.textContent = u ? (u.displayName || u.username || '') : ''; el.title = u && u.email ? u.email : 'Angemeldet'; } async function logout() { const running = state.timers.filter(t => t.isRunning).length; if (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?` )) return; stopLoops(); state.timers = []; // GET-Navigation: beendet lokale Sitzung und – bei Authentik-Login – auch die SSO-Sitzung window.location.href = '/auth/logout'; } // --------------------------------------------------------------------------- // 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.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; updateSettingsPreview(); renderPrinterState(settings.printer); refreshPrinterStatus(); } 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; try { const rate = await api('/api/settings', { method: 'PUT', body: JSON.stringify({ rateAmount, rateInterval, receiptHeader, receiptFooter, printAutoOnStop }) }); 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'); } } 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. Einzurichten in der .env des Servers (PRINTER_HOST) – siehe deploy/bondrucker/README.md.'; if (testBtn) testBtn.disabled = true; return; } if (testBtn) testBtn.disabled = false; const ziel = `${info.host}:${info.port}`; 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 }); } } 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) document.getElementById('setupHost').focus(); } /** 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 ? `
läuft …
` : ''); } async function startProvision() { const host = document.getElementById('setupHost').value.trim(); const port = document.getElementById('setupPort').value; const username = document.getElementById('setupUser').value.trim(); const passwordEl = document.getElementById('setupPassword'); const password = passwordEl.value; if (!host) return toast('Bitte die Adresse des Pi eintragen.', 'error'); if (!username) return toast('Bitte den Benutzernamen eintragen.', 'error'); if (!password) return toast('Bitte das Passwort eintragen.', 'error'); if (state.printerConfigured && !confirm( 'Es ist bereits ein Drucker eingerichtet.\n\n' + `Nach der Einrichtung druckt die App auf ${host}. Fortfahren?` )) return; const btn = document.getElementById('setupStartBtn'); btn.disabled = true; btn.textContent = '⏳ Wird eingerichtet …'; renderSetupLog([], true); try { const res = await api('/api/printer/provision', { method: 'POST', body: JSON.stringify({ host, port, username, password }) }); // Das Passwort hat seinen Zweck erfuellt und hat im Formular nichts // mehr verloren. passwordEl.value = ''; renderSetupLog(res.steps, false); state.printerConfigured = true; if (res.ready) { toast('Drucker eingerichtet und erreichbar. Ein Probedruck zeigt, ob Papier drin ist.', 'success'); } else { toast('Einrichtung durchgelaufen, der Drucker antwortet aber noch nicht – siehe Protokoll.', 'error'); } await refreshPrinterStatus(); await syncTimers().catch(() => {}); } catch (e) { // Die Schritte bis zum Abbruch stecken in der Fehlerantwort und sind // das Wertvollste am ganzen Vorgang - deshalb trotzdem anzeigen. renderSetupLog(e.steps, false); toast('Einrichtung fehlgeschlagen: ' + e.message, 'error'); } finally { btn.disabled = false; btn.textContent = '🚀 Jetzt einrichten'; } } 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();