Files
timer-app/frontend/app.js
T
Kerim AtilganandClaude Opus 5.5 0ee9b708a1 Kopfleiste: Benutzer-Menü und gemeinsames Abmelden
- Benutzername und "Abmelden" im eigenen Header entfallen, nur noch
  Rückfall, wenn topbar.js nicht lädt
- Sitzungsanzeige als slot="benutzer" im Benutzer-Menü der Leiste
- hi-abmelden: POST /api/logout beendet die Timer-Sitzung, laufende
  Timer fragen vorher nach (Abbrechen stoppt das ganze Abmelden)
- html data-theme="dark": die Leiste ist dunkel wie die App, auch bei
  hellem System
- README/DEPLOY: Abmelden, Launch-URL /auth/login, CSP-Hinweis

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:12:20 +01:00

982 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 `
<div class="timer-card ${timer.isRunning ? 'running' : 'paused'}" data-id="${timer.id}">
<div class="timer-card-head">
<input class="timer-name" type="text" value="${escapeHtml(timer.customerName)}"
placeholder="Name / Platz (optional)" maxlength="80"
onchange="renameTimer(${timer.id}, this.value)">
<span class="timer-badge">${timer.isRunning ? '● läuft' : '❚❚ pausiert'}</span>
</div>
<div class="timer-card-time" id="time-${timer.id}">${formatTime(ms)}</div>
<div class="timer-card-amount" id="amount-${timer.id}">${formatEuro(calcAmount(ms))}</div>
<div class="timer-card-since">gestartet um ${since} Uhr</div>
<div class="timer-card-actions">
${timer.isRunning
? `<button class="btn btn-stop btn-sm" onclick="pauseTimer(${timer.id})">❚❚ Pause</button>`
: `<button class="btn btn-start btn-sm" onclick="resumeTimer(${timer.id})">▶ Weiter</button>`}
${state.printerConfigured
? `<button class="btn btn-secondary btn-sm" onclick="printInterim(${timer.id})"
title="Zwischenstand drucken - der Timer läuft weiter">🖨 Bon</button>`
: ''}
<button class="btn btn-sm" onclick="stopTimer(${timer.id})">✓ Beenden</button>
<button class="btn btn-danger btn-sm" onclick="discardTimer(${timer.id})">✕ Verwerfen</button>
</div>
</div>`;
}).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 =
`<div class="empty-state"><div class="empty-state-icon">⚠️</div><p>Fehler beim Laden: ${escapeHtml(e.message)}</p></div>`;
}
}
function renderHistory(data, reset) {
const listEl = document.getElementById('historyList');
const statsEl = document.getElementById('historyStats');
const moreBtn = document.getElementById('historyMore');
statsEl.innerHTML = `
<div class="stat-card"><h3>Einträge</h3><p>${data.totals.count}</p></div>
<div class="stat-card"><h3>Gesamtzeit</h3><p>${formatTime(data.totals.duration)}</p></div>
<div class="stat-card"><h3>Gesamtbetrag</h3><p>${formatEuro(data.totals.amount)}</p></div>
`;
if (data.totals.count === 0) {
listEl.innerHTML = '<div class="empty-state"><div class="empty-state-icon">📭</div><p>Keine Einträge für diesen Zeitraum</p></div>';
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
? `<span class="history-item-name">${escapeHtml(s.customer_name)}</span>`
: '<span class="history-item-name muted">ohne Namen</span>';
return `
<div class="history-item">
<div class="history-item-info">
${name}
<div class="history-item-date">${date} Uhr</div>
<div class="history-item-duration">${formatTime(s.duration)}</div>
</div>
<div class="history-item-right">
<div class="history-item-amount">${formatEuro(s.amount)}</div>
${state.printerConfigured
? `<button class="btn btn-secondary btn-sm" onclick="reprintSession(${s.id})"
title="Bon noch einmal drucken">🖨</button>`
: ''}
<button class="btn btn-danger btn-sm" onclick="deleteSession(${s.id})" title="Eintrag löschen">🗑</button>
</div>
</div>`;
}).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 = `<option value="">${escapeHtml(automatisch)}</option>` +
res.devices.map(g => {
const beschriftung = g.name ? `${g.path} — ${g.name}` : g.path;
return `<option value="${escapeHtml(g.path)}">${escapeHtml(beschriftung)}</option>`;
}).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 += `<option value="${escapeHtml(eingestellt)}">` +
`${escapeHtml(eingestellt)} — zurzeit nicht angeschlossen</option>`;
}
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 = `<option value="">${escapeHtml(gewaehlt || 'automatisch')}</option>`;
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 => `
<div class="setup-step ${s.ok ? '' : 'fail'}">
<span class="setup-step-icon">${s.ok ? '✅' : '❌'}</span>
<div>
<div class="setup-step-name">${escapeHtml(s.name)}</div>
${s.detail ? `<div class="setup-step-detail">${escapeHtml(s.detail)}</div>` : ''}
</div>
</div>`).join('');
el.innerHTML = zeilen + (laufend
? `<div class="setup-step"><span class="setup-step-icon">⏳</span>
<div><div class="setup-step-name">wartet auf den Pi …</div></div></div>`
: '');
}
/** 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 =
`<strong>Beispiele:</strong><br>${examples.join(' | ')}`;
}
document.getElementById('settingsForm').addEventListener('submit', (e) => {
e.preventDefault();
saveSettings();
});
document.getElementById('rateAmount').addEventListener('input', updateSettingsPreview);
document.getElementById('rateInterval').addEventListener('input', updateSettingsPreview);
// ---------------------------------------------------------------------------
checkAuth();