chore: initialize public repository
CI / checks (push) Has been cancelled

This commit is contained in:
maddin
2026-03-22 12:55:55 +00:00
commit 9794362f39
143 changed files with 19832 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#2c2d2f" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style"
content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="{{ app_title }}" />
{% set __page_title %}{% block title %}{{ app_name }}{% endblock %}{% endset %}
<title>
{% if __page_title | trim == app_name %}{{ app_title }}{% else %}{{ app_title }} - {{ __page_title | trim }}{% endif %}
</title>
<link rel="manifest" href="/manifest.webmanifest?v={{ asset_version }}" />
<link rel="icon" type="image/svg+xml" href="/img/Logo.svg" />
<link rel="icon"
type="image/png"
sizes="32x32"
href="/static/icons/favicon-32.png?v={{ asset_version }}" />
<link rel="icon"
type="image/png"
sizes="16x16"
href="/static/icons/favicon-16.png?v={{ asset_version }}" />
<link rel="shortcut icon"
href="/static/icons/favicon.ico?v={{ asset_version }}" />
<link rel="apple-touch-icon"
sizes="180x180"
href="{% if app_env != 'production' %}/static/icons/apple-touch-icon-stage.png{% else %}/static/icons/apple-touch-icon.png{% endif %}?v={{ asset_version }}" />
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}" />
{% block head_extra %}{% endblock %}
</head>
<body class="app-theme {% block body_class %}{% endblock %}">
{% include "partials/header.html" %}
<div class="container app-page-actions-wrap">{%- block page_actions -%}{%- endblock -%}</div>
<main class="container page {% block page_class %}{% endblock %}">
{% include "partials/flash.html" %}
{% block content %}{% endblock %}
</main>
{% include "partials/footer.html" %}
{% include "partials/version_badge.html" %}
<script type="module" src="/static/js/app.js?v={{ asset_version }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% from "ui/card.html" import card %}
{% from "ui/button.html" import button, link_button %}
{% from "ui/form_field.html" import input_field, select_field, textarea_field %}
{% from "ui/page_header.html" import page_header %}
{% block title %}Mehrfacheingabe{% endblock %}
{% block content %}
{{ page_header('Mehrere Tage / Wochen bearbeiten', 'Zeitraum und Wochentage auswählen, dann Zeiten gesammelt für alle passenden Tage setzen.') }}
{% call card('form-card full-width') %}
<form method="post"
action="/bulk-entry"
class="stack"
data-component="break-rules-form"
data-auto-break-enabled="{{ 'true' if user.automatic_break_rules_enabled else 'false' }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="break_mode" value="{{ break_mode }}" data-break-mode />
<div class="inline-grid">
{{ input_field('Von', 'from_date', type='date', value=from_date, required=true) }}
{{ input_field('Bis', 'to_date', type='date', value=to_date, required=true) }}
</div>
<fieldset class="weekday-fieldset">
<legend>Wochentage</legend>
<div class="weekday-grid">
{% for option in weekday_options %}
<label class="checkbox-row">
<input type="checkbox"
name="weekdays_values"
value="{{ option.value }}"
{% if option.value in weekdays_selected %}checked{% endif %} />
<span>{{ option.label }}</span>
</label>
{% endfor %}
</div>
</fieldset>
<div class="inline-grid">
{{ input_field('Beginn', 'start_time', type='time', value=start_time, required=true, attrs='data-break-input=\"start\"') }}
{{ input_field('Ende', 'end_time', type='time', value=end_time, required=true, attrs='data-break-input=\"end\"') }}
</div>
<div class="inline-grid">
{{ input_field('Pause (Minuten)', 'break_minutes', type='number', value=break_minutes, required=true, attrs='min="0" step="1" data-break-input=\"minutes\"') }}
{{ select_field('Modus', 'mode', [
{'value': 'only_missing', 'label': 'Nur leere Tage anlegen'},
{'value': 'upsert', 'label': 'Bestehende Einträge aktualisieren + fehlende anlegen'}
], bulk_mode, required=true) }}
</div>
{% if user.automatic_break_rules_enabled %}
<div class="form-field form-field--hint stack-xs" data-component="break-rules-status">
<p class="muted" data-break-status>
{% if break_mode == 'manual' %}
Pause manuell gesetzt. Gesetzliche Mindestpause wird nicht automatisch überschrieben.
{% else %}
Gesetzliche Mindestpause nach deutschem Arbeitsrecht wird automatisch vorgeschlagen.
{% endif %}
</p>
<button type="button" class="button ghost" data-action="break-reset-auto">Automatische Pause erneut anwenden</button>
</div>
{% endif %}
{{ textarea_field('Notiz (optional)', 'notes', notes, 3) }}
<div class="nav-row">
{{ button('Mehrfacheingabe speichern', type='submit') }}
{{ link_button('Zurück', '/dashboard', 'ghost') }}
</div>
</form>
{% endcall %}
{% endblock %}
+76
View File
@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% from "ui/button.html" import button %}
{% from "ui/card.html" import card %}
{% from "ui/page_header.html" import page_header %}
{% block title %}Kontakt{% endblock %}
{% block page_class %}contact-page{% endblock %}
{% block content %}
{{ page_header("Kontakt", "Schicke eine Nachricht bei Problemen, Fehlermeldungen oder Funktionswünschen. Antworten erfolgen per E-Mail.") }}
<div class="contact-grid">
{% call card('contact-card') %}
<form method="post" action="/kontakt" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="started_at" value="{{ contact_started_at }}" />
<label class="contact-honeypot" aria-hidden="true">
<span>Bitte leer lassen</span>
<input type="text" name="website" tabindex="-1" autocomplete="off" />
</label>
<div class="inline-grid">
<label>
Name (optional)
<input type="text" name="name" value="{{ contact_name }}" maxlength="120" />
</label>
<label>
E-Mail-Adresse
<input type="email" name="email" value="{{ contact_email }}" maxlength="254" required />
</label>
</div>
<label>
Kategorie
<select name="category" required>
{% for option in category_options %}
<option value="{{ option.value }}"
{% if option.value == contact_category %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
<label>
Betreff
<input type="text" name="subject" value="{{ contact_subject }}" maxlength="180" required />
</label>
<label>
Nachricht
<textarea name="message" rows="9" maxlength="5000" required>{{ contact_message }}</textarea>
</label>
<p class="muted">
Bitte keine sensiblen Passwörter oder Zugangsdaten mitsenden. Anhänge sind in dieser ersten Version noch nicht möglich.
</p>
{{ button("Nachricht senden", type="submit") }}
</form>
{% endcall %}
{% call card('contact-card contact-info-card') %}
<h2>Wofür ist das gedacht?</h2>
<ul class="contact-info-list">
<li>Fehler melden, wenn etwas nicht wie erwartet funktioniert</li>
<li>Funktionswünsche einreichen</li>
<li>Fragen zur Nutzung oder zu Einstellungen stellen</li>
</ul>
<p class="muted">
Nachrichten werden intern als Ticket gespeichert. So gehen Rückmeldungen nicht verloren und können strukturiert bearbeitet werden.
</p>
<p class="muted">
Hinweise zu Anbieter und Datenschutz findest du ebenfalls unten im Footer über <a href="/impressum">Impressum</a> und <a href="/datenschutz">Datenschutz</a>.
</p>
{% endcall %}
</div>
{% endblock %}
+45
View File
@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% from "ui/day_row.html" import day_row with context %}
{% from "ui/kpi_bar.html" import kpi_bar with context %}
{% from "ui/week_header_bar.html" import week_header_bar with context %}
{% from "ui/icon_button.html" import icon_link with context %}
{% from "ui/warning_components.html" import workhours_target_warning_banner with context %}
{% block title %}Wochenansicht{% endblock %}
{% block body_class %}dashboard-theme{% endblock %}
{% block page_class %}dashboard-page{% endblock %}
{% block content %}
{% set return_to = request.url.path ~ ('?' ~ request.url.query if request.url.query else '') %}
<div class="week-view-shell">
{% call week_header_bar('/dashboard?date=' ~ previous_week.isoformat(), '/dashboard?date=' ~ next_week.isoformat(), 'KW ' ~ week.week_start.isocalendar()[1] ~ ' (' ~ week.week_start.strftime('%d.%m.') ~ ' - ' ~ week.week_end.strftime('%d.%m.%Y') ~ ')') %}
<form method="post" action="/vacation/week/toggle" class="inline-form" data-async-refresh="view">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="week_start" value="{{ week.week_start.isoformat() }}" />
<input type="hidden" name="week_end" value="{{ week.week_end.isoformat() }}" />
<input type="hidden" name="return_to" value="{{ return_to }}" />
<button type="submit"
class="week-vacation-button {% if week.is_vacation_week %}is-active{% endif %}"
title="Urlaub für ganze Woche umschalten"
aria-label="Urlaub für ganze Woche umschalten">Urlaub</button>
</form>
{{ icon_link('/entry/new?date=' ~ selected_date.isoformat(), '/static/icons/add.svg', 'Tag hinzufügen') }}
{{ icon_link('/bulk-entry?from=' ~ week.week_start.isoformat() ~ '&to=' ~ week.week_end.isoformat(), '/static/icons/batch.svg', 'Mehrere Tage bearbeiten') }}
{{ icon_link('/export', '/static/icons/export.svg', 'Export') }}
{% endcall %}
{{ kpi_bar([
{'label': 'IST', 'value': ('%.2f'|format(week.weekly_ist / 60) )|replace('.00', '')},
{'label': 'SOLL', 'value': ('%.2f'|format(week.weekly_soll / 60))|replace('.00', '')},
{'label': 'DELTA', 'value': ('%.2f'|format(week.weekly_delta / 60))|replace('.00', ''), 'value_class': 'negative' if week.weekly_delta < 0 else 'positive'},
{'label': 'KUMULIERT', 'value': ('%.2f'|format(week.cumulative_delta / 60))|replace('.00', ''), 'value_class': 'negative' if week.cumulative_delta < 0 else 'positive'}
], 'kpi-bar--week') }}
{{ workhours_target_warning_banner(workhours_target_warning) }}
<section class="day-list day-list--week">
{% for day in week.days %}
{{ day_row(day, csrf_token, weekday_name_de(day.date) ~ ', ' ~ day.date.strftime('%d.%m.%Y'), return_to, 'week') }}
{% endfor %}
</section>
</div>
{% endblock %}
+49
View File
@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% from "ui/card.html" import card %}
{% from "ui/button.html" import link_button %}
{% from "ui/page_header.html" import page_header %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
{{ page_header(title, selected_date.strftime('%d.%m.%Y')) }}
{% call card('form-card') %}
<div class="stack">
{% if has_entry %}
<p class="muted">An diesem Tag ist bereits regulaere Arbeitszeit eingetragen. Bitte bearbeite zuerst den Zeiteintrag.</p>
<div class="nav-row">
{{ link_button('Zeiteintrag bearbeiten', '/entry/' ~ existing_entry_id ~ '/edit', 'primary') }}
{{ link_button('Zurueck', return_to, 'ghost') }}
</div>
{% else %}
<p class="muted">
{% if is_active %}
{{ title }} ist aktuell gesetzt. Mit dem Speichern entfernst du diesen Status wieder.
{% else %}
{% if current_status_label %}
Aktuell ist {{ current_status_label }} gesetzt. Mit dem Speichern wird dieser Status ersetzt.
{% else %}
Hier kannst du diesen Status direkt für den ausgewählten Tag setzen oder wieder entfernen.
{% endif %}
{% endif %}
</p>
{% if day_overtime_adjustment_minutes %}
<p class="muted">
Zusaetzlicher Stundenausgleich aktiv:
<strong>{{ '+' if day_overtime_adjustment_minutes > 0 else '' }}{{ minutes_to_hhmm(day_overtime_adjustment_minutes) }}</strong>
</p>
{% endif %}
<form method="post" action="{{ action_url }}" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="date" value="{{ selected_date.isoformat() }}" />
<input type="hidden" name="return_to" value="{{ return_to }}" />
{% if status_key != 'vacation' %}
<input type="hidden" name="status" value="{{ status_key }}" />
{% endif %}
<div class="nav-row">
<button type="submit" class="btn btn--primary">{{ title }} {% if is_active %}entfernen{% else %}speichern{% endif %}</button>
{{ link_button('Abbrechen', return_to, 'ghost') }}
</div>
</form>
{% endif %}
</div>
{% endcall %}
{% endblock %}
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% from "ui/card.html" import card %}
{% from "ui/button.html" import button %}
{% from "ui/form_field.html" import input_field %}
{% from "ui/page_header.html" import page_header %}
{% block title %}E-Mail bestätigen{% endblock %}
{% block content %}
{{ page_header("Bestätigungslink anfordern") }}
{% call card('auth-card') %}
<form action="/verify-email/resend" method="post" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
{{ input_field('E-Mail-Adresse', 'email', type='email', required=true, autocomplete='username') }}
{{ button('Link senden', type='submit') }}
</form>
<p>
<a href="/login">Zur Anmeldung</a>
</p>
{% endcall %}
{% endblock %}
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% from "ui/card.html" import card %}
{% from "ui/page_header.html" import page_header %}
{% from "ui/flash.html" import alert %}
{% block title %}E-Mail bestätigen{% endblock %}
{% block content %}
{{ page_header("E-Mail-Bestätigung") }}
{% call card('auth-card') %}
{% if success %}
{{ alert(message, 'success') }}
{% else %}
{{ alert(message, 'error') }}
{% endif %}
<p>
<a href="/verify-email/resend">Neuen Bestätigungslink anfordern</a>
</p>
<p>
<a href="/login">Zur Anmeldung</a>
</p>
{% endcall %}
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% from "ui/card.html" import card %}
{% from "ui/button.html" import button, link_button %}
{% from "ui/form_field.html" import input_field, textarea_field %}
{% from "ui/page_header.html" import page_header %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
{{ page_header(title, "Nur fuer regulaere Arbeitszeit." ~ (" Gesetzliche Pausen koennen automatisch beruecksichtigt werden." if user.automatic_break_rules_enabled else "")) }}
{% call card('form-card') %}
<form method="post"
action="{{ action_url }}"
class="stack"
data-component="break-rules-form"
data-auto-break-enabled="{{ 'true' if user.automatic_break_rules_enabled else 'false' }}"
data-full-day-net-minutes="{{ full_day_net_minutes if full_day_net_minutes is not none else '' }}"
data-default-break-minutes="{{ user.default_break_minutes }}"
data-default-start-time="08:30">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="return_to" value="{{ return_to }}" />
<input type="hidden" name="break_mode" value="{{ entry.break_mode if entry else ('auto' if user.automatic_break_rules_enabled else 'manual') }}" data-break-mode />
{{ input_field('Datum', 'date', type='date', value=(entry.date if entry and entry.date else selected_date.isoformat()), required=true) }}
{{ input_field('Arbeitsbeginn', 'start_time', type='time', value=(entry.start_time if entry else ''), required=true, attrs='data-break-input=\"start\"') }}
{{ input_field('Arbeitsende', 'end_time', type='time', value=(entry.end_time if entry else ''), required=true, attrs='data-break-input=\"end\"') }}
{% if full_day_net_minutes is not none %}
<div class="nav-row">
<button type="button" class="button ghost" data-action="entry-apply-full-day">Ganzer Tag</button>
</div>
{% endif %}
{{ input_field('Pause in Minuten', 'break_minutes', type='number', value=(entry.break_minutes if entry and entry.break_minutes is not none else 0), required=true, attrs='min=\"0\" step=\"1\" data-break-input=\"minutes\"') }}
{% if user.automatic_break_rules_enabled %}
<div class="form-field form-field--hint stack-xs" data-component="break-rules-status">
<p class="muted" data-break-status>
{% if entry and entry.break_mode == 'manual' %}
Pause manuell gesetzt. Gesetzliche Mindestpause wird nicht automatisch überschrieben.
{% else %}
Gesetzliche Mindestpause nach deutschem Arbeitsrecht wird automatisch vorgeschlagen.
{% endif %}
</p>
<button type="button" class="button ghost" data-action="break-reset-auto">Automatische Pause erneut anwenden</button>
</div>
{% endif %}
{{ textarea_field('Notiz (optional)', 'notes', (entry.notes if entry else ''), 3) }}
<p class="muted">Mit gespeicherter Arbeitszeit werden Urlaub, Feiertag, Krankheit und Stundenausgleich fuer diesen Tag entfernt.</p>
<div class="nav-row">
{{ button('Speichern', type='submit') }}
{{ link_button('Abbrechen', return_to, 'ghost') }}
</div>
</form>
{% endcall %}
{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% from "ui/card.html" import card %}
{% from "ui/button.html" import button, link_button %}
{% from "ui/form_field.html" import input_field, select_field %}
{% from "ui/page_header.html" import page_header %}
{% block title %}Export{% endblock %}
{% block content %}
{{ page_header('Export', 'Zeitraum auf den Tag genau wählen und als Excel oder PDF herunterladen.') }}
{% call card('form-card') %}
<form method="post" action="/export" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="inline-grid">
{{ input_field('Von', 'from_date', type='date', value=from_date, required=true) }}
{{ input_field('Bis', 'to_date', type='date', value=to_date, required=true) }}
</div>
{{ select_field('Format', 'format', [
{'value': 'xlsx', 'label': 'Excel (.xlsx)'},
{'value': 'pdf', 'label': 'PDF (.pdf)'}
], 'xlsx', required=true) }}
<div class="nav-row">
{{ button('Export starten', type='submit') }}
{{ link_button('Zurück', '/dashboard', 'ghost') }}
</div>
</form>
{% endcall %}
{% endblock %}
+206
View File
@@ -0,0 +1,206 @@
{% extends "base.html" %}
{% from "ui/page_header.html" import page_header %}
{% from "ui/help_section.html" import help_section %}
{% block title %}Hilfe{% endblock %}
{% block page_class %}help-page{% endblock %}
{% block content %}
{{ page_header("Hilfe", "Hier findest du alle wichtigen Funktionen des Stundenfuchs verständlich erklärt.") }}
<section class="help-intro">
<p>
Stundenfuchs hilft dir dabei, Arbeitszeiten, Urlaub, Feiertage, Krankheitstage und Überstunden an einem Ort zu verwalten.
Die App ist so aufgebaut, dass du im Alltag schnell arbeiten kannst, ohne jede Berechnung selbst im Kopf machen zu müssen.
</p>
<p>
Wenn du neu startest, beginne am besten mit der Wochenansicht. Dort kannst du Tage eintragen, bearbeiten und direkt sehen,
wie sich deine Stunden verändern.
</p>
</section>
<section class="help-guides">
<header class="help-guides__header">
<h2>Schritt-für-Schritt-Anleitungen</h2>
<p class="muted">Die wichtigsten Abläufe einmal komplett erklärt. Du kannst diese Anleitungen direkt nacheinander durchgehen.</p>
</header>
<div class="help-guides__grid">
<article class="help-guide-card">
<h3>Einen normalen Arbeitstag eintragen</h3>
<ol class="help-steps">
<li>Gehe in die Wochen- oder Monatsansicht.</li>
<li>Klicke beim gewünschten Tag auf <strong>+</strong> oder auf <strong>Bearbeiten</strong>.</li>
<li>Wähle <strong>Zeit</strong>, wenn du einen normalen Arbeitstag erfassen möchtest.</li>
<li>Trage Arbeitsbeginn und Arbeitsende ein. Wenn du die automatische Pausenregel aktiviert hast, schlägt Stundenfuchs die gesetzliche Mindestpause automatisch vor.</li>
<li>Du kannst die Pause trotzdem jederzeit manuell ändern. Dann bleibt dein eigener Wert maßgeblich.</li>
<li>Speichere den Eintrag. Die App berechnet Nettozeit, Ist-Stunden und Delta automatisch.</li>
</ol>
</article>
<article class="help-guide-card">
<h3>Urlaub, Feiertag oder Krankheit eintragen</h3>
<ol class="help-steps">
<li>Klicke beim gewünschten Tag auf <strong>+</strong>.</li>
<li>Wähle <strong>Urlaub</strong>, <strong>Feiertag</strong> oder <strong>Krankheit</strong>.</li>
<li>Der Status wird sofort gesetzt. Danach erscheint der Tag direkt in der Liste mit dem passenden Kürzel.</li>
<li>Wenn nötig, kannst du den Status später wieder ändern oder entfernen.</li>
</ol>
</article>
<article class="help-guide-card">
<h3>Stundenausgleich buchen</h3>
<ol class="help-steps">
<li>Klicke am gewünschten Tag auf <strong>+</strong> und wähle <strong>Stundenausgleich</strong>.</li>
<li>Entscheide dich für eine der drei Varianten: <strong>Stunden</strong>, <strong>Von-Bis Uhrzeit</strong> oder <strong>Ganzer Tag</strong>.</li>
<li>Wähle, ob der Ausgleich positiv oder negativ sein soll.</li>
<li>Speichere den Eintrag. In der Liste erscheint der Tag danach mit dem <strong>S</strong>-Symbol.</li>
<li>Der Ausgleich verändert direkt deinen Überstundenstand, ohne als normale Arbeitszeit zu zählen.</li>
</ol>
</article>
<article class="help-guide-card">
<h3>Arbeitsstunden-Counter einrichten</h3>
<ol class="help-steps">
<li>Öffne die <strong>Einstellungen</strong>.</li>
<li>Gehe zum Bereich <strong>Arbeitsstunden-Counter</strong>.</li>
<li>Aktiviere den Counter und trage Start- und Enddatum ein.</li>
<li>Optional kannst du Zusatzstunden, ein Ziel und die Anzeige im Header aktivieren.</li>
<li>Speichere die Einstellungen. Danach siehst du deinen Stand direkt im Einstellungsbereich und auf Wunsch oben im Header.</li>
</ol>
</article>
<article class="help-guide-card">
<h3>Backup importieren</h3>
<ol class="help-steps">
<li>Öffne die <strong>Einstellungen</strong> oder nutze den Backup-Upload direkt in der Registrierung.</li>
<li>Wähle deine Backup-Datei aus.</li>
<li>Entscheide, ob du deine Daten <strong>zusammenführen</strong> oder <strong>vollständig ersetzen</strong> möchtest.</li>
<li>Prüfe die Vorschau mit Datensatzanzahl und Konflikten.</li>
<li>Starte erst danach den eigentlichen Import.</li>
</ol>
</article>
</div>
</section>
<section class="help-grid">
{% call help_section("Schnellstart", "So kommst du am schnellsten zu einem sauberen Stundenstand.") %}
<ol class="help-steps">
<li>Lege in den Einstellungen deine Wochenstunden und relevanten Arbeitstage fest.</li>
<li>Trage in der Wochenansicht deine regulären Arbeitstage ein oder markiere Urlaub, Feiertag oder Krankheit.</li>
<li>Nutze den Stundenausgleich, wenn du Plus- oder Minusstunden ohne normale Arbeitszeit buchen möchtest.</li>
<li>Kontrolliere oben in den Kacheln dein aktuelles Delta und den kumulierten Stand.</li>
</ol>
{% endcall %}
{% call help_section("Die Kopfzeile", "Die Leiste oben zeigt dir jederzeit die wichtigsten Werte.") %}
<ul class="help-list">
<li><strong>Urlaub</strong>: zeigt den verbleibenden Urlaub im Verhältnis zum Gesamturlaub.</li>
<li><strong>Arbeitsstunden</strong>: erscheint nur, wenn du den Arbeitsstunden-Counter aktiviert hast.</li>
<li><strong>Kumuliert</strong>: zeigt deinen gesamten Überstundenstand bis heute.</li>
<li><strong>Woche / Monat</strong>: wechselt zwischen den beiden Hauptansichten.</li>
<li><strong>Einstellungen</strong>: hier legst du alle persönlichen Regeln fest.</li>
<li><strong>Hilfe</strong>: öffnet diese Erklärung.</li>
</ul>
{% endcall %}
{% call help_section("Wochenansicht", "Die Wochenansicht ist der schnellste Weg für den Alltag.") %}
<ul class="help-list">
<li>Oben siehst du die ausgewählte Kalenderwoche mit Datumsspanne.</li>
<li>Die Kacheln <strong>IST</strong>, <strong>SOLL</strong>, <strong>DELTA</strong> und <strong>KUMULIERT</strong> werden automatisch berechnet.</li>
<li>Jeder Tag erscheint als eigene Zeile. Dort kannst du direkt sehen, was eingetragen ist.</li>
<li>Leere Tage erkennst du an <strong>Keinen Eintrag</strong> und dem <strong>+</strong>-Button rechts.</li>
</ul>
{% endcall %}
{% call help_section("Monatsansicht", "Die Monatsansicht eignet sich gut für Rückblicke und längere Zeiträume.") %}
<ul class="help-list">
<li>Der Monat ist in Kalenderwochen gegliedert, damit du längere Zeiträume übersichtlich prüfen kannst.</li>
<li>Jede Woche zeigt eine eigene Zusammenfassung mit Ist, Soll, Delta und Urlaubstagen.</li>
<li>Du kannst Tage auch hier direkt bearbeiten, ohne in die Wochenansicht zu wechseln.</li>
</ul>
{% endcall %}
{% call help_section("Arbeitszeit eintragen", "Für normale Arbeitstage nutzt du immer den Zeiteintrag.") %}
<ul class="help-list">
<li>Gib <strong>Arbeitsbeginn</strong> und <strong>Arbeitsende</strong> an. Die Pause kannst du direkt mitpflegen.</li>
<li>Wenn du in den Einstellungen die automatische Pausenregel aktiviert hast, setzt Stundenfuchs nach deutschem Arbeitsrecht automatisch mindestens 30 oder 45 Minuten Pause, sobald die Arbeitszeit lang genug ist.</li>
<li>Manuelle Änderungen an der Pause haben immer Vorrang vor dem automatisch vorgeschlagenen Wert.</li>
<li>Die App berechnet daraus automatisch deine Netto-Arbeitszeit.</li>
<li>Reguläre Arbeitszeit und Stundenausgleich schließen sich aus. Ein Tag ist entweder Arbeitszeit oder Ausgleich.</li>
</ul>
{% endcall %}
{% call help_section("Urlaub, Feiertag und Krankheit", "Diese Tagesarten beeinflussen deine Berechnungen anders als normale Arbeitszeit.") %}
<ul class="help-list">
<li><strong>Urlaub</strong>: reduziert normalerweise dein Soll und zählt als Urlaubstag.</li>
<li><strong>Feiertag</strong>: markiert einen arbeitsfreien Feiertag ohne Urlaub zu verbrauchen.</li>
<li><strong>Krankheit</strong>: markiert einen Krankheitstag ohne Urlaub zu verbrauchen.</li>
<li>In den Einstellungen kannst du festlegen, ob diese Tage stundenmäßig wie reguläre Arbeitstage behandelt werden sollen.</li>
</ul>
{% endcall %}
{% call help_section("Stundenausgleich (S)", "Damit kannst du Überstunden oder Minusstunden direkt verändern, ohne normale Arbeitszeit einzutragen.") %}
<ul class="help-list">
<li>Du kannst den Wert manuell als <strong>+/- Stunden</strong> eintragen.</li>
<li>Alternativ kannst du eine <strong>Von-Bis Uhrzeit</strong> wählen, wenn du die Dauer nicht selbst ausrechnen möchtest.</li>
<li>Außerdem gibt es <strong>Ganzer Tag +</strong> und <strong>Ganzer Tag -</strong>. Die App nutzt dafür dein Wochenziel und deine relevanten Arbeitstage.</li>
<li>Stundenausgleich darf mit Urlaub, Feiertag oder Krankheit kombiniert werden, aber nicht mit normaler Arbeitszeit.</li>
</ul>
{% endcall %}
{% call help_section("Arbeitsstunden-Counter", "Dieser Bereich ist unabhängig von deinem Überstundenkonto.") %}
<ul class="help-list">
<li>Der Counter ist für längere Zeiträume gedacht, zum Beispiel für Praxisstunden oder ein Anerkennungsjahr.</li>
<li>Du legst Startdatum, Enddatum und optional ein Stundenziel fest.</li>
<li>Zusätzliche bereits geleistete Stunden kannst du separat eintragen, zum Beispiel aus früheren Praktika.</li>
<li>Wenn du es aktivierst, kann der Counter auch im Header angezeigt werden.</li>
</ul>
{% endcall %}
{% call help_section("Automatischer Modus", "Wenn du nicht jeden Standard-Arbeitstag einzeln eintragen möchtest.") %}
<ul class="help-list">
<li>Im automatischen Modus füllt die App fehlende reguläre Arbeitstage bis einschließlich heute nach deinen Einstellungen automatisch aus.</li>
<li>Zukünftige Tage werden dabei bewusst nicht vorausgefüllt.</li>
<li>Du passt dann nur noch Abweichungen an, zum Beispiel Urlaub, Krankheit oder andere Zeiten.</li>
<li>Wenn du in den Modus wechselst oder später zurück auf manuell gehst, werden automatisch erzeugte zukünftige Einträge entfernt.</li>
<li>Zusätzlich gibt es einen serverseitigen Tagesabgleich, damit fehlende Tage auch ohne deine Anmeldung nachgezogen werden können.</li>
</ul>
{% endcall %}
{% call help_section("Einstellungen", "Hier steuerst du die Regeln, nach denen Stunden berechnet werden.") %}
<ul class="help-list">
<li><strong>Urlaub</strong>: Gesamturlaubstage, Resturlaub im Header und Urlaubszeiträume.</li>
<li><strong>Wochenstunden</strong>: dein allgemeines Wochenziel, das für Soll-Berechnungen genutzt wird.</li>
<li><strong>Relevante Arbeitstage</strong>: bestimmt, an welchen Wochentagen deine Sollstunden verteilt werden.</li>
<li><strong>Überstunden-Regeln</strong>: Startdatum, Verfall und andere Regeln für dein Delta.</li>
<li><strong>Arbeitsstunden-Counter</strong>: langer Zeitraum, Ziel und optionale Anzeige im Header.</li>
<li><strong>Datenexport</strong>: kompletter Export aller erfassten Daten als Excel, PDF oder Backup-Datei.</li>
<li><strong>Backup importieren</strong>: Sichere Daten aus einer Backup-Datei wieder einspielen, entweder ergänzend oder als vollständigen Ersatz deiner bisherigen Arbeitsdaten.</li>
<li><strong>Konto löschen</strong>: entfernt dein Konto und alle zugehörigen Daten dauerhaft nach Sicherheitsbestätigung.</li>
<li><strong>Footer</strong>: Über Kontakt, Impressum und Datenschutz erreichst du die öffentlichen Service- und Rechtstexte.</li>
</ul>
{% endcall %}
{% call help_section("Backup und Wiederherstellung", "So sicherst du deine Daten und spielst sie später wieder ein.") %}
<ul class="help-list">
<li>Die Backup-Datei enthält nur arbeitsbezogene Daten und fachliche Einstellungen, aber keine Passwörter, MFA-Daten oder deine E-Mail-Adresse.</li>
<li>Ein Backup kannst du direkt bei der Registrierung importieren oder später in den Einstellungen einspielen.</li>
<li><strong>Zusammenführen</strong> ergänzt nur konfliktfreie Inhalte. Bereits vorhandene Tagesdaten bleiben erhalten.</li>
<li><strong>Alle bisherigen Daten ersetzen</strong> löscht zuerst deine importierbaren Arbeitsdaten und übernimmt dann den Inhalt des Backups.</li>
</ul>
{% endcall %}
{% call help_section("Praktische Tipps", "Diese Hinweise vermeiden typische Fehler im Alltag.") %}
<ul class="help-list">
<li>Trage Zeiten möglichst zeitnah ein, damit Wochen- und Monatswerte korrekt bleiben.</li>
<li>Nutze Urlaub, Feiertag und Krankheit nur dann, wenn an dem Tag keine normale Arbeitszeit eingetragen wird.</li>
<li>Verwende Stundenausgleich nur für direkte Saldo-Korrekturen, nicht für normale Arbeitstage.</li>
<li>Wenn ein Wert unerwartet wirkt, prüfe zuerst Wochenstunden, relevante Arbeitstage und Sondertage in den Einstellungen.</li>
</ul>
{% endcall %}
</section>
<section class="help-callout">
<h2>Wenn etwas nicht passt</h2>
<p>
Die meisten Abweichungen entstehen durch falsche Wochenstunden, unpassende relevante Arbeitstage oder einen gesetzten Sonderstatus.
Prüfe in diesem Fall zuerst die Tageszeile und danach die Einstellungen. Wenn der Fehler bleibt, kannst du über den Footer die Kontaktseite nutzen.
</p>
</section>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% from "ui/button.html" import link_button %}
{% block title %}Stundenfuchs{% endblock %}
{% block page_class %}landing-page{% endblock %}
{% block content %}
<section class="landing-shell stack">
<section class="landing-hero landing-hero--centered">
<div class="landing-hero__logo-wrap" aria-hidden="true">
<img class="landing-hero__logo" src="/img/fuchs.png" alt="" />
</div>
<div class="landing-hero__copy stack">
<p class="landing-eyebrow">Arbeitszeit, Urlaub und Überstunden an einem Ort</p>
<h1 class="landing-title">Stundenfuchs bringt Ordnung in deinen Arbeitsalltag.</h1>
<p class="landing-lead">
Dokumentiere deine Arbeitszeit übersichtlich, behalte Urlaub und Fehlzeiten im Blick und lass dir Sollstunden, Saldo und wichtige Auswertungen automatisch berechnen.
</p>
<div class="landing-cta-row">
{{ link_button('Jetzt registrieren', '/register', extra_class='landing-cta-primary') }}
{{ link_button('Einloggen', '/login', variant='ghost', extra_class='landing-cta-secondary') }}
</div>
</div>
</section>
</section>
{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% from "ui/card.html" import card %}
{% from "ui/page_header.html" import page_header %}
{% block title %}{{ title }}{% endblock %}
{% block page_class %}legal-page{% endblock %}
{% block content %}
{{ page_header(title, subtitle or '') }}
{% call card('legal-card') %}
<div class="legal-content">{{ content_html | safe }}</div>
{% endcall %}
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% from "ui/card.html" import card %}
{% from "ui/button.html" import button %}
{% from "ui/form_field.html" import input_field %}
{% from "ui/page_header.html" import page_header %}
{% block title %}Anmeldung{% endblock %}
{% block content %}
{{ page_header("Anmeldung") }}
{% call card('auth-card') %}
<form action="/login" method="post" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
{{ input_field('E-Mail', 'email', type='email', required=true, autocomplete='username') }}
{{ input_field('Passwort', 'password', type='password', required=true, autocomplete='current-password') }}
{{ button('Einloggen', type='submit') }}
</form>
<div class="auth-links">
<p>
<a href="/verify-email/resend">Bestätigungslink erneut senden</a>
</p>
<p>
<a href="/password-reset/request">Passwort vergessen?</a>
</p>
<p>
Noch kein Konto? <a href="/register">Jetzt registrieren</a>
</p>
</div>
{% endcall %}
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% from "ui/card.html" import card %}
{% from "ui/button.html" import button %}
{% from "ui/form_field.html" import input_field %}
{% from "ui/page_header.html" import page_header %}
{% block title %}Zwei-Faktor-Anmeldung{% endblock %}
{% block content %}
{{ page_header("Zwei-Faktor-Anmeldung", "Methode: " ~ mfa_method_label) }}
{% call card('auth-card') %}
<form action="/login/mfa" method="post" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
{{ input_field('6-stelliger Code', 'code', type='text', required=true, attrs='inputmode="numeric" pattern="[0-9]{6}" minlength="6" maxlength="6"') }}
{{ button('Code prüfen', type='submit') }}
</form>
{% if mfa_is_email %}
<form action="/login/mfa/resend"
method="post"
class="stack mfa-resend-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
{{ button('Neuen Code senden', type='submit', variant='ghost') }}
</form>
{% endif %}
<p>
<a href="/login">Zurück zur Anmeldung</a>
</p>
{% endcall %}
{% endblock %}
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% from "ui/day_row.html" import day_row with context %}
{% from "ui/kpi_bar.html" import kpi_bar with context %}
{% from "ui/month_header_bar.html" import month_header_bar with context %}
{% from "ui/week_group_header.html" import week_group_header with context %}
{% from "ui/week_group_card_mobile.html" import week_group_card_mobile with context %}
{% from "ui/icon_button.html" import icon_link with context %}
{% from "ui/warning_components.html" import workhours_target_warning_banner with context %}
{% block title %}Monatsansicht{% endblock %}
{% block page_class %}month-page{% endblock %}
{% block content %}
{% set month_names = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"] %}
{% set return_to = request.url.path ~ ('?' ~ request.url.query if request.url.query else '') %}
{% set month_prev_url = '/month?month=' ~ previous_month.strftime('%Y-%m') ~ '&view=' ~ view_mode %}
{% set month_next_url = '/month?month=' ~ next_month.strftime('%Y-%m') ~ '&view=' ~ view_mode %}
<div class="month-view-shell">
{% call month_header_bar(month_prev_url, month_next_url, month_names[month_start.month - 1] ~ ' ' ~ month_start.year) %}
{{ icon_link('/entry/new?date=' ~ month_start.isoformat(), '/static/icons/add.svg', 'Tag hinzufügen') }}
{{ icon_link('/bulk-entry?from=' ~ month_start.isoformat() ~ '&to=' ~ month_end.isoformat(), '/static/icons/batch.svg', 'Mehrfacheingabe') }}
{{ icon_link('/export?from=' ~ month_start.isoformat() ~ '&to=' ~ month_end.isoformat(), '/static/icons/export.svg', 'Export') }}
{% endcall %}
{{ kpi_bar([
{'label': 'IST', 'value': ('%.2f'|format(month_ist / 60) )|replace('.00', '')},
{'label': 'SOLL', 'value': ('%.2f'|format(month_soll / 60))|replace('.00', '')},
{'label': 'DELTA', 'value': ('%.2f'|format(month_delta / 60))|replace('.00', ''), 'value_class': 'negative' if month_delta < 0 else 'positive'},
{'label': 'KUMULIERT', 'value': ('%.2f'|format(header_cumulative_minutes / 60))|replace('.00', ''), 'value_class': 'negative' if header_cumulative_minutes < 0 else 'positive'}
], 'kpi-bar--month') }}
{{ workhours_target_warning_banner(workhours_target_warning) }}
<section class="week-group-list">
{% for week in weeks %}
{% call week_group_card_mobile(week, csrf_token, return_to) %}
{{ week_group_header(week, csrf_token, return_to) }}
<div class="day-list day-list--month">
{% for day in week.days %}
{{ day_row(day, csrf_token, weekday_name_de(day.date) ~ ', ' ~ day.date.strftime('%d.%m.%Y'), return_to, 'month') }}
{% endfor %}
</div>
{% endcall %}
{% endfor %}
</section>
</div>
{% endblock %}
@@ -0,0 +1,101 @@
{% extends "base.html" %}
{% from "ui/card.html" import card %}
{% from "ui/button.html" import button, link_button %}
{% from "ui/form_field.html" import input_field %}
{% from "ui/page_header.html" import page_header %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
{{ page_header(title, selected_date.strftime('%d.%m.%Y')) }}
{% call card('form-card') %}
<div class="stack">
{% if has_entry %}
<p class="muted">An diesem Tag ist bereits regulaere Arbeitszeit eingetragen. Stundenausgleich ist dann nicht verfuegbar.</p>
<div class="nav-row">
{{ link_button('Zeiteintrag bearbeiten', '/entry/' ~ existing_entry_id ~ '/edit', 'primary') }}
{{ link_button('Zurueck', return_to, 'ghost') }}
</div>
{% else %}
<p class="muted">
Baut Ueberstunden direkt auf oder ab. Der Eintrag wirkt nur auf den Saldo, nicht auf Ist-Stunden oder den Arbeitsstunden-Counter.
</p>
{% if day_is_vacation %}
<p class="muted">Fuer diesen Tag ist zusaetzlich Urlaub gesetzt.</p>
{% elif day_special_status == 'holiday' %}
<p class="muted">Fuer diesen Tag ist zusaetzlich Feiertag gesetzt.</p>
{% elif day_special_status == 'sick' %}
<p class="muted">Fuer diesen Tag ist zusaetzlich Krankheit gesetzt.</p>
{% endif %}
{% if overtime_adjustment_error %}
<p class="flash flash--error">{{ overtime_adjustment_error }}</p>
{% endif %}
{% if day_overtime_adjustment_minutes %}
<p class="muted">
Aktuell gesetzt:
<strong>{{ '+' if day_overtime_adjustment_minutes > 0 else '' }}{{ minutes_to_hhmm(day_overtime_adjustment_minutes) }}</strong>
</p>
{% endif %}
<hr />
<form method="post" action="/overtime-adjustment/set" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="date" value="{{ selected_date.isoformat() }}" />
<input type="hidden" name="return_to" value="{{ return_to }}" />
<input type="hidden" name="adjustment_mode" value="manual" />
<h2>Stunden</h2>
{{ input_field('Manuell (+HH:MM oder -HH:MM)', 'adjustment_value', type='text', value='', attrs='placeholder=\"+02:30\"') }}
<div class="nav-row">
{{ button('Stunden speichern', type='submit') }}
</div>
</form>
<hr />
<form method="post" action="/overtime-adjustment/set" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="date" value="{{ selected_date.isoformat() }}" />
<input type="hidden" name="return_to" value="{{ return_to }}" />
<input type="hidden" name="adjustment_mode" value="interval" />
<h2>Von-Bis Uhrzeit</h2>
<div class="inline-grid">
{{ input_field('Von', 'interval_start_time', type='time', value='', required=true) }}
{{ input_field('Bis', 'interval_end_time', type='time', value='', required=true) }}
</div>
<div class="nav-row">
<button type="submit" name="interval_direction" value="positive" class="btn btn--primary">Als Plus speichern</button>
<button type="submit" name="interval_direction" value="negative" class="btn btn--ghost">Als Minus speichern</button>
</div>
</form>
<hr />
<form method="post" action="/overtime-adjustment/set" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="date" value="{{ selected_date.isoformat() }}" />
<input type="hidden" name="return_to" value="{{ return_to }}" />
<input type="hidden" name="adjustment_mode" value="full_day" />
<h2>Ganzer Tag</h2>
<p class="muted">Der Tageswert wird aus Wochenstunden geteilt durch die relevanten Arbeitstage berechnet.</p>
<div class="nav-row">
<button type="submit" name="full_day_direction" value="positive" class="btn btn--primary">Ganzer Tag +</button>
<button type="submit" name="full_day_direction" value="negative" class="btn btn--ghost">Ganzer Tag -</button>
</div>
</form>
<hr />
<div class="nav-row">
{% if day_overtime_adjustment_minutes %}
<form method="post" action="/overtime-adjustment/clear" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="date" value="{{ selected_date.isoformat() }}" />
<input type="hidden" name="return_to" value="{{ return_to }}" />
<button type="submit" class="btn btn--ghost">Stundenausgleich entfernen</button>
</form>
{% endif %}
{{ link_button('Zurueck', return_to, 'ghost') }}
</div>
{% endif %}
</div>
{% endcall %}
{% endblock %}
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% from "ui/card.html" import card %}
{% from "ui/button.html" import button %}
{% from "ui/form_field.html" import input_field %}
{% from "ui/page_header.html" import page_header %}
{% block title %}Neues Passwort setzen{% endblock %}
{% block content %}
{{ page_header("Neues Passwort setzen") }}
{% call card('auth-card') %}
{% if token %}
<form action="/password-reset/confirm" method="post" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="token" value="{{ token }}" />
{{ input_field('Neues Passwort (mindestens 10 Zeichen)', 'new_password', type='password', required=true, autocomplete='new-password', attrs='minlength="10"') }}
{{ input_field('Neues Passwort wiederholen', 'new_password_repeat', type='password', required=true, autocomplete='new-password', attrs='minlength="10"') }}
{{ button('Passwort speichern', type='submit') }}
</form>
{% endif %}
<p>
<a href="/login">Zur Anmeldung</a>
</p>
{% endcall %}
{% endblock %}
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% from "ui/card.html" import card %}
{% from "ui/button.html" import button %}
{% from "ui/form_field.html" import input_field %}
{% from "ui/page_header.html" import page_header %}
{% block title %}Passwort zurücksetzen{% endblock %}
{% block content %}
{{ page_header('Passwort zurücksetzen', 'Gib deine E-Mail ein. Du erhältst einen Link zum Setzen eines neuen Passworts.') }}
{% call card('auth-card') %}
<form action="/password-reset/request" method="post" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
{{ input_field('E-Mail', 'email', type='email', required=true, autocomplete='username') }}
{{ button('Reset-Link senden', type='submit') }}
</form>
<p>
<a href="/login">Zur Anmeldung</a>
</p>
{% endcall %}
{% endblock %}
+171
View File
@@ -0,0 +1,171 @@
{% extends "base.html" %}
{% from "ui/button.html" import button %}
{% from "ui/form_field.html" import input_field %}
{% block title %}Registrierung{% endblock %}
{% block body_class %}register-theme{% endblock %}
{% block page_class %}register-page{% endblock %}
{% block content %}
<section class="register-shell">
<h1 class="register-title">Registrierung</h1>
<form action="/register" method="post" enctype="multipart/form-data" class="register-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<section class="register-section register-section-auth">
{{ input_field('E-Mail', 'email', type='email', required=true, autocomplete='username') }}
{{ input_field('Passwort (mindestens 10 Zeichen)', 'password', type='password', required=true, autocomplete='new-password', attrs='minlength="10"') }}
<p class="muted register-email-note">Nach dem Anlegen bestätigst du deine E-Mail-Adresse über einen Link.</p>
</section>
<section class="register-section">
<h2>Backup importieren</h2>
<p class="muted register-subtitle">Wenn du bereits eine Sicherung aus Stundenfuchs hast, kannst du sie direkt bei der Registrierung einspielen.</p>
<label class="form-field">
<span class="form-field__label">Backup-Datei (optional)</span>
<input class="input" type="file" name="backup_file" accept=".json,application/json" />
</label>
<p class="muted register-subtitle">
Deine E-Mail-Adresse, dein Passwort und deine gewählte Zwei-Faktor-Anmeldung bleiben erhalten.
Arbeitsdaten und fachliche Einstellungen werden aus dem Backup übernommen.
</p>
</section>
<p class="muted register-section-note">
Alle folgenden Einstellungen sind optional und können auch später in den Einstellungen geändert werden.
</p>
<section class="register-section">
<h2>Bundesland</h2>
<p class="muted register-subtitle">für das automatische Festlegen von Feiertagen</p>
<label>
<select name="federal_state">
<option value="">Bundesland auswählen...</option>
{% for state in federal_state_options %}<option value="{{ state.code }}">{{ state.label }}</option>{% endfor %}
</select>
</label>
</section>
<section class="register-section">
<h2>Urlaub</h2>
<p class="muted register-subtitle">Wieviele Urlaubstage pro Jahr stehen dir zur Verfügung</p>
{{ input_field('', 'vacation_days_total', type='number', placeholder='z. B. 30', attrs='min="0" max="365" step="1"') }}
{{ input_field('Wochenstunden (Standard)', 'weekly_target_hours', type='number', value='25', attrs='min="0.25" step="0.25"') }}
</section>
<section class="register-section">
<h2>Erfassungsmodus</h2>
<p class="muted register-subtitle">Lege fest, ob du deine Arbeitstage komplett selbst pflegst oder ob Stundenfuchs fehlende Arbeitstage bis heute automatisch ergänzt.</p>
<label>
<select name="entry_mode">
<option value="manual" selected>Manuell</option>
<option value="auto_until_today">Automatisch bis heute</option>
</select>
</label>
</section>
<section class="register-section">
<h2>Überstunden</h2>
<div class="register-grid-2">
{{ input_field('Startdatum für Überstundenberechnung', 'overtime_start_date', type='date', value=today_iso) }}
{{ input_field('Nach welchem Zeitraum verfallen Überstunden (Tage)', 'overtime_expiry_days', type='number', placeholder='optional', attrs='min="1" step="1"') }}
</div>
<label class="checkbox-row">
<input type="checkbox" name="expire_negative_overtime" checked />
<span>Negative Stunden verfallen auch</span>
</label>
</section>
<section class="register-section">
<h2>Gesamtarbeitsstunden</h2>
<p class="muted register-subtitle">z. B. für die Übersicht zu geleisteten Praxisstunden im Anerkennungsjahr der Erzieherausbildung.</p>
<label class="checkbox-row">
<input type="checkbox" name="workhours_counter_enabled" />
<span>Gesamtarbeitsstunden Counter aktivieren</span>
</label>
</section>
<section class="register-section">
<h2>Gesamtarbeitsstunden</h2>
<p class="muted register-subtitle">z. B. für die Übersicht zu geleisteten Praxisstunden im Anerkennungsjahr der Erzieherausbildung.</p>
<div class="register-grid-2 register-grid-counter">
{{ input_field('Counter Startdatum', 'workhours_counter_start_date', type='date') }}
{{ input_field('Counter Enddatum', 'workhours_counter_end_date', type='date') }}
</div>
{{ input_field('Bereits geleistete Zusatzstunden (optional)', 'workhours_counter_manual_offset_hours', type='number', placeholder='z. B. 80', attrs='min="0" step="0.25"') }}
<p class="muted register-subtitle">Zusätzlich geleistete Stunden, z.B. aus Praktika</p>
{{ input_field('Gesamtstundenziel (in Stunden)', 'workhours_counter_target_hours', type='number', placeholder='z. B. 1200', attrs='min="0.25" step="0.25"') }}
<div class="register-checkbox-row">
<label class="checkbox-row">
<input type="checkbox" name="workhours_counter_show_in_header" />
<span>Counter im Header anzeigen</span>
</label>
<label class="checkbox-row">
<input type="checkbox" name="workhours_counter_target_email_enabled" />
<span>Zielwarnung per E-Mail (wenn das Ziel im angegebenen Zeitraum nicht erreicht werden sollte aufgrund von z. B. Krankheit)</span>
</label>
</div>
</section>
<section class="register-section">
<h2>Relevante Arbeitstage</h2>
<p class="muted register-subtitle">
Diese Tage steuern Soll und Urlaubslogik. Beispiel: Wenn du nur Montag bis Donnerstag arbeitest, werden Sollstunden auf diese vier Tage verteilt.
</p>
<fieldset class="weekday-fieldset register-weekday-fieldset">
<div class="register-weekday-grid">
{% for weekday in weekday_options %}
<label class="checkbox-row">
<input type="checkbox"
name="working_days"
value="{{ weekday.value }}"
{% if weekday.value < 5 %}checked{% endif %} />
<span>{{ weekday.label }}</span>
</label>
{% endfor %}
</div>
</fieldset>
<div class="register-checkbox-row">
<label class="checkbox-row">
<input type="checkbox" name="count_vacation_as_worktime" />
<span>Urlaubstage wie reguläre Arbeitstage rechnen</span>
</label>
<label class="checkbox-row">
<input type="checkbox" name="count_holiday_as_worktime" />
<span>Feiertage wie reguläre Arbeitstage rechnen</span>
</label>
<label class="checkbox-row">
<input type="checkbox" name="count_sick_as_worktime" />
<span>Kranktage wie reguläre Arbeitstage rechnen</span>
</label>
<label class="checkbox-row">
<input type="checkbox" name="automatic_break_rules_enabled" />
<span>Gesetzliche Pausen automatisch nach deutscher Arbeitszeit berechnen</span>
</label>
</div>
</section>
<section class="register-section">
<h2>Zwei-Faktor-Anmeldung</h2>
<p class="muted register-subtitle">Zur Erhöhung der Sicherheit</p>
<label>
<select name="mfa_preference">
<option value="none" selected>Keine 2FA</option>
<option value="totp">Authenticator-App (6-stelliger Code)</option>
<option value="email">E-Mail-Code</option>
</select>
</label>
{% if not email_mfa_available %}
<p class="muted">Hinweis: E-Mail-2FA ist aktuell nicht verfügbar, da kein Mailserver konfiguriert ist.</p>
{% endif %}
</section>
<section class="register-actions">
{{ button('Konto anlegen', type='submit', extra_class='register-submit') }}
</section>
</form>
<p class="register-footer">
Du hast bereits ein Konto? <a href="/login">Zur Anmeldung</a>
</p>
</section>
{% endblock %}
+812
View File
@@ -0,0 +1,812 @@
{% extends "base.html" %}
{% from "ui/segmented_toggle.html" import segmented_toggle %}
{% from "ui/collapsible_section.html" import collapsible_section %}
{% block title %}Einstellungen{% endblock %}
{% block body_class %}settings-theme{% endblock %}
{% block page_class %}settings-page{% endblock %}
{% block content %}
<section class="top-row">
<h1>Einstellungen</h1>
</section>
{% if is_admin %}
{{ segmented_toggle([
{'href': '/settings?tab=settings', 'label': 'Einstellungen', 'active': active_settings_tab != 'admin'},
{'href': '/settings?tab=admin', 'label': 'Admin', 'active': active_settings_tab == 'admin'}
], 'Einstellungsbereiche', 'settings-tabs') }}
{% endif %}
<section class="settings-grid">
{% if not is_admin or active_settings_tab != 'admin' %}
{% call collapsible_section('Urlaub', 'settings-vacation') %}
<p class="muted">Lege hier deine Gesamturlaubstage pro Kalenderjahr fest. Im Header siehst du danach Resturlaub/Gesamturlaub.</p>
<form method="post" action="/settings/vacation-allowance" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="vacation_show_in_header_present" value="1" />
<div class="inline-grid">
<label>
Gesamturlaubstage pro Jahr
<input type="number"
min="0"
max="365"
step="1"
name="vacation_days_total"
value="{{ user.vacation_days_total }}"
required />
</label>
<label>
Aktueller Stand ({{ header_vacation_year }})
<input type="text"
value="{{ header_vacation_days_remaining }} / {{ header_vacation_days_total }} (verplant/genutzt: {{ header_vacation_days_used }})"
disabled />
</label>
</div>
<label class="checkbox-row">
<input type="checkbox"
name="vacation_show_in_header"
{% if user.vacation_show_in_header %}checked{% endif %} />
<span>Resturlaub im Header anzeigen</span>
</label>
<button type="submit" class="button">Speichern</button>
</form>
<p class="muted">Definierte Urlaubstage reduzieren automatisch das Wochen-Soll für die betroffenen Wochen.</p>
<form method="post" action="/settings/vacations/add" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="inline-grid">
<label>
Startdatum
<input type="date" name="start_date" value="{{ vacation_start }}" required />
</label>
<label>
Enddatum
<input type="date" name="end_date" value="{{ vacation_end }}" required />
</label>
</div>
<label class="checkbox-row">
<input type="checkbox" name="include_weekends" />
<span>Wochenenden mit einschließen</span>
</label>
<label>
Notiz (optional)
<input type="text" name="notes" />
</label>
<button type="submit" class="button">Speichern</button>
</form>
<div class="vacation-list">
{% for vacation in vacation_ranges %}
<article class="vacation-item">
<div>
<strong>{{ vacation.start_date.strftime("%d.%m.%Y") }} - {{ vacation.end_date.strftime("%d.%m.%Y") }}</strong>
<p class="muted">Effektive Urlaubstage unter Berücksichtigung deiner Arbeitstage.</p>
</div>
<form method="post"
action="/settings/vacations/delete-range"
class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden"
name="start_date"
value="{{ vacation.start_date.isoformat() }}" />
<input type="hidden"
name="end_date"
value="{{ vacation.end_date.isoformat() }}" />
<button type="submit" class="button danger">Löschen</button>
</form>
</article>
{% else %}
<p class="muted">Noch keine Urlaubszeiträume angelegt.</p>
{% endfor %}
</div>
{% endcall %}
{% call collapsible_section('Wochenstunden', 'settings-weekly-target') %}
<p class="muted">Lege fest, wie viele Stunden du generell pro Woche arbeiten möchtest (Standard-Soll).</p>
<form method="post" action="/settings/weekly-target" class="stack" data-component="break-settings-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label>
Wochenstunden
<input type="number"
min="0.25"
step="0.25"
name="weekly_target_hours"
value="{{ '%.2f'|format(settings_weekly_target_minutes / 60) |replace('.00', '') }}"
required />
</label>
<label class="checkbox-row">
<input type="checkbox"
name="automatic_break_rules_enabled"
data-break-settings-toggle
{% if user.automatic_break_rules_enabled %}checked{% endif %} />
<span>Gesetzliche Pausen automatisch nach deutscher Arbeitszeit berechnen</span>
</label>
<label>
Tägliche Pause in Minuten
<input type="number"
min="0"
step="1"
name="default_break_minutes"
value="{{ user.default_break_minutes }}"
data-break-settings-minutes
{% if user.automatic_break_rules_enabled %}disabled{% endif %} />
</label>
<p class="muted">
Dieser Wert wird für neue reguläre Arbeitszeiteinträge und automatische Einträge verwendet, solange die gesetzliche Pausenregel nicht aktiv ist.
</p>
<button type="submit" class="button">Speichern</button>
</form>
{% endcall %}
{% call collapsible_section('Standardansicht', 'settings-preferences') %}
<form method="post" action="/settings/preferences" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="preferred_month_view_mode" value="flat" />
<label>
Startansicht nach Anmeldung
<select name="preferred_home_view" required>
<option value="week"
{% if user.preferred_home_view == 'week' %}selected{% endif %}>Wochenansicht</option>
<option value="month"
{% if user.preferred_home_view == 'month' %}selected{% endif %}>Monatsansicht</option>
</select>
</label>
<label>
Erfassungsmodus
<select name="entry_mode" required>
<option value="manual"
{% if user.entry_mode == 'manual' %}selected{% endif %}>Manuell (jeden Tag selbst erfassen)</option>
<option value="auto_until_today"
{% if user.entry_mode == 'auto_until_today' %}selected{% endif %}>Automatisch bis heute</option>
</select>
</label>
<p class="muted">
Im automatischen Modus werden fehlende Einträge für deine Arbeitstage bis einschließlich heute automatisch angelegt. Abweichungen kannst du danach einzeln anpassen.
</p>
<button type="submit" class="button">Speichern</button>
</form>
{% endcall %}
{% call collapsible_section('Überstunden-Regeln', 'settings-overtime') %}
<p class="muted">Optionales Startdatum und Verfall für die kumulierte Überstunden-Berechnung.</p>
<form method="post" action="/settings/overtime" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="inline-grid">
<label>
Startdatum (optional)
<input type="date"
name="overtime_start_date"
value="{{ user.overtime_start_date.isoformat() if user.overtime_start_date else '' }}" />
</label>
<label>
Verfall in Tagen (optional)
<input type="number"
min="1"
step="1"
name="overtime_expiry_days"
value="{{ user.overtime_expiry_days if user.overtime_expiry_days is not none else '' }}" />
</label>
</div>
<label class="checkbox-row">
<input type="checkbox"
name="expire_negative_overtime"
{% if user.expire_negative_overtime %}checked{% endif %} />
<span>Negative Stunden verfallen ebenfalls</span>
</label>
<button type="submit" class="button">Speichern</button>
</form>
<div class="vacation-list">
<article class="vacation-item">
<div>
<strong>Saldoaufbau gesamt</strong>
<p class="muted">{{ minutes_to_hhmm(overtime_adjustment_total_positive) }}</p>
</div>
<div>
<strong>Saldoabbau gesamt</strong>
<p class="muted">{{ minutes_to_hhmm(overtime_adjustment_total_negative) }}</p>
</div>
<div>
<strong>Ganze Tage</strong>
<p class="muted">{{ overtime_adjustment_full_day_count }}</p>
</div>
</article>
{% for adjustment in overtime_adjustments %}
<article class="vacation-item">
<div>
<strong>{{ adjustment.date.strftime("%d.%m.%Y") }}</strong>
<p class="muted">{{ adjustment.notes or "Stundenausgleich" }}</p>
</div>
<div class="settings-adjustment-meta">
<strong class="{% if adjustment.minutes < 0 %}negative{% else %}positive{% endif %}">
{{ '+' if adjustment.minutes > 0 else '' }}{{ minutes_to_hhmm(adjustment.minutes) }}
</strong>
<a href="/overtime-adjustment/edit?date={{ adjustment.date.isoformat() }}" class="button ghost">Bearbeiten</a>
</div>
</article>
{% else %}
<p class="muted">Noch keine Ausgleichsstunden eingetragen.</p>
{% endfor %}
</div>
{% endcall %}
{% call collapsible_section('Arbeitsstunden-Counter', 'settings-workhours-counter') %}
<div class="settings-workhours-intro stack">
<p class="muted">Hier kannst du einen Zeitraum festlegen und sehen, wie viele Stunden du darin insgesamt gesammelt hast. Das ist zum Beispiel hilfreich für Praxisstunden im Anerkennungsjahr.</p>
<p class="muted">Urlaub, Feiertage und Krankheit werden nur dann mitgezählt, wenn du das unter „Relevante Arbeitstage“ aktiviert hast.</p>
</div>
<form method="post" action="/settings/workhours-counter" class="settings-workhours-form stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label class="checkbox-row">
<input type="checkbox"
name="workhours_counter_enabled"
{% if user.workhours_counter_enabled %}checked{% endif %} />
<span>Arbeitsstunden-Counter aktivieren</span>
</label>
<label class="checkbox-row">
<input type="checkbox"
name="workhours_counter_show_in_header"
{% if user.workhours_counter_show_in_header %}checked{% endif %} />
<span>Counter im Header anzeigen</span>
</label>
<div class="inline-grid">
<label>
Zeitraum von
<input type="date"
name="workhours_counter_start_date"
value="{{ user.workhours_counter_start_date.isoformat() if user.workhours_counter_start_date else '' }}" />
</label>
<label>
Zeitraum bis
<input type="date"
name="workhours_counter_end_date"
value="{{ user.workhours_counter_end_date.isoformat() if user.workhours_counter_end_date else '' }}" />
</label>
</div>
<div class="inline-grid settings-workhours-inline-grid">
<label class="settings-workhours-field">
Zusätzliche Stunden (optional)
<input type="number"
min="0"
step="0.25"
name="workhours_counter_manual_offset_hours"
value="{{ '%.2f'|format(user.workhours_counter_manual_offset_minutes / 60) |replace('.00', '') if user.workhours_counter_manual_offset_minutes else '' }}" />
</label>
<label class="settings-workhours-field">
Zielstunden im Zeitraum (optional)
<input type="number"
min="0.25"
step="0.25"
name="workhours_counter_target_hours"
value="{{ '%.2f'|format(user.workhours_counter_target_minutes / 60) |replace('.00', '') if user.workhours_counter_target_minutes is not none else '' }}" />
</label>
</div>
<p class="muted settings-workhours-field-hint">Zum Beispiel bereits geleistete Praxis- oder Praktikumsstunden, die nicht im Tracker erfasst wurden.</p>
<label class="checkbox-row">
<input type="checkbox"
name="workhours_counter_target_email_enabled"
{% if not mail_settings_available %}disabled{% endif %}
{% if user.workhours_counter_target_email_enabled %}checked{% endif %} />
<span>E-Mail senden, wenn das Ziel voraussichtlich nicht erreicht wird</span>
</label>
{% if not mail_settings_available %}
<p class="muted">Diese Funktion ist erst verfügbar, wenn ein E-Mail-Server eingerichtet wurde.</p>
{% endif %}
<p class="muted">Beispiel: So kannst du deine Praxisstunden im Anerkennungsjahr im Blick behalten.</p>
{% if user.workhours_counter_enabled %}
<p class="muted">
{% if workhours_counter_minutes is not none %}
Aktueller Stand im gewählten Zeitraum:
{% else %}
Bitte gültigen Zeitraum setzen, um den Counter zu berechnen.
{% endif %}
</p>
{% endif %}
{% if workhours_counter_warning %}
<div class="settings-counter-badges app-total-badges" aria-label="Ziel und Prognose">
<span class="app-total-badge app-total-badge-workhours">
<span class="app-total-badge__label">Bisher</span>
<span class="app-total-badge__value">
{% if workhours_counter_minutes is not none %}
{{ minutes_to_hhmm(workhours_counter_minutes) }}
{% else %}
--
{% endif %}
</span>
</span>
<span class="app-total-badge app-total-badge-target">
<span class="app-total-badge__label">Ziel</span>
<span class="app-total-badge__value">{{ minutes_to_hhmm(workhours_counter_warning.target_minutes) }}</span>
</span>
<span class="app-total-badge app-total-badge-projection {% if workhours_counter_warning.at_risk %}is-negative{% else %}is-positive{% endif %}">
<span class="app-total-badge__label">Prognose</span>
<span class="app-total-badge__value">{{ minutes_to_hhmm(workhours_counter_warning.projected_minutes) }}</span>
</span>
</div>
{% if workhours_counter_warning.at_risk %}
<p class="muted">
Bis zum Ziel fehlen voraussichtlich noch <strong class="negative">{{ minutes_to_hhmm(workhours_counter_warning.missing_minutes) }}</strong>
</p>
{% endif %}
{% endif %}
<button type="submit" class="button">Speichern</button>
</form>
{% endcall %}
{% call collapsible_section('Relevante Arbeitstage', 'settings-workdays') %}
<p class="muted">Diese Tage werden für Soll-/Delta-Berechnung verwendet (z. B. 4-Tage-Woche Mo-Do).</p>
<form method="post" action="/settings/workdays" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<fieldset class="weekday-fieldset">
<legend>Arbeitstage</legend>
<div class="weekday-grid">
{% for weekday in weekday_options %}
<label class="checkbox-row">
<input type="checkbox"
name="working_days"
value="{{ weekday.value }}"
{% if weekday.value in working_days_selected %}checked{% endif %} />
<span>{{ weekday.label }}</span>
</label>
{% endfor %}
</div>
</fieldset>
<div class="stack">
<label class="checkbox-row">
<input type="checkbox"
name="count_vacation_as_worktime"
{% if user.count_vacation_as_worktime %}checked{% endif %} />
<span>Urlaubstage wie reguläre Arbeitstage rechnen</span>
</label>
<label class="checkbox-row">
<input type="checkbox"
name="count_holiday_as_worktime"
{% if user.count_holiday_as_worktime %}checked{% endif %} />
<span>Feiertage wie reguläre Arbeitstage rechnen</span>
</label>
<label class="checkbox-row">
<input type="checkbox"
name="count_sick_as_worktime"
{% if user.count_sick_as_worktime %}checked{% endif %} />
<span>Kranktage wie reguläre Arbeitstage rechnen</span>
</label>
</div>
<button type="submit" class="button">Speichern</button>
</form>
{% endcall %}
{% call collapsible_section('Sicherheit (2FA)', 'settings-mfa') %}
{% set mfa_totp_pending = mfa_setup_secret and user.mfa_method == 'none' %}
{% set mfa_selected_method = 'totp' if mfa_totp_pending else user.mfa_method %}
<p class="muted">
Status:
<strong>
{% if mfa_totp_pending %}
TOTP-Einrichtung läuft
{% else %}
{{ mfa_method_labels.get(user.mfa_method, 'Unbekannt') }}
{% endif %}
</strong>
</p>
{% if mfa_totp_pending %}
<p class="muted">2FA wird aktiviert, sobald du den aktuellen 6-stelligen Code aus deiner Authenticator-App bestätigst.</p>
{% endif %}
<form method="post" action="/settings/mfa" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label>
Zwei-Faktor-Methode
<select name="mfa_method" required>
<option value="none" {% if mfa_selected_method == 'none' %}selected{% endif %}>Keine 2FA</option>
<option value="totp" {% if mfa_selected_method == 'totp' %}selected{% endif %}>Authenticator-App (TOTP)</option>
<option value="email" {% if mfa_selected_method == 'email' %}selected{% endif %}>E-Mail-Code</option>
</select>
</label>
<label>
Aktuelles Passwort bestätigen
<input type="password" name="current_password" required />
</label>
<label>
Setup-Code (nur für TOTP-Aktivierung)
<input type="text"
name="setup_code"
inputmode="numeric"
pattern="[0-9]{6}"
maxlength="6" />
</label>
<label class="checkbox-row">
<input type="checkbox" name="regenerate_totp" />
<span>TOTP neu einrichten (alten Schlüssel verwerfen)</span>
</label>
{% if mfa_setup_secret %}
<div class="settings-note">
<p class="muted">
<strong>TOTP-Setup aktiv:</strong> Hinterlege den folgenden Schlüssel oder die URI in deiner Authenticator-App und bestätige danach den Code.
</p>
<label>
TOTP Secret
<input type="text" value="{{ mfa_setup_secret }}" readonly />
</label>
<label>
TOTP URI
<input type="text" value="{{ mfa_setup_uri }}" readonly />
</label>
</div>
{% endif %}
<button type="submit" class="button">Speichern</button>
</form>
{% endcall %}
<div class="settings-auth-row">
{% call collapsible_section('Account', 'settings-account', 'settings-auth-card', 'account-security') %}
<form method="post" action="/settings/profile" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label>
E-Mail-Adresse
<input type="email" name="email" value="{{ user.email }}" required />
</label>
<label>
Bundesland
<select name="federal_state">
<option value="">Bitte auswählen</option>
{% for state in federal_state_options %}
<option value="{{ state.code }}"
{% if user.federal_state == state.code %}selected{% endif %}>{{ state.label }}</option>
{% endfor %}
</select>
</label>
<p class="muted">
Gesetzliche Feiertage deines Bundeslands werden automatisch als Feiertag markiert, sofern an diesen Tagen keine Arbeitszeit eingetragen ist.
</p>
<label>
Aktuelles Passwort bestätigen
<input type="password" name="current_password" required />
</label>
<button type="submit" class="button">Speichern</button>
</form>
{% endcall %}
{% call collapsible_section('Passwort ändern', 'settings-password', 'settings-auth-card', 'account-security') %}
<form method="post" action="/settings/password" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label>
Aktuelles Passwort
<input type="password" name="current_password" required />
</label>
<label>
Neues Passwort
<input type="password" name="new_password" minlength="10" required />
</label>
<label>
Neues Passwort wiederholen
<input type="password" name="new_password_repeat" minlength="10" required />
</label>
<button type="submit" class="button">Speichern</button>
</form>
{% endcall %}
</div>
{% call collapsible_section('Datenexport', 'settings-export') %}
<p class="muted">Lade hier alle bisher eingetragenen Daten herunter. Für Excel und PDF wird dein kompletter erfasster Zeitraum exportiert. Die Backup-Datei ist für Sicherung und späteren Import gedacht.</p>
<form method="post" action="/settings/export-all" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="settings-export-actions">
<button type="submit" name="format" value="xlsx" class="button">Alles als Excel exportieren</button>
<button type="submit" name="format" value="pdf" class="button ghost">Alles als PDF exportieren</button>
<button type="submit" name="format" value="backup_json" class="button ghost">Backup-Datei herunterladen</button>
</div>
</form>
<p class="muted">Die Backup-Datei enthält deine Einstellungen, Arbeitszeiteinträge, Urlaub, Sondertage, Soll-Historie und Stundenausgleich in einem strukturierten Format. Sicherheits- und Kontodaten sind nicht enthalten.</p>
{% endcall %}
{% call collapsible_section('Backup importieren', 'settings-import') %}
<p class="muted">Du kannst eine zuvor exportierte Backup-Datei wieder einspielen. Dein Konto, dein Passwort und deine Sicherheitsdaten bleiben dabei unverändert.</p>
<form method="post" action="/settings/import/preview" enctype="multipart/form-data" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label>
Importmodus
<select name="import_mode">
<option value="merge" {% if import_mode_selected == 'merge' %}selected{% endif %}>Zusammenführen</option>
<option value="replace_user_data"
{% if import_mode_selected == 'replace_user_data' %}selected{% endif %}>Alle bisherigen Daten ersetzen</option>
</select>
</label>
<p class="muted">
Zusammenführen behält bestehende Tagesdaten bei und ergänzt nur konfliktfreie Inhalte. Ersetzen löscht zuerst alle importierbaren Arbeits- und Einstellungsdaten deines Kontos.
</p>
<label>
Backup-Datei
<input type="file" name="backup_file" accept=".json,application/json" required />
</label>
<button type="submit" class="button">Backup prüfen</button>
</form>
{% if import_preview %}
<div class="settings-import-preview stack">
<div class="settings-import-preview__header">
<div>
<h3>Importvorschau</h3>
<p class="muted">
Backup v{{ import_preview.backup_version }}
{% if import_preview.source_app_version %}• exportiert mit {{ import_preview.source_app_version }}{% endif %}
{% if import_preview.exported_at %}• {{ import_preview.exported_at }}{% endif %}
</p>
</div>
<span class="settings-import-preview__mode">{{ import_preview.mode_label }}</span>
</div>
<div class="settings-import-grid">
<div class="settings-import-stat">
<strong>{{ import_preview.counts.time_entries }}</strong>
<span>Arbeitszeiteinträge</span>
</div>
<div class="settings-import-stat">
<strong>{{ import_preview.counts.weekly_target_rules }}</strong>
<span>Wochenziele</span>
</div>
<div class="settings-import-stat">
<strong>{{ import_preview.counts.vacation_periods }}</strong>
<span>Urlaubszeiträume</span>
</div>
<div class="settings-import-stat">
<strong>{{ import_preview.counts.special_day_statuses }}</strong>
<span>Sondertage</span>
</div>
<div class="settings-import-stat">
<strong>{{ import_preview.counts.overtime_adjustments }}</strong>
<span>Stundenausgleich</span>
</div>
</div>
<div class="settings-import-summary">
<p class="muted">
Übernommen werden auch fachliche Einstellungen wie Wochenstunden, relevante Arbeitstage, Überstunden-Regeln, Arbeitsstunden-Counter und das Bundesland.
</p>
<ul class="settings-import-conflicts">
<li>Konflikte Arbeitszeiteinträge: {{ import_preview.conflicts.time_entries }}</li>
<li>Konflikte Wochenziele: {{ import_preview.conflicts.weekly_target_rules }}</li>
<li>Konflikte Urlaubszeiträume: {{ import_preview.conflicts.vacation_periods }}</li>
<li>Konflikte Sondertage: {{ import_preview.conflicts.special_day_statuses }}</li>
<li>Konflikte Stundenausgleich: {{ import_preview.conflicts.overtime_adjustments }}</li>
</ul>
</div>
<form method="post" action="/settings/import/execute" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="preview_id" value="{{ import_preview.id }}" />
{% if import_preview.mode == 'replace_user_data' %}
<label class="checkbox-row">
<input type="checkbox" name="confirm_replace" />
<span>Ich möchte meine bisherigen importierbaren Daten wirklich vollständig durch dieses Backup ersetzen.</span>
</label>
{% endif %}
<button type="submit" class="button">Import jetzt ausführen</button>
</form>
</div>
{% endif %}
{% endcall %}
{% call collapsible_section('Konto löschen', 'settings-delete-account', 'danger-card') %}
<p class="muted">Wenn du dein Konto löschst, werden alle zugehörigen Daten dauerhaft entfernt: Arbeitszeiten, Urlaub, Sondertage, Stundenausgleich, Soll-Historie und persönliche Einstellungen.</p>
<form method="post" action="/settings/account/delete" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label>
Zur Bestätigung deine E-Mail-Adresse eingeben
<input type="email" name="confirm_email" placeholder="{{ user.email }}" required />
</label>
<label>
Aktuelles Passwort bestätigen
<input type="password" name="current_password" required />
</label>
<label class="checkbox-row">
<input type="checkbox" name="confirm_delete" />
<span>Ich möchte mein Konto und alle zugehörigen Daten dauerhaft löschen.</span>
</label>
<button type="submit" class="button danger">Konto dauerhaft löschen</button>
</form>
{% endcall %}
{% endif %}
{% if is_admin and active_settings_tab == 'admin' %}
{% call collapsible_section('Benutzerverwaltung', 'settings-admin-users', 'admin-card') %}
<p class="muted">Aktive Admins: {{ admin_user_count }}</p>
<div class="admin-user-list">
{% for managed in managed_users %}
{% set disable_delete = managed.id == user.id or (managed.role == 'admin' and managed.is_active and admin_user_count <= 1) %}
<form method="post"
action="/settings/admin/users/{{ managed.id }}"
class="admin-user-row">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="admin-user-meta">
<strong>{{ managed.email }}</strong>
<span class="muted">erstellt: {{ managed.created_at.strftime("%d.%m.%Y") }}</span>
</div>
<label>
Rolle
<select name="role" required>
<option value="user" {% if managed.role == 'user' %}selected{% endif %}>User</option>
<option value="admin" {% if managed.role == 'admin' %}selected{% endif %}>Admin</option>
</select>
</label>
<label class="checkbox-row">
<input type="checkbox"
name="is_active"
{% if managed.is_active %}checked{% endif %} />
<span>Aktiv</span>
</label>
<label class="checkbox-row">
<input type="checkbox" name="reset_mfa" />
<span>MFA zurücksetzen</span>
</label>
<div class="admin-user-actions">
<button type="submit" class="button">Speichern</button>
<button type="submit"
formaction="/settings/admin/users/{{ managed.id }}/delete"
formmethod="post"
class="button danger"
{% if disable_delete %}disabled{% endif %}
onclick="return confirm('Benutzer wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.');">
Löschen
</button>
</div>
</form>
{% endfor %}
</div>
{% endcall %}
{% call collapsible_section('E-Mail-Server', 'settings-admin-email', 'admin-card') %}
<p class="muted">Wird für Passwort-Reset, E-Mail-MFA und Registrierungsmails verwendet.</p>
<form method="post" action="/settings/admin/email-server" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="inline-grid">
<label>
SMTP Host
<input type="text"
name="smtp_host"
value="{{ email_server.smtp_host }}"
required />
</label>
<label>
SMTP Port
<input type="number"
min="1"
max="65535"
name="smtp_port"
value="{{ email_server.smtp_port }}"
required />
</label>
<label>
SMTP Username
<input type="text"
name="smtp_username"
value="{{ email_server.smtp_username }}" />
</label>
<label>
SMTP Passwort
{% if email_server.has_password %}(leer lassen = unverändert){% endif %}
<input type="password" name="smtp_password" />
</label>
<label>
Absender E-Mail
<input type="email"
name="from_email"
value="{{ email_server.from_email }}"
required />
</label>
<label>
Absender Name
<input type="text"
name="from_name"
value="{{ email_server.from_name }}"
required />
</label>
</div>
<div class="inline-grid">
<label class="checkbox-row">
<input type="checkbox"
name="use_starttls"
{% if email_server.use_starttls %}checked{% endif %} />
<span>STARTTLS verwenden</span>
</label>
<label class="checkbox-row">
<input type="checkbox"
name="use_ssl"
{% if email_server.use_ssl %}checked{% endif %} />
<span>SMTP SSL verwenden</span>
</label>
<label class="checkbox-row">
<input type="checkbox"
name="verify_tls"
{% if email_server.verify_tls %}checked{% endif %} />
<span>TLS Zertifikat prüfen</span>
</label>
<label class="checkbox-row">
<input type="checkbox"
name="registration_mails_enabled"
{% if email_server.registration_mails_enabled %}checked{% endif %} />
<span>Registrierungsmails aktiv</span>
</label>
<label class="checkbox-row">
<input type="checkbox"
name="password_reset_mails_enabled"
{% if email_server.password_reset_mails_enabled %}checked{% endif %} />
<span>Passwort-Reset-Mails aktiv</span>
</label>
<label class="checkbox-row">
<input type="checkbox"
name="registration_admin_notify_enabled"
{% if email_server.registration_admin_notify_enabled %}checked{% endif %} />
<span>Infomails bei neuer Registrierung aktiv</span>
</label>
</div>
<div class="admin-recipient-picker">
<p class="muted">Empfänger für Registrierungs-Infomails (aktive Admins)</p>
{% if admin_recipients %}
<div class="admin-recipient-grid">
{% for admin_recipient in admin_recipients %}
<label class="checkbox-row">
<input type="checkbox"
name="registration_admin_notify_admin_ids"
value="{{ admin_recipient.id }}"
{% if admin_recipient.id in email_server.registration_admin_notify_admin_ids %}checked{% endif %} />
<span>{{ admin_recipient.email }}</span>
</label>
{% endfor %}
</div>
{% else %}
<p class="muted">Keine aktiven Admins gefunden.</p>
{% endif %}
{% if email_server.registration_notify_fallback_email %}
<p class="muted">
Wenn keine Admins ausgewählt sind, wird die Fallback-Adresse
<strong>{{ email_server.registration_notify_fallback_email }}</strong> genutzt.
</p>
{% endif %}
</div>
<button type="submit" class="button">Speichern</button>
</form>
<form method="post"
action="/settings/admin/email-server/test"
class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button type="submit" class="button ghost">Testmail an mich senden</button>
</form>
{% endcall %}
{% call collapsible_section('Rechtliches', 'settings-admin-legal', 'admin-card') %}
<p class="muted">Diese Inhalte werden öffentlich über den Footer unter Impressum und Datenschutz angezeigt. Markdown ist erlaubt und wird beim Anzeigen sicher bereinigt.</p>
<form method="post" action="/settings/admin/site-content" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label>
Impressum (Markdown)
<textarea name="impressum_markdown" rows="14" required>{{ site_content_markdown['impressum'] }}</textarea>
</label>
<label>
Datenschutz (Markdown)
<textarea name="privacy_markdown" rows="16" required>{{ site_content_markdown['datenschutz'] }}</textarea>
</label>
<button type="submit" class="button">Speichern</button>
</form>
{% endcall %}
{% call collapsible_section('Kontakt & Tickets', 'settings-admin-tickets', 'admin-card') %}
<p class="muted">Neue Nachrichten aus dem Kontaktformular werden hier als Tickets gesammelt. Für Benachrichtigungen werden dieselben Admin-Empfänger wie bei Registrierungs-Infomails verwendet.</p>
<div class="support-ticket-list">
{% for ticket in support_tickets %}
<article class="support-ticket-card">
<div class="support-ticket-card__header">
<div class="support-ticket-card__title-wrap">
<h3>{{ ticket.subject }}</h3>
<p class="muted">
{{ ticket_category_label(ticket.category) }} · {{ ticket_status_label(ticket.status) }} · {{ ticket.created_at.strftime("%d.%m.%Y %H:%M") }} UTC
</p>
</div>
<div class="support-ticket-card__meta">
<strong>{{ ticket.email }}</strong>
<span class="muted">{{ ticket.name or 'Ohne Namen' }}</span>
{% if ticket.user_id %}<span class="muted">Angemeldeter Nutzer</span>{% endif %}
</div>
</div>
<div class="support-ticket-card__message">
{{ ticket.message }}
</div>
<form method="post" action="/settings/admin/tickets/{{ ticket.id }}" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="inline-grid support-ticket-card__controls">
<label>
Status
<select name="status" required>
<option value="open" {% if ticket.status == 'open' %}selected{% endif %}>Offen</option>
<option value="closed" {% if ticket.status == 'closed' %}selected{% endif %}>Geschlossen</option>
</select>
</label>
<label>
Interne Notiz
<textarea name="admin_notes" rows="4">{{ ticket.admin_notes or '' }}</textarea>
</label>
</div>
<button type="submit" class="button">Ticket speichern</button>
</form>
</article>
{% else %}
<p class="muted">Aktuell liegen keine Kontakt-Tickets vor.</p>
{% endfor %}
</div>
{% endcall %}
{% endif %}
</section>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% from "ui/flash.html" import alert %}
{% set error_message = error if error is defined else None %}
{% if success_message %}{{ alert(success_message, "success") }}{% endif %}
{% if error_message %}{{ alert(error_message, "error") }}{% endif %}
{% if info_message %}{{ alert(info_message, "info") }}{% endif %}
{% if flash_messages %}
{% for flash in flash_messages %}{{ alert(flash.message, flash.level or "info") }}{% endfor %}
{% endif %}
+10
View File
@@ -0,0 +1,10 @@
<footer class="site-footer">
<div class="container site-footer-inner">
<small class="muted">{{ app_name }}</small>
<nav class="site-footer-nav" aria-label="Footer">
<a href="/kontakt">Kontakt</a>
<a href="/impressum">Impressum</a>
<a href="/datenschutz">Datenschutz</a>
</nav>
</div>
</footer>
+38
View File
@@ -0,0 +1,38 @@
{% set is_auth_header = (not user) and (request.url.path.startswith('/login') or request.url.path.startswith('/register')) %}
<header class="site-header app-topbar {% if is_auth_header %}is-auth-header{% endif %}">
<div class="container app-topbar-inner {% if user %}is-user{% else %}is-guest{% endif %}">
<div class="app-brand-wrap">
<a class="brand app-brand"
href="{% if user %}{% if user.preferred_home_view == 'month' %}/month?view={{ user.preferred_month_view_mode or 'flat' }}{% else %}/dashboard{% endif %}{% else %}/{% endif %}"
aria-label="{{ app_name }} Startseite">
<img class="app-logo" src="/img/Logo.svg" alt="{{ app_name }}" />
</a>
{% if user %}
<div class="app-total-badges">
{% if header_vacation_visible is not defined or header_vacation_visible %}
<span class="app-total-badge app-total-badge-vacation"
title="Resturlaub / Gesamturlaubstage ({{ header_vacation_year }})">
<span class="app-total-badge__label">Urlaub</span>
<span class="app-total-badge__value">{{ header_vacation_days_remaining }}/{{ header_vacation_days_total }}</span>
</span>
{% endif %}
{% if header_workhours_counter_visible and header_workhours_counter_minutes is not none %}
<span class="app-total-badge app-total-badge-workhours"
title="Arbeitsstunden-Counter">
<span class="app-total-badge__label">Gesamt</span>
<span class="app-total-badge__value">{{ minutes_to_hhmm(header_workhours_counter_minutes) }}</span>
</span>
{% endif %}
{% if header_cumulative_minutes is not none %}
<span class="app-total-badge {% if header_cumulative_minutes < 0 %}is-negative{% else %}is-positive{% endif %}"
title="Kumulierte Überstunden">
<span class="app-total-badge__label">Stunden</span>
<span class="app-total-badge__value">{{ ('%.2f'|format(header_cumulative_minutes / 60) )|replace('.00', '') }}</span>
</span>
{% endif %}
</div>
{% endif %}
</div>
{% include "partials/nav.html" %}
</div>
</header>
+38
View File
@@ -0,0 +1,38 @@
{% from "ui/segmented_toggle.html" import segmented_toggle %}
{% if user %}
<div class="app-user-nav">
{{ segmented_toggle([
{'href': main_nav_week_url, 'label': 'Woche', 'active': request.url.path.startswith('/dashboard')},
{'href': main_nav_month_url, 'label': 'Monat', 'active': request.url.path.startswith('/month')}
], 'Hauptnavigation', 'topbar-toggle app-main-nav') }}
<div class="app-icon-nav" aria-label="Kopfzeilen-Aktionen">
<a class="app-icon-btn"
href="/settings"
title="Einstellungen"
aria-label="Einstellungen">
<img class="dash-icon" src="/static/icons/settings.svg" alt="" />
</a>
<a class="app-icon-btn"
href="/hilfe"
title="Hilfe"
aria-label="Hilfe">
<img class="dash-icon" src="/img/Icon-Help.svg" alt="" />
</a>
<form action="/logout" method="post" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button type="submit"
class="app-icon-btn"
title="Abmelden"
aria-label="Abmelden">
<img class="dash-icon" src="/static/icons/logout.svg" alt="" />
</button>
</form>
</div>
</div>
{% else %}
{{ segmented_toggle([
{'href': '/login', 'label': 'Login', 'active': request.url.path.startswith('/login')},
{'href': '/register', 'label': 'Registrierung', 'active': request.url.path.startswith('/register')}
], 'Authentifizierung', 'auth-toggle app-auth-nav') }}
{% endif %}
@@ -0,0 +1,5 @@
{% if user and user.role == 'admin' %}
<span class="admin-version-badge" aria-label="Version {{ app_version }}{% if app_env != 'production' %} {{ app_env|capitalize }}{% endif %}">
v{{ app_version }}{% if app_env != 'production' %} {{ app_env|capitalize }}{% endif %}
</span>
{% endif %}
+3
View File
@@ -0,0 +1,3 @@
{% macro badge(text, tone='default', class_name='') -%}
<span class="badge badge--{{ tone }} {{ class_name }}">{{ text }}</span>
{%- endmacro %}
+9
View File
@@ -0,0 +1,9 @@
{% macro button(label, type='button', variant='primary', extra_class='', icon=None) -%}
<button type="{{ type }}" class="btn btn--{{ variant }} {{ extra_class }}">
{% if icon %}<span class="btn__icon" aria-hidden="true">{{ icon }}</span>{% endif %}
<span>{{ label }}</span>
</button>
{%- endmacro %}
{% macro link_button(label, href, variant='primary', extra_class='') -%}
<a href="{{ href }}" class="btn btn--{{ variant }} {{ extra_class }}">{{ label }}</a>
{%- endmacro %}
+5
View File
@@ -0,0 +1,5 @@
{% macro card(class_name='') -%}
<section class="card {{ class_name }}">
{{ caller() }}
</section>
{%- endmacro %}
+3
View File
@@ -0,0 +1,3 @@
{% macro chip(text, kind='default', extra_class='') -%}
<span class="ui-chip ui-chip--{{ kind }} {{ extra_class }}">{{ text }}</span>
{%- endmacro %}
+12
View File
@@ -0,0 +1,12 @@
{% macro collapsible_section(title, section_id, classes='', sync_group='') -%}
<details id="{{ section_id }}"
class="settings-section settings-section--collapsible form-card full-width {{ classes }}"
data-component="settings-section"
{% if sync_group %}data-sync-group="{{ sync_group }}"{% endif %}>
<summary class="settings-section__summary">
<span class="settings-section__heading">{{ title }}</span>
<span class="settings-section__chevron" aria-hidden="true"></span>
</summary>
<div class="settings-section__content">{{ caller() }}</div>
</details>
{%- endmacro %}
+127
View File
@@ -0,0 +1,127 @@
{% from "ui/chip.html" import chip %}
{% macro status_badge_form(csrf_token, day, return_to, kind) -%}
{% if kind == 'vacation' %}
<form method="post"
action="/vacation/day/toggle"
class="inline-form"
data-async-refresh="view">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="date" value="{{ day.date.isoformat() }}" />
<input type="hidden" name="return_to" value="{{ return_to }}" />
<button type="submit" class="day-status-badge is-vacation" title="Urlaub entfernen" aria-label="Urlaub entfernen">U</button>
</form>
{% elif kind == 'holiday' %}
<form method="post"
action="/special-day/toggle"
class="inline-form"
data-async-refresh="view">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="date" value="{{ day.date.isoformat() }}" />
<input type="hidden" name="status" value="holiday" />
<input type="hidden" name="return_to" value="{{ return_to }}" />
<button type="submit" class="day-status-badge is-holiday" title="Feiertag entfernen" aria-label="Feiertag entfernen">F</button>
</form>
{% elif kind == 'sick' %}
<form method="post"
action="/special-day/toggle"
class="inline-form"
data-async-refresh="view">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="date" value="{{ day.date.isoformat() }}" />
<input type="hidden" name="status" value="sick" />
<input type="hidden" name="return_to" value="{{ return_to }}" />
<button type="submit" class="day-status-badge is-sick" title="Krankheitstag entfernen" aria-label="Krankheitstag entfernen">K</button>
</form>
{% elif kind == 'overtime' %}
<form method="post" action="/overtime-adjustment/clear" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="date" value="{{ day.date.isoformat() }}" />
<input type="hidden" name="return_to" value="{{ return_to }}" />
<button type="submit" class="day-status-badge is-overtime" title="Stundenausgleich entfernen" aria-label="Stundenausgleich entfernen">S</button>
</form>
{% endif %}
{%- endmacro %}
{% macro add_menu_status_form(action_url, csrf_token, day, return_to, label, status='') -%}
<form method="post"
action="{{ action_url }}"
class="inline-form"
data-async-refresh="view">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="date" value="{{ day.date.isoformat() }}" />
<input type="hidden" name="return_to" value="{{ return_to }}" />
{% if status %}<input type="hidden" name="status" value="{{ status }}" />{% endif %}
<button type="submit" class="day-row__add-menu-item">{{ label }}</button>
</form>
{%- endmacro %}
{% macro day_row(day, csrf_token, date_label, return_to='/dashboard', mode='week') -%}
{% set is_vacation = day.is_vacation if day.is_vacation is defined else false %}
{% set special_status = day.special_status if day.special_status is defined else None %}
{% set overtime_adjustment_minutes = day.overtime_adjustment_minutes if day.overtime_adjustment_minutes is defined else 0 %}
{% set has_status = is_vacation or special_status in ['holiday', 'sick'] or overtime_adjustment_minutes %}
{% set is_weekend = day.is_weekend if day.is_weekend is defined else day.date.weekday() >= 5 %}
{% set is_today = today_date is defined and day.date == today_date %}
{% set status_edit_url = None %}
{% if overtime_adjustment_minutes %}
{% set status_edit_url = '/overtime-adjustment/edit?date=' ~ day.date.isoformat() %}
{% elif is_vacation %}
{% set status_edit_url = '/day-status/edit?date=' ~ day.date.isoformat() ~ '&status=vacation' %}
{% elif special_status == 'holiday' %}
{% set status_edit_url = '/day-status/edit?date=' ~ day.date.isoformat() ~ '&status=holiday' %}
{% elif special_status == 'sick' %}
{% set status_edit_url = '/day-status/edit?date=' ~ day.date.isoformat() ~ '&status=sick' %}
{% endif %}
<article class="day-row day-row--{{ mode }} {% if is_weekend %}day-row--weekend{% endif %} {% if is_today %}day-row--today{% endif %} {% if has_status %}day-row--has-status{% endif %}">
<div class="day-row__label" title="{{ date_label }}">{{ date_label }}</div>
<div class="day-row__cells {% if not day.entry %}day-row__cells--empty{% endif %}">
{% if day.entry %}
{{ chip(minutes_to_hhmm(day.entry.start_minutes) ~ ' → ' ~ minutes_to_hhmm(day.entry.end_minutes), 'time') }}
{{ chip('Pause: ' ~ day.entry.break_minutes ~ ' min', 'break') }}
{{ chip('Netto: ' ~ minutes_to_hhmm(day.net_minutes), 'net') }}
{% else %}
{{ chip('Keinen Eintrag', 'empty') }}
{% endif %}
</div>
<div class="day-row__actions">
{% if is_vacation %}{{ status_badge_form(csrf_token, day, return_to, 'vacation') }}{% endif %}
{% if special_status == 'holiday' %}{{ status_badge_form(csrf_token, day, return_to, 'holiday') }}{% endif %}
{% if special_status == 'sick' %}{{ status_badge_form(csrf_token, day, return_to, 'sick') }}{% endif %}
{% if overtime_adjustment_minutes %}{{ status_badge_form(csrf_token, day, return_to, 'overtime') }}{% endif %}
{% if day.entry %}
<a class="icon-button" href="/entry/{{ day.entry.id }}/edit" title="Eintrag bearbeiten" aria-label="Eintrag bearbeiten">
<img class="dash-icon" src="/static/icons/edit.svg" alt="" />
</a>
<form method="post" action="/entry/{{ day.entry.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button type="submit" class="icon-button" title="Eintrag löschen" aria-label="Eintrag löschen">
<img class="dash-icon" src="/static/icons/delete.svg" alt="" />
</button>
</form>
{% else %}
{% if has_status and status_edit_url %}
<a class="icon-button" href="{{ status_edit_url }}" title="Tag bearbeiten" aria-label="Tag bearbeiten">
<img class="dash-icon" src="/static/icons/edit.svg" alt="" />
</a>
{% endif %}
<details class="day-row__add-menu">
<summary class="icon-button" title="Optionen hinzufügen" aria-label="Optionen hinzufügen">
<img class="dash-icon" src="/static/icons/add.svg" alt="" />
</summary>
<div class="day-row__add-menu-panel">
<a class="day-row__add-menu-item" href="/entry/new?date={{ day.date.isoformat() }}">Zeit</a>
{{ add_menu_status_form('/vacation/day/toggle', csrf_token, day, return_to, 'Urlaub (U)') }}
{{ add_menu_status_form('/special-day/toggle', csrf_token, day, return_to, 'Feiertag (F)', 'holiday') }}
{{ add_menu_status_form('/special-day/toggle', csrf_token, day, return_to, 'Krankheit (K)', 'sick') }}
<a class="day-row__add-menu-item" href="/overtime-adjustment/edit?date={{ day.date.isoformat() }}">Stundenausgleich (S)</a>
</div>
</details>
{% endif %}
</div>
</article>
{%- endmacro %}
+6
View File
@@ -0,0 +1,6 @@
{% macro empty_state(title, text='') -%}
<section class="empty-state">
<h2 class="empty-state__title">{{ title }}</h2>
{% if text %}<p class="empty-state__text muted">{{ text }}</p>{% endif %}
</section>
{%- endmacro %}
+12
View File
@@ -0,0 +1,12 @@
{% macro alert(message, level='info') -%}
<div class="alert alert--{{ level }}"
role="status"
aria-live="polite"
data-component="flash">
<span class="alert__message">{{ message }}</span>
<button class="alert__close"
type="button"
aria-label="Hinweis schließen"
data-action="flash-close">×</button>
</div>
{%- endmacro %}
+37
View File
@@ -0,0 +1,37 @@
{% macro input_field(label, name, type='text', value='', required=false, placeholder='', autocomplete='', extra_class='', attrs='') -%}
<label class="form-field {{ extra_class }}">
<span class="form-field__label">{{ label }}</span>
<input class="input"
type="{{ type }}"
name="{{ name }}"
value="{{ value }}"
{% if required %}required{% endif %}
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %}
{% if attrs %}{{ attrs|safe }}{% endif %} />
</label>
{%- endmacro %}
{% macro textarea_field(label, name, value='', rows=3, placeholder='', extra_class='', attrs='') -%}
<label class="form-field {{ extra_class }}">
<span class="form-field__label">{{ label }}</span>
<textarea class="input"
name="{{ name }}"
rows="{{ rows }}"
{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}
{% if attrs %} {{ attrs|safe }}{% endif %}>{{ value }}</textarea>
</label>
{%- endmacro %}
{% macro select_field(label, name, options, selected='', required=false, extra_class='', attrs='') -%}
<label class="form-field {{ extra_class }}">
<span class="form-field__label">{{ label }}</span>
<select class="input"
name="{{ name }}"
{% if required %}required{% endif %}
{% if attrs %}{{ attrs|safe }}{% endif %}>
{% for option in options %}
<option value="{{ option.value }}"
{% if option.value == selected %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
{%- endmacro %}
+9
View File
@@ -0,0 +1,9 @@
{% macro help_section(title, subtitle='') -%}
<article class="help-section">
<header class="help-section__header">
<h2>{{ title }}</h2>
{% if subtitle %}<p class="muted">{{ subtitle }}</p>{% endif %}
</header>
<div class="help-section__body">{{ caller() }}</div>
</article>
{%- endmacro %}
+17
View File
@@ -0,0 +1,17 @@
{% macro icon_link(href, icon, label, title='', extra_class='') -%}
<a class="icon-button {{ extra_class }}"
href="{{ href }}"
title="{{ title if title else label }}"
aria-label="{{ label }}">
<img class="dash-icon" src="{{ icon }}" alt="" />
</a>
{%- endmacro %}
{% macro icon_submit(icon, label, title='', extra_class='') -%}
<button type="submit"
class="icon-button {{ extra_class }}"
title="{{ title if title else label }}"
aria-label="{{ label }}">
<img class="dash-icon" src="{{ icon }}" alt="" />
</button>
{%- endmacro %}
+18
View File
@@ -0,0 +1,18 @@
{% macro kpi_bar(items, extra_class='') -%}
<section class="kpi-bar {{ extra_class }}">
{% for item in items %}
<p class="kpi-bar__item {% if item.get('show_edit') %}kpi-bar__item--editable{% endif %}">
<span class="kpi-bar__label">{{ item.label }}:</span>
<strong class="kpi-bar__value {% if item.get('value_class') %}{{ item.get('value_class') }}{% endif %}">{{ item.value }}</strong>
{% if item.get('show_edit') %}
<button type="button"
class="kpi-bar__edit js-toggle-weekly-target-editor"
aria-label="Wochen-Soll bearbeiten"
title="Wochen-Soll bearbeiten">
<img class="dash-icon" src="/static/icons/edit.svg" alt="" />
</button>
{% endif %}
</p>
{% endfor %}
</section>
{%- endmacro %}
+18
View File
@@ -0,0 +1,18 @@
{% macro modal(id, title, close_label='Schließen') -%}
<div class="modal" id="{{ id }}" data-component="modal" hidden>
<div class="modal__backdrop" data-action="modal-close"></div>
<section class="modal__dialog"
role="dialog"
aria-modal="true"
aria-labelledby="{{ id }}-title">
<header class="modal__header">
<h2 id="{{ id }}-title">{{ title }}</h2>
<button class="modal__close"
type="button"
data-action="modal-close"
aria-label="{{ close_label }}">×</button>
</header>
<div class="modal__body">{{ caller() }}</div>
</section>
</div>
{%- endmacro %}
+5
View File
@@ -0,0 +1,5 @@
{% from "ui/week_group_header.html" import week_group_header %}
{% macro month_week_divider(week, csrf_token, return_to) -%}
{{ week_group_header(week, csrf_token, return_to) }}
{%- endmacro %}
+23
View File
@@ -0,0 +1,23 @@
{% macro month_header_bar(prev_url, next_url, title) -%}
<section class="period-header"
data-component="swipe-nav"
data-prev-url="{{ prev_url }}"
data-next-url="{{ next_url }}">
<div class="period-header__nav" data-component="period-nav">
<a class="icon-button"
href="{{ prev_url }}"
title="Vorheriger Monat"
aria-label="Vorheriger Monat">
<img class="dash-icon dash-icon-rotate-180" src="/static/icons/arrow.svg" alt="" />
</a>
<p class="period-header__title">{{ title }}</p>
<a class="icon-button"
href="{{ next_url }}"
title="Nächster Monat"
aria-label="Nächster Monat">
<img class="dash-icon" src="/static/icons/arrow.svg" alt="" />
</a>
</div>
<div class="period-header__actions">{{ caller() }}</div>
</section>
{%- endmacro %}
+6
View File
@@ -0,0 +1,6 @@
{% macro page_header(title, subtitle='') -%}
<header class="page-header">
<h1 class="page-header__title">{{ title }}</h1>
{% if subtitle %}<p class="page-header__subtitle muted">{{ subtitle }}</p>{% endif %}
</header>
{%- endmacro %}
+6
View File
@@ -0,0 +1,6 @@
{% macro pagination(prev_href='', next_href='', class_name='') -%}
<nav class="pagination {{ class_name }}" aria-label="Seitennavigation">
{% if prev_href %}<a class="btn btn--ghost" href="{{ prev_href }}">Zurück</a>{% endif %}
{% if next_href %}<a class="btn btn--ghost" href="{{ next_href }}">Weiter</a>{% endif %}
</nav>
{%- endmacro %}
+8
View File
@@ -0,0 +1,8 @@
{% macro segmented_toggle(items, aria_label='Umschalter', extra_class='') -%}
<nav class="segmented-toggle {{ extra_class }}" aria-label="{{ aria_label }}">
{% for item in items %}
<a href="{{ item.href }}"
class="segmented-toggle__item {% if item.active %}is-active{% endif %}">{{ item.label }}</a>
{% endfor %}
</nav>
{%- endmacro %}
+5
View File
@@ -0,0 +1,5 @@
{% from "ui/kpi_bar.html" import kpi_bar %}
{% macro status_strip(items, columns='4', extra_class='') -%}
{{ kpi_bar(items, extra_class) }}
{%- endmacro %}
+14
View File
@@ -0,0 +1,14 @@
{% macro table(headers, class_name='') -%}
<div class="table-wrap {{ class_name }}">
<table class="table">
<thead>
<tr>
{% for header in headers %}<th>{{ header }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{{ caller() }}
</tbody>
</table>
</div>
{%- endmacro %}
+5
View File
@@ -0,0 +1,5 @@
{% from "ui/day_row.html" import day_row %}
{% macro time_day_row(day, csrf_token, date_label, return_to='/dashboard') -%}
{{ day_row(day, csrf_token, date_label, return_to, 'week') }}
{%- endmacro %}
+13
View File
@@ -0,0 +1,13 @@
{% macro workhours_target_warning_banner(warning) -%}
{% if warning and warning.at_risk %}
<section class="workhours-warning-banner"
data-component="workhours-warning"
data-workhours-warning="{{ warning.start_date.isoformat() }}-{{ warning.end_date.isoformat() }}-{{ warning.target_minutes }}">
<p class="workhours-warning-text">Achtung: Arbeitsstundenziel wird ggf. nicht erreicht</p>
<button type="button"
class="workhours-warning-close"
aria-label="Hinweis ausblenden"
data-action="warning-close">×</button>
</section>
{% endif %}
{%- endmacro %}
@@ -0,0 +1,5 @@
{% macro week_group_card_mobile(week, csrf_token, return_to) -%}
<section class="week-group-card-mobile {% if week.is_vacation_week %}is-vacation-week{% endif %}">
{{ caller() }}
</section>
{%- endmacro %}
+27
View File
@@ -0,0 +1,27 @@
{% macro week_group_header(week, csrf_token, return_to) -%}
<header class="week-group-header {% if week.is_vacation_week %}is-vacation-week{% endif %}">
<div class="week-group-header__left">
<strong>KW{{ week.iso_week }}</strong>
<span class="week-group-header__arrow">&rarr;</span>
<span>{{ week.week_start.strftime("%d.%m.") }} - {{ week.week_end.strftime("%d.%m.") }}</span>
</div>
<p class="week-group-header__meta">
Ist {{ minutes_to_hhmm(week.weekly_ist) }} | Soll {{ minutes_to_hhmm(week.weekly_soll) }} | Delta
<span class="{% if week.weekly_delta < 0 %}negative{% else %}positive{% endif %}">{{ minutes_to_hhmm(week.weekly_delta) }}</span>
| Urlaubstage {{ week.vacation_days }}
</p>
<form method="post"
action="/vacation/week/toggle"
class="inline-form week-group-header__action"
data-async-refresh="view">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="week_start" value="{{ week.week_start.isoformat() }}" />
<input type="hidden" name="week_end" value="{{ week.week_end.isoformat() }}" />
<input type="hidden" name="return_to" value="{{ return_to }}" />
<button type="submit"
class="week-vacation-button {% if week.is_vacation_week %}is-active{% endif %}"
title="Urlaub für ganze Woche umschalten"
aria-label="Urlaub für ganze Woche umschalten">Urlaub</button>
</form>
</header>
{%- endmacro %}
+23
View File
@@ -0,0 +1,23 @@
{% macro week_header_bar(prev_url, next_url, title) -%}
<section class="period-header"
data-component="swipe-nav"
data-prev-url="{{ prev_url }}"
data-next-url="{{ next_url }}">
<div class="period-header__nav" data-component="period-nav">
<a class="icon-button"
href="{{ prev_url }}"
title="Vorherige Woche"
aria-label="Vorherige Woche">
<img class="dash-icon dash-icon-rotate-180" src="/static/icons/arrow.svg" alt="" />
</a>
<p class="period-header__title">{{ title }}</p>
<a class="icon-button"
href="{{ next_url }}"
title="Nächste Woche"
aria-label="Nächste Woche">
<img class="dash-icon" src="/static/icons/arrow.svg" alt="" />
</a>
</div>
<div class="period-header__actions">{{ caller() }}</div>
</section>
{%- endmacro %}