Files
timer-app/frontend/index.html
T
Kerim (portable/claudecode)andClaude Opus 5 e68daee457 Drucker-Pi holt sich die Einrichtung, statt sie geschickt zu bekommen
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>
2026-09-12 13:37:26 +02:00

986 lines
35 KiB
HTML
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.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Timer - Zeiterfassung</title>
<link rel="icon" type="image/jpeg" href="/favicon.jpg">
<style>
/* Bewusst KEIN Google-Fonts-Import: kein externer Abruf zur Laufzeit.
Der System-Font-Stack sieht praktisch identisch aus (Segoe UI /
Roboto / SF Pro). Wer Inter zwingend will: siehe DEPLOY.md. */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI',
Roboto, 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d44 50%, #1a1a2e 100%);
min-height: 100vh;
color: #ffffff;
display: flex;
flex-direction: column;
position: relative;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(45, 27, 105, 0.1) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
@keyframes pulse {
0%, 100% { transform: scale(1) rotate(0deg); opacity: 0.3; }
50% { transform: scale(1.1) rotate(180deg); opacity: 0.5; }
}
@media (prefers-reduced-motion: reduce) {
body::before { animation: none; }
.view { animation: none; }
}
.container {
position: relative;
z-index: 1;
flex: 1;
display: flex;
flex-direction: column;
}
header {
padding: 1.5rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
background: rgba(26, 26, 46, 0.8);
border-bottom: 1px solid rgba(45, 27, 105, 0.3);
}
.logo {
height: 50px;
width: auto;
object-fit: contain;
}
.header-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
}
.header-user {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 0.85rem;
border-radius: 10px;
background: rgba(45, 27, 105, 0.35);
border: 1px solid rgba(255, 255, 255, 0.12);
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
}
.header-user::before { content: '👤'; }
.header-user:empty { display: none; }
.btn {
padding: 0.625rem 1.5rem;
background: linear-gradient(135deg, #2D1B69, #3d2589);
color: white;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
font-family: inherit;
box-shadow: 0 4px 15px rgba(45, 27, 105, 0.3);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(45, 27, 105, 0.5);
background: linear-gradient(135deg, #3d2589, #4d35a9);
}
.btn:active { transform: translateY(0); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn:focus-visible {
outline: 2px solid #a8a8ff;
outline-offset: 2px;
}
.btn-sm {
padding: 0.5rem 1rem;
font-size: 0.875rem;
box-shadow: none;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: none;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.btn-danger { background: linear-gradient(135deg, #8B1538, #a01848); }
.btn-danger:hover { background: linear-gradient(135deg, #a01848, #b02058); }
.btn-start { background: linear-gradient(135deg, #10b981, #059669); }
.btn-start:hover { background: linear-gradient(135deg, #059669, #047857); }
.btn-stop { background: linear-gradient(135deg, #f59e0b, #d97706); }
.btn-stop:hover { background: linear-gradient(135deg, #d97706, #b45309); }
main {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 2rem;
}
.view {
display: none;
width: 100%;
max-width: 1200px;
animation: fadeIn 0.4s ease-out;
}
.view.active { display: block; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
/* ---------- Login ---------- */
#loginView { display: none; }
#loginView.active { display: flex; align-items: center; justify-content: center; min-height: 70vh; }
.login-container {
max-width: 450px;
width: 100%;
margin: 0 auto;
background: rgba(26, 26, 46, 0.8);
border-radius: 20px;
padding: 3rem;
border: 1px solid rgba(45, 27, 105, 0.3);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.login-logo {
width: 120px;
height: 120px;
margin: 0 auto 2rem;
display: block;
border-radius: 50%;
}
.login-container h1 {
text-align: center;
margin-bottom: 2rem;
font-size: 1.8rem;
font-weight: 600;
background: linear-gradient(135deg, #ffffff, #a8a8ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.form-group { margin-bottom: 1.5rem; }
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
}
input[type="text"],
input[type="password"],
input[type="number"],
input[type="date"],
textarea {
width: 100%;
padding: 0.875rem 1.125rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
font-size: 1rem;
color: white;
font-family: inherit;
transition: all 0.3s ease;
}
input:focus,
textarea:focus {
outline: none;
border-color: #4d35a9;
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 0 0 3px rgba(45, 27, 105, 0.35);
}
input::placeholder,
textarea::placeholder { color: rgba(255, 255, 255, 0.35); }
/* Datumsfelder im Dark Theme lesbar halten */
input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1); opacity: 0.6; cursor: pointer; }
.error-message {
color: #ff8787;
font-size: 0.875rem;
margin-top: 0.5rem;
display: none;
}
.error-message.show { display: block; }
/* ---------- Timer-Uebersicht ---------- */
.timer-toolbar {
background: rgba(26, 26, 46, 0.6);
border-radius: 20px;
padding: 1.5rem;
margin-bottom: 2rem;
border: 1px solid rgba(45, 27, 105, 0.3);
}
.timer-toolbar form {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.timer-toolbar input {
flex: 1;
min-width: 220px;
}
.timer-toolbar .btn { white-space: nowrap; }
.rate-hint {
margin-top: 1rem;
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.6);
}
.timer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
}
.timer-card {
background: rgba(26, 26, 46, 0.6);
border-radius: 20px;
padding: 1.75rem;
border: 2px solid rgba(45, 27, 105, 0.4);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
text-align: center;
transition: border-color 0.3s ease;
}
.timer-card.running { border-color: rgba(16, 185, 129, 0.45); }
.timer-card.paused { border-color: rgba(245, 158, 11, 0.45); }
.timer-card-head {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.timer-name {
flex: 1;
padding: 0.5rem 0.75rem !important;
font-size: 0.95rem !important;
font-weight: 500;
text-align: left;
}
.timer-badge {
font-size: 0.75rem;
font-weight: 600;
padding: 0.35rem 0.6rem;
border-radius: 6px;
white-space: nowrap;
letter-spacing: 0.03em;
}
.timer-card.running .timer-badge { background: rgba(16, 185, 129, 0.18); color: #6ee7b7; }
.timer-card.paused .timer-badge { background: rgba(245, 158, 11, 0.18); color: #fcd34d; }
.timer-card-time {
font-size: 3.2rem;
font-weight: 300;
letter-spacing: 0.03em;
font-variant-numeric: tabular-nums;
margin-bottom: 0.75rem;
background: linear-gradient(135deg, #ffffff, #c8c8ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.timer-card-amount {
font-size: 1.8rem;
font-weight: 600;
color: #ffffff;
background: linear-gradient(135deg, #2D1B69, #3d2589);
padding: 0.6rem 1.5rem;
border-radius: 12px;
display: inline-block;
min-width: 140px;
border: 2px solid rgba(255, 255, 255, 0.18);
font-variant-numeric: tabular-nums;
}
.timer-card-since {
margin-top: 0.75rem;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.45);
}
.timer-card-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 1.5rem;
flex-wrap: wrap;
}
/* ---------- Historie ---------- */
.history-container,
.settings-container {
background: rgba(26, 26, 46, 0.6);
border-radius: 20px;
padding: 2rem;
border: 1px solid rgba(45, 27, 105, 0.3);
}
.settings-container { max-width: 700px; margin: 0 auto; padding: 2.5rem; }
.history-container h2,
.settings-container h2 {
margin-bottom: 1.5rem;
font-size: 2rem;
font-weight: 600;
background: linear-gradient(135deg, #ffffff, #a8a8ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Der Farbverlauf oben faerbt den Text ueber eine durchsichtige
Schriftfarbe ein. Ein Farb-Emoji hat dabei keine Kontur, die sich
einfaerben liesse - der Browser fuellt seine gesamte Flaeche mit dem
Verlauf, und aus dem Balkendiagramm wird ein weisser Kasten.
Deshalb bekommen die Symbole ihre Schriftfarbe zurueck und malen
sich wieder selbst. */
.heading-icon {
-webkit-text-fill-color: #ffffff;
color: #ffffff;
}
.filter-bar {
display: flex;
gap: 1rem;
align-items: flex-end;
flex-wrap: wrap;
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.filter-field { display: flex; flex-direction: column; gap: 0.4rem; }
.filter-field label {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.6);
font-weight: 500;
}
.filter-field input { min-width: 150px; padding: 0.6rem 0.9rem !important; font-size: 0.9rem !important; }
.filter-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.history-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: rgba(45, 27, 105, 0.2);
padding: 1.5rem;
border-radius: 15px;
border: 1px solid rgba(45, 27, 105, 0.3);
}
.stat-card h3 {
font-size: 0.8rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-card p {
font-size: 1.9rem;
font-weight: 600;
color: #ffffff;
font-variant-numeric: tabular-nums;
}
.history-list { max-height: 55vh; overflow-y: auto; }
.history-item {
background: rgba(255, 255, 255, 0.05);
padding: 1.1rem 1.25rem;
border-radius: 12px;
margin-bottom: 0.75rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.25s ease;
}
.history-item:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(45, 27, 105, 0.4);
}
.history-item-info { flex: 1; min-width: 0; }
.history-item-name {
display: block;
font-size: 1.05rem;
font-weight: 600;
margin-bottom: 0.15rem;
}
.history-item-name.muted { color: rgba(255, 255, 255, 0.4); font-weight: 400; font-style: italic; }
.history-item-date { font-size: 0.85rem; color: rgba(255, 255, 255, 0.55); }
.history-item-duration {
font-size: 1.05rem;
font-weight: 500;
font-variant-numeric: tabular-nums;
margin-top: 0.15rem;
}
.history-item-right { display: flex; align-items: center; gap: 0.75rem; }
.history-item-amount {
font-size: 1.4rem;
font-weight: 600;
background: linear-gradient(135deg, #2D1B69, #3d2589);
padding: 0.5rem 1rem;
border-radius: 8px;
min-width: 100px;
text-align: center;
border: 1px solid rgba(255, 255, 255, 0.15);
font-variant-numeric: tabular-nums;
}
.history-footer { text-align: center; margin-top: 1rem; }
.empty-state {
text-align: center;
padding: 3rem;
color: rgba(255, 255, 255, 0.5);
}
.empty-state-icon { font-size: 4rem; margin-bottom: 1rem; opacity: 0.3; }
/* ---------- Einstellungen ---------- */
.settings-description { color: rgba(255, 255, 255, 0.7); margin-bottom: 2rem; font-size: 0.95rem; }
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 2rem;
}
.form-group small {
display: block;
margin-top: 0.5rem;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.5);
}
.settings-preview {
background: rgba(45, 27, 105, 0.2);
padding: 1.5rem;
border-radius: 15px;
margin-bottom: 2rem;
border: 1px solid rgba(45, 27, 105, 0.3);
text-align: center;
}
.settings-preview h3 {
font-size: 0.9rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.7);
margin-bottom: 1rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.settings-preview p { font-size: 1.7rem; font-weight: 600; margin-bottom: 0.5rem; }
.preview-examples {
font-size: 0.95rem !important;
font-weight: 400 !important;
color: rgba(255, 255, 255, 0.7) !important;
margin-top: 1rem !important;
}
/* ---------- Bondrucker ---------- */
.settings-section {
border-top: 1px solid rgba(255, 255, 255, 0.12);
margin: 2rem 0;
padding-top: 1.5rem;
}
.settings-section h3 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.settings-section > p {
color: rgba(255, 255, 255, 0.7);
font-size: 0.9rem;
margin-bottom: 1.25rem;
}
.settings-section textarea {
box-sizing: border-box;
resize: vertical;
min-height: 4.5rem;
line-height: 1.5;
}
.checkbox-row {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin-top: 1.25rem;
}
.checkbox-row input[type="checkbox"] {
width: 1.15rem;
height: 1.15rem;
margin-top: 0.15rem;
flex-shrink: 0;
accent-color: #6d4aff;
}
.checkbox-row label { font-weight: 500; cursor: pointer; }
.checkbox-row small { display: block; color: rgba(255, 255, 255, 0.5); font-size: 0.8rem; margin-top: 0.25rem; }
.printer-state {
font-size: 0.9rem;
padding: 0.75rem 1rem;
border-radius: 10px;
margin-bottom: 1rem;
background: rgba(255, 255, 255, 0.05);
border-left: 3px solid rgba(255, 255, 255, 0.25);
}
.setup-box {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1.25rem;
margin-top: 1rem;
}
.setup-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1rem;
}
@media (max-width: 600px) {
.setup-grid { grid-template-columns: 1fr; }
}
.setup-hint {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.55);
margin-top: 1rem;
line-height: 1.5;
}
.setup-link { margin-top: 1.25rem; }
/* Der Befehl muss vollstaendig lesbar sein - er wird abgetippt, wenn
das Kopieren ins Leere geht (kein HTTPS, fremdes Tablet). Deshalb
umbrechen statt abschneiden. */
.setup-command {
display: flex;
align-items: flex-start;
gap: 0.75rem;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 0.85rem 1rem;
}
.setup-command code {
flex: 1;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.8rem;
line-height: 1.6;
color: #d7d7ff;
word-break: break-all;
user-select: all;
}
.setup-command .btn { flex-shrink: 0; }
@media (max-width: 600px) {
.setup-command { flex-direction: column; }
}
/* Protokoll der Einrichtung - Schritt fuer Schritt, damit im Fehlerfall
sichtbar ist, wie weit es gekommen ist. */
.setup-log {
margin-top: 1rem;
font-size: 0.85rem;
display: none;
}
.setup-log.show { display: block; }
.setup-step {
display: flex;
gap: 0.65rem;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.setup-step:last-child { border-bottom: none; }
.setup-step-icon { flex-shrink: 0; }
.setup-step-name { font-weight: 600; }
.setup-step-detail {
color: rgba(255, 255, 255, 0.6);
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.78rem;
margin-top: 0.3rem;
max-height: 11rem;
overflow-y: auto;
}
.setup-step.fail .setup-step-name { color: #f87171; }
.printer-state.ok { border-left-color: #22c55e; }
.printer-state.error { border-left-color: #ef4444; }
.printer-state.off { border-left-color: rgba(255, 255, 255, 0.2); color: rgba(255, 255, 255, 0.6); }
.settings-actions { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
.settings-actions .btn { flex: 1; padding: 1rem; }
.settings-info {
background: rgba(255, 255, 255, 0.05);
padding: 1rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.settings-info p { font-size: 0.875rem; color: rgba(255, 255, 255, 0.7); margin: 0; }
/* ---------- Toast ---------- */
.toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%) translateY(200%);
background: rgba(26, 26, 46, 0.97);
border: 1px solid rgba(45, 27, 105, 0.6);
color: #fff;
padding: 1rem 1.75rem;
border-radius: 12px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
z-index: 100;
transition: transform 0.35s ease, opacity 0.35s ease;
opacity: 0;
max-width: 90vw;
font-size: 0.95rem;
text-align: center;
/* Meldungen zum Bondruck sind zweizeilig - Umbrueche sollen stehen bleiben. */
white-space: pre-line;
}
.toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
.toast.success { border-color: rgba(16, 185, 129, 0.7); }
.toast.error { border-color: rgba(220, 38, 38, 0.8); }
/* ---------- Mobil ---------- */
@media (max-width: 768px) {
header { padding: 1rem; }
main { padding: 1rem; }
.logo { height: 38px; }
.header-actions { gap: 0.5rem; width: 100%; }
.header-actions .btn { flex: 1; padding: 0.6rem 0.75rem; font-size: 0.85rem; }
.login-container { padding: 2rem 1.5rem; }
.timer-grid { grid-template-columns: 1fr; }
.timer-card-time { font-size: 2.6rem; }
.settings-grid { grid-template-columns: 1fr; }
.settings-actions { flex-direction: column; }
.history-item { flex-direction: column; align-items: stretch; text-align: center; }
.history-item-right { justify-content: center; }
.filter-field, .filter-field input { width: 100%; }
}
</style>
</head>
<body>
<div class="container">
<header id="header" style="display: none;">
<img src="/logo.png" alt="Logo" class="logo">
<div class="header-actions">
<span id="currentUser" class="header-user" title="Angemeldet"></span>
<button class="btn btn-secondary" onclick="showTimer()">⏱️ Timer</button>
<button class="btn btn-secondary" onclick="showHistory()">📊 Historie</button>
<button class="btn btn-secondary" onclick="showSettings()">⚙️ Einstellungen</button>
<button class="btn btn-secondary" onclick="logout()">🚪 Abmelden</button>
</div>
</header>
<main>
<!-- Anmeldung -->
<div id="loginView" class="view active">
<div class="login-container">
<img src="/favicon.jpg" alt="" class="login-logo">
<h1>Zeiterfassung</h1>
<!-- Anmeldung ausschliesslich ueber Authentik (SSO) -->
<a id="ssoLoginBtn" href="/auth/login" class="btn"
style="width: 100%; padding: 1rem; text-decoration: none; box-sizing: border-box;">🔐 Anmelden mit Authentik</a>
<div class="error-message" id="loginError" style="margin-top: 1rem;">
Anmeldung derzeit nicht möglich – Authentik ist nicht erreichbar.
</div>
</div>
</div>
<!-- Timer-Uebersicht -->
<div id="timerView" class="view">
<div class="timer-toolbar">
<form id="newTimerForm">
<input type="text" id="newTimerName" maxlength="80"
placeholder="Name oder Platz (optional) - z. B. „Tisch 2" oder „Herr Müller"">
<button type="submit" class="btn btn-start">▶ Neuer Timer</button>
</form>
<div class="rate-hint" id="rateHint">Tarif wird geladen …</div>
</div>
<div class="timer-grid" id="timerList"></div>
<div class="empty-state" id="timerEmpty" style="display: none;">
<div class="empty-state-icon">⏱️</div>
<p>Kein Timer läuft gerade.<br>Oben einen neuen starten.</p>
</div>
</div>
<!-- Historie -->
<div id="historyView" class="view">
<div class="history-container">
<h2><span class="heading-icon">📊</span> Historie</h2>
<div class="filter-bar">
<div class="filter-field">
<label for="filterFrom">Von</label>
<input type="date" id="filterFrom">
</div>
<div class="filter-field">
<label for="filterTo">Bis</label>
<input type="date" id="filterTo">
</div>
<div class="filter-field">
<label for="filterName">Name</label>
<input type="text" id="filterName" placeholder="Kunde / Platz">
</div>
<div class="filter-actions">
<button class="btn btn-secondary btn-sm" onclick="filterToday()">Heute</button>
<button class="btn btn-secondary btn-sm" onclick="filterThisMonth()">Dieser Monat</button>
<button class="btn btn-secondary btn-sm" onclick="resetFilter()">Alle</button>
<button class="btn btn-sm" onclick="exportCsv()">⬇ CSV-Export</button>
</div>
</div>
<div class="history-stats" id="historyStats"></div>
<div class="history-list" id="historyList"></div>
<div class="history-footer">
<button class="btn btn-secondary" id="historyMore"
onclick="loadMoreHistory()" style="display: none;">Weitere laden</button>
</div>
</div>
</div>
<!-- Einstellungen -->
<div id="settingsView" class="view">
<div class="settings-container">
<h2><span class="heading-icon">⚙️</span> Einstellungen</h2>
<p class="settings-description">Der Tarif gilt für alle Geräte gemeinsam.</p>
<form id="settingsForm">
<div class="settings-grid">
<div class="form-group">
<label for="rateAmount">Betrag (€)</label>
<input type="number" id="rateAmount" min="0.5" max="10000" step="0.5" value="10" required>
<small>Euro pro Intervall</small>
</div>
<div class="form-group">
<label for="rateInterval">Intervall (Minuten)</label>
<input type="number" id="rateInterval" min="1" max="600" step="1" value="10" required>
<small>Jede angefangene Einheit wird voll berechnet</small>
</div>
</div>
<div class="settings-preview">
<h3>Vorschau</h3>
<p id="settingsPreview">€10 pro angefangene 10 Minuten</p>
<p class="preview-examples" id="previewExamples"></p>
</div>
<div class="settings-section">
<h3>🖨️ Bon für die Kasse</h3>
<p>Was auf dem Zettel steht, den der Kunde mit zur Kasse nimmt.
Betrag, Zeiten und Tarif kommen automatisch dazu.</p>
<div id="printerState" class="printer-state">Druckerzustand wird geprüft …</div>
<div class="form-group">
<label for="receiptHeader">Kopfzeilen</label>
<textarea id="receiptHeader" maxlength="240" rows="3"
placeholder="Handy-Insel&#10;Hauptstraße 12, 12345 Musterstadt&#10;Tel. 01234 / 56789"></textarea>
<small>Erste Zeile wird groß gedruckt. Eine Zeile je Zeilenumbruch, maximal 240 Zeichen.</small>
</div>
<div class="form-group">
<label for="receiptFooter">Fußzeilen</label>
<textarea id="receiptFooter" maxlength="240" rows="2"
placeholder="Bitte an der Kasse bezahlen."></textarea>
<small>Steht unter dem Betrag, maximal 240 Zeichen.</small>
</div>
<div class="checkbox-row">
<input type="checkbox" id="printAutoOnStop" checked>
<label for="printAutoOnStop">Bon beim Beenden automatisch drucken
<small>Aus: der Bon wird nur über die Knöpfe 🖨 gedruckt.</small>
</label>
</div>
<div class="checkbox-row">
<input type="checkbox" id="receiptBarcodeEnabled" checked
onchange="toggleBarcodeField()">
<label for="receiptBarcodeEnabled">Strichcode für die Kasse drucken
<small>Die Kasse scannt ihn, statt den Artikel zu suchen.</small>
</label>
</div>
<div class="form-group" id="barcodeArticleGroup">
<label for="receiptBarcodeArticle">Artikel in der Kasse</label>
<input type="text" id="receiptBarcodeArticle" maxlength="32"
placeholder="techniker" autocomplete="off" spellcheck="false">
<small>Genau der Name, unter dem die Kasse die Leistung führt.
Keine Umlaute – der Strichcode kann sie nicht abbilden.</small>
</div>
<div class="settings-actions" style="margin-top: 1.25rem;">
<button type="button" class="btn btn-secondary" id="printerTestBtn"
onclick="testPrint()">🖨️ Probedruck</button>
<button type="button" class="btn btn-secondary"
onclick="refreshPrinterStatus()">🔄 Drucker prüfen</button>
<button type="button" class="btn btn-secondary"
onclick="toggleSetup()" id="setupToggleBtn">🔧 Drucker einrichten</button>
</div>
<div class="setup-box" id="setupBox" style="display: none;">
<h3 style="margin-bottom: 0.35rem;">Drucker-Pi einrichten</h3>
<p style="color: rgba(255,255,255,0.7); font-size: 0.9rem; margin-bottom: 1.25rem;">
Die App legt ein Einrichtungsskript bereit. Das wird auf dem Pi
heruntergeladen und gestartet — dort wird auch ausgewählt, welcher
Drucker verwendet werden soll. Danach meldet sich der Pi von selbst
hier an. Der Pi braucht nur Python 3 und den Drucker am USB-Anschluss.
</p>
<div class="settings-actions" style="margin-bottom: 0;">
<button type="button" class="btn" id="setupStartBtn"
onclick="startSetup()">🔗 Einrichtungslink erzeugen</button>
</div>
<div id="setupLink" class="setup-link" style="display: none;">
<p class="setup-step-name" style="margin-bottom: 0.5rem;">
Auf dem Pi im Terminal ausführen:
</p>
<div class="setup-command">
<code id="setupCommand"></code>
<button type="button" class="btn btn-secondary btn-sm"
onclick="copySetupCommand()">📋 Kopieren</button>
</div>
<p class="setup-hint" id="setupExpiry"></p>
</div>
<div class="setup-log" id="setupLog"></div>
</div>
</div>
<div class="settings-actions">
<button type="submit" class="btn">💾 Speichern</button>
<button type="button" class="btn btn-secondary" onclick="resetSettings()">🔄 Zurücksetzen</button>
</div>
</form>
<div class="settings-info">
<p><strong>Hinweis:</strong> Eine Tarifänderung gilt ab sofort für laufende und neue Timer.
Bereits gespeicherte Einträge in der Historie behalten den Tarif, der beim Beenden galt.</p>
</div>
</div>
</div>
</main>
</div>
<div class="toast" id="toast"></div>
<script src="/app.js?v=20260912-setup"></script>
</body>
</html>