Deploy auf den Timer-Server / deploy (push) Successful in 20s
Das Einrichtungsskript fragte bisher am Pi, welcher Drucker bedient werden soll. Damit stand die Einstellung an einem Ort, an den man nur mit einem Terminal kommt - und ein zweiter Drucker am Pi bedeutete: Skript nochmal. Jetzt fragt die App den Pi, was angeschlossen ist, und der Bediener waehlt in den Einstellungen. Die Bruecke bekam dafuer /devices; sie liest die Klartextnamen aus dem sysfs, damit dort "EPSON TM-T88IV" steht und nicht "lp0" - bei zwei Druckern der Unterschied zwischen Wissen und Raten. Der gewaehlte Pfad geht bei jedem Auftrag im Kopf X-Print-Device mit. Die Bruecke haelt dazu bewusst keinen Zustand und das Skript schreibt kein PRINT_DEVICE mehr: es soll genau eine Stelle geben, die weiss, welcher Drucker gemeint ist, naemlich die, an der es bedient wird. Leer bleibt "automatisch" - das erste gefundene Geraet, zugleich der Rueckfall, wenn aus lp0 nach dem Umstecken ein lp1 geworden ist. Der Pfad kommt damit von aussen und wird an beiden Enden geprueft, in PUT /api/settings und noch einmal in der Bruecke. Eine Schranke, die nur an einem Ende steht, ist keine: ohne die Pruefung auf dem Pi liesse sich der Dienst dazu bringen, in eine beliebige Datei zu schreiben. Nebenbei: ein nicht bereiter Drucker meldete sich in der Oberflaeche als "HTTP 503". Das verriet niemandem, dass nur ein Stecker lose ist. Die 503 der Bruecke ist kein Protokollfehler, und ihr Rumpf sagt, was fehlt - der wird jetzt ausgewertet. Geprueft am echten Pi (Debian 13, TM-T88IV): Einrichtung ohne Rueckfrage, Geraeteliste mit Namen, ausdrueckliche Wahl, Druck darauf, Rueckkehr auf automatisch, Druck darauf. Dazu die Abweise: /etc/passwd und ein Pfad mit angehaengtem Befehl werden zurueckgewiesen, ein erlaubtes aber fehlendes /dev/usb/lp7 ergibt eine Meldung im Klartext. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
939 lines
35 KiB
JavaScript
939 lines
35 KiB
JavaScript
/**
|
||
* 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 `
|
||
<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();
|