/** * 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 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.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 ? `
⏳
wartet auf den Pi …
` : ''); } /** 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();