Compare commits

..
10 Commits
Author SHA1 Message Date
florian.wilde dca4af2a0b Merge docs/features: Vorstellung des Funktionsumfangs 2026-08-16 00:04:31 +02:00
florian.wildeandClaude Opus 5 c168d3cca3 docs: FEATURES.md - Vorstellung des Funktionsumfangs
Dokumentiert alle implementierten Features als "Was kann die
Speisekarte?"-Vorstellung: oeffentliche Speisekarte, Kantinen-Bereich,
Admin-Bereich, Anmeldung/Sicherheit, Mehrsprachigkeit, Betrieb sowie
eine kompakte Versionstabelle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 00:04:24 +02:00
florian.wilde 10be022180 Merge docs/zeitaufwand: Zeitübersicht je Version 2026-07-20 12:52:16 +02:00
florian.wilde 1096993120 docs: ZEITAUFWAND.md - Zeitübersicht je Version
Vergleich der Umsetzungszeit mit Claude gegen eine grobe Schätzung
für einen erfahrenen Entwickler ohne KI-Unterstützung, je Version
seit v1.0.0. Wird bei jeder neuen Version ergänzt.
2026-07-20 12:52:01 +02:00
florian.wilde 8b3921c50e Merge fix/closed-day-icon-and-banner: Schloss-Symbol + durchgehendes Geschlossen-Banner 2026-07-20 12:49:46 +02:00
florian.wilde 63870ba2e6 fix: Schloss-Symbol statt Text + durchgehendes Geschlossen-Banner
Ersetzt den "Tag schließen"-Textlink durch ein Schloss-Symbol
(🔓/🔒) und lässt den "Geschlossen"-Hinweis eines geschlossenen
Tages über alle Essenszeiten-Zeilen durchlaufen (per rowSpan), statt
ihn in jeder Zeile einzeln zu wiederholen – sowohl im Wochenplan als
auch auf der öffentlichen Speisekarte.
2026-07-20 12:49:04 +02:00
florian.wilde e9d4b51fce Merge feature/v1.12.0: Tage als geschlossen markierbar (Feiertage o. Ä.) 2026-07-20 12:35:31 +02:00
florian.wilde 2a2c5affd5 feat: Tage als geschlossen markierbar (Feiertage o. Ä.)
Im Wochenplan lässt sich pro Tag ein optionaler, mehrsprachiger
Schließungsgrund hinterlegen. Geschlossene Tage werden auf der
öffentlichen Speisekarte (Desktop + mobil) und im RSS-Feed als
"Geschlossen" statt mit Essenszeiten angezeigt, im Wochenplan bleibt
"Mahlzeit hinzufügen" für diesen Tag deaktiviert.
2026-07-20 12:35:00 +02:00
florian.wilde 0c05a16f09 Merge fix/locale-fallback-inactive-german: Sprach-Fallback ignoriert deaktiviertes Deutsch 2026-07-20 11:23:30 +02:00
florian.wilde c98a3617a4 fix: Sprach-Fallback ignoriert deaktiviertes Deutsch
getLocale() fiel bei fehlendem/ungültigem Cookie bisher immer fest auf
Deutsch zurück, auch wenn Deutsch im Sprachen-Admin deaktiviert wurde.
Da der Sprachumschalter sich bei nur einer aktiven Sprache zusätzlich
ausblendet, gab es keine Möglichkeit, das über die Oberfläche zu
korrigieren. Fallback nutzt jetzt Deutsch nur noch, wenn es aktiv ist.
2026-07-20 11:22:49 +02:00
19 changed files with 953 additions and 100 deletions
+44
View File
@@ -4,6 +4,50 @@ Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert.
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
Versionierung nach [Semantic Versioning](https://semver.org/lang/de/).
## [1.12.1] – 2026-07-20
### Geändert
- Bedienung geschlossener Tage im Wochenplan überarbeitet: Statt eines
Text-Links am Datum markiert jetzt ein Schloss-Symbol (🔓 zum
Schließen, 🔒 zeigt „geschlossen" an und öffnet per Klick wieder) den
Tag. Ein geschlossener Tag zeigt sowohl im Wochenplan als auch auf
der öffentlichen Speisekarte (Desktop-Tabelle) einen einzigen, über
alle Essenszeiten-Zeilen durchgehenden „Geschlossen"-Hinweis statt
einer Wiederholung in jeder einzelnen Zeile.
## [1.12.0] – 2026-07-20
### Hinzugefügt
- Einzelne Tage als geschlossen markierbar (z. B. Feiertage,
Betriebsausflug) – im Wochenplan lässt sich pro Tag über „Tag
schließen" ein optionaler, mehrsprachiger Grund hinterlegen (inkl.
automatischer Übersetzung per DeepL, sofern konfiguriert). Ein
geschlossener Tag:
- blendet in der Gerichte-Verwaltung des Wochenplans „Mahlzeit
hinzufügen" für diesen Tag aus (bestehende Einträge lassen sich
weiterhin entfernen), lässt sich per „Wieder öffnen" jederzeit
rückgängig machen.
- zeigt auf der öffentlichen Speisekarte (Desktop-Tabelle und
mobile Ansicht) einen „Geschlossen"-Hinweis mit Grund statt der
Essenszeiten-Spalten/-Karte für diesen Tag.
- erscheint im RSS-Feed als eigener Eintrag mit Schließungsgrund
statt eines leeren oder fehlenden Tages.
- Neue Tabellen `closed_days`/`closed_day_translations` (Migration
`0007_closed_days.sql`).
## [1.11.1] – 2026-07-20
### Behoben
- Wurde Deutsch im Sprachen-Admin deaktiviert (z. B. um die App auf nur
Englisch zu reduzieren), blieb die gesamte App trotzdem auf Deutsch
stehen, sowohl im öffentlichen als auch im internen Bereich: `getLocale()`
fiel bei fehlendem/ungültigem Sprach-Cookie bisher immer fest auf
Deutsch zurück, unabhängig davon, ob Deutsch überhaupt noch aktiv war.
Da der Sprachumschalter sich bei nur einer aktiven Sprache zusätzlich
automatisch ausblendet, gab es keine Möglichkeit, das über die
Oberfläche zu korrigieren. Der Fallback nutzt jetzt Deutsch nur noch,
wenn es tatsächlich aktiv ist – sonst die erste aktive Sprache.
## [1.11.0] – 2026-07-20
### Hinzugefügt
+305
View File
@@ -0,0 +1,305 @@
# Was kann die Speisekarte?
Vorstellung aller Funktionen der digitalen Kantinen-Speisekarte
(Stand Version 1.12.1). Technische Details zur Installation stehen in
[DEPLOYMENT.md](DEPLOYMENT.md), die Änderungen je Version in
[CHANGELOG.md](CHANGELOG.md).
## In einem Satz
Eine selbst gehostete Web-Anwendung, mit der das Kantinen-Team den
Wochenplan pflegt und alle Mitarbeiter ihn ohne Login am PC oder Handy
lesen können – dreisprachig, mit Allergen-Kennzeichnung, RSS-Abo,
Rezeptvorschlägen und eigenem Branding.
Drei Bereiche mit getrennten Rechten:
| Bereich | Wer | Zweck |
| --- | --- | --- |
| Öffentliche Speisekarte (`/`) | alle Mitarbeiter, ohne Login | Wochenplan lesen |
| Kantinen-Bereich (`/intern`) | Kantinen-Team (`staff`) und Admins | Gerichte und Wochenplan pflegen |
| Admin-Bereich (`/admin`) | nur Admins | Benutzer, Design, Sprachen |
---
## 1. Für alle Mitarbeiter: die öffentliche Speisekarte
Erreichbar ohne Anmeldung – Link oder QR-Code genügt.
**Wochenplan Montag bis Freitag**
Tabelle mit einer Spalte je Wochentag und einer Zeile je Essenszeit
(z. B. Brotzeit, Mittagessen, Mittagessen vegetarisch, Suppe). Pro
Zelle können mehrere Gerichte stehen. Jedes Gericht wird mit Preis
angezeigt.
**Navigation**
Buttons für vorherige Woche, nächste Woche und „Heute". Beliebige
Wochen lassen sich so vor- und zurückblättern – auch als direkter Link
(`/?woche=YYYY-MM-DD`), praktisch für Aushänge oder Bookmarks.
**Handy-Ansicht**
Auf schmalen Bildschirmen erscheint standardmäßig eine Tagesansicht mit
Tab-Leiste (Mo–Fr) statt der Tabelle; auf Wunsch kann pro Besucher auf
die Wochenansicht umgeschaltet werden. Die Desktop-Tabelle bleibt davon
unberührt.
**Allergene und Zusatzstoffe**
Je Gericht erscheinen die Kürzel (z. B. `A`, `G`) direkt neben dem
Namen, die vollständige Legende steht unterhalb der Karte. Alternativ
lässt sich die Kennzeichnung auf einen einzelnen Hinweistext umstellen
(siehe Abschnitt 2).
**Geschlossene Tage**
Feiertage, Betriebsausflüge oder Urlaub werden als „Geschlossen"
angezeigt – ein einziges, über alle Essenszeiten-Zeilen durchgehendes
Banner mit optionalem Grund („Betriebsferien"), statt leerer Zellen.
**Sprachumschalter**
Umschalten der kompletten Oberfläche zwischen den freigeschalteten
Sprachen (ausgeliefert mit Deutsch, Englisch, Polnisch). Die Auswahl
wird per Cookie gemerkt, die Seite muss dafür nicht neu geladen werden.
Ist nur eine Sprache aktiv, verschwindet der Umschalter automatisch.
**RSS-Feed zum Abonnieren**
`/feed.xml` liefert einen Eintrag pro Tag (aktuelle und nächste Woche)
mit allen Gerichten je Essenszeit inkl. Preis; geschlossene Tage
erscheinen mit Schließungsgrund. Sichtbares RSS-Icon im Kopfbereich
plus Autodiscovery im Seiten-`<head>`, damit Outlook, Thunderbird & Co.
den Feed automatisch finden. Der Inhalt wird zusätzlich als
`content:encoded` ausgeliefert, damit Reader ihn nicht nach der ersten
Essenszeit abschneiden.
**Rezept vorschlagen**
Jeder Besucher kann ohne Login ein fehlendes Gericht vorschlagen (Name
+ optionale Beschreibung). Eingebaut: Dublettenprüfung gegen bestehende
Gerichte und ein unsichtbares Honeypot-Feld als Spamschutz.
**Eigenes Erscheinungsbild**
Firmenlogo zentriert über der Karte, eigenes Favicon im Browser-Tab,
Farben nach Corporate Design – alles vom Admin einstellbar, ohne
Codeänderung.
---
## 2. Für das Kantinen-Team (`/intern`)
Nach dem Login sichtbar, gemeinsame Navigation für alle Unterseiten,
auf dem Handy als Hamburger-Menü.
### Wochenplan
- Gerichte per **Suchfeld** einer Zelle (Tag + Essenszeit) zuweisen –
keine langen Dropdown-Listen zum Durchscrollen.
- Mehrere Gerichte pro Zelle, einzeln wieder entfernbar.
- Wochen-Navigation inkl. „Heute"-Button wie in der öffentlichen Ansicht.
- **Tag schließen**: Schloss-Symbol am Datum (🔓 schließt, 🔒 öffnet
wieder) mit optionalem, mehrsprachigem Grund. Für geschlossene Tage
wird „Mahlzeit hinzufügen" ausgeblendet; bereits eingetragene
Gerichte bleiben entfernbar.
- Auf dem Handy Tag-für-Tag-Ansicht mit Tab-Leiste; beide Ansichten
teilen denselben Bearbeitungsstand.
### Gerichte
- Anlegen, bearbeiten, löschen – mit Name, Preis, Allergenen und
Zusatzstoffen (Chip-Auswahl).
- **Live-Suche** in der Gerichteliste.
- **Mehrfachauswahl** zum Löschen vieler Gerichte in einem Schritt.
- Ein Namensfeld je aktiver Sprache, mit Button **„Automatisch
übersetzen"** (DeepL) – das Ergebnis bleibt danach frei korrigierbar.
- **CSV-Export/-Import**: aktuelle Liste als CSV herunterladen,
bearbeiten, wieder hochladen. Spalten
`name,name_en,…,price,allergens,additives` (Kürzel semikolongetrennt,
z. B. `A;G`), Spaltenreihenfolge und Groß-/Kleinschreibung der
Kopfzeile egal. Bestehende Gerichte werden über den Namen erkannt und
aktualisiert, neue angelegt. Fehlerhafte Zeilen (Pflichtfeld fehlt,
ungültiger Preis, mehrdeutiger Name) werden übersprungen und mit
Grund aufgelistet, statt den ganzen Import abzubrechen.
### Essenszeiten
Vollständig verwaltbar statt fest im Code: anlegen, umbenennen (je
Sprache), Uhrzeit ändern, per Pfeil-Buttons neu anordnen, löschen.
Beim Löschen werden zugewiesene Gerichte automatisch aus allen
Wochenplänen entfernt.
### Allergene & Zusatzstoffe
- Globale Listen pflegen (Kürzel + Name je Sprache, Sortierung),
ebenfalls mit DeepL-Übersetzung und CSV-Export/-Import.
- **Zwei Betriebsmodi, umschaltbar:**
1. *Detailliert* – Auswahl je Gericht, Kürzel am Gericht, Legende
unter der Karte.
2. *Hinweistext* – ein einziger, mehrsprachig editierbarer Satz
(z. B. „Bei Fragen zu Allergenen und Zusatzstoffen wenden Sie sich
bitte an das Kantinen-Team."). Kürzel, Legende und die
Allergen-Auswahl in der Gerichteverwaltung verschwinden;
zugeordnete Daten bleiben in der Datenbank erhalten und sind beim
Zurückschalten wieder da.
### Rezeptvorschläge
Eingereichte Vorschläge sichten, direkt als Gericht **übernehmen** oder
**ablehnen**. Die Anzahl offener Vorschläge steht als rote Zahl im
Navigationspunkt „Vorschläge".
### Eigenes Konto
Name, E-Mail und Passwort ändern, Passkeys registrieren und entfernen,
Passwort-Anmeldung für das eigene Konto abschalten.
---
## 3. Für Admins (`/admin`)
### Benutzerverwaltung
Kantinenmitarbeiter anlegen, bearbeiten und löschen; Rolle vergeben
(Kantinenmitarbeiter oder Admin); Passwort zurücksetzen – jeweils mit
Wiederholungsfeld gegen Tippfehler.
### Design
- Acht Farben per Farbwähler einstellbar: Primär, Primär dunkel,
Akzent, Kopfzeilentext, Zeilenhintergrund, Zeilenhintergrund
alternierend, Hinzufügen-Button, Löschen-Button. Auf Werkszustand
zurücksetzbar.
- Logo und Favicon hochladen oder entfernen. Aus Sicherheitsgründen
nur Rasterformate (png/jpg/webp/gif/ico) – SVG ist bewusst nicht
erlaubt, da eingebettetes Script auf der eigenen Domain ausgeführt
würde.
### Sprachen
Sprachen sind zur Laufzeit verwaltbar, ohne neues Deployment:
- Vorlage der deutschen Oberflächentexte herunterladen, übersetzt
wieder hochladen – die neue Sprache ist sofort aktiv.
- Je Sprache wird **Vollständigkeit in %** angezeigt sowie „N neue
Begriffe seit letztem Import", falls eine App-Version zusätzliche
Texte mitgebracht hat. Fehlende Begriffe fallen automatisch auf
Deutsch zurück, bleiben also nie leer.
- Neue Sprachen selbst anlegen (Code + Anzeigename). Sobald eine
Sprache aktiv ist, erscheint in der Gerichte-, Essenszeiten- und
Kennzeichnungsverwaltung automatisch ein Eingabefeld dafür – und im
CSV-Export eine zusätzliche Spalte (`name_fr`, …).
- Sprachen einzeln deaktivieren, bis hinunter auf eine einzige;
Deutsch bleibt als garantiert vollständige Basissprache unlöschbar.
- Der Import prüft die Datei gegen die aktuelle Vorlage: Platzhalter
wie `{message}` müssen erhalten bleiben, unbekannte Schlüssel werden
ignoriert statt einen Fehler zu werfen.
---
## 4. Anmeldung und Sicherheit
**Drei Anmeldewege, kombinierbar**
1. **E-Mail + Passwort** – Hashing mit `scrypt` aus der
Node-Standardbibliothek.
2. **Passkey** (WebAuthn, z. B. Windows Hello, Face ID, Sicherheits-
schlüssel) – im eigenen Konto nach dem ersten Login registrierbar.
Die Passwort-Anmeldung lässt sich pro Konto abschalten, sobald
mindestens ein Passkey vorhanden ist; das wird serverseitig
erzwungen, nicht nur im Browser geprüft.
3. **SSO per OpenID Connect** (z. B. gegen ein selbst gehostetes
Authentik) – Authorization-Code-Flow mit PKCE, Signaturprüfung des
ID-Tokens gegen das JWKS des Providers, State/Nonce serverseitig
kurzlebig und nur einmal verwendbar. Aktiviert sich über drei
Umgebungsvariablen; ohne sie bleibt der SSO-Button unsichtbar.
Wichtig: SSO legt **nie** neue Konten an und übernimmt **keine**
Rollen aus Claims – nur bereits unter „Benutzer" angelegte
E-Mail-Adressen kommen herein, die Rolle bleibt allein in der
Benutzerverwaltung steuerbar.
**Sitzungen**
Opake Tokens, nur als Hash in der Datenbank, Cookie mit
`httpOnly`/`sameSite=lax`. Laufzeit 30 Tage mit gleitender
Verlängerung: fällt die Restlaufzeit unter 7 Tage, wird sie beim
nächsten Aufruf erneuert – statt bei jedem Request zu schreiben.
**Zugriffsschutz**
`/intern`, `/admin` und `/konto` sind serverseitig geschützt; jede
API-Route prüft zusätzlich die Rolle. Öffentlich lesbar sind nur
Speisekarte, Essenszeiten, Kennzeichnungen und Logo/Favicon.
**Weitere Härtung**
CSRF-Origin-Check, HTTP-Security-Header für alle Routen
(`X-Frame-Options: DENY`, `frame-ancestors 'none'`, `nosniff`,
`Referrer-Policy`, restriktive `Permissions-Policy`), Datenbank ohne
Host-Port (nur im internen Docker-Netz erreichbar), Upload-Whitelist
für Bildformate.
**Erster Start**
Ist die Datenbank leer, wird automatisch genau ein Admin mit
Zufallspasswort angelegt und die Zugangsdaten nach `ZUGANGSDATEN.md`
geschrieben (nicht im Repository). Klappt das Schreiben nicht, landen
sie im Container-Log statt verloren zu gehen.
---
## 5. Mehrsprachigkeit im Detail
Zwei Ebenen, beide zur Laufzeit erweiterbar:
| Ebene | Was | Verwaltung |
| --- | --- | --- |
| Oberfläche | alle Buttons, Beschriftungen, Meldungen | Admin → Sprachen (Vorlage exportieren/importieren) |
| Inhalte | Gerichtnamen, Essenszeiten, Allergene, Zusatzstoffe, Schließungsgründe | ein Feld je aktiver Sprache in der jeweiligen Verwaltung |
Beides ist **nicht** auf Deutsch/Englisch/Polnisch begrenzt – eine neu
angelegte Sprache erscheint automatisch überall. Übersetzungen können
per DeepL vorbefüllt werden (in alle aktiven, von DeepL unterstützten
Sprachen gleichzeitig) und bleiben danach manuell korrigierbar. Fehlt
eine Übersetzung, greift der Deutsch-Fallback; ist Deutsch selbst
deaktiviert, die erste aktive Sprache.
DeepL ist optional: ohne `DEEPL_API_KEY` verschwindet nur der Button
„Automatisch übersetzen", alle Felder bleiben von Hand ausfüllbar.
---
## 6. Betrieb
- **Vollständig selbst gehostet.** Einzige optionale externe
Abhängigkeit ist DeepL. Keine Cloud-Datenbank, kein
Auth-Dienstleister, kein Storage-Anbieter.
- **Stack:** Next.js 16 (App Router, TypeScript, Tailwind CSS),
PostgreSQL über `pg` mit eigenen SQL-Migrationen.
- **Docker Compose** für Produktion und Entwicklung (Dev-Profil mit
Hot-Reload, ohne lokal installiertes Node.js/PostgreSQL).
- **Migrationen laufen bei jedem Start automatisch** und idempotent –
ein Update braucht keinen manuellen Datenbankschritt.
- **`update.sh`** für Linux-Deployments: Stack stoppen, lokale
Änderungen an `docker-compose.yml` sichern, `git pull`, Änderungen
zurückspielen, neu bauen und starten. Bricht bei Konflikten
kontrolliert ab, statt mit halbem Stand weiterzulaufen.
- **`npm run db:seed-demo`** spielt einen Beispieldatensatz für Demos
ein (Essenszeiten, Kennzeichnungen, Testgerichte, zwei Wochen).
Frische Deployments starten bewusst leer.
- **Versionsnummer im Fußbereich** jeder Seite, Git-Tags markieren die
Versionsstände.
---
## 7. Wann kam was
| Version | Kern |
| --- | --- |
| 1.0.0 | Öffentliche Speisekarte, Login (Passwort + Passkey), Kantinen- und Admin-Bereich, Docker-Setup |
| 1.1.0 | Versionsnummer im Fußbereich |
| 1.2.0 | „Heute"-Button, mobile Tages-/Wochenansicht, Allergen-Chips, Passwort-Wiederholung, Favicon, `SITE_TITLE` |
| 1.3.0 | Mehrsprachigkeit DE/EN/PL, DeepL, Rezeptvorschläge inkl. Sichtung |
| 1.4.0 | Essenszeiten frei verwaltbar |
| 1.5.0 | Supabase komplett entfernt – eigene Datenbank, Auth, Passkeys, Datei-Ablage |
| 1.6.x | RSS-Feed, Vorschläge-Badge, DEPLOYMENT.md, DB-Port geschlossen, Bugfixes |
| 1.7.0 | CSV-Import/-Export für Gerichte, Allergene, Zusatzstoffe |
| 1.8.x | Umschaltbarer Hinweismodus für Kennzeichnungen, `update.sh` |
| 1.9.0 | SSO per OpenID Connect |
| 1.9.5 | Interner Bereich für Mobilgeräte nutzbar |
| 1.10.0 | Sprachen zur Laufzeit verwaltbar (Oberfläche) |
| 1.11.x | Sprachen zur Laufzeit verwaltbar (Inhalte), Sprach-Fallback-Fix |
| 1.12.x | Tage als geschlossen markierbar, Schloss-Symbol und durchgehendes Banner |
Ausführlich in [CHANGELOG.md](CHANGELOG.md), der Zeitaufwand je Version
in [ZEITAUFWAND.md](ZEITAUFWAND.md).
+54
View File
@@ -0,0 +1,54 @@
# Zeitaufwand
Übersicht, wie lange die Umsetzung der einzelnen Versionen mit Claude
gedauert hat, zum Vergleich mit der Schätzung für einen erfahrenen
Entwickler ohne KI-Unterstützung. Wird bei jeder neuen Version
ergänzt.
## Methodik
- **„Zeit bei Claude"** basiert auf den Git-Commit-Zeitstempeln
zwischen zwei Versions-Tags. Das ist kein reiner Rechenzeit-Wert,
sondern enthält auch die Zeit, die der Nutzer zum Lesen/Antworten
gebraucht hat – aber es ist der einzige objektiv messbare Anhaltspunkt.
- Große Lücken zwischen zwei Tags (mehrere Stunden bis über einen Tag)
markieren eindeutig eine neue Sitzung, kein durchgehendes Arbeiten –
diese sind als „neue Sitzung" markiert statt als Zeitangabe.
- **„Erfahrener Entwickler"** ist eine grobe Schätzung für jemanden,
der mit dem Stack (Next.js, PostgreSQL, TypeScript) vertraut, aber
neu in diesem Projekt ist, inkl. Testen/Verifikation, exkl. Code-Review
durch Dritte.
## Übersicht
| Version | Datum | Kern der Version | Zeit bei Claude | Erfahrener Entwickler |
|---|---|---|---|---|
| v1.0.0/v1.1.0 | 17.07. | Grundgerüst: öffentliche Speisekarte, Login (Passwort+Passkey), Kantinen-/Admin-Bereich, Supabase-Schema+RLS, Docker-Setup, lokale PG-Kopie | nicht messbar (erster Commit) | 3–5 Tage (24–40 h) |
| v1.2.0 | 18.07. | Heute-Button, mobile Tages-/Wochenansicht, Allergen-Chips, Passwort-Wiederholung, Passkey-Pflicht, SITE_TITLE/Favicon, UX-Politur | 56 min | 1–1,5 Tage (8–12 h) |
| v1.3.0 | 18.07. | Volle Mehrsprachigkeit DE/EN/PL, DeepL-Anbindung, Rezeptvorschläge (öffentlich + Sichtung), 2 Bugfixes | 1 h 23 min | 1,5–2 Tage (12–16 h) |
| v1.4.0 | 18.07. | Essenszeiten variabel (CRUD + Sortierung), 2 Bugfixes | *(neue Sitzung)* | 0,5–1 Tag (4–8 h) |
| v1.5.0 | 18.07. | **Supabase komplett entfernt**: eigene DB/Auth/Passkeys/Storage, 2 Sicherheitsfixes (Passkey-Pflicht serverseitig, SVG-XSS) | 3 h 26 min | 2–3 Tage (16–24 h) |
| v1.6.0 | 18.07. | RSS-Feed, Vorschläge-Badge, DEPLOYMENT.md, Supabase-Doku-Reste entfernt | 33 min | 0,5–1 Tag (4–8 h) |
| v1.6.1 | 18.07. | DB-Port abgesichert, Troubleshooting-Doku | 1 h 58 min | 1–2 h |
| v1.6.2 | 18.07. | Bootstrap-Admin-Fehlerbehandlung + Doku-Fix | 22 min | 1–2 h |
| v1.6.3 | 18.07. | Docker `BRANDING_DIR`-Bugfix | 20 min | 0,5–1 h |
| v1.7.0 | 19.07. | CSV-Import/-Export (3 Ressourcen), RSS-Fix | *(neue Sitzung)* | 0,5–1 Tag (4–8 h) |
| v1.8.0 | 19.07. | Umschaltbarer Hinweismodus für Allergene/Zusatzstoffe | 45 min | 0,5–1 Tag (4–6 h) |
| v1.8.1 | 20.07. | `update.sh` Deployment-Skript | 10 min | 1–2 h |
| v1.9.0 | 20.07. | SSO-Login per OpenID Connect (PKCE, JWKS-Prüfung) | 26 min | 1–1,5 Tage (8–12 h) |
| v1.9.5 | 20.07. | Interner Bereich mobil nutzbar (Hamburger-Nav, Tab-Wochenansicht, 5 Komponenten) | 21 min | 1 Tag (6–10 h) |
| v1.10.0 | 20.07. | Dynamisches Sprachpaket-System (Teil 1 Mehrsprachig-Umbau) | *(neue Sitzung)* | 1–1,5 Tage (8–12 h) |
| v1.11.0 | 20.07. | Speiseplan-Inhalte variabel (Teil 2, inkl. Produktionsdaten-Migration) | 35 min | 1–1,5 Tage (8–12 h) |
| v1.11.1 | 20.07. | Sprach-Fallback-Bugfix (deaktiviertes Deutsch) | 12 min | 0,5–1,5 h |
| v1.12.0 | 20.07. | Tage als geschlossen markierbar (Feiertage o. Ä.), mehrsprachiger Grund, RSS-Integration | 1 h 12 min | 0,5–1 Tag (4–8 h) |
| v1.12.1 | 20.07. | Schloss-Symbol statt Textlink, durchgehendes Geschlossen-Banner über alle Essenszeiten | 14 min | 2–4 h |
| **Gesamt** | | | **~12,3 h messbare aktive Zeit** (+ 1 nicht messbarer Grundgerüst-Block) | **~114–187 h ≈ 15–24 Arbeitstage** (real eher 1–2 Monate Kalenderzeit) |
## Warum der Unterschied?
Der Zeitvorteil kommt vor allem daher, dass zwischen Planung,
Umsetzung und Test keine Wartezeit liegt (Code-Review, Freigaben,
andere Aufgaben dazwischen) – nicht daher, dass grundsätzlich
"schneller gedacht" wird. Ein Mensch bräuchte für dieselbe Sorgfalt
vermutlich nicht weniger Zeit pro Aufgabe, aber deutlich mehr
Kalenderzeit durch Kontextwechsel.
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { removeClosedDay } from "@/lib/db/closed-days";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ date: string }> }
) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const { date } = await params;
await removeClosedDay(date);
return NextResponse.json({ ok: true });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { listClosedDays, setClosedDay } from "@/lib/db/closed-days";
import { authErrorResponse, requireRole } from "@/lib/auth/session";
import { isSameOrigin } from "@/lib/origin";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const from = searchParams.get("from");
const to = searchParams.get("to");
if (!from || !to) {
return NextResponse.json({ error: "invalid_input" }, { status: 400 });
}
return NextResponse.json(await listClosedDays(from, to));
}
export async function POST(request: Request) {
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
}
try {
await requireRole(["staff", "admin"]);
const body = (await request.json()) as {
closed_date: string;
reason: string | null;
translations?: Record<string, string | null>;
};
const closedDay = await setClosedDay(body.closed_date, body.reason, body.translations);
return NextResponse.json(closedDay, { status: 201 });
} catch (err) {
const res = authErrorResponse(err);
if (res) return res;
throw err;
}
}
+1
View File
@@ -14,6 +14,7 @@ const ALLOWED_TABLES: Record<string, { idColumn: string; translationTable: Trans
sections: { idColumn: "key", translationTable: "section_translations" },
allergens: { idColumn: "code", translationTable: "allergen_translations" },
additives: { idColumn: "code", translationTable: "additive_translations" },
closed_days: { idColumn: "closed_date", translationTable: "closed_day_translations" },
};
// Unsere Sprachcodes -> DeepL target_lang. Sprachen ohne Eintrag hier sind
+25 -16
View File
@@ -1,5 +1,6 @@
import { query } from "@/lib/db";
import { loadMenuEntries } from "@/lib/db/menu-entries";
import { listClosedDays } from "@/lib/db/closed-days";
import { addDays, entriesFor, formatPrice, mondayOf, todayISO } from "@/lib/menu";
import { localized, SITE_TITLE } from "@/lib/types";
import type { Section } from "@/lib/types";
@@ -45,30 +46,38 @@ export async function GET() {
// Wochennavigation anzeigt.
const rangeEnd = addDays(monday, 11);
const [sections, entries] = await Promise.all([
const [sections, entries, closedDays] = await Promise.all([
query<Section>("select * from sections order by sort"),
loadMenuEntries(monday, rangeEnd),
listClosedDays(monday, rangeEnd),
]);
const days = Array.from(new Set(entries.map((e) => e.menu_date))).sort();
const days = Array.from(
new Set([...entries.map((e) => e.menu_date), ...closedDays.map((c) => c.closed_date)])
).sort();
const items = days
.map((iso) => {
const dayContent = sections
.map((s) => {
const dishes = entriesFor(entries, iso, s.key);
if (dishes.length === 0) return "";
const list = dishes
.map(
(e) =>
`<li>${escapeXml(localized(e.dish, "de"))} – ${formatPrice(e.dish.price)}</li>`
)
const closed = closedDays.find((c) => c.closed_date === iso);
const dayContent = closed
? `<p><strong>Geschlossen</strong>${
closed.reason ? ` – ${escapeXml(closed.reason)}` : ""
}</p>`
: sections
.map((s) => {
const dishes = entriesFor(entries, iso, s.key);
if (dishes.length === 0) return "";
const list = dishes
.map(
(e) =>
`<li>${escapeXml(localized(e.dish, "de"))} – ${formatPrice(e.dish.price)}</li>`
)
.join("");
return `<p><strong>${escapeXml(localized(s, "de"))} (${escapeXml(
s.time_range
)})</strong></p><ul>${list}</ul>`;
})
.join("");
return `<p><strong>${escapeXml(localized(s, "de"))} (${escapeXml(
s.time_range
)})</strong></p><ul>${list}</ul>`;
})
.join("");
if (!dayContent) return "";
const [y, m, d] = iso.split("-").map(Number);
+4
View File
@@ -102,6 +102,10 @@ body {
font-size: 0.85rem;
}
.menu-table tr:nth-child(even) td.cell { background: var(--c-row-bg-alt); }
.menu-table td.cell.closed-cell {
vertical-align: middle;
background: #fee2e2;
}
.menu-table td.cell .dish { margin-bottom: 0.35rem; }
.menu-table td.cell .dish sup {
font-size: 0.6rem;
+6 -1
View File
@@ -7,5 +7,10 @@ export default async function InternPage({
searchParams: Promise<{ woche?: string }>;
}) {
const { woche } = await searchParams;
return <WeekEditor monday={mondayOf(woche)} />;
return (
<WeekEditor
monday={mondayOf(woche)}
translateEnabled={!!process.env.DEEPL_API_KEY}
/>
);
}
+43 -17
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { query } from "@/lib/db";
import { loadMenuEntries } from "@/lib/db/menu-entries";
import { loadTranslations } from "@/lib/db/translations";
import { listClosedDays } from "@/lib/db/closed-days";
import { getSettings } from "@/lib/db/settings";
import {
addDays,
@@ -43,6 +44,7 @@ export default async function Home({
sectionTranslations,
allergenTranslations,
additiveTranslations,
closedDays,
] = await Promise.all([
query<Omit<Section, "translations">>("select key, label, time_range, sort from sections order by sort"),
loadMenuEntries(monday, friday),
@@ -52,7 +54,9 @@ export default async function Home({
loadTranslations("section_translations"),
loadTranslations("allergen_translations"),
loadTranslations("additive_translations"),
listClosedDays(monday, friday),
]);
const closedDayFor = (iso: string) => closedDays.find((c) => c.closed_date === iso);
const sections: Section[] = sectionRows.map((s) => ({
...s,
@@ -130,6 +134,7 @@ export default async function Home({
monday={monday}
sections={sections}
entries={entries}
closedDays={closedDays}
showLabeling={labelingDetailed}
/>
@@ -165,31 +170,52 @@ export default async function Home({
</tr>
</thead>
<tbody>
{sections.map((s) => (
{sections.map((s, sIdx) => (
<tr key={s.key}>
<th className="section-head">
<div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => (
<td key={d.iso} className="cell">
{entriesFor(entries, d.iso, s.key).map((e) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish">
<span className="font-bold">
{localized(e.dish, locale)}
{days.map((d) => {
const closed = closedDayFor(d.iso);
if (closed) {
if (sIdx > 0) return null;
return (
<td key={d.iso} className="cell closed-cell" rowSpan={sections.length}>
<div className="flex flex-col items-center justify-center gap-1 py-4 text-red-700">
<span className="text-2xl" aria-hidden>
🔒
</span>
{labelingDetailed && codes && (
<sup className="dish-codes">{codes}</sup>
<span className="font-semibold">{dict.publicMenu.closedLabel}</span>
{localized(closed, locale) && (
<span className="text-sm px-2 text-center">
{localized(closed, locale)}
</span>
)}
{" – "}
{formatPrice(e.dish.price)}
</div>
);
})}
</td>
))}
</td>
);
}
return (
<td key={d.iso} className="cell">
{entriesFor(entries, d.iso, s.key).map((e) => {
const codes = dishCodes(e.dish);
return (
<div key={e.id} className="dish">
<span className="font-bold">
{localized(e.dish, locale)}
</span>
{labelingDetailed && codes && (
<sup className="dish-codes">{codes}</sup>
)}
{" – "}
{formatPrice(e.dish.price)}
</div>
);
})}
</td>
);
})}
</tr>
))}
</tbody>
+47 -36
View File
@@ -19,7 +19,7 @@ import {
weekdayIndex,
} from "@/lib/menu";
import { localized } from "@/lib/types";
import type { MenuEntry, Section } from "@/lib/types";
import type { ClosedDay, MenuEntry, Section } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
import LanguageSwitcher from "@/components/i18n/LanguageSwitcher";
@@ -27,11 +27,13 @@ export default function PublicMobileMenu({
monday,
sections,
entries,
closedDays,
showLabeling,
}: {
monday: string;
sections: Section[];
entries: MenuEntry[];
closedDays: ClosedDay[];
showLabeling: boolean;
}) {
const router = useRouter();
@@ -60,47 +62,56 @@ export default function PublicMobileMenu({
}
function renderDay(day: { weekday: string; iso: string }) {
const closed = closedDays.find((c) => c.closed_date === day.iso);
return (
<div key={day.iso} className="mb-4 last:mb-0">
<h3 className="font-bold text-[var(--c-primary)] mb-2">
{day.weekday} · {formatDateLong(day.iso, dict.dateLocale)}
</h3>
<div className="space-y-3">
{sections.map((s) => {
const items = entriesFor(entries, day.iso, s.key);
return (
<div
key={s.key}
className="rounded-md border border-gray-200 overflow-hidden"
>
<div className="bg-[var(--c-primary)] text-[var(--c-header-text)] px-3 py-1.5 text-sm">
<span className="font-semibold">{localized(s, locale)}</span>{" "}
<span className="opacity-80">· {s.time_range}</span>
{closed ? (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700 text-center">
<span aria-hidden>🔒</span>{" "}
<span className="font-semibold">{dict.publicMenu.closedLabel}</span>
{localized(closed, locale) && <span> – {localized(closed, locale)}</span>}
</div>
) : (
<div className="space-y-3">
{sections.map((s) => {
const items = entriesFor(entries, day.iso, s.key);
return (
<div
key={s.key}
className="rounded-md border border-gray-200 overflow-hidden"
>
<div className="bg-[var(--c-primary)] text-[var(--c-header-text)] px-3 py-1.5 text-sm">
<span className="font-semibold">{localized(s, locale)}</span>{" "}
<span className="opacity-80">· {s.time_range}</span>
</div>
<div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm">
{items.length === 0 ? (
<p className="text-gray-500">{dict.publicMenu.noEntry}</p>
) : (
items.map((e) => {
const codes = dishCodes(e.dish);
return (
<p key={e.id} className="mb-1 last:mb-0">
<span className="font-semibold">
{localized(e.dish, locale)}
</span>
{showLabeling && codes && (
<sup className="dish-codes">{codes}</sup>
)}{" "}
– {formatPrice(e.dish.price)}
</p>
);
})
)}
</div>
</div>
<div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm">
{items.length === 0 ? (
<p className="text-gray-500">{dict.publicMenu.noEntry}</p>
) : (
items.map((e) => {
const codes = dishCodes(e.dish);
return (
<p key={e.id} className="mb-1 last:mb-0">
<span className="font-semibold">
{localized(e.dish, locale)}
</span>
{showLabeling && codes && (
<sup className="dish-codes">{codes}</sup>
)}{" "}
– {formatPrice(e.dish.price)}
</p>
);
})
)}
</div>
</div>
);
})}
</div>
);
})}
</div>
)}
</div>
);
}
+274 -25
View File
@@ -14,12 +14,19 @@ import {
} from "@/lib/menu";
import { formatMessage } from "@/lib/i18n/shared";
import { localized } from "@/lib/types";
import type { Dish, MenuEntry, Section } from "@/lib/types";
import { useDict, useLocale } from "@/components/i18n/LocaleProvider";
import type { ClosedDay, Dish, MenuEntry, Section } from "@/lib/types";
import { useDict, useLanguages, useLocale } from "@/components/i18n/LocaleProvider";
export default function WeekEditor({ monday }: { monday: string }) {
export default function WeekEditor({
monday,
translateEnabled,
}: {
monday: string;
translateEnabled: boolean;
}) {
const dict = useDict();
const locale = useLocale();
const languages = useLanguages().filter((l) => l.code !== "de");
const friday = addDays(monday, 4);
const days = dict.weekdays.long.map((weekday, i) => ({
weekday,
@@ -32,6 +39,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
const [sections, setSections] = useState<Section[]>([]);
const [dishes, setDishes] = useState<Dish[]>([]);
const [entries, setEntries] = useState<MenuEntry[]>([]);
const [closedDays, setClosedDays] = useState<ClosedDay[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Zelle, in der gerade ein Gericht hinzugefügt wird
@@ -43,6 +51,19 @@ export default function WeekEditor({ monday }: { monday: string }) {
// Mobil: ein Tag auf einmal statt aller fünf gleichzeitig
const [selectedDayIdx, setSelectedDayIdx] = useState(todayIdx >= 0 ? todayIdx : 0);
// Tag, für den gerade das Schließen-Formular offen ist
const [closingIso, setClosingIso] = useState<string | null>(null);
const [closeReason, setCloseReason] = useState("");
const [closeTranslations, setCloseTranslations] = useState<Record<string, string>>({});
const [closeTranslateState, setCloseTranslateState] = useState<"idle" | "busy" | "done">(
"idle"
);
const [closeBusy, setCloseBusy] = useState(false);
function closedDayFor(iso: string): ClosedDay | undefined {
return closedDays.find((c) => c.closed_date === iso);
}
function startAdding(iso: string, section: string) {
setAdding({ iso, section });
setSelectedDish("");
@@ -57,17 +78,19 @@ export default function WeekEditor({ monday }: { monday: string }) {
const load = useCallback(async () => {
setError(null);
const [se, di, en] = await Promise.all([
const [se, di, en, cd] = await Promise.all([
fetch("/api/sections"),
fetch("/api/dishes"),
fetch(`/api/menu-entries?from=${monday}&to=${friday}`),
fetch(`/api/closed-days?from=${monday}&to=${friday}`),
]);
if (!se.ok || !di.ok || !en.ok) {
if (!se.ok || !di.ok || !en.ok || !cd.ok) {
setError(formatMessage(dict.weekEditor.loadError, { message: "load failed" }));
} else {
setSections(await se.json());
setDishes(await di.json());
setEntries(await en.json());
setClosedDays(await cd.json());
}
setLoading(false);
}, [monday, friday, dict]);
@@ -107,6 +130,77 @@ export default function WeekEditor({ monday }: { monday: string }) {
await load();
}
function startClosing(iso: string) {
const existing = closedDayFor(iso);
setClosingIso(iso);
setCloseReason(existing?.reason ?? "");
setCloseTranslations({ ...(existing?.translations ?? {}) });
setCloseTranslateState("idle");
}
function cancelClosing() {
setClosingIso(null);
}
async function saveClosing() {
if (!closingIso) return;
setCloseBusy(true);
setError(null);
const res = await fetch("/api/closed-days", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
closed_date: closingIso,
reason: closeReason.trim() || null,
translations: closeTranslations,
}),
});
setCloseBusy(false);
if (!res.ok) {
setError(formatMessage(dict.weekEditor.saveError, { message: String(res.status) }));
return;
}
setClosingIso(null);
await load();
}
async function reopenDay(iso: string) {
setCloseBusy(true);
setError(null);
const res = await fetch(`/api/closed-days/${iso}`, { method: "DELETE" });
setCloseBusy(false);
if (!res.ok) {
setError(formatMessage(dict.weekEditor.deleteError, { message: String(res.status) }));
return;
}
if (closingIso === iso) setClosingIso(null);
await load();
}
async function autoTranslateClosing() {
if (!closingIso || !closeReason.trim()) return;
setCloseTranslateState("busy");
setError(null);
try {
const res = await fetch("/api/translate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
table: "closed_days",
id: closedDayFor(closingIso) ? closingIso : null,
text: closeReason.trim(),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? `Fehler (${res.status})`);
setCloseTranslations((prev) => ({ ...prev, ...(data.translations ?? {}) }));
setCloseTranslateState("done");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setCloseTranslateState("idle");
}
}
const [hintBefore, hintAfter] = dict.weekEditor.newDishesHint.split("{link}");
const selectedDishObj = dishes.find((d) => d.id === selectedDish) ?? null;
const query = dishSearch.trim().toLowerCase();
@@ -114,6 +208,132 @@ export default function WeekEditor({ monday }: { monday: string }) {
? dishes.filter((d) => localized(d, locale).toLowerCase().includes(query))
: dishes;
// Kleines Schloss-Symbol neben dem Tagesdatum: 🔓 zum Schließen
// anklicken, 🔒 zeigt einen geschlossenen Tag und öffnet ihn per Klick
// wieder – der Zustand des Symbols selbst ist die Beschriftung.
function renderClosureToggle(iso: string) {
const closed = Boolean(closedDayFor(iso));
return (
<button
type="button"
className="mt-0.5 text-base leading-none align-middle opacity-70 hover:opacity-100 disabled:opacity-40"
onClick={() => (closed ? reopenDay(iso) : startClosing(iso))}
disabled={closeBusy}
title={closed ? dict.weekEditor.reopenDay : dict.weekEditor.closeDay}
aria-label={closed ? dict.weekEditor.reopenDay : dict.weekEditor.closeDay}
>
{closed ? "🔒" : "🔓"}
</button>
);
}
// Banner, das bei einem geschlossenen Tag alle Essenszeiten-Zellen
// dieses Tages ersetzt (Desktop: eine über rowSpan verbundene Zelle,
// Mobil: anstelle der Essenszeiten-Karten) – "Bearbeiten" öffnet das
// Formular erneut, um nur den Grund anzupassen, ohne den Tag über das
// Symbol im Header erst wieder öffnen zu müssen.
function renderClosedBanner(iso: string, closed: ClosedDay) {
return (
<div className="flex flex-col items-center justify-center gap-1 py-4 text-red-700">
<span className="text-2xl" aria-hidden>
🔒
</span>
<span className="font-semibold">{dict.weekEditor.closedLabel}</span>
{closed.reason && <span className="text-sm px-2 text-center">{closed.reason}</span>}
<button
type="button"
className="mt-1 text-xs text-gray-500 hover:text-[var(--c-primary)] underline"
onClick={() => startClosing(iso)}
>
{dict.common.edit}
</button>
</div>
);
}
// Formular zum Schließen/Bearbeiten eines Tages – ein gemeinsames
// Formular für Desktop-Tabelle und mobile Tagesansicht, da immer nur
// ein Tag gleichzeitig bearbeitet werden kann.
function renderClosingForm() {
if (!closingIso) return null;
const day = days.find((d) => d.iso === closingIso);
return (
<form
onSubmit={(e) => {
e.preventDefault();
saveClosing();
}}
className="card mb-4 space-y-3"
>
<h2 className="font-semibold">
{dict.weekEditor.closeDay}: {day?.weekday} ·{" "}
{formatDateLong(closingIso, dict.dateLocale)}
</h2>
<div>
<label className="label" htmlFor="closed-reason">
{dict.weekEditor.closedReasonLabel}
</label>
<input
id="closed-reason"
className="input"
placeholder={dict.weekEditor.closedReasonPlaceholder}
value={closeReason}
onChange={(e) => setCloseReason(e.target.value)}
/>
</div>
{languages.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{languages.map((lang) => (
<div key={lang.code}>
<label className="label" htmlFor={`closed-reason-${lang.code}`}>
{lang.label}
</label>
<input
id={`closed-reason-${lang.code}`}
className="input"
value={closeTranslations[lang.code] ?? ""}
onChange={(e) =>
setCloseTranslations({
...closeTranslations,
[lang.code]: e.target.value,
})
}
/>
</div>
))}
</div>
)}
{translateEnabled && languages.length > 0 && (
<div>
<button
type="button"
className="btn btn-neutral"
onClick={autoTranslateClosing}
disabled={closeTranslateState === "busy" || !closeReason.trim()}
>
{closeTranslateState === "busy"
? dict.dishManager.translating
: dict.dishManager.autoTranslate}
</button>
{closeTranslateState === "done" && (
<span className="ml-2 text-sm text-green-700">
{dict.dishManager.translated}
</span>
)}
</div>
)}
<div className="flex gap-2">
<button type="submit" className="btn btn-add" disabled={closeBusy}>
{dict.common.save}
</button>
<button type="button" className="btn btn-neutral" onClick={cancelClosing}>
{dict.common.cancel}
</button>
</div>
</form>
);
}
// Inhalt einer Tag/Essenszeit-Zelle: Eintragsliste + "hinzufügen"-Flow.
// Identisches Fragment für Desktop-Tabelle und Mobile-Karten, damit der
// Bearbeitungs-Zustand (adding/selectedDish/dishSearch) nicht dupliziert
@@ -250,6 +470,7 @@ export default function WeekEditor({ monday }: { monday: string }) {
{error}
</p>
)}
{renderClosingForm()}
{loading ? (
<p className="text-gray-500">{dict.weekEditor.loading}</p>
) : (
@@ -272,22 +493,35 @@ export default function WeekEditor({ monday }: { monday: string }) {
</button>
))}
</div>
<h2 className="font-bold text-[var(--c-primary)] mb-2">
{selectedDay.weekday} · {formatDateLong(selectedDay.iso, dict.dateLocale)}
<h2 className="font-bold text-[var(--c-primary)] mb-1">
{selectedDay.weekday} · {formatDateLong(selectedDay.iso, dict.dateLocale)}{" "}
{renderClosureToggle(selectedDay.iso)}
</h2>
<div className="space-y-3">
{sections.map((s) => (
<div key={s.key} className="rounded-md border border-gray-200 overflow-hidden">
<div className="bg-[var(--c-primary)] text-[var(--c-header-text)] px-3 py-1.5 text-sm">
<span className="font-semibold">{localized(s, locale)}</span>{" "}
<span className="opacity-80">· {s.time_range}</span>
</div>
<div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm text-center">
{renderCell(selectedDay.iso, s.key)}
{(() => {
const closed = closedDayFor(selectedDay.iso);
if (closed) {
return (
<div className="rounded-md border border-red-200 bg-red-50">
{renderClosedBanner(selectedDay.iso, closed)}
</div>
);
}
return (
<div className="space-y-3">
{sections.map((s) => (
<div key={s.key} className="rounded-md border border-gray-200 overflow-hidden">
<div className="bg-[var(--c-primary)] text-[var(--c-header-text)] px-3 py-1.5 text-sm">
<span className="font-semibold">{localized(s, locale)}</span>{" "}
<span className="opacity-80">· {s.time_range}</span>
</div>
<div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm text-center">
{renderCell(selectedDay.iso, s.key)}
</div>
</div>
))}
</div>
))}
</div>
);
})()}
</div>
{/* Ab "sm": klassische Wochentabelle */}
@@ -300,23 +534,38 @@ export default function WeekEditor({ monday }: { monday: string }) {
<th key={d.iso} className="day-head">
<span className="font-bold">{d.weekday}</span>
<br />
{formatDateLong(d.iso, dict.dateLocale)}
{formatDateLong(d.iso, dict.dateLocale)} {renderClosureToggle(d.iso)}
</th>
))}
</tr>
</thead>
<tbody>
{sections.map((s) => (
{sections.map((s, sIdx) => (
<tr key={s.key}>
<th className="section-head">
<div>{localized(s, locale)}</div>
<div className="time">{s.time_range}</div>
</th>
{days.map((d) => (
<td key={d.iso} className="cell">
{renderCell(d.iso, s.key)}
</td>
))}
{days.map((d) => {
const closed = closedDayFor(d.iso);
if (closed) {
if (sIdx > 0) return null;
return (
<td
key={d.iso}
className="cell closed-cell"
rowSpan={sections.length}
>
{renderClosedBanner(d.iso, closed)}
</td>
);
}
return (
<td key={d.iso} className="cell">
{renderCell(d.iso, s.key)}
</td>
);
})}
</tr>
))}
</tbody>
+18
View File
@@ -0,0 +1,18 @@
-- Kantine an einzelnen Tagen als geschlossen markieren (Feiertage o. Ä.),
-- mit optionalem, mehrsprachigem Grund. Ein Tag ohne Zeile hier gilt als
-- regulär geöffnet.
create table closed_days (
closed_date date primary key,
reason text,
created_at timestamptz not null default now()
);
-- Wert-Spalte heißt bewusst "name" (nicht "reason"), damit die generische
-- Zugriffsschicht in lib/db/translations.ts unverändert funktioniert –
-- dieselbe Konvention wie bei section_translations.name für sections.label.
create table closed_day_translations (
closed_date date not null references closed_days(closed_date) on delete cascade,
language_code text not null references languages(code) on delete cascade,
name text not null,
primary key (closed_date, language_code)
);
+50
View File
@@ -0,0 +1,50 @@
import "server-only";
import { query, queryOne } from "@/lib/db";
import { loadTranslationsFor, replaceTranslations } from "./translations";
import type { ClosedDay } from "@/lib/types";
interface ClosedDayRow {
closed_date: string;
reason: string | null;
}
/** Geschlossene Tage in einem Zeitraum (für Wochenplan/öffentliche Speisekarte/RSS). */
export async function listClosedDays(fromISO: string, toISO: string): Promise<ClosedDay[]> {
const rows = await query<ClosedDayRow>(
"select closed_date, reason from closed_days where closed_date between $1 and $2",
[fromISO, toISO]
);
if (rows.length === 0) return [];
const translations = await loadTranslationsFor(
"closed_day_translations",
rows.map((r) => r.closed_date)
);
return rows.map((r) => ({
...r,
translations: translations.get(r.closed_date) ?? {},
}));
}
/** Tag als geschlossen markieren (oder bestehenden Grund aktualisieren). */
export async function setClosedDay(
date: string,
reason: string | null,
translations?: Record<string, string | null>
): Promise<ClosedDay> {
const row = (await queryOne<ClosedDayRow>(
`insert into closed_days (closed_date, reason) values ($1, $2)
on conflict (closed_date) do update set reason = excluded.reason
returning closed_date, reason`,
[date, reason]
))!;
if (translations) {
await replaceTranslations("closed_day_translations", row.closed_date, translations);
}
const loaded = await loadTranslationsFor("closed_day_translations", [row.closed_date]);
return { ...row, translations: loaded.get(row.closed_date) ?? {} };
}
/** Tag wieder öffnen (löscht auch alle Übersetzungen des Grunds per Cascade). */
export async function removeClosedDay(date: string): Promise<void> {
await query("delete from closed_days where closed_date = $1", [date]);
}
+3 -1
View File
@@ -11,13 +11,15 @@ export type TranslationTable =
| "dish_translations"
| "section_translations"
| "allergen_translations"
| "additive_translations";
| "additive_translations"
| "closed_day_translations";
const ID_COLUMN: Record<TranslationTable, string> = {
dish_translations: "dish_id",
section_translations: "section_key",
allergen_translations: "allergen_code",
additive_translations: "additive_code",
closed_day_translations: "closed_date",
};
/** Alle Übersetzungen einer Tabelle, gruppiert nach Eltern-ID. */
+6
View File
@@ -75,6 +75,7 @@ const de = {
suggestDish: "Rezept vorschlagen",
noEntry: "–",
rssFeed: "RSS-Feed abonnieren",
closedLabel: "Geschlossen",
},
weekEditor: {
title: "Wochenplan",
@@ -87,6 +88,11 @@ const de = {
duplicateEntry: "Dieses Gericht ist an diesem Tag bereits zugewiesen.",
saveError: "Speichern fehlgeschlagen: {message}",
deleteError: "Löschen fehlgeschlagen: {message}",
closeDay: "Tag schließen",
reopenDay: "Wieder öffnen",
closedLabel: "Geschlossen",
closedReasonLabel: "Grund (optional)",
closedReasonPlaceholder: "z. B. Weihnachtsfeiertage",
},
dishManager: {
title: "Gerichte",
+5 -2
View File
@@ -14,12 +14,15 @@ export * from "./shared";
/** Aktuelle Sprache aus dem Cookie lesen, validiert gegen die aktiven
* Sprachen (Server-Komponenten). Ungültig oder nicht mehr aktiv →
* Basis-Sprache. */
* Basis-Sprache, sofern die noch aktiv ist – sonst die erste aktive
* Sprache (z. B. wenn Deutsch im Admin-Bereich deaktiviert wurde). */
export async function getLocale(): Promise<Locale> {
const store = await cookies();
const value = store.get(LOCALE_COOKIE)?.value;
const active = await listActiveLanguageCodes();
return isLocale(value, active) ? value : DEFAULT_LOCALE;
if (isLocale(value, active)) return value;
if (active.includes(DEFAULT_LOCALE)) return DEFAULT_LOCALE;
return active[0] ?? DEFAULT_LOCALE;
}
/** Wörterbuch für eine Sprache. Nicht-deutsche Sprachen werden aus der
+10 -1
View File
@@ -50,11 +50,20 @@ export interface DishSuggestion {
created_at: string;
}
export interface ClosedDay {
closed_date: string;
reason: string | null;
translations: Record<string, string>;
}
/** Übersetzten Namen für die aktuelle Sprache liefern, sonst Deutsch als Fallback. */
export function localized(
item: {
name?: string;
label?: string;
// Grund eines geschlossenen Tages (s. ClosedDay) – optional, anders
// als name/label bei den übrigen Inhaltstypen.
reason?: string | null;
// Neue, dynamische Form (Gerichte/Essenszeiten/Allergene/
// Zusatzstoffe, s. lib/db/translations.ts) – Sprachcode -> Text.
translations?: Record<string, string>;
@@ -68,7 +77,7 @@ export function localized(
},
locale: string
): string {
const de = item.name ?? item.label ?? "";
const de = item.name ?? item.label ?? item.reason ?? "";
if (locale === "de") return de;
if (item.translations) return item.translations[locale] || de;
if (locale === "en") return item.name_en || item.label_en || de;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canteen-menu",
"version": "1.11.0",
"version": "1.12.1",
"private": true,
"scripts": {
"predev": "node --env-file-if-exists=.env.local db/migrate.mjs && node --env-file-if-exists=.env.local scripts/bootstrap-admin.mjs",