Bisher richtete die App den Pi per SSH ein: Adresse, Benutzer und Passwort ins Webformular, Knopf druecken. Das verlangte ein SSH-Passwort in einem Browser, ein weiteres Paket (ssh2) und eine Verbindung vom Server zum Pi. Jetzt andersherum. Die App legt einen Link bereit und wartet. Auf dem Pi wird das Skript geholt und gestartet, dort wird der Drucker ausgewaehlt, und am Ende meldet sich der Pi von selbst an. Wer einen Drucker ansteckt, steht ohnehin am Pi - dort gehoert auch die Frage hin, welcher es sein soll. Die Auswahl zeigt Klartextnamen aus dem sysfs: "EPSON TM-T88IV" statt "lp0". Bei mehreren Druckern ist das der Unterschied zwischen Wissen und Raten. Das ausgelieferte Skript ist zusammengesetzt, nicht statisch: einrichten.sh ist eine Vorlage, in die setup.js Adresse, Code, print-server.py und die systemd-Unit einsetzt. Heraus kommt EINE Datei - auf dem Pi ist nichts nachzuladen, und eine halb geglueckte Uebertragung kann es nicht geben. Zwei Endpunkte muessen ohne Anmeldung auskommen, weil der Pi sich bei Authentik nicht anmelden kann. Der Einrichtungscode ist dort der einzige Schluessel: 32 Zeichen, gilt einmal und 30 Minuten, nur im Arbeitsspeicher, zeitkonstant verglichen, hinter einem eigenen Zaehler. Die Adresse des Pi wird der Verbindung entnommen und nicht dem Rumpf der Anmeldung - was der Pi ueber sich behauptet, koennte auch jemand anders behaupten. Die eigene Adresse leitet die App aus dem Host-Kopf ab und prueft sie streng, statt sie zu maskieren: der Wert landet in einem Shell-Skript. Geprueft am echten Pi (Debian 13, TM-T88IV): Link erzeugen, am Pi abholen, Drucker aus der Liste waehlen, Anmeldung, Uebernahme, Probedruck. Dazu die Abweise: verbrauchter Code, falscher Code, Abholung ohne Code, und dass die uebrige API weiterhin 401 liefert. Entfaellt: provision.js, install.sh, die Abhaengigkeit ssh2, das SSH-Formular. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
847 lines
32 KiB
JavaScript
847 lines
32 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
|
||
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.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();
|
||
} 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 });
|
||
}
|
||
}
|
||
|
||
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, 'success');
|
||
await refreshPrinterStatus();
|
||
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();
|