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

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

1035 lines
38 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>
<!-- data-theme: die App ist immer dunkel, die Portal-Kopfleiste soll es auch sein -->
<html lang="de" data-theme="dark">
<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">
<!-- Zentrale Handy-Insel-Kopfleiste (App-Umschalter + Benutzermenue).
Einzige externe Ressource der Seite; Details: portal.handy-insel.de -->
<script src="https://portal.handy-insel.de/topbar.js" data-app="timer" defer></script>
<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"],
select,
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,
select: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;
}
/* Das Auswahlfeld erbt die Eingabefeld-Optik; die Liste selbst malt der
Browser und braucht deshalb eine eigene, dunkle Fuellfarbe. */
select option { background: #2d2d44; color: #ffffff; }
.device-row {
display: flex;
gap: 0.75rem;
align-items: center;
}
.device-row select { flex: 1; }
.device-row .btn { flex-shrink: 0; }
.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%; }
}
/* ---------- Portal-Kopfleiste ---------- */
/* Die Leiste steht im Fluss als erstes Kind von <body>; body hat nur
min-height: 100vh, dadurch entsteht kein Ueberlauf. Beim Drucken
soll sie nicht erscheinen. */
hi-topbar { flex-shrink: 0; }
@media print { hi-topbar { display: none !important; } }
/* Benutzername und Abmelden sitzen im Benutzer-Menue der Leiste. Die
eigenen bleiben nur als Rueckfall, wenn topbar.js nicht laedt. */
hi-topbar ~ .container #currentUser,
hi-topbar ~ .container #logoutBtn { display: none; }
/* Sitzungsanzeige im Benutzer-Menue (slot="benutzer"), Schriftfarbe kommt von der Leiste */
.topbar-session { padding: 10px 16px; font-size: 13px; line-height: 1.4; }
.topbar-session span { display: block; opacity: 0.7; }
</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 id="logoutBtn" 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" id="printerDeviceGroup">
<label for="printerDevice">Drucker am Pi</label>
<div class="device-row">
<select id="printerDevice" onchange="saveDevice()">
<option value="">automatisch</option>
</select>
<button type="button" class="btn btn-secondary btn-sm"
onclick="loadDevices(true)">🔄 Suchen</button>
</div>
<small id="printerDeviceHint">Welches Gerät am Raspberry Pi bedruckt wird.
„Automatisch" nimmt den ersten gefundenen.</small>
</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=20261025-topbar"></script>
</body>
</html>