diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..37559ea
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,10 @@
+# Vorlage für /opt/checkpoint/.env (NICHT ins Git – enthält Geheimnisse).
+# Kopieren: cp .env.example .env und SECRET_KEY ersetzen.
+
+# Schaltet die Produktions-Härtung scharf (sichere Cookies, ProxyFix,
+# SECRET_KEY-Pflicht).
+CP_ENV=production
+
+# Langes Zufalls-Secret. Erzeugen z. B. mit:
+# python -c "import secrets; print(secrets.token_urlsafe(48))"
+SECRET_KEY=bitte-durch-langes-zufalls-secret-ersetzen
diff --git a/CLAUDE-backup.md b/CLAUDE-backup.md
new file mode 100644
index 0000000..4349596
--- /dev/null
+++ b/CLAUDE-backup.md
@@ -0,0 +1,213 @@
+# CLAUDE.md — CheckPoint Ehrenamt App
+
+> Projektanker. Claude Code liest diese Datei automatisch zu Beginn jeder Session.
+> Sie ist die verbindliche Quelle für Stack, Regeln und Logik. Bei Widerspruch
+> zwischen Wunsch und dieser Datei: nachfragen, nicht raten.
+
+---
+
+## Zweck
+
+Interne, mobile-first Web-App für das Vor-Ort-Team Prävention der AIDS-Hilfe.
+Sie organisiert Einsätze (Terminabstimmung + Dienstverteilung) und ersetzt
+schrittweise die WhatsApp/Signal-Zettelwirtschaft. **Das Herzstück ist die
+Dienstplanung — alles andere ordnet sich dem unter.**
+
+---
+
+## Stack & Rahmenbedingungen (fest)
+
+| Bereich | Entscheidung |
+|---|---|
+| Sprache/Backend | **Python + Flask** |
+| Templating | **Jinja2**, server-gerendertes HTML (klassische Multi-Page-App) |
+| ORM/DB | **SQLAlchemy**, **SQLite** für v1 (ein VPS, 17 Nutzer). Keine Annahmen treffen, die einen späteren Umstieg auf PostgreSQL verbauen. |
+| Passwörter | `werkzeug.security` (Hashing), nie Klartext |
+| CSS | **selbst gebaut, kein CSS-Framework**. Basis: `tokens.css` + `components.css` |
+| JavaScript | **so wenig wie möglich, nur Vanilla, kein Framework**. Einzige Ausnahme v1: Chat-Polling (s. u.) |
+| Hosting | **eigener VPS in der EU** |
+| Plattform | mobile-first Web-App, **kein App Store**, „zum Homescreen" möglich |
+| UI-Sprache | **Deutsch** |
+
+**Erlaubte Zusatz-Bibliotheken (klein halten):** Flask, SQLAlchemy/Flask-SQLAlchemy,
+Werkzeug, optional Flask-Login. Keine schweren Frameworks, kein CSS-/JS-Framework.
+
+---
+
+## Architektur-Prinzipien
+
+1. **Server-rendered first.** Seiten kommen fertig vom Server. Formulare sind echte
+ `POST`-Formulare. Kein clientseitiges Routing, kein SPA-Verhalten.
+2. **JavaScript nur, wo es sich beweist.** Einzige JS-Ausnahme in v1: der Chat holt
+ neue Nachrichten per kleinem `fetch`-Polling. Sonst nirgends JS verlangen.
+3. **Mandantenfähig ab Tag 1.** Siehe eigener Abschnitt. Jeder Datensatz gehört zu
+ genau einem Team.
+4. **Mobile-first.** Getestet in App-Breite 360–460px.
+5. **Barrierearmut ist Pflicht, kein Extra.** 44px Touch-Ziele, sichtbarer Fokus,
+ Status nie nur über Farbe, sinnvolle `aria-label`.
+
+---
+
+## Rollen
+
+| Rolle | Rechte |
+|---|---|
+| **Admin** | Logins vergeben, Planungszeiträume und Einsätze anlegen, Dienstplan bauen und veröffentlichen, Dokumente/Fotos freigeben, im Kanal „Ankündigungen" posten, Team verwalten. |
+| **Ehrenamtliche:r** | Verfügbarkeit melden, eigenen Dienstplan sehen, freie Dienste übernehmen, Dokumente/Fotos hochladen (Freigabe nötig), im Kanal „Team" schreiben, Profil pflegen. |
+
+- **Keine Selbstregistrierung.** Logins legt ausschließlich der Admin an.
+- **v1:** Nutzername + Passwort. (E-Mail + Code = Future Log.)
+
+---
+
+## Mandantenfähigkeit (Pflichtregeln)
+
+- Zentrale Einheit **`team`** (= Mandant). Das CheckPoint-Team ist das erste von
+ perspektivisch mehreren.
+- **Jeder** Datensatz (user, planungszeitraum, einsatz, verfuegbarkeit, zuteilung,
+ dokument, kanal, nachricht) trägt eine **`team_id`**.
+- **Jede** Datenbankabfrage filtert nach dem Team des eingeloggten Nutzers. Nie Daten
+ über Teamgrenzen hinweg ausliefern. Das aktive Team wird aus der Session abgeleitet.
+- Das Corporate Design wird **pro Team** über die Brand-Tokens in `tokens.css`
+ gesetzt (serverseitig in den `
` injiziert). Code darf keine Markenfarbe
+ fest verdrahten — immer Tokens verwenden.
+
+---
+
+## Datenmodell (Regeln, konzeptionell)
+
+Jede Tabelle hat `team_id`. Namen/Felder sind Richtwerte, keine starre Vorgabe.
+
+- **team** — Mandant. Felder u. a.: Name, Brand-Tokens (Primär, Primär-stark, Akzent,
+ Akzent-stark, Schrift), Logo-Pfad.
+- **user** — `rolle` (admin | ehrenamt), Nutzername, Passwort-Hash, Anzeigename,
+ optionales Profilbild, optionale freiwillige Angaben.
+- **planungszeitraum** — Block über einen frei wählbaren Zeitraum (kein Mindestzeitraum).
+ `status`: in_planung | veroeffentlicht.
+- **einsatz** — gehört **optional** zu einem Planungszeitraum (`planungszeitraum_id`
+ nullbar). Ohne Zeitraum = **Einzeltermin**, der direkt zu besetzen ist. Datum,
+ Uhrzeit (Start/Ende), Art/Ort (z. B. Tour Altstadt, Party, Sonderveranstaltung).
+ Braucht **2 Hauptplätze + 1 Springerplatz**.
+- **verfuegbarkeit** — (user × einsatz) → kann | kann_nicht.
+- **zuteilung** — (user × einsatz) mit `platztyp` (haupt | springer) und
+ `status` (zugeteilt | abgesagt | offen | uebernommen).
+- **dokument** — Datei (PDF/Word/Bild). Felder: Titel (Pflicht), Uploader, `status`
+ (wartet_auf_freigabe | freigegeben | abgelehnt), `ablage` (aktuell | archiv).
+- **kanal** — pro Team zwei feste: `ankuendigungen`, `team`.
+- **nachricht** — Kanal, Autor, Text, Zeitstempel. **Nur Text.**
+
+---
+
+## Kern-Business-Logik (verbindlich)
+
+### Planungszyklus
+1. Admin legt einen Planungszeitraum (frei wählbare Länge) an und trägt die kuratierten
+ Einsätze ein (Freitags-Touren, Samstags-Einsätze/Partys, Sonderveranstaltungen).
+ Alternativ: einzelne Einzeltermine ohne Zeitraum, direkt zum Besetzen.
+2. Status `in_planung` → Ehrenamtliche melden pro Einsatz **kann / kann nicht**.
+ Änderung der Verfügbarkeit nur solange `in_planung`.
+3. Admin baut den Plan **manuell**: pro Einsatz **2 Haupt + 1 Springer**. Die App zeigt
+ pro Person **Anzahl bisheriger Einsätze** und **letzten Einsatz** als Hilfe
+ (keine automatische Verteilung in v1).
+4. Bei zu wenigen Verfügbaren: Einsatz als **unterbesetzt** markieren.
+5. Admin **veröffentlicht** → alle sehen ihren persönlichen Dienstplan.
+
+### Absage & Übernahme
+- Sagt eine **Hauptperson** ab → **Springer rückt automatisch nach** (Status `uebernommen`).
+- Der frei gewordene **Springerplatz** wird `offen` → zum **„Übernehmen"** für alle.
+- Gibt es keinen Springer / sagt er auch ab → offener Platz für alle.
+- „Übernehmen" dient **nur dem Nachrücken**, nie der Erstverteilung.
+
+### Dokumente
+- Upload durch alle, aber Status startet **`wartet_auf_freigabe`** (nur Admin + Uploader sichtbar).
+- Admin gibt frei oder lehnt ab (optional Grund). Erst nach Freigabe für alle sichtbar.
+- Admin-Uploads gehen direkt durch.
+- Liste geteilt in **Aktuell** und **Archiv** (Verschieben macht der Admin).
+- **Foto-Regel:** nur ohne erkennbare Dritte bzw. mit deren Einverständnis.
+
+### Chat
+- Zwei Kanäle: **Ankündigungen** (nur Admin postet, alle lesen) und **Team** (alle posten).
+- Nur Text. Neue Nachrichten via Hintergrund-`fetch`-Polling (einzige v1-JS-Ausnahme).
+
+### Benachrichtigungen
+- **v1: nur in-App** (neuer Dienst, offener Dienst, neue Nachricht). Push/E-Mail = Future Log.
+
+---
+
+## Designsystem
+
+- Quelle der Wahrheit: **`tokens.css`** (mandantenfähig, hell + dunkel via `light-dark()`).
+- **Hell/Dunkel** folgt standardmäßig der Systemeinstellung. Zusätzlich gibt es einen
+ manuellen Umschalter (Topbar-Icon, schaltet System → Hell → Dunkel). Er läuft
+ **serverseitig ohne JS**: ein POST setzt ein `theme`-Cookie, der Server schreibt
+ `data-theme="light|dark"` an `` (bei „System" kein Attribut). „System" ist
+ Default und jederzeit wieder wählbar.
+- **Nie feste Farben** im Komponenten-CSS — immer Tokens (`var(--cp-...)`).
+- Erledigt (Session 4 + Nachzug): `components.css` enthält **kein** Farb-Literal mehr.
+ Die früher feste weiße Button-Schrift auf Türkis/Rot läuft jetzt über das Token
+ `--cp-on-brand` (BRAND-Ebene, bewusst themeunabhängig, kein `light-dark()`, da die
+ Flächen `--cp-teal`/`--cp-red` in beiden Themes derselbe Vollton sind). Pro Team
+ überschreibbar. Regel daher ausnahmslos: nie Farb-Literale im Komponenten-CSS.
+- Status-Mapping: Türkis = offen/bestätigt/verfügbar · Rot = dringend/Vertretung ·
+ Neutral = Info/intern/erledigt.
+- Schrift: DM Sans.
+
+---
+
+## UI-Regeln
+
+- Mobile-first bei 360–460px Basisbreite; auf Tablet/Desktop wächst die zentrierte
+ Spalte (inkl. fixierter Topbar/Bottom-Nav) ab 768px auf ~720px. Viel Weißraum,
+ klare Karten (Radius 12px, Innenabstand 16px), keine verschachtelten Karten.
+- **Bottom-Navigation, 4 Punkte: Start · Termine · Team · Profil.** (Nicht „Kalender".)
+- Jede Ansicht hat **eine klare Hauptaktion** (Button oder FAB).
+- Buttons: Primär = Türkis/weiß · Dringend = Rot/weiß · Ghost = Fläche mit feinem
+ Rahmen. Kurze Beschriftungen: „Speichern", „Eintragen", „Übernehmen", „Details".
+- Formulare: Labels immer sichtbar über dem Feld, Felder ≥ 44px, kurze freundliche
+ Hilfetexte, Fehler klar und nicht nur über Farbe.
+
+## Texte / Copy
+
+- **Deutsch**, Satzanfang groß (sentence case), aktive Verben, keine Floskeln.
+- Eine Aktion heißt im ganzen Flow gleich (Button „Übernehmen" → Bestätigung „Übernommen").
+- Fehlermeldungen sagen, was passiert ist und wie es weitergeht — ohne Entschuldigung.
+
+---
+
+## Bewusst raus aus v1 (Out of Scope)
+
+Keine öffentlichen Seiten · keine Klient:innendaten · keine Bezahlfunktion ·
+kein Kalender-Sync (Terminübersicht = Liste) · keine Dateien/Bilder im Chat ·
+keine automatische Dienstverteilung · kein 1:1-Chat · kein Push/E-Mail.
+
+---
+
+## Vorgeschlagene Projektstruktur
+
+```
+checkpoint-ehrenamt/
+├── CLAUDE.md
+├── TASKS.md
+├── app/
+│ ├── __init__.py # App-Factory, DB-Init, Team-Scoping
+│ ├── models.py # SQLAlchemy-Modelle (alle mit team_id)
+│ ├── auth.py # Login, Logout, Admin-Anlage
+│ ├── routes/ # Blueprints: planung, dienste, dokumente, chat, profil
+│ ├── templates/ # Jinja2 (base.html + Teilansichten)
+│ └── static/
+│ ├── tokens.css
+│ └── components.css
+├── instance/ # SQLite-DB, Uploads (nicht im Git)
+└── requirements.txt
+```
+
+---
+
+## Arbeitsweise mit den Modellen (Continue)
+
+- **Mistral (Architect):** Planung, Design-Entscheidungen, Reviews. Nie zum Schreiben
+ von Produktivcode.
+- **Qwen (Coder):** Implementierung, Bugfixes, Inline-Edits (`Ctrl+I`). Immer ein
+ Stück nach dem anderen (Datenmodell → Service → Route → Template).
+- **Claude Code:** Scaffold, Mehrdateien-Arbeit, übergreifendes Verdrahten.
+- **Eine Aufgabe pro Session.** Immer `@file`-Kontext. Plan vor Code.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..649e73f
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,272 @@
+# CLAUDE.md — CheckPoint Ehrenamt App
+
+> Projektanker. Claude Code liest diese Datei automatisch zu Beginn jeder Session.
+> Sie ist die verbindliche Quelle für Stack, Regeln und Logik. Bei Widerspruch
+> zwischen Wunsch und dieser Datei: nachfragen, nicht raten.
+
+---
+
+## Zweck
+
+Interne, mobile-first Web-App für das Vor-Ort-Team Prävention der AIDS-Hilfe.
+Sie organisiert Einsätze (Terminabstimmung + Dienstverteilung) und ersetzt
+schrittweise die WhatsApp/Signal-Zettelwirtschaft. **Das Herzstück ist die
+Dienstplanung — alles andere ordnet sich dem unter.**
+
+---
+
+## Stack & Rahmenbedingungen (fest)
+
+| Bereich | Entscheidung |
+|---|---|
+| Sprache/Backend | **Python + Flask** |
+| Templating | **Jinja2**, server-gerendertes HTML (klassische Multi-Page-App) |
+| ORM/DB | **SQLAlchemy**, **SQLite** für v1 (ein VPS, 17 Nutzer). Keine Annahmen treffen, die einen späteren Umstieg auf PostgreSQL verbauen. |
+| Passwörter | `werkzeug.security` (Hashing), nie Klartext |
+| CSS | **selbst gebaut, kein CSS-Framework**. Basis: `tokens.css` + `components.css` (User-Shell) + `components-admin.css` (Admin-Shell) |
+| JavaScript | **so wenig wie möglich, nur Vanilla, kein Framework**. Einzige Ausnahme v1: Chat-Polling (s. u.) |
+| Hosting | **eigener VPS in der EU** |
+| Plattform | mobile-first Web-App, **kein App Store**, „zum Homescreen" möglich |
+| UI-Sprache | **Deutsch** |
+
+**Erlaubte Zusatz-Bibliotheken (klein halten):** Flask, SQLAlchemy/Flask-SQLAlchemy,
+Werkzeug, optional Flask-Login. Keine schweren Frameworks, kein CSS-/JS-Framework.
+
+---
+
+## Architektur-Prinzipien
+
+1. **Server-rendered first.** Seiten kommen fertig vom Server. Formulare sind echte
+ `POST`-Formulare. Kein clientseitiges Routing, kein SPA-Verhalten.
+2. **JavaScript nur, wo es sich beweist.** Zwei JS-Ausnahmen in v1: der Chat holt
+ neue Nachrichten per kleinem `fetch`-Polling, und die Admin-Sidebar wird auf
+ schmalen Bildschirmen per Hamburger-Icon als Off-Canvas-Overlay ein-/ausgeklappt
+ (Klasse toggeln, kein Framework). Sonst nirgends JS verlangen.
+3. **Mandantenfähig ab Tag 1.** Siehe eigener Abschnitt. Jeder Datensatz gehört zu
+ genau einem Team.
+4. **Mobile-first.** Getestet in App-Breite 360–460px.
+5. **Barrierearmut ist Pflicht, kein Extra.** 44px Touch-Ziele, sichtbarer Fokus,
+ Status nie nur über Farbe, sinnvolle `aria-label`.
+
+---
+
+## Rollen
+
+| Rolle | Rechte |
+|---|---|
+| **Admin** | Logins vergeben, Planungszeiträume und Einsätze anlegen, Dienstplan bauen und veröffentlichen, Dokumente/Fotos freigeben, im Kanal „Ankündigungen" posten, Team verwalten. |
+| **Ehrenamtliche:r** | Verfügbarkeit melden, eigenen Dienstplan sehen, freie Dienste übernehmen, Dokumente/Fotos hochladen (Freigabe nötig), im Kanal „Team" schreiben, Profil pflegen. |
+
+- **Keine Selbstregistrierung.** Logins legt ausschließlich der Admin an.
+- **v1:** Nutzername + Passwort. Der Nutzername ist nur **pro Team** eindeutig, daher
+ ist der Login team-scoped: bei mehreren Teams kommt ein **Team-Kürzel** (`team.slug`)
+ dazu; gibt es nur ein Team, entfällt das Feld. (E-Mail + Code = Future Log.)
+
+---
+
+## Mandantenfähigkeit (Pflichtregeln)
+
+- Zentrale Einheit **`team`** (= Mandant). Das CheckPoint-Team ist das erste von
+ perspektivisch mehreren.
+- **Jeder** Datensatz (user, planungszeitraum, einsatz, verfuegbarkeit, zuteilung,
+ dokument, kanal, nachricht) trägt eine **`team_id`**.
+- **Jede** Datenbankabfrage filtert nach dem Team des eingeloggten Nutzers. Nie Daten
+ über Teamgrenzen hinweg ausliefern. Das aktive Team wird aus der Session abgeleitet.
+- Das Corporate Design wird **pro Team** über die Brand-Tokens in `tokens.css`
+ gesetzt (serverseitig in den `` injiziert). Code darf keine Markenfarbe
+ fest verdrahten — immer Tokens verwenden.
+
+---
+
+## Zwei Oberflächen, zwei Shells (Pflichtregel)
+
+Es gibt **zwei eigenständige Layout-Shells**, eine pro Rolle. Sie sind keine
+Variante derselben Vorlage, sondern zwei getrennte Templates mit jeweils eigenem
+Tokensatz:
+
+| | **User-Shell** | **Admin-Shell** |
+|---|---|---|
+| Für wen | Ehrenamtliche | Admin |
+| Vorlage | `base_user.html` | `base_admin.html` |
+| Navigation | Bottom-Nav, 4 Punkte (Start, Termine, Team, Profil) | Sidebar links, fest: Start, Planung, Dienste, Freigaben, Team, Chat-Verwaltung, Profil, Abmelden |
+| Grundform | Mobile-first (App-Breite 360–460px) | Desktop-first (Sidebar 260px), responsiv bis Mobile |
+| Mobiles Verhalten | nativ mobil, keine Anpassung nötig | Sidebar wird unter dem Breakpoint zu einer **Off-Canvas-Navigation**: Hamburger-Icon oben links öffnet sie als Overlay |
+| Tokens | `--cp-user-*` (Türkis/Rot, DM Sans) | `--cp-admin-*` (Navy/Teal/Gelb/Pink, Roboto Condensed + Alfa Slab One für Seitentitel) |
+| CSS-Wurzelklasse | `.cp-shell-user` | `.cp-shell-admin` |
+
+**Wichtig:** Die Admin-Startseite hat **keine eigene Kachel-Navigation** wie die
+User-Startseite — die Sidebar selbst trägt die Hauptaktionen. Die Admin-Inhaltsfläche
+zeigt direkt die Arbeit (Tabellen, Listen, Formulare), nicht zusätzliche Navigations-
+Buttons.
+
+**Mobile Sidebar-Mechanik (einzige zusätzliche v1-JS-Ausnahme neben dem Chat):**
+Unter dem Breakpoint kollabiert die Sidebar zu einem Hamburger-Icon in einer
+schmalen Topbar. Klick öffnet die Sidebar als Off-Canvas-Overlay (kleines
+Vanilla-JS: Klasse toggeln, Overlay schließt bei Klick daneben oder auf einen
+Menüpunkt). Kein Framework, keine Bibliothek.
+
+**Mandantenfähigkeit gilt für beide Shells unabhängig:** ein neues Team tauscht
+sowohl den `--cp-user-brand-*`- als auch den `--cp-admin-brand-*`-Block. Die beiden
+Themes werden nie gemischt — Komponenten in der User-Shell nutzen ausschließlich
+`--cp-user-*`, Komponenten in der Admin-Shell ausschließlich `--cp-admin-*`.
+
+---
+
+## Datenmodell (Regeln, konzeptionell)
+
+Jede Tabelle hat `team_id`. Namen/Felder sind Richtwerte, keine starre Vorgabe.
+
+- **team** — Mandant. Felder u. a.: Name, Brand-Tokens (Primär, Primär-stark, Akzent,
+ Akzent-stark, Schrift), Logo-Pfad.
+- **user** — `rolle` (admin | ehrenamt), Nutzername, Passwort-Hash, Anzeigename,
+ optionales Profilbild, optionale freiwillige Angaben.
+- **planungszeitraum** — Block von mind. 2 Monaten. `status`: in_planung | veroeffentlicht.
+- **einsatz** — gehört zu einem Planungszeitraum. Datum, Uhrzeit (Start/Ende), Art/Ort
+ (z. B. Tour Altstadt, Party, Sonderveranstaltung). Braucht **2 Hauptplätze + 1 Springerplatz**.
+- **verfuegbarkeit** — (user × einsatz) → kann | kann_nicht.
+- **zuteilung** — (user × einsatz) mit `platztyp` (haupt | springer) und
+ `status` (zugeteilt | abgesagt | offen | uebernommen).
+- **dokument** — Datei (PDF/Word/Bild). Felder: Titel (Pflicht), Uploader, `status`
+ (wartet_auf_freigabe | freigegeben | abgelehnt), `ablage` (aktuell | archiv).
+- **kanal** — pro Team zwei feste: `ankuendigungen`, `team`.
+- **nachricht** — Kanal, Autor, Text, Zeitstempel. **Nur Text.**
+
+---
+
+## Kern-Business-Logik (verbindlich)
+
+### Planungszyklus
+1. Admin legt Planungszeitraum (≥ 2 Monate) an und trägt die kuratierten Einsätze ein
+ (Freitags-Touren, Samstags-Einsätze/Partys, Sonderveranstaltungen).
+2. Status `in_planung` → Ehrenamtliche melden pro Einsatz **kann / kann nicht**.
+ Änderung der Verfügbarkeit nur solange `in_planung`.
+3. Admin baut den Plan **manuell**: pro Einsatz **2 Haupt + 1 Springer**. Die App zeigt
+ pro Person **Anzahl bisheriger Einsätze** und **letzten Einsatz** als Hilfe
+ (keine automatische Verteilung in v1).
+4. Bei zu wenigen Verfügbaren: Einsatz als **unterbesetzt** markieren.
+5. Admin **veröffentlicht** → alle sehen ihren persönlichen Dienstplan.
+
+### Absage & Übernahme
+- Sagt eine **Hauptperson** ab → **Springer rückt automatisch nach** (Status `uebernommen`).
+- Der frei gewordene **Springerplatz** wird `offen` → zum **„Übernehmen"** für alle.
+- Gibt es keinen Springer / sagt er auch ab → offener Platz für alle.
+- „Übernehmen" dient **nur dem Nachrücken**, nie der Erstverteilung.
+
+### Dokumente
+- Upload durch alle, aber Status startet **`wartet_auf_freigabe`** (nur Admin + Uploader sichtbar).
+- Admin gibt frei oder lehnt ab (optional Grund). Erst nach Freigabe für alle sichtbar.
+- Admin-Uploads gehen direkt durch.
+- Liste geteilt in **Aktuell** und **Archiv** (Verschieben macht der Admin).
+- **Foto-Regel:** nur ohne erkennbare Dritte bzw. mit deren Einverständnis.
+
+### Chat
+- Zwei Kanäle: **Ankündigungen** (nur Admin postet, alle lesen) und **Team** (alle posten).
+- Nur Text. Neue Nachrichten via Hintergrund-`fetch`-Polling (einzige v1-JS-Ausnahme).
+
+### Benachrichtigungen
+- **v1: nur in-App** (neuer Dienst, offener Dienst, neue Nachricht). Push/E-Mail = Future Log.
+
+---
+
+## Designsystem
+
+- Quelle der Wahrheit: **`tokens.css`** — enthält **zwei vollständig getrennte
+ Tokensätze**, `--cp-user-*` (User-Shell) und `--cp-admin-*` (Admin-Shell), je
+ mandantenfähig und mit hell + dunkel via `light-dark()`.
+- **User-Theme:** Türkis/Rot, Schrift DM Sans. Status-Mapping: Türkis =
+ offen/bestätigt/verfügbar · Rot = dringend/Vertretung · Neutral = Info/intern/erledigt.
+- **Admin-Theme:** Navy-Sidebar (in beiden Farbmodi gleich dunkel — bewusst, sie ist
+ das feste Markenelement), helle Arbeitsfläche, Teal für Aktionen/aktive Navigation,
+ Gelb nur für Sidebar-Icons, Pink nur als Markenakzent (nie als Fehlerfarbe — dafür
+ ist Rot da). Schrift: Roboto Condensed für UI/Tabellen/Formulare, Alfa Slab One nur
+ für kurze Seitentitel, Roboto Mono für Zahlen/IDs.
+- **Hell/Dunkel** folgt standardmäßig der Systemeinstellung. Zusätzlich gibt es einen
+ manuellen Umschalter (Topbar/Sidebar-Icon: System → Hell → Dunkel), serverseitig
+ ohne JS — ein POST setzt ein `theme`-Cookie, der Server schreibt
+ `data-theme="light|dark"` an `` (bei „System" kein Attribut).
+- **Nie feste Farben** im Komponenten-CSS — immer Tokens. **Nie Theme-Tokens
+ mischen** — eine Komponente innerhalb der User-Shell nutzt nur `--cp-user-*`,
+ innerhalb der Admin-Shell nur `--cp-admin-*`.
+- Erledigt: beide Komponenten-CSS sind frei von Farb-Literalen. Weiße Schrift auf
+ Voll-Marken-Flächen läuft über `--cp-user-on-brand` bzw. `--cp-admin-on-brand`
+ (themeunabhängig, pro Team überschreibbar).
+
+---
+
+## UI-Regeln
+
+### User-Shell
+- App-Breite 360–460px, viel Weißraum, klare Karten (Radius 12px, Innenabstand 16px),
+ keine verschachtelten Karten.
+- **Bottom-Navigation, 4 Punkte: Start · Termine · Team · Profil.** (Nicht „Kalender".)
+- Jede Ansicht hat **eine klare Hauptaktion** (Button oder FAB).
+- Buttons: Primär = Türkis/weiß · Dringend = Rot/weiß · Ghost = Fläche mit feinem
+ Rahmen. Kurze Beschriftungen: „Speichern", „Eintragen", „Übernehmen", „Details".
+
+### Admin-Shell
+- Desktop-Grundraster: Sidebar 260px + fließender Hauptbereich, Seitenabstand 32px.
+- **Sidebar-Punkte (fest, in dieser Reihenfolge):** Start · Planung · Dienste ·
+ Freigaben · Team · Chat-Verwaltung · Profil — Abmelden unten abgetrennt.
+- Aktiver Menüpunkt: Teal-Fläche, weißer Text, gelbes Icon. Inaktiv: gedimmtes Weiß,
+ gelbes Icon.
+- Cards/Panels: Radius 8–12px (max. 14px), Innenabstand 20–30px, sehr dezenter Schatten,
+ Panel-Header darf `--cp-admin-surface-soft` nutzen.
+- Tabellenkopf in Navy mit weißem, fettem Text. Statusspalten farbig **mit Text**, nie
+ nur über Farbe.
+- **Unter dem Breakpoint:** Sidebar kollabiert zu Hamburger-Icon, öffnet als
+ Off-Canvas-Overlay. Touch-Ziele bleiben ≥ 44px auch im Admin-Bereich.
+
+### Beide Shells
+- Formulare: Labels immer sichtbar über dem Feld, Felder ≥ 44px, kurze freundliche
+ Hilfetexte, Fehler klar und nicht nur über Farbe.
+
+## Texte / Copy
+
+- **Deutsch**, Satzanfang groß (sentence case), aktive Verben, keine Floskeln.
+- Eine Aktion heißt im ganzen Flow gleich (Button „Übernehmen" → Bestätigung „Übernommen").
+- Fehlermeldungen sagen, was passiert ist und wie es weitergeht — ohne Entschuldigung.
+
+---
+
+## Bewusst raus aus v1 (Out of Scope)
+
+Keine öffentlichen Seiten · keine Klient:innendaten · keine Bezahlfunktion ·
+kein Kalender-Sync (Terminübersicht = Liste) · keine Dateien/Bilder im Chat ·
+keine automatische Dienstverteilung · kein 1:1-Chat · kein Push/E-Mail.
+
+---
+
+## Vorgeschlagene Projektstruktur
+
+```
+checkpoint-ehrenamt/
+├── CLAUDE.md
+├── TASKS.md
+├── app/
+│ ├── __init__.py # App-Factory, DB-Init, Team-Scoping
+│ ├── models.py # SQLAlchemy-Modelle (alle mit team_id)
+│ ├── auth.py # Login, Logout, Admin-Anlage
+│ ├── routes/ # Blueprints: planung, dienste, dokumente, chat, profil
+│ ├── templates/
+│ │ ├── base_user.html # User-Shell: Bottom-Nav
+│ │ ├── base_admin.html # Admin-Shell: Sidebar + mobiles Off-Canvas
+│ │ └── ... # Teilansichten je Route
+│ └── static/
+│ ├── tokens.css # beide Theme-Tokensätze (--cp-user-*, --cp-admin-*)
+│ ├── components.css # Komponenten der User-Shell
+│ ├── components-admin.css # Komponenten der Admin-Shell
+│ └── sidebar.js # Hamburger/Off-Canvas-Logik (Vanilla, ~20 Zeilen)
+├── instance/ # SQLite-DB, Uploads (nicht im Git)
+└── requirements.txt
+```
+
+---
+
+## Arbeitsweise mit den Modellen (Continue)
+
+- **Mistral (Architect):** Planung, Design-Entscheidungen, Reviews. Nie zum Schreiben
+ von Produktivcode.
+- **Qwen (Coder):** Implementierung, Bugfixes, Inline-Edits (`Ctrl+I`). Immer ein
+ Stück nach dem anderen (Datenmodell → Service → Route → Template).
+- **Claude Code:** Scaffold, Mehrdateien-Arbeit, übergreifendes Verdrahten.
+- **Eine Aufgabe pro Session.** Immer `@file`-Kontext. Plan vor Code.
diff --git a/CheckPoint-Ehrenamt.png b/CheckPoint-Ehrenamt.png
new file mode 100644
index 0000000..ae81ca7
Binary files /dev/null and b/CheckPoint-Ehrenamt.png differ
diff --git a/DEPLOY.md b/DEPLOY.md
new file mode 100644
index 0000000..b16eb1f
--- /dev/null
+++ b/DEPLOY.md
@@ -0,0 +1,154 @@
+# DEPLOY.md — CheckPoint Ehrenamt auf einen EU-VPS
+
+> Ziel: App unter **gunicorn** hinter **nginx**, **HTTPS** per Let's Encrypt,
+> Secrets aus der Umgebung, **SQLite + Uploads persistent** in `instance/`,
+> tägliches **DB-Backup**. Befehle für die **Fish-Shell**, auf dem VPS auszuführen.
+>
+> Platzhalter überall ersetzen: Domain `checkpoint.example.org`, ggf. Pfad
+> `/opt/checkpoint`. Annahme: frischer Debian/Ubuntu-VPS in der EU, Domain zeigt
+> per A/AAAA-Record auf die VPS-IP, du hast einen sudo-fähigen Login.
+
+---
+
+## 1 · Systempakete
+
+```fish
+sudo apt update
+sudo apt install -y python3 python3-venv python3-pip nginx sqlite3 git certbot python3-certbot-nginx
+```
+
+## 2 · Dienst-Nutzer + Verzeichnis
+
+```fish
+sudo useradd --system --create-home --home-dir /opt/checkpoint --shell /usr/sbin/nologin checkpoint
+sudo mkdir -p /opt/checkpoint
+```
+
+## 3 · Code holen
+
+```fish
+sudo git clone /opt/checkpoint
+# oder per scp/rsync hochladen. Danach Eigentümer setzen:
+sudo chown -R checkpoint:checkpoint /opt/checkpoint
+```
+
+## 4 · Virtualenv + Abhängigkeiten
+
+```fish
+cd /opt/checkpoint
+sudo -u checkpoint python3 -m venv .venv
+sudo -u checkpoint .venv/bin/pip install --upgrade pip
+sudo -u checkpoint .venv/bin/pip install -r requirements.txt
+```
+
+## 5 · Secrets (`.env`)
+
+`.env` liegt unter `/opt/checkpoint/.env`, ist **nicht** im Git und wird von systemd
+geladen. Aus der Vorlage erzeugen und ein echtes Secret eintragen:
+
+```fish
+cd /opt/checkpoint
+sudo -u checkpoint cp .env.example .env
+# Secret erzeugen und einsetzen:
+set SECRET (sudo -u checkpoint .venv/bin/python -c "import secrets; print(secrets.token_urlsafe(48))")
+sudo -u checkpoint sed -i "s|^SECRET_KEY=.*|SECRET_KEY=$SECRET|" .env
+sudo chmod 600 .env
+```
+
+`.env` sollte enthalten: `CP_ENV=production` und `SECRET_KEY=`.
+
+## 6 · Datenbank anlegen, Team seeden, Admin erstellen
+
+```fish
+cd /opt/checkpoint
+# DB-Tabellen + erstes Team (CheckPoint) + die zwei Chat-Kanäle (idempotent):
+sudo -u checkpoint --preserve-env=CP_ENV env CP_ENV=production .venv/bin/python seed.py
+# Ersten Admin anlegen (Nutzername Anzeigename Passwort):
+sudo -u checkpoint env CP_ENV=production SECRET_KEY=$SECRET \
+ .venv/bin/flask --app wsgi create-admin admin "Team-Leitung" "EinStarkesPasswort"
+```
+
+> Hinweis: DB (`instance/checkpoint.sqlite`) und Uploads (`instance/uploads/`)
+> liegen unter `/opt/checkpoint/instance/` und bleiben über Neustarts/Updates
+> erhalten. Nur dieses Verzeichnis ist schreibbar (systemd `ReadWritePaths`).
+
+## 7 · gunicorn als systemd-Dienst
+
+```fish
+sudo cp /opt/checkpoint/deploy/checkpoint.service /etc/systemd/system/checkpoint.service
+sudo systemctl daemon-reload
+sudo systemctl enable --now checkpoint
+sudo systemctl status checkpoint --no-pager
+```
+
+Test (lokal auf dem VPS): `curl -I http://127.0.0.1:8000` sollte eine Antwort
+liefern (Redirect zur Anmeldung ist ok).
+
+## 8 · nginx als Reverse Proxy
+
+```fish
+sudo cp /opt/checkpoint/deploy/nginx-checkpoint.conf /etc/nginx/sites-available/checkpoint
+sudo sed -i "s/checkpoint.example.org/DEINE-DOMAIN/" /etc/nginx/sites-available/checkpoint
+sudo ln -sf /etc/nginx/sites-available/checkpoint /etc/nginx/sites-enabled/checkpoint
+sudo rm -f /etc/nginx/sites-enabled/default
+sudo nginx -t
+sudo systemctl reload nginx
+```
+
+`client_max_body_size 12m` ist gesetzt (passt zum 10-MB-Upload-Limit der App).
+Statische Dateien (CSS/JS/Schriften) liefert nginx direkt; Uploads/Dokumente
+laufen bewusst **nicht** über nginx, sondern zugriffsgeschützt über die App.
+
+## 9 · HTTPS (Let's Encrypt)
+
+```fish
+sudo certbot --nginx -d DEINE-DOMAIN
+```
+
+certbot ergänzt den 443-Block, richtet die Weiterleitung 80→443 ein und erneuert
+automatisch (Timer `certbot.timer`). Erst **nach** HTTPS greifen die `Secure`-Cookies
+sinnvoll — `CP_ENV=production` setzt sie bereits voraus.
+
+## 10 · Tägliches DB-Backup
+
+```fish
+sudo chmod +x /opt/checkpoint/deploy/backup-db.sh
+sudo crontab -u checkpoint -l 2>/dev/null | cat - <(echo "30 3 * * * /opt/checkpoint/deploy/backup-db.sh") | sudo crontab -u checkpoint -
+```
+
+Sichert täglich 03:30 nach `/opt/checkpoint/backups/` (Online-`.backup`, also auch
+im laufenden Betrieb konsistent) und hält 14 Tage vor. Backups liegen außerhalb
+von Git. Tipp: zusätzlich regelmäßig vom VPS wegkopieren (anderer Ort).
+
+## 11 · Updates einspielen
+
+```fish
+cd /opt/checkpoint
+sudo -u checkpoint git pull
+sudo -u checkpoint .venv/bin/pip install -r requirements.txt
+sudo systemctl restart checkpoint
+```
+
+Neue Tabellen legt die App beim Start via `create_all()` an. **Achtung:** echte
+Schema-*Änderungen* an bestehenden Tabellen macht SQLAlchemy so nicht — dafür
+vorher Backup ziehen und ggf. manuell migrieren.
+
+---
+
+## Checkliste (Deliverables Session 15)
+
+- [ ] App läuft unter gunicorn hinter nginx → Schritte 7–8
+- [ ] HTTPS aktiv → Schritt 9
+- [ ] Secrets über Umgebungsvariablen, nicht im Code → Schritt 5 (`.env`, systemd `EnvironmentFile`)
+- [ ] DB + Uploads persistent, einfaches DB-Backup eingerichtet → `instance/` + Schritt 10
+
+## Fehlersuche
+
+- Dienst-Logs: `sudo journalctl -u checkpoint -e`
+- nginx-Logs: `sudo tail -f /var/log/nginx/error.log`
+- 413 beim Upload → `client_max_body_size` in nginx prüfen (muss ≥ 10 MB sein).
+- 500 direkt nach Deploy → meist fehlendes/falsches `SECRET_KEY` in `.env`
+ (bei `CP_ENV=production` bricht die App ohne `SECRET_KEY` bewusst ab).
+- „database is locked" → sollte dank WAL + busy_timeout nicht auftreten; sonst
+ Worker-Zahl in `gunicorn.conf.py` senken.
+```
diff --git a/Konzept-CheckPoint-Ehrenamt.md b/Konzept-CheckPoint-Ehrenamt.md
new file mode 100644
index 0000000..6102b28
--- /dev/null
+++ b/Konzept-CheckPoint-Ehrenamt.md
@@ -0,0 +1,270 @@
+# Konzeptdokument — CheckPoint Ehrenamt App
+
+> Interne Orga-App für das Vor-Ort-Team Prävention der AIDS-Hilfe.
+> Ersetzt schrittweise die WhatsApp/Signal-Zettelwirtschaft.
+> Stand: Konzeptphase (vor dem ersten Code).
+
+---
+
+## 1 · Ziel & Kernidee
+
+Eine schlanke, mobile-first Web-App, mit der das Ehrenamtsteam seine Einsätze
+organisiert, ohne auf WhatsApp/Signal angewiesen zu sein.
+
+**Das Herzstück ist die Terminabstimmung und Dienstverteilung** — wer macht wann
+was. Alles andere (Chat, Dokumente) ordnet sich diesem Zweck unter.
+
+**Leitprinzip:** Lesbarkeit und Orientierung vor Funktionsfülle. Die App muss für
+17 Ehrenamtliche im Alltag spürbar einfacher sein als eine WhatsApp-Umfrage.
+
+**Übergang:** Ziel ist, WhatsApp/Signal als Orga-Werkzeug komplett zu ersetzen.
+In der Anfangszeit darf parallel weiter dort kommuniziert werden — die App muss
+aber eigenständig funktionieren und darf wichtige Infos nicht „verlieren", nur
+weil jemand noch nicht umgestiegen ist.
+
+---
+
+## 2 · Nutzer & Rollen
+
+| Rolle | Wer | Rechte |
+|---|---|---|
+| **Admin** | Koordination (du) | Legt Planungszeiträume und Termine an, baut den Dienstplan, vergibt Logins, gibt Dokumente/Fotos frei, postet Ankündigungen, verwaltet das Team. |
+| **Ehrenamtliche:r** | die 17 Teammitglieder | Meldet Verfügbarkeit, sieht den eigenen Dienstplan, übernimmt freie Dienste, lädt Dokumente/Fotos hoch (Freigabe nötig), chattet im Team-Kanal. |
+
+Für v1 reichen genau diese zwei Rollen. Weitere Abstufungen (z. B. „Co-Admin")
+sind möglich, aber nicht nötig — siehe Future Log.
+
+---
+
+## 3 · Mandantenfähigkeit (von Tag 1)
+
+Die App wird **von Anfang an mandantenfähig** gebaut (Entscheidung A1): mehrere
+Teams parallel, je eigenes Corporate Design und eigene, sauber getrennte Inhalte.
+
+**Konsequenzen fürs Datenmodell:**
+- Es gibt eine zentrale Einheit **`team`** (= Mandant).
+- **Jeder** Datensatz (Nutzer, Termin, Dienst, Dokument, Nachricht, …) hängt an
+ genau einem `team`.
+- Kein Datensatz ist über Teamgrenzen hinweg sichtbar.
+- Das Corporate Design (Farben, Logo, Schrift) wird **pro Team** über
+ CSS-Variablen-Tokens gesetzt. Das bestehende CheckPoint-CD ist der erste Mandant.
+
+Das CheckPoint-Team ist also der erste von perspektivisch mehreren Teams. Diese
+Trennung wird im ersten Datenmodell verankert, damit später kein Umbau nötig ist.
+
+---
+
+## 4 · Technischer Rahmen (feste Vorgaben)
+
+| Bereich | Entscheidung |
+|---|---|
+| Backend | **Flask** (Python) |
+| Templating | **Jinja2**, server-gerendertes HTML |
+| Architektur | klassische Multi-Page-App (server-rendered) |
+| CSS | **selbst gebaut**, kein CSS-Framework (Basis: vorhandenes Token-System) |
+| JavaScript | **so wenig wie möglich, nur Vanilla**, kein JS-Framework |
+| Hosting | **eigener VPS in der EU** |
+| Plattform | mobile-first Web-App, **kein App Store**, „zum Homescreen hinzufügen" möglich |
+
+**Chat-Sonderfall (Variante 2):** Alles ist server-gerendert und JS-frei —
+**außer** der Chat holt sich neue Nachrichten per kleinem `fetch`-Polling im
+Hintergrund (wenige Zeilen Vanilla-JS). So fühlt sich der Chat lebendig an, ohne
+WebSockets oder Framework. Polls, „Übernehmen", Formulare und die Terminübersicht
+laufen komplett ohne JavaScript als normale Formular-Abschicke.
+
+---
+
+## 5 · Auth & Onboarding
+
+- **Logins werden ausschließlich vom Admin vergeben.** Keine Selbstregistrierung.
+- **v1:** Nutzername + Passwort, vom Admin angelegt.
+- Passwörter werden gehasht gespeichert (nie im Klartext).
+- Neue Ehrenamtliche bekommen ihre Zugangsdaten vom Admin und können sich danach
+ einloggen.
+- **Future Log:** E-Mail + Einmal-Code (Magic Link) als komfortablere Alternative —
+ Entscheidung dazu trifft das Team später gemeinsam.
+
+---
+
+## 6 · Datenmodell — Entscheidungen
+
+Konzeptionelle Sicht (keine Implementierungsdetails). Jede Einheit gehört zu
+einem `team`.
+
+- **team** — der Mandant. Trägt das Corporate Design (Tokens, Logo) und bündelt alle Inhalte.
+- **user** — Ehrenamtliche:r oder Admin. Feld `rolle`, Login-Daten, Anzeigename, optionales **Profilbild** und optionale freiwillige Angaben.
+- **planungszeitraum** — ein Block von mindestens 2 Monaten. Status: *in Planung* (Verfügbarkeit wird gesammelt) → *veröffentlicht* (Plan steht).
+- **einsatz** (Termin/Dienst) — gehört zu einem Planungszeitraum. Hat Datum, Uhrzeit, Ort/Art (z. B. Tour Altstadt, Party, Sonderveranstaltung). Braucht **2 Hauptplätze + 1 Springerplatz**.
+- **verfuegbarkeit** — Rückmeldung einer Person zu einem Einsatz: *kann / kann nicht*.
+- **zuteilung** — verbindet `user` mit `einsatz` und einem Platztyp (*Haupt* oder *Springer*) und Status (*zugeteilt / abgesagt / übernommen*).
+- **dokument** — Datei (Dokument oder Bild) mit Titel, Uploader, Status (*wartet auf Freigabe / freigegeben / abgelehnt*) und Ablage (*aktuell / Archiv*).
+- **kanal** — Chat-Kanal (für v1 zwei feste pro Team: *Ankündigungen*, *Team*).
+- **nachricht** — Textnachricht in einem Kanal, mit Autor und Zeitstempel.
+
+---
+
+## 7 · Kernflows
+
+### 7.1 Planungszyklus (Admin)
+
+1. Admin legt einen **Planungszeitraum** an (mind. 2 Monate).
+2. Admin trägt die relevanten **Einsätze** ein: regelmäßige **Freitags-Touren**
+ (Alt-/Neustadt), **Samstags-Einsätze** (oft Partys) und kuratierte
+ **Sonderveranstaltungen**. Die Termine sind nicht fix vorgegeben — der Admin
+ wählt sie pro Zeitraum aus.
+3. Der Zeitraum geht in den Status *in Planung* → Verfügbarkeitsabfrage startet.
+
+### 7.2 Verfügbarkeit melden (Ehrenamtliche)
+
+- Jede:r sieht die Liste der Einsätze des aktuellen Zeitraums und meldet pro Datum
+ **kann / kann nicht**.
+- Änderung der Verfügbarkeit ist möglich, **solange der Zeitraum *in Planung* ist**.
+ Nach Veröffentlichung läuft alles über Absage/Übernahme (siehe 7.4).
+
+### 7.3 Dienstplan bauen (Admin)
+
+- Pro Einsatz: **2 Hauptpersonen + 1 Springer**.
+- Der Admin verteilt **manuell** und sieht dabei pro Person Hilfsinfos: wie oft
+ schon eingeteilt, wann zuletzt im Einsatz — damit niemand zu oft hintereinander
+ dran ist und die Last fair bleibt.
+- Wenn sich für einen Einsatz **zu wenige** verfügbar gemeldet haben, zeigt die App
+ ihn als **unterbesetzt** an, damit der Admin nachsteuern kann.
+- Ist der Plan fertig, **veröffentlicht** der Admin den Zeitraum. Ab jetzt sehen
+ alle ihren persönlichen Dienstplan.
+
+> **Bewusste Entscheidung für v1:** Die Zuteilung ist manuell (Admin entscheidet),
+> die App unterstützt nur mit Anzeige. Automatische Fairness-Verteilung ist Future Log.
+
+### 7.4 Absage & Übernahme
+
+- Sagt eine **Hauptperson** ab, **rückt der Springer automatisch nach**. Der Einsatz
+ bleibt voll besetzt.
+- Der dadurch frei werdende **Springerplatz** wird **offen zum „Übernehmen"** — wer
+ zuerst klickt, bekommt ihn.
+- Sagt auch der Springer ab (oder es gibt keinen), wird der offene Platz für alle
+ zur Übernahme angeboten.
+- Der Self-Service-„Übernehmen"-Button dient **ausschließlich dem Nachrücken** bei
+ Ausfall — nicht der Erstverteilung.
+
+### 7.7 Profil
+
+- Bewusst **minimal** in v1.
+- Jede:r kann ein **Profilbild** hochladen und **freiwillig** ein paar Angaben zu
+ sich machen. Welche Felder das genau sind, wird noch festgelegt (siehe offene
+ Notiz unten) — nichts davon ist Pflicht.
+- Login-/Passwort-Verwaltung kann hier später andocken.
+
+### 7.5 Chat
+
+- **Zwei feste Kanäle pro Team:**
+ - **Ankündigungen / Orga** — nur der **Admin** postet, alle lesen (z. B. Hinweis
+ auf neues Protokoll, wichtige Orga-Infos).
+ - **Team** — alle posten frei und reden untereinander.
+- **Nur Text** (keine Bilder/Dateien im Chat — die laufen über die Dokumentenablage).
+- Neue Nachrichten erscheinen per Hintergrund-Polling (Variante 2).
+- **Future Log:** 1:1-Direktnachrichten.
+
+### 7.6 Dokumente & Fotos
+
+- **Inhalte:** Protokolle (PDF/Word) und **Bilder** (z. B. Tour-Fotos). Alles andere
+ bleibt bei WhatsApp.
+- **Upload durch alle**, aber mit **Freigabe-Pflicht**:
+ 1. User lädt hoch (mit kurzem Pflicht-Titel „was ist das") → Status *wartet auf Freigabe*, sichtbar nur für Admin und den Uploader.
+ 2. Admin sieht eine **„Freigaben"-Ansicht** → **Freigeben** oder **Ablehnen** (optional kurzer Grund).
+ 3. Erst nach Freigabe erscheint das Dokument in der allgemeinen Liste.
+- **Admin-Uploads** gehen direkt durch (keine Selbst-Freigabe).
+- **Ablage:** eine einfache Liste, geteilt in **Aktuell** und **Archiv**. Verschieben
+ ins Archiv macht der Admin manuell.
+- **Inhaltsregel (Foto-Schutz):** Hochgeladene Fotos nur **ohne erkennbare Dritte**
+ bzw. nur mit deren Einverständnis. Der Freigabe-Schritt sichert das zusätzlich ab.
+
+---
+
+## 8 · Benachrichtigungen
+
+- **v1: nur in-App** — z. B. „du hast einen neuen Dienst", „neuer offener Dienst",
+ „neue Nachricht". Dezent, nicht nervig.
+- **Push und/oder E-Mail: Future Log.** Push ist ohne App Store eingeschränkt
+ (v. a. iPhone) und würde v1 überladen.
+
+---
+
+## 9 · Design & Corporate Design
+
+- Mobile-first, App-Breite ca. 360–460px, viel Weißraum, klare Karten, runde
+ Aktionsflächen, Bottom-Navigation (**Start, Termine, Team, Profil**).
+- Türkis als Hauptfarbe (Orientierung/Bestätigung), Rot sparsam (dringend,
+ Vertretung gesucht). Schrift: DM Sans.
+- **Pro Team austauschbar** über CSS-Tokens — das CheckPoint-CD ist der erste
+ Mandant; weitere Teams bekommen ihre eigenen Tokens/Logos.
+- Das vorhandene Mockup ist eine **erste Designidee**, keine verbindliche Vorlage.
+ Das finale Designsystem wird in Phase 1, Schritt 3 ausgearbeitet.
+
+---
+
+## 10 · Edge Cases & Regeln
+
+- **Einsatz unterbesetzt:** zu wenige Verfügbare → sichtbarer *unterbesetzt*-Status für den Admin.
+- **Springer fällt auch aus:** Platz wird für alle offen.
+- **Verfügbarkeit nachträglich ändern:** nur solange *in Planung*; danach über Absage/Übernahme.
+- **Memes/Unpassendes:** durch Freigabe-Pflicht abgefangen.
+- **Übergangszeit:** App funktioniert eigenständig, auch wenn noch nicht alle umgestiegen sind.
+
+---
+
+## 11 · Datenschutz
+
+- **Hosting auf eigenem VPS in der EU.**
+- **Keine Klient:innendaten** in der App — es geht ausschließlich um die interne
+ Team-Orga.
+- **Datensparsamkeit:** nur, was für die Orga nötig ist (Name, Login, Verfügbarkeit,
+ Zuteilung).
+- Passwörter gehasht. Inhalte streng pro Team getrennt.
+- Foto-Inhaltsregel (siehe 7.6) wegen des sensiblen AIDS-Hilfe-Kontexts.
+
+> Hinweis: Für den realen Betrieb empfiehlt sich später eine knappe
+> Datenschutzerklärung / ein Verarbeitungsverzeichnis in Abstimmung mit der
+> AIDS-Hilfe. Das ist kein Entwicklungsthema, aber fürs Konzept vermerkt.
+
+---
+
+## 12 · Bewusst raus aus v1 (Out of Scope)
+
+- Keine öffentlichen Seiten (rein internes Tool).
+- Keine Klient:innendaten.
+- Keine Bezahlfunktion.
+- Kein Kalender-Sync (die Terminübersicht ist eine Liste, kein Kalender).
+- Keine Bilder/Dateien im Chat.
+- Keine automatische Dienstverteilung.
+
+---
+
+## 13 · Future Log (spätere Versionen)
+
+- **1:1-Direktnachrichten** im Chat.
+- **Push-Benachrichtigungen** und/oder **E-Mail-Benachrichtigungen**.
+- **E-Mail + Einmal-Code (Magic Link)** als Login-Alternative.
+- **Automatische Fairness-Verteilung** der Dienste (Vorschläge statt nur Anzeige).
+- Weitere Rollen (z. B. Co-Admin).
+- Laptop-Spiegelung des Setups / Desktop-Fallback über LAN (Entwicklungsumgebung).
+
+---
+
+## 14 · Geklärte Restpunkte
+
+Alle zuvor offenen Punkte sind entschieden:
+
+1. **Bottom-Nav:** zweiter Punkt heißt **„Termine"** (Liste der Einsätze), nicht „Kalender". ✔
+2. **Fairness-Anzeige:** App zeigt pro Person *Anzahl Einsätze* und *letzter Einsatz*; Verteilung bleibt manuell. ✔
+3. **Dokument-Dateitypen:** PDF, Word und Bilder (JPG/PNG) — ausreichend. ✔
+4. **Profil:** minimal in v1 — Profilbild + optionale freiwillige Angaben (siehe 7.7). ✔
+
+**Eine verbleibende Notiz (kein Blocker):** Welche freiwilligen Profil-Felder genau
+angeboten werden, überlegst du dir noch. Lässt sich jederzeit ergänzen, ohne das
+Konzept zu verändern.
+
+---
+
+**Damit ist das Konzept fix.** Nächste Schritte: **Schritt 3 (Designsystem)** und
+danach **Schritt 4 (die drei Projektdateien: CLAUDE.md, TASKS.md,
+Stakeholder-Dokument)**. Erst danach öffnest du VS Code.
diff --git a/Stakeholder-CheckPoint-Ehrenamt.md b/Stakeholder-CheckPoint-Ehrenamt.md
new file mode 100644
index 0000000..d65b491
--- /dev/null
+++ b/Stakeholder-CheckPoint-Ehrenamt.md
@@ -0,0 +1,129 @@
+# CheckPoint Ehrenamt — eine App für unser Vor-Ort-Team
+
+*Zum Vorstellen im Team und bei der AIDS-Hilfe. Ohne Technik-Kauderwelsch.*
+
+---
+
+## Worum es geht
+
+Unser Präventionsteam organisiert sich bisher über WhatsApp: Terminumfragen,
+Absprachen in der Teamsitzung, Protokolle und allgemeine Kommunikation laufen alle
+dort. Das hat lange funktioniert — wird aber zunehmend unübersichtlich. Und seit
+ein Teil des Teams von WhatsApp zu Signal gewechselt ist, sind wir auf zwei Kanälen
+unterwegs. Umfragen, Infos und Dokumente erreichen nicht mehr alle am selben Ort.
+
+**Die Idee:** eine einzige, einfache App nur für unser Team. Sie bündelt das, was
+heute über zwei Messenger verstreut ist — vor allem die Dienstplanung — an einem
+verlässlichen Ort.
+
+---
+
+## Was die App können wird
+
+### 1 · Termine abstimmen und Dienste verteilen (das Herzstück)
+
+So wie heute, nur ohne Zettelwirtschaft:
+
+- Die Koordination legt einen Planungszeitraum an (mindestens zwei Monate) und trägt
+ die Einsätze ein — die regelmäßigen Freitags-Touren, die Samstags-Einsätze und
+ besondere Veranstaltungen.
+- Jede:r meldet pro Termin einfach **„kann ich" oder „kann ich nicht"**.
+- Die Koordination sieht auf einen Blick, wer verfügbar ist, und verteilt die Dienste
+ — immer **zwei Personen plus eine Springer-Person** pro Einsatz. Die App hilft
+ dabei, niemanden zu oft hintereinander einzuteilen und die Einsätze fair zu verteilen.
+
+**Warum so:** Das ist der Punkt, der heute am meisten Aufwand macht. Eine WhatsApp-
+Umfrage zu zwei Monaten ist mühsam auszuwerten. Die App nimmt diese Arbeit ab und
+macht für alle transparent, wer wann eingeteilt ist.
+
+### 2 · Einspringen, wenn jemand ausfällt
+
+Wird jemand krank oder muss absagen, **rückt automatisch die Springer-Person nach**.
+Der dann offene Platz erscheint für alle sichtbar zum **Übernehmen** — wer mag, klickt
+und übernimmt.
+
+**Warum so:** Kurzfristige Ausfälle sind der Stressmoment. Statt hektischer
+Nachrichten in der Gruppe ist sofort klar, dass ein Platz frei ist und wer ihn füllt.
+
+### 3 · Eine Terminübersicht
+
+Jede:r sieht die eigenen kommenden Einsätze als übersichtliche Liste — mit Datum,
+Uhrzeit und ob als feste Person oder als Springer.
+
+**Warum so:** Kein Suchen mehr im Chatverlauf. Die eigenen Dienste stehen klar an
+einem Ort. (Bewusst eine Liste, kein voller Kalender — das reicht für unseren Zweck
+und bleibt einfach.)
+
+### 4 · Ein Team-Chat mit zwei Bereichen
+
+- **Ankündigungen:** Hier postet nur die Koordination wichtige Infos. Alle lesen mit.
+- **Team:** Hier können alle frei miteinander schreiben.
+
+**Warum so:** Wichtige Orga-Infos gehen heute im Plauder-Verlauf unter. Die Trennung
+sorgt dafür, dass Ankündigungen nicht verloren gehen — der lockere Austausch bleibt
+trotzdem möglich.
+
+### 5 · Dokumente und Fotos an einem Ort
+
+Protokolle und andere Dokumente liegen geordnet in der App — geteilt in **Aktuell**
+und **Archiv**. Auch Fotos von Touren können hochgeladen werden. Damit nichts
+Unpassendes landet, **gibt die Koordination hochgeladene Inhalte vorher frei**.
+
+**Warum so:** Protokolle im Chat sind nach zwei Wochen nicht mehr auffindbar. Hier
+bleiben sie geordnet. Die Freigabe hält die Ablage sauber, ohne dass die Koordination
+alles selbst hochladen muss.
+
+### 6 · Ein einfaches Profil
+
+Jede:r kann ein Profilbild hinterlegen und freiwillig ein paar Angaben machen — nichts
+davon ist Pflicht.
+
+---
+
+## Wie man die App benutzt
+
+Die App läuft **im Browser auf dem Handy** — kein Download aus einem App-Store nötig.
+Man kann sie sich wie eine normale App auf den Startbildschirm legen. Sie funktioniert
+auch auf hellen und dunklen Geräte-Einstellungen.
+
+**Anmeldung:** Es gibt keine offene Registrierung. Die Koordination legt für jede
+Person einen Zugang an. So ist sichergestellt, dass nur das Team Zugriff hat.
+
+---
+
+## Datenschutz
+
+Das ist uns bei einer Anwendung im Umfeld der AIDS-Hilfe besonders wichtig:
+
+- Die App speichert **nur Team-interne Orga-Daten** (wer ist wann im Einsatz, Termine,
+ Dokumente, Nachrichten). **Keine Daten von Klient:innen.**
+- Sie läuft auf einem **eigenen Server in der EU** — nicht bei einem großen
+ US-Anbieter.
+- Zugänge gibt es nur über die Koordination, Passwörter werden sicher gespeichert.
+- Für hochgeladene Fotos gilt: nur ohne erkennbare unbeteiligte Personen
+ beziehungsweise nur mit deren Einverständnis. Die Freigabe sichert das zusätzlich ab.
+
+---
+
+## Was bewusst (noch) nicht kommt
+
+Damit die erste Version übersichtlich und schnell nutzbar bleibt, lassen wir zunächst
+weg: Push-Benachrichtigungen aufs Handy, E-Mail-Benachrichtigungen, private
+Einzelchats und einen vollwertigen Kalender. Diese Dinge können später ergänzt
+werden, wenn sich die App im Alltag bewährt hat.
+
+---
+
+## Und für andere Teams?
+
+Die App wird von Anfang an so gebaut, dass auch **andere ehrenamtliche Teams** sie
+nutzen könnten — jeweils mit eigenem Logo, eigenen Farben und streng getrennten
+Daten. Für uns ändert das nichts; es hält nur die Tür für die Zukunft offen.
+
+---
+
+## Kurz gesagt
+
+Aus zwei Messengern und vielen Umfragen wird **ein Ort**: für Termine, Dienste,
+Dokumente und Absprachen. Weniger Suchen, weniger Aufwand bei der Planung, und für
+alle klar, wer wann wo im Einsatz ist.
diff --git a/TASKS-backup.md b/TASKS-backup.md
new file mode 100644
index 0000000..cc9520e
--- /dev/null
+++ b/TASKS-backup.md
@@ -0,0 +1,385 @@
+# TASKS.md — Build-Checkliste CheckPoint Ehrenamt App
+
+> Eine Aufgabe pro Session. Nach Abhängigkeit geordnet — von oben nach unten abarbeiten.
+> Pro Session: **Tool**, **Ziel**, **Session-Prompt** (zum Einfügen), **Deliverables**.
+> Nach jeder Session: alle Häkchen setzen, committen, erst dann die nächste starten.
+>
+> Tool-Legende: 🛠 Claude Code · 💻 Continue + Qwen · 🏛 Continue + Mistral
+> Modell wechseln: `llama-switch` → [1] Architect / [2] Coder
+
+---
+
+## Session 1 · Scaffold & Projektgerüst 🛠
+
+**Ziel:** Lauffähiges Flask-Grundgerüst mit App-Factory, SQLite, Basis-Layout,
+Static-Einbindung und Team-Scoping-Stub.
+
+**Prompt:**
+```
+Lies CLAUDE.md. Erstelle das Projektgerüst gemäß der vorgeschlagenen Struktur:
+Flask-App-Factory, SQLAlchemy mit SQLite (instance/), requirements.txt,
+ein base.html mit eingebundener tokens.css und components.css, sowie ein
+leeres Team-Scoping (Helper, der das aktive Team aus der Session liest).
+Zeig mir zuerst den Plan und die Dateiliste, bevor du schreibst.
+```
+
+**Deliverables:**
+- [x] App startet lokal (`flask run`) und zeigt eine leere Startseite
+- [x] `tokens.css` + `components.css` in `base.html` eingebunden
+- [x] SQLite initialisiert, `instance/` in `.gitignore`
+- [x] Team-Scoping-Helper vorhanden (noch ohne echte Logik)
+- [x] `requirements.txt` minimal gehalten
+
+---
+
+## Session 2 · Datenmodell & Mandanten 💻
+
+**Ziel:** Alle Modelle aus CLAUDE.md, jedes mit `team_id`. Seed für das erste Team
+(CheckPoint) inkl. Brand-Tokens und den zwei Chat-Kanälen.
+
+**Prompt:**
+```
+@file app/models.py
+@file CLAUDE.md
+
+Erst den Ansatz erklären, dann implementieren — ein Modell nach dem anderen.
+Baue die Modelle: team, user, planungszeitraum, einsatz, verfuegbarkeit,
+zuteilung, dokument, kanal, nachricht. Jedes mit team_id. Danach ein Seed-Skript,
+das das CheckPoint-Team mit Brand-Tokens und den Kanälen "ankuendigungen" und
+"team" anlegt. Halte dich an die Feldvorgaben in CLAUDE.md.
+```
+
+**Deliverables:**
+- [x] Alle Modelle vorhanden, jedes mit `team_id`
+- [x] Beziehungen sauber (Einsatz↔Planungszeitraum, Zuteilung↔User/Einsatz)
+- [x] Seed legt CheckPoint-Team + 2 Kanäle an
+- [x] DB lässt sich anlegen, Seed läuft ohne Fehler
+
+---
+
+## Session 3 · Auth & Admin-Anlage 💻
+
+**Ziel:** Login (Nutzername + Passwort, gehasht), Session, Logout, Team-Scoping aktiv,
+Admin kann Nutzer anlegen. Keine Selbstregistrierung.
+
+**Prompt:**
+```
+@file app/auth.py
+@file app/models.py
+@file CLAUDE.md
+
+Ansatz zuerst. Baue: Login mit Nutzername+Passwort (werkzeug-Hashing), Session,
+Logout. Aktiviere das Team-Scoping (aktives Team aus der Session). Admin-geschützte
+Route zum Anlegen neuer Nutzer (Rolle wählbar). Geschützte Routen leiten ohne
+Login zur Anmeldung um. Ein Stück nach dem anderen.
+```
+
+**Deliverables:**
+- [x] Login/Logout funktioniert, Passwörter gehasht
+- [x] Geschützte Routen ohne Login → Redirect
+- [x] Admin kann Nutzer mit Rolle anlegen
+- [x] Alle Queries laufen team-gescoped
+- [ ] 🏛 Mini-Review (Mistral): Auth gegen CLAUDE.md prüfen — als Liste, nicht umschreiben
+
+---
+
+## Session 4 · Base-Layout, Theming & Dark-Mode-Fix 💻
+
+**Ziel:** base.html final (Topbar, Bottom-Nav Start/Termine/Team/Profil, FAB-Slot),
+Brand-Tokens pro Team in den `` injizieren, Mockup-CSS auf Tokens umstellen.
+
+**Prompt:**
+```
+@file app/templates/base.html
+@file app/static/components.css
+@file app/static/tokens.css
+@file CLAUDE.md
+
+Ansatz zuerst. 1) base.html mit Topbar (Logo, Glocke, Avatar) und Bottom-Nav
+(Start, Termine, Team, Profil). 2) Brand-Tokens des aktiven Teams serverseitig
+in den schreiben. 3) Im components.css alle fest verdrahteten Weißwerte
+(Topbar, Bottom-Nav, .cp-page-Verlauf) auf Tokens umstellen, damit der Dunkelmodus
+greift. Erst die Token-Umstellung erklären, dann umsetzen.
+```
+
+**Deliverables:**
+- [x] base.html mit korrekter Bottom-Nav (4 Punkte, „Termine")
+- [x] Brand-Tokens kommen pro Team aus der DB in den ``
+- [x] Keine festen Weiß-/Schwarzwerte mehr im components.css (weiße Button-Schrift jetzt über Token `--cp-on-brand`, themeunabhängig)
+- [x] Hell- und Dunkelmodus optisch sauber (System-Umschaltung testen)
+
+---
+
+## Session 5 · Planungszeitraum & Einsätze (Admin) 💻
+
+**Ziel:** Admin legt Planungszeiträume an und trägt Einsätze ein (Datum, Zeit, Art).
+
+**Prompt:**
+```
+@file app/routes/planung.py
+@file app/models.py
+@file CLAUDE.md
+
+Ansatz zuerst. Admin-Ansichten: Planungszeitraum anlegen (Status in_planung) und
+darin Einsätze hinzufügen/bearbeiten/löschen (Datum, Start/Ende, Art/Ort, je 2 Haupt-
++ 1 Springerplatz). Reine POST-Formulare, kein JS. Liste der Einsätze eines Zeitraums.
+```
+
+**Deliverables:**
+- [x] Admin kann Zeitraum anlegen
+- [x] Admin kann Einsätze anlegen/bearbeiten/löschen
+- [x] Einsatz kennt seine Platzstruktur (2 + Springer)
+- [x] Alles team-gescoped, ohne JavaScript
+
+---
+
+## Session 6 · Verfügbarkeit melden (Ehrenamtliche) 💻
+
+**Ziel:** Ehrenamtliche melden pro Einsatz „kann / kann nicht", solange `in_planung`.
+
+**Prompt:**
+```
+@file app/routes/planung.py
+@file app/models.py
+@file CLAUDE.md
+
+Ansatz zuerst. Ansicht für Ehrenamtliche: Liste der Einsätze im laufenden Zeitraum
+mit Umschaltung kann/kann_nicht pro Einsatz (POST-Formular). Änderung nur bei Status
+in_planung. Übersichtlich für mobile.
+```
+
+**Deliverables:**
+- [x] Ehrenamtliche sehen die Einsätze des aktuellen Zeitraums
+- [x] kann/kann_nicht wird gespeichert und ist änderbar (nur in_planung)
+- [x] Nach Veröffentlichung gesperrt
+- [x] Mobile sauber bedienbar (44px-Ziele)
+
+---
+
+## Session 7 · Dienstplan bauen (Admin) 💻
+
+**Ziel:** Admin verteilt manuell 2 Haupt + 1 Springer pro Einsatz, mit Fairness-Anzeige.
+
+**Prompt:**
+```
+@file app/routes/dienste.py
+@file app/models.py
+@file CLAUDE.md
+
+Ansatz zuerst. Admin-Ansicht pro Einsatz: verfügbare Personen auswählen und auf
+Haupt/Springer setzen. Pro Person anzeigen: Anzahl bisheriger Einsätze und letzter
+Einsatz (Fairness-Hilfe). Einsatz als „unterbesetzt" markieren, wenn zu wenige.
+Keine automatische Verteilung — nur Anzeige + manuelle Auswahl.
+```
+
+**Deliverables:**
+- [x] Admin kann Haupt- und Springerplätze besetzen
+- [x] Fairness-Anzeige (Anzahl + letzter Einsatz) pro Person sichtbar
+- [x] Unterbesetzte Einsätze klar markiert
+- [ ] 🏛 Review (Mistral): Zuteilungslogik gegen CLAUDE.md prüfen
+
+---
+
+## Session 8 · Veröffentlichen & persönliche Terminübersicht 💻
+
+**Ziel:** Admin veröffentlicht den Zeitraum; jede:r sieht „Meine Termine" als Liste.
+
+**Prompt:**
+```
+@file app/routes/dienste.py
+@file app/templates/
+@file CLAUDE.md
+
+Ansatz zuerst. 1) Admin-Aktion „Veröffentlichen" (Status veroeffentlicht, sperrt
+Verfügbarkeit). 2) Ansicht „Meine Termine": Liste der eigenen zugeteilten Einsätze,
+chronologisch, mit Rolle (Haupt/Springer) und Status. Keine Kalenderansicht.
+```
+
+**Deliverables:**
+- [x] Veröffentlichen funktioniert und sperrt die Verfügbarkeit
+- [x] „Meine Termine" zeigt eigene Einsätze als Liste
+- [x] Haupt/Springer und Status erkennbar (nicht nur über Farbe)
+
+---
+
+## Session 9 · Absage & Übernahme 💻
+
+**Ziel:** Absage einer Hauptperson → Springer rückt automatisch nach → Springerplatz
+wird offen → „Übernehmen" für alle.
+
+**Prompt:**
+```
+@file app/routes/dienste.py
+@file app/models.py
+@file CLAUDE.md
+
+Ansatz zuerst. Logik laut CLAUDE.md: Sagt eine Hauptperson ab, rückt der Springer
+automatisch nach (status uebernommen) und der Springerplatz wird offen. Offene
+Plätze erscheinen mit „Übernehmen"-Button; wer zuerst klickt, bekommt ihn (sauber
+gegen Doppel-Klicks absichern). Kein Springer → Platz direkt offen.
+```
+
+**Deliverables:**
+- [x] Absage einer Hauptperson → Springer rückt automatisch nach
+- [x] Frei gewordener Springerplatz wird offen zum Übernehmen
+- [x] „Übernehmen" sicher gegen gleichzeitige Klicks (atomares UPDATE)
+- [x] Fall „kein Springer" korrekt behandelt
+- [x] Review der Übernahme-Logik (Randfälle) — mit Claude Code statt Mistral
+
+---
+
+## Session 10 · Dokumente & Freigabe 💻
+
+**Ziel:** Upload (alle), Freigabe-Warteschlange (Admin), Liste Aktuell/Archiv.
+
+**Prompt:**
+```
+@file app/routes/dokumente.py
+@file app/models.py
+@file CLAUDE.md
+
+Ansatz zuerst. Upload (PDF/Word/Bild) mit Pflicht-Titel → Status wartet_auf_freigabe
+(nur Admin + Uploader sichtbar). Admin-Ansicht „Freigaben": freigeben/ablehnen
+(optional Grund). Admin-Uploads direkt freigegeben. Liste geteilt in Aktuell/Archiv,
+Verschieben durch Admin. Dateigrößen-Limit und Dateityp-Prüfung serverseitig.
+```
+
+**Deliverables:**
+- [x] Upload landet in wartet_auf_freigabe (nicht öffentlich sichtbar)
+- [x] Admin kann freigeben/ablehnen, Admin-Uploads direkt sichtbar
+- [x] Liste Aktuell + Archiv, Verschieben durch Admin
+- [x] Server prüft Dateityp und -größe
+
+---
+
+## Session 11 · Chat (2 Kanäle, Polling) 💻
+
+**Ziel:** Ankündigungen (nur Admin postet) + Team (alle). Nur Text. Neue Nachrichten
+per `fetch`-Polling (einzige v1-JS-Ausnahme).
+
+**Prompt:**
+```
+@file app/routes/chat.py
+@file app/models.py
+@file CLAUDE.md
+
+Ansatz zuerst. Zwei Kanäle: ankuendigungen (nur Admin darf posten, alle lesen) und
+team (alle posten). Nur Text. Nachrichten chronologisch. Dazu ein kleiner
+JSON-Endpunkt „neue Nachrichten seit X" und ~30 Zeilen Vanilla-JS, das im
+Hintergrund pollt und neue Nachrichten anhängt. Kein Framework, kein WebSocket.
+```
+
+**Deliverables:**
+- [x] Beide Kanäle funktionieren, Rechte korrekt (Ankündigungen nur Admin)
+- [x] Nachrichten werden gespeichert und angezeigt (nur Text)
+- [x] Polling lädt neue Nachrichten ohne Seiten-Reload
+- [x] JS ist minimal und vanilla
+
+---
+
+## Session 12 · Profil 💻
+
+**Ziel:** Profilbild-Upload + Platzhalter für optionale freiwillige Angaben.
+
+**Prompt:**
+```
+@file app/routes/profil.py
+@file app/models.py
+@file CLAUDE.md
+
+Ansatz zuerst. Profil-Ansicht: Profilbild hochladen/ändern, Anzeigename, optionale
+freiwillige Felder (vorerst ein, zwei Freitextfelder als Platzhalter — Struktur so,
+dass Felder später leicht ergänzt werden). Nichts davon Pflicht.
+```
+
+**Deliverables:**
+- [ ] Profilbild hochladbar
+- [ ] Optionale Felder vorhanden, nichts Pflicht
+- [ ] Struktur erlaubt späteres Ergänzen von Feldern
+
+---
+
+## Session 13 · In-App-Benachrichtigungen 💻
+
+**Ziel:** Dezente in-App-Hinweise (neuer Dienst, offener Dienst, neue Nachricht).
+
+**Prompt:**
+```
+@file app/models.py
+@file app/templates/base.html
+@file CLAUDE.md
+
+Ansatz zuerst. Einfaches Benachrichtigungsmodell + Anzeige (Glocke in der Topbar mit
+Zähler, Liste beim Antippen). Auslöser: neue Zuteilung, neuer offener Dienst, neue
+Nachricht. Als gelesen markierbar. Kein Push, keine E-Mail.
+```
+
+**Deliverables:**
+- [ ] Benachrichtigungen werden bei den drei Auslösern erzeugt
+- [ ] Glocke zeigt ungelesene an, Liste antippbar
+- [ ] Als gelesen markierbar
+
+---
+
+## Session 14 · Gesamt-Review 🏛
+
+**Ziel:** Durchsicht des fertigen Stands gegen CLAUDE.md — Korrektheit, Konsistenz,
+Barrierearmut, Dunkelmodus.
+
+**Prompt:**
+```
+@file CLAUDE.md
+@file app/models.py
+@file app/routes/
+
+Review den aktuellen Stand gegen CLAUDE.md. Korrektheitsprobleme zuerst, dann
+Konsistenz und Wartbarkeit, dann Barrierearmut und Dunkelmodus-Lücken. Nichts
+umschreiben — alles als nummerierte Liste mit Fundstelle.
+```
+
+**Deliverables:**
+- [ ] Review-Liste erstellt
+- [ ] Gefundene Korrektheitsprobleme in 💻-Folgeschritten behoben
+- [ ] A11y- und Dark-Mode-Lücken geschlossen
+
+---
+
+## Session 15 · Deployment auf den VPS 🛠
+
+**Ziel:** App läuft auf dem EU-VPS hinter einem produktiven Server.
+
+**Prompt:**
+```
+Lies CLAUDE.md. Hilf mir beim Deployment auf einen EU-VPS: WSGI-Server (z. B.
+gunicorn) hinter nginx, Umgebungsvariablen/Secrets, Persistenz für SQLite und
+Uploads, einfaches Backup der DB, HTTPS. Gib mir die Schritte als Fish-Befehle,
+wo Terminal nötig ist. Plan zuerst.
+```
+
+**Deliverables:**
+- [ ] App läuft unter gunicorn hinter nginx
+- [ ] HTTPS aktiv
+- [ ] Secrets über Umgebungsvariablen, nicht im Code
+- [ ] DB + Uploads persistent, einfaches DB-Backup eingerichtet
+
+---
+
+## Future Log (nicht in v1)
+
+1:1-Chat · Push-/E-Mail-Benachrichtigungen · E-Mail+Code-Login (Magic Link) ·
+automatische Fairness-Verteilung · weitere Rollen ·
+Laptop-Spiegelung / Desktop-Fallback über LAN ·
+komfortablere Zeit-Auswahl (Rad-/Scroll-Picker für Startzeit & Dauer statt Dropdown).
+
+## Härtung (vor Produktiv-Deploy / Session 15)
+
+- [x] **CSRF-Schutz für alle POST-Formulare.** Erledigt (nach Session 3): eigenes,
+ Session-gebundenes Token in `app/csrf.py`, ohne Flask-WTF. `csrf_token()` als
+ Jinja-Global, Prüfung aller unsicheren Methoden via `before_request`.
+ **Pflicht für alle künftigen POST-Formulare:** verstecktes Feld
+ `` einbauen.
+- [ ] **Brand-Tokens im `` werden mit `|safe` ausgegeben** (`base.html`,
+ wegen der Anführungszeichen im Font). Quelle ist aktuell nur Admin/Seed, daher
+ für v1 vertretbar. **Sobald eine UI zum Bearbeiten der Brand-Felder entsteht:**
+ Werte serverseitig validieren/escapen (CSS-Injection / ``-Ausbruch
+ verhindern) – z. B. Hex-Farben per Regex prüfen, Font auf Whitelist begrenzen.
diff --git a/TASKS.md b/TASKS.md
new file mode 100644
index 0000000..8918a1e
--- /dev/null
+++ b/TASKS.md
@@ -0,0 +1,431 @@
+# TASKS.md — Build-Checkliste CheckPoint Ehrenamt App
+
+> Eine Aufgabe pro Session. Nach Abhängigkeit geordnet — von oben nach unten abarbeiten.
+> Pro Session: **Tool**, **Ziel**, **Session-Prompt** (zum Einfügen), **Deliverables**.
+> Nach jeder Session: alle Häkchen setzen, committen, erst dann die nächste starten.
+>
+> Tool-Legende: 🛠 Claude Code · 💻 Continue + Qwen · 🏛 Continue + Mistral
+> Modell wechseln: `llama-switch` → [1] Architect / [2] Coder
+
+---
+
+## Session 1 · Scaffold & Projektgerüst 🛠
+
+**Ziel:** Lauffähiges Flask-Grundgerüst mit App-Factory, SQLite, zwei leeren
+Shell-Templates, Static-Einbindung und Team-Scoping-Stub.
+
+**Prompt:**
+```
+Lies CLAUDE.md. Erstelle das Projektgerüst gemäß der vorgeschlagenen Struktur:
+Flask-App-Factory, SQLAlchemy mit SQLite (instance/), requirements.txt,
+zwei minimale Shell-Templates base_user.html (Klasse cp-shell-user) und
+base_admin.html (Klasse cp-shell-admin), jeweils mit eingebundener tokens.css
+und ihrem jeweiligen Komponenten-CSS, sowie ein leeres Team-Scoping (Helper,
+der das aktive Team aus der Session liest). Zeig mir zuerst den Plan und die
+Dateiliste, bevor du schreibst.
+```
+
+**Deliverables:**
+- [x] App startet lokal (`flask run`) und zeigt eine leere Startseite
+- [x] `base_user.html` und `base_admin.html` existieren, je mit korrekter Shell-Klasse
+- [x] `tokens.css` in beiden eingebunden; `components.css` nur in `base_user.html`, `components-admin.css` nur in `base_admin.html`
+- [x] SQLite initialisiert, `instance/` in `.gitignore`
+- [x] Team-Scoping-Helper vorhanden (noch ohne echte Logik)
+- [x] `requirements.txt` minimal gehalten
+
+---
+
+## Session 2 · Datenmodell & Mandanten 💻
+
+**Ziel:** Alle Modelle aus CLAUDE.md, jedes mit `team_id`. Seed für das erste Team
+(CheckPoint) inkl. Brand-Tokens und den zwei Chat-Kanälen.
+
+**Prompt:**
+```
+@file app/models.py
+@file CLAUDE.md
+
+Erst den Ansatz erklären, dann implementieren — ein Modell nach dem anderen.
+Baue die Modelle: team, user, planungszeitraum, einsatz, verfuegbarkeit,
+zuteilung, dokument, kanal, nachricht. Jedes mit team_id. Danach ein Seed-Skript,
+das das CheckPoint-Team mit Brand-Tokens und den Kanälen "ankuendigungen" und
+"team" anlegt. Halte dich an die Feldvorgaben in CLAUDE.md.
+```
+
+**Deliverables:**
+- [x] Alle Modelle vorhanden, jedes mit `team_id`
+- [x] Beziehungen sauber (Einsatz↔Planungszeitraum, Zuteilung↔User/Einsatz)
+- [x] Seed legt CheckPoint-Team + 2 Kanäle an
+- [x] DB lässt sich anlegen, Seed läuft ohne Fehler
+
+---
+
+## Session 3 · Auth & Admin-Anlage 💻
+
+**Ziel:** Login (Nutzername + Passwort, gehasht), Session, Logout, Team-Scoping aktiv,
+Admin kann Nutzer anlegen. Keine Selbstregistrierung.
+
+**Prompt:**
+```
+@file app/auth.py
+@file app/models.py
+@file CLAUDE.md
+
+Ansatz zuerst. Baue: Login mit Nutzername+Passwort (werkzeug-Hashing), Session,
+Logout. Aktiviere das Team-Scoping (aktives Team aus der Session). Admin-geschützte
+Route zum Anlegen neuer Nutzer (Rolle wählbar). Geschützte Routen leiten ohne
+Login zur Anmeldung um. Ein Stück nach dem anderen.
+```
+
+**Deliverables:**
+- [x] Login/Logout funktioniert, Passwörter gehasht
+- [x] Geschützte Routen ohne Login → Redirect
+- [x] Admin kann Nutzer mit Rolle anlegen
+- [x] Alle Queries laufen team-gescoped
+- [x] 🏛 Mini-Review (Mistral): Auth gegen CLAUDE.md prüfen — als Liste, nicht umschreiben
+
+---
+
+## Session 4a · User-Shell: Base-Layout, Theming & Dark-Mode-Fix 💻
+
+**Ziel:** `base_user.html` final (Topbar, Bottom-Nav Start/Termine/Team/Profil,
+FAB-Slot), User-Brand-Tokens pro Team in den `` injizieren, Mockup-CSS auf
+`--cp-user-*`-Tokens umstellen.
+
+**Prompt:**
+```
+@file app/templates/base_user.html
+@file app/static/components.css
+@file app/static/tokens.css
+@file CLAUDE.md
+
+Ansatz zuerst. 1) base_user.html mit Topbar (Logo, Glocke, Avatar), Body-Klasse
+cp-shell-user und Bottom-Nav (Start, Termine, Team, Profil). 2) User-Brand-Tokens
+(--cp-user-brand-*) des aktiven Teams serverseitig in den schreiben.
+3) Im components.css alle fest verdrahteten Weißwerte (Topbar, Bottom-Nav,
+.cp-page-Verlauf) auf --cp-user-*-Tokens umstellen, damit der Dunkelmodus greift.
+Erst die Token-Umstellung erklären, dann umsetzen.
+```
+
+**Deliverables:**
+- [x] `base_user.html` mit korrekter Bottom-Nav (4 Punkte, „Termine"), Klasse `cp-shell-user`
+- [x] User-Brand-Tokens kommen pro Team aus der DB in den ``
+- [x] Keine festen Weiß-/Schwarzwerte mehr im `components.css`
+- [x] Hell- und Dunkelmodus optisch sauber (System-Umschaltung testen)
+
+---
+
+## Session 4b · Admin-Shell: Sidebar, Off-Canvas & Theming 💻
+
+**Ziel:** `base_admin.html` mit Desktop-Sidebar (Start, Planung, Dienste, Freigaben,
+Team, Chat-Verwaltung, Profil, Abmelden), Admin-Brand-Tokens pro Team in den ``,
+mobiles Off-Canvas-Menü per Hamburger-Icon und kleinem Vanilla-JS.
+
+**Prompt:**
+```
+@file app/templates/base_admin.html
+@file app/static/components-admin.css
+@file app/static/tokens.css
+@file app/static/sidebar.js
+@file CLAUDE.md
+
+Ansatz zuerst. 1) base_admin.html mit Body-Klasse cp-shell-admin: Sidebar links
+(Logo oben, Navigationspunkte Start/Planung/Dienste/Freigaben/Team/Chat-Verwaltung/
+Profil, Abmelden unten abgetrennt) und Hauptbereich rechts. 2) Admin-Brand-Tokens
+(--cp-admin-brand-*) serverseitig in den schreiben. 3) components-admin.css
+ausschließlich mit --cp-admin-*-Tokens aufbauen (Cards, Tabellen, Buttons gemäß
+CLAUDE.md-Regeln). 4) Unter dem Breakpoint: Sidebar kollabiert zu einer schmalen
+Topbar mit Hamburger-Icon; sidebar.js togglet eine Klasse, die die Sidebar als
+Off-Canvas-Overlay einblendet, schließt bei Klick auf Overlay-Fläche oder einen
+Menüpunkt. Vanilla, keine Bibliothek. Erst den Ansatz fürs Off-Canvas-Verhalten
+erklären, dann umsetzen.
+```
+
+**Deliverables:**
+- [x] `base_admin.html` mit Sidebar (alle 7 Punkte + Abmelden), Klasse `cp-shell-admin`
+- [x] Admin-Brand-Tokens kommen pro Team aus der DB in den ``
+- [x] `components-admin.css` nutzt ausschließlich `--cp-admin-*`-Tokens, keine Vermischung mit User-Tokens
+- [x] Sidebar kollabiert unter dem Breakpoint zu Hamburger + Off-Canvas-Overlay
+- [x] Hell- und Dunkelmodus der Arbeitsfläche sauber (Sidebar bleibt bewusst dunkel in beiden Modi)
+- [x] Touch-Ziele auch im Admin-Bereich ≥ 44px
+
+---
+
+## Session 5 · Planungszeitraum & Einsätze (Admin) 💻
+
+**Ziel:** Admin legt Planungszeiträume an und trägt Einsätze ein (Datum, Zeit, Art).
+Läuft in der Admin-Shell.
+
+**Prompt:**
+```
+@file app/routes/planung.py
+@file app/templates/base_admin.html
+@file app/models.py
+@file CLAUDE.md
+
+Ansatz zuerst. Admin-Ansichten (erweitern base_admin.html): Planungszeitraum
+anlegen (Status in_planung) und darin Einsätze hinzufügen/bearbeiten/löschen
+(Datum, Start/Ende, Art/Ort, je 2 Haupt- + 1 Springerplatz). Reine POST-Formulare,
+kein JS. Liste der Einsätze eines Zeitraums als Cards/Tabelle im Admin-Stil.
+```
+
+**Deliverables:**
+- [x] Admin kann Zeitraum anlegen
+- [x] Admin kann Einsätze anlegen/bearbeiten/löschen
+- [x] Einsatz kennt seine Platzstruktur (2 + Springer)
+- [x] Alles team-gescoped, ohne JavaScript, in der Admin-Shell
+
+---
+
+## Session 6 · Verfügbarkeit melden (Ehrenamtliche) 💻
+
+**Ziel:** Ehrenamtliche melden pro Einsatz „kann / kann nicht", solange `in_planung`.
+
+**Prompt:**
+```
+@file app/routes/planung.py
+@file app/models.py
+@file CLAUDE.md
+
+Ansatz zuerst. Ansicht für Ehrenamtliche: Liste der Einsätze im laufenden Zeitraum
+mit Umschaltung kann/kann_nicht pro Einsatz (POST-Formular). Änderung nur bei Status
+in_planung. Übersichtlich für mobile.
+```
+
+**Deliverables:**
+- [x] Ehrenamtliche sehen die Einsätze des aktuellen Zeitraums
+- [x] kann/kann_nicht wird gespeichert und ist änderbar (nur in_planung)
+- [x] Nach Veröffentlichung gesperrt
+- [x] Mobile sauber bedienbar (44px-Ziele)
+
+---
+
+## Session 7 · Dienstplan bauen (Admin) 💻
+
+**Ziel:** Admin verteilt manuell 2 Haupt + 1 Springer pro Einsatz, mit Fairness-Anzeige.
+Läuft in der Admin-Shell (Tabellen-Stil).
+
+**Prompt:**
+```
+@file app/routes/dienste.py
+@file app/templates/base_admin.html
+@file app/models.py
+@file CLAUDE.md
+
+Ansatz zuerst. Admin-Ansicht pro Einsatz (in der Admin-Shell, Tabelle im Stil aus
+CLAUDE.md: Navy-Tabellenkopf, Statusspalten mit Text+Farbe): verfügbare Personen
+auswählen und auf Haupt/Springer setzen. Pro Person anzeigen: Anzahl bisheriger
+Einsätze und letzter Einsatz (Fairness-Hilfe). Einsatz als „unterbesetzt" markieren,
+wenn zu wenige. Keine automatische Verteilung — nur Anzeige + manuelle Auswahl.
+```
+
+**Deliverables:**
+- [x] Admin kann Haupt- und Springerplätze besetzen
+- [x] Fairness-Anzeige (Anzahl + letzter Einsatz) pro Person sichtbar
+- [x] Unterbesetzte Einsätze klar markiert
+- [x] Tabelle nutzt ausschließlich `--cp-admin-*`-Tokens
+- [x] 🏛 Review (Mistral): Zuteilungslogik gegen CLAUDE.md prüfen
+
+---
+
+## Session 8 · Veröffentlichen & persönliche Terminübersicht 💻
+
+**Ziel:** Admin veröffentlicht den Zeitraum; jede:r sieht „Meine Termine" als Liste.
+
+**Prompt:**
+```
+@file app/routes/dienste.py
+@file app/templates/
+@file CLAUDE.md
+
+Ansatz zuerst. 1) Admin-Aktion „Veröffentlichen" (Status veroeffentlicht, sperrt
+Verfügbarkeit). 2) Ansicht „Meine Termine": Liste der eigenen zugeteilten Einsätze,
+chronologisch, mit Rolle (Haupt/Springer) und Status. Keine Kalenderansicht.
+```
+
+**Deliverables:**
+- [x] Veröffentlichen funktioniert und sperrt die Verfügbarkeit
+- [x] „Meine Termine" zeigt eigene Einsätze als Liste
+- [x] Haupt/Springer und Status erkennbar (nicht nur über Farbe)
+
+---
+
+## Session 9 · Absage & Übernahme 💻
+
+**Ziel:** Absage einer Hauptperson → Springer rückt automatisch nach → Springerplatz
+wird offen → „Übernehmen" für alle.
+
+**Prompt:**
+```
+@file app/routes/dienste.py
+@file app/models.py
+@file CLAUDE.md
+
+Ansatz zuerst. Logik laut CLAUDE.md: Sagt eine Hauptperson ab, rückt der Springer
+automatisch nach (status uebernommen) und der Springerplatz wird offen. Offene
+Plätze erscheinen mit „Übernehmen"-Button; wer zuerst klickt, bekommt ihn (sauber
+gegen Doppel-Klicks absichern). Kein Springer → Platz direkt offen.
+```
+
+**Deliverables:**
+- [x] Absage einer Hauptperson → Springer rückt automatisch nach
+- [x] Frei gewordener Springerplatz wird offen zum Übernehmen
+- [x] „Übernehmen" sicher gegen gleichzeitige Klicks
+- [x] Fall „kein Springer" korrekt behandelt
+- [x] 🏛 Review (Mistral): Übernahme-Logik prüfen (Randfälle!)
+
+---
+
+## Session 10 · Dokumente & Freigabe 💻
+
+**Ziel:** Upload (alle), Freigabe-Warteschlange (Admin), Liste Aktuell/Archiv.
+**Hinweis — gemischte Shell:** Die Upload-/Listenansicht für Ehrenamtliche läuft in
+der User-Shell (`base_user.html`), die Freigaben-Ansicht ausschließlich in der
+Admin-Shell (`base_admin.html`). Beide Templates entsprechend ansprechen, nicht
+vermischen.
+
+**Prompt:**
+```
+@file app/routes/dokumente.py
+@file app/templates/base_user.html
+@file app/templates/base_admin.html
+@file app/models.py
+@file CLAUDE.md
+
+Ansatz zuerst. 1) Upload-Ansicht in der User-Shell (PDF/Word/Bild) mit Pflicht-Titel
+→ Status wartet_auf_freigabe (nur Admin + Uploader sichtbar), dort auch die Liste
+Aktuell/Archiv für Ehrenamtliche. 2) Freigaben-Ansicht in der Admin-Shell:
+freigeben/ablehnen (optional Grund), Admin-Uploads direkt freigegeben, Verschieben
+Aktuell↔Archiv. Dateigrößen-Limit und Dateityp-Prüfung serverseitig.
+```
+
+**Deliverables:**
+- [x] Upload landet in wartet_auf_freigabe (nicht öffentlich sichtbar), in der User-Shell
+- [x] Admin kann freigeben/ablehnen in der Admin-Shell, Admin-Uploads direkt sichtbar
+- [x] Liste Aktuell + Archiv (User-Shell), Verschieben durch Admin (Admin-Shell)
+- [x] Server prüft Dateityp und -größe
+
+---
+
+## Session 11 · Chat (2 Kanäle, Polling) 💻
+
+**Ziel:** Ankündigungen (nur Admin postet) + Team (alle). Nur Text. Neue Nachrichten
+per `fetch`-Polling (einzige v1-JS-Ausnahme).
+
+**Prompt:**
+```
+@file app/routes/chat.py
+@file app/models.py
+@file CLAUDE.md
+
+Ansatz zuerst. Zwei Kanäle: ankuendigungen (nur Admin darf posten, alle lesen) und
+team (alle posten). Nur Text. Nachrichten chronologisch. Dazu ein kleiner
+JSON-Endpunkt „neue Nachrichten seit X" und ~30 Zeilen Vanilla-JS, das im
+Hintergrund pollt und neue Nachrichten anhängt. Kein Framework, kein WebSocket.
+```
+
+**Deliverables:**
+- [x] Beide Kanäle funktionieren, Rechte korrekt (Ankündigungen nur Admin)
+- [x] Nachrichten werden gespeichert und angezeigt (nur Text)
+- [x] Polling lädt neue Nachrichten ohne Seiten-Reload
+- [x] JS ist minimal und vanilla
+
+---
+
+## Session 12 · Profil 💻
+
+**Ziel:** Profilbild-Upload + Platzhalter für optionale freiwillige Angaben.
+
+**Prompt:**
+```
+@file app/routes/profil.py
+@file app/models.py
+@file CLAUDE.md
+
+Ansatz zuerst. Profil-Ansicht: Profilbild hochladen/ändern, Anzeigename, optionale
+freiwillige Felder (vorerst ein, zwei Freitextfelder als Platzhalter — Struktur so,
+dass Felder später leicht ergänzt werden). Nichts davon Pflicht.
+```
+
+**Deliverables:**
+- [x] Profilbild hochladbar
+- [x] Optionale Felder vorhanden, nichts Pflicht
+- [x] Struktur erlaubt späteres Ergänzen von Feldern
+
+---
+
+## Session 13 · In-App-Benachrichtigungen 💻
+
+**Ziel:** Dezente in-App-Hinweise (neuer Dienst, offener Dienst, neue Nachricht) in
+beiden Shells.
+
+**Prompt:**
+```
+@file app/models.py
+@file app/templates/base_user.html
+@file app/templates/base_admin.html
+@file CLAUDE.md
+
+Ansatz zuerst. Einfaches Benachrichtigungsmodell + Anzeige: Glocke in der Topbar
+der User-Shell mit Zähler, Liste beim Antippen; in der Admin-Shell entsprechend in
+der Sidebar/Kopfbereich. Auslöser: neue Zuteilung, neuer offener Dienst, neue
+Nachricht. Als gelesen markierbar. Kein Push, keine E-Mail.
+```
+
+**Deliverables:**
+- [x] Benachrichtigungen werden bei den drei Auslösern erzeugt
+- [x] Glocke/Hinweis zeigt ungelesene an, in beiden Shells passend platziert
+- [x] Als gelesen markierbar
+
+---
+
+## Session 14 · Gesamt-Review 🏛
+
+**Ziel:** Durchsicht des fertigen Stands gegen CLAUDE.md — Korrektheit, Konsistenz,
+Barrierearmut, Dunkelmodus.
+
+**Prompt:**
+```
+@file CLAUDE.md
+@file app/models.py
+@file app/routes/
+
+Review den aktuellen Stand gegen CLAUDE.md. Korrektheitsprobleme zuerst, dann
+Konsistenz und Wartbarkeit, dann Barrierearmut und Dunkelmodus-Lücken. Nichts
+umschreiben — alles als nummerierte Liste mit Fundstelle.
+```
+
+**Deliverables:**
+- [x] Review-Liste erstellt
+- [x] Gefundene Korrektheitsprobleme behoben (Lokalzeit-Filter, Offen-Benachrichtigung
+ ohne bereits Besetzte; offene Struktur-Divergenzen als Entscheidungen vermerkt)
+- [x] A11y-Lücke geschlossen (Body-Scroll-Lock bei offenem Off-Canvas); keine echten
+ Dark-Mode-Lücken gefunden
+
+---
+
+## Session 15 · Deployment auf den VPS 🛠
+
+**Ziel:** App läuft auf dem EU-VPS hinter einem produktiven Server.
+
+**Prompt:**
+```
+Lies CLAUDE.md. Hilf mir beim Deployment auf einen EU-VPS: WSGI-Server (z. B.
+gunicorn) hinter nginx, Umgebungsvariablen/Secrets, Persistenz für SQLite und
+Uploads, einfaches Backup der DB, HTTPS. Gib mir die Schritte als Fish-Befehle,
+wo Terminal nötig ist. Plan zuerst.
+```
+
+**Deliverables:**
+- [ ] App läuft unter gunicorn hinter nginx
+- [ ] HTTPS aktiv
+- [ ] Secrets über Umgebungsvariablen, nicht im Code
+- [ ] DB + Uploads persistent, einfaches DB-Backup eingerichtet
+
+---
+
+## Future Log (nicht in v1)
+
+1:1-Chat · Push-/E-Mail-Benachrichtigungen · E-Mail+Code-Login (Magic Link) ·
+automatische Fairness-Verteilung · weitere Rollen ·
+Laptop-Spiegelung / Desktop-Fallback über LAN.
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..6eed55c
--- /dev/null
+++ b/app/__init__.py
@@ -0,0 +1,124 @@
+"""App-Factory für die CheckPoint-Ehrenamt-App.
+
+Baut die Flask-App zusammen, initialisiert die Datenbank (SQLite in instance/)
+und registriert die Blueprints. Mandantenfähigkeit wird über das Team-Scoping
+(app/team_scope.py) vorbereitet – die echte Logik folgt in späteren Sessions.
+"""
+
+import os
+from datetime import timezone
+from zoneinfo import ZoneInfo
+
+from flask import Flask
+from flask_sqlalchemy import SQLAlchemy
+from sqlalchemy import event
+from sqlalchemy.engine import Engine
+
+_BERLIN = ZoneInfo("Europe/Berlin")
+
+
+@event.listens_for(Engine, "connect")
+def _sqlite_pragmas(dbapi_connection, connection_record):
+ """Nur für SQLite: WAL + busy_timeout machen parallele gunicorn-Worker
+ verträglich (statt „database is locked"), Fremdschlüssel werden erzwungen.
+ Bei anderen Engines (z. B. späteres PostgreSQL) ein No-op."""
+ import sqlite3
+
+ if isinstance(dbapi_connection, sqlite3.Connection):
+ cur = dbapi_connection.cursor()
+ cur.execute("PRAGMA journal_mode=WAL")
+ cur.execute("PRAGMA busy_timeout=5000")
+ cur.execute("PRAGMA foreign_keys=ON")
+ cur.close()
+
+
+def _lokalzeit(dt, fmt="%d.%m.%Y %H:%M"):
+ """Jinja-Filter: UTC-Zeitstempel (in SQLite naiv) → Europe/Berlin formatiert."""
+ if dt is None:
+ return ""
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt.astimezone(_BERLIN).strftime(fmt)
+
+# Eine zentrale DB-Instanz, die die Modelle (Session 2) importieren.
+db = SQLAlchemy()
+
+
+def create_app(test_config=None):
+ # instance_relative_config: Pfade wie die SQLite-DB liegen in instance/.
+ app = Flask(__name__, instance_relative_config=True)
+
+ # CP_ENV=production schaltet die Härtung scharf (sichere Cookies, SECRET_KEY Pflicht).
+ ist_produktion = os.environ.get("CP_ENV") == "production"
+ secret = os.environ.get("SECRET_KEY")
+ if ist_produktion and not secret:
+ raise RuntimeError(
+ "SECRET_KEY muss in Produktion gesetzt sein (CP_ENV=production)."
+ )
+
+ app.config.from_mapping(
+ # In Produktion über Umgebungsvariable setzen, nie hart im Code.
+ SECRET_KEY=secret or "dev-only-change-me",
+ SQLALCHEMY_DATABASE_URI="sqlite:///"
+ + os.path.join(app.instance_path, "checkpoint.sqlite"),
+ SQLALCHEMY_TRACK_MODIFICATIONS=False,
+ # Dokument-Uploads (Session 10): außerhalb von static/, max. 10 MB.
+ UPLOAD_FOLDER=os.path.join(app.instance_path, "uploads"),
+ MAX_CONTENT_LENGTH=10 * 1024 * 1024,
+ # Cookie-Härtung. Secure nur in Produktion (sonst bricht http-Login lokal).
+ SESSION_COOKIE_HTTPONLY=True,
+ SESSION_COOKIE_SAMESITE="Lax",
+ SESSION_COOKIE_SECURE=ist_produktion,
+ )
+
+ if test_config is not None:
+ app.config.from_mapping(test_config)
+
+ # instance/ + Upload-Ordner sicherstellen (für DB + Dokumente).
+ os.makedirs(app.instance_path, exist_ok=True)
+ os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True)
+
+ # Hinter nginx: X-Forwarded-Proto/-For/-Host auswerten (korrektes https-Schema).
+ if ist_produktion:
+ from werkzeug.middleware.proxy_fix import ProxyFix
+
+ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
+
+ db.init_app(app)
+
+ # CSRF-Schutz für alle POST-Formulare.
+ from .csrf import init_csrf
+
+ init_csrf(app)
+
+ # Lokalzeit-Filter (einheitliche Anzeige in Europe/Berlin).
+ app.jinja_env.filters["lokalzeit"] = _lokalzeit
+
+ # Modelle importieren, damit sie bei create_all() bekannt sind.
+ # (Noch leer – die Tabellen kommen in Session 2.)
+ from . import models # noqa: F401
+
+ with app.app_context():
+ db.create_all()
+
+ # Blueprints registrieren.
+ from .routes import main_bp
+ from .routes.planung import bp as planung_bp
+ from .routes.dienste import bp as dienste_bp
+ from .routes.dokumente import bp as dokumente_bp
+ from .routes.chat import bp as chat_bp
+ from .routes.profil import bp as profil_bp
+ from .auth import bp as auth_bp, register_cli
+
+ app.register_blueprint(main_bp)
+ app.register_blueprint(auth_bp)
+ app.register_blueprint(planung_bp)
+ app.register_blueprint(dienste_bp)
+ app.register_blueprint(dokumente_bp)
+ app.register_blueprint(chat_bp)
+ app.register_blueprint(profil_bp)
+
+ # CLI-Befehle (z. B. flask create-admin).
+ register_cli(app)
+
+ return app
diff --git a/app/auth.py b/app/auth.py
new file mode 100644
index 0000000..fd135b3
--- /dev/null
+++ b/app/auth.py
@@ -0,0 +1,215 @@
+"""Authentifizierung & Admin-Anlage.
+
+- Login (Nutzername + Passwort, werkzeug-Hashing), Logout, Session.
+- Team-Scoping wird beim Login aktiviert (Team in die Session).
+- Admin-geschützte Route zum Anlegen neuer Nutzer (Rolle wählbar).
+- Keine Selbstregistrierung: den ersten Admin legt der CLI-Befehl create-admin an.
+"""
+
+import functools
+
+import click
+from flask.cli import with_appcontext
+from flask import (
+ Blueprint,
+ flash,
+ g,
+ redirect,
+ render_template,
+ request,
+ session,
+ url_for,
+)
+from werkzeug.security import check_password_hash, generate_password_hash
+
+from . import db
+from .models import Team, User
+from .team_scope import (
+ SESSION_USER_KEY,
+ clear_session,
+ current_team,
+ get_active_team_id,
+ set_active_session,
+ team_query,
+)
+
+bp = Blueprint("auth", __name__)
+
+
+# -----------------------------------------------------------------------------
+# Nutzer pro Request laden
+# -----------------------------------------------------------------------------
+@bp.before_app_request
+def load_logged_in_user():
+ """g.user pro Request setzen – team-gescoped, damit Session und Team passen."""
+ user_id = session.get(SESSION_USER_KEY)
+ team_id = get_active_team_id()
+ if user_id is None or team_id is None:
+ g.user = None
+ return
+
+ # Bewusst über team_query: ein manipuliertes Session-Cookie kann so keinen
+ # Nutzer aus einem fremden Team laden.
+ g.user = team_query(User).filter_by(id=user_id).first()
+ if g.user is None:
+ # Session verweist ins Leere (z. B. Nutzer gelöscht) -> aufräumen.
+ clear_session()
+
+
+def current_user():
+ return getattr(g, "user", None)
+
+
+@bp.app_context_processor
+def inject_user_and_team():
+ """current_user und current_team in allen Templates verfügbar machen
+ (Topbar, Bottom-Nav, Brand-Token-Injektion in )."""
+ return {"current_user": current_user(), "current_team": current_team()}
+
+
+# -----------------------------------------------------------------------------
+# Dekoratoren
+# -----------------------------------------------------------------------------
+def login_required(view):
+ @functools.wraps(view)
+ def wrapped(*args, **kwargs):
+ if current_user() is None:
+ return redirect(url_for("auth.login", next=request.path))
+ return view(*args, **kwargs)
+
+ return wrapped
+
+
+def admin_required(view):
+ @functools.wraps(view)
+ def wrapped(*args, **kwargs):
+ user = current_user()
+ if user is None:
+ return redirect(url_for("auth.login", next=request.path))
+ if not user.ist_admin:
+ return render_template("403.html"), 403
+ return view(*args, **kwargs)
+
+ return wrapped
+
+
+# -----------------------------------------------------------------------------
+# Login / Logout
+# -----------------------------------------------------------------------------
+@bp.route("/login", methods=("GET", "POST"))
+def login():
+ if current_user() is not None:
+ return redirect(url_for("main.index"))
+
+ # Nutzername ist nur PRO Team eindeutig → der Login muss team-scoped sein.
+ mehrere_teams = Team.query.count() > 1
+
+ if request.method == "POST":
+ nutzername = (request.form.get("nutzername") or "").strip()
+ passwort = request.form.get("passwort") or ""
+ team_kuerzel = (request.form.get("team") or "").strip().lower()
+
+ # Team bestimmen: per Kürzel; ohne Kürzel nur, wenn es genau eins gibt.
+ if team_kuerzel:
+ team = Team.query.filter_by(slug=team_kuerzel).first()
+ elif not mehrere_teams:
+ team = Team.query.first()
+ else:
+ team = None
+
+ user = None
+ if team is not None:
+ user = User.query.filter_by(team_id=team.id, nutzername=nutzername).first()
+
+ if user is None or not check_password_hash(user.passwort_hash, passwort):
+ # Gleiche Meldung für alle Fälle (kein Enumerieren von Team/Nutzer/Passwort).
+ flash("Anmeldedaten stimmen nicht.")
+ else:
+ set_active_session(user.id, user.team_id)
+ next_url = request.args.get("next")
+ # Nur lokale Pfade zulassen (kein offener Redirect).
+ if next_url and next_url.startswith("/"):
+ return redirect(next_url)
+ return redirect(url_for("main.index"))
+
+ return render_template("login.html", mehrere_teams=mehrere_teams)
+
+
+@bp.route("/logout", methods=("POST",))
+def logout():
+ clear_session()
+ return redirect(url_for("auth.login"))
+
+
+# -----------------------------------------------------------------------------
+# Admin: Nutzer anlegen
+# -----------------------------------------------------------------------------
+@bp.route("/admin/nutzer", methods=("GET", "POST"))
+@admin_required
+def nutzer_anlegen():
+ if request.method == "POST":
+ nutzername = (request.form.get("nutzername") or "").strip()
+ anzeigename = (request.form.get("anzeigename") or "").strip()
+ passwort = request.form.get("passwort") or ""
+ rolle = request.form.get("rolle") or User.ROLLE_EHRENAMT
+
+ fehler = None
+ if not nutzername or not anzeigename or not passwort:
+ fehler = "Nutzername, Anzeigename und Passwort sind nötig."
+ elif rolle not in (User.ROLLE_ADMIN, User.ROLLE_EHRENAMT):
+ fehler = "Unbekannte Rolle."
+ elif team_query(User).filter_by(nutzername=nutzername).first() is not None:
+ fehler = f"Nutzername „{nutzername}“ ist im Team schon vergeben."
+
+ if fehler:
+ flash(fehler)
+ else:
+ user = User(
+ team_id=get_active_team_id(),
+ rolle=rolle,
+ nutzername=nutzername,
+ anzeigename=anzeigename,
+ passwort_hash=generate_password_hash(passwort),
+ )
+ db.session.add(user)
+ db.session.commit()
+ flash(f"Nutzer „{anzeigename}“ angelegt.")
+ return redirect(url_for("auth.nutzer_anlegen"))
+
+ nutzer = team_query(User).order_by(User.anzeigename).all()
+ return render_template("nutzer_neu.html", nutzer=nutzer)
+
+
+# -----------------------------------------------------------------------------
+# CLI: ersten Admin anlegen (Bootstrap, da keine Selbstregistrierung)
+# -----------------------------------------------------------------------------
+@click.command("create-admin")
+@click.argument("nutzername")
+@click.argument("anzeigename")
+@click.argument("passwort")
+@click.option("--team", "team_name", default="CheckPoint", help="Teamname (Default: CheckPoint).")
+@with_appcontext
+def create_admin_command(nutzername, anzeigename, passwort, team_name):
+ """Legt einen Admin im genannten Team an (Team muss existieren – siehe seed.py)."""
+ team = Team.query.filter_by(name=team_name).first()
+ if team is None:
+ raise click.ClickException(
+ f"Team „{team_name}“ existiert nicht. Erst `python seed.py` ausführen."
+ )
+ if User.query.filter_by(team_id=team.id, nutzername=nutzername).first() is not None:
+ raise click.ClickException(f"Nutzername „{nutzername}“ ist in „{team_name}“ schon vergeben.")
+
+ user = User(
+ team_id=team.id,
+ rolle=User.ROLLE_ADMIN,
+ nutzername=nutzername,
+ anzeigename=anzeigename,
+ passwort_hash=generate_password_hash(passwort),
+ )
+ db.session.add(user)
+ db.session.commit()
+ click.echo(f"Admin „{anzeigename}“ in Team „{team_name}“ angelegt.")
+
+
+def register_cli(app):
+ app.cli.add_command(create_admin_command)
diff --git a/app/csrf.py b/app/csrf.py
new file mode 100644
index 0000000..1c99b89
--- /dev/null
+++ b/app/csrf.py
@@ -0,0 +1,50 @@
+"""CSRF-Schutz – leichtgewichtig, ohne Flask-WTF (siehe CLAUDE.md: „so wenig wie möglich").
+
+Ein Session-gebundenes Token wird in jedes POST-Formular geschrieben und bei jeder
+unsicheren Anfrage geprüft. Reicht für eine interne App mit Session-Cookies.
+
+Nutzung im Template:
+
+"""
+
+import hmac
+import secrets
+
+from flask import abort, render_template, request, session
+
+CSRF_SESSION_KEY = "_csrf_token"
+CSRF_FORM_FIELD = "csrf_token"
+CSRF_HEADER = "X-CSRFToken"
+SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
+
+
+def generate_csrf():
+ """Token der aktuellen Session zurückgeben (beim ersten Aufruf erzeugen)."""
+ if CSRF_SESSION_KEY not in session:
+ session[CSRF_SESSION_KEY] = secrets.token_urlsafe(32)
+ return session[CSRF_SESSION_KEY]
+
+
+def _validate_csrf():
+ erwartet = session.get(CSRF_SESSION_KEY)
+ gesendet = request.form.get(CSRF_FORM_FIELD) or request.headers.get(CSRF_HEADER)
+ # compare_digest: konstante Laufzeit, verträgt aber keine None-Werte.
+ if not erwartet or not gesendet or not hmac.compare_digest(erwartet, gesendet):
+ abort(400, description="csrf")
+
+
+def init_csrf(app):
+ @app.before_request
+ def csrf_protect():
+ if request.method not in SAFE_METHODS:
+ _validate_csrf()
+
+ @app.errorhandler(400)
+ def csrf_fehler(err):
+ # Nur die CSRF-Fälle bekommen die freundliche Seite; andere 400 unverändert.
+ if getattr(err, "description", None) == "csrf":
+ return render_template("400_csrf.html"), 400
+ return err
+
+ # Im Template als csrf_token() verfügbar machen.
+ app.jinja_env.globals["csrf_token"] = generate_csrf
diff --git a/app/models.py b/app/models.py
new file mode 100644
index 0000000..925260f
--- /dev/null
+++ b/app/models.py
@@ -0,0 +1,375 @@
+"""SQLAlchemy-Modelle der CheckPoint-Ehrenamt-App.
+
+Regeln (siehe CLAUDE.md):
+- Mandantenfähig ab Tag 1: JEDES Modell trägt eine team_id. Jede Abfrage filtert
+ später nach dem Team des eingeloggten Nutzers.
+- SQLite für v1, aber portabel halten (kein SQLite-spezifischer Typ), damit ein
+ späterer Umstieg auf PostgreSQL möglich bleibt.
+- Status-/Typ-Felder sind Strings mit Konstanten (unten je Modell), keine DB-Enums.
+"""
+
+from datetime import date, datetime, timedelta, timezone
+
+from . import db
+
+
+def _now():
+ """Zeitzonenbewusster UTC-Zeitstempel (portabel, ohne SQLite-Defaults)."""
+ return datetime.now(timezone.utc)
+
+
+# =============================================================================
+# team — der Mandant. Das CheckPoint-Team ist das erste von perspektivisch mehreren.
+# =============================================================================
+class Team(db.Model):
+ __tablename__ = "team"
+
+ id = db.Column(db.Integer, primary_key=True)
+ name = db.Column(db.String(120), nullable=False)
+ # Kurzes, eindeutiges Kürzel für den teambezogenen Login (z. B. "checkpoint").
+ slug = db.Column(db.String(40), nullable=False, unique=True, index=True)
+
+ # Brand-Tokens (werden pro Team in den injiziert, Session 4).
+ brand_primary = db.Column(db.String(32), nullable=False, default="#21a8ad")
+ brand_primary_strong = db.Column(db.String(32), nullable=False, default="#147f84")
+ brand_accent = db.Column(db.String(32), nullable=False, default="#ef1823")
+ brand_accent_strong = db.Column(db.String(32), nullable=False, default="#bd0f18")
+ brand_font = db.Column(
+ db.String(200),
+ nullable=False,
+ default='"DM Sans", Arial, Helvetica, sans-serif',
+ )
+ logo_pfad = db.Column(db.String(255), nullable=True)
+
+ # Beziehungen (alle Kinder gehören zu genau diesem Team).
+ users = db.relationship("User", back_populates="team", cascade="all, delete-orphan")
+ planungszeitraeume = db.relationship(
+ "Planungszeitraum", back_populates="team", cascade="all, delete-orphan"
+ )
+ kanaele = db.relationship(
+ "Kanal", back_populates="team", cascade="all, delete-orphan"
+ )
+
+ def __repr__(self):
+ return f""
+
+
+# =============================================================================
+# user — Admin oder Ehrenamtliche:r. Keine Selbstregistrierung (Admin legt an).
+# =============================================================================
+class User(db.Model):
+ __tablename__ = "user"
+ # Nutzername eindeutig pro Team, nicht global (Mandantenfähigkeit).
+ __table_args__ = (
+ db.UniqueConstraint("team_id", "nutzername", name="uq_user_team_nutzername"),
+ )
+
+ ROLLE_ADMIN = "admin"
+ ROLLE_EHRENAMT = "ehrenamt"
+
+ id = db.Column(db.Integer, primary_key=True)
+ team_id = db.Column(
+ db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
+ )
+
+ rolle = db.Column(db.String(16), nullable=False, default=ROLLE_EHRENAMT)
+ nutzername = db.Column(db.String(80), nullable=False)
+ passwort_hash = db.Column(db.String(255), nullable=False)
+ anzeigename = db.Column(db.String(120), nullable=False)
+ profilbild_pfad = db.Column(db.String(255), nullable=True)
+
+ # Optionale freiwillige Angaben (Platzhalter, Session 12 erweitert die Struktur).
+ freiwillige_angaben = db.Column(db.Text, nullable=True)
+
+ team = db.relationship("Team", back_populates="users")
+ verfuegbarkeiten = db.relationship(
+ "Verfuegbarkeit", back_populates="user", cascade="all, delete-orphan"
+ )
+ zuteilungen = db.relationship(
+ "Zuteilung", back_populates="user", cascade="all, delete-orphan"
+ )
+
+ @property
+ def ist_admin(self):
+ return self.rolle == self.ROLLE_ADMIN
+
+ def __repr__(self):
+ return f""
+
+
+# =============================================================================
+# planungszeitraum — Block von mind. 2 Monaten. Enthält die kuratierten Einsätze.
+# =============================================================================
+class Planungszeitraum(db.Model):
+ __tablename__ = "planungszeitraum"
+
+ STATUS_IN_PLANUNG = "in_planung"
+ STATUS_VEROEFFENTLICHT = "veroeffentlicht"
+
+ id = db.Column(db.Integer, primary_key=True)
+ team_id = db.Column(
+ db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
+ )
+
+ name = db.Column(db.String(120), nullable=False)
+ start_datum = db.Column(db.Date, nullable=False)
+ end_datum = db.Column(db.Date, nullable=False)
+ status = db.Column(db.String(20), nullable=False, default=STATUS_IN_PLANUNG)
+
+ team = db.relationship("Team", back_populates="planungszeitraeume")
+ einsaetze = db.relationship(
+ "Einsatz",
+ back_populates="planungszeitraum",
+ cascade="all, delete-orphan",
+ order_by="Einsatz.datum, Einsatz.start_zeit",
+ )
+
+ @property
+ def ist_veroeffentlicht(self):
+ return self.status == self.STATUS_VEROEFFENTLICHT
+
+ def __repr__(self):
+ return f""
+
+
+# =============================================================================
+# einsatz — gehört zu einem Planungszeitraum. Braucht 2 Haupt- + 1 Springerplatz.
+# =============================================================================
+class Einsatz(db.Model):
+ __tablename__ = "einsatz"
+
+ # Platzstruktur ist fix (CLAUDE.md): 2 Haupt + 1 Springer. Belegung über Zuteilungen.
+ HAUPT_PLAETZE = 2
+ SPRINGER_PLAETZE = 1
+
+ id = db.Column(db.Integer, primary_key=True)
+ team_id = db.Column(
+ db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
+ )
+ # Optional: ein Einsatz kann zu einem Planungszeitraum gehören ODER ein
+ # eigenständiger Einzeltermin sein (planungszeitraum_id = NULL).
+ planungszeitraum_id = db.Column(
+ db.Integer, db.ForeignKey("planungszeitraum.id"), nullable=True, index=True
+ )
+
+ datum = db.Column(db.Date, nullable=False)
+ start_zeit = db.Column(db.Time, nullable=False)
+ # Erwartete Dauer in Minuten (30er-Schritte). Optional: leer = offenes Ende.
+ dauer_minuten = db.Column(db.Integer, nullable=True)
+ art = db.Column(db.String(80), nullable=False) # z. B. Tour, Party, Sonderveranstaltung
+ ort = db.Column(db.String(120), nullable=True) # z. B. Altstadt
+ unterbesetzt = db.Column(db.Boolean, nullable=False, default=False)
+
+ planungszeitraum = db.relationship("Planungszeitraum", back_populates="einsaetze")
+ verfuegbarkeiten = db.relationship(
+ "Verfuegbarkeit", back_populates="einsatz", cascade="all, delete-orphan"
+ )
+ zuteilungen = db.relationship(
+ "Zuteilung", back_populates="einsatz", cascade="all, delete-orphan"
+ )
+
+ @property
+ def end_zeit(self):
+ """Aus Start + erwarteter Dauer abgeleitet. None, wenn keine Dauer gesetzt."""
+ if self.start_zeit is None or not self.dauer_minuten:
+ return None
+ basis = datetime.combine(date.min, self.start_zeit)
+ return (basis + timedelta(minutes=self.dauer_minuten)).time()
+
+ @property
+ def dauer_text(self):
+ """Menschliche Dauer, z. B. 'ca. 1,5 Std' oder 'ca. 30 Min'. None ohne Dauer."""
+ if not self.dauer_minuten:
+ return None
+ stunden, minuten = divmod(self.dauer_minuten, 60)
+ if stunden and minuten == 30:
+ return f"ca. {stunden},5 Std"
+ if stunden:
+ return f"ca. {stunden} Std"
+ return f"ca. {minuten} Min"
+
+ def __repr__(self):
+ return f""
+
+
+# =============================================================================
+# verfuegbarkeit — (user × einsatz) → kann | kann_nicht.
+# =============================================================================
+class Verfuegbarkeit(db.Model):
+ __tablename__ = "verfuegbarkeit"
+ __table_args__ = (
+ db.UniqueConstraint("user_id", "einsatz_id", name="uq_verfuegbarkeit_user_einsatz"),
+ )
+
+ WERT_KANN = "kann"
+ WERT_KANN_NICHT = "kann_nicht"
+
+ id = db.Column(db.Integer, primary_key=True)
+ team_id = db.Column(
+ db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
+ )
+ user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
+ einsatz_id = db.Column(
+ db.Integer, db.ForeignKey("einsatz.id"), nullable=False, index=True
+ )
+
+ wert = db.Column(db.String(16), nullable=False)
+
+ user = db.relationship("User", back_populates="verfuegbarkeiten")
+ einsatz = db.relationship("Einsatz", back_populates="verfuegbarkeiten")
+
+ def __repr__(self):
+ return f""
+
+
+# =============================================================================
+# zuteilung — (user × einsatz) mit platztyp (haupt|springer) und status.
+# =============================================================================
+class Zuteilung(db.Model):
+ __tablename__ = "zuteilung"
+
+ PLATZ_HAUPT = "haupt"
+ PLATZ_SPRINGER = "springer"
+
+ STATUS_ZUGETEILT = "zugeteilt"
+ STATUS_ABGESAGT = "abgesagt"
+ STATUS_OFFEN = "offen"
+ STATUS_UEBERNOMMEN = "uebernommen"
+
+ id = db.Column(db.Integer, primary_key=True)
+ team_id = db.Column(
+ db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
+ )
+ einsatz_id = db.Column(
+ db.Integer, db.ForeignKey("einsatz.id"), nullable=False, index=True
+ )
+ # Bei offenem Platz kann (noch) keine Person zugeordnet sein.
+ user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True, index=True)
+
+ platztyp = db.Column(db.String(16), nullable=False)
+ status = db.Column(db.String(16), nullable=False, default=STATUS_ZUGETEILT)
+
+ einsatz = db.relationship("Einsatz", back_populates="zuteilungen")
+ user = db.relationship("User", back_populates="zuteilungen")
+
+ def __repr__(self):
+ return f""
+
+
+# =============================================================================
+# dokument — Datei (PDF/Word/Bild) mit Freigabe-Workflow.
+# =============================================================================
+class Dokument(db.Model):
+ __tablename__ = "dokument"
+
+ STATUS_WARTET = "wartet_auf_freigabe"
+ STATUS_FREIGEGEBEN = "freigegeben"
+ STATUS_ABGELEHNT = "abgelehnt"
+
+ ABLAGE_AKTUELL = "aktuell"
+ ABLAGE_ARCHIV = "archiv"
+
+ id = db.Column(db.Integer, primary_key=True)
+ team_id = db.Column(
+ db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
+ )
+ uploader_id = db.Column(
+ db.Integer, db.ForeignKey("user.id"), nullable=False, index=True
+ )
+
+ titel = db.Column(db.String(200), nullable=False) # Pflicht
+ datei_pfad = db.Column(db.String(255), nullable=False)
+ status = db.Column(db.String(24), nullable=False, default=STATUS_WARTET)
+ ablage = db.Column(db.String(16), nullable=False, default=ABLAGE_AKTUELL)
+ ablehnungsgrund = db.Column(db.Text, nullable=True)
+ erstellt_am = db.Column(db.DateTime(timezone=True), nullable=False, default=_now)
+
+ uploader = db.relationship("User")
+
+ def __repr__(self):
+ return f""
+
+
+# =============================================================================
+# kanal — pro Team zwei feste: ankuendigungen (nur Admin postet), team (alle).
+# =============================================================================
+class Kanal(db.Model):
+ __tablename__ = "kanal"
+ __table_args__ = (
+ db.UniqueConstraint("team_id", "art", name="uq_kanal_team_art"),
+ )
+
+ ART_ANKUENDIGUNGEN = "ankuendigungen"
+ ART_TEAM = "team"
+
+ id = db.Column(db.Integer, primary_key=True)
+ team_id = db.Column(
+ db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
+ )
+ art = db.Column(db.String(20), nullable=False)
+
+ team = db.relationship("Team", back_populates="kanaele")
+ nachrichten = db.relationship(
+ "Nachricht",
+ back_populates="kanal",
+ cascade="all, delete-orphan",
+ order_by="Nachricht.erstellt_am",
+ )
+
+ @property
+ def nur_admin_darf_posten(self):
+ return self.art == self.ART_ANKUENDIGUNGEN
+
+ def __repr__(self):
+ return f""
+
+
+# =============================================================================
+# nachricht — Kanal, Autor, Text, Zeitstempel. Nur Text.
+# =============================================================================
+class Nachricht(db.Model):
+ __tablename__ = "nachricht"
+
+ id = db.Column(db.Integer, primary_key=True)
+ team_id = db.Column(
+ db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
+ )
+ kanal_id = db.Column(db.Integer, db.ForeignKey("kanal.id"), nullable=False, index=True)
+ autor_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
+
+ text = db.Column(db.Text, nullable=False)
+ erstellt_am = db.Column(db.DateTime(timezone=True), nullable=False, default=_now)
+
+ kanal = db.relationship("Kanal", back_populates="nachrichten")
+ autor = db.relationship("User")
+
+ def __repr__(self):
+ return f""
+
+
+# =============================================================================
+# benachrichtigung — In-App-Hinweis pro Empfänger (v1: kein Push/E-Mail).
+# Ausgelöst von: neuer Dienst, offener Dienst, neue Nachricht.
+# =============================================================================
+class Benachrichtigung(db.Model):
+ __tablename__ = "benachrichtigung"
+
+ TYP_DIENST = "dienst" # neue/eigene Zuteilung
+ TYP_OFFEN = "offener_dienst" # ein Platz ist frei geworden
+ TYP_NACHRICHT = "nachricht" # neue Chat-Nachricht
+
+ id = db.Column(db.Integer, primary_key=True)
+ team_id = db.Column(
+ db.Integer, db.ForeignKey("team.id"), nullable=False, index=True
+ )
+ # Empfänger der Benachrichtigung.
+ user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True)
+
+ typ = db.Column(db.String(20), nullable=False)
+ text = db.Column(db.String(255), nullable=False)
+ url = db.Column(db.String(255), nullable=True) # Ziel beim Antippen (optional)
+ gelesen = db.Column(db.Boolean, nullable=False, default=False, index=True)
+ erstellt_am = db.Column(db.DateTime(timezone=True), nullable=False, default=_now)
+
+ def __repr__(self):
+ return f""
diff --git a/app/notifications.py b/app/notifications.py
new file mode 100644
index 0000000..8966825
--- /dev/null
+++ b/app/notifications.py
@@ -0,0 +1,40 @@
+"""In-App-Benachrichtigungen erzeugen (v1: kein Push/E-Mail, siehe CLAUDE.md).
+
+Kleine Helfer, die Benachrichtigungs-Datensätze anlegen. Sie committen NICHT –
+das übernimmt der aufrufende Request, damit die Auslöser-Aktion und ihre Hinweise
+in einer Transaktion landen. Immer team-gescoped.
+"""
+
+from . import db
+from .models import Benachrichtigung, User
+from .team_scope import get_active_team_id, team_query
+
+
+def benachrichtigen(user_id, text, url=None, typ=Benachrichtigung.TYP_DIENST):
+ """Einen Hinweis an genau eine Person anlegen."""
+ db.session.add(
+ Benachrichtigung(
+ team_id=get_active_team_id(),
+ user_id=user_id,
+ typ=typ,
+ text=text,
+ url=url,
+ )
+ )
+
+
+def benachrichtigen_team(text, url=None, typ=Benachrichtigung.TYP_NACHRICHT, ausser_user_ids=()):
+ """Einen Hinweis an alle Team-Mitglieder anlegen (optional einige auslassen)."""
+ ausgenommen = set(ausser_user_ids)
+ for user in team_query(User).all():
+ if user.id in ausgenommen:
+ continue
+ db.session.add(
+ Benachrichtigung(
+ team_id=user.team_id,
+ user_id=user.id,
+ typ=typ,
+ text=text,
+ url=url,
+ )
+ )
diff --git a/app/routes/__init__.py b/app/routes/__init__.py
new file mode 100644
index 0000000..0452539
--- /dev/null
+++ b/app/routes/__init__.py
@@ -0,0 +1,111 @@
+"""Start-Blueprint.
+
+Hält die Startseite und – vorerst – schlanke Platzhalter für die übrigen
+Navigationsziele (Termine, Team, Profil, Benachrichtigungen). Diese Platzhalter
+werden in späteren Sessions durch die echten Ansichten/Blueprints ersetzt
+(planung, dienste, dokumente, chat, profil). Interne App: alles login_required.
+"""
+
+from flask import Blueprint, current_app, redirect, render_template, request, url_for
+
+from .. import db
+from ..auth import current_user, login_required
+from ..models import Benachrichtigung
+from ..team_scope import team_query
+
+main_bp = Blueprint("main", __name__)
+
+# Theme-Modi (serverseitig, ohne JS): "system" folgt der Systemeinstellung,
+# "hell"/"dunkel" überschreiben sie fest. Gespeichert in einem Cookie.
+THEME_MODI = ("system", "hell", "dunkel")
+
+
+@main_bp.app_context_processor
+def inject_theme():
+ """Aktiven Theme-Modus aus dem Cookie in allen Templates verfügbar machen."""
+ modus = request.cookies.get("theme", "system")
+ return {"current_theme": modus if modus in THEME_MODI else "system"}
+
+
+@main_bp.app_context_processor
+def inject_shell_base():
+ """Wählt die Layout-Shell je Rolle: Admins bekommen die Sidebar-Shell, alle
+ anderen die mobile Bottom-Nav-Shell. Templates: {% extends shell_base %}."""
+ user = current_user()
+ basis = "base_admin.html" if (user and user.ist_admin) else "base_user.html"
+ return {"shell_base": basis}
+
+
+@main_bp.route("/")
+@login_required
+def index():
+ return render_template("index.html", user=current_user())
+
+
+@main_bp.route("/theme", methods=("POST",))
+def theme_setzen():
+ """Theme-Modus setzen (Cookie) und zur Ausgangsseite zurück. Kein Login nötig,
+ damit die Einstellung überall greift; CSRF-geschützt wie alle POST-Formulare."""
+ modus = request.form.get("modus", "system")
+ if modus not in THEME_MODI:
+ modus = "system"
+
+ # Nur lokale Pfade als Rücksprungziel zulassen (kein Open-Redirect).
+ ziel = request.form.get("next", "")
+ if not ziel.startswith("/"):
+ ziel = url_for("main.index")
+
+ antwort = redirect(ziel)
+ if modus == "system":
+ antwort.delete_cookie("theme")
+ else:
+ antwort.set_cookie(
+ "theme", modus, max_age=60 * 60 * 24 * 365,
+ samesite="Lax", httponly=True,
+ secure=current_app.config.get("SESSION_COOKIE_SECURE", False),
+ )
+ return antwort
+
+
+# --- Platzhalter, damit die Bottom-Nav schon vollständig funktioniert ---------
+# „Termine" zeigt auf planung.verfuegbarkeit (Session 6).
+@main_bp.route("/team")
+@login_required
+def team():
+ return render_template("team.html")
+
+
+@main_bp.app_context_processor
+def inject_benachrichtigungen_zahl():
+ """Anzahl ungelesener Benachrichtigungen für die Glocke (0, wenn nicht eingeloggt)."""
+ user = current_user()
+ if user is None:
+ return {"benachrichtigungen_ungelesen": 0}
+ anzahl = (
+ team_query(Benachrichtigung)
+ .filter_by(user_id=user.id, gelesen=False)
+ .count()
+ )
+ return {"benachrichtigungen_ungelesen": anzahl}
+
+
+@main_bp.route("/benachrichtigungen")
+@login_required
+def benachrichtigungen():
+ eintraege = (
+ team_query(Benachrichtigung)
+ .filter_by(user_id=current_user().id)
+ .order_by(Benachrichtigung.erstellt_am.desc())
+ .all()
+ )
+ return render_template("benachrichtigungen.html", eintraege=eintraege)
+
+
+@main_bp.route("/benachrichtigungen/gelesen", methods=("POST",))
+@login_required
+def benachrichtigungen_gelesen():
+ team_query(Benachrichtigung).filter_by(
+ user_id=current_user().id, gelesen=False
+ ).update({"gelesen": True}, synchronize_session=False)
+ db.session.commit()
+ return redirect(url_for("main.benachrichtigungen"))
diff --git a/app/routes/chat.py b/app/routes/chat.py
new file mode 100644
index 0000000..5318e14
--- /dev/null
+++ b/app/routes/chat.py
@@ -0,0 +1,135 @@
+"""Chat: zwei feste Kanäle pro Team (CLAUDE.md).
+
+- ankuendigungen: nur Admin postet, alle lesen.
+- team: alle posten.
+
+Nur Text. Nachrichten chronologisch. Neue Nachrichten holt der Client per
+kleinem fetch-Polling (JSON-Endpunkt /chat//seit/) – das ist die einzige
+in v1 erlaubte JavaScript-Ausnahme. Kein WebSocket, kein Framework. Alles
+team-gescoped (team_query) und CSRF-geschützt.
+"""
+
+from datetime import timezone
+from zoneinfo import ZoneInfo
+
+from flask import (
+ Blueprint,
+ abort,
+ flash,
+ jsonify,
+ redirect,
+ render_template,
+ request,
+ url_for,
+)
+
+from .. import db
+from ..auth import current_user, login_required
+from ..models import Benachrichtigung, Kanal, Nachricht
+from ..notifications import benachrichtigen_team
+from ..team_scope import get_active_team_id, team_query
+
+bp = Blueprint("chat", __name__, url_prefix="/chat")
+
+LABELS = {Kanal.ART_ANKUENDIGUNGEN: "Ankündigungen", Kanal.ART_TEAM: "Team"}
+MAX_LAENGE = 2000
+_BERLIN = ZoneInfo("Europe/Berlin")
+
+
+def _zeit(dt):
+ """erstellt_am (in UTC, aus SQLite meist naiv) → 'DD.MM. HH:MM' in Berliner Zeit."""
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt.astimezone(_BERLIN).strftime("%d.%m. %H:%M")
+
+
+def _kanal_holen(art):
+ if art not in (Kanal.ART_ANKUENDIGUNGEN, Kanal.ART_TEAM):
+ abort(404)
+ kanal = team_query(Kanal).filter_by(art=art).first()
+ if kanal is None:
+ abort(404)
+ return kanal
+
+
+def _serialisieren(nachricht, user_id):
+ return {
+ "id": nachricht.id,
+ "autor": nachricht.autor.anzeigename,
+ "zeit": _zeit(nachricht.erstellt_am),
+ "text": nachricht.text,
+ "eigene": nachricht.autor_id == user_id,
+ }
+
+
+@bp.route("/")
+@login_required
+def kanal(art):
+ kanal = _kanal_holen(art)
+ nachrichten = (
+ team_query(Nachricht)
+ .filter_by(kanal_id=kanal.id)
+ .order_by(Nachricht.erstellt_am)
+ .all()
+ )
+ darf_posten = (not kanal.nur_admin_darf_posten) or current_user().ist_admin
+ letzte_id = nachrichten[-1].id if nachrichten else 0
+ return render_template(
+ "chat.html",
+ kanal=kanal,
+ label=LABELS[kanal.art],
+ nachrichten=nachrichten,
+ zeit=_zeit,
+ darf_posten=darf_posten,
+ letzte_id=letzte_id,
+ )
+
+
+@bp.route("/", methods=("POST",))
+@login_required
+def posten(art):
+ kanal = _kanal_holen(art)
+ # Ankündigungen: nur Admin darf posten (alle dürfen lesen).
+ if kanal.nur_admin_darf_posten and not current_user().ist_admin:
+ abort(403)
+
+ text = (request.form.get("text") or "").strip()
+ if not text:
+ flash("Bitte einen Text eingeben.")
+ return redirect(url_for("chat.kanal", art=art))
+ if len(text) > MAX_LAENGE:
+ flash(f"Nachricht zu lang (max. {MAX_LAENGE} Zeichen).")
+ return redirect(url_for("chat.kanal", art=art))
+
+ db.session.add(
+ Nachricht(
+ team_id=get_active_team_id(),
+ kanal_id=kanal.id,
+ autor_id=current_user().id,
+ text=text,
+ )
+ )
+ # Alle außer dem Autor über die neue Nachricht informieren.
+ benachrichtigen_team(
+ f"Neue Nachricht in „{LABELS[kanal.art]}“ von {current_user().anzeigename}",
+ url=url_for("chat.kanal", art=art),
+ typ=Benachrichtigung.TYP_NACHRICHT,
+ ausser_user_ids={current_user().id},
+ )
+ db.session.commit()
+ return redirect(url_for("chat.kanal", art=art))
+
+
+@bp.route("//seit/")
+@login_required
+def neu(art, seit):
+ """JSON: alle Nachrichten dieses Kanals mit id > seit (für das Polling)."""
+ kanal = _kanal_holen(art)
+ user_id = current_user().id
+ nachrichten = (
+ team_query(Nachricht)
+ .filter(Nachricht.kanal_id == kanal.id, Nachricht.id > seit)
+ .order_by(Nachricht.erstellt_am)
+ .all()
+ )
+ return jsonify(nachrichten=[_serialisieren(n, user_id) for n in nachrichten])
diff --git a/app/routes/dienste.py b/app/routes/dienste.py
new file mode 100644
index 0000000..bb29c0b
--- /dev/null
+++ b/app/routes/dienste.py
@@ -0,0 +1,429 @@
+"""Dienstplan bauen (Admin).
+
+Manuelle Zuteilung: pro Einsatz 2 Haupt + 1 Springer. Keine automatische
+Verteilung – die App zeigt nur die verfügbaren Personen samt Fairness-Hilfe
+(Anzahl bisheriger Einsätze, letzter Einsatz) an; auswählen tut der Admin.
+
+Alles admin-geschützt und team-gescoped.
+"""
+
+from flask import (
+ Blueprint,
+ abort,
+ flash,
+ redirect,
+ render_template,
+ request,
+ url_for,
+)
+from sqlalchemy import func, or_
+
+from .. import db
+from ..auth import admin_required, current_user, login_required
+from ..models import Benachrichtigung, Einsatz, Planungszeitraum, User, Verfuegbarkeit, Zuteilung
+from ..notifications import benachrichtigen, benachrichtigen_team
+from ..team_scope import get_active_team_id, team_query
+
+bp = Blueprint("dienste", __name__, url_prefix="/dienste")
+
+# Status, die einen Platz tatsächlich belegen.
+AKTIVE_STATUS = (Zuteilung.STATUS_ZUGETEILT, Zuteilung.STATUS_UEBERNOMMEN)
+
+
+# -----------------------------------------------------------------------------
+# Hilfen
+# -----------------------------------------------------------------------------
+def _einsatz_holen(einsatz_id):
+ einsatz = team_query(Einsatz).filter_by(id=einsatz_id).first()
+ if einsatz is None:
+ abort(404)
+ return einsatz
+
+
+def _belegung(einsatz):
+ """Aktive Zuteilungen des Einsatzes, getrennt nach Platztyp."""
+ haupt, springer = [], []
+ for z in einsatz.zuteilungen:
+ if z.status not in AKTIVE_STATUS:
+ continue
+ if z.platztyp == Zuteilung.PLATZ_HAUPT:
+ haupt.append(z)
+ elif z.platztyp == Zuteilung.PLATZ_SPRINGER:
+ springer.append(z)
+ return haupt, springer
+
+
+def _fairness_map():
+ """{user_id: (anzahl_einsaetze, letztes_datum)} über das ganze Team.
+
+ Grundlage: aktive Zuteilungen. Hilft dem Admin, fair zu verteilen.
+ """
+ rows = (
+ db.session.query(
+ Zuteilung.user_id,
+ func.count(Zuteilung.id),
+ func.max(Einsatz.datum),
+ )
+ .join(Einsatz, Zuteilung.einsatz_id == Einsatz.id)
+ .filter(Zuteilung.team_id == get_active_team_id())
+ .filter(Zuteilung.user_id.isnot(None))
+ .filter(Zuteilung.status.in_(AKTIVE_STATUS))
+ .group_by(Zuteilung.user_id)
+ .all()
+ )
+ return {uid: (anzahl, letztes) for uid, anzahl, letztes in rows}
+
+
+def _kandidaten(einsatz, belegt_ids):
+ """Personen mit „Kann" für diesen Einsatz, die noch nicht zugeteilt sind."""
+ q = (
+ team_query(User)
+ .join(Verfuegbarkeit, Verfuegbarkeit.user_id == User.id)
+ .filter(Verfuegbarkeit.einsatz_id == einsatz.id)
+ .filter(Verfuegbarkeit.wert == Verfuegbarkeit.WERT_KANN)
+ )
+ if belegt_ids:
+ q = q.filter(User.id.notin_(belegt_ids))
+ return q.order_by(User.anzeigename).all()
+
+
+# -----------------------------------------------------------------------------
+# Übersichten
+# -----------------------------------------------------------------------------
+@bp.route("/")
+@admin_required
+def liste():
+ zeitraeume = team_query(Planungszeitraum).order_by(
+ Planungszeitraum.start_datum.desc()
+ ).all()
+ einzeltermine = (
+ team_query(Einsatz)
+ .filter_by(planungszeitraum_id=None)
+ .order_by(Einsatz.datum, Einsatz.start_zeit)
+ .all()
+ )
+ return render_template(
+ "dienste_liste.html", zeitraeume=zeitraeume, einzeltermine=einzeltermine
+ )
+
+
+@bp.route("/zeitraum//veroeffentlichen", methods=("POST",))
+@admin_required
+def veroeffentlichen(zeitraum_id):
+ zr = team_query(Planungszeitraum).filter_by(id=zeitraum_id).first()
+ if zr is None:
+ abort(404)
+
+ if zr.ist_veroeffentlicht:
+ flash("Der Zeitraum ist bereits veröffentlicht.")
+ else:
+ zr.status = Planungszeitraum.STATUS_VEROEFFENTLICHT
+ # Jede zugeteilte Person über ihre Dienste informieren.
+ zuteilungen = (
+ team_query(Zuteilung)
+ .join(Einsatz, Zuteilung.einsatz_id == Einsatz.id)
+ .filter(Einsatz.planungszeitraum_id == zr.id)
+ .filter(Zuteilung.user_id.isnot(None))
+ .filter(Zuteilung.status.in_(AKTIVE_STATUS))
+ .all()
+ )
+ for z in zuteilungen:
+ rolle = "Haupt" if z.platztyp == Zuteilung.PLATZ_HAUPT else "Springer"
+ benachrichtigen(
+ z.user_id,
+ f"Neuer Dienst: {z.einsatz.art} am {z.einsatz.datum.strftime('%d.%m.%Y')} ({rolle})",
+ url=url_for("dienste.meine_termine"),
+ typ=Benachrichtigung.TYP_DIENST,
+ )
+ db.session.commit()
+ flash(f"„{zr.name}“ veröffentlicht. Alle sehen jetzt ihren Dienstplan.")
+ return redirect(url_for("dienste.zeitraum", zeitraum_id=zr.id))
+
+
+@bp.route("/zeitraum/")
+@admin_required
+def zeitraum(zeitraum_id):
+ zr = team_query(Planungszeitraum).filter_by(id=zeitraum_id).first()
+ if zr is None:
+ abort(404)
+
+ # Besetzungsstand je Einsatz für die Übersicht.
+ stand = {}
+ for e in zr.einsaetze:
+ haupt, springer = _belegung(e)
+ stand[e.id] = (len(haupt), len(springer))
+
+ return render_template("dienste_zeitraum.html", zeitraum=zr, stand=stand)
+
+
+# -----------------------------------------------------------------------------
+# Bau-Ansicht pro Einsatz
+# -----------------------------------------------------------------------------
+@bp.route("/einsatz/")
+@admin_required
+def einsatz(einsatz_id):
+ e = _einsatz_holen(einsatz_id)
+ haupt, springer = _belegung(e)
+ belegt_ids = [z.user_id for z in haupt + springer]
+ kandidaten = _kandidaten(e, belegt_ids)
+ fairness = _fairness_map()
+
+ return render_template(
+ "dienste_einsatz.html",
+ einsatz=e,
+ haupt=haupt,
+ springer=springer,
+ haupt_frei=Einsatz.HAUPT_PLAETZE - len(haupt),
+ springer_frei=Einsatz.SPRINGER_PLAETZE - len(springer),
+ kandidaten=kandidaten,
+ fairness=fairness,
+ )
+
+
+@bp.route("/einsatz//zuteilen", methods=("POST",))
+@admin_required
+def zuteilen(einsatz_id):
+ e = _einsatz_holen(einsatz_id)
+ platztyp = request.form.get("platztyp")
+ user_id = request.form.get("user_id", type=int)
+
+ if platztyp not in (Zuteilung.PLATZ_HAUPT, Zuteilung.PLATZ_SPRINGER):
+ flash("Ungültiger Platztyp.")
+ return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
+
+ person = team_query(User).filter_by(id=user_id).first()
+ if person is None:
+ abort(404)
+
+ # Person muss „Kann" gemeldet haben.
+ kann = team_query(Verfuegbarkeit).filter_by(
+ user_id=person.id, einsatz_id=e.id, wert=Verfuegbarkeit.WERT_KANN
+ ).first()
+ if kann is None:
+ flash(f"{person.anzeigename} hat für diesen Einsatz kein „Kann“ gemeldet.")
+ return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
+
+ haupt, springer = _belegung(e)
+ belegt_ids = [z.user_id for z in haupt + springer]
+ if person.id in belegt_ids:
+ flash(f"{person.anzeigename} ist diesem Einsatz schon zugeteilt.")
+ return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
+
+ if platztyp == Zuteilung.PLATZ_HAUPT and len(haupt) >= Einsatz.HAUPT_PLAETZE:
+ flash("Die Hauptplätze sind bereits voll.")
+ return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
+ if platztyp == Zuteilung.PLATZ_SPRINGER and len(springer) >= Einsatz.SPRINGER_PLAETZE:
+ flash("Der Springerplatz ist bereits besetzt.")
+ return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
+
+ db.session.add(
+ Zuteilung(
+ team_id=get_active_team_id(),
+ einsatz_id=e.id,
+ user_id=person.id,
+ platztyp=platztyp,
+ status=Zuteilung.STATUS_ZUGETEILT,
+ )
+ )
+ db.session.commit()
+ flash(f"{person.anzeigename} zugeteilt.")
+ return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
+
+
+@bp.route("/einsatz//entfernen", methods=("POST",))
+@admin_required
+def entfernen(einsatz_id):
+ e = _einsatz_holen(einsatz_id)
+ zuteilung_id = request.form.get("zuteilung_id", type=int)
+ z = team_query(Zuteilung).filter_by(id=zuteilung_id, einsatz_id=e.id).first()
+ if z is None:
+ abort(404)
+
+ db.session.delete(z)
+ db.session.commit()
+ flash("Zuteilung entfernt.")
+ return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
+
+
+@bp.route("/einsatz//unterbesetzt", methods=("POST",))
+@admin_required
+def unterbesetzt(einsatz_id):
+ e = _einsatz_holen(einsatz_id)
+ e.unterbesetzt = not e.unterbesetzt
+ db.session.commit()
+ flash("Als unterbesetzt markiert." if e.unterbesetzt else "Markierung entfernt.")
+ return redirect(url_for("dienste.einsatz", einsatz_id=e.id))
+
+
+# -----------------------------------------------------------------------------
+# Sichtbarkeitsfilter: nur veröffentlichte Zeiträume oder Einzeltermine
+# -----------------------------------------------------------------------------
+def _sichtbar_filter(query):
+ return query.filter(
+ or_(
+ Einsatz.planungszeitraum_id.is_(None),
+ Planungszeitraum.status == Planungszeitraum.STATUS_VEROEFFENTLICHT,
+ )
+ )
+
+
+# -----------------------------------------------------------------------------
+# Meine Termine (alle eingeloggten Nutzer) – eigene Zuteilungen + offene Dienste
+# -----------------------------------------------------------------------------
+@bp.route("/meine-termine")
+@login_required
+def meine_termine():
+ rows = _sichtbar_filter(
+ db.session.query(Zuteilung, Einsatz)
+ .join(Einsatz, Zuteilung.einsatz_id == Einsatz.id)
+ .outerjoin(Planungszeitraum, Einsatz.planungszeitraum_id == Planungszeitraum.id)
+ .filter(Zuteilung.team_id == get_active_team_id())
+ .filter(Zuteilung.user_id == current_user().id)
+ .filter(Zuteilung.status.in_(AKTIVE_STATUS))
+ ).order_by(Einsatz.datum, Einsatz.start_zeit).all()
+
+ # Offene Dienste (nach einer Absage) – für alle zum Übernehmen.
+ offene = _sichtbar_filter(
+ db.session.query(Zuteilung, Einsatz)
+ .join(Einsatz, Zuteilung.einsatz_id == Einsatz.id)
+ .outerjoin(Planungszeitraum, Einsatz.planungszeitraum_id == Planungszeitraum.id)
+ .filter(Zuteilung.team_id == get_active_team_id())
+ .filter(Zuteilung.status == Zuteilung.STATUS_OFFEN)
+ ).order_by(Einsatz.datum, Einsatz.start_zeit).all()
+
+ # Einsätze, auf denen man selbst schon aktiv ist – dort kein zweiter Platz.
+ eigene_einsatz_ids = {e.id for _, e in rows}
+
+ # Gibt es gerade etwas zu melden? (für den Link zur Verfügbarkeit)
+ offene_planung = (
+ team_query(Planungszeitraum)
+ .filter_by(status=Planungszeitraum.STATUS_IN_PLANUNG)
+ .first()
+ is not None
+ or team_query(Einsatz).filter_by(planungszeitraum_id=None).first() is not None
+ )
+ return render_template(
+ "meine_termine.html",
+ termine=rows,
+ offene=offene,
+ eigene_einsatz_ids=eigene_einsatz_ids,
+ offene_planung=offene_planung,
+ )
+
+
+# -----------------------------------------------------------------------------
+# Absage & Übernahme
+# -----------------------------------------------------------------------------
+def _offen_anlegen(einsatz, platztyp):
+ db.session.add(
+ Zuteilung(
+ team_id=get_active_team_id(),
+ einsatz_id=einsatz.id,
+ user_id=None,
+ platztyp=platztyp,
+ status=Zuteilung.STATUS_OFFEN,
+ )
+ )
+
+
+@bp.route("/zuteilung//absagen", methods=("POST",))
+@login_required
+def absagen(zuteilung_id):
+ z = team_query(Zuteilung).filter_by(id=zuteilung_id).first()
+ if z is None:
+ abort(404)
+ # Nur die eigene Zuteilung absagen.
+ if z.user_id != current_user().id:
+ abort(403)
+ if z.status not in AKTIVE_STATUS:
+ flash("Dieser Dienst ist nicht (mehr) aktiv.")
+ return redirect(url_for("dienste.meine_termine"))
+
+ einsatz = z.einsatz
+ war_haupt = z.platztyp == Zuteilung.PLATZ_HAUPT
+ z.status = Zuteilung.STATUS_ABGESAGT
+
+ if war_haupt:
+ # Springer rückt nach, falls vorhanden.
+ springer = next(
+ (
+ s
+ for s in einsatz.zuteilungen
+ if s.platztyp == Zuteilung.PLATZ_SPRINGER
+ and s.status in AKTIVE_STATUS
+ and s.user_id is not None
+ ),
+ None,
+ )
+ if springer is not None:
+ springer.platztyp = Zuteilung.PLATZ_HAUPT
+ springer.status = Zuteilung.STATUS_UEBERNOMMEN
+ _offen_anlegen(einsatz, Zuteilung.PLATZ_SPRINGER)
+ benachrichtigen(
+ springer.user_id,
+ f"Du bist nachgerückt: {einsatz.art} am {einsatz.datum.strftime('%d.%m.%Y')} (jetzt Haupt)",
+ url=url_for("dienste.meine_termine"),
+ typ=Benachrichtigung.TYP_DIENST,
+ )
+ else:
+ _offen_anlegen(einsatz, Zuteilung.PLATZ_HAUPT)
+ else:
+ # Springer sagt ab -> Springerplatz wird offen.
+ _offen_anlegen(einsatz, Zuteilung.PLATZ_SPRINGER)
+
+ # Offener Platz: alle zum Übernehmen einladen – außer wer schon auf diesem
+ # Einsatz steht (z. B. der gerade nachgerückte Springer) und der absagenden Person.
+ schon_dabei = {
+ s.user_id
+ for s in einsatz.zuteilungen
+ if s.user_id is not None and s.status in AKTIVE_STATUS
+ }
+ schon_dabei.add(current_user().id)
+ benachrichtigen_team(
+ f"Offener Dienst: {einsatz.art} am {einsatz.datum.strftime('%d.%m.%Y')} – jetzt übernehmen",
+ url=url_for("dienste.meine_termine"),
+ typ=Benachrichtigung.TYP_OFFEN,
+ ausser_user_ids=schon_dabei,
+ )
+ db.session.commit()
+ flash("Abgesagt. Der frei gewordene Platz ist jetzt offen.")
+ return redirect(url_for("dienste.meine_termine"))
+
+
+@bp.route("/zuteilung//uebernehmen", methods=("POST",))
+@login_required
+def uebernehmen(zuteilung_id):
+ user = current_user()
+
+ # Vorprüfung: nicht zweimal auf denselben Einsatz.
+ z = team_query(Zuteilung).filter_by(id=zuteilung_id).first()
+ if z is None:
+ abort(404)
+ schon_dabei = team_query(Zuteilung).filter(
+ Zuteilung.einsatz_id == z.einsatz_id,
+ Zuteilung.user_id == user.id,
+ Zuteilung.status.in_(AKTIVE_STATUS),
+ ).first()
+ if schon_dabei is not None:
+ flash("Du bist diesem Einsatz schon zugeteilt.")
+ return redirect(url_for("dienste.meine_termine"))
+
+ # Atomar gegen gleichzeitiges Übernehmen: nur, solange wirklich noch offen.
+ getroffen = (
+ Zuteilung.query.filter_by(
+ id=zuteilung_id,
+ team_id=get_active_team_id(),
+ status=Zuteilung.STATUS_OFFEN,
+ user_id=None,
+ ).update(
+ {"user_id": user.id, "status": Zuteilung.STATUS_UEBERNOMMEN},
+ synchronize_session=False,
+ )
+ )
+ db.session.commit()
+
+ if getroffen == 1:
+ flash("Übernommen.")
+ else:
+ flash("Dieser Dienst wurde gerade schon übernommen.")
+ return redirect(url_for("dienste.meine_termine"))
diff --git a/app/routes/dokumente.py b/app/routes/dokumente.py
new file mode 100644
index 0000000..2b1ec14
--- /dev/null
+++ b/app/routes/dokumente.py
@@ -0,0 +1,236 @@
+"""Dokumente: Upload mit Freigabe-Workflow (CLAUDE.md).
+
+Alle dürfen hochladen; der Upload startet als wartet_auf_freigabe und ist nur
+für Admin + Uploader sichtbar. Der Admin gibt frei oder lehnt ab (optional mit
+Grund); Admin-Uploads sind direkt freigegeben. Freigegebene Dokumente sehen alle
+im Team, getrennt in Aktuell und Archiv (Verschieben macht der Admin).
+
+Dateien liegen außerhalb von static/ (instance/uploads/) und werden nur über die
+geschützte Route /dokumente//datei ausgeliefert. Reine POST-Formulare, kein JS.
+Alles team-gescoped (team_query) und CSRF-geschützt.
+"""
+
+import os
+import uuid
+
+from flask import (
+ Blueprint,
+ abort,
+ current_app,
+ flash,
+ redirect,
+ render_template,
+ request,
+ send_from_directory,
+ url_for,
+)
+
+from .. import db
+from ..auth import admin_required, current_user, login_required
+from ..models import Dokument
+from ..team_scope import get_active_team_id, team_query
+
+bp = Blueprint("dokumente", __name__, url_prefix="/dokumente")
+
+# Endungs-Whitelist (CLAUDE.md: PDF/Word/Bild) + Tabellen.
+ERLAUBTE_ENDUNGEN = {
+ "pdf",
+ "doc",
+ "docx",
+ "xls",
+ "xlsx",
+ "png",
+ "jpg",
+ "jpeg",
+ "gif",
+ "webp",
+}
+
+
+def _endung(dateiname):
+ """Kleingeschriebene Dateiendung ohne Punkt (oder '' ohne Endung)."""
+ if not dateiname or "." not in dateiname:
+ return ""
+ return dateiname.rsplit(".", 1)[1].lower()
+
+
+def _darf_sehen(dok, user):
+ """Sichtbarkeit eines einzelnen Dokuments für den aktuellen Nutzer."""
+ if dok.status == Dokument.STATUS_FREIGEGEBEN:
+ return True
+ # Wartend oder abgelehnt: nur Admin oder der Uploader.
+ return user.ist_admin or dok.uploader_id == user.id
+
+
+# -----------------------------------------------------------------------------
+# Liste (alle): freigegeben in Aktuell/Archiv + eigene wartende Uploads
+# -----------------------------------------------------------------------------
+@bp.route("/")
+@login_required
+def liste():
+ user = current_user()
+
+ freigegeben = (
+ team_query(Dokument)
+ .filter_by(status=Dokument.STATUS_FREIGEGEBEN)
+ .order_by(Dokument.erstellt_am.desc())
+ .all()
+ )
+ aktuell = [d for d in freigegeben if d.ablage == Dokument.ABLAGE_AKTUELL]
+ archiv = [d for d in freigegeben if d.ablage == Dokument.ABLAGE_ARCHIV]
+
+ # Eigene noch nicht freigegebene Uploads (nur für den Uploader sichtbar).
+ eigene_wartend = (
+ team_query(Dokument)
+ .filter(Dokument.uploader_id == user.id)
+ .filter(Dokument.status != Dokument.STATUS_FREIGEGEBEN)
+ .order_by(Dokument.erstellt_am.desc())
+ .all()
+ )
+
+ offene_freigaben = 0
+ if user.ist_admin:
+ offene_freigaben = (
+ team_query(Dokument).filter_by(status=Dokument.STATUS_WARTET).count()
+ )
+
+ return render_template(
+ "dokumente.html",
+ aktuell=aktuell,
+ archiv=archiv,
+ eigene_wartend=eigene_wartend,
+ offene_freigaben=offene_freigaben,
+ )
+
+
+# -----------------------------------------------------------------------------
+# Upload (alle): Pflicht-Titel + Datei, serverseitige Typ-/Größenprüfung
+# -----------------------------------------------------------------------------
+@bp.route("/upload", methods=("POST",))
+@login_required
+def upload():
+ user = current_user()
+ titel = (request.form.get("titel") or "").strip()
+ datei = request.files.get("datei")
+
+ if not titel:
+ flash("Bitte einen Titel angeben.")
+ return redirect(url_for("dokumente.liste"))
+ if datei is None or datei.filename == "":
+ flash("Bitte eine Datei auswählen.")
+ return redirect(url_for("dokumente.liste"))
+
+ endung = _endung(datei.filename)
+ if endung not in ERLAUBTE_ENDUNGEN:
+ flash("Dieser Dateityp ist nicht erlaubt. Erlaubt: PDF, Word, Excel, Bilder.")
+ return redirect(url_for("dokumente.liste"))
+
+ # Kollisionsfreier, sicherer Speichername. Der Originalname dient nur als
+ # Titel-Hilfe; gespeichert wird unter einem zufälligen Namen.
+ speichername = f"{uuid.uuid4().hex}.{endung}"
+ datei.save(os.path.join(current_app.config["UPLOAD_FOLDER"], speichername))
+
+ # Admin-Uploads sind direkt freigegeben, sonst Warteschlange.
+ status = Dokument.STATUS_FREIGEGEBEN if user.ist_admin else Dokument.STATUS_WARTET
+ dok = Dokument(
+ team_id=get_active_team_id(),
+ uploader_id=user.id,
+ titel=titel,
+ datei_pfad=speichername,
+ status=status,
+ )
+ db.session.add(dok)
+ db.session.commit()
+
+ if user.ist_admin:
+ flash("Dokument hochgeladen und freigegeben.")
+ else:
+ flash("Dokument hochgeladen. Es ist sichtbar, sobald der Admin es freigibt.")
+ return redirect(url_for("dokumente.liste"))
+
+
+# -----------------------------------------------------------------------------
+# Datei ausliefern (geschützt, mit Sichtbarkeitsprüfung)
+# -----------------------------------------------------------------------------
+@bp.route("//datei")
+@login_required
+def datei(dok_id):
+ dok = team_query(Dokument).filter_by(id=dok_id).first()
+ if dok is None:
+ abort(404)
+ if not _darf_sehen(dok, current_user()):
+ abort(403)
+ return send_from_directory(
+ current_app.config["UPLOAD_FOLDER"],
+ dok.datei_pfad,
+ download_name=f"{dok.titel}.{_endung(dok.datei_pfad)}",
+ )
+
+
+# -----------------------------------------------------------------------------
+# Freigabe-Warteschlange (Admin)
+# -----------------------------------------------------------------------------
+@bp.route("/freigaben")
+@admin_required
+def freigaben():
+ wartend = (
+ team_query(Dokument)
+ .filter_by(status=Dokument.STATUS_WARTET)
+ .order_by(Dokument.erstellt_am.asc())
+ .all()
+ )
+ return render_template("dokumente_freigaben.html", wartend=wartend)
+
+
+@bp.route("//freigeben", methods=("POST",))
+@admin_required
+def freigeben(dok_id):
+ dok = team_query(Dokument).filter_by(id=dok_id).first()
+ if dok is None:
+ abort(404)
+ dok.status = Dokument.STATUS_FREIGEGEBEN
+ dok.ablehnungsgrund = None
+ db.session.commit()
+ flash("Dokument freigegeben.")
+ return redirect(url_for("dokumente.freigaben"))
+
+
+@bp.route("//ablehnen", methods=("POST",))
+@admin_required
+def ablehnen(dok_id):
+ dok = team_query(Dokument).filter_by(id=dok_id).first()
+ if dok is None:
+ abort(404)
+ dok.status = Dokument.STATUS_ABGELEHNT
+ dok.ablehnungsgrund = (request.form.get("grund") or "").strip() or None
+ db.session.commit()
+ flash("Dokument abgelehnt.")
+ return redirect(url_for("dokumente.freigaben"))
+
+
+@bp.route("//verschieben", methods=("POST",))
+@admin_required
+def verschieben(dok_id):
+ dok = team_query(Dokument).filter_by(id=dok_id).first()
+ if dok is None:
+ abort(404)
+ if dok.status != Dokument.STATUS_FREIGEGEBEN:
+ flash("Nur freigegebene Dokumente lassen sich verschieben.")
+ return redirect(url_for("dokumente.liste"))
+ dok.ablage = (
+ Dokument.ABLAGE_ARCHIV
+ if dok.ablage == Dokument.ABLAGE_AKTUELL
+ else Dokument.ABLAGE_AKTUELL
+ )
+ db.session.commit()
+ flash("Ins Archiv verschoben." if dok.ablage == Dokument.ABLAGE_ARCHIV else "Zurück zu Aktuell.")
+ return redirect(url_for("dokumente.liste"))
+
+
+# -----------------------------------------------------------------------------
+# Zu große Datei: freundliche Meldung statt nackter 413-Seite
+# -----------------------------------------------------------------------------
+@bp.app_errorhandler(413)
+def datei_zu_gross(e):
+ flash("Die Datei ist zu groß (max. 10 MB).")
+ return redirect(url_for("dokumente.liste"))
diff --git a/app/routes/planung.py b/app/routes/planung.py
new file mode 100644
index 0000000..3a34d6d
--- /dev/null
+++ b/app/routes/planung.py
@@ -0,0 +1,339 @@
+"""Planung (Admin): Planungszeiträume anlegen und darin Einsätze pflegen.
+
+Reine POST-Formulare, kein JavaScript. Alles admin-geschützt und team-gescoped
+(team_query). Strukturelle Änderungen an Einsätzen sind nur erlaubt, solange der
+Zeitraum in_planung ist – ein veröffentlichter Plan bleibt unangetastet.
+"""
+
+from datetime import datetime
+
+from flask import (
+ Blueprint,
+ abort,
+ flash,
+ redirect,
+ render_template,
+ request,
+ url_for,
+)
+
+from .. import db
+from ..auth import admin_required, current_user, login_required
+from ..models import Einsatz, Planungszeitraum, Verfuegbarkeit
+from ..team_scope import get_active_team_id, team_query
+
+bp = Blueprint("planung", __name__, url_prefix="/planung")
+
+# Typische Einsatzarten (CLAUDE.md). art ist im Modell ein freier String,
+# wir bieten hier eine kuratierte Auswahl an.
+ARTEN = ["Tour", "Party", "Sonderveranstaltung"]
+
+
+# -----------------------------------------------------------------------------
+# Hilfen
+# -----------------------------------------------------------------------------
+def _parse_datum(wert):
+ try:
+ return datetime.strptime(wert, "%Y-%m-%d").date()
+ except (ValueError, TypeError):
+ return None
+
+
+def _parse_zeit(wert):
+ try:
+ return datetime.strptime(wert, "%H:%M").time()
+ except (ValueError, TypeError):
+ return None
+
+
+def _einsatz_gesperrt(einsatz):
+ """Einsätze eines veröffentlichten Zeitraums sind gesperrt.
+ Einzeltermine (ohne Zeitraum) sind nie gesperrt."""
+ zr = einsatz.planungszeitraum
+ return zr is not None and zr.ist_veroeffentlicht
+
+
+def _einsatz_redirect(einsatz):
+ """Nach Bearbeiten/Löschen zurück zur passenden Liste."""
+ if einsatz.planungszeitraum_id:
+ return redirect(url_for("planung.detail", zeitraum_id=einsatz.planungszeitraum_id))
+ return redirect(url_for("planung.einzeltermine"))
+
+
+def _zeitraum_holen(zeitraum_id):
+ """Zeitraum des aktiven Teams oder 404."""
+ zr = team_query(Planungszeitraum).filter_by(id=zeitraum_id).first()
+ if zr is None:
+ abort(404)
+ return zr
+
+
+# -----------------------------------------------------------------------------
+# Planungszeiträume: Liste + Anlegen
+# -----------------------------------------------------------------------------
+@bp.route("/", methods=("GET", "POST"))
+@admin_required
+def liste():
+ if request.method == "POST":
+ name = (request.form.get("name") or "").strip()
+ start = _parse_datum(request.form.get("start_datum"))
+ ende = _parse_datum(request.form.get("end_datum"))
+
+ fehler = None
+ if not name:
+ fehler = "Bitte einen Namen angeben."
+ elif start is None or ende is None:
+ fehler = "Bitte Start- und Enddatum angeben."
+ elif ende <= start:
+ fehler = "Das Ende muss nach dem Start liegen."
+
+ if fehler:
+ flash(fehler)
+ else:
+ zr = Planungszeitraum(
+ team_id=get_active_team_id(),
+ name=name,
+ start_datum=start,
+ end_datum=ende,
+ status=Planungszeitraum.STATUS_IN_PLANUNG,
+ )
+ db.session.add(zr)
+ db.session.commit()
+ flash(f"Planungszeitraum „{name}“ angelegt.")
+ return redirect(url_for("planung.detail", zeitraum_id=zr.id))
+
+ zeitraeume = team_query(Planungszeitraum).order_by(
+ Planungszeitraum.start_datum.desc()
+ ).all()
+ return render_template("planung_liste.html", zeitraeume=zeitraeume)
+
+
+# -----------------------------------------------------------------------------
+# Zeitraum-Detail + Einsatz anlegen
+# -----------------------------------------------------------------------------
+@bp.route("/", methods=("GET", "POST"))
+@admin_required
+def detail(zeitraum_id):
+ zr = _zeitraum_holen(zeitraum_id)
+
+ if request.method == "POST":
+ if zr.ist_veroeffentlicht:
+ flash("Der Zeitraum ist veröffentlicht – Einsätze sind gesperrt.")
+ return redirect(url_for("planung.detail", zeitraum_id=zr.id))
+
+ fehler, daten = _einsatz_formular_lesen()
+ if fehler:
+ flash(fehler)
+ else:
+ einsatz = Einsatz(
+ team_id=get_active_team_id(),
+ planungszeitraum_id=zr.id,
+ **daten,
+ )
+ db.session.add(einsatz)
+ db.session.commit()
+ flash("Einsatz angelegt.")
+ return redirect(url_for("planung.detail", zeitraum_id=zr.id))
+
+ return render_template(
+ "planung_detail.html",
+ zeitraum=zr,
+ einsaetze=zr.einsaetze,
+ arten=ARTEN,
+ )
+
+
+# -----------------------------------------------------------------------------
+# Einsatz bearbeiten / löschen
+# -----------------------------------------------------------------------------
+@bp.route("/einsatz//bearbeiten", methods=("GET", "POST"))
+@admin_required
+def einsatz_bearbeiten(einsatz_id):
+ einsatz = team_query(Einsatz).filter_by(id=einsatz_id).first()
+ if einsatz is None:
+ abort(404)
+
+ if request.method == "POST":
+ if _einsatz_gesperrt(einsatz):
+ flash("Der Zeitraum ist veröffentlicht – Einsätze sind gesperrt.")
+ return _einsatz_redirect(einsatz)
+
+ fehler, daten = _einsatz_formular_lesen()
+ if fehler:
+ flash(fehler)
+ else:
+ einsatz.datum = daten["datum"]
+ einsatz.start_zeit = daten["start_zeit"]
+ einsatz.dauer_minuten = daten["dauer_minuten"]
+ einsatz.art = daten["art"]
+ einsatz.ort = daten["ort"]
+ db.session.commit()
+ flash("Einsatz aktualisiert.")
+ return _einsatz_redirect(einsatz)
+
+ return render_template(
+ "einsatz_bearbeiten.html", zeitraum=einsatz.planungszeitraum, einsatz=einsatz, arten=ARTEN
+ )
+
+
+@bp.route("/einsatz//loeschen", methods=("POST",))
+@admin_required
+def einsatz_loeschen(einsatz_id):
+ einsatz = team_query(Einsatz).filter_by(id=einsatz_id).first()
+ if einsatz is None:
+ abort(404)
+
+ if _einsatz_gesperrt(einsatz):
+ flash("Der Zeitraum ist veröffentlicht – Einsätze sind gesperrt.")
+ return _einsatz_redirect(einsatz)
+
+ ziel = _einsatz_redirect(einsatz)
+ db.session.delete(einsatz)
+ db.session.commit()
+ flash("Einsatz gelöscht.")
+ return ziel
+
+
+# -----------------------------------------------------------------------------
+# Einzeltermine (Admin): eigenständige Einsätze ohne Planungszeitraum
+# -----------------------------------------------------------------------------
+@bp.route("/einzeltermine", methods=("GET", "POST"))
+@admin_required
+def einzeltermine():
+ if request.method == "POST":
+ fehler, daten = _einsatz_formular_lesen()
+ if fehler:
+ flash(fehler)
+ else:
+ einsatz = Einsatz(
+ team_id=get_active_team_id(),
+ planungszeitraum_id=None,
+ **daten,
+ )
+ db.session.add(einsatz)
+ db.session.commit()
+ flash("Einzeltermin angelegt.")
+ return redirect(url_for("planung.einzeltermine"))
+
+ termine = (
+ team_query(Einsatz)
+ .filter_by(planungszeitraum_id=None)
+ .order_by(Einsatz.datum, Einsatz.start_zeit)
+ .all()
+ )
+ return render_template("einzeltermine.html", termine=termine, arten=ARTEN)
+
+
+# -----------------------------------------------------------------------------
+# Verfügbarkeit melden (Ehrenamtliche) – kann / kann_nicht je Einsatz
+# -----------------------------------------------------------------------------
+def _laufender_zeitraum():
+ """Aktueller Zeitraum in Planung (neuester nach Startdatum) oder None."""
+ return (
+ team_query(Planungszeitraum)
+ .filter_by(status=Planungszeitraum.STATUS_IN_PLANUNG)
+ .order_by(Planungszeitraum.start_datum.desc())
+ .first()
+ )
+
+
+@bp.route("/verfuegbarkeit")
+@login_required
+def verfuegbarkeit():
+ zr = _laufender_zeitraum()
+
+ # Meldbar sind: Einzeltermine (ohne Zeitraum) + die Einsätze des laufenden Zeitraums.
+ einzeltermine = (
+ team_query(Einsatz)
+ .filter_by(planungszeitraum_id=None)
+ .order_by(Einsatz.datum, Einsatz.start_zeit)
+ .all()
+ )
+ einsaetze = list(einzeltermine) + (list(zr.einsaetze) if zr else [])
+
+ eigene = {}
+ if einsaetze:
+ ids = [e.id for e in einsaetze]
+ rows = (
+ team_query(Verfuegbarkeit)
+ .filter_by(user_id=current_user().id)
+ .filter(Verfuegbarkeit.einsatz_id.in_(ids))
+ .all()
+ )
+ eigene = {r.einsatz_id: r.wert for r in rows}
+
+ return render_template(
+ "verfuegbarkeit.html",
+ zeitraum=zr,
+ einsaetze=einsaetze,
+ eigene=eigene,
+ )
+
+
+@bp.route("/verfuegbarkeit/", methods=("POST",))
+@login_required
+def verfuegbarkeit_setzen(einsatz_id):
+ einsatz = team_query(Einsatz).filter_by(id=einsatz_id).first()
+ if einsatz is None:
+ abort(404)
+
+ if _einsatz_gesperrt(einsatz):
+ flash("Der Zeitraum ist veröffentlicht – Verfügbarkeit ist gesperrt.")
+ return redirect(url_for("planung.verfuegbarkeit"))
+
+ wert = request.form.get("wert")
+ if wert not in (Verfuegbarkeit.WERT_KANN, Verfuegbarkeit.WERT_KANN_NICHT):
+ flash("Ungültige Auswahl.")
+ return redirect(url_for("planung.verfuegbarkeit"))
+
+ eintrag = team_query(Verfuegbarkeit).filter_by(
+ user_id=current_user().id, einsatz_id=einsatz.id
+ ).first()
+ if eintrag is None:
+ eintrag = Verfuegbarkeit(
+ team_id=get_active_team_id(),
+ user_id=current_user().id,
+ einsatz_id=einsatz.id,
+ wert=wert,
+ )
+ db.session.add(eintrag)
+ else:
+ eintrag.wert = wert
+ db.session.commit()
+
+ return redirect(url_for("planung.verfuegbarkeit"))
+
+
+# -----------------------------------------------------------------------------
+# Gemeinsames Einlesen + Validieren des Einsatz-Formulars
+# -----------------------------------------------------------------------------
+def _einsatz_formular_lesen():
+ """Liest die Einsatz-Felder aus request.form.
+
+ Rückgabe: (fehler_text_oder_None, daten_dict).
+ """
+ datum = _parse_datum(request.form.get("datum"))
+ start_zeit = _parse_zeit(request.form.get("start_zeit"))
+ dauer_roh = (request.form.get("dauer_minuten") or "").strip()
+ art = (request.form.get("art") or "").strip()
+ ort = (request.form.get("ort") or "").strip() or None
+
+ if datum is None:
+ return "Bitte ein gültiges Datum angeben.", None
+ if start_zeit is None:
+ return "Bitte eine Startzeit angeben.", None
+ dauer_minuten = None
+ if dauer_roh:
+ if not dauer_roh.isdigit() or int(dauer_roh) <= 0:
+ return "Bitte eine gültige Dauer wählen.", None
+ dauer_minuten = int(dauer_roh)
+ if art not in ARTEN:
+ return "Bitte eine Art auswählen.", None
+
+ return None, {
+ "datum": datum,
+ "start_zeit": start_zeit,
+ "dauer_minuten": dauer_minuten,
+ "art": art,
+ "ort": ort,
+ }
diff --git a/app/routes/profil.py b/app/routes/profil.py
new file mode 100644
index 0000000..584bdf0
--- /dev/null
+++ b/app/routes/profil.py
@@ -0,0 +1,110 @@
+"""Profil: Anzeigename, Profilbild und optionale freiwillige Angaben.
+
+Nichts außer dem (ohnehin vorhandenen) Anzeigenamen ist Pflicht. Die freiwilligen
+Felder sind über die Liste FREIWILLIGE_FELDER definiert und werden als JSON in
+user.freiwillige_angaben abgelegt – ein neues Feld ist damit eine Zeile mehr in
+der Liste, ohne DB-Migration. Läuft in beiden Shells (shell_base je Rolle).
+"""
+
+import json
+import os
+import uuid
+
+from flask import (
+ Blueprint,
+ abort,
+ current_app,
+ flash,
+ redirect,
+ render_template,
+ request,
+ send_from_directory,
+ url_for,
+)
+
+from .. import db
+from ..auth import current_user, login_required
+from ..models import User
+from ..team_scope import team_query
+
+bp = Blueprint("profil", __name__, url_prefix="/profil")
+
+# Erlaubte Bildtypen fürs Profilbild (serverseitig geprüft).
+BILD_ENDUNGEN = {"png", "jpg", "jpeg", "gif", "webp"}
+
+# Optionale freiwillige Angaben. Neues Feld = ein Eintrag mehr; "mehrzeilig" für
+# Textarea. Werte landen als JSON-Objekt in user.freiwillige_angaben.
+FREIWILLIGE_FELDER = [
+ {"key": "pronomen", "label": "Pronomen", "placeholder": "z. B. sie/ihr, er/ihm"},
+ {"key": "ueber_mich", "label": "Über mich", "placeholder": "Ein, zwei Sätze – ganz freiwillig", "mehrzeilig": True},
+]
+_GUELTIGE_KEYS = {f["key"] for f in FREIWILLIGE_FELDER}
+
+
+def _endung(dateiname):
+ if not dateiname or "." not in dateiname:
+ return ""
+ return dateiname.rsplit(".", 1)[1].lower()
+
+
+def _angaben_lesen(user):
+ """freiwillige_angaben als Dict (robust gegen leer/kaputt)."""
+ if not user.freiwillige_angaben:
+ return {}
+ try:
+ daten = json.loads(user.freiwillige_angaben)
+ return daten if isinstance(daten, dict) else {}
+ except (ValueError, TypeError):
+ return {}
+
+
+@bp.route("/", methods=("GET", "POST"))
+@login_required
+def index():
+ user = current_user()
+
+ if request.method == "POST":
+ anzeigename = (request.form.get("anzeigename") or "").strip()
+ if not anzeigename:
+ flash("Bitte einen Anzeigenamen angeben.")
+ return redirect(url_for("profil.index"))
+ user.anzeigename = anzeigename
+
+ # Freiwillige Felder: nur bekannte Keys, leere Werte fallen raus.
+ angaben = {}
+ for feld in FREIWILLIGE_FELDER:
+ wert = (request.form.get(feld["key"]) or "").strip()
+ if wert:
+ angaben[feld["key"]] = wert
+ user.freiwillige_angaben = json.dumps(angaben, ensure_ascii=False) if angaben else None
+
+ # Profilbild optional. Nur bei tatsächlichem Upload anfassen.
+ datei = request.files.get("profilbild")
+ if datei and datei.filename:
+ endung = _endung(datei.filename)
+ if endung not in BILD_ENDUNGEN:
+ flash("Dieses Bildformat ist nicht erlaubt. Erlaubt: PNG, JPG, GIF, WebP.")
+ return redirect(url_for("profil.index"))
+ name = f"profil_{uuid.uuid4().hex}.{endung}"
+ datei.save(os.path.join(current_app.config["UPLOAD_FOLDER"], name))
+ user.profilbild_pfad = name
+
+ db.session.commit()
+ flash("Profil gespeichert.")
+ return redirect(url_for("profil.index"))
+
+ return render_template(
+ "profil.html",
+ felder=FREIWILLIGE_FELDER,
+ angaben=_angaben_lesen(user),
+ )
+
+
+@bp.route("/bild/")
+@login_required
+def bild(user_id):
+ """Profilbild eines Team-Mitglieds ausliefern (team-gescoped)."""
+ user = team_query(User).filter_by(id=user_id).first()
+ if user is None or not user.profilbild_pfad:
+ abort(404)
+ return send_from_directory(current_app.config["UPLOAD_FOLDER"], user.profilbild_pfad)
diff --git a/app/static/components-admin.css b/app/static/components-admin.css
new file mode 100644
index 0000000..ae56e9a
--- /dev/null
+++ b/app/static/components-admin.css
@@ -0,0 +1,397 @@
+/* =============================================================================
+ CheckPoint Ehrenamt · components-admin.css
+ Komponenten der ADMIN-Shell (Sidebar links, mobil Off-Canvas). Nutzt
+ AUSSCHLIESSLICH --cp-admin-*-Tokens aus tokens.css – niemals --cp-user-*.
+ Geteilte Klassennamen mit der User-Shell (.cp-card, .cp-btn, …), aber eigenes
+ Aussehen: jede Shell lädt nur ihr Komponenten-CSS, daher keine Kollision.
+ Desktop-first; unter 900px kollabiert die Sidebar zum Off-Canvas-Overlay.
+ ============================================================================= */
+
+*,
+*::before,
+*::after { box-sizing: border-box; }
+
+html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: var(--cp-admin-bg);
+ color: var(--cp-admin-text);
+ font-family: var(--cp-admin-font-ui);
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+
+:focus-visible {
+ outline: 2px solid var(--cp-admin-teal);
+ outline-offset: 2px;
+}
+
+/* =============================================================================
+ Layout: Sidebar + Hauptbereich
+ ============================================================================= */
+.cp-sidebar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: var(--cp-admin-sidebar-width);
+ z-index: 30;
+ display: flex;
+ flex-direction: column;
+ gap: var(--cp-admin-space-2);
+ padding: var(--cp-admin-space-4);
+ overflow-y: auto;
+ background: var(--cp-admin-sidebar-bg);
+ color: var(--cp-admin-sidebar-text);
+}
+
+.cp-sidebar__brand {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--cp-admin-space-2);
+ font-family: var(--cp-admin-font-display);
+ font-size: 1.3rem;
+ color: var(--cp-admin-on-brand);
+ padding: var(--cp-admin-space-3) var(--cp-admin-space-2) var(--cp-admin-space-5);
+}
+.cp-sidebar__bell { color: var(--cp-admin-sidebar-text); flex: none; }
+.cp-admin-topbar__bell { margin-left: auto; color: var(--cp-admin-on-brand); }
+
+.cp-sidebar__nav {
+ display: flex;
+ flex-direction: column;
+ gap: var(--cp-admin-space-1);
+}
+
+.cp-navlink {
+ display: flex;
+ align-items: center;
+ gap: var(--cp-admin-space-3);
+ width: 100%;
+ min-height: 44px;
+ padding: 0 var(--cp-admin-space-3);
+ border: 0;
+ border-radius: var(--cp-admin-radius-md);
+ background: none;
+ color: var(--cp-admin-sidebar-text-muted);
+ font: inherit;
+ font-weight: 500;
+ text-align: left;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.cp-navlink__icon { flex: none; color: var(--cp-admin-sidebar-icon); }
+
+.cp-navlink:hover {
+ background: var(--cp-admin-sidebar-hover);
+ color: var(--cp-admin-sidebar-text);
+}
+
+.cp-navlink[aria-current="page"] {
+ background: var(--cp-admin-sidebar-active-bg);
+ color: var(--cp-admin-sidebar-active-text);
+ font-weight: 700;
+}
+
+.cp-sidebar__footer {
+ margin-top: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--cp-admin-space-1);
+ padding-top: var(--cp-admin-space-4);
+ border-top: 1px solid var(--cp-admin-brand-navy-soft);
+}
+
+.cp-sidebar__footer form { margin: 0; }
+
+/* Theme-Umschalter im (dunklen) Sidebar-Fuß. */
+.cp-iconbtn {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px;
+ height: 44px;
+ border: 0;
+ border-radius: var(--cp-admin-radius-md);
+ background: none;
+ color: var(--cp-admin-sidebar-text);
+ font: inherit;
+ cursor: pointer;
+ text-decoration: none;
+}
+.cp-topbar__theme { margin: 0; display: inline-flex; }
+
+/* Zähler-Badge auf der Glocke (ungelesene Benachrichtigungen). */
+.cp-badge {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ border-radius: 999px;
+ background: var(--cp-admin-red);
+ color: var(--cp-admin-on-brand);
+ font-size: 0.65rem;
+ font-weight: 700;
+ line-height: 1;
+ display: grid;
+ place-items: center;
+}
+
+/* Ungelesene Benachrichtigung: Akzentstreifen (zusätzlich zum „Neu"-Tag). */
+.cp-card--ungelesen { border-left: 3px solid var(--cp-admin-teal); }
+
+/* Mobile-Topbar (nur unter dem Breakpoint sichtbar). */
+.cp-admin-topbar {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 56px;
+ z-index: 20;
+ align-items: center;
+ gap: var(--cp-admin-space-3);
+ padding: 0 var(--cp-admin-space-4);
+ background: var(--cp-admin-sidebar-bg);
+ color: var(--cp-admin-on-brand);
+}
+
+.cp-admin-topbar__title {
+ font-family: var(--cp-admin-font-display);
+ font-size: 1.1rem;
+}
+
+.cp-hamburger {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px;
+ height: 44px;
+ border: 0;
+ background: none;
+ color: var(--cp-admin-on-brand);
+ cursor: pointer;
+}
+
+.cp-overlay {
+ position: fixed;
+ inset: 0;
+ z-index: 25;
+ background: rgba(0, 0, 0, 0.5);
+}
+.cp-overlay[hidden] { display: none; }
+
+.cp-admin-main {
+ margin-left: var(--cp-admin-sidebar-width);
+ min-height: 100vh;
+}
+
+.cp-page {
+ width: 100%;
+ max-width: 1080px;
+ margin-inline: auto;
+ padding: var(--cp-admin-space-6);
+}
+
+/* =============================================================================
+ Off-Canvas unter dem Breakpoint
+ ============================================================================= */
+@media (max-width: 899px) {
+ .cp-admin-topbar { display: flex; }
+
+ .cp-sidebar {
+ width: min(86vw, 320px);
+ transform: translateX(-100%);
+ transition: transform var(--cp-admin-transition);
+ box-shadow: var(--cp-admin-shadow-soft);
+ }
+ body.cp-sidebar-open .cp-sidebar { transform: translateX(0); }
+ /* Hintergrund nicht mitscrollen, solange das Overlay offen ist. */
+ body.cp-sidebar-open { overflow: hidden; }
+
+ .cp-admin-main {
+ margin-left: 0;
+ padding-top: 56px;
+ }
+ .cp-page { padding: var(--cp-admin-space-4); }
+}
+
+/* =============================================================================
+ Typografie
+ ============================================================================= */
+h1 {
+ margin: 0 0 var(--cp-admin-space-4);
+ font-family: var(--cp-admin-font-display);
+ font-weight: 400;
+ font-size: 1.7rem;
+ line-height: 1.15;
+ color: var(--cp-admin-heading);
+}
+
+h2 {
+ margin: var(--cp-admin-space-5) 0 var(--cp-admin-space-3);
+ font-weight: 700;
+ font-size: 1.2rem;
+ color: var(--cp-admin-heading);
+}
+
+.cp-muted { color: var(--cp-admin-muted); }
+
+/* Profilbild auf der Profilseite. */
+.cp-profilbild {
+ width: 96px;
+ height: 96px;
+ border-radius: 50%;
+ object-fit: cover;
+}
+.cp-profilbild--leer {
+ display: grid;
+ place-items: center;
+ background: var(--cp-admin-teal-soft);
+ color: var(--cp-admin-teal-dark);
+ font-weight: 700;
+ font-size: 1.6rem;
+}
+
+/* =============================================================================
+ Formulare
+ ============================================================================= */
+.cp-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--cp-admin-space-4);
+ margin-block: var(--cp-admin-space-5);
+ max-width: 560px;
+}
+
+.cp-field { display: flex; flex-direction: column; gap: var(--cp-admin-space-2); }
+.cp-field label { font-weight: 600; color: var(--cp-admin-text); }
+
+.cp-field input,
+.cp-field select,
+.cp-field textarea {
+ min-height: 44px;
+ padding: 0 var(--cp-admin-space-3);
+ border: 1px solid var(--cp-admin-line);
+ border-radius: var(--cp-admin-radius-sm);
+ background: var(--cp-admin-surface);
+ color: var(--cp-admin-text);
+ font: inherit;
+}
+.cp-field textarea { padding: var(--cp-admin-space-3); min-height: 88px; resize: vertical; }
+
+/* =============================================================================
+ Buttons
+ ============================================================================= */
+.cp-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 44px;
+ padding: 0 var(--cp-admin-space-5);
+ border: 1px solid transparent;
+ border-radius: var(--cp-admin-radius-md);
+ font: inherit;
+ font-weight: 600;
+ text-decoration: none;
+ cursor: pointer;
+}
+.cp-btn--primary { background: var(--cp-admin-teal-ui); color: var(--cp-admin-on-brand); box-shadow: var(--cp-admin-shadow-action); }
+.cp-btn--urgent { background: var(--cp-admin-red); color: var(--cp-admin-on-brand); }
+.cp-btn--ghost { background: var(--cp-admin-surface); color: var(--cp-admin-text); border-color: var(--cp-admin-line); }
+
+/* =============================================================================
+ Flash, Liste, Tags, Karten, Aktionen
+ ============================================================================= */
+.cp-flash {
+ list-style: none;
+ margin: 0 0 var(--cp-admin-space-4);
+ padding: var(--cp-admin-space-3) var(--cp-admin-space-4);
+ border: 1px solid var(--cp-admin-line);
+ border-radius: var(--cp-admin-radius-md);
+ background: var(--cp-admin-surface-soft);
+ color: var(--cp-admin-text);
+}
+
+.cp-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--cp-admin-space-3);
+}
+
+.cp-tag {
+ display: inline-block;
+ margin-left: var(--cp-admin-space-2);
+ padding: 2px var(--cp-admin-space-2);
+ border-radius: var(--cp-admin-radius-sm);
+ background: var(--cp-admin-status-neutral-bg);
+ color: var(--cp-admin-status-neutral-text);
+ font-size: 0.82rem;
+ font-weight: 600;
+}
+.cp-tag--urgent { background: var(--cp-admin-status-error-bg); color: var(--cp-admin-status-error-text); margin-left: 0; }
+
+.cp-card {
+ display: flex;
+ flex-direction: column;
+ gap: var(--cp-admin-space-2);
+ padding: var(--cp-admin-space-5);
+ border: 1px solid var(--cp-admin-line);
+ border-radius: var(--cp-admin-radius-lg);
+ background: var(--cp-admin-surface);
+ box-shadow: var(--cp-admin-shadow-soft);
+}
+.cp-card--leer { border-style: dashed; background: var(--cp-admin-surface-soft); box-shadow: none; }
+.cp-card a { color: var(--cp-admin-teal-dark); text-decoration: none; font-weight: 600; }
+
+.cp-actions { display: flex; flex-wrap: wrap; gap: var(--cp-admin-space-2); margin-top: var(--cp-admin-space-2); }
+.cp-actions form { margin: 0; }
+
+/* =============================================================================
+ Tabellen (Kopf in Navy, Statusspalten mit Text – nie nur Farbe)
+ ============================================================================= */
+.cp-table { width: 100%; border-collapse: collapse; background: var(--cp-admin-surface); border-radius: var(--cp-admin-radius-md); overflow: hidden; }
+.cp-table th { padding: var(--cp-admin-space-3); background: var(--cp-admin-brand-navy); color: var(--cp-admin-on-brand); text-align: left; font-weight: 700; }
+.cp-table td { padding: var(--cp-admin-space-3); border-top: 1px solid var(--cp-admin-line-soft); }
+
+/* =============================================================================
+ Zwei-Knopf-Umschalter (kann / kann_nicht)
+ ============================================================================= */
+.cp-toggle { display: flex; gap: var(--cp-admin-space-2); margin: var(--cp-admin-space-2) 0 0; }
+.cp-toggle__btn {
+ flex: 1;
+ min-height: 44px;
+ padding: 0 var(--cp-admin-space-3);
+ border: 1px solid var(--cp-admin-line);
+ border-radius: var(--cp-admin-radius-sm);
+ background: var(--cp-admin-surface);
+ color: var(--cp-admin-text);
+ font: inherit;
+ font-weight: 600;
+ cursor: pointer;
+}
+.cp-toggle__btn.is-active {
+ background: var(--cp-admin-teal-soft);
+ border-color: var(--cp-admin-teal);
+ color: var(--cp-admin-teal-dark);
+ font-weight: 700;
+}
+
+/* =============================================================================
+ Chat
+ ============================================================================= */
+.cp-chat { list-style: none; margin: 0 0 var(--cp-admin-space-4); padding: 0; display: flex; flex-direction: column; gap: var(--cp-admin-space-2); }
+.cp-msg { padding: var(--cp-admin-space-2) var(--cp-admin-space-3); border: 1px solid var(--cp-admin-line); border-radius: var(--cp-admin-radius-md); background: var(--cp-admin-surface); }
+.cp-msg--eigen { background: var(--cp-admin-teal-soft); border-color: transparent; align-self: flex-end; }
+.cp-msg__meta { font-size: 0.78rem; color: var(--cp-admin-muted); margin-bottom: 2px; }
+.cp-msg__text { white-space: pre-wrap; word-break: break-word; }
diff --git a/app/static/components.css b/app/static/components.css
new file mode 100644
index 0000000..3472d40
--- /dev/null
+++ b/app/static/components.css
@@ -0,0 +1,417 @@
+/* =============================================================================
+ CheckPoint Ehrenamt · components.css
+ Komponenten- und Grund-CSS. Baut ausschließlich auf den Tokens aus tokens.css
+ auf – nirgends feste Weiß-/Schwarzwerte, damit der Dunkelmodus greift.
+
+ Session 1: nur Reset + Grundtypografie. Topbar, Bottom-Nav, Karten und der
+ .cp-page-Verlauf folgen in Session 4 (dort konsequent auf Tokens umgestellt).
+ ============================================================================= */
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: var(--cp-user-bg);
+ color: var(--cp-user-text);
+ font-family: var(--cp-user-font);
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* Sichtbarer Fokus ist Pflicht (Barrierearmut, siehe CLAUDE.md). */
+:focus-visible {
+ outline: 2px solid var(--cp-user-teal-dark);
+ outline-offset: 2px;
+}
+
+/* Mobile-first Seitencontainer (App-Breite 360–460px, viel Weißraum). */
+.cp-page {
+ width: 100%;
+ max-width: 460px;
+ margin-inline: auto;
+ padding: var(--cp-user-space-4);
+}
+
+/* Wenn Topbar + Bottom-Nav da sind: Inhalt darunter/darüber freihalten. */
+.cp-has-nav .cp-page {
+ padding-top: calc(56px + var(--cp-user-space-4));
+ padding-bottom: calc(64px + var(--cp-user-space-5));
+}
+
+/* Mobile-first bleibt der Standard; auf Tablet/Desktop wächst die zentrierte
+ Spalte (inkl. der fixierten Topbar/Bottom-Nav) auf eine angenehme Breite. */
+@media (min-width: 768px) {
+ .cp-page,
+ .cp-topbar,
+ .cp-bottomnav {
+ max-width: 720px;
+ }
+}
+
+/* =============================================================================
+ Topbar (fix oben) – nutzt Tokens, kein festes Weiß (Dunkelmodus greift).
+ ============================================================================= */
+.cp-topbar {
+ position: fixed;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 100%;
+ max-width: 460px;
+ height: 56px;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-inline: var(--cp-user-space-4);
+ background: var(--cp-user-surface);
+ border-bottom: 1px solid var(--cp-user-line);
+}
+
+.cp-topbar__logo {
+ font-weight: 800;
+ font-size: 1.1rem;
+ color: var(--cp-user-text);
+ text-decoration: none;
+}
+
+.cp-topbar__actions {
+ display: flex;
+ align-items: center;
+ gap: var(--cp-user-space-2);
+}
+
+.cp-iconbtn {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px;
+ height: 44px;
+ border-radius: var(--cp-user-radius-pill);
+ color: var(--cp-user-text);
+ text-decoration: none;
+ /* Auch als